@markjaquith/agency 2.16.0 → 2.18.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.
@@ -6,7 +6,9 @@ import { dirname, join, relative, resolve } from "node:path"
6
6
  import { FileSystemService } from "./FileSystemService"
7
7
  import { parseFrontmatter } from "../workbase/frontmatter"
8
8
  import {
9
+ EntityId,
9
10
  EpicFrontmatter,
11
+ LegacyWorkbaseRegistry,
10
12
  PhaseFrontmatter,
11
13
  TaskFrontmatter,
12
14
  WorkbaseConfig,
@@ -15,6 +17,8 @@ import {
15
17
  type EpicFrontmatter as EpicData,
16
18
  type PhaseFrontmatter as PhaseData,
17
19
  type TaskFrontmatter as TaskData,
20
+ type WorkbaseRegistry as WorkbaseRegistryData,
21
+ type WorkbaseRegistration,
18
22
  } from "../workbase/schemas"
19
23
  import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
20
24
 
@@ -81,12 +85,17 @@ const registryPath = (configDirectory?: string) =>
81
85
  "workbases.json",
82
86
  )
83
87
 
88
+ const registrationId = (path: string) =>
89
+ `wb-${new Bun.CryptoHasher("sha256").update(path).digest("hex").slice(0, 12)}`
90
+
91
+ const emptyRegistry: WorkbaseRegistryData = { version: 2, workbases: [] }
92
+
84
93
  const readRegistry = (configDirectory?: string) =>
85
94
  Effect.gen(function* () {
86
95
  const fs = yield* FileSystemService
87
96
  const path = registryPath(configDirectory)
88
97
  if (!(yield* fs.exists(path))) {
89
- return { path, registry: { version: 1, workbases: [] } as const }
98
+ return { path, registry: emptyRegistry }
90
99
  }
91
100
 
92
101
  const content = yield* fs.readFile(path)
@@ -102,13 +111,37 @@ const readRegistry = (configDirectory?: string) =>
102
111
  }
103
112
 
104
113
  const decoded = decode(WorkbaseRegistry, input)
105
- if (!decoded.success) {
114
+ if (decoded.success) return { path, registry: decoded.value }
115
+
116
+ const legacy = decode(LegacyWorkbaseRegistry, input)
117
+ if (!legacy.success) {
106
118
  return yield* new WorkbaseRegistryError({
107
119
  path,
108
120
  message: `Invalid workbase registry in ${path}:\n${decoded.error}`,
109
121
  })
110
122
  }
111
- return { path, registry: decoded.value }
123
+ const registry: WorkbaseRegistryData = {
124
+ version: 2 as const,
125
+ workbases: legacy.value.workbases.map((workbasePath) => ({
126
+ id: registrationId(workbasePath),
127
+ path: workbasePath,
128
+ })),
129
+ }
130
+ return { path, registry }
131
+ })
132
+
133
+ const writeRegistry = (
134
+ path: string,
135
+ registry: {
136
+ readonly version: 2
137
+ readonly workbases: readonly WorkbaseRegistration[]
138
+ readonly defaultId?: string
139
+ },
140
+ ) =>
141
+ Effect.gen(function* () {
142
+ const fs = yield* FileSystemService
143
+ yield* fs.createDirectory(dirname(path))
144
+ yield* fs.writeJSON(path, registry)
112
145
  })
113
146
 
114
147
  const findCycles = (nodes: readonly Dependency[]): readonly string[] => {
@@ -285,26 +318,195 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
285
318
  return { root, config: decoded.value }
286
319
  }),
287
320
 
288
- register: (startPath: string, configDirectory?: string) =>
321
+ register: (startPath: string, configDirectory?: string, name?: string) =>
289
322
  Effect.gen(function* () {
290
323
  const service = yield* WorkbaseService
291
324
  const fs = yield* FileSystemService
292
325
  const discovered = yield* service.discover(startPath)
293
326
  const root = yield* fs.realPath(discovered)
294
327
  const { path, registry } = yield* readRegistry(configDirectory)
295
- if (registry.workbases.includes(root)) return root
328
+ if (name !== undefined) {
329
+ const decodedName = decode(EntityId, name)
330
+ if (!decodedName.success) {
331
+ return yield* new WorkbaseRegistryError({
332
+ path,
333
+ message: `Invalid workbase name '${name}': names must contain only letters, numbers, dots, underscores, and hyphens`,
334
+ })
335
+ }
336
+ }
337
+ const named = name
338
+ ? registry.workbases.find(
339
+ (entry) =>
340
+ (entry.name === name && entry.path !== root) ||
341
+ entry.id === name,
342
+ )
343
+ : undefined
344
+ if (named) {
345
+ return yield* new WorkbaseRegistryError({
346
+ path,
347
+ message: `Workbase name '${name}' is already registered for ${named.path}`,
348
+ })
349
+ }
350
+ const existing = registry.workbases.find(
351
+ (entry) => entry.path === root,
352
+ )
353
+ const registration = {
354
+ id: existing?.id ?? registrationId(root),
355
+ ...(name
356
+ ? { name }
357
+ : existing?.name
358
+ ? { name: existing.name }
359
+ : {}),
360
+ path: root,
361
+ }
362
+ const idNameCollision = registry.workbases.find(
363
+ (entry) => entry.name === registration.id && entry.path !== root,
364
+ )
365
+ if (idNameCollision) {
366
+ return yield* new WorkbaseRegistryError({
367
+ path,
368
+ message: `Workbase ID '${registration.id}' conflicts with the registered name for ${idNameCollision.path}`,
369
+ })
370
+ }
371
+ const workbases = existing
372
+ ? registry.workbases.map((entry) =>
373
+ entry.id === existing.id ? registration : entry,
374
+ )
375
+ : [...registry.workbases, registration]
376
+ yield* writeRegistry(path, { ...registry, workbases })
377
+ return registration
378
+ }),
379
+
380
+ listRegistered: (configDirectory?: string) =>
381
+ readRegistry(configDirectory).pipe(
382
+ Effect.map(({ registry }) =>
383
+ registry.workbases.map((entry) => entry.path),
384
+ ),
385
+ ),
386
+
387
+ listRegistrations: (configDirectory?: string) =>
388
+ readRegistry(configDirectory).pipe(
389
+ Effect.map(({ registry }) => ({
390
+ workbases: registry.workbases,
391
+ defaultId: registry.defaultId,
392
+ })),
393
+ ),
394
+
395
+ resolveRegistered: (
396
+ selector: string,
397
+ configDirectory?: string,
398
+ basePath: string = process.cwd(),
399
+ ) =>
400
+ Effect.gen(function* () {
401
+ const service = yield* WorkbaseService
402
+ const fs = yield* FileSystemService
403
+ const { path, registry } = yield* readRegistry(configDirectory)
404
+ const candidatePath = resolve(basePath, selector)
405
+ const direct =
406
+ registry.workbases.find((entry) => entry.id === selector) ??
407
+ registry.workbases.find((entry) => entry.name === selector) ??
408
+ registry.workbases.find((entry) => entry.path === candidatePath)
409
+ if (direct) return direct.path
410
+ if (yield* fs.exists(candidatePath))
411
+ return yield* service.discover(candidatePath)
412
+ return yield* new WorkbaseRegistryError({
413
+ path,
414
+ message: `Unknown workbase selector '${selector}'`,
415
+ })
416
+ }),
417
+
418
+ removeRegistered: (
419
+ selector: string,
420
+ configDirectory?: string,
421
+ basePath: string = process.cwd(),
422
+ ) =>
423
+ Effect.gen(function* () {
424
+ const fs = yield* FileSystemService
425
+ const { path, registry } = yield* readRegistry(configDirectory)
426
+ const candidatePath = resolve(basePath, selector)
427
+ const canonicalCandidate = (yield* fs.exists(candidatePath))
428
+ ? yield* fs.realPath(candidatePath)
429
+ : candidatePath
430
+ const entry =
431
+ registry.workbases.find((item) => item.id === selector) ??
432
+ registry.workbases.find((item) => item.name === selector) ??
433
+ registry.workbases.find((item) => item.path === canonicalCandidate)
434
+ if (!entry) {
435
+ return yield* new WorkbaseRegistryError({
436
+ path,
437
+ message: `Unknown workbase selector '${selector}'`,
438
+ })
439
+ }
440
+ const workbases = registry.workbases.filter(
441
+ (item) => item.id !== entry.id,
442
+ )
443
+ const next = {
444
+ version: 2 as const,
445
+ workbases,
446
+ ...(registry.defaultId && registry.defaultId !== entry.id
447
+ ? { defaultId: registry.defaultId }
448
+ : {}),
449
+ }
450
+ yield* writeRegistry(path, next)
451
+ return entry
452
+ }),
296
453
 
297
- yield* fs.createDirectory(dirname(path))
298
- yield* fs.writeJSON(path, {
299
- version: 1,
300
- workbases: [...registry.workbases, root],
454
+ pruneRegistered: (configDirectory?: string) =>
455
+ Effect.gen(function* () {
456
+ const fs = yield* FileSystemService
457
+ const { path, registry } = yield* readRegistry(configDirectory)
458
+ const kept: WorkbaseRegistration[] = []
459
+ const removed: WorkbaseRegistration[] = []
460
+ for (const entry of registry.workbases) {
461
+ if (yield* fs.exists(join(entry.path, "agency.json")))
462
+ kept.push(entry)
463
+ else removed.push(entry)
464
+ }
465
+ const defaultId = kept.some(
466
+ (entry) => entry.id === registry.defaultId,
467
+ )
468
+ ? registry.defaultId
469
+ : undefined
470
+ yield* writeRegistry(path, {
471
+ version: 2,
472
+ workbases: kept,
473
+ ...(defaultId ? { defaultId } : {}),
301
474
  })
302
- return root
475
+ return removed
303
476
  }),
304
477
 
305
- listRegistered: (configDirectory?: string) =>
478
+ setDefault: (selector: string | null, configDirectory?: string) =>
479
+ Effect.gen(function* () {
480
+ const { path, registry } = yield* readRegistry(configDirectory)
481
+ if (selector === null) {
482
+ yield* writeRegistry(path, {
483
+ version: 2,
484
+ workbases: registry.workbases,
485
+ })
486
+ return null
487
+ }
488
+ const entry = registry.workbases.find(
489
+ (item) => item.id === selector || item.name === selector,
490
+ )
491
+ if (!entry) {
492
+ return yield* new WorkbaseRegistryError({
493
+ path,
494
+ message: `Unknown registered workbase selector '${selector}'`,
495
+ })
496
+ }
497
+ yield* writeRegistry(path, {
498
+ version: 2,
499
+ workbases: registry.workbases,
500
+ defaultId: entry.id,
501
+ })
502
+ return entry
503
+ }),
504
+
505
+ getDefault: (configDirectory?: string) =>
306
506
  readRegistry(configDirectory).pipe(
307
- Effect.map(({ registry }) => registry.workbases),
507
+ Effect.map(({ registry }) =>
508
+ registry.workbases.find((entry) => entry.id === registry.defaultId),
509
+ ),
308
510
  ),
309
511
 
310
512
  validate: (startPath: string = process.cwd()) =>
@@ -206,6 +206,18 @@ describe("WorktreeService", () => {
206
206
  expect(
207
207
  await Bun.file(join(root, "tasks", "valid-target", "code")).exists(),
208
208
  ).toBe(false)
209
+
210
+ await expect(
211
+ runTestEffect(
212
+ WorktreeService.pipe(
213
+ Effect.flatMap((service) =>
214
+ service.materialize("valid-target", undefined, root, {
215
+ force: true,
216
+ }),
217
+ ),
218
+ ),
219
+ ),
220
+ ).resolves.toMatchObject({ repo: "agency" })
209
221
  })
210
222
 
211
223
  test("uses a configured worktree creation command", async () => {
@@ -84,6 +84,10 @@ const isCommitId = (ref: string) => /^[0-9a-f]{40,64}$/i.test(ref)
84
84
  const originRef = (ref: string) =>
85
85
  ref.replace(/^refs\/remotes\/origin\//, "").replace(/^origin\//, "")
86
86
 
87
+ interface MaterializeOptions extends BaseCommandOptions {
88
+ readonly force?: boolean
89
+ }
90
+
87
91
  export class WorktreeService extends Effect.Service<WorktreeService>()(
88
92
  "WorktreeService",
89
93
  {
@@ -92,7 +96,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
92
96
  taskId: string,
93
97
  phaseId?: string,
94
98
  startPath: string = process.cwd(),
95
- options: BaseCommandOptions = {},
99
+ options: MaterializeOptions = {},
96
100
  ) =>
97
101
  Effect.gen(function* () {
98
102
  const fs = yield* FileSystemService
@@ -105,7 +109,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
105
109
  const { root, config } = yield* workbase.loadConfig(startPath)
106
110
  const report = yield* workbase.validate(root)
107
111
  const validationIssue = report.issues[0]
108
- if (validationIssue) {
112
+ if (validationIssue && !options.force) {
109
113
  return yield* new WorktreeError({
110
114
  message: `${validationIssue.path}: ${validationIssue.message}`,
111
115
  })
package/src/test-utils.ts CHANGED
@@ -17,6 +17,7 @@ import { ContextService } from "./services/ContextService"
17
17
  import { GraphService } from "./services/GraphService"
18
18
  import { ClaimService } from "./services/ClaimService"
19
19
  import { SyncService } from "./services/SyncService"
20
+ import { ReadinessService } from "./services/ReadinessService"
20
21
 
21
22
  export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
22
23
 
@@ -38,6 +39,7 @@ const TestLayer = Layer.mergeAll(
38
39
  GraphService.Default,
39
40
  ClaimService.Default,
40
41
  SyncService.Default,
42
+ ReadinessService.Default,
41
43
  )
42
44
 
43
45
  export async function runTestEffect<A, E>(
@@ -185,23 +185,34 @@ describe("workbase registry", () => {
185
185
  test("accepts registered paths", () => {
186
186
  expect(
187
187
  Schema.decodeUnknownSync(WorkbaseRegistry)({
188
- version: 1,
189
- workbases: ["/work/one", "/work/two"],
188
+ version: 2,
189
+ workbases: [
190
+ { id: "wb-one", name: "one", path: "/work/one" },
191
+ { id: "wb-two", path: "/work/two" },
192
+ ],
193
+ defaultId: "wb-one",
190
194
  }),
191
- ).toEqual({ version: 1, workbases: ["/work/one", "/work/two"] })
195
+ ).toEqual({
196
+ version: 2,
197
+ workbases: [
198
+ { id: "wb-one", name: "one", path: "/work/one" },
199
+ { id: "wb-two", path: "/work/two" },
200
+ ],
201
+ defaultId: "wb-one",
202
+ })
192
203
  })
193
204
 
194
205
  test("rejects invalid versions and empty paths", () => {
195
206
  expect(() =>
196
207
  Schema.decodeUnknownSync(WorkbaseRegistry)({
197
- version: 2,
208
+ version: 3,
198
209
  workbases: [],
199
210
  }),
200
211
  ).toThrow()
201
212
  expect(() =>
202
213
  Schema.decodeUnknownSync(WorkbaseRegistry)({
203
- version: 1,
204
- workbases: [""],
214
+ version: 2,
215
+ workbases: [{ id: "wb-one", path: "" }],
205
216
  }),
206
217
  ).toThrow()
207
218
  })
@@ -54,11 +54,23 @@ export const WorkbaseConfig = Schema.Struct({
54
54
  worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
55
55
  })
56
56
 
57
- export const WorkbaseRegistry = Schema.Struct({
57
+ export const LegacyWorkbaseRegistry = Schema.Struct({
58
58
  version: Schema.Literal(1),
59
59
  workbases: Schema.Array(NonEmptyString),
60
60
  })
61
61
 
62
+ export const WorkbaseRegistration = Schema.Struct({
63
+ id: EntityId,
64
+ name: Schema.optional(EntityId),
65
+ path: NonEmptyString,
66
+ })
67
+
68
+ export const WorkbaseRegistry = Schema.Struct({
69
+ version: Schema.Literal(2),
70
+ workbases: Schema.Array(WorkbaseRegistration),
71
+ defaultId: Schema.optional(EntityId),
72
+ })
73
+
62
74
  export const Dependency = Schema.Struct({
63
75
  id: EntityId,
64
76
  dependsOn: Schema.optional(Schema.Array(EntityId)),
@@ -107,6 +119,9 @@ export const PhaseFrontmatter = Schema.Struct({
107
119
 
108
120
  export type WorkbaseConfig = Schema.Schema.Type<typeof WorkbaseConfig>
109
121
  export type WorkbaseRegistry = Schema.Schema.Type<typeof WorkbaseRegistry>
122
+ export type WorkbaseRegistration = Schema.Schema.Type<
123
+ typeof WorkbaseRegistration
124
+ >
110
125
  export type Dependency = Schema.Schema.Type<typeof Dependency>
111
126
  export type RepositoryReference = Schema.Schema.Type<typeof RepositoryReference>
112
127
  export type WorkStatus = Schema.Schema.Type<typeof WorkStatus>
@@ -30,6 +30,8 @@ export const resolveWorkbase = (
30
30
  return yield* workbase.discover(startPath).pipe(
31
31
  Effect.catchTag("WorkbaseNotFoundError", () =>
32
32
  Effect.gen(function* () {
33
+ const defaultWorkbase = yield* workbase.getDefault()
34
+ if (defaultWorkbase) return defaultWorkbase.path
33
35
  const registered = yield* workbase.listRegistered()
34
36
  if (registered.length === 0) {
35
37
  return yield* Effect.fail(