@syengup/friday-channel-next 0.0.46 → 0.1.2
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/LICENSE +21 -0
- package/dist/index.js +10 -0
- package/dist/src/agent/run-usage-accumulator.d.ts +13 -0
- package/dist/src/agent/run-usage-accumulator.js +58 -0
- package/dist/src/friday-session.js +84 -15
- package/dist/src/health/self-health.d.ts +39 -0
- package/dist/src/health/self-health.js +174 -0
- package/dist/src/http/handlers/health.d.ts +23 -0
- package/dist/src/http/handlers/health.js +225 -0
- package/dist/src/http/server.js +5 -0
- package/dist/src/run-metadata.d.ts +6 -0
- package/dist/src/run-metadata.js +24 -1
- package/index.ts +16 -0
- package/install.js +4 -0
- package/package.json +12 -10
- package/src/agent/run-usage-accumulator.ts +70 -0
- package/src/friday-session.forward-agent.test.ts +100 -33
- package/src/friday-session.ts +78 -16
- package/src/http/handlers/health.test.ts +515 -0
- package/src/http/handlers/health.ts +289 -0
- package/src/http/server.ts +6 -0
- package/src/run-metadata.ts +28 -1
package/src/friday-session.ts
CHANGED
|
@@ -4,7 +4,9 @@ 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";
|
|
9
|
+
import type { FridaySessionUsagePayload } from "./session-usage-snapshot.js";
|
|
8
10
|
import {
|
|
9
11
|
lookupByRunId,
|
|
10
12
|
registerSessionKeyForRun,
|
|
@@ -178,7 +180,7 @@ function mergeRunMetadataIntoLifecycleEnd(
|
|
|
178
180
|
return { ...base, ...extra };
|
|
179
181
|
}
|
|
180
182
|
|
|
181
|
-
function tryReadSessionUsageFromStore(sessionKeyForStore: string):
|
|
183
|
+
function tryReadSessionUsageFromStore(sessionKeyForStore: string): FridaySessionUsagePayload | undefined {
|
|
182
184
|
const access = getFridayAgentForwardRuntime();
|
|
183
185
|
if (!access) return undefined;
|
|
184
186
|
try {
|
|
@@ -195,6 +197,51 @@ function tryReadSessionUsageFromStore(sessionKeyForStore: string): ReturnType<ty
|
|
|
195
197
|
}
|
|
196
198
|
}
|
|
197
199
|
|
|
200
|
+
function buildSessionUsageFromRunMetadata(runId: string): FridaySessionUsagePayload | undefined {
|
|
201
|
+
const meta = getRunMetadata(runId);
|
|
202
|
+
if (!meta) return undefined;
|
|
203
|
+
const payload: FridaySessionUsagePayload = {};
|
|
204
|
+
if (typeof meta.modelName === "string" && meta.modelName.trim()) {
|
|
205
|
+
payload.modelId = meta.modelName.trim();
|
|
206
|
+
}
|
|
207
|
+
if (typeof meta.modelProvider === "string" && meta.modelProvider.trim()) {
|
|
208
|
+
payload.modelProvider = meta.modelProvider.trim();
|
|
209
|
+
}
|
|
210
|
+
const tokens: NonNullable<typeof payload.tokens> = {};
|
|
211
|
+
if (typeof meta.inputTokens === "number") tokens.input = meta.inputTokens;
|
|
212
|
+
if (typeof meta.outputTokens === "number") tokens.output = meta.outputTokens;
|
|
213
|
+
if (typeof meta.cacheReadTokens === "number") tokens.cacheRead = meta.cacheReadTokens;
|
|
214
|
+
if (typeof meta.cacheWriteTokens === "number") tokens.cacheWrite = meta.cacheWriteTokens;
|
|
215
|
+
if (typeof meta.totalTokens === "number") tokens.total = meta.totalTokens;
|
|
216
|
+
if (Object.keys(tokens).length > 0) payload.tokens = tokens;
|
|
217
|
+
const context: NonNullable<typeof payload.context> = {};
|
|
218
|
+
if (typeof meta.contextWindowMax === "number") context.windowMax = meta.contextWindowMax;
|
|
219
|
+
if (typeof meta.totalTokens === "number") context.used = meta.totalTokens;
|
|
220
|
+
if (Object.keys(context).length > 0) payload.context = context;
|
|
221
|
+
if (!payload.modelId && !payload.modelProvider && !payload.tokens && !payload.context) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
return payload;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function mergeUsage(
|
|
228
|
+
llmUsage: FridaySessionUsagePayload | undefined,
|
|
229
|
+
memUsage: FridaySessionUsagePayload | undefined,
|
|
230
|
+
): FridaySessionUsagePayload | undefined {
|
|
231
|
+
if (!llmUsage && !memUsage) return undefined;
|
|
232
|
+
if (!llmUsage) return memUsage;
|
|
233
|
+
if (!memUsage) return llmUsage;
|
|
234
|
+
// llm_output tokens are authoritative (per API call, no race);
|
|
235
|
+
// RunMetadata fills context window gaps.
|
|
236
|
+
return {
|
|
237
|
+
modelId: llmUsage.modelId ?? memUsage.modelId,
|
|
238
|
+
modelProvider: llmUsage.modelProvider ?? memUsage.modelProvider,
|
|
239
|
+
tokens: llmUsage.tokens,
|
|
240
|
+
context: memUsage.context ?? llmUsage.context,
|
|
241
|
+
estimatedCostUsd: llmUsage.estimatedCostUsd ?? memUsage.estimatedCostUsd,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
198
245
|
function completeAgentEventForward(params: {
|
|
199
246
|
evt: ForwardAgentEventArgs;
|
|
200
247
|
sk: string;
|
|
@@ -436,23 +483,38 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
|
|
|
436
483
|
}
|
|
437
484
|
}
|
|
438
485
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
486
|
+
// Build sessionUsage: llm_output hook (primary, no race) → store read (fallback).
|
|
487
|
+
if (isTerminalLifecycle) {
|
|
488
|
+
const llmUsage = consumeRunUsage(evt.runId);
|
|
489
|
+
const memUsage = buildSessionUsageFromRunMetadata(evt.runId);
|
|
490
|
+
const hasRealTokens = llmUsage?.tokens && Object.keys(llmUsage.tokens).length > 1;
|
|
491
|
+
|
|
492
|
+
if (hasRealTokens) {
|
|
493
|
+
const usage = mergeUsage(llmUsage, memUsage);
|
|
443
494
|
if (usage) {
|
|
444
|
-
|
|
495
|
+
outgoingData = { ...outgoingData, sessionUsage: usage };
|
|
445
496
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
497
|
+
} else if (getFridayAgentForwardRuntime()) {
|
|
498
|
+
// llm_output hook fires async ~20ms after lifecycle.end.
|
|
499
|
+
// Wait 100ms then re-check before falling back to store read.
|
|
500
|
+
setTimeout(() => {
|
|
501
|
+
let data = outgoingData;
|
|
502
|
+
const retryLlm = consumeRunUsage(evt.runId);
|
|
503
|
+
const usage = mergeUsage(retryLlm, memUsage) ?? tryReadSessionUsageFromStore(sk);
|
|
504
|
+
if (usage) {
|
|
505
|
+
data = { ...outgoingData, sessionUsage: usage };
|
|
506
|
+
}
|
|
507
|
+
completeAgentEventForward({
|
|
508
|
+
evt,
|
|
509
|
+
sk,
|
|
510
|
+
deviceIdRaw,
|
|
511
|
+
outgoingData: data,
|
|
512
|
+
isTerminalLifecycle: true,
|
|
513
|
+
subagentMeta,
|
|
514
|
+
});
|
|
515
|
+
}, 100);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
456
518
|
}
|
|
457
519
|
|
|
458
520
|
completeAgentEventForward({
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import { setMockRuntime } from "../../test-support/mock-runtime.js";
|
|
5
|
+
import { __setMockNodePairingForTests } from "../../agent/node-pairing-bridge.js";
|
|
6
|
+
|
|
7
|
+
const { mockListDevices, mockApproveDevice } = vi.hoisted(() => ({
|
|
8
|
+
mockListDevices: vi.fn(),
|
|
9
|
+
mockApproveDevice: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("openclaw/plugin-sdk/device-bootstrap", () => ({
|
|
13
|
+
listDevicePairing: mockListDevices,
|
|
14
|
+
approveDevicePairing: mockApproveDevice,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const { mockListNodePairing, mockApproveNodePairing } = vi.hoisted(() => ({
|
|
18
|
+
mockListNodePairing: vi.fn(),
|
|
19
|
+
mockApproveNodePairing: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { handleHealth } from "./health.js";
|
|
23
|
+
|
|
24
|
+
class MockRes extends EventEmitter {
|
|
25
|
+
statusCode = 0;
|
|
26
|
+
headers: Record<string, string> = {};
|
|
27
|
+
body = "";
|
|
28
|
+
setHeader(name: string, value: string): void {
|
|
29
|
+
this.headers[name.toLowerCase()] = value;
|
|
30
|
+
}
|
|
31
|
+
end(body?: string): void {
|
|
32
|
+
if (body) this.body += body;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mockReq(method: string, url: string, headers: Record<string, string> = {}): IncomingMessage {
|
|
37
|
+
return { method, url, headers } as IncomingMessage;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEVICE_ID = "a80b8c4b305fb02c5772c409c6dfcbacde691b61557f7779511ad1a5be8fdf06";
|
|
41
|
+
const NODE_ID = "b91c9d5c416gc13d6883d510d7egcdbdf702c72668g8888622be2b6cf9eg17";
|
|
42
|
+
const REQUEST_ID = "12f150e8-b1bc-4688-be23-e3a7fa8b9e51";
|
|
43
|
+
|
|
44
|
+
describe("handleHealth", () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
setMockRuntime();
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
mockListDevices.mockResolvedValue({ pending: [], paired: [] });
|
|
49
|
+
mockApproveDevice.mockResolvedValue({ status: "approved", requestId: REQUEST_ID });
|
|
50
|
+
__setMockNodePairingForTests({
|
|
51
|
+
listNodePairing: mockListNodePairing,
|
|
52
|
+
approveNodePairing: mockApproveNodePairing,
|
|
53
|
+
});
|
|
54
|
+
mockListNodePairing.mockResolvedValue({ pending: [], paired: [] });
|
|
55
|
+
mockApproveNodePairing.mockResolvedValue({ requestId: REQUEST_ID, node: { nodeId: NODE_ID } });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// --- Method & Auth ---
|
|
59
|
+
|
|
60
|
+
it("returns 405 on non-GET", async () => {
|
|
61
|
+
const req = mockReq("POST", "/friday-next/health");
|
|
62
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
63
|
+
await handleHealth(req, res);
|
|
64
|
+
expect((res as unknown as MockRes).statusCode).toBe(405);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns 401 for missing auth", async () => {
|
|
68
|
+
const req = mockReq("GET", "/friday-next/health");
|
|
69
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
70
|
+
await handleHealth(req, res);
|
|
71
|
+
expect((res as unknown as MockRes).statusCode).toBe(401);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// --- Basic health (no IDs) ---
|
|
75
|
+
|
|
76
|
+
it("returns 200 with ok:true when no IDs provided", async () => {
|
|
77
|
+
const req = mockReq("GET", "/friday-next/health", { authorization: "Bearer test-token" });
|
|
78
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
79
|
+
await handleHealth(req, res);
|
|
80
|
+
expect((res as unknown as MockRes).statusCode).toBe(200);
|
|
81
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
82
|
+
expect(body.ok).toBe(true);
|
|
83
|
+
expect(body.deviceId).toBe("");
|
|
84
|
+
expect(body.nodeDeviceId).toBe("");
|
|
85
|
+
expect(body.repairActions).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// --- selfHeal parameter ---
|
|
89
|
+
|
|
90
|
+
it("selfHeal defaults to false (opt-in)", async () => {
|
|
91
|
+
mockListDevices.mockResolvedValueOnce({
|
|
92
|
+
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
93
|
+
paired: [],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
|
|
97
|
+
authorization: "Bearer test-token",
|
|
98
|
+
});
|
|
99
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
100
|
+
await handleHealth(req, res);
|
|
101
|
+
expect((res as unknown as MockRes).statusCode).toBe(200);
|
|
102
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
103
|
+
expect(body.devicePairing.status).toBe("pending");
|
|
104
|
+
expect(mockApproveDevice).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("selfHeal=true enables auto-approve", async () => {
|
|
108
|
+
mockListDevices.mockResolvedValueOnce({
|
|
109
|
+
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
110
|
+
paired: [],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&selfHeal=true`, {
|
|
114
|
+
authorization: "Bearer test-token",
|
|
115
|
+
});
|
|
116
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
117
|
+
await handleHealth(req, res);
|
|
118
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
119
|
+
expect(body.devicePairing.status).toBe("ok");
|
|
120
|
+
expect(body.repairActions).toHaveLength(1);
|
|
121
|
+
expect(body.repairActions[0].component).toBe("devicePairing");
|
|
122
|
+
expect(body.repairActions[0].result).toBe("ok");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("selfHeal=True (capital T) is case-insensitive and enables auto-heal", async () => {
|
|
126
|
+
mockListDevices.mockResolvedValueOnce({
|
|
127
|
+
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
128
|
+
paired: [],
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&selfHeal=True`, {
|
|
132
|
+
authorization: "Bearer test-token",
|
|
133
|
+
});
|
|
134
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
135
|
+
await handleHealth(req, res);
|
|
136
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
137
|
+
expect(body.devicePairing.status).toBe("ok");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("selfHeal=FALSE is case-insensitive and stays disabled", async () => {
|
|
141
|
+
mockListDevices.mockResolvedValueOnce({
|
|
142
|
+
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
143
|
+
paired: [],
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&selfHeal=FALSE`, {
|
|
147
|
+
authorization: "Bearer test-token",
|
|
148
|
+
});
|
|
149
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
150
|
+
await handleHealth(req, res);
|
|
151
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
152
|
+
expect(body.devicePairing.status).toBe("pending");
|
|
153
|
+
expect(mockApproveDevice).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// --- Device pairing: paired + healthy ---
|
|
157
|
+
|
|
158
|
+
it("returns ok when device is paired and healthy", async () => {
|
|
159
|
+
mockListDevices.mockResolvedValueOnce({
|
|
160
|
+
pending: [],
|
|
161
|
+
paired: [
|
|
162
|
+
{ deviceId: DEVICE_ID, approvedScopes: ["operator.read"], tokens: { t1: {} } },
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
|
|
167
|
+
authorization: "Bearer test-token",
|
|
168
|
+
});
|
|
169
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
170
|
+
await handleHealth(req, res);
|
|
171
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
172
|
+
expect(body.ok).toBe(true);
|
|
173
|
+
expect(body.devicePairing.status).toBe("ok");
|
|
174
|
+
expect(body.devicePairing.devicePaired).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// --- Device pairing: degraded ---
|
|
178
|
+
|
|
179
|
+
it("returns degraded when device has no approved scopes", async () => {
|
|
180
|
+
mockListDevices.mockResolvedValueOnce({
|
|
181
|
+
pending: [],
|
|
182
|
+
paired: [
|
|
183
|
+
{ deviceId: DEVICE_ID, approvedScopes: [], tokens: { t1: {} } },
|
|
184
|
+
],
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
|
|
188
|
+
authorization: "Bearer test-token",
|
|
189
|
+
});
|
|
190
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
191
|
+
await handleHealth(req, res);
|
|
192
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
193
|
+
expect(body.ok).toBe(false);
|
|
194
|
+
expect(body.devicePairing.status).toBe("degraded");
|
|
195
|
+
expect(body.devicePairing.approvedScopesEmpty).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("returns degraded when all tokens are revoked", async () => {
|
|
199
|
+
mockListDevices.mockResolvedValueOnce({
|
|
200
|
+
pending: [],
|
|
201
|
+
paired: [
|
|
202
|
+
{
|
|
203
|
+
deviceId: DEVICE_ID,
|
|
204
|
+
approvedScopes: ["operator.read"],
|
|
205
|
+
tokens: { t1: { revokedAtMs: 1000 } },
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
|
|
211
|
+
authorization: "Bearer test-token",
|
|
212
|
+
});
|
|
213
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
214
|
+
await handleHealth(req, res);
|
|
215
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
216
|
+
expect(body.ok).toBe(false);
|
|
217
|
+
expect(body.devicePairing.status).toBe("degraded");
|
|
218
|
+
expect(body.devicePairing.tokensRevoked).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// --- Device pairing: pending ---
|
|
222
|
+
|
|
223
|
+
it("returns pending when device is pending and selfHeal is false", async () => {
|
|
224
|
+
mockListDevices.mockResolvedValueOnce({
|
|
225
|
+
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
226
|
+
paired: [],
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&selfHeal=false`, {
|
|
230
|
+
authorization: "Bearer test-token",
|
|
231
|
+
});
|
|
232
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
233
|
+
await handleHealth(req, res);
|
|
234
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
235
|
+
expect(body.devicePairing.status).toBe("pending");
|
|
236
|
+
expect(mockApproveDevice).not.toHaveBeenCalled();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// --- Device pairing: not_found ---
|
|
240
|
+
|
|
241
|
+
it("returns not_found when device is not in paired or pending", async () => {
|
|
242
|
+
mockListDevices.mockResolvedValueOnce({ pending: [], paired: [] });
|
|
243
|
+
|
|
244
|
+
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
|
|
245
|
+
authorization: "Bearer test-token",
|
|
246
|
+
});
|
|
247
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
248
|
+
await handleHealth(req, res);
|
|
249
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
250
|
+
expect(body.ok).toBe(false);
|
|
251
|
+
expect(body.devicePairing.status).toBe("not_found");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// --- Device pairing: listDevicePairing throws ---
|
|
255
|
+
|
|
256
|
+
it("returns failed when listDevicePairing throws", async () => {
|
|
257
|
+
mockListDevices.mockRejectedValueOnce(new Error("ENOENT"));
|
|
258
|
+
|
|
259
|
+
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
|
|
260
|
+
authorization: "Bearer test-token",
|
|
261
|
+
});
|
|
262
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
263
|
+
await handleHealth(req, res);
|
|
264
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
265
|
+
expect(body.devicePairing.status).toBe("failed");
|
|
266
|
+
expect(body.devicePairing.detail).toContain("ENOENT");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// --- Device pairing: approveDevicePairing throws during self-heal ---
|
|
270
|
+
|
|
271
|
+
it("returns degraded when auto-approve device throws", async () => {
|
|
272
|
+
mockListDevices.mockResolvedValueOnce({
|
|
273
|
+
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
274
|
+
paired: [],
|
|
275
|
+
});
|
|
276
|
+
mockApproveDevice.mockRejectedValueOnce(new Error("unknown requestId"));
|
|
277
|
+
|
|
278
|
+
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&selfHeal=true`, {
|
|
279
|
+
authorization: "Bearer test-token",
|
|
280
|
+
});
|
|
281
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
282
|
+
await handleHealth(req, res);
|
|
283
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
284
|
+
expect(body.devicePairing.status).toBe("degraded");
|
|
285
|
+
expect(body.repairActions).toHaveLength(1);
|
|
286
|
+
expect(body.repairActions[0].result).toBe("failed");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// --- Device pairing: normalize case-insensitively ---
|
|
290
|
+
|
|
291
|
+
it("matches deviceId case-insensitively", async () => {
|
|
292
|
+
mockListDevices.mockResolvedValueOnce({
|
|
293
|
+
pending: [],
|
|
294
|
+
paired: [
|
|
295
|
+
{ deviceId: DEVICE_ID.toUpperCase(), approvedScopes: ["operator.read"], tokens: { t1: {} } },
|
|
296
|
+
],
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID.toLowerCase()}`, {
|
|
300
|
+
authorization: "Bearer test-token",
|
|
301
|
+
});
|
|
302
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
303
|
+
await handleHealth(req, res);
|
|
304
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
305
|
+
expect(body.devicePairing.status).toBe("ok");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// --- Node pairing: paired + healthy ---
|
|
309
|
+
|
|
310
|
+
it("returns ok when node is paired with required caps and commands", async () => {
|
|
311
|
+
mockListNodePairing.mockResolvedValueOnce({
|
|
312
|
+
pending: [],
|
|
313
|
+
paired: [
|
|
314
|
+
{
|
|
315
|
+
nodeId: NODE_ID,
|
|
316
|
+
caps: ["location", "canvas"],
|
|
317
|
+
commands: ["location.get", "canvas.present", "canvas.hide", "canvas.navigate", "canvas.eval", "canvas.snapshot", "canvas.a2ui.push", "canvas.a2ui.pushJSONL", "canvas.a2ui.reset"],
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}`, {
|
|
323
|
+
authorization: "Bearer test-token",
|
|
324
|
+
});
|
|
325
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
326
|
+
await handleHealth(req, res);
|
|
327
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
328
|
+
expect(body.ok).toBe(true);
|
|
329
|
+
expect(body.nodePairing.status).toBe("ok");
|
|
330
|
+
expect(body.nodePairing.capsValid).toBe(true);
|
|
331
|
+
expect(body.nodePairing.commandsValid).toBe(true);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// --- Node pairing: degraded ---
|
|
335
|
+
|
|
336
|
+
it("returns degraded when node is missing required caps", async () => {
|
|
337
|
+
mockListNodePairing.mockResolvedValueOnce({
|
|
338
|
+
pending: [],
|
|
339
|
+
paired: [
|
|
340
|
+
{ nodeId: NODE_ID, caps: ["canvas"], commands: ["canvas.present"] },
|
|
341
|
+
],
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}`, {
|
|
345
|
+
authorization: "Bearer test-token",
|
|
346
|
+
});
|
|
347
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
348
|
+
await handleHealth(req, res);
|
|
349
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
350
|
+
expect(body.ok).toBe(false);
|
|
351
|
+
expect(body.nodePairing.status).toBe("degraded");
|
|
352
|
+
expect(body.nodePairing.capsValid).toBe(false);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// --- Node pairing: pending + self-heal ---
|
|
356
|
+
|
|
357
|
+
it("auto-approves pending node when selfHeal=true", async () => {
|
|
358
|
+
mockListNodePairing.mockResolvedValueOnce({
|
|
359
|
+
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
360
|
+
paired: [],
|
|
361
|
+
});
|
|
362
|
+
mockApproveNodePairing.mockResolvedValueOnce({ requestId: REQUEST_ID, node: { nodeId: NODE_ID } });
|
|
363
|
+
|
|
364
|
+
const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}&selfHeal=true`, {
|
|
365
|
+
authorization: "Bearer test-token",
|
|
366
|
+
});
|
|
367
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
368
|
+
await handleHealth(req, res);
|
|
369
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
370
|
+
expect(body.nodePairing.status).toBe("ok");
|
|
371
|
+
expect(body.repairActions).toHaveLength(1);
|
|
372
|
+
expect(body.repairActions[0].component).toBe("nodePairing");
|
|
373
|
+
expect(body.repairActions[0].result).toBe("ok");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// --- Node pairing: approveNodePairing returns null → degraded ---
|
|
377
|
+
|
|
378
|
+
it("returns degraded when approveNodePairing returns null", async () => {
|
|
379
|
+
mockListNodePairing.mockResolvedValueOnce({
|
|
380
|
+
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
381
|
+
paired: [],
|
|
382
|
+
});
|
|
383
|
+
mockApproveNodePairing.mockResolvedValueOnce(null);
|
|
384
|
+
|
|
385
|
+
const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}&selfHeal=true`, {
|
|
386
|
+
authorization: "Bearer test-token",
|
|
387
|
+
});
|
|
388
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
389
|
+
await handleHealth(req, res);
|
|
390
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
391
|
+
expect(body.nodePairing.status).toBe("degraded");
|
|
392
|
+
expect(body.repairActions[0].result).toBe("failed");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// --- Node pairing: approveNodePairing returns empty object → degraded ---
|
|
396
|
+
|
|
397
|
+
it("returns degraded when approveNodePairing returns empty object", async () => {
|
|
398
|
+
mockListNodePairing.mockResolvedValueOnce({
|
|
399
|
+
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
400
|
+
paired: [],
|
|
401
|
+
});
|
|
402
|
+
mockApproveNodePairing.mockResolvedValueOnce({});
|
|
403
|
+
|
|
404
|
+
const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}&selfHeal=true`, {
|
|
405
|
+
authorization: "Bearer test-token",
|
|
406
|
+
});
|
|
407
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
408
|
+
await handleHealth(req, res);
|
|
409
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
410
|
+
expect(body.nodePairing.status).toBe("degraded");
|
|
411
|
+
expect(body.repairActions[0].result).toBe("failed");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// --- Node pairing: forbidden → degraded ---
|
|
415
|
+
|
|
416
|
+
it("returns degraded when approveNodePairing returns forbidden", async () => {
|
|
417
|
+
mockListNodePairing.mockResolvedValueOnce({
|
|
418
|
+
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
419
|
+
paired: [],
|
|
420
|
+
});
|
|
421
|
+
mockApproveNodePairing.mockResolvedValueOnce({ status: "forbidden", missingScope: "operator.admin" });
|
|
422
|
+
|
|
423
|
+
const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}&selfHeal=true`, {
|
|
424
|
+
authorization: "Bearer test-token",
|
|
425
|
+
});
|
|
426
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
427
|
+
await handleHealth(req, res);
|
|
428
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
429
|
+
expect(body.nodePairing.status).toBe("degraded");
|
|
430
|
+
expect(body.repairActions[0].result).toBe("failed");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// --- Node pairing: not_found ---
|
|
434
|
+
|
|
435
|
+
it("returns not_found when node is not in paired or pending", async () => {
|
|
436
|
+
mockListNodePairing.mockResolvedValueOnce({ pending: [], paired: [] });
|
|
437
|
+
|
|
438
|
+
const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}`, {
|
|
439
|
+
authorization: "Bearer test-token",
|
|
440
|
+
});
|
|
441
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
442
|
+
await handleHealth(req, res);
|
|
443
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
444
|
+
expect(body.ok).toBe(false);
|
|
445
|
+
expect(body.nodePairing.status).toBe("not_found");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// --- Node pairing: listNodePairing throws ---
|
|
449
|
+
|
|
450
|
+
it("returns failed when listNodePairing throws", async () => {
|
|
451
|
+
mockListNodePairing.mockRejectedValueOnce(new Error("EPIPE"));
|
|
452
|
+
|
|
453
|
+
const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}`, {
|
|
454
|
+
authorization: "Bearer test-token",
|
|
455
|
+
});
|
|
456
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
457
|
+
await handleHealth(req, res);
|
|
458
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
459
|
+
expect(body.nodePairing.status).toBe("failed");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// --- Combined: device + node ---
|
|
463
|
+
|
|
464
|
+
it("checks both device and node and reports overall ok", async () => {
|
|
465
|
+
mockListDevices.mockResolvedValueOnce({
|
|
466
|
+
pending: [],
|
|
467
|
+
paired: [
|
|
468
|
+
{ deviceId: DEVICE_ID, approvedScopes: ["operator.read"], tokens: { t1: {} } },
|
|
469
|
+
],
|
|
470
|
+
});
|
|
471
|
+
mockListNodePairing.mockResolvedValueOnce({
|
|
472
|
+
pending: [],
|
|
473
|
+
paired: [
|
|
474
|
+
{
|
|
475
|
+
nodeId: NODE_ID,
|
|
476
|
+
caps: ["location", "canvas"],
|
|
477
|
+
commands: ["location.get", "canvas.present", "canvas.hide", "canvas.navigate", "canvas.eval", "canvas.snapshot", "canvas.a2ui.push", "canvas.a2ui.pushJSONL", "canvas.a2ui.reset"],
|
|
478
|
+
},
|
|
479
|
+
],
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&nodeDeviceId=${NODE_ID}`, {
|
|
483
|
+
authorization: "Bearer test-token",
|
|
484
|
+
});
|
|
485
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
486
|
+
await handleHealth(req, res);
|
|
487
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
488
|
+
expect(body.ok).toBe(true);
|
|
489
|
+
expect(body.devicePairing.status).toBe("ok");
|
|
490
|
+
expect(body.nodePairing.status).toBe("ok");
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("reports ok=false when device is ok but node is degraded", async () => {
|
|
494
|
+
mockListDevices.mockResolvedValueOnce({
|
|
495
|
+
pending: [],
|
|
496
|
+
paired: [
|
|
497
|
+
{ deviceId: DEVICE_ID, approvedScopes: ["operator.read"], tokens: { t1: {} } },
|
|
498
|
+
],
|
|
499
|
+
});
|
|
500
|
+
mockListNodePairing.mockResolvedValueOnce({
|
|
501
|
+
pending: [],
|
|
502
|
+
paired: [{ nodeId: NODE_ID, caps: [], commands: [] }],
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&nodeDeviceId=${NODE_ID}`, {
|
|
506
|
+
authorization: "Bearer test-token",
|
|
507
|
+
});
|
|
508
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
509
|
+
await handleHealth(req, res);
|
|
510
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
511
|
+
expect(body.ok).toBe(false);
|
|
512
|
+
expect(body.devicePairing.status).toBe("ok");
|
|
513
|
+
expect(body.nodePairing.status).toBe("degraded");
|
|
514
|
+
});
|
|
515
|
+
});
|