@pentatonic-ai/ai-agent-sdk 0.4.9 → 0.5.1
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.
- package/README.md +61 -1
- package/bin/cli.js +70 -9
- package/dist/index.cjs +25 -3
- package/dist/index.js +25 -3
- package/package.json +4 -2
- package/packages/doctor/README.md +106 -0
- package/packages/doctor/__tests__/checks.test.js +187 -0
- package/packages/doctor/__tests__/detect.test.js +101 -0
- package/packages/doctor/__tests__/output.test.js +92 -0
- package/packages/doctor/__tests__/plugins.test.js +111 -0
- package/packages/doctor/__tests__/runner.test.js +131 -0
- package/packages/doctor/package.json +6 -0
- package/packages/doctor/src/checks/hosted-tes.js +109 -0
- package/packages/doctor/src/checks/local-memory.js +290 -0
- package/packages/doctor/src/checks/platform.js +170 -0
- package/packages/doctor/src/checks/universal.js +121 -0
- package/packages/doctor/src/detect.js +102 -0
- package/packages/doctor/src/index.js +33 -0
- package/packages/doctor/src/output.js +55 -0
- package/packages/doctor/src/plugins.js +81 -0
- package/packages/doctor/src/runner.js +136 -0
- package/packages/memory/migrations/005-atomic-memories.sql +16 -0
- package/packages/memory/migrations/006-fix-vector-dim.sql +97 -0
- package/packages/memory/openclaw-plugin/__tests__/chat-turn.test.js +208 -0
- package/packages/memory/openclaw-plugin/__tests__/indicator.test.js +142 -0
- package/packages/memory/openclaw-plugin/__tests__/query-expansion.test.js +193 -0
- package/packages/memory/openclaw-plugin/__tests__/version-check.test.js +136 -0
- package/packages/memory/openclaw-plugin/index.js +410 -60
- package/packages/memory/openclaw-plugin/openclaw.plugin.json +11 -1
- package/packages/memory/openclaw-plugin/package.json +1 -1
- package/packages/memory/src/__tests__/api-contract.test.js +41 -0
- package/packages/memory/src/__tests__/distill.test.js +175 -0
- package/packages/memory/src/__tests__/openclaw-chat-turn.test.js +289 -0
- package/packages/memory/src/distill.js +162 -0
- package/packages/memory/src/index.js +1 -0
- package/packages/memory/src/ingest.js +16 -0
- package/packages/memory/src/openclaw/index.js +280 -23
- package/packages/memory/src/openclaw/package.json +1 -1
- package/packages/memory/src/server.js +27 -5
- package/src/normalizer.js +16 -0
- package/src/session.js +21 -2
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { detectPath, detectPaths, PATHS } from "../src/detect.js";
|
|
2
|
+
|
|
3
|
+
describe("detectPaths", () => {
|
|
4
|
+
it("returns explicit override when set", () => {
|
|
5
|
+
const paths = detectPaths({ path: PATHS.HOSTED, env: {} });
|
|
6
|
+
expect(paths).toEqual(new Set([PATHS.HOSTED]));
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("respects PENTATONIC_DOCTOR_PATH env", () => {
|
|
10
|
+
const paths = detectPaths({ env: { PENTATONIC_DOCTOR_PATH: PATHS.LOCAL } });
|
|
11
|
+
expect(paths).toEqual(new Set([PATHS.LOCAL]));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("rejects invalid override", () => {
|
|
15
|
+
expect(() => detectPaths({ path: "fake", env: {} })).toThrow(/Unknown path/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("treats 'auto' as no override", () => {
|
|
19
|
+
const paths = detectPaths({ path: "auto", env: {} });
|
|
20
|
+
expect(paths).toEqual(new Set([PATHS.UNKNOWN]));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("detects hosted from TES env vars", () => {
|
|
24
|
+
const paths = detectPaths({
|
|
25
|
+
env: { TES_ENDPOINT: "https://x", TES_API_KEY: "k" },
|
|
26
|
+
});
|
|
27
|
+
expect(paths.has(PATHS.HOSTED)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("does not detect hosted with only one TES var", () => {
|
|
31
|
+
const paths = detectPaths({ env: { TES_ENDPOINT: "https://x" } });
|
|
32
|
+
expect(paths.has(PATHS.HOSTED)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("detects platform from HYBRIDRAG_URL", () => {
|
|
36
|
+
const paths = detectPaths({
|
|
37
|
+
env: { HYBRIDRAG_URL: "http://hybridrag:8031" },
|
|
38
|
+
});
|
|
39
|
+
expect(paths.has(PATHS.PLATFORM)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("detects local from full DSN trio", () => {
|
|
43
|
+
const paths = detectPaths({
|
|
44
|
+
env: {
|
|
45
|
+
DATABASE_URL: "postgres://x",
|
|
46
|
+
EMBEDDING_URL: "http://x/v1",
|
|
47
|
+
LLM_URL: "http://x/v1",
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
expect(paths.has(PATHS.LOCAL)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("can detect multiple paths simultaneously", () => {
|
|
54
|
+
const paths = detectPaths({
|
|
55
|
+
env: {
|
|
56
|
+
TES_ENDPOINT: "https://x",
|
|
57
|
+
TES_API_KEY: "k",
|
|
58
|
+
HYBRIDRAG_URL: "http://h",
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
expect(paths.has(PATHS.HOSTED)).toBe(true);
|
|
62
|
+
expect(paths.has(PATHS.PLATFORM)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("falls back to UNKNOWN when nothing matches", () => {
|
|
66
|
+
const paths = detectPaths({ env: {} });
|
|
67
|
+
expect(paths).toEqual(new Set([PATHS.UNKNOWN]));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("detectPath", () => {
|
|
72
|
+
it("prefers platform over hosted over local", () => {
|
|
73
|
+
expect(
|
|
74
|
+
detectPath({
|
|
75
|
+
env: {
|
|
76
|
+
HYBRIDRAG_URL: "http://h",
|
|
77
|
+
TES_ENDPOINT: "x",
|
|
78
|
+
TES_API_KEY: "k",
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
).toBe(PATHS.PLATFORM);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("prefers hosted over local", () => {
|
|
85
|
+
expect(
|
|
86
|
+
detectPath({
|
|
87
|
+
env: {
|
|
88
|
+
TES_ENDPOINT: "x",
|
|
89
|
+
TES_API_KEY: "k",
|
|
90
|
+
DATABASE_URL: "postgres://x",
|
|
91
|
+
EMBEDDING_URL: "http://x/v1",
|
|
92
|
+
LLM_URL: "http://x/v1",
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
).toBe(PATHS.HOSTED);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns UNKNOWN with no signals", () => {
|
|
99
|
+
expect(detectPath({ env: {} })).toBe(PATHS.UNKNOWN);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { renderHuman, renderJson } from "../src/output.js";
|
|
2
|
+
import { SEVERITY } from "../src/index.js";
|
|
3
|
+
import { PATHS } from "../src/detect.js";
|
|
4
|
+
|
|
5
|
+
const sampleReport = {
|
|
6
|
+
timestamp: "2026-04-18T08:00:00.000Z",
|
|
7
|
+
paths: [PATHS.HOSTED],
|
|
8
|
+
pluginCount: 2,
|
|
9
|
+
summary: { ok: 2, warning: 1, critical: 1, total: 4 },
|
|
10
|
+
checks: [
|
|
11
|
+
{
|
|
12
|
+
name: "good",
|
|
13
|
+
severity: SEVERITY.INFO,
|
|
14
|
+
ok: true,
|
|
15
|
+
msg: "all fine",
|
|
16
|
+
detail: {},
|
|
17
|
+
durationMs: 5,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "bad",
|
|
21
|
+
severity: SEVERITY.WARNING,
|
|
22
|
+
ok: false,
|
|
23
|
+
msg: "kinda broken",
|
|
24
|
+
detail: {},
|
|
25
|
+
durationMs: 5,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "broken",
|
|
29
|
+
severity: SEVERITY.CRITICAL,
|
|
30
|
+
ok: false,
|
|
31
|
+
msg: "very broken",
|
|
32
|
+
detail: {},
|
|
33
|
+
durationMs: 5,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "good2",
|
|
37
|
+
severity: SEVERITY.INFO,
|
|
38
|
+
ok: true,
|
|
39
|
+
msg: "fine",
|
|
40
|
+
detail: {},
|
|
41
|
+
durationMs: 5,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe("renderHuman", () => {
|
|
47
|
+
it("includes detected paths line", () => {
|
|
48
|
+
const out = renderHuman(sampleReport);
|
|
49
|
+
expect(out).toMatch(/paths detected: hosted/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("includes plugin count when non-zero", () => {
|
|
53
|
+
const out = renderHuman(sampleReport);
|
|
54
|
+
expect(out).toMatch(/plugins loaded: 2/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("uses ✓ for ok checks", () => {
|
|
58
|
+
expect(renderHuman(sampleReport)).toMatch(/✓ {2}good/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("uses ✗ for critical failures", () => {
|
|
62
|
+
expect(renderHuman(sampleReport)).toMatch(/✗ {2}broken/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("uses ! for warnings", () => {
|
|
66
|
+
expect(renderHuman(sampleReport)).toMatch(/! {2}bad/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("includes the summary line", () => {
|
|
70
|
+
expect(renderHuman(sampleReport)).toMatch(
|
|
71
|
+
/summary: 2 ok, 1 warning, 1 critical \(of 4\)/
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("handles empty reports", () => {
|
|
76
|
+
const out = renderHuman({
|
|
77
|
+
...sampleReport,
|
|
78
|
+
checks: [],
|
|
79
|
+
summary: { ok: 0, warning: 0, critical: 0, total: 0 },
|
|
80
|
+
});
|
|
81
|
+
expect(out).toMatch(/no checks/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("renderJson", () => {
|
|
86
|
+
it("emits valid JSON", () => {
|
|
87
|
+
const out = renderJson(sampleReport);
|
|
88
|
+
const parsed = JSON.parse(out);
|
|
89
|
+
expect(parsed.summary.total).toBe(4);
|
|
90
|
+
expect(parsed.checks).toHaveLength(4);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { loadPlugins } from "../src/plugins.js";
|
|
5
|
+
|
|
6
|
+
function tmpPluginDir() {
|
|
7
|
+
return mkdtempSync(join(tmpdir(), "doctor-plugins-"));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function writePlugin(dir, file, src) {
|
|
11
|
+
writeFileSync(join(dir, file), src);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("loadPlugins", () => {
|
|
15
|
+
let dir;
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
18
|
+
dir = null;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns [] when the plugin dir doesn't exist", async () => {
|
|
22
|
+
const fake = join(tmpdir(), "doctor-does-not-exist-" + Date.now());
|
|
23
|
+
expect(await loadPlugins({ dir: fake })).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("loads a valid .mjs plugin", async () => {
|
|
27
|
+
dir = tmpPluginDir();
|
|
28
|
+
writePlugin(
|
|
29
|
+
dir,
|
|
30
|
+
"good.mjs",
|
|
31
|
+
`export default {
|
|
32
|
+
name: "good",
|
|
33
|
+
checks: [
|
|
34
|
+
{ name: "c1", run: async () => ({ ok: true, msg: "ok" }) },
|
|
35
|
+
],
|
|
36
|
+
};`
|
|
37
|
+
);
|
|
38
|
+
const plugins = await loadPlugins({ dir });
|
|
39
|
+
expect(plugins).toHaveLength(1);
|
|
40
|
+
expect(plugins[0].name).toBe("good");
|
|
41
|
+
expect(plugins[0].checks).toHaveLength(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("ignores .js files (only .mjs is supported)", async () => {
|
|
45
|
+
dir = tmpPluginDir();
|
|
46
|
+
writePlugin(
|
|
47
|
+
dir,
|
|
48
|
+
"ignored.js",
|
|
49
|
+
`export default { name: "ignored", checks: [] };`
|
|
50
|
+
);
|
|
51
|
+
expect(await loadPlugins({ dir })).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("ignores non-js files", async () => {
|
|
55
|
+
dir = tmpPluginDir();
|
|
56
|
+
writeFileSync(join(dir, "README.md"), "# notes");
|
|
57
|
+
writeFileSync(join(dir, "data.json"), "{}");
|
|
58
|
+
expect(await loadPlugins({ dir })).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("skips invalid plugins via onError without throwing", async () => {
|
|
62
|
+
dir = tmpPluginDir();
|
|
63
|
+
writePlugin(dir, "bad.mjs", `export default { name: 'no checks' };`);
|
|
64
|
+
const errors = [];
|
|
65
|
+
const plugins = await loadPlugins({ dir, onError: (e) => errors.push(e) });
|
|
66
|
+
expect(plugins).toEqual([]);
|
|
67
|
+
expect(errors[0]).toMatch(/not a valid plugin/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("skips plugins that throw at load time", async () => {
|
|
71
|
+
// Note: syntax errors in dynamic-imported ESM crash the Jest worker
|
|
72
|
+
// (the SyntaxError isn't catchable across the module boundary), so we
|
|
73
|
+
// exercise the load-failure path with a runtime throw instead, which
|
|
74
|
+
// hits the same try/catch in loadPlugins.
|
|
75
|
+
dir = tmpPluginDir();
|
|
76
|
+
writePlugin(dir, "throws-on-load.mjs", `throw new Error("boom at load");`);
|
|
77
|
+
const errors = [];
|
|
78
|
+
const plugins = await loadPlugins({ dir, onError: (e) => errors.push(e) });
|
|
79
|
+
expect(plugins).toEqual([]);
|
|
80
|
+
expect(errors[0]).toMatch(/failed to load/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("loads multiple plugins from the same dir", async () => {
|
|
84
|
+
dir = tmpPluginDir();
|
|
85
|
+
writePlugin(
|
|
86
|
+
dir,
|
|
87
|
+
"a.mjs",
|
|
88
|
+
`export default { name: "a", checks: [{ name: "x", run: async () => ({ ok: true, msg: "" }) }] };`
|
|
89
|
+
);
|
|
90
|
+
writePlugin(
|
|
91
|
+
dir,
|
|
92
|
+
"b.mjs",
|
|
93
|
+
`export default { name: "b", checks: [{ name: "y", run: async () => ({ ok: true, msg: "" }) }] };`
|
|
94
|
+
);
|
|
95
|
+
const plugins = await loadPlugins({ dir });
|
|
96
|
+
expect(plugins.map((p) => p.name).sort()).toEqual(["a", "b"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("rejects plugins whose checks lack run()", async () => {
|
|
100
|
+
dir = tmpPluginDir();
|
|
101
|
+
writePlugin(
|
|
102
|
+
dir,
|
|
103
|
+
"bad.mjs",
|
|
104
|
+
`export default { name: "bad", checks: [{ name: "x" }] };`
|
|
105
|
+
);
|
|
106
|
+
const errors = [];
|
|
107
|
+
const plugins = await loadPlugins({ dir, onError: (e) => errors.push(e) });
|
|
108
|
+
expect(plugins).toEqual([]);
|
|
109
|
+
expect(errors[0]).toMatch(/not a valid plugin/);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { runDoctor } from "../src/runner.js";
|
|
2
|
+
import { SEVERITY } from "../src/index.js";
|
|
3
|
+
|
|
4
|
+
// Force UNKNOWN path so we don't pull in any built-in path checks.
|
|
5
|
+
const NO_PATH = { env: {} };
|
|
6
|
+
|
|
7
|
+
function ok(name) {
|
|
8
|
+
return {
|
|
9
|
+
name,
|
|
10
|
+
severity: SEVERITY.INFO,
|
|
11
|
+
run: async () => ({ ok: true, msg: "fine" }),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fail(name, severity = SEVERITY.WARNING) {
|
|
16
|
+
return {
|
|
17
|
+
name,
|
|
18
|
+
severity,
|
|
19
|
+
run: async () => ({ ok: false, msg: "broken" }),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function crashes(name) {
|
|
24
|
+
return {
|
|
25
|
+
name,
|
|
26
|
+
severity: SEVERITY.WARNING,
|
|
27
|
+
run: async () => {
|
|
28
|
+
throw new Error("boom");
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hangs(name) {
|
|
34
|
+
return {
|
|
35
|
+
name,
|
|
36
|
+
severity: SEVERITY.WARNING,
|
|
37
|
+
run: () => new Promise(() => {}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("runDoctor", () => {
|
|
42
|
+
it("includes universal checks even with no path detected", async () => {
|
|
43
|
+
const r = await runDoctor({ ...NO_PATH, plugins: false });
|
|
44
|
+
const names = r.checks.map((c) => c.name);
|
|
45
|
+
expect(names).toContain("node version");
|
|
46
|
+
expect(names).toContain("disk space");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("runs extraChecks", async () => {
|
|
50
|
+
const r = await runDoctor({
|
|
51
|
+
...NO_PATH,
|
|
52
|
+
plugins: false,
|
|
53
|
+
extraChecks: [ok("custom-1"), ok("custom-2")],
|
|
54
|
+
});
|
|
55
|
+
expect(r.checks.find((c) => c.name === "custom-1")).toBeDefined();
|
|
56
|
+
expect(r.checks.find((c) => c.name === "custom-2")).toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("counts ok/warning/critical correctly", async () => {
|
|
60
|
+
const r = await runDoctor({
|
|
61
|
+
...NO_PATH,
|
|
62
|
+
plugins: false,
|
|
63
|
+
extraChecks: [
|
|
64
|
+
ok("a"),
|
|
65
|
+
fail("b", SEVERITY.WARNING),
|
|
66
|
+
fail("c", SEVERITY.CRITICAL),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
const a = r.checks.find((c) => c.name === "a");
|
|
70
|
+
const b = r.checks.find((c) => c.name === "b");
|
|
71
|
+
const c = r.checks.find((c) => c.name === "c");
|
|
72
|
+
expect(a.ok).toBe(true);
|
|
73
|
+
expect(b.ok).toBe(false);
|
|
74
|
+
expect(c.ok).toBe(false);
|
|
75
|
+
// summary counts include the universal checks too — count just the ones we added
|
|
76
|
+
expect(r.summary.total).toBe(r.checks.length);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("does not let a crashing check abort the run", async () => {
|
|
80
|
+
const r = await runDoctor({
|
|
81
|
+
...NO_PATH,
|
|
82
|
+
plugins: false,
|
|
83
|
+
extraChecks: [crashes("explody"), ok("survivor")],
|
|
84
|
+
});
|
|
85
|
+
const e = r.checks.find((c) => c.name === "explody");
|
|
86
|
+
const s = r.checks.find((c) => c.name === "survivor");
|
|
87
|
+
expect(e.ok).toBe(false);
|
|
88
|
+
expect(e.msg).toMatch(/check itself failed: boom/);
|
|
89
|
+
expect(s.ok).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("times out hung checks instead of hanging the runner", async () => {
|
|
93
|
+
const r = await runDoctor({
|
|
94
|
+
...NO_PATH,
|
|
95
|
+
plugins: false,
|
|
96
|
+
timeoutMs: 100,
|
|
97
|
+
extraChecks: [hangs("hangs")],
|
|
98
|
+
});
|
|
99
|
+
const h = r.checks.find((c) => c.name === "hangs");
|
|
100
|
+
expect(h.ok).toBe(false);
|
|
101
|
+
expect(h.msg).toMatch(/timed out/);
|
|
102
|
+
}, 5000);
|
|
103
|
+
|
|
104
|
+
it("reports invalid check return values", async () => {
|
|
105
|
+
const r = await runDoctor({
|
|
106
|
+
...NO_PATH,
|
|
107
|
+
plugins: false,
|
|
108
|
+
extraChecks: [
|
|
109
|
+
{
|
|
110
|
+
name: "lying",
|
|
111
|
+
severity: SEVERITY.WARNING,
|
|
112
|
+
run: async () => ({ msg: "nope" }), // missing 'ok'
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
const l = r.checks.find((c) => c.name === "lying");
|
|
117
|
+
expect(l.ok).toBe(false);
|
|
118
|
+
expect(l.msg).toMatch(/invalid result/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns summary that adds up to total", async () => {
|
|
122
|
+
const r = await runDoctor({
|
|
123
|
+
...NO_PATH,
|
|
124
|
+
plugins: false,
|
|
125
|
+
extraChecks: [ok("a"), fail("b"), fail("c", SEVERITY.CRITICAL)],
|
|
126
|
+
});
|
|
127
|
+
expect(r.summary.ok + r.summary.warning + r.summary.critical).toBe(
|
|
128
|
+
r.summary.total
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosted TES path checks.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that TES_ENDPOINT is reachable and TES_API_KEY is accepted
|
|
5
|
+
* for the configured TES_CLIENT_ID. Uses a tiny GraphQL probe so we
|
|
6
|
+
* exercise the same auth path the SDK uses at runtime.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SEVERITY } from "../index.js";
|
|
10
|
+
|
|
11
|
+
async function fetchWithTimeout(url, opts = {}, timeoutMs = 5000) {
|
|
12
|
+
return await fetch(url, {
|
|
13
|
+
...opts,
|
|
14
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function checkTesReachable() {
|
|
19
|
+
return {
|
|
20
|
+
name: "TES endpoint reachable",
|
|
21
|
+
severity: SEVERITY.CRITICAL,
|
|
22
|
+
run: async () => {
|
|
23
|
+
const endpoint = process.env.TES_ENDPOINT;
|
|
24
|
+
if (!endpoint) {
|
|
25
|
+
return { ok: false, msg: "TES_ENDPOINT not set" };
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetchWithTimeout(`${endpoint.replace(/\/$/, "")}/api/health`);
|
|
29
|
+
if (res.ok) {
|
|
30
|
+
return { ok: true, msg: `${endpoint} reachable` };
|
|
31
|
+
}
|
|
32
|
+
// Many TES deployments don't expose /api/health; fall back to a
|
|
33
|
+
// GraphQL introspection ping which is always available.
|
|
34
|
+
const probe = await fetchWithTimeout(
|
|
35
|
+
`${endpoint.replace(/\/$/, "")}/api/graphql`,
|
|
36
|
+
{
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify({ query: "{ __typename }" }),
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
if (probe.ok || probe.status === 401) {
|
|
43
|
+
// 401 is fine here — it proves the server is alive; auth is
|
|
44
|
+
// a separate check below.
|
|
45
|
+
return { ok: true, msg: `${endpoint} reachable (graphql)` };
|
|
46
|
+
}
|
|
47
|
+
return { ok: false, msg: `HTTP ${probe.status}` };
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return { ok: false, msg: err.message };
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function checkTesAuth() {
|
|
56
|
+
return {
|
|
57
|
+
name: "TES API key valid",
|
|
58
|
+
severity: SEVERITY.CRITICAL,
|
|
59
|
+
run: async () => {
|
|
60
|
+
const endpoint = process.env.TES_ENDPOINT;
|
|
61
|
+
const apiKey = process.env.TES_API_KEY;
|
|
62
|
+
const clientId = process.env.TES_CLIENT_ID;
|
|
63
|
+
if (!endpoint || !apiKey || !clientId) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
msg: "TES_ENDPOINT / TES_API_KEY / TES_CLIENT_ID required",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
// viewer / me-style query — exact name varies by deployment, so
|
|
71
|
+
// we use a generic introspection that should always require auth.
|
|
72
|
+
const res = await fetchWithTimeout(
|
|
73
|
+
`${endpoint.replace(/\/$/, "")}/api/graphql`,
|
|
74
|
+
{
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
Authorization: `Bearer ${apiKey}`,
|
|
79
|
+
"x-client-id": clientId,
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
query: "{ __schema { queryType { name } } }",
|
|
83
|
+
}),
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
if (res.status === 401 || res.status === 403) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
msg: `auth rejected (HTTP ${res.status}) — check API key + client ID`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
return { ok: false, msg: `HTTP ${res.status}` };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
msg: `key accepted for ${clientId}`,
|
|
98
|
+
detail: { clientId, endpoint },
|
|
99
|
+
};
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return { ok: false, msg: err.message };
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function hostedTesChecks() {
|
|
108
|
+
return [checkTesReachable(), checkTesAuth()];
|
|
109
|
+
}
|