@markjaquith/agency 2.13.0 → 2.15.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,270 @@
1
+ import { afterEach, beforeEach, 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, runTestEffect } from "../test-utils"
6
+ import { ClaimService } from "./ClaimService"
7
+ import { PhaseService } from "./PhaseService"
8
+ import { TaskService } from "./TaskService"
9
+
10
+ const at = (value: string) => new Date(value)
11
+
12
+ describe("claim service", () => {
13
+ let root: string
14
+
15
+ beforeEach(async () => {
16
+ root = await createTempDir()
17
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
18
+ await mkdir(join(root, "repos/agency"), { recursive: true })
19
+ await runTestEffect(
20
+ TaskService.pipe(
21
+ Effect.flatMap((service) =>
22
+ service.create(
23
+ {
24
+ id: "single",
25
+ ticketUrl: null,
26
+ repo: "agency",
27
+ branch: "task/single",
28
+ base: "main",
29
+ },
30
+ root,
31
+ ),
32
+ ),
33
+ ),
34
+ )
35
+ })
36
+
37
+ afterEach(async () => cleanupTempDir(root))
38
+
39
+ const inspect = (taskId = "single", phaseId?: string) =>
40
+ runTestEffect(
41
+ ClaimService.pipe(
42
+ Effect.flatMap((service) => service.inspect(taskId, phaseId, root)),
43
+ ),
44
+ )
45
+
46
+ const claim = async (
47
+ revision: string,
48
+ sessionId = "session-1",
49
+ now = at("2026-07-17T12:00:00.000Z"),
50
+ expiresAt?: string,
51
+ ) =>
52
+ runTestEffect(
53
+ ClaimService.pipe(
54
+ Effect.flatMap((service) =>
55
+ service.claim(
56
+ {
57
+ taskId: "single",
58
+ claimant: "orchestrator-1",
59
+ runner: "agent-1",
60
+ sessionId,
61
+ revision,
62
+ now,
63
+ ...(expiresAt ? { expiresAt } : {}),
64
+ },
65
+ root,
66
+ ),
67
+ ),
68
+ ),
69
+ )
70
+
71
+ test("records ownership and guarded release and finish transitions", async () => {
72
+ const initial = await inspect()
73
+ const acquired = await claim(
74
+ initial.revision,
75
+ "session-1",
76
+ at("2026-07-17T12:00:00.000Z"),
77
+ "2026-07-17T13:00:00.000Z",
78
+ )
79
+
80
+ expect(acquired.claim).toEqual({
81
+ claimant: "orchestrator-1",
82
+ runner: "agent-1",
83
+ sessionId: "session-1",
84
+ startedAt: "2026-07-17T12:00:00.000Z",
85
+ targetRevision: initial.revision,
86
+ expiresAt: "2026-07-17T13:00:00.000Z",
87
+ state: "active",
88
+ })
89
+ expect((await inspect()).data.status).toBe("working")
90
+ await expect(
91
+ runTestEffect(
92
+ TaskService.pipe(
93
+ Effect.flatMap((service) =>
94
+ service.setStatus("single", "done", root),
95
+ ),
96
+ ),
97
+ ),
98
+ ).rejects.toThrow("has an active claim")
99
+
100
+ const released = await runTestEffect(
101
+ ClaimService.pipe(
102
+ Effect.flatMap((service) =>
103
+ service.release(
104
+ {
105
+ taskId: "single",
106
+ sessionId: "session-1",
107
+ revision: acquired.revision,
108
+ now: at("2026-07-17T12:15:00.000Z"),
109
+ },
110
+ root,
111
+ ),
112
+ ),
113
+ ),
114
+ )
115
+ expect(released.data.status).toBe("open")
116
+ expect(released.claim).toMatchObject({
117
+ state: "released",
118
+ releasedAt: "2026-07-17T12:15:00.000Z",
119
+ })
120
+
121
+ const reacquired = await claim(
122
+ released.revision,
123
+ "session-2",
124
+ at("2026-07-17T12:20:00.000Z"),
125
+ )
126
+ const finished = await runTestEffect(
127
+ ClaimService.pipe(
128
+ Effect.flatMap((service) =>
129
+ service.finish(
130
+ {
131
+ taskId: "single",
132
+ sessionId: "session-2",
133
+ revision: reacquired.revision,
134
+ outcome: "done",
135
+ now: at("2026-07-17T12:45:00.000Z"),
136
+ },
137
+ root,
138
+ ),
139
+ ),
140
+ ),
141
+ )
142
+ expect(finished.data.status).toBe("done")
143
+ expect(finished.claim).toMatchObject({
144
+ state: "finished",
145
+ finishedAt: "2026-07-17T12:45:00.000Z",
146
+ outcome: "done",
147
+ })
148
+ })
149
+
150
+ test("returns structured ownership and revision conflicts", async () => {
151
+ const initial = await inspect()
152
+ const acquired = await claim(initial.revision)
153
+
154
+ await expect(claim(acquired.revision, "session-2")).rejects.toThrow(
155
+ "is claimed by 'agent-1'",
156
+ )
157
+ await expect(
158
+ runTestEffect(
159
+ ClaimService.pipe(
160
+ Effect.flatMap((service) =>
161
+ service.release(
162
+ {
163
+ taskId: "single",
164
+ sessionId: "session-2",
165
+ revision: acquired.revision,
166
+ },
167
+ root,
168
+ ),
169
+ ),
170
+ ),
171
+ ),
172
+ ).rejects.toThrow("does not own")
173
+ await expect(claim(initial.revision, "session-3")).rejects.toThrow(
174
+ "Revision conflict",
175
+ )
176
+ })
177
+
178
+ test("serializes concurrent claims and allows expired ownership replacement", async () => {
179
+ const initial = await inspect()
180
+ const attempts = await Promise.allSettled([
181
+ claim(initial.revision, "session-a"),
182
+ claim(initial.revision, "session-b"),
183
+ ])
184
+ expect(
185
+ attempts.filter((result) => result.status === "fulfilled"),
186
+ ).toHaveLength(1)
187
+ expect(
188
+ attempts.filter((result) => result.status === "rejected"),
189
+ ).toHaveLength(1)
190
+ const current = await inspect()
191
+ expect(current.data.claim?.state).toBe("active")
192
+
193
+ await runTestEffect(
194
+ ClaimService.pipe(
195
+ Effect.flatMap((service) =>
196
+ service.release(
197
+ {
198
+ taskId: "single",
199
+ sessionId: current.data.claim!.sessionId,
200
+ revision: current.revision,
201
+ },
202
+ root,
203
+ ),
204
+ ),
205
+ ),
206
+ )
207
+ const released = await inspect()
208
+ const expiring = await claim(
209
+ released.revision,
210
+ "expiring",
211
+ at("2026-07-17T12:00:00.000Z"),
212
+ "2026-07-17T12:01:00.000Z",
213
+ )
214
+ const replacement = await claim(
215
+ expiring.revision,
216
+ "replacement",
217
+ at("2026-07-17T12:02:00.000Z"),
218
+ )
219
+ expect(replacement.claim.sessionId).toBe("replacement")
220
+ })
221
+
222
+ test("claims phases and rejects multi-phase task containers", async () => {
223
+ await runTestEffect(
224
+ TaskService.pipe(
225
+ Effect.flatMap((service) =>
226
+ service.create(
227
+ { id: "multi", ticketUrl: null, multiPhase: true },
228
+ root,
229
+ ),
230
+ ),
231
+ ),
232
+ )
233
+ await runTestEffect(
234
+ PhaseService.pipe(
235
+ Effect.flatMap((service) =>
236
+ service.create(
237
+ {
238
+ taskId: "multi",
239
+ id: "implementation",
240
+ repo: "agency",
241
+ branch: "task/multi",
242
+ base: "main",
243
+ },
244
+ root,
245
+ ),
246
+ ),
247
+ ),
248
+ )
249
+ await expect(inspect("multi")).rejects.toThrow("claim a phase instead")
250
+ const phase = await inspect("multi", "implementation")
251
+ const acquired = await runTestEffect(
252
+ ClaimService.pipe(
253
+ Effect.flatMap((service) =>
254
+ service.claim(
255
+ {
256
+ taskId: "multi",
257
+ phaseId: "implementation",
258
+ claimant: "orchestrator",
259
+ runner: "agent",
260
+ sessionId: "phase-session",
261
+ revision: phase.revision,
262
+ },
263
+ root,
264
+ ),
265
+ ),
266
+ ),
267
+ )
268
+ expect(acquired.target).toBe("phase 'multi/implementation'")
269
+ })
270
+ })
@@ -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
+ ) {}