@syengup/friday-channel-next 0.0.45 → 0.1.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/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { sseEmitter } from "./src/sse/emitter.js";
9
9
  import { forwardAgentEventRaw, getLastRegisteredFridayDeviceId, resolveFridayDeviceIdForSessionKey, } from "./src/friday-session.js";
10
10
  import { setFridayAgentForwardRuntime } from "./src/agent-forward-runtime.js";
11
11
  import { getOpenClawAgentRunContext } from "./src/agent-run-context-bridge.js";
12
+ import { accumulateRunUsage } from "./src/agent/run-usage-accumulator.js";
12
13
  export { fridayNextChannelPlugin } from "./src/channel.js";
13
14
  export { setFridayNextRuntime } from "./src/runtime.js";
14
15
  /** `api.on` returns void — register tool hooks at most once per process. */
@@ -95,6 +96,15 @@ export default defineChannelPluginEntry({
95
96
  sessionKey: evt.sessionKey,
96
97
  });
97
98
  });
99
+ api.on("llm_output", (event) => {
100
+ accumulateRunUsage(event.runId, {
101
+ input: event.usage?.input,
102
+ output: event.usage?.output,
103
+ cacheRead: event.usage?.cacheRead,
104
+ cacheWrite: event.usage?.cacheWrite,
105
+ total: event.usage?.total,
106
+ }, event.model, event.provider);
107
+ });
98
108
  if (fridayNextToolHooksRegistered) {
99
109
  return;
100
110
  }
@@ -0,0 +1,13 @@
1
+ import type { FridaySessionUsagePayload } from "../session-usage-snapshot.js";
2
+ type UsageFields = {
3
+ input?: number;
4
+ output?: number;
5
+ cacheRead?: number;
6
+ cacheWrite?: number;
7
+ total?: number;
8
+ };
9
+ export declare function accumulateRunUsage(runId: string, usage: UsageFields, model?: string, provider?: string): void;
10
+ export declare function consumeRunUsage(runId: string): FridaySessionUsagePayload | undefined;
11
+ /** Vitest-only. */
12
+ export declare function resetRunUsageAccumulatorForTest(): void;
13
+ export {};
@@ -0,0 +1,58 @@
1
+ const usageByRunId = new Map();
2
+ function ensure(runId) {
3
+ let entry = usageByRunId.get(runId);
4
+ if (!entry) {
5
+ entry = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
6
+ usageByRunId.set(runId, entry);
7
+ }
8
+ return entry;
9
+ }
10
+ export function accumulateRunUsage(runId, usage, model, provider) {
11
+ if (!runId.trim())
12
+ return;
13
+ const entry = ensure(runId);
14
+ if (typeof usage.input === "number" && usage.input > 0)
15
+ entry.input += usage.input;
16
+ if (typeof usage.output === "number" && usage.output > 0)
17
+ entry.output += usage.output;
18
+ if (typeof usage.cacheRead === "number" && usage.cacheRead > 0)
19
+ entry.cacheRead += usage.cacheRead;
20
+ if (typeof usage.cacheWrite === "number" && usage.cacheWrite > 0)
21
+ entry.cacheWrite += usage.cacheWrite;
22
+ if (typeof usage.total === "number" && usage.total > 0)
23
+ entry.total += usage.total;
24
+ if (model && model.trim())
25
+ entry.model = model.trim();
26
+ if (provider && provider.trim())
27
+ entry.provider = provider.trim();
28
+ }
29
+ export function consumeRunUsage(runId) {
30
+ const entry = usageByRunId.get(runId);
31
+ if (!entry)
32
+ return undefined;
33
+ usageByRunId.delete(runId);
34
+ const tokens = {};
35
+ if (entry.input > 0)
36
+ tokens.input = entry.input;
37
+ if (entry.output > 0)
38
+ tokens.output = entry.output;
39
+ if (entry.cacheRead > 0)
40
+ tokens.cacheRead = entry.cacheRead;
41
+ if (entry.cacheWrite > 0)
42
+ tokens.cacheWrite = entry.cacheWrite;
43
+ if (entry.total > 0)
44
+ tokens.total = entry.total;
45
+ tokens.totalFresh = true;
46
+ if (Object.keys(tokens).length === 1)
47
+ return undefined; // only totalFresh, no actual tokens
48
+ const payload = { tokens };
49
+ if (entry.model)
50
+ payload.modelId = entry.model;
51
+ if (entry.provider)
52
+ payload.modelProvider = entry.provider;
53
+ return payload;
54
+ }
55
+ /** Vitest-only. */
56
+ export function resetRunUsageAccumulatorForTest() {
57
+ usageByRunId.clear();
58
+ }
@@ -4,6 +4,7 @@ import { toSessionStoreKey } from "./session/session-manager.js";
4
4
  import { getOpenClawAgentRunContext } from "./agent-run-context-bridge.js";
5
5
  import { observeAgentEventForActiveRuns } from "./agent/active-runs.js";
6
6
  import { getRunMetadata, ingestAgentEventMetadata } from "./run-metadata.js";
7
+ import { consumeRunUsage } from "./agent/run-usage-accumulator.js";
7
8
  import { buildSessionUsageSnapshot } from "./session-usage-snapshot.js";
8
9
  import { lookupByRunId, registerSessionKeyForRun, registerSpawnIntent, consumeSpawnIntent, ensureSubagentFromSpawnTool, registerEnded as registerSubagentEnded, } from "./agent/subagent-registry.js";
9
10
  /** Last `data.text` per run for `stream: "thinking"` — OpenClaw core may send cumulative `delta`; we rewrite true increments for the app. */
@@ -162,6 +163,59 @@ function tryReadSessionUsageFromStore(sessionKeyForStore) {
162
163
  return undefined;
163
164
  }
164
165
  }
166
+ function buildSessionUsageFromRunMetadata(runId) {
167
+ const meta = getRunMetadata(runId);
168
+ if (!meta)
169
+ return undefined;
170
+ const payload = {};
171
+ if (typeof meta.modelName === "string" && meta.modelName.trim()) {
172
+ payload.modelId = meta.modelName.trim();
173
+ }
174
+ if (typeof meta.modelProvider === "string" && meta.modelProvider.trim()) {
175
+ payload.modelProvider = meta.modelProvider.trim();
176
+ }
177
+ const tokens = {};
178
+ if (typeof meta.inputTokens === "number")
179
+ tokens.input = meta.inputTokens;
180
+ if (typeof meta.outputTokens === "number")
181
+ tokens.output = meta.outputTokens;
182
+ if (typeof meta.cacheReadTokens === "number")
183
+ tokens.cacheRead = meta.cacheReadTokens;
184
+ if (typeof meta.cacheWriteTokens === "number")
185
+ tokens.cacheWrite = meta.cacheWriteTokens;
186
+ if (typeof meta.totalTokens === "number")
187
+ tokens.total = meta.totalTokens;
188
+ if (Object.keys(tokens).length > 0)
189
+ payload.tokens = tokens;
190
+ const context = {};
191
+ if (typeof meta.contextWindowMax === "number")
192
+ context.windowMax = meta.contextWindowMax;
193
+ if (typeof meta.totalTokens === "number")
194
+ context.used = meta.totalTokens;
195
+ if (Object.keys(context).length > 0)
196
+ payload.context = context;
197
+ if (!payload.modelId && !payload.modelProvider && !payload.tokens && !payload.context) {
198
+ return undefined;
199
+ }
200
+ return payload;
201
+ }
202
+ function mergeUsage(llmUsage, memUsage) {
203
+ if (!llmUsage && !memUsage)
204
+ return undefined;
205
+ if (!llmUsage)
206
+ return memUsage;
207
+ if (!memUsage)
208
+ return llmUsage;
209
+ // llm_output tokens are authoritative (per API call, no race);
210
+ // RunMetadata fills context window gaps.
211
+ return {
212
+ modelId: llmUsage.modelId ?? memUsage.modelId,
213
+ modelProvider: llmUsage.modelProvider ?? memUsage.modelProvider,
214
+ tokens: llmUsage.tokens,
215
+ context: memUsage.context ?? llmUsage.context,
216
+ estimatedCostUsd: llmUsage.estimatedCostUsd ?? memUsage.estimatedCostUsd,
217
+ };
218
+ }
165
219
  function completeAgentEventForward(params) {
166
220
  const { evt, sk, deviceIdRaw, outgoingData, isTerminalLifecycle, subagentMeta } = params;
167
221
  observeAgentEventForActiveRuns({ stream: evt.stream, runId: evt.runId, data: outgoingData });
@@ -366,23 +420,38 @@ export function forwardAgentEventRaw(evt) {
366
420
  }, ended.deviceId);
367
421
  }
368
422
  }
369
- if (isTerminalLifecycle && getFridayAgentForwardRuntime()) {
370
- setImmediate(() => {
371
- let data = outgoingData;
372
- const usage = tryReadSessionUsageFromStore(sk);
423
+ // Build sessionUsage: llm_output hook (primary, no race) → store read (fallback).
424
+ if (isTerminalLifecycle) {
425
+ const llmUsage = consumeRunUsage(evt.runId);
426
+ const memUsage = buildSessionUsageFromRunMetadata(evt.runId);
427
+ const hasRealTokens = llmUsage?.tokens && Object.keys(llmUsage.tokens).length > 1;
428
+ if (hasRealTokens) {
429
+ const usage = mergeUsage(llmUsage, memUsage);
373
430
  if (usage) {
374
- data = { ...outgoingData, sessionUsage: usage };
431
+ outgoingData = { ...outgoingData, sessionUsage: usage };
375
432
  }
376
- completeAgentEventForward({
377
- evt,
378
- sk,
379
- deviceIdRaw,
380
- outgoingData: data,
381
- isTerminalLifecycle: true,
382
- subagentMeta,
383
- });
384
- });
385
- return;
433
+ }
434
+ else if (getFridayAgentForwardRuntime()) {
435
+ // llm_output hook fires async ~20ms after lifecycle.end.
436
+ // Wait 100ms then re-check before falling back to store read.
437
+ setTimeout(() => {
438
+ let data = outgoingData;
439
+ const retryLlm = consumeRunUsage(evt.runId);
440
+ const usage = mergeUsage(retryLlm, memUsage) ?? tryReadSessionUsageFromStore(sk);
441
+ if (usage) {
442
+ data = { ...outgoingData, sessionUsage: usage };
443
+ }
444
+ completeAgentEventForward({
445
+ evt,
446
+ sk,
447
+ deviceIdRaw,
448
+ outgoingData: data,
449
+ isTerminalLifecycle: true,
450
+ subagentMeta,
451
+ });
452
+ }, 100);
453
+ return;
454
+ }
386
455
  }
387
456
  completeAgentEventForward({
388
457
  evt,
@@ -0,0 +1,39 @@
1
+ export interface SelfHealthOptions {
2
+ enabled: boolean;
3
+ checkIntervalMs: number;
4
+ selfHeal: boolean;
5
+ }
6
+ interface CheckResult {
7
+ name: string;
8
+ status: "ok" | "degraded" | "failed";
9
+ detail: string;
10
+ }
11
+ interface RepairAction {
12
+ component: string;
13
+ action: string;
14
+ result: "ok" | "failed" | "skipped";
15
+ detail: string;
16
+ }
17
+ export interface SelfHealthReport {
18
+ timestamp: number;
19
+ checks: CheckResult[];
20
+ repairs: RepairAction[];
21
+ overallStatus: "ok" | "degraded" | "failed";
22
+ }
23
+ export declare class HealthCheckRunner {
24
+ private timer;
25
+ private options;
26
+ constructor(options?: Partial<SelfHealthOptions>);
27
+ updateOptions(opts: Partial<SelfHealthOptions>): void;
28
+ start(_api: unknown): void;
29
+ stop(): void;
30
+ runCheck(): Promise<SelfHealthReport>;
31
+ private checkConfig;
32
+ private checkSseEmitter;
33
+ private checkActiveRuns;
34
+ private repairConfig;
35
+ private repairSseEmitter;
36
+ }
37
+ export declare function getHealthCheckRunner(): HealthCheckRunner;
38
+ export declare function resetHealthCheckRunnerForTest(): void;
39
+ export {};
@@ -0,0 +1,174 @@
1
+ import { resolveFridayNextConfig } from "../config.js";
2
+ import { getHostOpenClawConfigSnapshot } from "../host-config.js";
3
+ import { getFridayNextRuntime } from "../runtime.js";
4
+ import { sseEmitter } from "../sse/emitter.js";
5
+ import { getActiveRunCount } from "../agent/active-runs.js";
6
+ import { createFridayNextLogger } from "../logging.js";
7
+ const log = createFridayNextLogger("health-runner", "info");
8
+ export class HealthCheckRunner {
9
+ timer = null;
10
+ options;
11
+ constructor(options = {}) {
12
+ this.options = {
13
+ enabled: options.enabled ?? true,
14
+ checkIntervalMs: options.checkIntervalMs ?? 60_000,
15
+ selfHeal: options.selfHeal ?? true,
16
+ };
17
+ }
18
+ updateOptions(opts) {
19
+ Object.assign(this.options, opts);
20
+ }
21
+ start(_api) {
22
+ this.stop();
23
+ if (!this.options.enabled) {
24
+ log.info("Self-health check disabled by config");
25
+ return;
26
+ }
27
+ log.info(`Starting self-health checks every ${this.options.checkIntervalMs}ms`);
28
+ this.timer = setInterval(() => {
29
+ this.runCheck().catch((err) => {
30
+ log.error(`Self-health check error: ${err instanceof Error ? err.message : String(err)}`);
31
+ });
32
+ }, this.options.checkIntervalMs);
33
+ if (this.timer && typeof this.timer.unref === "function") {
34
+ this.timer.unref();
35
+ }
36
+ }
37
+ stop() {
38
+ if (this.timer) {
39
+ clearInterval(this.timer);
40
+ this.timer = null;
41
+ }
42
+ }
43
+ async runCheck() {
44
+ const report = {
45
+ timestamp: Date.now(),
46
+ checks: [],
47
+ repairs: [],
48
+ overallStatus: "ok",
49
+ };
50
+ report.checks.push(this.checkConfig());
51
+ report.checks.push(this.checkSseEmitter());
52
+ report.checks.push(this.checkActiveRuns());
53
+ if (this.options.selfHeal) {
54
+ const configCheck = report.checks[0];
55
+ if (configCheck.status === "failed") {
56
+ report.repairs.push(await this.repairConfig());
57
+ }
58
+ const sseCheck = report.checks[1];
59
+ if (sseCheck.status === "failed") {
60
+ report.repairs.push(await this.repairSseEmitter());
61
+ }
62
+ }
63
+ const statuses = report.checks.map((c) => c.status);
64
+ if (statuses.includes("failed")) {
65
+ report.overallStatus = "failed";
66
+ }
67
+ else if (statuses.includes("degraded")) {
68
+ report.overallStatus = "degraded";
69
+ }
70
+ if (report.overallStatus !== "ok" || report.repairs.length > 0) {
71
+ log.warn(`Self-health result: ${report.overallStatus}, ` +
72
+ `checks=${report.checks.length}, repairs=${report.repairs.length}`);
73
+ }
74
+ return report;
75
+ }
76
+ checkConfig() {
77
+ try {
78
+ const runtime = getFridayNextRuntime();
79
+ if (!runtime?.config) {
80
+ return { name: "config", status: "failed", detail: "Runtime config loader not available" };
81
+ }
82
+ const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(runtime.config));
83
+ if (!cfg.authToken) {
84
+ return { name: "config", status: "degraded", detail: "authToken is empty; all requests will 401" };
85
+ }
86
+ return { name: "config", status: "ok", detail: "Config resolved with authToken" };
87
+ }
88
+ catch (err) {
89
+ return {
90
+ name: "config",
91
+ status: "failed",
92
+ detail: `Config resolution error: ${err instanceof Error ? err.message : String(err)}`,
93
+ };
94
+ }
95
+ }
96
+ checkSseEmitter() {
97
+ try {
98
+ const connCount = sseEmitter.getConnectionCount();
99
+ return {
100
+ name: "sseEmitter",
101
+ status: "ok",
102
+ detail: `Active connections: ${connCount}`,
103
+ };
104
+ }
105
+ catch (err) {
106
+ return {
107
+ name: "sseEmitter",
108
+ status: "failed",
109
+ detail: `Emitter check error: ${err instanceof Error ? err.message : String(err)}`,
110
+ };
111
+ }
112
+ }
113
+ checkActiveRuns() {
114
+ try {
115
+ const count = getActiveRunCount();
116
+ return {
117
+ name: "activeRuns",
118
+ status: "ok",
119
+ detail: `Active runs: ${count}`,
120
+ };
121
+ }
122
+ catch (err) {
123
+ return {
124
+ name: "activeRuns",
125
+ status: "failed",
126
+ detail: `Active runs check error: ${err instanceof Error ? err.message : String(err)}`,
127
+ };
128
+ }
129
+ }
130
+ async repairConfig() {
131
+ try {
132
+ const runtime = getFridayNextRuntime();
133
+ if (!runtime?.config) {
134
+ return {
135
+ component: "config",
136
+ action: "re-resolve config",
137
+ result: "failed",
138
+ detail: "Runtime config loader not available",
139
+ };
140
+ }
141
+ resolveFridayNextConfig(getHostOpenClawConfigSnapshot(runtime.config));
142
+ return { component: "config", action: "re-resolve config", result: "ok", detail: "Config re-resolved" };
143
+ }
144
+ catch (err) {
145
+ return {
146
+ component: "config",
147
+ action: "re-resolve config",
148
+ result: "failed",
149
+ detail: err instanceof Error ? err.message : String(err),
150
+ };
151
+ }
152
+ }
153
+ async repairSseEmitter() {
154
+ return {
155
+ component: "sseEmitter",
156
+ action: "verify emitter",
157
+ result: "ok",
158
+ detail: "SSE emitter singleton is accessible",
159
+ };
160
+ }
161
+ }
162
+ let healthCheckRunner = null;
163
+ export function getHealthCheckRunner() {
164
+ if (!healthCheckRunner) {
165
+ healthCheckRunner = new HealthCheckRunner();
166
+ }
167
+ return healthCheckRunner;
168
+ }
169
+ export function resetHealthCheckRunnerForTest() {
170
+ if (healthCheckRunner) {
171
+ healthCheckRunner.stop();
172
+ healthCheckRunner = null;
173
+ }
174
+ }
@@ -0,0 +1,23 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ export interface HealthComponentStatus {
3
+ status: "ok" | "degraded" | "failed" | "pending" | "not_found";
4
+ detail: string;
5
+ [key: string]: unknown;
6
+ }
7
+ interface RepairAction {
8
+ component: string;
9
+ action: string;
10
+ result: "ok" | "failed";
11
+ detail: string;
12
+ }
13
+ export interface HealthCheckResult {
14
+ ok: boolean;
15
+ timestamp: number;
16
+ deviceId: string;
17
+ nodeDeviceId: string;
18
+ devicePairing?: HealthComponentStatus;
19
+ nodePairing?: HealthComponentStatus;
20
+ repairActions?: RepairAction[];
21
+ }
22
+ export declare function handleHealth(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
23
+ export {};
@@ -0,0 +1,225 @@
1
+ import { listDevicePairing, approveDevicePairing } from "openclaw/plugin-sdk/device-bootstrap";
2
+ import { extractBearerToken } from "../middleware/auth.js";
3
+ import { loadNodePairingModule } from "../../agent/node-pairing-bridge.js";
4
+ import { createFridayNextLogger } from "../../logging.js";
5
+ const REQUIRED_NODE_CAPS = ["location", "canvas"];
6
+ const REQUIRED_NODE_COMMANDS = [
7
+ "location.get",
8
+ "canvas.present",
9
+ "canvas.hide",
10
+ "canvas.navigate",
11
+ "canvas.eval",
12
+ "canvas.snapshot",
13
+ "canvas.a2ui.push",
14
+ "canvas.a2ui.pushJSONL",
15
+ "canvas.a2ui.reset",
16
+ ];
17
+ export async function handleHealth(req, res) {
18
+ if (req.method !== "GET") {
19
+ res.statusCode = 405;
20
+ res.setHeader("Content-Type", "application/json");
21
+ res.end(JSON.stringify({ error: "Method Not Allowed" }));
22
+ return true;
23
+ }
24
+ const token = extractBearerToken(req);
25
+ if (!token) {
26
+ res.statusCode = 401;
27
+ res.setHeader("Content-Type", "application/json");
28
+ res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
29
+ return true;
30
+ }
31
+ const url = new URL(req.url ?? "/", "http://localhost");
32
+ const deviceId = (url.searchParams.get("deviceId") ?? "").trim();
33
+ const nodeDeviceId = (url.searchParams.get("nodeDeviceId") ?? "").trim();
34
+ const selfHeal = (url.searchParams.get("selfHeal") ?? "").toLowerCase() === "true";
35
+ const result = {
36
+ ok: true,
37
+ timestamp: Date.now(),
38
+ deviceId,
39
+ nodeDeviceId,
40
+ };
41
+ const log = createFridayNextLogger("health");
42
+ if (deviceId) {
43
+ result.devicePairing = await checkDevicePairing(deviceId, selfHeal, result, log);
44
+ }
45
+ if (nodeDeviceId) {
46
+ result.nodePairing = await checkNodePairing(nodeDeviceId, selfHeal, result, log);
47
+ }
48
+ const statuses = [
49
+ result.devicePairing?.status,
50
+ result.nodePairing?.status,
51
+ ].filter(Boolean);
52
+ result.ok = statuses.length === 0 || statuses.every((s) => s === "ok" || s === "pending");
53
+ res.statusCode = 200;
54
+ res.setHeader("Content-Type", "application/json");
55
+ res.end(JSON.stringify(result));
56
+ return true;
57
+ }
58
+ async function checkDevicePairing(deviceId, selfHeal, result, log) {
59
+ const normalizedDeviceId = deviceId.trim().toUpperCase();
60
+ let pairing;
61
+ try {
62
+ pairing = await listDevicePairing();
63
+ }
64
+ catch (err) {
65
+ log.error(`listDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
66
+ return {
67
+ status: "failed",
68
+ detail: `listDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`,
69
+ devicePaired: false,
70
+ };
71
+ }
72
+ const pairedDevice = (pairing?.paired ?? []).find((entry) => entry.deviceId?.trim().toUpperCase() === normalizedDeviceId);
73
+ if (pairedDevice) {
74
+ const approvedScopes = pairedDevice.approvedScopes ?? [];
75
+ const tokens = pairedDevice.tokens ?? {};
76
+ const hasValidToken = Object.values(tokens).some((t) => !t.revokedAtMs);
77
+ if (approvedScopes.length === 0 || !hasValidToken) {
78
+ const issues = [];
79
+ if (approvedScopes.length === 0)
80
+ issues.push("no approved scopes");
81
+ if (!hasValidToken)
82
+ issues.push("all tokens revoked");
83
+ return {
84
+ status: "degraded",
85
+ detail: `Device paired but degraded: ${issues.join(", ")}`,
86
+ devicePaired: true,
87
+ approvedScopesEmpty: approvedScopes.length === 0,
88
+ tokensRevoked: !hasValidToken,
89
+ };
90
+ }
91
+ return { status: "ok", detail: "Device paired and healthy", devicePaired: true };
92
+ }
93
+ const pendingDevice = (pairing?.pending ?? []).find((entry) => entry.deviceId?.trim().toUpperCase() === normalizedDeviceId);
94
+ if (pendingDevice && selfHeal) {
95
+ try {
96
+ const approved = await approveDevicePairing(pendingDevice.requestId);
97
+ const succeeded = approved && approved.status === "approved";
98
+ (result.repairActions ??= []).push({
99
+ component: "devicePairing",
100
+ action: "approveDevicePairing",
101
+ result: succeeded ? "ok" : "failed",
102
+ detail: succeeded
103
+ ? `Auto-approved device ${normalizedDeviceId}`
104
+ : `approveDevicePairing returned status=${approved?.status ?? "null"}`,
105
+ });
106
+ if (succeeded) {
107
+ log.info(`Auto-approved device ${normalizedDeviceId}`);
108
+ return { status: "ok", detail: "Device was pending, auto-approved", devicePaired: true };
109
+ }
110
+ }
111
+ catch (err) {
112
+ log.error(`approveDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
113
+ (result.repairActions ??= []).push({
114
+ component: "devicePairing",
115
+ action: "approveDevicePairing",
116
+ result: "failed",
117
+ detail: err instanceof Error ? err.message : String(err),
118
+ });
119
+ }
120
+ return {
121
+ status: "degraded",
122
+ detail: "Device pending but auto-approve failed",
123
+ devicePaired: false,
124
+ };
125
+ }
126
+ if (pendingDevice) {
127
+ return { status: "pending", detail: "Device is pending approval", devicePaired: false };
128
+ }
129
+ return { status: "not_found", detail: `Device ${normalizedDeviceId} not registered`, devicePaired: false };
130
+ }
131
+ async function checkNodePairing(nodeDeviceId, selfHeal, result, log) {
132
+ const normalizedNodeId = nodeDeviceId.trim().toUpperCase();
133
+ let listData, listNodePairing, approveNodePairing;
134
+ try {
135
+ ({ listNodePairing, approveNodePairing } = await loadNodePairingModule());
136
+ }
137
+ catch (err) {
138
+ log.error(`loadNodePairingModule failed: ${err instanceof Error ? err.message : String(err)}`);
139
+ return {
140
+ status: "failed",
141
+ detail: `loadNodePairingModule failed: ${err instanceof Error ? err.message : String(err)}`,
142
+ nodePaired: false,
143
+ };
144
+ }
145
+ try {
146
+ listData = await listNodePairing();
147
+ }
148
+ catch (err) {
149
+ log.error(`listNodePairing failed: ${err instanceof Error ? err.message : String(err)}`);
150
+ return {
151
+ status: "failed",
152
+ detail: `listNodePairing failed: ${err instanceof Error ? err.message : String(err)}`,
153
+ nodePaired: false,
154
+ };
155
+ }
156
+ const pairedNodes = listData?.paired ?? [];
157
+ const pairedMatch = pairedNodes.find((entry) => entry.nodeId?.trim().toUpperCase() === normalizedNodeId);
158
+ if (pairedMatch) {
159
+ const caps = pairedMatch.caps ?? [];
160
+ const commands = pairedMatch.commands ?? [];
161
+ const hasRequiredCaps = REQUIRED_NODE_CAPS.every((c) => caps.includes(c));
162
+ const hasRequiredCommands = REQUIRED_NODE_COMMANDS.every((c) => commands.includes(c));
163
+ const capsValid = caps.length > 0 && hasRequiredCaps;
164
+ const commandsValid = commands.length > 0 && hasRequiredCommands;
165
+ if (capsValid && commandsValid) {
166
+ return {
167
+ status: "ok",
168
+ detail: `Node paired with ${caps.length} caps, ${commands.length} commands`,
169
+ nodePaired: true,
170
+ capsCount: caps.length,
171
+ commandsCount: commands.length,
172
+ capsValid: true,
173
+ commandsValid: true,
174
+ };
175
+ }
176
+ return {
177
+ status: "degraded",
178
+ detail: `Node paired but caps/commands incomplete: caps=${caps.length} (valid=${capsValid}), commands=${commands.length} (valid=${commandsValid})`,
179
+ nodePaired: true,
180
+ capsCount: caps.length,
181
+ commandsCount: commands.length,
182
+ capsValid,
183
+ commandsValid,
184
+ };
185
+ }
186
+ const pendingNodes = listData?.pending ?? [];
187
+ const pendingMatch = pendingNodes.find((entry) => entry.nodeId?.trim().toUpperCase() === normalizedNodeId);
188
+ if (pendingMatch && selfHeal) {
189
+ try {
190
+ const callerScopes = ["operator.admin", "operator.pairing", "operator.read", "operator.write"];
191
+ const approved = await approveNodePairing(pendingMatch.requestId, { callerScopes });
192
+ const succeeded = approved != null && !("status" in approved && approved.status === "forbidden") && "requestId" in approved;
193
+ (result.repairActions ??= []).push({
194
+ component: "nodePairing",
195
+ action: "approveNodePairing",
196
+ result: succeeded ? "ok" : "failed",
197
+ detail: succeeded
198
+ ? `Auto-approved node ${normalizedNodeId}`
199
+ : `approveNodePairing returned status=${approved?.status ?? "null"}`,
200
+ });
201
+ if (succeeded) {
202
+ log.info(`Auto-approved node ${normalizedNodeId}`);
203
+ return { status: "ok", detail: "Node was pending, auto-approved", nodePaired: true };
204
+ }
205
+ }
206
+ catch (err) {
207
+ log.error(`approveNodePairing failed: ${err instanceof Error ? err.message : String(err)}`);
208
+ (result.repairActions ??= []).push({
209
+ component: "nodePairing",
210
+ action: "approveNodePairing",
211
+ result: "failed",
212
+ detail: err instanceof Error ? err.message : String(err),
213
+ });
214
+ }
215
+ return {
216
+ status: "degraded",
217
+ detail: "Node pending but auto-approve failed",
218
+ nodePaired: false,
219
+ };
220
+ }
221
+ if (pendingMatch) {
222
+ return { status: "pending", detail: "Node is pending approval", nodePaired: false };
223
+ }
224
+ return { status: "not_found", detail: `Node ${normalizedNodeId} not registered`, nodePaired: false };
225
+ }