@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
- let healthy = false;
99
- let error = null;
100
- try {
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 repaired = receipt?.resulting_state?.ok === true;
137
- updateSurfaceState(id, { state: repaired ? "current" : "unavailable", last_reconcile_operation_id: receipt?.operationId || null });
138
- inspected.push({ surface_id: id, state: repaired ? "reconciled" : "reconcile_failed", error, operation_id: receipt?.operationId || null, observed_at: observedAt });
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 [cmd, ...args] = command;
484
- const result = spawnSync(cmd, args, {
485
- cwd: surface.cwd || undefined,
486
- env: { ...process.env, CLAUTH_PM2_HOME: getClauthPm2Home(), PM2_HOME: getClauthPm2Home() },
487
- windowsHide: true,
488
- encoding: "utf8",
489
- timeout: Number(surface.timeoutMs || 30000),
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: false, status: 503 }),
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "1.30.15",
3
+ "version": "1.30.17",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {