@markjaquith/agency 2.13.0 → 2.14.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.
@@ -0,0 +1,446 @@
1
+ import { Schema, TreeFormatter } from "@effect/schema"
2
+ import { Data, Effect, Either } from "effect"
3
+ import { randomUUID } from "node:crypto"
4
+ import {
5
+ open,
6
+ readFile,
7
+ rename,
8
+ stat,
9
+ unlink,
10
+ writeFile,
11
+ } from "node:fs/promises"
12
+ import { basename, dirname, join } from "node:path"
13
+ import { PhaseService } from "./PhaseService"
14
+ import { TaskService } from "./TaskService"
15
+ import { WorkbaseService } from "./WorkbaseService"
16
+ import { FileSystemService } from "./FileSystemService"
17
+ import { documentRevision } from "../workbase/document-revision"
18
+ import {
19
+ formatMarkdownDocument,
20
+ parseFrontmatterSync,
21
+ } from "../workbase/frontmatter"
22
+ import {
23
+ PhaseFrontmatter,
24
+ TaskFrontmatter,
25
+ type ClaimRecord,
26
+ type PhaseFrontmatter as PhaseData,
27
+ type TaskFrontmatter as TaskData,
28
+ } from "../workbase/schemas"
29
+
30
+ class ClaimError extends Data.TaggedError("ClaimError")<{
31
+ readonly message: string
32
+ readonly target?: string
33
+ }> {}
34
+
35
+ class RevisionConflictError extends Data.TaggedError("RevisionConflictError")<{
36
+ readonly message: string
37
+ readonly target: string
38
+ readonly expectedRevision: string
39
+ readonly actualRevision: string
40
+ readonly claim?: ClaimRecord
41
+ }> {}
42
+
43
+ class ClaimConflictError extends Data.TaggedError("ClaimConflictError")<{
44
+ readonly message: string
45
+ readonly target: string
46
+ readonly currentRevision: string
47
+ readonly claim?: ClaimRecord
48
+ readonly legacyStatus?: "working" | "delegated"
49
+ }> {}
50
+
51
+ class ClaimOwnershipError extends Data.TaggedError("ClaimOwnershipError")<{
52
+ readonly message: string
53
+ readonly target: string
54
+ readonly currentRevision: string
55
+ readonly sessionId: string
56
+ readonly claim?: ClaimRecord
57
+ }> {}
58
+
59
+ interface ClaimTarget {
60
+ readonly kind: "task" | "phase"
61
+ readonly taskId: string
62
+ readonly phaseId?: string
63
+ readonly path: string
64
+ readonly label: string
65
+ }
66
+
67
+ interface ClaimInput {
68
+ readonly taskId: string
69
+ readonly phaseId?: string
70
+ readonly claimant: string
71
+ readonly runner: string
72
+ readonly sessionId: string
73
+ readonly revision: string
74
+ readonly expiresAt?: string
75
+ readonly now?: Date
76
+ }
77
+
78
+ interface OwnedClaimInput {
79
+ readonly taskId: string
80
+ readonly phaseId?: string
81
+ readonly sessionId: string
82
+ readonly revision: string
83
+ readonly now?: Date
84
+ }
85
+
86
+ interface FinishInput extends OwnedClaimInput {
87
+ readonly outcome: "done" | "dropped"
88
+ }
89
+
90
+ type SingleTaskData = Extract<TaskData, { readonly repo: string }>
91
+ type ExecutionData = SingleTaskData | PhaseData
92
+
93
+ const isTaggedClaimError = (
94
+ error: unknown,
95
+ ): error is
96
+ | ClaimError
97
+ | RevisionConflictError
98
+ | ClaimConflictError
99
+ | ClaimOwnershipError =>
100
+ typeof error === "object" &&
101
+ error !== null &&
102
+ "_tag" in error &&
103
+ typeof error._tag === "string" &&
104
+ [
105
+ "ClaimError",
106
+ "RevisionConflictError",
107
+ "ClaimConflictError",
108
+ "ClaimOwnershipError",
109
+ ].includes(error._tag)
110
+
111
+ const decodeExecution = (target: ClaimTarget, input: unknown) => {
112
+ const schema: Schema.Schema<any> =
113
+ target.kind === "task" ? TaskFrontmatter : PhaseFrontmatter
114
+ const result = Schema.decodeUnknownEither(schema, {
115
+ errors: "all",
116
+ onExcessProperty: "error",
117
+ })(input)
118
+ if (Either.isLeft(result)) {
119
+ throw new ClaimError({
120
+ target: target.label,
121
+ message: TreeFormatter.formatErrorSync(result.left),
122
+ })
123
+ }
124
+ if (target.kind === "task" && "phases" in result.right) {
125
+ throw new ClaimError({
126
+ target: target.label,
127
+ message: `Task '${target.taskId}' has multiple phases; claim a phase instead`,
128
+ })
129
+ }
130
+ return result.right as ExecutionData
131
+ }
132
+
133
+ const assertRevision = (revision: string) => {
134
+ if (!/^[a-f0-9]{64}$/.test(revision)) {
135
+ throw new ClaimError({
136
+ message: "Revision must be a 64-character SHA-256 hash",
137
+ })
138
+ }
139
+ }
140
+
141
+ const assertIdentity = (label: string, value: string) => {
142
+ if (!value.trim())
143
+ throw new ClaimError({ message: `${label} must not be empty` })
144
+ }
145
+
146
+ const isIsoTimestamp = (value: string) =>
147
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) &&
148
+ Number.isFinite(Date.parse(value))
149
+
150
+ const isUnexpired = (claim: ClaimRecord, now: Date) =>
151
+ claim.state === "active" &&
152
+ (claim.expiresAt === undefined || Date.parse(claim.expiresAt) > now.getTime())
153
+
154
+ const acquireLock = async (path: string) => {
155
+ const lockPath = `${path}.claim.lock`
156
+ for (let attempt = 0; attempt < 1_750; attempt += 1) {
157
+ try {
158
+ const handle = await open(lockPath, "wx")
159
+ return { handle, lockPath }
160
+ } catch (error) {
161
+ if (
162
+ !(error instanceof Error) ||
163
+ !("code" in error) ||
164
+ error.code !== "EEXIST"
165
+ ) {
166
+ throw error
167
+ }
168
+ try {
169
+ const lock = await stat(lockPath)
170
+ if (Date.now() - lock.mtimeMs > 30_000) await unlink(lockPath)
171
+ } catch {
172
+ // Another process released the lock between checks.
173
+ }
174
+ await Bun.sleep(20)
175
+ }
176
+ }
177
+ throw new ClaimError({ message: `Timed out waiting to update ${path}` })
178
+ }
179
+
180
+ const updateAtomically = async <T>(
181
+ target: ClaimTarget,
182
+ expectedRevision: string,
183
+ update: (
184
+ data: ExecutionData,
185
+ now: Date,
186
+ ) => T & {
187
+ readonly data: ExecutionData
188
+ },
189
+ now: Date,
190
+ ) => {
191
+ assertRevision(expectedRevision)
192
+ const { handle, lockPath } = await acquireLock(target.path)
193
+ let temporaryPath: string | undefined
194
+ try {
195
+ const content = await readFile(target.path, "utf8")
196
+ const actualRevision = documentRevision(content)
197
+ const parsed = parseFrontmatterSync(content, target.path)
198
+ const current = decodeExecution(target, parsed.data)
199
+ if (actualRevision !== expectedRevision) {
200
+ throw new RevisionConflictError({
201
+ target: target.label,
202
+ expectedRevision,
203
+ actualRevision,
204
+ claim: current.claim,
205
+ message: `Revision conflict for ${target.label}`,
206
+ })
207
+ }
208
+ const result = update(current, now)
209
+ const updatedContent = formatMarkdownDocument(result.data, parsed.body)
210
+ temporaryPath = join(
211
+ dirname(target.path),
212
+ `.${basename(target.path)}.${process.pid}.${randomUUID()}.tmp`,
213
+ )
214
+ await writeFile(temporaryPath, updatedContent, { flag: "wx" })
215
+ await rename(temporaryPath, target.path)
216
+ temporaryPath = undefined
217
+ return {
218
+ ...result,
219
+ target: target.label,
220
+ previousRevision: actualRevision,
221
+ revision: documentRevision(updatedContent),
222
+ }
223
+ } finally {
224
+ if (temporaryPath) await unlink(temporaryPath).catch(() => undefined)
225
+ await handle.close().catch(() => undefined)
226
+ await unlink(lockPath).catch(() => undefined)
227
+ }
228
+ }
229
+
230
+ const operation = <T>(run: () => Promise<T>) =>
231
+ Effect.tryPromise({
232
+ try: run,
233
+ catch: (error) =>
234
+ isTaggedClaimError(error)
235
+ ? error
236
+ : new ClaimError({
237
+ message: error instanceof Error ? error.message : String(error),
238
+ }),
239
+ })
240
+
241
+ export class ClaimService extends Effect.Service<ClaimService>()(
242
+ "ClaimService",
243
+ {
244
+ sync: () => ({
245
+ inspect: (
246
+ taskId: string,
247
+ phaseId?: string,
248
+ startPath: string = process.cwd(),
249
+ ) =>
250
+ Effect.gen(function* () {
251
+ const fs = yield* FileSystemService
252
+ const workbase = yield* WorkbaseService
253
+ const tasks = yield* TaskService
254
+ const phases = yield* PhaseService
255
+ const root = yield* workbase.discover(startPath)
256
+ const task = yield* tasks.show(taskId, root)
257
+ const phase = phaseId
258
+ ? yield* phases.show(task.id, phaseId, root)
259
+ : undefined
260
+ const target: ClaimTarget = phaseId
261
+ ? {
262
+ kind: "phase",
263
+ taskId: task.id,
264
+ phaseId,
265
+ path: phase!.path,
266
+ label: `phase '${task.id}/${phaseId}'`,
267
+ }
268
+ : {
269
+ kind: "task",
270
+ taskId: task.id,
271
+ path: task.path,
272
+ label: `task '${task.id}'`,
273
+ }
274
+ if (!phaseId && "phases" in task.data) {
275
+ return yield* new ClaimError({
276
+ target: target.label,
277
+ message: `Task '${task.id}' has multiple phases; claim a phase instead`,
278
+ })
279
+ }
280
+ const content = yield* fs.readFile(target.path)
281
+ const parsed = parseFrontmatterSync(content, target.path)
282
+ return {
283
+ target,
284
+ revision: documentRevision(content),
285
+ data: decodeExecution(target, parsed.data),
286
+ }
287
+ }),
288
+
289
+ claim: (input: ClaimInput, startPath: string = process.cwd()) =>
290
+ Effect.gen(function* () {
291
+ for (const [label, value] of [
292
+ ["Claimant", input.claimant],
293
+ ["Runner", input.runner],
294
+ ["Session ID", input.sessionId],
295
+ ] as const) {
296
+ assertIdentity(label, value)
297
+ }
298
+ const service = yield* ClaimService
299
+ const inspected = yield* service.inspect(
300
+ input.taskId,
301
+ input.phaseId,
302
+ startPath,
303
+ )
304
+ const now = input.now ?? new Date()
305
+ if (
306
+ input.expiresAt !== undefined &&
307
+ (!isIsoTimestamp(input.expiresAt) ||
308
+ Date.parse(input.expiresAt) <= now.getTime())
309
+ ) {
310
+ return yield* new ClaimError({
311
+ message: "Claim expiry must be a future ISO-8601 timestamp",
312
+ })
313
+ }
314
+ return yield* operation(() =>
315
+ updateAtomically(
316
+ inspected.target,
317
+ input.revision,
318
+ (data, operationTime) => {
319
+ const replacingExpiredClaim =
320
+ data.claim?.state === "active" &&
321
+ !isUnexpired(data.claim, operationTime)
322
+ if (data.claim && isUnexpired(data.claim, operationTime)) {
323
+ throw new ClaimConflictError({
324
+ target: inspected.target.label,
325
+ currentRevision: input.revision,
326
+ claim: data.claim,
327
+ message: `${inspected.target.label} is claimed by '${data.claim.runner}'`,
328
+ })
329
+ }
330
+ if (
331
+ !data.claim &&
332
+ (data.status === "working" || data.status === "delegated")
333
+ ) {
334
+ throw new ClaimConflictError({
335
+ target: inspected.target.label,
336
+ currentRevision: input.revision,
337
+ legacyStatus: data.status,
338
+ message: `${inspected.target.label} has legacy '${data.status}' ownership; reopen it before claiming`,
339
+ })
340
+ }
341
+ if (data.status !== "open" && !replacingExpiredClaim) {
342
+ throw new ClaimError({
343
+ target: inspected.target.label,
344
+ message: `${inspected.target.label} cannot be claimed while ${data.status}`,
345
+ })
346
+ }
347
+ const claim: ClaimRecord = {
348
+ claimant: input.claimant.trim(),
349
+ runner: input.runner.trim(),
350
+ sessionId: input.sessionId.trim(),
351
+ startedAt: operationTime.toISOString(),
352
+ targetRevision: input.revision,
353
+ ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
354
+ state: "active",
355
+ }
356
+ return { data: { ...data, status: "working", claim }, claim }
357
+ },
358
+ now,
359
+ ),
360
+ )
361
+ }),
362
+
363
+ release: (input: OwnedClaimInput, startPath: string = process.cwd()) =>
364
+ Effect.gen(function* () {
365
+ assertIdentity("Session ID", input.sessionId)
366
+ const service = yield* ClaimService
367
+ const inspected = yield* service.inspect(
368
+ input.taskId,
369
+ input.phaseId,
370
+ startPath,
371
+ )
372
+ return yield* operation(() =>
373
+ updateAtomically(
374
+ inspected.target,
375
+ input.revision,
376
+ (data, now) => {
377
+ if (
378
+ !data.claim ||
379
+ data.claim.state !== "active" ||
380
+ data.claim.sessionId !== input.sessionId
381
+ ) {
382
+ throw new ClaimOwnershipError({
383
+ target: inspected.target.label,
384
+ currentRevision: input.revision,
385
+ sessionId: input.sessionId,
386
+ claim: data.claim,
387
+ message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
388
+ })
389
+ }
390
+ const claim: ClaimRecord = {
391
+ ...data.claim,
392
+ state: "released",
393
+ releasedAt: now.toISOString(),
394
+ }
395
+ return { data: { ...data, status: "open", claim }, claim }
396
+ },
397
+ input.now ?? new Date(),
398
+ ),
399
+ )
400
+ }),
401
+
402
+ finish: (input: FinishInput, startPath: string = process.cwd()) =>
403
+ Effect.gen(function* () {
404
+ assertIdentity("Session ID", input.sessionId)
405
+ const service = yield* ClaimService
406
+ const inspected = yield* service.inspect(
407
+ input.taskId,
408
+ input.phaseId,
409
+ startPath,
410
+ )
411
+ return yield* operation(() =>
412
+ updateAtomically(
413
+ inspected.target,
414
+ input.revision,
415
+ (data, now) => {
416
+ if (
417
+ !data.claim ||
418
+ data.claim.state !== "active" ||
419
+ data.claim.sessionId !== input.sessionId
420
+ ) {
421
+ throw new ClaimOwnershipError({
422
+ target: inspected.target.label,
423
+ currentRevision: input.revision,
424
+ sessionId: input.sessionId,
425
+ claim: data.claim,
426
+ message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
427
+ })
428
+ }
429
+ const claim: ClaimRecord = {
430
+ ...data.claim,
431
+ state: "finished",
432
+ finishedAt: now.toISOString(),
433
+ outcome: input.outcome,
434
+ }
435
+ return {
436
+ data: { ...data, status: input.outcome, claim },
437
+ claim,
438
+ }
439
+ },
440
+ input.now ?? new Date(),
441
+ ),
442
+ )
443
+ }),
444
+ }),
445
+ },
446
+ ) {}
@@ -193,6 +193,8 @@ export class PhaseService extends Effect.Service<PhaseService>()(
193
193
  branch: task.data.branch,
194
194
  base: task.data.base,
195
195
  pr: task.data.pr,
196
+ status: task.data.status,
197
+ ...(task.data.claim ? { claim: task.data.claim } : {}),
196
198
  })
197
199
  const firstTitle = firstPhaseId!
198
200
  .split("-")
@@ -340,7 +342,18 @@ export class PhaseService extends Effect.Service<PhaseService>()(
340
342
  const fs = yield* FileSystemService
341
343
  const service = yield* PhaseService
342
344
  const validStatus = yield* decodeStatus(status)
345
+ if (validStatus === "working" || validStatus === "delegated") {
346
+ return yield* new PhaseError({
347
+ message:
348
+ "Active work and delegation require explicit ownership; use 'agency claim'",
349
+ })
350
+ }
343
351
  const record = yield* service.show(taskId, id, startPath)
352
+ if (record.data.claim?.state === "active") {
353
+ return yield* new PhaseError({
354
+ message: `Phase '${id}' has an active claim; use agency release or agency finish`,
355
+ })
356
+ }
344
357
  if (!canTransitionStatus(record.data.status, validStatus)) {
345
358
  return yield* new PhaseError({
346
359
  message: `Cannot transition phase '${id}' from ${record.data.status} to ${validStatus}; reopen it first`,
@@ -234,16 +234,17 @@ describe("task and phase services", () => {
234
234
  ),
235
235
  )
236
236
  expect(createdTask.content).toContain("status: open")
237
- const task = await runTestEffect(
238
- TaskService.pipe(
239
- Effect.flatMap((service) =>
240
- service.setStatus("single-status", "delegated", root),
237
+ for (const status of ["working", "delegated"]) {
238
+ await expect(
239
+ runTestEffect(
240
+ TaskService.pipe(
241
+ Effect.flatMap((service) =>
242
+ service.setStatus("single-status", status, root),
243
+ ),
244
+ ),
241
245
  ),
242
- ),
243
- )
244
- expect(task.data.status).toBe("delegated")
245
- expect(task.content).toContain("status: delegated")
246
- expect(task.content).toContain("Describe the task outcome.")
246
+ ).rejects.toThrow("require explicit ownership")
247
+ }
247
248
  await runTestEffect(
248
249
  TaskService.pipe(
249
250
  Effect.flatMap((service) =>
@@ -207,12 +207,23 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
207
207
  const fs = yield* FileSystemService
208
208
  const service = yield* TaskService
209
209
  const validStatus = yield* decodeStatus(status)
210
+ if (validStatus === "working" || validStatus === "delegated") {
211
+ return yield* new TaskError({
212
+ message:
213
+ "Active work and delegation require explicit ownership; use 'agency claim'",
214
+ })
215
+ }
210
216
  const record = yield* service.show(id, startPath)
211
217
  if ("phases" in record.data) {
212
218
  return yield* new TaskError({
213
219
  message: `Task '${id}' has multiple phases; set status on a phase instead`,
214
220
  })
215
221
  }
222
+ if (record.data.claim?.state === "active") {
223
+ return yield* new TaskError({
224
+ message: `Task '${id}' has an active claim; use agency release or agency finish`,
225
+ })
226
+ }
216
227
  if (!canTransitionStatus(record.data.status, validStatus)) {
217
228
  return yield* new TaskError({
218
229
  message: `Cannot transition task '${id}' from ${record.data.status} to ${validStatus}; reopen it first`,
package/src/test-utils.ts CHANGED
@@ -15,6 +15,7 @@ import { ArchiveService } from "./services/ArchiveService"
15
15
  import { IntegrationService } from "./services/IntegrationService"
16
16
  import { ContextService } from "./services/ContextService"
17
17
  import { GraphService } from "./services/GraphService"
18
+ import { ClaimService } from "./services/ClaimService"
18
19
 
19
20
  export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
20
21
 
@@ -34,6 +35,7 @@ const TestLayer = Layer.mergeAll(
34
35
  IntegrationService.Default,
35
36
  ContextService.Default,
36
37
  GraphService.Default,
38
+ ClaimService.Default,
37
39
  )
38
40
 
39
41
  export async function runTestEffect<A, E>(
@@ -26,8 +26,8 @@ field. Repositories listed in plural `repos` are read-only references.
26
26
  `agency integration sync` to update managed agent files explicitly.
27
27
  - Keep task-level decisions in `TASK.md` and phase-specific delivery context in
28
28
  `PHASE.md`.
29
- - Keep execution-unit `status` current with `agency task status` or
30
- `agency phase status`; `agency work` marks launched work as `working`.
29
+ - Coordinate execution ownership with `agency claim`, `agency release`, and
30
+ `agency finish`; `agency work` claims execution units before launch.
31
31
  - Do not manually create, move, or remove worktrees under `code/`.
32
32
  - Use `agency archive`, rather than moving work item folders manually.
33
33
  - Do not edit bare repositories or repository symlinks under `repos/`.
@@ -0,0 +1,2 @@
1
+ export const documentRevision = (content: string) =>
2
+ new Bun.CryptoHasher("sha256").update(content).digest("hex")
@@ -12,65 +12,71 @@ interface ParsedFrontmatter {
12
12
  readonly body: string
13
13
  }
14
14
 
15
- export const parseFrontmatter = (content: string, path: string) =>
16
- Effect.try({
17
- try: (): ParsedFrontmatter => {
18
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)
19
- if (!match) {
20
- throw new Error("Markdown file must begin with YAML frontmatter")
21
- }
15
+ export const parseFrontmatterSync = (
16
+ content: string,
17
+ path: string,
18
+ ): ParsedFrontmatter => {
19
+ try {
20
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)
21
+ if (!match) {
22
+ throw new Error("Markdown file must begin with YAML frontmatter")
23
+ }
24
+
25
+ const document = parseDocument(match[1]!, {
26
+ customTags: [],
27
+ merge: false,
28
+ schema: "core",
29
+ strict: true,
30
+ uniqueKeys: true,
31
+ version: "1.2",
32
+ })
22
33
 
23
- const document = parseDocument(match[1]!, {
24
- customTags: [],
25
- merge: false,
26
- schema: "core",
27
- strict: true,
28
- uniqueKeys: true,
29
- version: "1.2",
30
- })
34
+ const parseMessages = [...document.errors, ...document.warnings]
35
+ if (parseMessages.length > 0) {
36
+ throw new Error(parseMessages.map((error) => error.message).join("; "))
37
+ }
31
38
 
32
- const parseMessages = [...document.errors, ...document.warnings]
33
- if (parseMessages.length > 0) {
34
- throw new Error(parseMessages.map((error) => error.message).join("; "))
35
- }
39
+ let unsupportedFeature: string | null = null
40
+ visit(document, {
41
+ Alias: () => {
42
+ unsupportedFeature = "YAML aliases are not supported"
43
+ },
44
+ Node: (_key, node) => {
45
+ if (node.anchor) {
46
+ unsupportedFeature = "YAML anchors are not supported"
47
+ } else if (node.tag && !node.tag.startsWith("tag:yaml.org,2002:")) {
48
+ unsupportedFeature = "Custom YAML tags are not supported"
49
+ }
50
+ },
51
+ })
36
52
 
37
- let unsupportedFeature: string | null = null
38
- visit(document, {
39
- Alias: () => {
40
- unsupportedFeature = "YAML aliases are not supported"
41
- },
42
- Node: (_key, node) => {
43
- if (node.anchor) {
44
- unsupportedFeature = "YAML anchors are not supported"
45
- } else if (node.tag && !node.tag.startsWith("tag:yaml.org,2002:")) {
46
- unsupportedFeature = "Custom YAML tags are not supported"
47
- }
48
- },
49
- })
53
+ if (unsupportedFeature) {
54
+ throw new Error(unsupportedFeature)
55
+ }
50
56
 
51
- if (unsupportedFeature) {
52
- throw new Error(unsupportedFeature)
53
- }
57
+ const data = document.toJS({ maxAliasCount: 0 })
58
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
59
+ throw new Error("YAML frontmatter must be a mapping")
60
+ }
54
61
 
55
- const data = document.toJS({ maxAliasCount: 0 })
56
- if (data === null || typeof data !== "object" || Array.isArray(data)) {
57
- throw new Error("YAML frontmatter must be a mapping")
58
- }
62
+ return {
63
+ data,
64
+ body: content.slice(match[0].length),
65
+ }
66
+ } catch (cause) {
67
+ throw new FrontmatterParseError({
68
+ path,
69
+ message:
70
+ cause instanceof Error ? cause.message : "Failed to parse frontmatter",
71
+ cause,
72
+ })
73
+ }
74
+ }
59
75
 
60
- return {
61
- data,
62
- body: content.slice(match[0].length),
63
- }
64
- },
65
- catch: (cause) =>
66
- new FrontmatterParseError({
67
- path,
68
- message:
69
- cause instanceof Error
70
- ? cause.message
71
- : "Failed to parse frontmatter",
72
- cause,
73
- }),
76
+ export const parseFrontmatter = (content: string, path: string) =>
77
+ Effect.try({
78
+ try: () => parseFrontmatterSync(content, path),
79
+ catch: (error) => error as FrontmatterParseError,
74
80
  })
75
81
 
76
82
  export const formatMarkdownDocument = (data: object, body: string) =>