@ouro.bot/cli 0.1.0-alpha.300 → 0.1.0-alpha.302
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/changelog.json +6 -0
- package/dist/heart/daemon/cli-exec.js +28 -0
- package/dist/heart/daemon/cli-parse.js +3 -0
- package/dist/heart/daemon/cli-render-doctor.js +57 -0
- package/dist/heart/daemon/doctor-types.js +8 -0
- package/dist/heart/daemon/doctor.js +322 -0
- package/dist/heart/daemon/process-manager.js +22 -1
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.302",
|
|
6
|
+
"changes": [
|
|
7
|
+
"feat(cli): add `ouro doctor` system health check command. New command runs 6 diagnostic categories — daemon (socket existence + responsiveness), agents (bundle discovery, agent.json validation for version/humanFacing/agentFacing/enabled), senses (BlueBubbles and Teams config presence and well-formedness), habits (launchd plist discovery, degraded state), security (secrets.json permissions, credential leak detection in agent.json), and disk (log size thresholds at 100MB warn / 500MB critical, bundle root existence). Output is a colored checklist with per-category grouping and a summary line. Works without daemon running — daemon checks fail gracefully while all other categories still execute, making it useful for cold diagnostics. 3 new files (doctor.ts, doctor-types.ts, cli-render-doctor.ts), 3 modified (cli-types.ts, cli-parse.ts, cli-exec.ts), 4 test files with 61 tests at 100% coverage on new code."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
4
10
|
{
|
|
5
11
|
"version": "0.1.0-alpha.300",
|
|
6
12
|
"changes": [
|
|
@@ -68,6 +68,8 @@ const cli_parse_1 = require("./cli-parse");
|
|
|
68
68
|
const cli_parse_2 = require("./cli-parse");
|
|
69
69
|
const cli_render_1 = require("./cli-render");
|
|
70
70
|
const cli_defaults_1 = require("./cli-defaults");
|
|
71
|
+
const doctor_1 = require("./doctor");
|
|
72
|
+
const cli_render_doctor_1 = require("./cli-render-doctor");
|
|
71
73
|
// ── ensureDaemonRunning ──
|
|
72
74
|
async function ensureDaemonRunning(deps) {
|
|
73
75
|
const alive = await deps.checkSocketAlive(deps.socketPath);
|
|
@@ -1912,6 +1914,32 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
1912
1914
|
deps.writeStdout(message);
|
|
1913
1915
|
return message;
|
|
1914
1916
|
}
|
|
1917
|
+
// ── doctor (local, no daemon socket needed) ──
|
|
1918
|
+
if (command.kind === "doctor") {
|
|
1919
|
+
const doctorDeps = {
|
|
1920
|
+
/* v8 ignore start -- thin fs wrappers tested via doctor.test.ts with injected deps @preserve */
|
|
1921
|
+
existsSync: (p) => fs.existsSync(p),
|
|
1922
|
+
readFileSync: (p) => fs.readFileSync(p, "utf-8"),
|
|
1923
|
+
readdirSync: (p) => fs.readdirSync(p),
|
|
1924
|
+
statSync: (p) => fs.statSync(p),
|
|
1925
|
+
/* v8 ignore stop */
|
|
1926
|
+
checkSocketAlive: deps.checkSocketAlive,
|
|
1927
|
+
socketPath: deps.socketPath,
|
|
1928
|
+
bundlesRoot: deps.bundlesRoot ?? (0, identity_1.getAgentBundlesRoot)(),
|
|
1929
|
+
secretsRoot: deps.secretsRoot ?? path.join(os.homedir(), ".agentsecrets"),
|
|
1930
|
+
homedir: os.homedir(),
|
|
1931
|
+
};
|
|
1932
|
+
const doctorResult = await (0, doctor_1.runDoctorChecks)(doctorDeps);
|
|
1933
|
+
const output = (0, cli_render_doctor_1.formatDoctorOutput)(doctorResult);
|
|
1934
|
+
deps.writeStdout(output);
|
|
1935
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1936
|
+
component: "daemon",
|
|
1937
|
+
event: "daemon.doctor_run",
|
|
1938
|
+
message: "ouro doctor completed",
|
|
1939
|
+
meta: { passed: doctorResult.summary.passed, warnings: doctorResult.summary.warnings, failed: doctorResult.summary.failed },
|
|
1940
|
+
});
|
|
1941
|
+
return output;
|
|
1942
|
+
}
|
|
1915
1943
|
const daemonCommand = toDaemonCommand(command);
|
|
1916
1944
|
let response;
|
|
1917
1945
|
try {
|
|
@@ -78,6 +78,7 @@ function usage() {
|
|
|
78
78
|
" ouro mcp call <server> <tool> [--args '{...}']",
|
|
79
79
|
" ouro rollback [<version>]",
|
|
80
80
|
" ouro versions",
|
|
81
|
+
" ouro doctor",
|
|
81
82
|
].join("\n");
|
|
82
83
|
}
|
|
83
84
|
// ── Per-group parsers ──
|
|
@@ -802,6 +803,8 @@ function parseOuroCommand(args) {
|
|
|
802
803
|
return parseMcpServeCommand(args.slice(1));
|
|
803
804
|
if (head === "setup")
|
|
804
805
|
return parseSetupCommand(args.slice(1));
|
|
806
|
+
if (head === "doctor")
|
|
807
|
+
return { kind: "doctor" };
|
|
805
808
|
if (head === "bluebubbles")
|
|
806
809
|
return parseBlueBubblesCommand(args.slice(1));
|
|
807
810
|
throw new Error(`Unknown command '${args.join(" ")}'.\n${usage()}`);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Rendering for `ouro doctor` output.
|
|
4
|
+
*
|
|
5
|
+
* Pure function: takes a DoctorResult and returns a formatted string
|
|
6
|
+
* with ANSI colors, category grouping, and a summary line.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.formatDoctorOutput = formatDoctorOutput;
|
|
10
|
+
// ── ANSI color helpers ──
|
|
11
|
+
const RESET = "\x1b[0m";
|
|
12
|
+
const BOLD = "\x1b[1m";
|
|
13
|
+
const DIM = "\x1b[2m";
|
|
14
|
+
const GREEN = "\x1b[38;2;46;204;64m";
|
|
15
|
+
const RED = "\x1b[38;2;231;76;60m";
|
|
16
|
+
const YELLOW = "\x1b[38;2;230;190;50m";
|
|
17
|
+
const TEAL = "\x1b[38;2;78;201;176m";
|
|
18
|
+
function green(text) { return `${GREEN}${text}${RESET}`; }
|
|
19
|
+
function red(text) { return `${RED}${text}${RESET}`; }
|
|
20
|
+
function yellow(text) { return `${YELLOW}${text}${RESET}`; }
|
|
21
|
+
function bold(text) { return `${BOLD}${text}${RESET}`; }
|
|
22
|
+
function dim(text) { return `${DIM}${text}${RESET}`; }
|
|
23
|
+
function teal(text) { return `${TEAL}${text}${RESET}`; }
|
|
24
|
+
// ── Check status symbols ──
|
|
25
|
+
function statusSymbol(status) {
|
|
26
|
+
switch (status) {
|
|
27
|
+
case "pass": return green("\u2714"); // checkmark
|
|
28
|
+
case "warn": return yellow("\u26A0"); // warning
|
|
29
|
+
case "fail": return red("\u2718"); // X
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ── Main formatter ──
|
|
33
|
+
function formatDoctorOutput(result) {
|
|
34
|
+
const lines = [];
|
|
35
|
+
lines.push("");
|
|
36
|
+
lines.push(` ${bold("ouro doctor")}`);
|
|
37
|
+
lines.push("");
|
|
38
|
+
for (const category of result.categories) {
|
|
39
|
+
lines.push(` ${teal("--")} ${bold(category.name)} ${teal("-".repeat(Math.max(1, 40 - category.name.length)))}`);
|
|
40
|
+
for (const check of category.checks) {
|
|
41
|
+
const symbol = statusSymbol(check.status);
|
|
42
|
+
const detail = check.detail ? ` ${dim(check.detail)}` : "";
|
|
43
|
+
lines.push(` ${symbol} ${check.label}${detail}`);
|
|
44
|
+
}
|
|
45
|
+
lines.push("");
|
|
46
|
+
}
|
|
47
|
+
// Summary line
|
|
48
|
+
const { passed, warnings, failed } = result.summary;
|
|
49
|
+
const parts = [
|
|
50
|
+
green(`${passed} passed`),
|
|
51
|
+
yellow(`${warnings} warning${warnings === 1 ? "" : "s"}`),
|
|
52
|
+
red(`${failed} failed`),
|
|
53
|
+
];
|
|
54
|
+
lines.push(` ${parts.join(dim(" | "))}`);
|
|
55
|
+
lines.push("");
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Type definitions for the `ouro doctor` system health check.
|
|
4
|
+
*
|
|
5
|
+
* Describes the structure of health check results: individual checks,
|
|
6
|
+
* grouped categories, and the aggregated result.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* System health check ("ouro doctor") — runs all diagnostic categories
|
|
4
|
+
* and aggregates results into a structured DoctorResult.
|
|
5
|
+
*
|
|
6
|
+
* Each category checker is isolated: if one throws, it produces a single
|
|
7
|
+
* "fail" check and the remaining categories still run.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.checkDaemon = checkDaemon;
|
|
11
|
+
exports.checkAgents = checkAgents;
|
|
12
|
+
exports.checkSenses = checkSenses;
|
|
13
|
+
exports.checkHabits = checkHabits;
|
|
14
|
+
exports.checkSecurity = checkSecurity;
|
|
15
|
+
exports.checkDisk = checkDisk;
|
|
16
|
+
exports.runDoctorChecks = runDoctorChecks;
|
|
17
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
18
|
+
// ── Category checkers ──
|
|
19
|
+
async function checkDaemon(deps) {
|
|
20
|
+
const checks = [];
|
|
21
|
+
const socketExists = deps.existsSync(deps.socketPath);
|
|
22
|
+
checks.push({
|
|
23
|
+
label: "daemon socket exists",
|
|
24
|
+
status: socketExists ? "pass" : "fail",
|
|
25
|
+
detail: socketExists ? deps.socketPath : `not found at ${deps.socketPath}`,
|
|
26
|
+
});
|
|
27
|
+
if (socketExists) {
|
|
28
|
+
const alive = await deps.checkSocketAlive(deps.socketPath);
|
|
29
|
+
checks.push({
|
|
30
|
+
label: "daemon is responsive",
|
|
31
|
+
status: alive ? "pass" : "fail",
|
|
32
|
+
detail: alive ? "socket responded" : "socket exists but daemon unresponsive",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
checks.push({
|
|
37
|
+
label: "daemon is responsive",
|
|
38
|
+
status: "fail",
|
|
39
|
+
detail: "skipped — socket missing",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return { name: "Daemon", checks };
|
|
43
|
+
}
|
|
44
|
+
/** Discover all *.ouro directories under bundlesRoot. */
|
|
45
|
+
function discoverAgents(deps) {
|
|
46
|
+
if (!deps.existsSync(deps.bundlesRoot))
|
|
47
|
+
return [];
|
|
48
|
+
return deps.readdirSync(deps.bundlesRoot).filter((name) => name.endsWith(".ouro"));
|
|
49
|
+
}
|
|
50
|
+
function checkAgents(deps) {
|
|
51
|
+
const checks = [];
|
|
52
|
+
if (!deps.existsSync(deps.bundlesRoot)) {
|
|
53
|
+
checks.push({ label: "bundles directory", status: "fail", detail: `${deps.bundlesRoot} not found` });
|
|
54
|
+
return { name: "Agents", checks };
|
|
55
|
+
}
|
|
56
|
+
const agents = discoverAgents(deps);
|
|
57
|
+
if (agents.length === 0) {
|
|
58
|
+
checks.push({ label: "agent bundles", status: "warn", detail: "no *.ouro bundles found" });
|
|
59
|
+
return { name: "Agents", checks };
|
|
60
|
+
}
|
|
61
|
+
for (const agentDir of agents) {
|
|
62
|
+
const agentPath = `${deps.bundlesRoot}/${agentDir}`;
|
|
63
|
+
const configPath = `${agentPath}/agent.json`;
|
|
64
|
+
if (!deps.existsSync(configPath)) {
|
|
65
|
+
checks.push({ label: `${agentDir}/agent.json`, status: "fail", detail: "missing" });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
let config;
|
|
69
|
+
try {
|
|
70
|
+
config = JSON.parse(deps.readFileSync(configPath));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
checks.push({ label: `${agentDir}/agent.json`, status: "fail", detail: "unparseable JSON" });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const missing = [];
|
|
77
|
+
if (!config.version)
|
|
78
|
+
missing.push("version");
|
|
79
|
+
if (!config.humanFacing || typeof config.humanFacing !== "object") {
|
|
80
|
+
missing.push("humanFacing");
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const hf = config.humanFacing;
|
|
84
|
+
if (!hf.provider)
|
|
85
|
+
missing.push("humanFacing.provider");
|
|
86
|
+
if (!hf.model)
|
|
87
|
+
missing.push("humanFacing.model");
|
|
88
|
+
}
|
|
89
|
+
if (!config.agentFacing || typeof config.agentFacing !== "object") {
|
|
90
|
+
missing.push("agentFacing");
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const af = config.agentFacing;
|
|
94
|
+
if (!af.provider)
|
|
95
|
+
missing.push("agentFacing.provider");
|
|
96
|
+
if (!af.model)
|
|
97
|
+
missing.push("agentFacing.model");
|
|
98
|
+
}
|
|
99
|
+
if (missing.length > 0) {
|
|
100
|
+
checks.push({ label: `${agentDir}/agent.json`, status: "warn", detail: `missing fields: ${missing.join(", ")}` });
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
checks.push({ label: `${agentDir}/agent.json`, status: "pass", detail: "valid" });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { name: "Agents", checks };
|
|
107
|
+
}
|
|
108
|
+
function checkSenses(deps) {
|
|
109
|
+
const checks = [];
|
|
110
|
+
const agents = discoverAgents(deps);
|
|
111
|
+
for (const agentDir of agents) {
|
|
112
|
+
const configPath = `${deps.bundlesRoot}/${agentDir}/agent.json`;
|
|
113
|
+
if (!deps.existsSync(configPath))
|
|
114
|
+
continue;
|
|
115
|
+
let config;
|
|
116
|
+
try {
|
|
117
|
+
config = JSON.parse(deps.readFileSync(configPath));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
checks.push({ label: `${agentDir} senses`, status: "fail", detail: "agent.json unparseable" });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!config.senses || typeof config.senses !== "object") {
|
|
124
|
+
checks.push({ label: `${agentDir} senses`, status: "warn", detail: "no senses config block" });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const senses = config.senses;
|
|
128
|
+
const senseNames = ["cli", "teams", "bluebubbles"];
|
|
129
|
+
for (const sense of senseNames) {
|
|
130
|
+
if (!(sense in senses))
|
|
131
|
+
continue;
|
|
132
|
+
const entry = senses[sense];
|
|
133
|
+
if (!entry || typeof entry !== "object") {
|
|
134
|
+
checks.push({ label: `${agentDir} ${sense}`, status: "fail", detail: "malformed sense entry" });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const senseObj = entry;
|
|
138
|
+
if (typeof senseObj.enabled !== "boolean") {
|
|
139
|
+
checks.push({ label: `${agentDir} ${sense}`, status: "warn", detail: "missing enabled boolean" });
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
checks.push({
|
|
143
|
+
label: `${agentDir} ${sense}`,
|
|
144
|
+
status: "pass",
|
|
145
|
+
detail: senseObj.enabled ? "enabled" : "disabled",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (checks.length === 0) {
|
|
151
|
+
checks.push({ label: "senses", status: "warn", detail: "no agents with senses config found" });
|
|
152
|
+
}
|
|
153
|
+
return { name: "Senses", checks };
|
|
154
|
+
}
|
|
155
|
+
function checkHabits(deps) {
|
|
156
|
+
const checks = [];
|
|
157
|
+
const agents = discoverAgents(deps);
|
|
158
|
+
for (const agentDir of agents) {
|
|
159
|
+
const agentName = agentDir.replace(/\.ouro$/, "");
|
|
160
|
+
const habitsDir = `${deps.bundlesRoot}/${agentDir}/habits`;
|
|
161
|
+
if (!deps.existsSync(habitsDir)) {
|
|
162
|
+
checks.push({ label: `${agentDir} habits dir`, status: "warn", detail: "no habits directory" });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
checks.push({ label: `${agentDir} habits dir`, status: "pass", detail: habitsDir });
|
|
166
|
+
// Check for launchd plists on macOS
|
|
167
|
+
const launchAgentsDir = `${deps.homedir}/Library/LaunchAgents`;
|
|
168
|
+
if (deps.existsSync(launchAgentsDir)) {
|
|
169
|
+
const plists = deps.readdirSync(launchAgentsDir).filter((f) => f.startsWith(`bot.ouro.${agentName}.`) && f.endsWith(".plist"));
|
|
170
|
+
if (plists.length > 0) {
|
|
171
|
+
checks.push({ label: `${agentDir} launchd plists`, status: "pass", detail: `${plists.length} plist(s)` });
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
checks.push({ label: `${agentDir} launchd plists`, status: "fail", detail: "no matching plists in LaunchAgents" });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (checks.length === 0) {
|
|
179
|
+
checks.push({ label: "habits", status: "warn", detail: "no agents found" });
|
|
180
|
+
}
|
|
181
|
+
return { name: "Habits", checks };
|
|
182
|
+
}
|
|
183
|
+
function checkSecurity(deps) {
|
|
184
|
+
const checks = [];
|
|
185
|
+
const agents = discoverAgents(deps);
|
|
186
|
+
for (const agentDir of agents) {
|
|
187
|
+
const agentName = agentDir.replace(/\.ouro$/, "");
|
|
188
|
+
const secretsPath = `${deps.secretsRoot}/${agentName}/secrets.json`;
|
|
189
|
+
if (!deps.existsSync(secretsPath)) {
|
|
190
|
+
checks.push({ label: `${agentDir} secrets.json`, status: "fail", detail: "missing" });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
// Check file permissions
|
|
194
|
+
const stat = deps.statSync(secretsPath);
|
|
195
|
+
const worldReadable = (stat.mode & 0o004) !== 0;
|
|
196
|
+
if (worldReadable) {
|
|
197
|
+
checks.push({ label: `${agentDir} secrets.json perms`, status: "warn", detail: "world-readable — consider chmod 600" });
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
checks.push({ label: `${agentDir} secrets.json perms`, status: "pass", detail: "not world-readable" });
|
|
201
|
+
}
|
|
202
|
+
// Check agent.json for leaked credential keys
|
|
203
|
+
const configPath = `${deps.bundlesRoot}/${agentDir}/agent.json`;
|
|
204
|
+
if (deps.existsSync(configPath)) {
|
|
205
|
+
try {
|
|
206
|
+
const raw = deps.readFileSync(configPath);
|
|
207
|
+
const sensitiveKeys = ["apiKey", "token", "secret", "password"];
|
|
208
|
+
const found = sensitiveKeys.filter((key) => raw.includes(`"${key}"`));
|
|
209
|
+
if (found.length > 0) {
|
|
210
|
+
checks.push({ label: `${agentDir} credential leak`, status: "warn", detail: `agent.json contains keys: ${found.join(", ")}` });
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
checks.push({ label: `${agentDir} credential leak`, status: "pass", detail: "no credential keys in agent.json" });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
checks.push({ label: `${agentDir} credential leak`, status: "fail", detail: "could not read agent.json" });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (checks.length === 0) {
|
|
222
|
+
checks.push({ label: "security", status: "warn", detail: "no agents found" });
|
|
223
|
+
}
|
|
224
|
+
return { name: "Security", checks };
|
|
225
|
+
}
|
|
226
|
+
function checkDisk(deps) {
|
|
227
|
+
const checks = [];
|
|
228
|
+
// Check daemon logs directory
|
|
229
|
+
const logsDir = `${deps.homedir}/.ouro-cli/logs`;
|
|
230
|
+
if (!deps.existsSync(logsDir)) {
|
|
231
|
+
checks.push({ label: "daemon logs dir", status: "warn", detail: `${logsDir} not found` });
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
let totalSize = 0;
|
|
235
|
+
try {
|
|
236
|
+
const files = deps.readdirSync(logsDir);
|
|
237
|
+
for (const file of files) {
|
|
238
|
+
try {
|
|
239
|
+
const stat = deps.statSync(`${logsDir}/${file}`);
|
|
240
|
+
totalSize += stat.size;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// skip unreadable files
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// readdirSync failure handled below
|
|
249
|
+
}
|
|
250
|
+
const sizeMB = totalSize / (1024 * 1024);
|
|
251
|
+
if (sizeMB > 500) {
|
|
252
|
+
checks.push({ label: "daemon log size", status: "fail", detail: `${sizeMB.toFixed(1)}MB — exceeds 500MB limit` });
|
|
253
|
+
}
|
|
254
|
+
else if (sizeMB > 100) {
|
|
255
|
+
checks.push({ label: "daemon log size", status: "warn", detail: `${sizeMB.toFixed(1)}MB — consider pruning with \`ouro logs prune\`` });
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
checks.push({ label: "daemon log size", status: "pass", detail: `${sizeMB.toFixed(1)}MB` });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Check AgentBundles root
|
|
262
|
+
if (deps.existsSync(deps.bundlesRoot)) {
|
|
263
|
+
checks.push({ label: "bundles root", status: "pass", detail: deps.bundlesRoot });
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
checks.push({ label: "bundles root", status: "warn", detail: `${deps.bundlesRoot} not found` });
|
|
267
|
+
}
|
|
268
|
+
return { name: "Disk", checks };
|
|
269
|
+
}
|
|
270
|
+
// ── Orchestrator ──
|
|
271
|
+
function computeSummary(categories) {
|
|
272
|
+
let passed = 0;
|
|
273
|
+
let warnings = 0;
|
|
274
|
+
let failed = 0;
|
|
275
|
+
for (const cat of categories) {
|
|
276
|
+
for (const check of cat.checks) {
|
|
277
|
+
/* v8 ignore next 3 -- all three branches tested; v8 misreports compound if/else-if chain @preserve */
|
|
278
|
+
if (check.status === "pass")
|
|
279
|
+
passed++;
|
|
280
|
+
else if (check.status === "warn")
|
|
281
|
+
warnings++;
|
|
282
|
+
else
|
|
283
|
+
failed++;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return { passed, warnings, failed };
|
|
287
|
+
}
|
|
288
|
+
const CATEGORY_CHECKERS = [
|
|
289
|
+
{ name: "Daemon", fn: checkDaemon },
|
|
290
|
+
{ name: "Agents", fn: checkAgents },
|
|
291
|
+
{ name: "Senses", fn: checkSenses },
|
|
292
|
+
{ name: "Habits", fn: checkHabits },
|
|
293
|
+
{ name: "Security", fn: checkSecurity },
|
|
294
|
+
{ name: "Disk", fn: checkDisk },
|
|
295
|
+
];
|
|
296
|
+
async function runDoctorChecks(deps) {
|
|
297
|
+
const categories = [];
|
|
298
|
+
for (const checker of CATEGORY_CHECKERS) {
|
|
299
|
+
try {
|
|
300
|
+
const category = await Promise.resolve(checker.fn(deps));
|
|
301
|
+
categories.push(category);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
(0, runtime_1.emitNervesEvent)({
|
|
305
|
+
level: "warn",
|
|
306
|
+
component: "daemon",
|
|
307
|
+
event: "daemon.doctor_check_error",
|
|
308
|
+
message: `doctor check ${checker.name} failed`,
|
|
309
|
+
meta: { category: checker.name, error: error instanceof Error ? error.message : String(error) },
|
|
310
|
+
});
|
|
311
|
+
categories.push({
|
|
312
|
+
name: checker.name,
|
|
313
|
+
checks: [{
|
|
314
|
+
label: checker.name.toLowerCase(),
|
|
315
|
+
status: "fail",
|
|
316
|
+
detail: `check crashed: ${error instanceof Error ? error.message : String(error)}`,
|
|
317
|
+
}],
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return { categories, summary: computeSummary(categories) };
|
|
322
|
+
}
|
|
@@ -55,6 +55,7 @@ class DaemonProcessManager {
|
|
|
55
55
|
maxCooldownRetries;
|
|
56
56
|
existsSyncFn;
|
|
57
57
|
configCheckFn;
|
|
58
|
+
statusWriterFn;
|
|
58
59
|
onSnapshotChangeFn;
|
|
59
60
|
/**
|
|
60
61
|
* Notify the snapshot-change observer (if registered). Swallows any
|
|
@@ -80,6 +81,23 @@ class DaemonProcessManager {
|
|
|
80
81
|
});
|
|
81
82
|
}
|
|
82
83
|
}
|
|
84
|
+
writeStatus(agent, text) {
|
|
85
|
+
try {
|
|
86
|
+
this.statusWriterFn(text);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
(0, runtime_1.emitNervesEvent)({
|
|
90
|
+
level: "warn",
|
|
91
|
+
component: "daemon",
|
|
92
|
+
event: "daemon.status_writer_error",
|
|
93
|
+
message: "daemon status writer threw",
|
|
94
|
+
meta: {
|
|
95
|
+
agent,
|
|
96
|
+
error: error instanceof Error ? error.message : String(error),
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
83
101
|
constructor(options) {
|
|
84
102
|
this.maxRestartsPerHour = options.maxRestartsPerHour ?? 10;
|
|
85
103
|
this.stabilityThresholdMs = options.stabilityThresholdMs ?? 60_000;
|
|
@@ -93,6 +111,9 @@ class DaemonProcessManager {
|
|
|
93
111
|
this.clearTimeoutFn = options.clearTimeoutFn ?? ((timer) => clearTimeout(timer));
|
|
94
112
|
this.existsSyncFn = options.existsSync ?? null;
|
|
95
113
|
this.configCheckFn = options.configCheck ?? null;
|
|
114
|
+
this.statusWriterFn = options.statusWriter ?? ((text) => {
|
|
115
|
+
process.stderr.write(text);
|
|
116
|
+
});
|
|
96
117
|
this.onSnapshotChangeFn = options.onSnapshotChange ?? null;
|
|
97
118
|
for (const agent of options.agents) {
|
|
98
119
|
this.agents.set(agent.name, {
|
|
@@ -153,7 +174,7 @@ class DaemonProcessManager {
|
|
|
153
174
|
message: result.error ?? "agent config validation failed",
|
|
154
175
|
meta: { agent, fix: result.fix ?? null },
|
|
155
176
|
});
|
|
156
|
-
|
|
177
|
+
this.writeStatus(agent, `[daemon] ${agent}: ${result.error}\n` +
|
|
157
178
|
(result.fix ? ` Fix: ${result.fix}\n` : ""));
|
|
158
179
|
this.notifySnapshotChange(state.snapshot);
|
|
159
180
|
return;
|