@markjaquith/agency 3.2.8 → 3.2.9

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": "3.2.8",
3
+ "version": "3.2.9",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -0,0 +1,81 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { mkdir } from "node:fs/promises"
4
+ import { join } from "node:path"
5
+ import { cleanupTempDir, createTempDir } from "../test-utils"
6
+ import { FileSystemService } from "./FileSystemService"
7
+
8
+ const runFileSystem = <A, E>(effect: Effect.Effect<A, E, FileSystemService>) =>
9
+ Effect.runPromise(effect.pipe(Effect.provide(FileSystemService.Default)))
10
+
11
+ describe("FileSystemService", () => {
12
+ const roots: string[] = []
13
+
14
+ afterEach(async () => {
15
+ await Promise.all(roots.splice(0).map(cleanupTempDir))
16
+ })
17
+
18
+ test("distinguishes missing files from other read failures", async () => {
19
+ const root = await createTempDir()
20
+ roots.push(root)
21
+ const directory = join(root, "document")
22
+ await mkdir(directory)
23
+
24
+ const missing = await runFileSystem(
25
+ FileSystemService.pipe(
26
+ Effect.flatMap((service) => service.readFile(join(root, "missing"))),
27
+ Effect.either,
28
+ ),
29
+ )
30
+ expect(missing).toMatchObject({
31
+ _tag: "Left",
32
+ left: { _tag: "FileNotFoundError" },
33
+ })
34
+
35
+ const unreadable = await runFileSystem(
36
+ FileSystemService.pipe(
37
+ Effect.flatMap((service) => service.readFile(directory)),
38
+ Effect.either,
39
+ ),
40
+ )
41
+ expect(unreadable).toMatchObject({
42
+ _tag: "Left",
43
+ left: {
44
+ _tag: "FileSystemError",
45
+ message: `Failed to read file: ${directory}`,
46
+ },
47
+ })
48
+ })
49
+
50
+ test("only treats a missing symlink path as absent", async () => {
51
+ const root = await createTempDir()
52
+ roots.push(root)
53
+ const regularFile = join(root, "regular")
54
+ await Bun.write(regularFile, "content")
55
+
56
+ await expect(
57
+ runFileSystem(
58
+ FileSystemService.pipe(
59
+ Effect.flatMap((service) =>
60
+ service.readSymlinkTarget(join(root, "missing")),
61
+ ),
62
+ ),
63
+ ),
64
+ ).resolves.toBeNull()
65
+ const unreadable = await runFileSystem(
66
+ FileSystemService.pipe(
67
+ Effect.flatMap((service) =>
68
+ service.readSymlinkTarget(join(regularFile, "child")),
69
+ ),
70
+ Effect.either,
71
+ ),
72
+ )
73
+ expect(unreadable).toMatchObject({
74
+ _tag: "Left",
75
+ left: {
76
+ _tag: "FileSystemError",
77
+ message: `Failed to read symlink target: ${join(regularFile, "child")}`,
78
+ },
79
+ })
80
+ })
81
+ })
@@ -23,6 +23,12 @@ class FileNotFoundError extends Data.TaggedError("FileNotFoundError")<{
23
23
  path: string
24
24
  }> {}
25
25
 
26
+ const isFileNotFound = (error: unknown) =>
27
+ typeof error === "object" &&
28
+ error !== null &&
29
+ "code" in error &&
30
+ error.code === "ENOENT"
31
+
26
32
  // FileSystem Service using Effect.Service pattern
27
33
  export class FileSystemService extends Effect.Service<FileSystemService>()(
28
34
  "FileSystemService",
@@ -88,7 +94,13 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
88
94
  readFile: (path: string) =>
89
95
  Effect.tryPromise({
90
96
  try: () => Bun.file(path).text(),
91
- catch: () => new FileNotFoundError({ path }),
97
+ catch: (error) =>
98
+ isFileNotFound(error)
99
+ ? new FileNotFoundError({ path })
100
+ : new FileSystemError({
101
+ message: `Failed to read file: ${path}`,
102
+ cause: error,
103
+ }),
92
104
  }),
93
105
 
94
106
  inspectFile: (path: string) =>
@@ -184,10 +196,12 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
184
196
  }))
185
197
  },
186
198
  catch: (error) =>
187
- new FileSystemError({
188
- message: `Failed to read directory: ${path}`,
189
- cause: error,
190
- }),
199
+ isFileNotFound(error)
200
+ ? new FileNotFoundError({ path })
201
+ : new FileSystemError({
202
+ message: `Failed to read directory: ${path}`,
203
+ cause: error,
204
+ }),
191
205
  }),
192
206
 
193
207
  deleteDirectory: (path: string) =>
@@ -301,8 +315,16 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
301
315
  }
302
316
  return await readlink(path)
303
317
  },
304
- catch: () => null,
305
- }).pipe(Effect.catchAll(() => Effect.succeed(null))),
318
+ catch: (error) =>
319
+ isFileNotFound(error)
320
+ ? new FileNotFoundError({ path })
321
+ : new FileSystemError({
322
+ message: `Failed to read symlink target: ${path}`,
323
+ cause: error,
324
+ }),
325
+ }).pipe(
326
+ Effect.catchTag("FileNotFoundError", () => Effect.succeed(null)),
327
+ ),
306
328
  }),
307
329
  },
308
330
  ) {}
@@ -230,6 +230,17 @@ describe("GraphService", () => {
230
230
  expect(calls).toBe(0)
231
231
  })
232
232
 
233
+ test("propagates directory discovery failures", async () => {
234
+ const root = await createTempDir()
235
+ roots.push(root)
236
+ await write(root, "agency.json", '{"version":2}\n')
237
+ await write(root, "tasks", "not a directory")
238
+
239
+ await expect(getGraph(root)).rejects.toThrow(
240
+ `Failed to read directory: ${join(root, "tasks")}`,
241
+ )
242
+ })
243
+
233
244
  test("never reports active or terminal execution units as ready", async () => {
234
245
  const root = await createWorkbase()
235
246
  roots.push(root)
@@ -158,7 +158,7 @@ export class GraphService extends Effect.Service<GraphService>()(
158
158
  .map((entry) => entry.name)
159
159
  .sort(),
160
160
  ),
161
- Effect.catchAll(() => Effect.succeed([])),
161
+ Effect.catchTag("FileNotFoundError", () => Effect.succeed([])),
162
162
  )
163
163
 
164
164
  const readDocument = <S extends Schema.Schema.AnyNoContext>(
@@ -164,6 +164,21 @@ pr: null
164
164
  expect(exists).not.toContain(join(root, "tasks/example/TASK.md"))
165
165
  })
166
166
 
167
+ test("propagates document read failures instead of reporting them as missing", async () => {
168
+ await write(root, "agency.json", '{"version":2}\n')
169
+ await mkdir(join(root, "tasks/example/TASK.md"), { recursive: true })
170
+
171
+ await expect(
172
+ runTestEffect(
173
+ WorkbaseService.pipe(
174
+ Effect.flatMap((service) => service.validate(root)),
175
+ ),
176
+ ),
177
+ ).rejects.toThrow(
178
+ `Failed to read file: ${join(root, "tasks/example/TASK.md")}`,
179
+ )
180
+ })
181
+
167
182
  test("validates non-PR completion invariants without rejecting legacy done work", async () => {
168
183
  await write(
169
184
  root,
@@ -694,7 +694,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
694
694
  .map((entry) => entry.name)
695
695
  .sort(),
696
696
  ),
697
- Effect.catchAll(() => Effect.succeed([])),
697
+ Effect.catchTag("FileNotFoundError", () => Effect.succeed([])),
698
698
  )
699
699
 
700
700
  const aliases = new Set(Object.keys(config.repositories ?? {}))
@@ -710,12 +710,18 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
710
710
  schema: S,
711
711
  ) =>
712
712
  Effect.gen(function* () {
713
- const content = yield* Effect.option(fs.readFile(path))
714
- if (content._tag === "None") {
713
+ const content = yield* fs
714
+ .readFile(path)
715
+ .pipe(
716
+ Effect.catchTag("FileNotFoundError", () =>
717
+ Effect.succeed(null),
718
+ ),
719
+ )
720
+ if (content === null) {
715
721
  issue(path, "Required document is missing")
716
722
  return null
717
723
  }
718
- const documentContent = content.value
724
+ const documentContent = content
719
725
  const parsed = yield* Effect.either(
720
726
  parseFrontmatter(documentContent, path),
721
727
  )