@lifeaitools/clauth 1.30.15 → 1.30.17
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.
|
@@ -82,6 +82,23 @@ function appendSupervisorEvent(event) {
|
|
|
82
82
|
appendJsonl(file("events.jsonl"), { ts: now(), ...event });
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
async function probeSurfaceHealth(url, fetchImpl, timeoutMs) {
|
|
86
|
+
try {
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
89
|
+
try {
|
|
90
|
+
const response = await fetchImpl(url, { signal: controller.signal });
|
|
91
|
+
return response?.ok
|
|
92
|
+
? { healthy: true, error: null }
|
|
93
|
+
: { healthy: false, error: `HTTP ${response?.status ?? "unknown"}` };
|
|
94
|
+
} finally {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
return { healthy: false, error: err?.name === "AbortError" ? "health timeout" : String(err?.message || err) };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
85
102
|
/**
|
|
86
103
|
* Probe enabled local clauth-owned surfaces and repair an unavailable one via
|
|
87
104
|
* the same governed action path exposed to Dev Center. External/plugin-owned
|
|
@@ -95,21 +112,9 @@ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, tim
|
|
|
95
112
|
const url = healthUrlForSurface(surface);
|
|
96
113
|
if (!url) continue;
|
|
97
114
|
const observedAt = now();
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const controller = new AbortController();
|
|
102
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
103
|
-
try {
|
|
104
|
-
const response = await fetchImpl(url, { signal: controller.signal });
|
|
105
|
-
healthy = Boolean(response?.ok);
|
|
106
|
-
if (!healthy) error = `HTTP ${response?.status ?? "unknown"}`;
|
|
107
|
-
} finally {
|
|
108
|
-
clearTimeout(timer);
|
|
109
|
-
}
|
|
110
|
-
} catch (err) {
|
|
111
|
-
error = err?.name === "AbortError" ? "health timeout" : String(err?.message || err);
|
|
112
|
-
}
|
|
115
|
+
const health = await probeSurfaceHealth(url, fetchImpl, timeoutMs);
|
|
116
|
+
const healthy = health.healthy;
|
|
117
|
+
const error = health.error;
|
|
113
118
|
|
|
114
119
|
if (healthy) {
|
|
115
120
|
updateSurfaceState(id, { state: "current", last_health_at: observedAt, last_health_ok: true, last_health_error: null });
|
|
@@ -133,9 +138,18 @@ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, tim
|
|
|
133
138
|
});
|
|
134
139
|
appendSupervisorEvent({ kind: "surface_health_failed", surface_id: id, error, health: url });
|
|
135
140
|
const receipt = runSurfaceAction(id, "reconcile", "supervisor-health-loop");
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
141
|
+
const commandCompleted = receipt?.resulting_state?.ok === true;
|
|
142
|
+
const postHealth = commandCompleted ? await probeSurfaceHealth(url, fetchImpl, timeoutMs) : { healthy: false, error: receipt?.resulting_state?.state || "reconcile_failed" };
|
|
143
|
+
const repaired = commandCompleted && postHealth.healthy;
|
|
144
|
+
updateSurfaceState(id, {
|
|
145
|
+
state: repaired ? "current" : "unavailable",
|
|
146
|
+
last_health_at: now(),
|
|
147
|
+
last_health_ok: repaired,
|
|
148
|
+
last_health_error: repaired ? null : postHealth.error,
|
|
149
|
+
last_reconcile_operation_id: receipt?.operationId || null,
|
|
150
|
+
});
|
|
151
|
+
appendSupervisorEvent({ kind: repaired ? "surface_reconciled" : "surface_reconcile_failed", surface_id: id, operation_id: receipt?.operationId || null, command_completed: commandCompleted, health_ok: postHealth.healthy, error: postHealth.error || null });
|
|
152
|
+
inspected.push({ surface_id: id, state: repaired ? "reconciled" : "reconcile_failed", error: postHealth.error || error, operation_id: receipt?.operationId || null, observed_at: observedAt });
|
|
139
153
|
}
|
|
140
154
|
return { inspected };
|
|
141
155
|
}
|
|
@@ -480,20 +494,33 @@ export function runSurfaceAction(id, action, actor = "localhost") {
|
|
|
480
494
|
if (!command || command.length === 0) {
|
|
481
495
|
return operation(action, { surface_id: id }, surface, { ok: false, state: "command_missing" }, actor);
|
|
482
496
|
}
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
497
|
+
const execute = (selectedCommand) => {
|
|
498
|
+
const [cmd, ...args] = selectedCommand;
|
|
499
|
+
return spawnSync(cmd, args, {
|
|
500
|
+
cwd: surface.cwd || undefined,
|
|
501
|
+
env: { ...process.env, CLAUTH_PM2_HOME: getClauthPm2Home(), PM2_HOME: getClauthPm2Home() },
|
|
502
|
+
windowsHide: true,
|
|
503
|
+
encoding: "utf8",
|
|
504
|
+
timeout: Number(surface.timeoutMs || 30000),
|
|
505
|
+
});
|
|
506
|
+
};
|
|
507
|
+
let result = execute(command);
|
|
508
|
+
let fallbackUsed = false;
|
|
509
|
+
// PM2 restart returns non-zero when the process was deleted. Reconcile is
|
|
510
|
+
// allowed to fall back to the declared start command; explicit restart keeps
|
|
511
|
+
// its strict failure semantics for operator-requested actions.
|
|
512
|
+
if (action === "reconcile" && result.status !== 0 && Array.isArray(surface.start) && surface.start.length > 0 && command !== surface.start) {
|
|
513
|
+
const restartStatus = result.status;
|
|
514
|
+
result = execute(surface.start);
|
|
515
|
+
fallbackUsed = true;
|
|
516
|
+
result.stderr = `restart exited ${restartStatus}; start fallback attempted\n${result.stderr || ""}`;
|
|
517
|
+
}
|
|
491
518
|
return operation(action, { surface_id: id }, surface, {
|
|
492
519
|
ok: result.status === 0,
|
|
493
520
|
state: result.status === 0 ? "operation_completed" : "operation_failed",
|
|
494
521
|
status: result.status,
|
|
495
522
|
stderr: result.stderr?.slice(0, 2000),
|
|
496
|
-
evidence: [`CLAUTH_PM2_HOME=${getClauthPm2Home()}
|
|
523
|
+
evidence: [`CLAUTH_PM2_HOME=${getClauthPm2Home()}`, ...(fallbackUsed ? ["reconcile_start_fallback=true"] : [])],
|
|
497
524
|
}, actor);
|
|
498
525
|
}
|
|
499
526
|
|
|
@@ -174,6 +174,29 @@ test("surface actions use dedicated clauth PM2 home and keep CodeFlow observe-on
|
|
|
174
174
|
assert.equal(supervisorHealth().surfaces, 2);
|
|
175
175
|
}));
|
|
176
176
|
|
|
177
|
+
test("reconcile falls back to the declared start command when restart reports a missing process", () => withTempSupervisor((root) => {
|
|
178
|
+
const managed = path.join(root, "managed");
|
|
179
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
180
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
181
|
+
writePlugin(root, "managed", "fallback-demo", baseManifest("fallback-demo", {
|
|
182
|
+
core: true,
|
|
183
|
+
enable_default: true,
|
|
184
|
+
surfaces: [{
|
|
185
|
+
id: "primary",
|
|
186
|
+
destination: "local/clauth/pm2",
|
|
187
|
+
lifecycle_owner: "clauth",
|
|
188
|
+
port: 39114,
|
|
189
|
+
health: "/health",
|
|
190
|
+
start: [process.execPath, "--version"],
|
|
191
|
+
restart: [process.execPath, "-e", "process.exit(1)"],
|
|
192
|
+
}],
|
|
193
|
+
}));
|
|
194
|
+
discoverPlugins();
|
|
195
|
+
const receipt = runSurfaceAction("fallback-demo:primary", "reconcile");
|
|
196
|
+
assert.equal(receipt.resulting_state.ok, true);
|
|
197
|
+
assert.equal(receipt.resulting_state.evidence.includes("reconcile_start_fallback=true"), true);
|
|
198
|
+
}));
|
|
199
|
+
|
|
177
200
|
test("surface promote and rollback never fall through to restart commands", () => withTempSupervisor((root) => {
|
|
178
201
|
const managed = path.join(root, "managed");
|
|
179
202
|
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
|
|
@@ -218,14 +241,46 @@ test("health reconciliation marks a failed clauth surface and repairs it through
|
|
|
218
241
|
}],
|
|
219
242
|
}));
|
|
220
243
|
discoverPlugins();
|
|
244
|
+
let healthCalls = 0;
|
|
221
245
|
const result = await reconcileSurfaceHealth({
|
|
222
|
-
fetchImpl: async () => ({ ok:
|
|
246
|
+
fetchImpl: async () => ({ ok: ++healthCalls > 1, status: 503 }),
|
|
223
247
|
});
|
|
224
248
|
assert.equal(result.inspected[0].surface_id, "health-demo:primary");
|
|
225
249
|
assert.equal(result.inspected[0].state, "reconciled");
|
|
226
250
|
const surface = listSurfaces().find((item) => item.plugin_id === "health-demo");
|
|
227
251
|
assert.equal(surface.state, "current");
|
|
228
252
|
assert.ok(surface.last_reconcile_operation_id);
|
|
253
|
+
assert.equal(healthCalls, 2);
|
|
254
|
+
} finally {
|
|
255
|
+
if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
|
|
256
|
+
else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
|
|
257
|
+
if (oldManaged === undefined) delete process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
258
|
+
else process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = oldManaged;
|
|
259
|
+
if (oldUser === undefined) delete process.env.CLAUTH_USER_PLUGIN_ROOTS;
|
|
260
|
+
else process.env.CLAUTH_USER_PLUGIN_ROOTS = oldUser;
|
|
261
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("health reconciliation does not claim current when a successful command leaves health down", async () => {
|
|
266
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-post-health-"));
|
|
267
|
+
const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
|
|
268
|
+
const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
269
|
+
const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
|
|
270
|
+
process.env.CLAUTH_SUPERVISOR_DIR = root;
|
|
271
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
|
|
272
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
273
|
+
try {
|
|
274
|
+
writePlugin(root, "managed", "post-health-demo", baseManifest("post-health-demo", {
|
|
275
|
+
core: true,
|
|
276
|
+
enable_default: true,
|
|
277
|
+
surfaces: [{ id: "primary", destination: "local/clauth/pm2", lifecycle_owner: "clauth", port: 39113, health: "/health", restart: [process.execPath, "--version"] }],
|
|
278
|
+
}));
|
|
279
|
+
discoverPlugins();
|
|
280
|
+
const result = await reconcileSurfaceHealth({ fetchImpl: async () => ({ ok: false, status: 503 }) });
|
|
281
|
+
assert.equal(result.inspected[0].state, "reconcile_failed");
|
|
282
|
+
assert.equal(listSurfaces()[0].state, "unavailable");
|
|
283
|
+
assert.equal(listSurfaces()[0].last_health_ok, false);
|
|
229
284
|
} finally {
|
|
230
285
|
if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
|
|
231
286
|
else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
|