@app-vitals/agent-runtime 0.0.0-stage → 0.1.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 (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +40 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
@@ -0,0 +1,429 @@
1
+ /**
2
+ * agent/src/runtime/sync-config-clone.unit.test.ts
3
+ *
4
+ * Unit tests for syncClonedRepos() and makeCloneRepo() — RSF-4.1's auto-clone
5
+ * step wired into index.ts's syncConfig() tick. Uses fully injected
6
+ * getScopedRepos/exists/cloneRepo/removePartialClone doubles and an injected
7
+ * spawner — no real filesystem or `gh` calls, no global.fetch/global.*
8
+ * overrides — per this repo's unit-test isolation contract.
9
+ */
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+ import { join } from "node:path";
13
+ import {
14
+ makeCloneRepo,
15
+ readRemoteOwner,
16
+ type SyncConfigCloneDeps,
17
+ syncClonedRepos,
18
+ } from "./sync-config-clone.ts";
19
+
20
+ const REPOS_DIR = "/data/agent-home/workspace/repos";
21
+
22
+ interface CloneCall {
23
+ repo: string;
24
+ dest: string;
25
+ }
26
+
27
+ interface MakeDepsOptions {
28
+ scopedRepos?: string[];
29
+ /** repo-dest paths (as computed from reposDir + short name) that already exist on disk. */
30
+ existingDests?: string[];
31
+ /** dest -> Error to throw from cloneRepo, if configured. */
32
+ cloneErrors?: Record<string, Error>;
33
+ /** When set, cloneRepo awaits this before resolving (used for overlap tests). */
34
+ cloneGate?: Promise<void>;
35
+ /** When set, removePartialClone throws this. */
36
+ removeError?: Error;
37
+ }
38
+
39
+ function makeDeps(opts: MakeDepsOptions = {}): {
40
+ deps: SyncConfigCloneDeps;
41
+ cloneCalls: CloneCall[];
42
+ removedDests: string[];
43
+ } {
44
+ const { scopedRepos = [], existingDests = [], cloneErrors = {} } = opts;
45
+ const cloneCalls: CloneCall[] = [];
46
+ const removedDests: string[] = [];
47
+
48
+ const deps: SyncConfigCloneDeps = {
49
+ getScopedRepos: () => scopedRepos,
50
+ reposDir: REPOS_DIR,
51
+ exists: (path: string) => existingDests.includes(path),
52
+ cloneRepo: async (repo: string, dest: string) => {
53
+ cloneCalls.push({ repo, dest });
54
+ if (opts.cloneGate) await opts.cloneGate;
55
+ const err = cloneErrors[dest];
56
+ if (err) throw err;
57
+ },
58
+ removePartialClone: (dest: string) => {
59
+ removedDests.push(dest);
60
+ if (opts.removeError) throw opts.removeError;
61
+ },
62
+ };
63
+
64
+ return { deps, cloneCalls, removedDests };
65
+ }
66
+
67
+ describe("syncClonedRepos", () => {
68
+ test("AC1: a newly-added repo not present on disk triggers a clone call with the correct destination path", async () => {
69
+ const { deps, cloneCalls } = makeDeps({
70
+ scopedRepos: ["app-vitals/shipwright"],
71
+ existingDests: [],
72
+ });
73
+
74
+ await syncClonedRepos(deps);
75
+
76
+ expect(cloneCalls).toEqual([
77
+ { repo: "app-vitals/shipwright", dest: join(REPOS_DIR, "shipwright") },
78
+ ]);
79
+ });
80
+
81
+ test("AC2: an already-present repo triggers no clone call", async () => {
82
+ const dest = join(REPOS_DIR, "shipwright");
83
+ const { deps, cloneCalls } = makeDeps({
84
+ scopedRepos: ["app-vitals/shipwright"],
85
+ existingDests: [dest],
86
+ });
87
+
88
+ await syncClonedRepos(deps);
89
+
90
+ expect(cloneCalls).toEqual([]);
91
+ });
92
+
93
+ test("AC3: a clone failure is caught and logged without throwing", async () => {
94
+ const dest = join(REPOS_DIR, "shipwright");
95
+ const { deps, cloneCalls } = makeDeps({
96
+ scopedRepos: ["app-vitals/shipwright"],
97
+ existingDests: [],
98
+ cloneErrors: {
99
+ [dest]: new Error("gh repo clone failed for app-vitals/shipwright"),
100
+ },
101
+ });
102
+
103
+ await expect(syncClonedRepos(deps)).resolves.toBeUndefined();
104
+
105
+ expect(cloneCalls).toEqual([{ repo: "app-vitals/shipwright", dest }]);
106
+ });
107
+
108
+ test("a mix of missing and present repos only clones the missing ones", async () => {
109
+ const presentDest = join(REPOS_DIR, "widget");
110
+ const { deps, cloneCalls } = makeDeps({
111
+ scopedRepos: ["app-vitals/shipwright", "app-vitals/widget"],
112
+ existingDests: [presentDest],
113
+ });
114
+
115
+ await syncClonedRepos(deps);
116
+
117
+ expect(cloneCalls).toEqual([
118
+ { repo: "app-vitals/shipwright", dest: join(REPOS_DIR, "shipwright") },
119
+ ]);
120
+ });
121
+
122
+ test("a failed clone does not block a subsequent missing repo from being cloned", async () => {
123
+ const brokenDest = join(REPOS_DIR, "broken-repo");
124
+ const fineDest = join(REPOS_DIR, "fine-repo");
125
+ const { deps, cloneCalls } = makeDeps({
126
+ scopedRepos: ["app-vitals/broken-repo", "app-vitals/fine-repo"],
127
+ existingDests: [],
128
+ cloneErrors: { [brokenDest]: new Error("auth failed") },
129
+ });
130
+
131
+ await expect(syncClonedRepos(deps)).resolves.toBeUndefined();
132
+
133
+ expect(cloneCalls).toEqual([
134
+ { repo: "app-vitals/broken-repo", dest: brokenDest },
135
+ { repo: "app-vitals/fine-repo", dest: fineDest },
136
+ ]);
137
+ });
138
+
139
+ test("empty scoped repos list triggers no clone calls and no exists checks", async () => {
140
+ let existsCalled = false;
141
+ const { deps, cloneCalls } = makeDeps({ scopedRepos: [] });
142
+ const wrappedDeps: SyncConfigCloneDeps = {
143
+ ...deps,
144
+ exists: (path: string) => {
145
+ existsCalled = true;
146
+ return deps.exists(path);
147
+ },
148
+ };
149
+
150
+ await syncClonedRepos(wrappedDeps);
151
+
152
+ expect(cloneCalls).toEqual([]);
153
+ expect(existsCalled).toBe(false);
154
+ });
155
+
156
+ test("a failed clone's partial destination directory is removed so the next tick retries it", async () => {
157
+ const dest = join(REPOS_DIR, "shipwright");
158
+ const { deps, removedDests } = makeDeps({
159
+ scopedRepos: ["app-vitals/shipwright"],
160
+ existingDests: [],
161
+ cloneErrors: { [dest]: new Error("gh repo clone failed") },
162
+ });
163
+
164
+ await syncClonedRepos(deps);
165
+
166
+ expect(removedDests).toEqual([dest]);
167
+ });
168
+
169
+ test("a successful clone leaves its destination directory alone", async () => {
170
+ const { deps, removedDests } = makeDeps({
171
+ scopedRepos: ["app-vitals/shipwright"],
172
+ existingDests: [],
173
+ });
174
+
175
+ await syncClonedRepos(deps);
176
+
177
+ expect(removedDests).toEqual([]);
178
+ });
179
+
180
+ test("a removePartialClone failure is caught and does not throw", async () => {
181
+ const dest = join(REPOS_DIR, "shipwright");
182
+ const { deps, removedDests } = makeDeps({
183
+ scopedRepos: ["app-vitals/shipwright"],
184
+ existingDests: [],
185
+ cloneErrors: { [dest]: new Error("gh repo clone failed") },
186
+ removeError: new Error("EACCES"),
187
+ });
188
+
189
+ await expect(syncClonedRepos(deps)).resolves.toBeUndefined();
190
+
191
+ expect(removedDests).toEqual([dest]);
192
+ });
193
+
194
+ test("an overlapping tick does not start a second clone of an in-flight destination", async () => {
195
+ // index.ts drives syncConfig() with a bare setInterval, which never waits
196
+ // for the previous tick — now that the clone's child process is spawned
197
+ // asynchronously, two ticks can genuinely overlap.
198
+ let release: () => void = () => {};
199
+ const gate = new Promise<void>((resolve) => {
200
+ release = resolve;
201
+ });
202
+ const { deps, cloneCalls } = makeDeps({
203
+ scopedRepos: ["app-vitals/shipwright"],
204
+ existingDests: [],
205
+ cloneGate: gate,
206
+ });
207
+
208
+ const first = syncClonedRepos(deps);
209
+ // Second tick fires while the first clone is still in flight — `exists()`
210
+ // still reports the destination as missing (gh hasn't created it yet).
211
+ const second = syncClonedRepos(deps);
212
+
213
+ release();
214
+ await Promise.all([first, second]);
215
+
216
+ expect(cloneCalls).toEqual([
217
+ { repo: "app-vitals/shipwright", dest: join(REPOS_DIR, "shipwright") },
218
+ ]);
219
+
220
+ // The in-flight guard releases afterwards, so a later tick clones again.
221
+ await syncClonedRepos(deps);
222
+ expect(cloneCalls).toHaveLength(2);
223
+ });
224
+ });
225
+
226
+ // ─── makeCloneRepo ──────────────────────────────────────────────────────────
227
+ //
228
+ // The clone's child process must be spawned ASYNCHRONOUSLY (Bun.spawn +
229
+ // await proc.exited), never with Bun.spawnSync: this code runs inside the
230
+ // long-lived process that also serves the liveness/readiness probes, and a
231
+ // synchronous clone would block that process's event loop for the whole
232
+ // duration of the clone. The injected spawner mirrors claude.ts's
233
+ // `spawner: typeof Bun.spawn = Bun.spawn` convention.
234
+
235
+ interface FakeSpawnCall {
236
+ cmd: string[];
237
+ opts: { stdout?: unknown; stderr?: unknown; env?: unknown };
238
+ }
239
+
240
+ function fakeSpawner(exitCode: number | Promise<number>): {
241
+ spawn: typeof Bun.spawn;
242
+ calls: FakeSpawnCall[];
243
+ } {
244
+ const calls: FakeSpawnCall[] = [];
245
+ const spawn = ((cmd: string[], opts: FakeSpawnCall["opts"]) => {
246
+ calls.push({ cmd, opts });
247
+ return {
248
+ exited:
249
+ typeof exitCode === "number" ? Promise.resolve(exitCode) : exitCode,
250
+ };
251
+ }) as unknown as typeof Bun.spawn;
252
+ return { spawn, calls };
253
+ }
254
+
255
+ describe("makeCloneRepo", () => {
256
+ test("spawns `gh repo clone <repo> <dest>` with inherited stdio and live process.env", async () => {
257
+ const { spawn, calls } = fakeSpawner(0);
258
+
259
+ await makeCloneRepo(spawn)(
260
+ "app-vitals/shipwright",
261
+ join(REPOS_DIR, "shipwright"),
262
+ );
263
+
264
+ expect(calls).toHaveLength(1);
265
+ expect(calls[0].cmd).toEqual([
266
+ "gh",
267
+ "repo",
268
+ "clone",
269
+ "app-vitals/shipwright",
270
+ join(REPOS_DIR, "shipwright"),
271
+ ]);
272
+ expect(calls[0].opts.stdout).toBe("inherit");
273
+ expect(calls[0].opts.stderr).toBe("inherit");
274
+ // Live process.env — not Bun's startup snapshot — so a GH_TOKEN synced by
275
+ // this very tick reaches `gh`; plus the GOR-1.3 owner hint.
276
+ expect(calls[0].opts.env).toEqual({
277
+ ...process.env,
278
+ SHIPWRIGHT_GH_OWNER: "app-vitals",
279
+ });
280
+ });
281
+
282
+ test("an ambient SHIPWRIGHT_GH_OWNER is overridden by the repo's org", async () => {
283
+ const saved = process.env.SHIPWRIGHT_GH_OWNER;
284
+ process.env.SHIPWRIGHT_GH_OWNER = "ambient";
285
+ try {
286
+ const { spawn, calls } = fakeSpawner(0);
287
+ await makeCloneRepo(spawn)("acme/x", join(REPOS_DIR, "x"));
288
+ expect(
289
+ (calls[0].opts.env as Record<string, string>).SHIPWRIGHT_GH_OWNER,
290
+ ).toBe("acme");
291
+ } finally {
292
+ if (saved === undefined) delete process.env.SHIPWRIGHT_GH_OWNER;
293
+ else process.env.SHIPWRIGHT_GH_OWNER = saved;
294
+ }
295
+ });
296
+
297
+ test("syncClonedRepos with an injected spawn passes each repo's own org (GOR-1.3)", async () => {
298
+ const { spawn, calls } = fakeSpawner(0);
299
+ const { deps } = makeDeps({ scopedRepos: ["org-a/one", "org-b/two"] });
300
+ await syncClonedRepos({ ...deps, cloneRepo: makeCloneRepo(spawn) });
301
+
302
+ expect(
303
+ calls.map((c) => [
304
+ c.cmd[3],
305
+ (c.opts.env as Record<string, string>).SHIPWRIGHT_GH_OWNER,
306
+ ]),
307
+ ).toEqual([
308
+ ["org-a/one", "org-a"],
309
+ ["org-b/two", "org-b"],
310
+ ]);
311
+ });
312
+
313
+ test("throws with the exit code when `gh repo clone` fails", async () => {
314
+ const { spawn } = fakeSpawner(1);
315
+
316
+ await expect(
317
+ makeCloneRepo(spawn)("app-vitals/shipwright", join(REPOS_DIR, "x")),
318
+ ).rejects.toThrow(
319
+ "gh repo clone failed for app-vitals/shipwright (exit 1)",
320
+ );
321
+ });
322
+
323
+ test("awaits the child process asynchronously rather than blocking", async () => {
324
+ let release: (code: number) => void = () => {};
325
+ const exited = new Promise<number>((resolve) => {
326
+ release = resolve;
327
+ });
328
+ const { spawn } = fakeSpawner(exited);
329
+
330
+ let settled = false;
331
+ const clonePromise = makeCloneRepo(spawn)(
332
+ "app-vitals/shipwright",
333
+ join(REPOS_DIR, "shipwright"),
334
+ ).then(() => {
335
+ settled = true;
336
+ });
337
+
338
+ // Control returned to the event loop while the child is still running —
339
+ // the proof that this is not a blocking spawnSync.
340
+ await Promise.resolve();
341
+ expect(settled).toBe(false);
342
+
343
+ release(0);
344
+ await clonePromise;
345
+ expect(settled).toBe(true);
346
+ });
347
+ });
348
+
349
+ describe("syncClonedRepos name collisions (MGI-3.3)", () => {
350
+ test("skips the later colliding repo and logs an error naming both", async () => {
351
+ const { deps, cloneCalls } = makeDeps({
352
+ scopedRepos: ["acme/api", "globex/api", "acme/web"],
353
+ });
354
+ const errors: string[] = [];
355
+ const orig = console.error;
356
+ console.error = (...a: unknown[]) => void errors.push(a.join(" "));
357
+ try {
358
+ await syncClonedRepos(deps);
359
+ } finally {
360
+ console.error = orig;
361
+ }
362
+
363
+ expect(cloneCalls.map((c) => c.repo)).toEqual(["acme/api", "acme/web"]);
364
+ expect(
365
+ errors.some((e) => e.includes("globex/api") && e.includes("acme/api")),
366
+ ).toBe(true);
367
+ });
368
+
369
+ test("an existing folder whose remote owner differs is skipped and logged", async () => {
370
+ const dest = join(REPOS_DIR, "api");
371
+ const { deps, cloneCalls } = makeDeps({
372
+ scopedRepos: ["acme/api"],
373
+ existingDests: [dest],
374
+ });
375
+ deps.getRemoteOwner = () => "globex";
376
+ const errors: string[] = [];
377
+ const orig = console.error;
378
+ console.error = (...a: unknown[]) => void errors.push(a.join(" "));
379
+ try {
380
+ await syncClonedRepos(deps);
381
+ } finally {
382
+ console.error = orig;
383
+ }
384
+
385
+ expect(cloneCalls).toEqual([]);
386
+ expect(errors.some((e) => e.includes("globex") && e.includes("acme"))).toBe(
387
+ true,
388
+ );
389
+ });
390
+
391
+ test("an existing folder with a matching owner (case-insensitive) is not a collision", async () => {
392
+ const dest = join(REPOS_DIR, "api");
393
+ const { deps } = makeDeps({
394
+ scopedRepos: ["Acme/api"],
395
+ existingDests: [dest],
396
+ });
397
+ deps.getRemoteOwner = () => "acme";
398
+ const errors: string[] = [];
399
+ const orig = console.error;
400
+ console.error = (...a: unknown[]) => void errors.push(a.join(" "));
401
+ try {
402
+ await syncClonedRepos(deps);
403
+ } finally {
404
+ console.error = orig;
405
+ }
406
+ expect(errors).toEqual([]);
407
+ });
408
+ });
409
+
410
+ describe("readRemoteOwner", () => {
411
+ const fake = (code: number, out: string) =>
412
+ (() => ({
413
+ exitCode: code,
414
+ stdout: Buffer.from(out),
415
+ })) as unknown as typeof Bun.spawnSync;
416
+
417
+ test("parses https and ssh origin URLs", () => {
418
+ expect(
419
+ readRemoteOwner("/x", fake(0, "https://github.com/acme/api.git\n")),
420
+ ).toBe("acme");
421
+ expect(
422
+ readRemoteOwner("/x", fake(0, "git@github.com:acme/api.git\n")),
423
+ ).toBe("acme");
424
+ });
425
+
426
+ test("returns null when git fails", () => {
427
+ expect(readRemoteOwner("/x", fake(128, ""))).toBeNull();
428
+ });
429
+ });
package/test-env.ts ADDED
@@ -0,0 +1,27 @@
1
+ // Import FIRST in test files — sets env vars before claude.ts reads process.env.ANTHROPIC_MODEL at module load time.
2
+
3
+ import { mkdirSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ export const TEST_AGENT_HOME = join(
8
+ tmpdir(),
9
+ `shipwright-agent-test-${process.pid}`,
10
+ );
11
+
12
+ mkdirSync(join(TEST_AGENT_HOME, "workspace"), { recursive: true });
13
+ writeFileSync(
14
+ join(TEST_AGENT_HOME, "workspace", "CLAUDE.md"),
15
+ "# Shipwright Agent\n",
16
+ );
17
+
18
+ process.env.AGENT_HOME = TEST_AGENT_HOME;
19
+ process.env.SLACK_BOT_TOKEN = "xoxb-test-token";
20
+ process.env.SLACK_APP_TOKEN = "xapp-test-token";
21
+ process.env.SLACK_SIGNING_SECRET = "test-signing-secret";
22
+ process.env.ANTHROPIC_MODEL = "claude-opus-4-6";
23
+ process.env.SLACK_ALERT_CHANNEL = "#alerts";
24
+ process.env.SLACK_OWNER_USER = "U12345";
25
+ process.env.SHIPWRIGHT_API_URL = "https://api.shipwright.app";
26
+ process.env.SHIPWRIGHT_AGENT_API_KEY = "key-123";
27
+ process.env.SHIPWRIGHT_AGENT_ID = "agent-test-123";
package/users.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Generic user display name / email resolver with in-memory cache.
3
+ *
4
+ * Uses a structural UserResolverClient interface instead of importing
5
+ * @slack/web-api directly — keeping the agent package Slack-agnostic.
6
+ *
7
+ * A single users.info() call is cached per user id and used to serve both
8
+ * the display name and the email, avoiding a second API call when a caller
9
+ * needs both.
10
+ */
11
+
12
+ interface ResolvedUser {
13
+ name: string;
14
+ email?: string;
15
+ }
16
+
17
+ // In-memory cache — survives for the lifetime of the process.
18
+ // Export clearCache() for test teardown to prevent cross-test leakage
19
+ // when Bun shares the module between test files.
20
+ const cache = new Map<string, ResolvedUser>();
21
+
22
+ /** Clear the in-memory display name / email cache. Use in test afterEach teardown. */
23
+ export function clearCache(): void {
24
+ cache.clear();
25
+ }
26
+
27
+ interface UserResolverClient {
28
+ users: {
29
+ info(args: { user: string }): Promise<{
30
+ user?: {
31
+ profile?: {
32
+ display_name?: string;
33
+ real_name?: string;
34
+ email?: string;
35
+ };
36
+ name?: string;
37
+ };
38
+ }>;
39
+ };
40
+ }
41
+
42
+ async function resolveUser(
43
+ userId: string,
44
+ client: UserResolverClient,
45
+ ): Promise<ResolvedUser> {
46
+ const cached = cache.get(userId);
47
+ if (cached !== undefined) return cached;
48
+
49
+ try {
50
+ const res = await client.users.info({ user: userId });
51
+ const resolved: ResolvedUser = {
52
+ name:
53
+ res.user?.profile?.display_name ||
54
+ res.user?.profile?.real_name ||
55
+ res.user?.name ||
56
+ userId,
57
+ email: res.user?.profile?.email || undefined,
58
+ };
59
+ cache.set(userId, resolved);
60
+ return resolved;
61
+ } catch (err) {
62
+ console.warn(
63
+ `[users] failed to resolve user ${userId}:`,
64
+ err instanceof Error ? err.message : String(err),
65
+ );
66
+ return { name: userId, email: undefined };
67
+ }
68
+ }
69
+
70
+ export async function resolveDisplayName(
71
+ userId: string,
72
+ client: UserResolverClient,
73
+ ): Promise<string> {
74
+ const { name } = await resolveUser(userId, client);
75
+ return name;
76
+ }
77
+
78
+ /**
79
+ * Resolve the Slack user's email from the same cached users.info() lookup
80
+ * used by resolveDisplayName. Returns undefined if the email is absent —
81
+ * e.g. the users:read.email scope hasn't been granted yet, or the user
82
+ * genuinely has no email on file — never throws.
83
+ */
84
+ export async function resolveUserEmail(
85
+ userId: string,
86
+ client: UserResolverClient,
87
+ ): Promise<string | undefined> {
88
+ const { email } = await resolveUser(userId, client);
89
+ return email;
90
+ }
@@ -0,0 +1,108 @@
1
+ import { afterEach, describe, expect, it, mock } from "bun:test";
2
+ import { clearCache, resolveDisplayName, resolveUserEmail } from "./users.ts";
3
+
4
+ const makeClient = (profile: Record<string, string | undefined>) => ({
5
+ users: {
6
+ info: mock(async () => ({ user: { profile, name: "fallback" } })),
7
+ },
8
+ });
9
+
10
+ describe("resolveDisplayName", () => {
11
+ afterEach(() => {
12
+ clearCache();
13
+ });
14
+
15
+ it("returns display_name when available", async () => {
16
+ const client = makeClient({
17
+ display_name: "Dan",
18
+ real_name: "Dan McAulay",
19
+ });
20
+ expect(await resolveDisplayName("U_DN1", client as never)).toBe("Dan");
21
+ });
22
+
23
+ it("falls back to real_name when display_name is empty", async () => {
24
+ const client = makeClient({ display_name: "", real_name: "Dan McAulay" });
25
+ expect(await resolveDisplayName("U_RN1", client as never)).toBe(
26
+ "Dan McAulay",
27
+ );
28
+ });
29
+
30
+ it("falls back to name when display_name and real_name are empty", async () => {
31
+ const client = makeClient({ display_name: "", real_name: "" });
32
+ expect(await resolveDisplayName("U_FB1", client as never)).toBe("fallback");
33
+ });
34
+
35
+ it("falls back to userId when Slack call throws", async () => {
36
+ const badClient = {
37
+ users: {
38
+ info: mock(async () => {
39
+ throw new Error("missing_scope");
40
+ }),
41
+ },
42
+ };
43
+ expect(await resolveDisplayName("U_ERR", badClient as never)).toBe("U_ERR");
44
+ });
45
+
46
+ it("caches the result — client is called only once for repeated lookups", async () => {
47
+ const client = makeClient({ display_name: "Cached" });
48
+ await resolveDisplayName("U_CACHE", client as never);
49
+ await resolveDisplayName("U_CACHE", client as never);
50
+ expect(client.users.info).toHaveBeenCalledTimes(1);
51
+ });
52
+
53
+ it("clearCache() allows re-fetching after reset", async () => {
54
+ const client = makeClient({ display_name: "Fresh" });
55
+ await resolveDisplayName("U_CLR", client as never);
56
+ clearCache();
57
+ await resolveDisplayName("U_CLR", client as never);
58
+ expect(client.users.info).toHaveBeenCalledTimes(2);
59
+ });
60
+ });
61
+
62
+ describe("resolveUserEmail", () => {
63
+ afterEach(() => {
64
+ clearCache();
65
+ });
66
+
67
+ it("returns profile.email when present", async () => {
68
+ const client = makeClient({
69
+ display_name: "Dan",
70
+ email: "dan@example.com",
71
+ });
72
+ expect(await resolveUserEmail("U_EMAIL1", client as never)).toBe(
73
+ "dan@example.com",
74
+ );
75
+ });
76
+
77
+ it("returns undefined when profile.email is absent (missing scope)", async () => {
78
+ const client = makeClient({ display_name: "Dan" });
79
+ expect(
80
+ await resolveUserEmail("U_NOEMAIL", client as never),
81
+ ).toBeUndefined();
82
+ });
83
+
84
+ it("returns undefined when Slack call throws", async () => {
85
+ const badClient = {
86
+ users: {
87
+ info: mock(async () => {
88
+ throw new Error("missing_scope");
89
+ }),
90
+ },
91
+ };
92
+ expect(
93
+ await resolveUserEmail("U_ERR2", badClient as never),
94
+ ).toBeUndefined();
95
+ });
96
+
97
+ it("shares the cache with resolveDisplayName — only one API call for both name and email", async () => {
98
+ const client = makeClient({
99
+ display_name: "Dan",
100
+ email: "dan@example.com",
101
+ });
102
+ const name = await resolveDisplayName("U_SHARED", client as never);
103
+ const email = await resolveUserEmail("U_SHARED", client as never);
104
+ expect(name).toBe("Dan");
105
+ expect(email).toBe("dan@example.com");
106
+ expect(client.users.info).toHaveBeenCalledTimes(1);
107
+ });
108
+ });