@hellcoder/companion 0.96.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 (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,379 @@
1
+ import { execFileSync, execSync } from "node:child_process";
2
+
3
+ // ─── Types ───────────────────────────────────────────────────────────────────
4
+
5
+ export interface GitHubCheckStatus {
6
+ name: string;
7
+ status: string;
8
+ conclusion: string | null;
9
+ }
10
+
11
+ export interface GitHubPRInfo {
12
+ number: number;
13
+ title: string;
14
+ url: string;
15
+ state: "OPEN" | "CLOSED" | "MERGED";
16
+ isDraft: boolean;
17
+ reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
18
+ additions: number;
19
+ deletions: number;
20
+ changedFiles: number;
21
+ checks: GitHubCheckStatus[];
22
+ checksSummary: {
23
+ total: number;
24
+ success: number;
25
+ failure: number;
26
+ pending: number;
27
+ };
28
+ reviewThreads: {
29
+ total: number;
30
+ resolved: number;
31
+ unresolved: number;
32
+ };
33
+ }
34
+
35
+ export interface PRStatusResponse {
36
+ available: boolean;
37
+ pr: GitHubPRInfo | null;
38
+ }
39
+
40
+ // ─── gh CLI Detection ────────────────────────────────────────────────────────
41
+
42
+ let _ghAvailable: boolean | null = null;
43
+
44
+ export function isGhAvailable(): boolean {
45
+ if (_ghAvailable !== null) return _ghAvailable;
46
+ try {
47
+ execSync("which gh", { stdio: "pipe", timeout: 5_000 });
48
+ _ghAvailable = true;
49
+ } catch {
50
+ _ghAvailable = false;
51
+ }
52
+ return _ghAvailable;
53
+ }
54
+
55
+ // Exported for testing
56
+ export function _resetGhAvailable() {
57
+ _ghAvailable = null;
58
+ }
59
+
60
+ // ─── Repo Slug Resolution ────────────────────────────────────────────────────
61
+
62
+ const repoSlugCache = new Map<string, { slug: string | null; timestamp: number }>();
63
+ const REPO_SLUG_TTL = 5 * 60_000; // 5 minutes
64
+
65
+ function getRepoSlug(cwd: string): string | null {
66
+ const cached = repoSlugCache.get(cwd);
67
+ if (cached && Date.now() - cached.timestamp < REPO_SLUG_TTL) {
68
+ return cached.slug;
69
+ }
70
+ try {
71
+ const slug = execSync("gh repo view --json nameWithOwner --jq .nameWithOwner", {
72
+ cwd,
73
+ stdio: "pipe",
74
+ timeout: 10_000,
75
+ })
76
+ .toString()
77
+ .trim();
78
+ const result = slug || null;
79
+ repoSlugCache.set(cwd, { slug: result, timestamp: Date.now() });
80
+ return result;
81
+ } catch {
82
+ repoSlugCache.set(cwd, { slug: null, timestamp: Date.now() });
83
+ return null;
84
+ }
85
+ }
86
+
87
+ async function getRepoSlugAsync(cwd: string): Promise<string | null> {
88
+ const cached = repoSlugCache.get(cwd);
89
+ if (cached && Date.now() - cached.timestamp < REPO_SLUG_TTL) {
90
+ return cached.slug;
91
+ }
92
+ try {
93
+ const proc = Bun.spawn(
94
+ ["gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
95
+ { cwd, stdout: "pipe", stderr: "pipe" },
96
+ );
97
+ const timeout = setTimeout(() => proc.kill(), 10_000);
98
+ const exitCode = await proc.exited;
99
+ clearTimeout(timeout);
100
+ if (exitCode !== 0) {
101
+ repoSlugCache.set(cwd, { slug: null, timestamp: Date.now() });
102
+ return null;
103
+ }
104
+ const slug = (await new Response(proc.stdout).text()).trim();
105
+ const result = slug || null;
106
+ repoSlugCache.set(cwd, { slug: result, timestamp: Date.now() });
107
+ return result;
108
+ } catch {
109
+ repoSlugCache.set(cwd, { slug: null, timestamp: Date.now() });
110
+ return null;
111
+ }
112
+ }
113
+
114
+ // ─── PR Data Cache ───────────────────────────────────────────────────────────
115
+
116
+ const prCache = new Map<string, { data: GitHubPRInfo | null; timestamp: number; ttl: number }>();
117
+ const PR_CACHE_TTL = 30_000; // 30 seconds (default / legacy)
118
+
119
+ // Exported for testing
120
+ export function _clearCaches() {
121
+ prCache.clear();
122
+ repoSlugCache.clear();
123
+ _ghAvailable = null;
124
+ }
125
+
126
+ // ─── Adaptive TTL ───────────────────────────────────────────────────────────
127
+
128
+ /** Compute polling interval based on PR state. */
129
+ export function computeAdaptiveTTL(pr: GitHubPRInfo | null): number {
130
+ if (!pr) return 60_000; // No PR found — check again in 60s
131
+
132
+ // Merged or closed — terminal state, rarely changes
133
+ if (pr.state === "MERGED" || pr.state === "CLOSED") return 300_000; // 5 minutes
134
+
135
+ // CI actively running (pending checks) — user is watching
136
+ if (pr.checksSummary.pending > 0) return 10_000; // 10 seconds
137
+
138
+ // CI failed — user likely pushing fixes
139
+ if (pr.checksSummary.failure > 0) return 30_000; // 30 seconds
140
+
141
+ // Changes requested — moderate frequency
142
+ if (pr.reviewDecision === "CHANGES_REQUESTED") return 30_000; // 30 seconds
143
+
144
+ // Approved, all checks passed — stable
145
+ if (pr.reviewDecision === "APPROVED" && pr.checksSummary.pending === 0) return 120_000; // 2 minutes
146
+
147
+ // Review pending, checks passed — waiting on human reviewer
148
+ if (pr.reviewDecision === "REVIEW_REQUIRED" || pr.reviewDecision === null) return 45_000; // 45 seconds
149
+
150
+ // Default fallback
151
+ return 30_000;
152
+ }
153
+
154
+ // ─── GraphQL Query ───────────────────────────────────────────────────────────
155
+
156
+ const PR_QUERY = `
157
+ query($owner: String!, $name: String!, $branch: String!) {
158
+ repository(owner: $owner, name: $name) {
159
+ pullRequests(headRefName: $branch, first: 5, orderBy: {field: UPDATED_AT, direction: DESC}, states: [OPEN, MERGED]) {
160
+ nodes {
161
+ number
162
+ title
163
+ url
164
+ state
165
+ isDraft
166
+ isCrossRepository
167
+ reviewDecision
168
+ additions
169
+ deletions
170
+ changedFiles
171
+ reviewThreads(first: 100) {
172
+ totalCount
173
+ nodes {
174
+ isResolved
175
+ }
176
+ }
177
+ commits(last: 1) {
178
+ nodes {
179
+ commit {
180
+ statusCheckRollup {
181
+ contexts(first: 50) {
182
+ nodes {
183
+ __typename
184
+ ... on CheckRun {
185
+ name
186
+ status
187
+ conclusion
188
+ }
189
+ ... on StatusContext {
190
+ context
191
+ state
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }
197
+ }
198
+ }
199
+ }
200
+ }
201
+ }
202
+ }`;
203
+
204
+ // ─── Response Parsing ────────────────────────────────────────────────────────
205
+
206
+ interface GraphQLCheckRunNode {
207
+ __typename: "CheckRun";
208
+ name: string;
209
+ status: string;
210
+ conclusion: string | null;
211
+ }
212
+
213
+ interface GraphQLStatusContextNode {
214
+ __typename: "StatusContext";
215
+ context: string;
216
+ state: string;
217
+ }
218
+
219
+ type GraphQLContextNode = GraphQLCheckRunNode | GraphQLStatusContextNode;
220
+
221
+ export function parseGraphQLResponse(data: unknown): GitHubPRInfo | null {
222
+ try {
223
+ const repo = (data as any)?.data?.repository;
224
+ const nodes = repo?.pullRequests?.nodes;
225
+ if (!nodes || nodes.length === 0) return null;
226
+
227
+ // Filter out cross-repository (fork) PRs — we only want same-repo PRs
228
+ const sameRepoPRs = nodes.filter((n: any) => !n.isCrossRepository);
229
+ if (sameRepoPRs.length === 0) return null;
230
+
231
+ const pr = sameRepoPRs[0];
232
+
233
+ // Normalize checks
234
+ const rawContexts: GraphQLContextNode[] =
235
+ pr.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts?.nodes ?? [];
236
+
237
+ const checks: GitHubCheckStatus[] = rawContexts.map((node) => {
238
+ if (node.__typename === "CheckRun") {
239
+ return {
240
+ name: node.name,
241
+ status: node.status,
242
+ conclusion: node.conclusion,
243
+ };
244
+ }
245
+ // StatusContext
246
+ return {
247
+ name: node.context,
248
+ status: node.state === "PENDING" ? "IN_PROGRESS" : "COMPLETED",
249
+ conclusion: node.state === "SUCCESS" ? "SUCCESS" : (node.state === "FAILURE" || node.state === "ERROR") ? "FAILURE" : null,
250
+ };
251
+ });
252
+
253
+ // Compute summary
254
+ let success = 0;
255
+ let failure = 0;
256
+ let pending = 0;
257
+ for (const check of checks) {
258
+ if (check.conclusion === "SUCCESS" || check.conclusion === "NEUTRAL" || check.conclusion === "SKIPPED") {
259
+ success++;
260
+ } else if (check.conclusion === "FAILURE" || check.conclusion === "CANCELLED" || check.conclusion === "TIMED_OUT") {
261
+ failure++;
262
+ } else {
263
+ pending++;
264
+ }
265
+ }
266
+
267
+ // Compute review threads
268
+ const threadNodes: { isResolved: boolean }[] = pr.reviewThreads?.nodes ?? [];
269
+ const resolved = threadNodes.filter((t) => t.isResolved).length;
270
+ const unresolved = threadNodes.filter((t) => !t.isResolved).length;
271
+
272
+ return {
273
+ number: pr.number,
274
+ title: pr.title,
275
+ url: pr.url,
276
+ state: pr.state,
277
+ isDraft: pr.isDraft ?? false,
278
+ reviewDecision: pr.reviewDecision || null,
279
+ additions: pr.additions ?? 0,
280
+ deletions: pr.deletions ?? 0,
281
+ changedFiles: pr.changedFiles ?? 0,
282
+ checks,
283
+ checksSummary: { total: checks.length, success, failure, pending },
284
+ reviewThreads: {
285
+ total: pr.reviewThreads?.totalCount ?? 0,
286
+ resolved,
287
+ unresolved,
288
+ },
289
+ };
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+
295
+ // ─── Main Fetch Function (sync — legacy, used by tests) ─────────────────────
296
+
297
+ export async function fetchPRInfo(cwd: string, branch: string): Promise<GitHubPRInfo | null> {
298
+ if (!isGhAvailable()) return null;
299
+
300
+ const cacheKey = `${cwd}:${branch}`;
301
+ const cached = prCache.get(cacheKey);
302
+ if (cached && Date.now() - cached.timestamp < cached.ttl) {
303
+ return cached.data;
304
+ }
305
+
306
+ const slug = getRepoSlug(cwd);
307
+ if (!slug) {
308
+ prCache.set(cacheKey, { data: null, timestamp: Date.now(), ttl: PR_CACHE_TTL });
309
+ return null;
310
+ }
311
+
312
+ const [owner, name] = slug.split("/");
313
+ if (!owner || !name) return null;
314
+
315
+ try {
316
+ const result = execFileSync(
317
+ "gh",
318
+ ["api", "graphql", "-f", `query=${PR_QUERY}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-f", `branch=${branch}`],
319
+ { cwd, stdio: "pipe", timeout: 15_000 },
320
+ )
321
+ .toString()
322
+ .trim();
323
+
324
+ const parsed = JSON.parse(result);
325
+ const prInfo = parseGraphQLResponse(parsed);
326
+ const ttl = computeAdaptiveTTL(prInfo);
327
+ prCache.set(cacheKey, { data: prInfo, timestamp: Date.now(), ttl });
328
+ return prInfo;
329
+ } catch {
330
+ prCache.set(cacheKey, { data: null, timestamp: Date.now(), ttl: PR_CACHE_TTL });
331
+ return null;
332
+ }
333
+ }
334
+
335
+ // ─── Async Fetch Function (non-blocking, uses Bun.spawn) ────────────────────
336
+
337
+ export async function fetchPRInfoAsync(cwd: string, branch: string): Promise<GitHubPRInfo | null> {
338
+ if (!isGhAvailable()) return null;
339
+
340
+ const cacheKey = `${cwd}:${branch}`;
341
+ const cached = prCache.get(cacheKey);
342
+ if (cached && Date.now() - cached.timestamp < cached.ttl) {
343
+ return cached.data;
344
+ }
345
+
346
+ const slug = await getRepoSlugAsync(cwd);
347
+ if (!slug) {
348
+ prCache.set(cacheKey, { data: null, timestamp: Date.now(), ttl: 60_000 });
349
+ return null;
350
+ }
351
+
352
+ const [owner, name] = slug.split("/");
353
+ if (!owner || !name) return null;
354
+
355
+ try {
356
+ const proc = Bun.spawn(
357
+ ["gh", "api", "graphql", "-f", `query=${PR_QUERY}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-f", `branch=${branch}`],
358
+ { cwd, stdout: "pipe", stderr: "pipe" },
359
+ );
360
+ const timeout = setTimeout(() => proc.kill(), 15_000);
361
+ const exitCode = await proc.exited;
362
+ clearTimeout(timeout);
363
+
364
+ if (exitCode !== 0) {
365
+ prCache.set(cacheKey, { data: null, timestamp: Date.now(), ttl: PR_CACHE_TTL });
366
+ return null;
367
+ }
368
+
369
+ const stdout = (await new Response(proc.stdout).text()).trim();
370
+ const parsed = JSON.parse(stdout);
371
+ const prInfo = parseGraphQLResponse(parsed);
372
+ const ttl = computeAdaptiveTTL(prInfo);
373
+ prCache.set(cacheKey, { data: prInfo, timestamp: Date.now(), ttl });
374
+ return prInfo;
375
+ } catch {
376
+ prCache.set(cacheKey, { data: null, timestamp: Date.now(), ttl: PR_CACHE_TTL });
377
+ return null;
378
+ }
379
+ }
@@ -0,0 +1,303 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Mock container-manager
4
+ const mockImageExists = vi.hoisted(() => vi.fn((_image: string) => false));
5
+ const mockPullImage = vi.hoisted(() => vi.fn(async (_remote: string, _local: string, _onProgress?: (line: string) => void) => true));
6
+ const mockBuildImage = vi.hoisted(() => vi.fn((_path: string, _tag?: string) => "ok"));
7
+ const mockGetRegistryImage = vi.hoisted(() => vi.fn((tag: string) => {
8
+ if (tag === "the-companion:latest") return "docker.io/stangirard/the-companion:latest";
9
+ return null as string | null;
10
+ }));
11
+
12
+ vi.mock("./container-manager.js", () => ({
13
+ containerManager: {
14
+ imageExists: mockImageExists,
15
+ pullImage: mockPullImage,
16
+ buildImage: mockBuildImage,
17
+ },
18
+ ContainerManager: {
19
+ getRegistryImage: mockGetRegistryImage,
20
+ },
21
+ }));
22
+
23
+ // Mock env-manager
24
+ const mockListEnvs = vi.hoisted(() => vi.fn(() => [] as Array<{ name: string; slug: string; baseImage?: string; imageTag?: string; variables: Record<string, string>; createdAt: number; updatedAt: number }>));
25
+ vi.mock("./env-manager.js", () => ({
26
+ listEnvs: mockListEnvs,
27
+ }));
28
+
29
+ // Mock existsSync for Dockerfile fallback
30
+ const mockExistsSync = vi.hoisted(() => vi.fn(() => true));
31
+ vi.mock("node:fs", async (importOriginal) => {
32
+ const actual = (await importOriginal()) as Record<string, unknown>;
33
+ return { ...actual, existsSync: mockExistsSync };
34
+ });
35
+
36
+ // We need to re-import for each test to get a fresh singleton.
37
+ // Instead we use a factory approach.
38
+ async function createManager() {
39
+ // Clear module cache so we get a fresh singleton
40
+ vi.resetModules();
41
+ const mod = await import("./image-pull-manager.js");
42
+ return mod.imagePullManager;
43
+ }
44
+
45
+ describe("ImagePullManager", () => {
46
+ beforeEach(() => {
47
+ vi.clearAllMocks();
48
+ mockImageExists.mockReturnValue(false);
49
+ mockPullImage.mockResolvedValue(true);
50
+ mockBuildImage.mockReturnValue("ok");
51
+ mockExistsSync.mockReturnValue(true);
52
+ // Restore default registry mapping (clearAllMocks removes it)
53
+ mockGetRegistryImage.mockImplementation((tag: string) => {
54
+ if (tag === "the-companion:latest") return "docker.io/stangirard/the-companion:latest";
55
+ return null as string | null;
56
+ });
57
+ });
58
+
59
+ describe("getState", () => {
60
+ it("returns 'ready' when image exists locally", async () => {
61
+ mockImageExists.mockReturnValue(true);
62
+ const manager = await createManager();
63
+ const state = manager.getState("the-companion:latest");
64
+ expect(state.status).toBe("ready");
65
+ expect(state.image).toBe("the-companion:latest");
66
+ });
67
+
68
+ it("returns 'idle' when image does not exist locally", async () => {
69
+ mockImageExists.mockReturnValue(false);
70
+ const manager = await createManager();
71
+ const state = manager.getState("the-companion:latest");
72
+ expect(state.status).toBe("idle");
73
+ });
74
+ });
75
+
76
+ describe("isReady", () => {
77
+ it("returns true when image exists locally", async () => {
78
+ mockImageExists.mockReturnValue(true);
79
+ const manager = await createManager();
80
+ expect(manager.isReady("the-companion:latest")).toBe(true);
81
+ });
82
+
83
+ it("returns false when image does not exist locally", async () => {
84
+ mockImageExists.mockReturnValue(false);
85
+ const manager = await createManager();
86
+ expect(manager.isReady("the-companion:latest")).toBe(false);
87
+ });
88
+ });
89
+
90
+ describe("ensureImage", () => {
91
+ it("starts a background pull when image is missing", async () => {
92
+ mockImageExists.mockReturnValue(false);
93
+ const manager = await createManager();
94
+
95
+ manager.ensureImage("the-companion:latest");
96
+
97
+ const state = manager.getState("the-companion:latest");
98
+ expect(state.status).toBe("pulling");
99
+ expect(state.startedAt).toBeGreaterThan(0);
100
+
101
+ // Wait for the async pull to complete
102
+ await vi.waitFor(() => {
103
+ expect(manager.getState("the-companion:latest").status).toBe("ready");
104
+ });
105
+ expect(mockPullImage).toHaveBeenCalledOnce();
106
+ });
107
+
108
+ it("is a no-op when image is already ready", async () => {
109
+ mockImageExists.mockReturnValue(true);
110
+ const manager = await createManager();
111
+
112
+ manager.ensureImage("the-companion:latest");
113
+
114
+ expect(mockPullImage).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it("is a no-op when image is already being pulled", async () => {
118
+ mockImageExists.mockReturnValue(false);
119
+ // Make pull hang
120
+ mockPullImage.mockImplementation(() => new Promise(() => {}));
121
+ const manager = await createManager();
122
+
123
+ manager.ensureImage("the-companion:latest");
124
+ manager.ensureImage("the-companion:latest"); // second call
125
+
126
+ expect(mockPullImage).toHaveBeenCalledOnce();
127
+ });
128
+ });
129
+
130
+ describe("waitForReady", () => {
131
+ it("resolves immediately when image already exists", async () => {
132
+ mockImageExists.mockReturnValue(true);
133
+ const manager = await createManager();
134
+
135
+ const result = await manager.waitForReady("the-companion:latest");
136
+ expect(result).toBe(true);
137
+ });
138
+
139
+ it("waits for an in-progress pull to complete", async () => {
140
+ mockImageExists.mockReturnValue(false);
141
+ const manager = await createManager();
142
+
143
+ manager.ensureImage("the-companion:latest");
144
+ const result = await manager.waitForReady("the-companion:latest", 5000);
145
+ expect(result).toBe(true);
146
+ });
147
+
148
+ it("returns false when pull fails", async () => {
149
+ mockImageExists.mockReturnValue(false);
150
+ mockPullImage.mockResolvedValue(false);
151
+ mockExistsSync.mockReturnValue(false); // no Dockerfile fallback
152
+ const manager = await createManager();
153
+
154
+ const result = await manager.waitForReady("the-companion:latest", 5000);
155
+ expect(result).toBe(false);
156
+ });
157
+
158
+ it("starts a pull if image is idle and not present", async () => {
159
+ mockImageExists.mockReturnValue(false);
160
+ const manager = await createManager();
161
+
162
+ // Calling waitForReady on an idle image should trigger a pull
163
+ const result = await manager.waitForReady("the-companion:latest", 5000);
164
+ expect(result).toBe(true);
165
+ expect(mockPullImage).toHaveBeenCalledOnce();
166
+ });
167
+
168
+ it("times out when pull takes too long", async () => {
169
+ mockImageExists.mockReturnValue(false);
170
+ mockPullImage.mockImplementation(() => new Promise(() => {})); // never resolves
171
+ const manager = await createManager();
172
+
173
+ const result = await manager.waitForReady("the-companion:latest", 50);
174
+ expect(result).toBe(false);
175
+ });
176
+ });
177
+
178
+ describe("pull (force re-pull)", () => {
179
+ it("triggers a pull even when image is already present", async () => {
180
+ mockImageExists.mockReturnValue(true);
181
+ const manager = await createManager();
182
+
183
+ manager.pull("the-companion:latest");
184
+
185
+ // Should have started pulling despite image being present
186
+ const state = manager.getState("the-companion:latest");
187
+ expect(state.status).toBe("pulling");
188
+ expect(mockPullImage).toHaveBeenCalledOnce();
189
+ });
190
+
191
+ it("is a no-op when a pull is already in progress", async () => {
192
+ mockImageExists.mockReturnValue(false);
193
+ mockPullImage.mockImplementation(() => new Promise(() => {}));
194
+ const manager = await createManager();
195
+
196
+ manager.pull("the-companion:latest");
197
+ manager.pull("the-companion:latest");
198
+
199
+ expect(mockPullImage).toHaveBeenCalledOnce();
200
+ });
201
+ });
202
+
203
+ describe("onProgress", () => {
204
+ it("fires callback for each progress line during pull", async () => {
205
+ mockImageExists.mockReturnValue(false);
206
+ const lines: string[] = [];
207
+ mockPullImage.mockImplementation(async (_remote: string, _local: string, onProgress?: (line: string) => void) => {
208
+ onProgress?.("Downloading layer 1/3");
209
+ onProgress?.("Downloading layer 2/3");
210
+ onProgress?.("Downloading layer 3/3");
211
+ return true;
212
+ });
213
+
214
+ const manager = await createManager();
215
+ const unsub = manager.onProgress("the-companion:latest", (line) => lines.push(line));
216
+
217
+ manager.ensureImage("the-companion:latest");
218
+
219
+ await vi.waitFor(() => {
220
+ expect(manager.getState("the-companion:latest").status).toBe("ready");
221
+ });
222
+
223
+ // Should have received the pull lines plus "Image ready"
224
+ expect(lines).toContain("Downloading layer 1/3");
225
+ expect(lines).toContain("Downloading layer 2/3");
226
+ expect(lines).toContain("Downloading layer 3/3");
227
+ expect(lines).toContain("Image ready");
228
+
229
+ unsub();
230
+ });
231
+ });
232
+
233
+ describe("fallback to local build", () => {
234
+ it("falls back to local build when pull fails for the-companion:latest", async () => {
235
+ mockImageExists.mockReturnValue(false);
236
+ mockPullImage.mockResolvedValue(false);
237
+ mockExistsSync.mockReturnValue(true);
238
+
239
+ const manager = await createManager();
240
+ const result = await manager.waitForReady("the-companion:latest", 5000);
241
+
242
+ expect(result).toBe(true);
243
+ expect(mockPullImage).toHaveBeenCalledOnce();
244
+ expect(mockBuildImage).toHaveBeenCalledOnce();
245
+ });
246
+
247
+ it("errors when pull fails for non-default image", async () => {
248
+ mockImageExists.mockReturnValue(false);
249
+ mockGetRegistryImage.mockReturnValue(null);
250
+
251
+ const manager = await createManager();
252
+ const result = await manager.waitForReady("custom-image:v1", 5000);
253
+
254
+ expect(result).toBe(false);
255
+ const state = manager.getState("custom-image:v1");
256
+ expect(state.status).toBe("error");
257
+ });
258
+
259
+ it("errors when pull fails and no Dockerfile exists", async () => {
260
+ mockImageExists.mockReturnValue(false);
261
+ mockPullImage.mockResolvedValue(false);
262
+ mockExistsSync.mockReturnValue(false); // no Dockerfile
263
+
264
+ const manager = await createManager();
265
+ const result = await manager.waitForReady("the-companion:latest", 5000);
266
+
267
+ expect(result).toBe(false);
268
+ const state = manager.getState("the-companion:latest");
269
+ expect(state.status).toBe("error");
270
+ expect(state.error).toContain("Dockerfile not found");
271
+ });
272
+ });
273
+
274
+ describe("initFromEnvironments", () => {
275
+ it("is a no-op since environments no longer carry Docker fields", async () => {
276
+ mockImageExists.mockReturnValue(false);
277
+ mockListEnvs.mockReturnValue([
278
+ { name: "env1", slug: "env1", baseImage: "the-companion:latest", variables: {}, createdAt: 0, updatedAt: 0 },
279
+ { name: "env2", slug: "env2", imageTag: "custom:v1", variables: {}, createdAt: 0, updatedAt: 0 },
280
+ ]);
281
+
282
+ const manager = await createManager();
283
+ manager.initFromEnvironments();
284
+
285
+ // initFromEnvironments is now a no-op — Docker fields moved to Sandboxes.
286
+ // No image pulls should be triggered regardless of environment config.
287
+ expect(mockPullImage).not.toHaveBeenCalled();
288
+ expect(mockListEnvs).not.toHaveBeenCalled();
289
+ });
290
+
291
+ it("skips images that are already available", async () => {
292
+ mockImageExists.mockReturnValue(true);
293
+ mockListEnvs.mockReturnValue([
294
+ { name: "env1", slug: "env1", baseImage: "the-companion:latest", variables: {}, createdAt: 0, updatedAt: 0 },
295
+ ]);
296
+
297
+ const manager = await createManager();
298
+ manager.initFromEnvironments();
299
+
300
+ expect(mockPullImage).not.toHaveBeenCalled();
301
+ });
302
+ });
303
+ });