@markjaquith/agency 3.2.0 → 3.2.2

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.
@@ -271,78 +271,57 @@ export class ContextService extends Effect.Service<ContextService>()(
271
271
 
272
272
  if (relative(root, candidate) === "") {
273
273
  const compact = !options.full
274
- const discover = <S extends Schema.Schema.AnyNoContext>(
275
- id: string,
276
- path: string,
277
- schema: S,
274
+ const validation = yield* provideContextServices(
275
+ workbase.validate(root, { includeDocuments: true }),
276
+ )
277
+ const projectDiscoveryDocument = <T>(
278
+ document: {
279
+ readonly id: string
280
+ readonly path: string
281
+ readonly content: string
282
+ readonly revision: string
283
+ readonly data: T
284
+ },
278
285
  extra: Record<string, string> = {},
279
286
  ) =>
280
287
  Effect.gen(function* () {
281
- if (!(yield* fs.exists(path))) return null
282
- const content = yield* fs.readFile(path)
283
- const parsed = yield* Effect.either(
284
- parseFrontmatter(content, path),
285
- )
286
- if (Either.isLeft(parsed)) return null
287
- const decoded = decode(schema, parsed.right.data)
288
- if (!decoded.ok) return null
288
+ const body = compact
289
+ ? {}
290
+ : {
291
+ body: (yield* parseFrontmatter(
292
+ document.content,
293
+ document.path,
294
+ )).body,
295
+ }
289
296
  return {
290
297
  ...extra,
291
- id,
292
- path,
293
- sha256: documentRevision(content),
294
- data: decoded.value,
295
- ...(compact ? {} : { body: parsed.right.body }),
298
+ id: document.id,
299
+ path: document.path,
300
+ sha256: document.revision,
301
+ data: document.data,
302
+ ...body,
296
303
  }
297
304
  })
298
-
299
- const epics: unknown[] = []
300
- const tasks: unknown[] = []
301
- const phases: unknown[] = []
302
- const epicRoot = join(root, "epics")
303
- if (yield* fs.isDirectory(epicRoot)) {
304
- for (const entry of (yield* fs.readDirectory(epicRoot))
305
- .filter((item) => item.isDirectory)
306
- .sort((a, b) => a.name.localeCompare(b.name))) {
307
- const document = yield* discover(
308
- entry.name,
309
- join(epicRoot, entry.name, "EPIC.md"),
310
- EpicFrontmatter,
311
- )
312
- if (document) epics.push(document)
313
- }
314
- }
315
-
316
- const taskRoot = join(root, "tasks")
317
- if (yield* fs.isDirectory(taskRoot)) {
318
- for (const entry of (yield* fs.readDirectory(taskRoot))
319
- .filter((item) => item.isDirectory)
320
- .sort((a, b) => a.name.localeCompare(b.name))) {
321
- const document = yield* discover(
322
- entry.name,
323
- join(taskRoot, entry.name, "TASK.md"),
324
- TaskFrontmatter,
325
- )
326
- if (document) tasks.push(document)
327
-
328
- const phaseRoot = join(taskRoot, entry.name, "phases")
329
- if (!(yield* fs.isDirectory(phaseRoot))) continue
330
- for (const phaseEntry of (yield* fs.readDirectory(phaseRoot))
331
- .filter((item) => item.isDirectory)
332
- .sort((a, b) => a.name.localeCompare(b.name))) {
333
- const phase = yield* discover(
334
- phaseEntry.name,
335
- join(phaseRoot, phaseEntry.name, "PHASE.md"),
336
- PhaseFrontmatter,
337
- { taskId: entry.name },
338
- )
339
- if (phase) phases.push(phase)
340
- }
341
- }
342
- }
343
-
344
- const validation = yield* provideContextServices(
345
- workbase.validate(root),
305
+ const documents = validation.documents!
306
+ const epics = yield* Effect.all(
307
+ documents.epics.map((document) =>
308
+ projectDiscoveryDocument(document),
309
+ ),
310
+ { concurrency: "unbounded" },
311
+ )
312
+ const tasks = yield* Effect.all(
313
+ documents.tasks.map((document) =>
314
+ projectDiscoveryDocument(document),
315
+ ),
316
+ { concurrency: "unbounded" },
317
+ )
318
+ const phases = yield* Effect.all(
319
+ [...documents.phasesByTask].flatMap(([taskId, records]) =>
320
+ records.map((document) =>
321
+ projectDiscoveryDocument(document, { taskId }),
322
+ ),
323
+ ),
324
+ { concurrency: "unbounded" },
346
325
  )
347
326
  return {
348
327
  projection: compact ? "compact" : "complete",
@@ -556,104 +535,74 @@ export class ContextService extends Effect.Service<ContextService>()(
556
535
  })
557
536
  }
558
537
 
559
- const taskDocuments = new Map<string, Document<TaskData>>()
538
+ const validation = yield* provideContextServices(
539
+ workbase.validate(root, { includeDocuments: true }),
540
+ )
541
+ const validationDocuments = validation.documents!
542
+ const taskDocuments = new Map<string, Document<TaskData>>(
543
+ validationDocuments.tasks.map((document) => [
544
+ document.id,
545
+ {
546
+ id: document.id,
547
+ path: document.path,
548
+ sha256: document.revision,
549
+ data: document.data,
550
+ body: "",
551
+ },
552
+ ]),
553
+ )
560
554
  const phaseDocuments = new Map<string, Document<PhaseData>>()
561
- for (const taskRoot of [
562
- join(root, "tasks"),
563
- join(root, "archive", "tasks"),
564
- ]) {
565
- if (!(yield* fs.isDirectory(taskRoot))) continue
566
- const entries = (yield* fs.readDirectory(taskRoot))
567
- .filter((entry) => entry.isDirectory)
568
- .sort((a, b) => a.name.localeCompare(b.name))
569
- const documents = yield* Effect.all(
570
- entries.map((entry) =>
571
- Effect.gen(function* () {
572
- const path = join(taskRoot, entry.name, "TASK.md")
573
- let taskDocument: Document<TaskData> | null = null
574
- if (yield* fs.exists(path)) {
575
- const content = yield* fs.readFile(path)
576
- const parsed = yield* Effect.either(
577
- parseFrontmatter(content, path),
578
- )
579
- if (Either.isRight(parsed)) {
580
- const decoded = decode(TaskFrontmatter, parsed.right.data)
581
- if (decoded.ok) {
582
- taskDocument = {
583
- id: entry.name,
584
- path,
585
- sha256: documentRevision(content),
586
- data: decoded.value,
587
- body: parsed.right.body,
588
- }
589
- }
590
- }
591
- }
592
-
593
- const phasesPath = join(taskRoot, entry.name, "phases")
594
- if (!(yield* fs.isDirectory(phasesPath))) {
595
- return { taskDocument, phaseDocuments: [] }
596
- }
597
- const phaseEntries = (yield* fs.readDirectory(phasesPath))
598
- .filter((item) => item.isDirectory)
599
- .sort((a, b) => a.name.localeCompare(b.name))
600
- const childDocuments = yield* Effect.all(
601
- phaseEntries.map((phaseEntry) =>
602
- Effect.gen(function* () {
603
- const phasePath = join(
604
- phasesPath,
605
- phaseEntry.name,
606
- "PHASE.md",
607
- )
608
- if (!(yield* fs.exists(phasePath))) return null
609
- const content = yield* fs.readFile(phasePath)
610
- const parsed = yield* Effect.either(
611
- parseFrontmatter(content, phasePath),
612
- )
613
- if (Either.isLeft(parsed)) return null
614
- const decoded = decode(
615
- PhaseFrontmatter,
616
- parsed.right.data,
617
- )
618
- if (!decoded.ok) return null
619
- return [
620
- `${entry.name}/${phaseEntry.name}`,
621
- {
622
- id: phaseEntry.name,
623
- path: phasePath,
624
- sha256: documentRevision(content),
625
- data: decoded.value,
626
- body: parsed.right.body,
627
- },
628
- ] as const
629
- }),
630
- ),
631
- { concurrency: "unbounded" },
632
- )
633
- return {
634
- taskDocument,
635
- phaseDocuments: childDocuments.filter(
636
- (document): document is NonNullable<typeof document> =>
637
- document !== null,
638
- ),
639
- }
640
- }),
641
- ),
642
- { concurrency: "unbounded" },
643
- )
555
+ for (const [taskId, documents] of validationDocuments.phasesByTask) {
644
556
  for (const document of documents) {
645
- if (
646
- document.taskDocument &&
647
- !taskDocuments.has(document.taskDocument.id)
648
- ) {
649
- taskDocuments.set(
650
- document.taskDocument.id,
651
- document.taskDocument,
652
- )
653
- }
654
- for (const [key, phaseDocument] of document.phaseDocuments) {
655
- if (!phaseDocuments.has(key))
656
- phaseDocuments.set(key, phaseDocument)
557
+ phaseDocuments.set(`${taskId}/${document.id}`, {
558
+ id: document.id,
559
+ path: document.path,
560
+ sha256: document.revision,
561
+ data: document.data,
562
+ body: "",
563
+ })
564
+ }
565
+ }
566
+ if (task && !taskDocuments.has(task.id))
567
+ taskDocuments.set(task.id, task)
568
+ if (phase && target.taskId) {
569
+ phaseDocuments.set(`${target.taskId}/${phase.id}`, phase)
570
+ }
571
+ const relevantTaskIds = new Set([
572
+ ...(target.taskId ? [target.taskId] : []),
573
+ ...(epic?.data.tasks.map((child: Dependency) => child.id) ?? []),
574
+ ])
575
+ for (const taskId of relevantTaskIds) {
576
+ let document = taskDocuments.get(taskId)
577
+ if (!document) {
578
+ const archived = yield* Effect.either(
579
+ readOptionalDocument(
580
+ taskId,
581
+ join(archivedTaskDirectory(root, taskId), "TASK.md"),
582
+ TaskFrontmatter,
583
+ ),
584
+ )
585
+ document = Either.isRight(archived)
586
+ ? (archived.right ?? undefined)
587
+ : undefined
588
+ if (document) taskDocuments.set(taskId, document)
589
+ }
590
+ if (!document || !("phases" in document.data)) continue
591
+ for (const child of document.data.phases) {
592
+ const key = `${taskId}/${child.id}`
593
+ if (phaseDocuments.has(key)) continue
594
+ const archived = yield* Effect.either(
595
+ readOptionalDocument(
596
+ child.id,
597
+ join(
598
+ archivedPhaseDirectory(root, taskId, child.id),
599
+ "PHASE.md",
600
+ ),
601
+ PhaseFrontmatter,
602
+ ),
603
+ )
604
+ if (Either.isRight(archived) && archived.right) {
605
+ phaseDocuments.set(key, archived.right)
657
606
  }
658
607
  }
659
608
  }
@@ -720,9 +669,6 @@ export class ContextService extends Effect.Service<ContextService>()(
720
669
  })
721
670
  }
722
671
 
723
- const validation = yield* provideContextServices(
724
- workbase.validate(root),
725
- )
726
672
  const relevantPaths = new Set<string>(
727
673
  [epic?.path, task?.path, phase?.path]
728
674
  .filter((path): path is string => Boolean(path))
@@ -1017,9 +963,11 @@ export class ContextService extends Effect.Service<ContextService>()(
1017
963
  const codePath = join(entityDirectory, "code")
1018
964
  const inspectionWarnings: string[] = []
1019
965
  const repositories = new Map(
1020
- (yield* provideContextServices(repositoryService.list(root))).map(
1021
- (repository) => [repository.alias, repository],
1022
- ),
966
+ reviewData
967
+ ? (yield* provideContextServices(
968
+ repositoryService.list(root),
969
+ )).map((repository) => [repository.alias, repository])
970
+ : [],
1023
971
  )
1024
972
 
1025
973
  const inspectCheckout = (
@@ -1027,12 +975,18 @@ export class ContextService extends Effect.Service<ContextService>()(
1027
975
  checkoutPath: string,
1028
976
  ) =>
1029
977
  Effect.gen(function* (): Generator<any, CheckoutInspection, any> {
1030
- const materialized = yield* fs.isDirectory(checkoutPath)
1031
- const listed = yield* runGit(fs, repositoryPath, [
1032
- "worktree",
1033
- "list",
1034
- "--porcelain",
1035
- ])
978
+ const [materialized, listed, canonicalRoot] = yield* Effect.all(
979
+ [
980
+ fs.isDirectory(checkoutPath),
981
+ runGit(fs, repositoryPath, [
982
+ "worktree",
983
+ "list",
984
+ "--porcelain",
985
+ ]),
986
+ fs.realPath(root),
987
+ ],
988
+ { concurrency: "unbounded" },
989
+ )
1036
990
  if (listed === null) {
1037
991
  inspectionWarnings.push(
1038
992
  `Unable to inspect worktree registrations for ${repositoryPath}`,
@@ -1040,7 +994,7 @@ export class ContextService extends Effect.Service<ContextService>()(
1040
994
  }
1041
995
  const listedPaths = worktreePaths(listed)
1042
996
  const canonicalCheckoutPath = join(
1043
- yield* fs.realPath(root),
997
+ canonicalRoot,
1044
998
  relative(root, checkoutPath),
1045
999
  )
1046
1000
  let registered =
@@ -1056,21 +1010,25 @@ export class ContextService extends Effect.Service<ContextService>()(
1056
1010
  dirty: null,
1057
1011
  }
1058
1012
  }
1059
- const checkoutCommit = yield* runGit(fs, checkoutPath, [
1060
- "rev-parse",
1061
- "HEAD",
1062
- ])
1063
- const checkoutBranch = yield* runGit(fs, checkoutPath, [
1064
- "symbolic-ref",
1065
- "--quiet",
1066
- "--short",
1067
- "HEAD",
1068
- ])
1069
- const checkoutStatus = yield* runGitText(fs, checkoutPath, [
1070
- "status",
1071
- "--porcelain",
1072
- ])
1073
- const resolvedCheckoutPath = yield* fs.realPath(checkoutPath)
1013
+ const [
1014
+ checkoutCommit,
1015
+ checkoutBranch,
1016
+ checkoutStatus,
1017
+ resolvedCheckoutPath,
1018
+ ] = yield* Effect.all(
1019
+ [
1020
+ runGit(fs, checkoutPath, ["rev-parse", "HEAD"]),
1021
+ runGit(fs, checkoutPath, [
1022
+ "symbolic-ref",
1023
+ "--quiet",
1024
+ "--short",
1025
+ "HEAD",
1026
+ ]),
1027
+ runGitText(fs, checkoutPath, ["status", "--porcelain"]),
1028
+ fs.realPath(checkoutPath),
1029
+ ],
1030
+ { concurrency: "unbounded" },
1031
+ )
1074
1032
  registered = registered || listedPaths.has(resolvedCheckoutPath)
1075
1033
  if (checkoutCommit === null) {
1076
1034
  inspectionWarnings.push(
@@ -1094,17 +1052,16 @@ export class ContextService extends Effect.Service<ContextService>()(
1094
1052
  const repositoryPath =
1095
1053
  repository?.path ?? join(root, "repos", executionData.repo)
1096
1054
  const checkoutPath = join(codePath, executionData.repo)
1097
- const branchCommit = yield* backend.resolveRevision(
1098
- repositoryPath,
1099
- executionData.branch,
1100
- )
1101
- const baseCommit = yield* backend.resolveRevision(
1102
- repositoryPath,
1103
- executionData.base,
1104
- )
1105
- const checkout = yield* inspectCheckout(
1106
- repositoryPath,
1107
- checkoutPath,
1055
+ const [branchCommit, baseCommit, checkout] = yield* Effect.all(
1056
+ [
1057
+ backend.resolveRevision(
1058
+ repositoryPath,
1059
+ executionData.branch,
1060
+ ),
1061
+ backend.resolveRevision(repositoryPath, executionData.base),
1062
+ inspectCheckout(repositoryPath, checkoutPath),
1063
+ ],
1064
+ { concurrency: "unbounded" },
1108
1065
  )
1109
1066
  if (branchCommit === null) {
1110
1067
  inspectionWarnings.push(
@@ -245,6 +245,7 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
245
245
  readonly forwardOutput?: boolean
246
246
  readonly passthrough?: boolean
247
247
  readonly env?: Record<string, string>
248
+ readonly timeoutMs?: number
248
249
  },
249
250
  ) =>
250
251
  pipe(
@@ -264,6 +265,7 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
264
265
  ? "tee"
265
266
  : "pipe",
266
267
  env: options?.env,
268
+ timeoutMs: options?.timeoutMs,
267
269
  }),
268
270
  Effect.mapError(
269
271
  (processError) =>
@@ -1,8 +1,9 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir } from "node:fs/promises"
4
- import { join } from "node:path"
3
+ import { chmod, mkdir } from "node:fs/promises"
4
+ import { join, resolve } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
+ import { errorEnvelope } from "../protocol"
6
7
  import { PushService } from "./PushService"
7
8
  import { TaskService } from "./TaskService"
8
9
  import { WorktreeService } from "./WorktreeService"
@@ -130,9 +131,18 @@ describe("PushService", () => {
130
131
  }
131
132
  }
132
133
 
133
- const publish = (taskPath: string) =>
134
+ const publish = (
135
+ taskPath: string,
136
+ options: {
137
+ fetchTimeoutMs?: number
138
+ pushTimeoutMs?: number
139
+ retryDelayMs?: number
140
+ } = {},
141
+ ) =>
134
142
  runTestEffect(
135
- PushService.pipe(Effect.flatMap((service) => service.publish(taskPath))),
143
+ PushService.pipe(
144
+ Effect.flatMap((service) => service.publish(taskPath, options)),
145
+ ),
136
146
  )
137
147
 
138
148
  const remoteBranch = async (remote: string) =>
@@ -157,6 +167,17 @@ describe("PushService", () => {
157
167
  )
158
168
  }
159
169
 
170
+ const prePushHook = async (checkout: string) =>
171
+ resolve(
172
+ checkout,
173
+ (
174
+ await requireCommand(
175
+ ["git", "rev-parse", "--git-path", "hooks/pre-push"],
176
+ checkout,
177
+ )
178
+ ).stdout,
179
+ )
180
+
160
181
  test("publishes a clean Git HEAD and establishes upstream tracking", async () => {
161
182
  const fixture = await setup()
162
183
  await configureAuthor(fixture.checkout)
@@ -299,4 +320,89 @@ describe("PushService", () => {
299
320
  "has an invalid author",
300
321
  )
301
322
  })
323
+
324
+ test("bounds a stalled pre-push hook and confirms non-publication", async () => {
325
+ const fixture = await setup()
326
+ await configureAuthor(fixture.checkout)
327
+ await Bun.write(join(fixture.checkout, "feature.txt"), "timeout\n")
328
+ await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
329
+ await requireCommand(
330
+ ["git", "commit", "-m", "Add timed publication"],
331
+ fixture.checkout,
332
+ )
333
+ const hook = await prePushHook(fixture.checkout)
334
+ await Bun.write(hook, "#!/bin/sh\nsleep 30\n")
335
+ await chmod(hook, 0o755)
336
+
337
+ const startedAt = performance.now()
338
+ const failure = await publish(fixture.taskPath, {
339
+ pushTimeoutMs: 25,
340
+ }).catch((error) => error)
341
+ expect(performance.now() - startedAt).toBeLessThan(1_000)
342
+ expect(errorEnvelope(failure).error).toMatchObject({
343
+ code: "PUSH_TIMEOUT",
344
+ fields: { category: "timeout", stage: "publish" },
345
+ retryable: true,
346
+ })
347
+ await expect(remoteBranch(fixture.remote)).rejects.toThrow()
348
+ })
349
+
350
+ test("classifies hook rejection separately from transport failure", async () => {
351
+ const fixture = await setup()
352
+ await configureAuthor(fixture.checkout)
353
+ await Bun.write(join(fixture.checkout, "feature.txt"), "rejected\n")
354
+ await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
355
+ await requireCommand(
356
+ ["git", "commit", "-m", "Add rejected publication"],
357
+ fixture.checkout,
358
+ )
359
+ const hook = await prePushHook(fixture.checkout)
360
+ await Bun.write(
361
+ hook,
362
+ "#!/bin/sh\necho 'pre-push hook declined' >&2\nexit 1\n",
363
+ )
364
+ await chmod(hook, 0o755)
365
+
366
+ const failure = await publish(fixture.taskPath).catch((error) => error)
367
+ expect(errorEnvelope(failure).error).toMatchObject({
368
+ code: "PUSH_HOOK_REJECTED",
369
+ fields: { category: "hook_rejection", stage: "publish" },
370
+ retryable: false,
371
+ })
372
+ })
373
+
374
+ test("retries one transient fetch with non-interactive authentication", async () => {
375
+ const fixture = await setup()
376
+ await configureAuthor(fixture.checkout)
377
+ await Bun.write(join(fixture.checkout, "feature.txt"), "retried\n")
378
+ await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
379
+ await requireCommand(
380
+ ["git", "commit", "-m", "Add retried publication"],
381
+ fixture.checkout,
382
+ )
383
+ const attempts = join(fixture.root, "upload-pack-attempts")
384
+ const uploadPack = join(fixture.root, "upload-pack")
385
+ await Bun.write(
386
+ uploadPack,
387
+ `#!/bin/sh
388
+ echo x >> ${JSON.stringify(attempts)}
389
+ test "$GIT_TERMINAL_PROMPT" = 0 || exit 2
390
+ test "$GCM_INTERACTIVE" = Never || exit 2
391
+ if test "$(wc -l < ${JSON.stringify(attempts)})" -eq 1; then
392
+ echo 'Connection reset by peer' >&2
393
+ exit 1
394
+ fi
395
+ exec git-upload-pack "$@"
396
+ `,
397
+ )
398
+ await chmod(uploadPack, 0o755)
399
+ await requireCommand(
400
+ ["git", "config", "remote.origin.uploadpack", uploadPack],
401
+ fixture.checkout,
402
+ )
403
+
404
+ const result = await publish(fixture.taskPath, { retryDelayMs: 0 })
405
+ expect(result.tip).toBe(await remoteBranch(fixture.remote))
406
+ expect((await Bun.file(attempts).text()).trim().split("\n")).toHaveLength(2)
407
+ })
302
408
  })