@blogic-cz/agent-tools 1.7.5 → 1.9.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.
Files changed (142) hide show
  1. package/README.md +2 -1
  2. package/dist/config/loader.d.ts.map +1 -1
  3. package/dist/config/types.d.ts +1 -0
  4. package/dist/config/types.d.ts.map +1 -1
  5. package/dist/credential-guard/index.d.ts.map +1 -1
  6. package/dist/db-tool/service.d.ts.map +1 -1
  7. package/dist/gh-tool/branch.d.ts +2 -3
  8. package/dist/gh-tool/branch.d.ts.map +1 -1
  9. package/dist/gh-tool/environment.d.ts +1 -1
  10. package/dist/gh-tool/environment.d.ts.map +1 -1
  11. package/dist/gh-tool/gist.d.ts +8 -28
  12. package/dist/gh-tool/gist.d.ts.map +1 -1
  13. package/dist/gh-tool/issue/commands.d.ts +11 -9
  14. package/dist/gh-tool/issue/commands.d.ts.map +1 -1
  15. package/dist/gh-tool/issue/core.d.ts +116 -9
  16. package/dist/gh-tool/issue/core.d.ts.map +1 -1
  17. package/dist/gh-tool/issue/triage.d.ts +6 -6
  18. package/dist/gh-tool/issue/triage.d.ts.map +1 -1
  19. package/dist/gh-tool/label.d.ts +20 -0
  20. package/dist/gh-tool/label.d.ts.map +1 -0
  21. package/dist/gh-tool/pr/commands.d.ts +53 -46
  22. package/dist/gh-tool/pr/commands.d.ts.map +1 -1
  23. package/dist/gh-tool/pr/core.d.ts +156 -86
  24. package/dist/gh-tool/pr/core.d.ts.map +1 -1
  25. package/dist/gh-tool/pr/review.d.ts +16 -16
  26. package/dist/gh-tool/pr/review.d.ts.map +1 -1
  27. package/dist/gh-tool/pr/snapshot.d.ts +9 -0
  28. package/dist/gh-tool/pr/snapshot.d.ts.map +1 -0
  29. package/dist/gh-tool/pr/stack-read.d.ts +1 -6
  30. package/dist/gh-tool/pr/stack-read.d.ts.map +1 -1
  31. package/dist/gh-tool/pr/stack.d.ts +3 -3
  32. package/dist/gh-tool/pr/stack.d.ts.map +1 -1
  33. package/dist/gh-tool/{api.d.ts → read/api.d.ts} +9 -3
  34. package/dist/gh-tool/read/api.d.ts.map +1 -0
  35. package/dist/gh-tool/read/cache.d.ts +44 -0
  36. package/dist/gh-tool/read/cache.d.ts.map +1 -0
  37. package/dist/gh-tool/read/catalog.d.ts +170 -0
  38. package/dist/gh-tool/read/catalog.d.ts.map +1 -0
  39. package/dist/gh-tool/read/classes.d.ts +98 -0
  40. package/dist/gh-tool/read/classes.d.ts.map +1 -0
  41. package/dist/gh-tool/read/commands.d.ts +3 -0
  42. package/dist/gh-tool/read/commands.d.ts.map +1 -0
  43. package/dist/gh-tool/read/commits.d.ts +10 -0
  44. package/dist/gh-tool/read/commits.d.ts.map +1 -0
  45. package/dist/gh-tool/read/gated.d.ts +14 -0
  46. package/dist/gh-tool/read/gated.d.ts.map +1 -0
  47. package/dist/gh-tool/read/index.d.ts +5 -0
  48. package/dist/gh-tool/read/index.d.ts.map +1 -0
  49. package/dist/gh-tool/read/issues.d.ts +80 -0
  50. package/dist/gh-tool/read/issues.d.ts.map +1 -0
  51. package/dist/gh-tool/read/keys.d.ts +64 -0
  52. package/dist/gh-tool/read/keys.d.ts.map +1 -0
  53. package/dist/gh-tool/read/porcelain.d.ts +68 -0
  54. package/dist/gh-tool/read/porcelain.d.ts.map +1 -0
  55. package/dist/gh-tool/read/pulls.d.ts +137 -0
  56. package/dist/gh-tool/read/pulls.d.ts.map +1 -0
  57. package/dist/gh-tool/read/reads.d.ts +220 -0
  58. package/dist/gh-tool/read/reads.d.ts.map +1 -0
  59. package/dist/gh-tool/read/rest.d.ts +49 -0
  60. package/dist/gh-tool/read/rest.d.ts.map +1 -0
  61. package/dist/gh-tool/read/review-guards.d.ts +20 -0
  62. package/dist/gh-tool/read/review-guards.d.ts.map +1 -0
  63. package/dist/gh-tool/read/review-model.d.ts +190 -0
  64. package/dist/gh-tool/read/review-model.d.ts.map +1 -0
  65. package/dist/gh-tool/read/reviews.d.ts +9 -0
  66. package/dist/gh-tool/read/reviews.d.ts.map +1 -0
  67. package/dist/gh-tool/read/stacks.d.ts +7 -0
  68. package/dist/gh-tool/read/stacks.d.ts.map +1 -0
  69. package/dist/gh-tool/read/store.d.ts +70 -0
  70. package/dist/gh-tool/read/store.d.ts.map +1 -0
  71. package/dist/gh-tool/{service.d.ts → read/transport.d.ts} +5 -3
  72. package/dist/gh-tool/read/transport.d.ts.map +1 -0
  73. package/dist/gh-tool/read/waits.d.ts +22 -0
  74. package/dist/gh-tool/read/waits.d.ts.map +1 -0
  75. package/dist/gh-tool/read/writes.d.ts +16 -0
  76. package/dist/gh-tool/read/writes.d.ts.map +1 -0
  77. package/dist/gh-tool/release.d.ts +10 -26
  78. package/dist/gh-tool/release.d.ts.map +1 -1
  79. package/dist/gh-tool/repo.d.ts +9 -4
  80. package/dist/gh-tool/repo.d.ts.map +1 -1
  81. package/dist/gh-tool/types.d.ts +1 -1
  82. package/dist/gh-tool/types.d.ts.map +1 -1
  83. package/dist/gh-tool/workflow.d.ts +43 -21
  84. package/dist/gh-tool/workflow.d.ts.map +1 -1
  85. package/dist/observability-tool/shared.d.ts.map +1 -1
  86. package/dist/shared/content-security.d.ts +2 -0
  87. package/dist/shared/content-security.d.ts.map +1 -1
  88. package/package.json +7 -6
  89. package/schemas/agent-tools.schema.json +5 -0
  90. package/src/api-tool/proxy-entry.ts +3 -1
  91. package/src/config/loader.ts +1 -0
  92. package/src/config/types.ts +1 -0
  93. package/src/credential-guard/index.ts +169 -1
  94. package/src/db-tool/service.ts +4 -1
  95. package/src/gh-tool/branch.ts +5 -8
  96. package/src/gh-tool/environment.ts +2 -1
  97. package/src/gh-tool/gist.ts +8 -35
  98. package/src/gh-tool/issue/commands.ts +12 -2
  99. package/src/gh-tool/issue/core.ts +58 -122
  100. package/src/gh-tool/issue/triage.ts +4 -26
  101. package/src/gh-tool/label.ts +57 -0
  102. package/src/gh-tool/main.ts +24 -4
  103. package/src/gh-tool/pr/commands.ts +17 -15
  104. package/src/gh-tool/pr/core.ts +486 -582
  105. package/src/gh-tool/pr/review.ts +106 -454
  106. package/src/gh-tool/pr/snapshot.ts +23 -0
  107. package/src/gh-tool/pr/stack-read.ts +1 -73
  108. package/src/gh-tool/pr/stack.ts +37 -36
  109. package/src/gh-tool/{api.ts → read/api.ts} +40 -7
  110. package/src/gh-tool/read/cache.ts +192 -0
  111. package/src/gh-tool/read/catalog.ts +310 -0
  112. package/src/gh-tool/read/classes.ts +31 -0
  113. package/src/gh-tool/read/commands.ts +25 -0
  114. package/src/gh-tool/read/commits.ts +28 -0
  115. package/src/gh-tool/read/gated.ts +56 -0
  116. package/src/gh-tool/read/index.ts +4 -0
  117. package/src/gh-tool/read/issues.ts +124 -0
  118. package/src/gh-tool/read/keys.ts +104 -0
  119. package/src/gh-tool/read/porcelain.ts +58 -0
  120. package/src/gh-tool/read/pulls.ts +266 -0
  121. package/src/gh-tool/read/reads.ts +306 -0
  122. package/src/gh-tool/read/rest.ts +111 -0
  123. package/src/gh-tool/read/review-guards.ts +61 -0
  124. package/src/gh-tool/read/review-model.ts +286 -0
  125. package/src/gh-tool/read/reviews.ts +114 -0
  126. package/src/gh-tool/read/stacks.ts +73 -0
  127. package/src/gh-tool/read/store.ts +504 -0
  128. package/src/gh-tool/{service.ts → read/transport.ts} +26 -6
  129. package/src/gh-tool/read/waits.ts +52 -0
  130. package/src/gh-tool/read/writes.ts +37 -0
  131. package/src/gh-tool/release.ts +15 -168
  132. package/src/gh-tool/repo.ts +26 -78
  133. package/src/gh-tool/types.ts +1 -1
  134. package/src/gh-tool/workflow.ts +123 -214
  135. package/src/observability-tool/shared.ts +2 -1
  136. package/src/shared/content-security.ts +10 -1
  137. package/src/shared/credential-patterns.ts +1 -1
  138. package/dist/gh-tool/api.d.ts.map +0 -1
  139. package/dist/gh-tool/pr/check-status-cache.d.ts +0 -6
  140. package/dist/gh-tool/pr/check-status-cache.d.ts.map +0 -1
  141. package/dist/gh-tool/service.d.ts.map +0 -1
  142. package/src/gh-tool/pr/check-status-cache.ts +0 -142
@@ -0,0 +1,504 @@
1
+ import { Duration, Effect, Schema } from "effect";
2
+ import { randomUUID } from "node:crypto";
3
+ import { lstat, mkdir, readFile, readdir, rename, rm, rmdir, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+
6
+ import type { CacheClass } from "./classes";
7
+ import { digest, entryKey, repoSlug, tagsFor } from "./keys";
8
+ import type { ReadKey, RepoRef, Tag } from "./keys";
9
+
10
+ const ClockSchema = Schema.Struct({ resetId: Schema.String, generation: Schema.Number });
11
+ export type ReadFence = typeof ClockSchema.Type;
12
+
13
+ const EntrySchema = Schema.Struct({
14
+ schema: Schema.Literal(1),
15
+ value: Schema.Unknown,
16
+ fetchedAt: Schema.Number,
17
+ startedAtClock: Schema.Union([ClockSchema, Schema.Number]),
18
+ immutable: Schema.Boolean,
19
+ identity: Schema.String,
20
+ repo: Schema.String,
21
+ etag: Schema.optional(Schema.String),
22
+ });
23
+ const LeaseSchema = Schema.Struct({ owner: Schema.String, expiresAt: Schema.Number });
24
+ const LockOwnerSchema = Schema.Struct({ pid: Schema.Number, owner: Schema.String });
25
+ const CounterSchema = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Number));
26
+ type Entry = Omit<typeof EntrySchema.Type, "startedAtClock"> & { startedAtClock: ReadFence };
27
+ export type StoreStats = Record<string, Record<string, number>>;
28
+ export type Prepared<A> =
29
+ | { readonly kind: "hit"; readonly value: A }
30
+ | {
31
+ readonly kind: "fetch";
32
+ readonly key: string;
33
+ readonly readKey: ReadKey;
34
+ readonly owner: string;
35
+ readonly startedAtClock: ReadFence;
36
+ readonly previous: (Entry & { value: A }) | null;
37
+ };
38
+
39
+ const repeatUntil = <A>(
40
+ attempt: () => Promise<A | null>,
41
+ intervalMs: number,
42
+ timeoutMs: number,
43
+ ): Promise<A> =>
44
+ Effect.runPromise(
45
+ Effect.gen(function* () {
46
+ const state: { value: A | null } = { value: null };
47
+ const deadline = Date.now() + timeoutMs;
48
+ yield* Effect.whileLoop({
49
+ while: () => state.value === null,
50
+ body: () =>
51
+ Effect.gen(function* () {
52
+ state.value = yield* Effect.promise(attempt);
53
+ if (state.value !== null) return;
54
+ if (Date.now() >= deadline)
55
+ return yield* Effect.die(new Error("Cache coordination timed out"));
56
+ yield* Effect.sleep(Duration.millis(intervalMs));
57
+ }),
58
+ step: () => undefined,
59
+ });
60
+ if (state.value === null)
61
+ return yield* Effect.die(new Error("Cache coordination returned no value"));
62
+ return state.value;
63
+ }),
64
+ );
65
+ const missing = (cause: unknown) =>
66
+ cause instanceof Error && "code" in cause && cause.code === "ENOENT";
67
+ const exists = (cause: unknown) =>
68
+ cause instanceof Error &&
69
+ "code" in cause &&
70
+ (cause.code === "EEXIST" || cause.code === "ENOTEMPTY");
71
+ const atomicWrite = async (path: string, text: string): Promise<void> => {
72
+ const temporary = `${path}.${randomUUID()}.tmp`;
73
+ try {
74
+ await writeFile(temporary, text, { flag: "wx", mode: 0o600 });
75
+ await rename(temporary, path);
76
+ } finally {
77
+ await rm(temporary, { force: true });
78
+ }
79
+ };
80
+
81
+ export const defaultCacheDirectory = (): string =>
82
+ `/tmp/agent-tools-${process.getuid?.() ?? "unknown"}`;
83
+ export class FileStore {
84
+ private readonly counters: StoreStats = {};
85
+ private readonly statsId = `${process.pid}-${randomUUID()}.json`;
86
+ private statsWrite = Promise.resolve();
87
+ private constructor(
88
+ readonly directory: string,
89
+ readonly identity: string,
90
+ ) {}
91
+
92
+ static async open(
93
+ identity: string,
94
+ directory = process.env["GH_TOOL_CACHE_DIR"] ?? defaultCacheDirectory(),
95
+ ): Promise<FileStore> {
96
+ const uid = process.getuid?.();
97
+ if (uid === undefined) throw new Error("Cache ownership cannot be verified");
98
+ await mkdir(directory, { recursive: true, mode: 0o700 });
99
+ const checkDirectory = async (path: string) => {
100
+ const stat = await lstat(path);
101
+ if (
102
+ !stat.isDirectory() ||
103
+ stat.isSymbolicLink() ||
104
+ stat.uid !== uid ||
105
+ (stat.mode & 0o777) !== 0o700
106
+ )
107
+ throw new Error("Cache directory must be real, owned by the current user, and mode 0700");
108
+ };
109
+ await checkDirectory(directory);
110
+ await mkdir(join(directory, "gh-read-v1"), { mode: 0o700, recursive: true });
111
+ await checkDirectory(join(directory, "gh-read-v1"));
112
+ await Promise.all(
113
+ ["entries", "leases", "tags", "stats", "gh-xdg-cache"].map(async (child) => {
114
+ const path = join(directory, "gh-read-v1", child);
115
+ await mkdir(path, { recursive: true, mode: 0o700 });
116
+ await checkDirectory(path);
117
+ }),
118
+ );
119
+ const store = new FileStore(join(directory, "gh-read-v1"), identity);
120
+ await mkdir(store.ghCacheDirectory, { recursive: true, mode: 0o700 });
121
+ await checkDirectory(store.ghCacheDirectory);
122
+ await store.readClock();
123
+ await store.prune();
124
+ return store;
125
+ }
126
+
127
+ readClock(): Promise<ReadFence> {
128
+ return this.locked(() => this.clockUnderLock());
129
+ }
130
+
131
+ private async clockUnderLock(): Promise<ReadFence> {
132
+ try {
133
+ const value = Schema.decodeUnknownSync(ClockSchema)(
134
+ JSON.parse(await readFile(join(this.directory, "clock"), "utf8")),
135
+ );
136
+ if (!Number.isSafeInteger(value.generation) || value.generation < 0)
137
+ throw new Error("Invalid cache clock");
138
+ return value;
139
+ } catch (error) {
140
+ if (missing(error) || Schema.isSchemaError(error)) {
141
+ const entries = join(this.directory, "entries");
142
+ await Promise.all(
143
+ (await readdir(entries)).map(async (name) => {
144
+ const path = join(entries, name);
145
+ const entry = Schema.decodeUnknownSync(EntrySchema)(
146
+ JSON.parse(await readFile(path, "utf8")),
147
+ );
148
+ if (!entry.immutable) await rm(path, { force: true });
149
+ }),
150
+ );
151
+ await Promise.all(
152
+ ["tags", "leases"].map(async (child) => {
153
+ const directory = join(this.directory, child);
154
+ await Promise.all(
155
+ (await readdir(directory)).map((name) => rm(join(directory, name), { force: true })),
156
+ );
157
+ }),
158
+ );
159
+ const clock = { resetId: randomUUID(), generation: 0 };
160
+ await atomicWrite(join(this.directory, "clock"), JSON.stringify(clock));
161
+ return clock;
162
+ }
163
+ throw error;
164
+ }
165
+ }
166
+
167
+ get ghCacheDirectory(): string {
168
+ return join(this.directory, "gh-xdg-cache", this.identity);
169
+ }
170
+
171
+ private async prune(): Promise<void> {
172
+ const marker = join(this.directory, "gc");
173
+ const now = Date.now();
174
+ let last = 0;
175
+ try {
176
+ last = Number(await readFile(marker, "utf8"));
177
+ } catch (error) {
178
+ if (!missing(error)) throw error;
179
+ }
180
+ if (Number.isFinite(last) && now - last < 86_400_000) return;
181
+ await atomicWrite(marker, String(now));
182
+ await Promise.all(
183
+ ["entries", "stats"].map(async (child) => {
184
+ const directory = join(this.directory, child);
185
+ await Promise.all(
186
+ (await readdir(directory))
187
+ .filter((name) => name.endsWith(".json"))
188
+ .map(async (name) => {
189
+ const path = join(directory, name);
190
+ try {
191
+ if ((await lstat(path)).mtimeMs < now - 14 * 86_400_000)
192
+ await rm(path, { force: true });
193
+ } catch (error) {
194
+ if (!missing(error)) throw error;
195
+ }
196
+ }),
197
+ );
198
+ }),
199
+ );
200
+ }
201
+
202
+ private async locked<A>(body: () => Promise<A>): Promise<A> {
203
+ const path = join(this.directory, "clock.lock");
204
+ const owner = randomUUID();
205
+ const temporary = `${path}.${owner}.tmp`;
206
+ const tombstone = `${path}.${owner}.released`;
207
+ await mkdir(temporary, { mode: 0o700 });
208
+ try {
209
+ await writeFile(
210
+ join(temporary, `${owner}.json`),
211
+ JSON.stringify({ pid: process.pid, owner }),
212
+ {
213
+ flag: "wx",
214
+ mode: 0o600,
215
+ },
216
+ );
217
+ await repeatUntil(
218
+ async () => {
219
+ try {
220
+ await rename(temporary, path);
221
+ return true;
222
+ } catch (error) {
223
+ if (!exists(error)) throw error;
224
+ await this.recoverClockLock(path);
225
+ return null;
226
+ }
227
+ },
228
+ 25,
229
+ 2000,
230
+ );
231
+ try {
232
+ return await body();
233
+ } finally {
234
+ await rename(path, tombstone);
235
+ await rm(tombstone, { recursive: true });
236
+ }
237
+ } finally {
238
+ await rm(temporary, { recursive: true, force: true });
239
+ }
240
+ }
241
+
242
+ private async recoverClockLock(path: string): Promise<void> {
243
+ try {
244
+ const stat = await lstat(path);
245
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return;
246
+ const names = await readdir(path);
247
+ if (names.length !== 1 || names[0] === undefined) return;
248
+ const ownerPath = join(path, names[0]);
249
+ const owner = Schema.decodeUnknownSync(LockOwnerSchema)(
250
+ JSON.parse(await readFile(ownerPath, "utf8")),
251
+ );
252
+ if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0 || names[0] !== `${owner.owner}.json`)
253
+ return;
254
+ try {
255
+ process.kill(owner.pid, 0);
256
+ return;
257
+ } catch (error) {
258
+ if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) return;
259
+ }
260
+ await rm(ownerPath);
261
+ await rmdir(path);
262
+ } catch (error) {
263
+ if (!missing(error) && !exists(error)) throw error;
264
+ }
265
+ }
266
+
267
+ private async maxGeneration(tags: readonly Tag[]): Promise<number> {
268
+ const generations = await Promise.all(
269
+ tags.map(async (tag) => {
270
+ try {
271
+ const generation = Number(
272
+ await readFile(join(this.directory, "tags", digest(tag)), "utf8"),
273
+ );
274
+ if (!Number.isSafeInteger(generation) || generation < 0)
275
+ throw new Error("Invalid tag generation");
276
+ return generation;
277
+ } catch (error) {
278
+ if (missing(error)) return 0;
279
+ throw error;
280
+ }
281
+ }),
282
+ );
283
+ return Math.max(0, ...generations);
284
+ }
285
+
286
+ async bump(tags: readonly Tag[]): Promise<void> {
287
+ await this.locked(async () => {
288
+ const clock = await this.clockUnderLock();
289
+ const generation = clock.generation + 1;
290
+ await Promise.all(
291
+ [...new Set(tags)].map((tag) =>
292
+ atomicWrite(join(this.directory, "tags", digest(tag)), String(generation)),
293
+ ),
294
+ );
295
+ await atomicWrite(join(this.directory, "clock"), JSON.stringify({ ...clock, generation }));
296
+ });
297
+ }
298
+
299
+ async record(
300
+ op: string,
301
+ outcome: "hit" | "fetch" | "wait" | "notModified" | "failopen" | "stale",
302
+ ): Promise<void> {
303
+ const counters = this.counters[op] ?? (this.counters[op] = {});
304
+ counters[outcome] = (counters[outcome] ?? 0) + 1;
305
+ const text = JSON.stringify(this.counters);
306
+ const write = this.statsWrite.then(() =>
307
+ atomicWrite(join(this.directory, "stats", this.statsId), text),
308
+ );
309
+ this.statsWrite = write.catch(() => undefined);
310
+ await write;
311
+ }
312
+
313
+ async stats(): Promise<StoreStats> {
314
+ const total: StoreStats = {};
315
+ const snapshots = await Promise.all(
316
+ (await readdir(join(this.directory, "stats")))
317
+ .filter((name) => name.endsWith(".json"))
318
+ .map(async (name) => {
319
+ const counters = Schema.decodeUnknownSync(CounterSchema)(
320
+ JSON.parse(await readFile(join(this.directory, "stats", name), "utf8")),
321
+ );
322
+ return counters;
323
+ }),
324
+ );
325
+ for (const counters of snapshots) {
326
+ for (const [op, counts] of Object.entries(counters)) {
327
+ const target = total[op] ?? (total[op] = {});
328
+ for (const [outcome, count] of Object.entries(counts))
329
+ target[outcome] = (target[outcome] ?? 0) + count;
330
+ }
331
+ }
332
+ return total;
333
+ }
334
+
335
+ private async entry<A>(
336
+ key: string,
337
+ decode: (value: unknown) => A,
338
+ ): Promise<(Entry & { value: A }) | null> {
339
+ try {
340
+ const entry = Schema.decodeUnknownSync(EntrySchema)(
341
+ JSON.parse(await readFile(join(this.directory, "entries", `${key}.json`), "utf8")),
342
+ );
343
+ return {
344
+ ...entry,
345
+ startedAtClock:
346
+ typeof entry.startedAtClock === "number"
347
+ ? { resetId: "legacy", generation: entry.startedAtClock }
348
+ : entry.startedAtClock,
349
+ value: decode(entry.value),
350
+ };
351
+ } catch (error) {
352
+ if (missing(error) || error instanceof SyntaxError || Schema.isSchemaError(error))
353
+ return null;
354
+ throw error;
355
+ }
356
+ }
357
+
358
+ prepare<A>(
359
+ readKey: ReadKey,
360
+ policy: CacheClass,
361
+ decode: (value: unknown) => A,
362
+ policyForValue: (value: A) => CacheClass = () => policy,
363
+ force = false,
364
+ ): Promise<Prepared<A>> {
365
+ const key = entryKey(this.identity, readKey);
366
+ const owner = randomUUID();
367
+ let waited = false;
368
+ return repeatUntil<Prepared<A>>(
369
+ async () => {
370
+ const prepared = await this.locked(async (): Promise<Prepared<A> | null> => {
371
+ const clock = await this.clockUnderLock();
372
+ const previous = await this.entry(key, decode);
373
+ const now = Date.now();
374
+ const effectivePolicy = previous === null ? policy : policyForValue(previous.value);
375
+ const valid =
376
+ previous !== null &&
377
+ (previous.immutable ||
378
+ (previous.startedAtClock.resetId === clock.resetId &&
379
+ previous.startedAtClock.generation >=
380
+ (await this.maxGeneration(tagsFor(readKey)))));
381
+ const fresh =
382
+ valid &&
383
+ previous !== null &&
384
+ (previous.immutable ||
385
+ (effectivePolicy.kind !== "passthrough" &&
386
+ effectivePolicy.kind !== "immutable" &&
387
+ now >= previous.fetchedAt &&
388
+ now - previous.fetchedAt < effectivePolicy.ttlMs));
389
+ if (!force && fresh && previous !== null) {
390
+ await this.record(readKey.op, "hit");
391
+ return { kind: "hit", value: previous.value };
392
+ }
393
+ const path = join(this.directory, "leases", `${key}.json`);
394
+ try {
395
+ const lease = Schema.decodeUnknownSync(LeaseSchema)(
396
+ JSON.parse(await readFile(path, "utf8")),
397
+ );
398
+ if (lease.expiresAt > now) return null;
399
+ } catch (error) {
400
+ if (!missing(error)) throw error;
401
+ }
402
+ await atomicWrite(path, JSON.stringify({ owner, expiresAt: now + 60_000 }));
403
+ return { kind: "fetch", key, readKey, owner, startedAtClock: clock, previous };
404
+ });
405
+ if (prepared !== null) return prepared;
406
+ if (!waited) {
407
+ await this.record(readKey.op, "wait");
408
+ waited = true;
409
+ }
410
+ return null;
411
+ },
412
+ 150,
413
+ 20_000,
414
+ );
415
+ }
416
+
417
+ put<A>(
418
+ prepared: Extract<Prepared<A>, { kind: "fetch" }>,
419
+ value: A,
420
+ immutable: boolean,
421
+ etag?: string,
422
+ ): Promise<boolean> {
423
+ return this.locked(async () => {
424
+ const clock = await this.clockUnderLock();
425
+ if (prepared.startedAtClock.resetId !== clock.resetId) return false;
426
+ try {
427
+ const lease = Schema.decodeUnknownSync(LeaseSchema)(
428
+ JSON.parse(
429
+ await readFile(join(this.directory, "leases", `${prepared.key}.json`), "utf8"),
430
+ ),
431
+ );
432
+ if (lease.owner !== prepared.owner || lease.expiresAt <= Date.now()) return false;
433
+ } catch (error) {
434
+ if (missing(error)) return false;
435
+ throw error;
436
+ }
437
+ if (
438
+ !immutable &&
439
+ (await this.maxGeneration(tagsFor(prepared.readKey))) > prepared.startedAtClock.generation
440
+ )
441
+ return false;
442
+ await atomicWrite(
443
+ join(this.directory, "entries", `${prepared.key}.json`),
444
+ JSON.stringify({
445
+ schema: 1,
446
+ value,
447
+ fetchedAt: Date.now(),
448
+ startedAtClock: prepared.startedAtClock,
449
+ immutable,
450
+ identity: this.identity,
451
+ repo: repoSlug(prepared.readKey.repo),
452
+ ...(etag === undefined ? {} : { etag }),
453
+ } satisfies Entry),
454
+ );
455
+ return true;
456
+ });
457
+ }
458
+
459
+ canServeStale<A>(prepared: Extract<Prepared<A>, { kind: "fetch" }>): Promise<boolean> {
460
+ return this.locked(async () => {
461
+ const clock = await this.clockUnderLock();
462
+ const previous = prepared.previous;
463
+ return (
464
+ previous !== null &&
465
+ (await this.entry(prepared.key, (value) => value)) !== null &&
466
+ (previous.immutable ||
467
+ (previous.startedAtClock.resetId === clock.resetId &&
468
+ prepared.startedAtClock.resetId === clock.resetId &&
469
+ previous.startedAtClock.generation >=
470
+ (await this.maxGeneration(tagsFor(prepared.readKey)))))
471
+ );
472
+ });
473
+ }
474
+
475
+ async release(key: string, owner: string): Promise<void> {
476
+ await this.locked(async () => {
477
+ const path = join(this.directory, "leases", `${key}.json`);
478
+ try {
479
+ const lease = Schema.decodeUnknownSync(LeaseSchema)(
480
+ JSON.parse(await readFile(path, "utf8")),
481
+ );
482
+ if (lease.owner === owner) await rm(path);
483
+ } catch (error) {
484
+ if (!missing(error)) throw error;
485
+ }
486
+ });
487
+ }
488
+
489
+ async clear(repo: RepoRef | null): Promise<void> {
490
+ const paths = await readdir(join(this.directory, "entries"));
491
+ await Promise.all(
492
+ paths
493
+ .filter((name) => name.endsWith(".json"))
494
+ .map(async (name) => {
495
+ const path = join(this.directory, "entries", name);
496
+ const entry = Schema.decodeUnknownSync(EntrySchema)(
497
+ JSON.parse(await readFile(path, "utf8")),
498
+ );
499
+ if (entry.identity === this.identity && (repo === null || entry.repo === repoSlug(repo)))
500
+ await rm(path, { force: true });
501
+ }),
502
+ );
503
+ }
504
+ }
@@ -2,16 +2,18 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process";
2
2
  import { Context, Effect, Layer, Stream } from "effect";
3
3
 
4
4
  import type { GitHubRepoConfig } from "#config";
5
- import type { RepoInfo } from "./types";
5
+ import type { RepoInfo } from "#gh/types";
6
+ import type { RepoRef } from "./keys";
6
7
 
7
- import { GH_BINARY } from "./config";
8
- import { ghEnvironment } from "./environment";
9
- import { GitHubAuthError, GitHubCommandError, GitHubNotFoundError } from "./errors";
8
+ import { GH_BINARY } from "#gh/config";
9
+ import { ghEnvironment } from "#gh/environment";
10
+ import { GitHubAuthError, GitHubCommandError, GitHubNotFoundError } from "#gh/errors";
10
11
  import { retryTransient } from "#shared/retry-transient";
11
- import { githubApi } from "./api";
12
+ import { githubApi, SessionCredential } from "./api";
12
13
  import type { GitHubApiRequest, GitHubApiResponse } from "./api";
13
14
  import { ConfigService, getGitHubConfig, resolveGitHubRepoTarget } from "#config";
14
15
  import { validateOutboundText } from "#gh/text-input";
16
+ import { ReadCache } from "./cache";
15
17
 
16
18
  // Transient GitHub-side failures worth a silent retry (vs. a hard error the agent must act on).
17
19
  const NETWORK_ERROR_RE =
@@ -92,6 +94,7 @@ export class GitHubService extends Context.Service<
92
94
  ) => Effect.Effect<GitHubApiResponse<T>, GhError>;
93
95
  readonly getRepoConfig: () => Effect.Effect<GitHubRepoConfig | undefined, never>;
94
96
  readonly getRepoInfo: () => Effect.Effect<RepoInfo, GhError>;
97
+ readonly getRepoRef: () => Effect.Effect<RepoRef, GhError>;
95
98
  readonly withRepoTarget: <A, E, R>(
96
99
  target: string | null,
97
100
  effect: Effect.Effect<A, E, R>,
@@ -163,10 +166,17 @@ export class GitHubService extends Context.Service<
163
166
  Effect.scoped(
164
167
  Effect.gen(function* () {
165
168
  const ghRepo = yield* RepoTarget;
169
+ const cache = yield* ReadCache;
170
+ const credential = yield* SessionCredential;
171
+ const cacheDirectory = cache?.store?.ghCacheDirectory;
166
172
  const command = ChildProcess.make(GH_BINARY, args, {
167
173
  stdout: "pipe",
168
174
  stderr: "pipe",
169
- env: { ...ghEnvironment(), ...(ghRepo ? { GH_REPO: ghRepo } : {}) },
175
+ env: {
176
+ ...ghEnvironment(process.env, credential),
177
+ ...(ghRepo ? { GH_REPO: ghRepo } : {}),
178
+ ...(cacheDirectory === undefined ? {} : { XDG_CACHE_HOME: cacheDirectory }),
179
+ },
170
180
  extendEnv: false,
171
181
  });
172
182
 
@@ -405,6 +415,16 @@ export class GitHubService extends Context.Service<
405
415
  apiRequest: githubApi,
406
416
  getRepoConfig,
407
417
  getRepoInfo,
418
+ getRepoRef: () =>
419
+ Effect.gen(function* () {
420
+ const target = yield* RepoTarget;
421
+ if (target !== undefined) {
422
+ const [owner, name, extra] = target.split("/");
423
+ if (owner && name && extra === undefined) return { owner, name };
424
+ }
425
+ const info = yield* getRepoInfo();
426
+ return { owner: info.owner, name: info.name };
427
+ }),
408
428
  withRepoTarget,
409
429
  };
410
430
  }),
@@ -0,0 +1,52 @@
1
+ import { Clock, Duration, Effect } from "effect";
2
+
3
+ export type WaitOutcome<A> =
4
+ | { readonly kind: "done"; readonly value: A }
5
+ | { readonly kind: "timedOut"; readonly last: A };
6
+ const until = <A, E, R>(
7
+ read: Effect.Effect<A, E, R>,
8
+ done: (value: A) => boolean,
9
+ opts: {
10
+ readonly intervalMs: number;
11
+ readonly timeoutMs: number;
12
+ readonly initial?: { readonly value: A };
13
+ readonly readAtDeadline?: boolean;
14
+ readonly onValue?: (value: A) => Effect.Effect<void, never, R>;
15
+ },
16
+ ): Effect.Effect<WaitOutcome<A>, E, R> =>
17
+ Effect.gen(function* () {
18
+ const deadline = Number(yield* Clock.currentTimeMillis) + opts.timeoutMs;
19
+ const state = {
20
+ value: opts.initial === undefined ? yield* read : opts.initial.value,
21
+ timedOut: false,
22
+ };
23
+ if (opts.onValue !== undefined) yield* opts.onValue(state.value);
24
+ yield* Effect.whileLoop({
25
+ while: () => !done(state.value) && !state.timedOut,
26
+ body: () =>
27
+ Effect.gen(function* () {
28
+ const remaining = deadline - Number(yield* Clock.currentTimeMillis);
29
+ if (remaining <= 0) {
30
+ state.timedOut = true;
31
+ return;
32
+ }
33
+ yield* Effect.sleep(
34
+ Duration.millis(Math.min(Math.max(5000, opts.intervalMs), remaining)),
35
+ );
36
+ if (
37
+ Number(yield* Clock.currentTimeMillis) >= deadline &&
38
+ (opts.readAtDeadline !== true || remaining < 5000)
39
+ ) {
40
+ state.timedOut = true;
41
+ return;
42
+ }
43
+ state.value = yield* read;
44
+ if (opts.onValue !== undefined) yield* opts.onValue(state.value);
45
+ }),
46
+ step: () => undefined,
47
+ });
48
+ return state.timedOut
49
+ ? { kind: "timedOut", last: state.value }
50
+ : { kind: "done", value: state.value };
51
+ });
52
+ export const waits = { until };
@@ -0,0 +1,37 @@
1
+ import { Effect } from "effect";
2
+ import { GitHubService } from "#gh/read/transport";
3
+ import { invalidate } from "./cache";
4
+ import type { Tag } from "./keys";
5
+ import type { GhError } from "#gh/read/transport";
6
+ import type { GitHubApiRequest } from "#gh/read/api";
7
+
8
+ const mutate = <A, R>(effect: Effect.Effect<A, GhError, R>, touched: readonly Tag[]) =>
9
+ effect.pipe(
10
+ Effect.tap(() => invalidate(touched)),
11
+ Effect.tapError(() => invalidate(touched)),
12
+ );
13
+
14
+ const run = Effect.fn("writes.run")(function* (argv: string[], touched: readonly Tag[]) {
15
+ const gh = yield* GitHubService;
16
+ return yield* mutate(gh.runGh(argv), touched);
17
+ });
18
+ const json = <A>(argv: string[], touched: readonly Tag[]) =>
19
+ Effect.gen(function* () {
20
+ const gh = yield* GitHubService;
21
+ return yield* mutate(gh.runGhJson<A>(argv), touched);
22
+ });
23
+ const graphql = (
24
+ query: string,
25
+ variables: Record<string, string | number | null>,
26
+ touched: readonly Tag[],
27
+ ) =>
28
+ Effect.gen(function* () {
29
+ const gh = yield* GitHubService;
30
+ return yield* mutate(gh.runGraphQL(query, variables), touched);
31
+ });
32
+ const api = <A>(request: GitHubApiRequest, touched: readonly Tag[]) =>
33
+ Effect.gen(function* () {
34
+ const gh = yield* GitHubService;
35
+ return yield* mutate(gh.apiRequest<A>(request), touched);
36
+ });
37
+ export const writes = { run, json, graphql, api };