Skip to content

Commit b65e8b5

Browse files
committed
feat: add live codex healthcheck
1 parent 18a91d2 commit b65e8b5

File tree

6 files changed

+183
-3
lines changed

6 files changed

+183
-3
lines changed

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
- `npm run check`: run the repository typecheck gate.
2323
- `npm run typecheck`: run `tsc --noEmit` directly.
2424
- `npm run healthcheck`: run the local runtime health check.
25+
- `npm run healthcheck:live`: run the live Codex + Telegram health probe when local credentials are configured.
2526

2627
## Lint And Format
2728

@@ -52,5 +53,6 @@
5253
- Run `npm run format:check`.
5354
- Run `npm test`.
5455
- Run `npm run healthcheck`.
56+
- Run `npm run healthcheck:live` before production-facing releases when real credentials are available.
5557
- Review `git diff --stat` and `git status --short` for accidental edits.
5658
- If bot commands or behavior changed, update `README.md` and include a Telegram usage example in the PR or commit notes.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ npm run lint
8383
npm run format:check
8484
npm test
8585
npm run healthcheck
86+
npm run healthcheck:live
8687
```
8788

8889
## Development Commands
@@ -98,6 +99,7 @@ npm run healthcheck
9899
- `npm test` - run the full test suite
99100
- `npm run healthcheck` - static runtime readiness check
100101
- `npm run healthcheck:strict` - stricter production-oriented health check
102+
- `npm run healthcheck:live` - live Codex + Telegram probe against the configured backend and bot token
101103
- `npm run telegram:smoke` - live Telegram API smoke test when a real bot token is available
102104

103105
## Architecture
@@ -369,7 +371,7 @@ Recommended local release gate:
369371

370372
```bash
371373
npm run ci
372-
node scripts/healthcheck.js --strict --telegram-live
374+
node scripts/healthcheck.js --strict --telegram-live --codex-live
373375
```
374376

375377
Release references:

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"format:check": "prettier --check .",
1717
"healthcheck": "tsx scripts/healthcheck.js",
1818
"healthcheck:strict": "tsx scripts/healthcheck.js --strict",
19+
"healthcheck:live": "tsx scripts/healthcheck.js --codex-live --telegram-live",
1920
"telegram:smoke": "tsx scripts/telegramSmoke.js",
2021
"ci": "npm run check && npm run lint && npm run format:check && npm test && npm run healthcheck"
2122
},

scripts/healthcheck.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { runHealthcheck } from "../src/ops/healthcheck.js";
44

55
const strict = process.argv.includes("--strict");
66
const telegramLiveCheck = process.argv.includes("--telegram-live");
7+
const codexLiveCheck = process.argv.includes("--codex-live");
78

89
let config;
910
try {
@@ -15,7 +16,8 @@ try {
1516

1617
const result = await runHealthcheck(config, {
1718
strict,
18-
telegramLiveCheck
19+
telegramLiveCheck,
20+
codexLiveCheck
1921
});
2022

2123
for (const check of result.checks) {

src/ops/healthcheck.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import fs from "node:fs";
2+
import { spawn } from "node:child_process";
23
import path from "node:path";
34
import process from "node:process";
45
import { repairNodePtySpawnHelperPermissions } from "../runner/ptyPreflight.js";
6+
import { extractCodexExecResponse } from "../bot/formatter.js";
57

68
function makeCheck(name, status, detail) {
79
return { name, status, detail };
@@ -68,6 +70,96 @@ function checkWritableDirectory(name, directoryPath) {
6870
}
6971
}
7072

73+
function runCliCodexLiveCheck(config) {
74+
return new Promise((resolve, reject) => {
75+
const prompt = "Reply with exactly: HEALTHCHECK_OK";
76+
const proc = spawn(
77+
config.runner.command,
78+
[...(config.runner.args || []), "exec", prompt],
79+
{
80+
cwd: config.runner.cwd,
81+
env: process.env
82+
}
83+
);
84+
85+
let stdout = "";
86+
let stderr = "";
87+
88+
proc.stdout?.on("data", (chunk) => {
89+
stdout += String(chunk || "");
90+
});
91+
proc.stderr?.on("data", (chunk) => {
92+
stderr += String(chunk || "");
93+
});
94+
proc.on("error", reject);
95+
proc.on("close", (code, signal) => {
96+
const output = extractCodexExecResponse(`${stdout}\n${stderr}`).trim();
97+
if (code !== 0) {
98+
reject(
99+
new Error(
100+
`CLI health check exited with code ${code}, signal ${signal || "none"}`
101+
)
102+
);
103+
return;
104+
}
105+
106+
if (output !== "HEALTHCHECK_OK") {
107+
reject(
108+
new Error(`Unexpected CLI response: ${output || "(empty output)"}`)
109+
);
110+
return;
111+
}
112+
113+
resolve({
114+
backend: "cli",
115+
output
116+
});
117+
});
118+
});
119+
}
120+
121+
async function runSdkCodexLiveCheck(config) {
122+
const { Codex } = await import("@openai/codex-sdk");
123+
const codex = new Codex({
124+
config: config.runner.sdkConfig
125+
});
126+
const thread = codex.startThread({
127+
workingDirectory: config.runner.cwd,
128+
skipGitRepoCheck: config.runner.sdkThreadOptions.skipGitRepoCheck,
129+
approvalPolicy: config.runner.sdkThreadOptions.approvalPolicy,
130+
sandboxMode: config.runner.sdkThreadOptions.sandboxMode,
131+
modelReasoningEffort: config.runner.sdkThreadOptions.modelReasoningEffort,
132+
networkAccessEnabled: config.runner.sdkThreadOptions.networkAccessEnabled,
133+
webSearchMode: config.runner.sdkThreadOptions.webSearchMode,
134+
additionalDirectories: config.runner.sdkThreadOptions.additionalDirectories
135+
});
136+
const turn = await thread.run("Reply with exactly: HEALTHCHECK_OK");
137+
138+
if (turn.finalResponse.trim() !== "HEALTHCHECK_OK") {
139+
throw new Error(
140+
`Unexpected SDK response: ${turn.finalResponse.trim() || "(empty output)"}`
141+
);
142+
}
143+
144+
return {
145+
backend: "sdk",
146+
threadId: thread.id,
147+
output: turn.finalResponse.trim()
148+
};
149+
}
150+
151+
async function runCodexLiveCheck(config, options = {}) {
152+
if (typeof options.codexLiveRunner === "function") {
153+
return options.codexLiveRunner(config);
154+
}
155+
156+
if (config.runner.backend === "sdk") {
157+
return runSdkCodexLiveCheck(config);
158+
}
159+
160+
return runCliCodexLiveCheck(config);
161+
}
162+
71163
export async function runHealthcheck(config, options = {}) {
72164
const strict = Boolean(options.strict);
73165
const env = options.env || process.env;
@@ -144,6 +236,30 @@ export async function runHealthcheck(config, options = {}) {
144236
}
145237
}
146238

239+
const codexLiveCheck = Boolean(options.codexLiveCheck);
240+
if (codexLiveCheck) {
241+
try {
242+
const result = await runCodexLiveCheck(config, options);
243+
checks.push(
244+
makeCheck(
245+
"codex live",
246+
"pass",
247+
`${result.backend} backend responded with ${result.output}${
248+
result.threadId ? ` (thread ${result.threadId})` : ""
249+
}`
250+
)
251+
);
252+
} catch (error) {
253+
checks.push(
254+
makeCheck(
255+
"codex live",
256+
"fail",
257+
error instanceof Error ? error.message : String(error)
258+
)
259+
);
260+
}
261+
}
262+
147263
const failed = checks.filter((check) => check.status === "fail");
148264
const warned = checks.filter((check) => check.status === "warn");
149265

tests/healthcheck.test.js

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,15 @@ function createConfig(root) {
1717
botToken: "dummy-token"
1818
},
1919
runner: {
20+
backend: "sdk",
2021
command: "node",
21-
cwd: root
22+
args: [],
23+
cwd: root,
24+
sdkConfig: {},
25+
sdkThreadOptions: {
26+
skipGitRepoCheck: true,
27+
additionalDirectories: []
28+
}
2229
},
2330
github: {
2431
defaultWorkdir: root
@@ -80,3 +87,53 @@ test("runHealthcheck fails when the configured command is missing in strict mode
8087
true
8188
);
8289
});
90+
91+
test("runHealthcheck reports a passing live Codex probe when the backend responds", async () => {
92+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "claws-health-"));
93+
const config = createConfig(root);
94+
95+
const result = await runHealthcheck(config, {
96+
env: process.env,
97+
codexLiveCheck: true,
98+
codexLiveRunner: async () => ({
99+
backend: "sdk",
100+
threadId: "thread-123",
101+
output: "HEALTHCHECK_OK"
102+
})
103+
});
104+
105+
assert.equal(result.ok, true);
106+
assert.equal(
107+
result.checks.some(
108+
(check) =>
109+
check.name === "codex live" &&
110+
check.status === "pass" &&
111+
check.detail.includes("HEALTHCHECK_OK")
112+
),
113+
true
114+
);
115+
});
116+
117+
test("runHealthcheck reports a failing live Codex probe when the backend check fails", async () => {
118+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "claws-health-"));
119+
const config = createConfig(root);
120+
121+
const result = await runHealthcheck(config, {
122+
env: process.env,
123+
codexLiveCheck: true,
124+
codexLiveRunner: async () => {
125+
throw new Error("simulated codex failure");
126+
}
127+
});
128+
129+
assert.equal(result.ok, false);
130+
assert.equal(
131+
result.checks.some(
132+
(check) =>
133+
check.name === "codex live" &&
134+
check.status === "fail" &&
135+
check.detail.includes("simulated codex failure")
136+
),
137+
true
138+
);
139+
});

0 commit comments

Comments
 (0)