@app-vitals/agent-runtime 0.0.1 → 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 +38 -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,244 @@
1
+ /**
2
+ * Smoke tests for startHealthServer() in agent/src/runtime/health.ts
3
+ *
4
+ * These tests boot real Bun.serve() instances on localhost ports and make
5
+ * live fetch() calls to exercise HTTP route contracts (status codes,
6
+ * response shapes) — an HTTP-server-booting test belongs in smoke per
7
+ * docs/test-readiness/test-system.md, even though it uses a real socket
8
+ * rather than Hono's app.request().
9
+ */
10
+
11
+ import { afterEach, describe, expect, it } from "bun:test";
12
+ import type { Server } from "bun";
13
+ import { FixedClock } from "./clock.ts";
14
+ import {
15
+ claudeVersionState,
16
+ markSlackConnected,
17
+ markSlackDisconnected,
18
+ SLACK_DOWN_GRACE_MS,
19
+ slackState,
20
+ startHealthServer,
21
+ } from "./health.ts";
22
+
23
+ describe("startHealthServer", () => {
24
+ const servers: Server<undefined>[] = [];
25
+
26
+ afterEach(() => {
27
+ for (const s of servers) s.stop(true);
28
+ servers.length = 0;
29
+ slackState.connected = false;
30
+ slackState.downSince = null;
31
+ claudeVersionState.version = "unknown";
32
+ });
33
+
34
+ function serve(port: number): Server<undefined> {
35
+ const s = startHealthServer(port);
36
+ servers.push(s);
37
+ return s;
38
+ }
39
+
40
+ it("GET /health returns 200 with slack disconnected when not connected", async () => {
41
+ slackState.connected = false;
42
+ slackState.downSince = null;
43
+ serve(19901);
44
+ const res = await fetch("http://localhost:19901/health");
45
+ expect(res.status).toBe(200);
46
+ const body = await res.json();
47
+ expect(body).toEqual({
48
+ ok: true,
49
+ slack: "disconnected",
50
+ claudeCodeVersion: "unknown",
51
+ });
52
+ });
53
+
54
+ it("GET /health returns slack connected when slackState.connected is true", async () => {
55
+ slackState.connected = true;
56
+ serve(19902);
57
+ const res = await fetch("http://localhost:19902/health");
58
+ expect(res.status).toBe(200);
59
+ const body = await res.json();
60
+ expect(body).toEqual({
61
+ ok: true,
62
+ slack: "connected",
63
+ claudeCodeVersion: "unknown",
64
+ });
65
+ });
66
+
67
+ it("reflects state changes at runtime without restart", async () => {
68
+ slackState.connected = false;
69
+ slackState.downSince = null;
70
+ serve(19903);
71
+
72
+ const res1 = await fetch("http://localhost:19903/health");
73
+ expect((await res1.json()).slack).toBe("disconnected");
74
+
75
+ slackState.connected = true;
76
+ const res2 = await fetch("http://localhost:19903/health");
77
+ expect((await res2.json()).slack).toBe("connected");
78
+
79
+ slackState.connected = false;
80
+ const res3 = await fetch("http://localhost:19903/health");
81
+ expect((await res3.json()).slack).toBe("disconnected");
82
+ });
83
+
84
+ it("GET /health reports the detected claudeCodeVersion", async () => {
85
+ claudeVersionState.version = "2.1.236";
86
+ serve(19906);
87
+ const res = await fetch("http://localhost:19906/health");
88
+ expect(res.status).toBe(200);
89
+ expect((await res.json()).claudeCodeVersion).toBe("2.1.236");
90
+ });
91
+
92
+ it("GET /health includes claudeCodeVersion on the 500 wedge response too", async () => {
93
+ claudeVersionState.version = "2.1.236";
94
+ const downAt = new Date("2026-06-06T00:00:00.000Z").getTime();
95
+ const clock = FixedClock(new Date(downAt + SLACK_DOWN_GRACE_MS + 1_000));
96
+ slackState.connected = false;
97
+ slackState.downSince = downAt;
98
+ servers.push(startHealthServer(19907, undefined, clock));
99
+ const res = await fetch("http://localhost:19907/health");
100
+ expect(res.status).toBe(500);
101
+ expect((await res.json()).claudeCodeVersion).toBe("2.1.236");
102
+ });
103
+
104
+ it("returns 404 for unknown paths", async () => {
105
+ serve(19904);
106
+ const res = await fetch("http://localhost:19904/not-a-thing");
107
+ expect(res.status).toBe(404);
108
+ });
109
+
110
+ it("returns 404 for POST /health", async () => {
111
+ serve(19905);
112
+ const res = await fetch("http://localhost:19905/health", {
113
+ method: "POST",
114
+ });
115
+ expect(res.status).toBe(404);
116
+ });
117
+
118
+ // ─── Self-heal grace-window behavior ──────────────────────────────────────
119
+
120
+ // A1: Healthy → 200 connected
121
+ it("GET /health returns 200 connected when socket is up", async () => {
122
+ const clock = FixedClock(new Date("2026-06-06T00:00:00.000Z"));
123
+ slackState.connected = true;
124
+ slackState.downSince = null;
125
+ const s = startHealthServer(19910, undefined, clock);
126
+ servers.push(s);
127
+ const res = await fetch("http://localhost:19910/health");
128
+ expect(res.status).toBe(200);
129
+ expect(await res.json()).toEqual({
130
+ ok: true,
131
+ slack: "connected",
132
+ claudeCodeVersion: "unknown",
133
+ });
134
+ });
135
+
136
+ // A2: Disconnected but within grace window → 200 disconnected
137
+ it("GET /health returns 200 when disconnected within grace window", async () => {
138
+ const downAt = new Date("2026-06-06T00:00:00.000Z").getTime();
139
+ // Server clock 30s later — well under the 90s grace window.
140
+ const clock = FixedClock(new Date("2026-06-06T00:00:30.000Z"));
141
+ slackState.connected = false;
142
+ slackState.downSince = downAt;
143
+ const s = startHealthServer(19911, undefined, clock);
144
+ servers.push(s);
145
+ const res = await fetch("http://localhost:19911/health");
146
+ expect(res.status).toBe(200);
147
+ expect(await res.json()).toEqual({
148
+ ok: true,
149
+ slack: "disconnected",
150
+ claudeCodeVersion: "unknown",
151
+ });
152
+ });
153
+
154
+ // A3: Disconnected beyond grace window → 500 disconnected
155
+ it("GET /health returns 500 when disconnected beyond grace window", async () => {
156
+ const downAt = new Date("2026-06-06T00:00:00.000Z").getTime();
157
+ // Server clock past the grace window.
158
+ const clock = FixedClock(new Date(downAt + SLACK_DOWN_GRACE_MS + 1_000));
159
+ slackState.connected = false;
160
+ slackState.downSince = downAt;
161
+ const s = startHealthServer(19912, undefined, clock);
162
+ servers.push(s);
163
+ const res = await fetch("http://localhost:19912/health");
164
+ expect(res.status).toBe(500);
165
+ expect(await res.json()).toEqual({
166
+ ok: false,
167
+ slack: "disconnected",
168
+ claudeCodeVersion: "unknown",
169
+ });
170
+ });
171
+
172
+ // A4: Recovery — markSlackConnected clears downSince → 200 again
173
+ it("GET /health returns 200 again after recovery from a wedge", async () => {
174
+ const downAt = new Date("2026-06-06T00:00:00.000Z").getTime();
175
+ const clock = FixedClock(new Date(downAt + SLACK_DOWN_GRACE_MS + 1_000));
176
+ slackState.connected = false;
177
+ slackState.downSince = downAt;
178
+ const s = startHealthServer(19913, undefined, clock);
179
+ servers.push(s);
180
+
181
+ const wedged = await fetch("http://localhost:19913/health");
182
+ expect(wedged.status).toBe(500);
183
+
184
+ markSlackConnected();
185
+ expect(slackState.connected).toBe(true);
186
+ expect(slackState.downSince).toBeNull();
187
+
188
+ const recovered = await fetch("http://localhost:19913/health");
189
+ expect(recovered.status).toBe(200);
190
+ expect(await recovered.json()).toEqual({
191
+ ok: true,
192
+ slack: "connected",
193
+ claudeCodeVersion: "unknown",
194
+ });
195
+ });
196
+
197
+ // A5: Cold start / never connected → 200 (not killed during initial connect)
198
+ it("GET /health returns 200 at cold start before first connect", async () => {
199
+ const clock = FixedClock(new Date("2026-06-06T05:00:00.000Z"));
200
+ slackState.connected = false;
201
+ slackState.downSince = null;
202
+ const s = startHealthServer(19914, undefined, clock);
203
+ servers.push(s);
204
+ const res = await fetch("http://localhost:19914/health");
205
+ expect(res.status).toBe(200);
206
+ expect(await res.json()).toEqual({
207
+ ok: true,
208
+ slack: "disconnected",
209
+ claudeCodeVersion: "unknown",
210
+ });
211
+ });
212
+
213
+ // A6: Repeated markSlackDisconnected does not push downSince forward
214
+ it("markSlackDisconnected anchors downSince to the first drop", () => {
215
+ slackState.connected = true;
216
+ slackState.downSince = null;
217
+
218
+ const first = FixedClock(new Date("2026-06-06T00:00:00.000Z"));
219
+ markSlackDisconnected(first);
220
+ const firstDown = slackState.downSince as unknown as number;
221
+ expect(firstDown).toBe(new Date("2026-06-06T00:00:00.000Z").getTime());
222
+
223
+ // A later error event must NOT reset/extend the timer.
224
+ const later = FixedClock(new Date("2026-06-06T00:01:00.000Z"));
225
+ markSlackDisconnected(later);
226
+ expect(slackState.downSince as number | null).toBe(firstDown);
227
+ });
228
+
229
+ it("markSlackConnected then markSlackDisconnected re-anchors after reconnect", () => {
230
+ slackState.connected = false;
231
+ slackState.downSince = null;
232
+
233
+ const first = FixedClock(new Date("2026-06-06T00:00:00.000Z"));
234
+ markSlackDisconnected(first);
235
+ expect(slackState.downSince as number | null).toBe(first.now().getTime());
236
+
237
+ markSlackConnected();
238
+ expect(slackState.downSince).toBeNull();
239
+
240
+ const second = FixedClock(new Date("2026-06-06T01:00:00.000Z"));
241
+ markSlackDisconnected(second);
242
+ expect(slackState.downSince as number | null).toBe(second.now().getTime());
243
+ });
244
+ });
package/health.ts ADDED
@@ -0,0 +1,193 @@
1
+ import type { ErrorCapturingClient } from "@app-vitals/agent-lib/sentry";
2
+ import type { Server } from "bun";
3
+ import { Hono } from "hono";
4
+ import { reportClaudeError } from "./claude.ts";
5
+ import { UNKNOWN_CLAUDE_CODE_VERSION } from "./claude-version.ts";
6
+ import { type Clock, SystemClock } from "./clock.ts";
7
+ import {
8
+ type CronHandlerDeps,
9
+ handleCronRequest,
10
+ ValidationError,
11
+ } from "./cron-handler.ts";
12
+
13
+ /**
14
+ * Shared connection state — mutated by index.ts as Slack socket connects/disconnects.
15
+ *
16
+ * `downSince` is the epoch-ms timestamp of the connected→disconnected transition
17
+ * (anchored to the FIRST drop, see markSlackDisconnected), or `null` when the
18
+ * socket is up or has never connected. It is the anchor for the grace-window
19
+ * wedge detection in /health.
20
+ */
21
+ export const slackState = {
22
+ connected: false,
23
+ downSince: null as number | null,
24
+ };
25
+
26
+ /** Claude Code CLI version detected at startup (set by index.ts); reported in /health. */
27
+ export const claudeVersionState = {
28
+ version: UNKNOWN_CLAUDE_CODE_VERSION,
29
+ };
30
+
31
+ /**
32
+ * How long the Slack socket may stay down before /health reports unhealthy (500).
33
+ * Sized above the liveness probe blip tolerance so transient reconnects don't
34
+ * trip the probe, while a sustained wedge does. K8s liveness on /health is
35
+ * periodSeconds=30, failureThreshold=3 — ~90s of failures triggers a restart.
36
+ */
37
+ export const SLACK_DOWN_GRACE_MS = 90_000;
38
+
39
+ /** Default port for the dedicated health server (SHIPWRIGHT_HEALTH_PORT). */
40
+ export const DEFAULT_HEALTH_PORT = 3459;
41
+
42
+ /** Mark the Slack socket as connected and clear the down-timer. */
43
+ export function markSlackConnected(): void {
44
+ slackState.connected = true;
45
+ slackState.downSince = null;
46
+ }
47
+
48
+ /**
49
+ * Mark the Slack socket as disconnected.
50
+ *
51
+ * Anchors `downSince` to the FIRST drop only — repeated `error`/`disconnecting`
52
+ * events while already down must not keep resetting the timer (that would let a
53
+ * flapping-but-wedged socket extend the grace window indefinitely).
54
+ */
55
+ export function markSlackDisconnected(clock: Clock): void {
56
+ slackState.connected = false;
57
+ if (slackState.downSince === null) {
58
+ slackState.downSince = clock.now().getTime();
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Create a minimal Hono health app for container liveness probes.
64
+ *
65
+ * GET /health → 200 { status: "ok" }
66
+ *
67
+ * No Slack state — the Shipwright reference agent is a CLI runner, not a
68
+ * long-lived Slack socket process.
69
+ */
70
+ export function createHealthApp(): Hono {
71
+ const app = new Hono();
72
+ app.get("/health", (c) => c.json({ status: "ok" }));
73
+ return app;
74
+ }
75
+
76
+ /**
77
+ * Start a minimal HTTP health server for K8s liveness probes.
78
+ *
79
+ * GET /health → 200 { ok: true, slack: "connected" | "disconnected", claudeCodeVersion }
80
+ * → 500 { ok: false, slack: "disconnected", claudeCodeVersion } when
81
+ * the socket has been down longer than `graceMs` (sustained wedge)
82
+ * GET /cron → 405 Method Not Allowed
83
+ * POST /cron → run a cron prompt through Claude and post result to Slack
84
+ * 503 if cronDeps not configured
85
+ * 400 for bad JSON or missing jobId/prompt
86
+ * 422 for ValidationError (e.g. missing delivery target)
87
+ * 500 for runner errors
88
+ * 200 on success
89
+ * Other routes → 404
90
+ *
91
+ * /health returns 500 ONLY when the Slack socket has been continuously down for
92
+ * longer than `graceMs` — this is the self-heal path: a sustained wedge trips the
93
+ * K8s liveness probe → pod restart → fresh socket. Transient reconnect blips and
94
+ * the not-yet-connected startup window (downSince === null) stay 200 so the pod
95
+ * is not killed during normal reconnects or cold start.
96
+ *
97
+ * `sentryClient` is optional — when absent (Sentry not initialized, i.e.
98
+ * SENTRY_DSN unset), the POST /cron catch block simply skips the capture call
99
+ * and behaves exactly as before. Only unhandled runner/delivery errors are
100
+ * reported; ValidationError (422) is an expected, typed outcome and is not.
101
+ */
102
+ export function startHealthServer(
103
+ port: number,
104
+ cronDeps?: CronHandlerDeps,
105
+ clock: Clock = SystemClock(),
106
+ graceMs: number = SLACK_DOWN_GRACE_MS,
107
+ sentryClient?: ErrorCapturingClient,
108
+ ): Server<undefined> {
109
+ return Bun.serve({
110
+ port,
111
+ async fetch(req) {
112
+ const url = new URL(req.url);
113
+
114
+ if (url.pathname === "/health" && req.method === "GET") {
115
+ const wedged =
116
+ !slackState.connected &&
117
+ slackState.downSince !== null &&
118
+ clock.now().getTime() - slackState.downSince > graceMs;
119
+ if (wedged) {
120
+ return Response.json(
121
+ {
122
+ ok: false,
123
+ slack: "disconnected",
124
+ claudeCodeVersion: claudeVersionState.version,
125
+ },
126
+ { status: 500 },
127
+ );
128
+ }
129
+ return Response.json({
130
+ ok: true,
131
+ slack: slackState.connected ? "connected" : "disconnected",
132
+ claudeCodeVersion: claudeVersionState.version,
133
+ });
134
+ }
135
+
136
+ if (url.pathname === "/cron" && req.method === "GET") {
137
+ return new Response("Method Not Allowed", { status: 405 });
138
+ }
139
+
140
+ if (url.pathname === "/cron" && req.method === "POST") {
141
+ if (!cronDeps) {
142
+ return Response.json(
143
+ { error: "cron handler not configured" },
144
+ { status: 503 },
145
+ );
146
+ }
147
+ let body: unknown;
148
+ try {
149
+ body = await req.json();
150
+ } catch {
151
+ return Response.json({ error: "invalid JSON body" }, { status: 400 });
152
+ }
153
+ if (
154
+ typeof body !== "object" ||
155
+ body === null ||
156
+ typeof (body as Record<string, unknown>).jobId !== "string" ||
157
+ typeof (body as Record<string, unknown>).prompt !== "string"
158
+ ) {
159
+ return Response.json(
160
+ { error: "missing required fields: jobId, prompt" },
161
+ { status: 400 },
162
+ );
163
+ }
164
+ const { jobId, prompt, channel, user, silent, preCheck } =
165
+ body as Record<string, unknown>;
166
+ try {
167
+ await handleCronRequest(
168
+ {
169
+ jobId: jobId as string,
170
+ prompt: prompt as string,
171
+ channel: typeof channel === "string" ? channel : undefined,
172
+ user: typeof user === "string" ? user : undefined,
173
+ silent: typeof silent === "boolean" ? silent : undefined,
174
+ preCheck: typeof preCheck === "string" ? preCheck : undefined,
175
+ },
176
+ cronDeps,
177
+ );
178
+ return Response.json({ ok: true });
179
+ } catch (err) {
180
+ if (err instanceof ValidationError) {
181
+ return Response.json({ error: err.message }, { status: 422 });
182
+ }
183
+ reportClaudeError(sentryClient, err);
184
+ console.error("[agent:cron] handler error:", err);
185
+ const message = err instanceof Error ? err.message : String(err);
186
+ return Response.json({ error: message }, { status: 500 });
187
+ }
188
+ }
189
+
190
+ return new Response("Not Found", { status: 404 });
191
+ },
192
+ });
193
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Unit tests for createHealthApp() in agent/src/runtime/health.ts
3
+ *
4
+ * Uses Hono's in-process app.request() — no real socket needed.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { createHealthApp } from "./health.ts";
9
+
10
+ // ─── createHealthApp ──────────────────────────────────────────────────────────
11
+
12
+ describe("GET /health (createHealthApp)", () => {
13
+ test("returns 200 with { status: 'ok' }", async () => {
14
+ const app = createHealthApp();
15
+ const res = await app.request("/health");
16
+ expect(res.status).toBe(200);
17
+ const body = (await res.json()) as { status: string };
18
+ expect(body).toEqual({ status: "ok" });
19
+ });
20
+
21
+ test("response is JSON", async () => {
22
+ const app = createHealthApp();
23
+ const res = await app.request("/health");
24
+ expect(res.headers.get("content-type")).toContain("application/json");
25
+ });
26
+ });
27
+
28
+ describe("unknown routes (createHealthApp)", () => {
29
+ test("GET /other returns 404", async () => {
30
+ const app = createHealthApp();
31
+ const res = await app.request("/other");
32
+ expect(res.status).toBe(404);
33
+ });
34
+
35
+ test("POST /health returns 404 (only GET is registered)", async () => {
36
+ const app = createHealthApp();
37
+ const res = await app.request("/health", { method: "POST" });
38
+ expect(res.status).toBe(404);
39
+ });
40
+ });