@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 +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 +17 -1
- package/package.json +11 -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
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { listDevicePairing, approveDevicePairing } from "openclaw/plugin-sdk/device-bootstrap";
|
|
3
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
4
|
+
import { loadNodePairingModule } from "../../agent/node-pairing-bridge.js";
|
|
5
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
6
|
+
|
|
7
|
+
const REQUIRED_NODE_CAPS = ["location", "canvas"];
|
|
8
|
+
const REQUIRED_NODE_COMMANDS = [
|
|
9
|
+
"location.get",
|
|
10
|
+
"canvas.present",
|
|
11
|
+
"canvas.hide",
|
|
12
|
+
"canvas.navigate",
|
|
13
|
+
"canvas.eval",
|
|
14
|
+
"canvas.snapshot",
|
|
15
|
+
"canvas.a2ui.push",
|
|
16
|
+
"canvas.a2ui.pushJSONL",
|
|
17
|
+
"canvas.a2ui.reset",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export interface HealthComponentStatus {
|
|
21
|
+
status: "ok" | "degraded" | "failed" | "pending" | "not_found";
|
|
22
|
+
detail: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RepairAction {
|
|
27
|
+
component: string;
|
|
28
|
+
action: string;
|
|
29
|
+
result: "ok" | "failed";
|
|
30
|
+
detail: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface HealthCheckResult {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
timestamp: number;
|
|
36
|
+
deviceId: string;
|
|
37
|
+
nodeDeviceId: string;
|
|
38
|
+
devicePairing?: HealthComponentStatus;
|
|
39
|
+
nodePairing?: HealthComponentStatus;
|
|
40
|
+
repairActions?: RepairAction[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function handleHealth(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
44
|
+
if (req.method !== "GET") {
|
|
45
|
+
res.statusCode = 405;
|
|
46
|
+
res.setHeader("Content-Type", "application/json");
|
|
47
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const token = extractBearerToken(req);
|
|
52
|
+
if (!token) {
|
|
53
|
+
res.statusCode = 401;
|
|
54
|
+
res.setHeader("Content-Type", "application/json");
|
|
55
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
60
|
+
const deviceId = (url.searchParams.get("deviceId") ?? "").trim();
|
|
61
|
+
const nodeDeviceId = (url.searchParams.get("nodeDeviceId") ?? "").trim();
|
|
62
|
+
const selfHeal = (url.searchParams.get("selfHeal") ?? "").toLowerCase() === "true";
|
|
63
|
+
|
|
64
|
+
const result: HealthCheckResult = {
|
|
65
|
+
ok: true,
|
|
66
|
+
timestamp: Date.now(),
|
|
67
|
+
deviceId,
|
|
68
|
+
nodeDeviceId,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const log = createFridayNextLogger("health");
|
|
72
|
+
|
|
73
|
+
if (deviceId) {
|
|
74
|
+
result.devicePairing = await checkDevicePairing(deviceId, selfHeal, result, log);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (nodeDeviceId) {
|
|
78
|
+
result.nodePairing = await checkNodePairing(nodeDeviceId, selfHeal, result, log);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const statuses = [
|
|
82
|
+
result.devicePairing?.status,
|
|
83
|
+
result.nodePairing?.status,
|
|
84
|
+
].filter(Boolean);
|
|
85
|
+
result.ok = statuses.length === 0 || statuses.every((s) => s === "ok" || s === "pending");
|
|
86
|
+
|
|
87
|
+
res.statusCode = 200;
|
|
88
|
+
res.setHeader("Content-Type", "application/json");
|
|
89
|
+
res.end(JSON.stringify(result));
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function checkDevicePairing(
|
|
94
|
+
deviceId: string,
|
|
95
|
+
selfHeal: boolean,
|
|
96
|
+
result: HealthCheckResult,
|
|
97
|
+
log: ReturnType<typeof createFridayNextLogger>,
|
|
98
|
+
): Promise<HealthComponentStatus> {
|
|
99
|
+
const normalizedDeviceId = deviceId.trim().toUpperCase();
|
|
100
|
+
|
|
101
|
+
let pairing;
|
|
102
|
+
try {
|
|
103
|
+
pairing = await listDevicePairing();
|
|
104
|
+
} catch (err) {
|
|
105
|
+
log.error(`listDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
106
|
+
return {
|
|
107
|
+
status: "failed",
|
|
108
|
+
detail: `listDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
109
|
+
devicePaired: false,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const pairedDevice = (pairing?.paired ?? []).find(
|
|
114
|
+
(entry) => entry.deviceId?.trim().toUpperCase() === normalizedDeviceId,
|
|
115
|
+
);
|
|
116
|
+
if (pairedDevice) {
|
|
117
|
+
const approvedScopes: string[] = (pairedDevice as any).approvedScopes ?? [];
|
|
118
|
+
const tokens: Record<string, { revokedAtMs?: number }> = (pairedDevice as any).tokens ?? {};
|
|
119
|
+
const hasValidToken = Object.values(tokens).some((t: any) => !t.revokedAtMs);
|
|
120
|
+
|
|
121
|
+
if (approvedScopes.length === 0 || !hasValidToken) {
|
|
122
|
+
const issues: string[] = [];
|
|
123
|
+
if (approvedScopes.length === 0) issues.push("no approved scopes");
|
|
124
|
+
if (!hasValidToken) issues.push("all tokens revoked");
|
|
125
|
+
return {
|
|
126
|
+
status: "degraded",
|
|
127
|
+
detail: `Device paired but degraded: ${issues.join(", ")}`,
|
|
128
|
+
devicePaired: true,
|
|
129
|
+
approvedScopesEmpty: approvedScopes.length === 0,
|
|
130
|
+
tokensRevoked: !hasValidToken,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { status: "ok", detail: "Device paired and healthy", devicePaired: true };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const pendingDevice = (pairing?.pending ?? []).find(
|
|
138
|
+
(entry) => entry.deviceId?.trim().toUpperCase() === normalizedDeviceId,
|
|
139
|
+
);
|
|
140
|
+
if (pendingDevice && selfHeal) {
|
|
141
|
+
try {
|
|
142
|
+
const approved = await approveDevicePairing(pendingDevice.requestId);
|
|
143
|
+
const succeeded = approved && approved.status === "approved";
|
|
144
|
+
(result.repairActions ??= []).push({
|
|
145
|
+
component: "devicePairing",
|
|
146
|
+
action: "approveDevicePairing",
|
|
147
|
+
result: succeeded ? "ok" : "failed",
|
|
148
|
+
detail: succeeded
|
|
149
|
+
? `Auto-approved device ${normalizedDeviceId}`
|
|
150
|
+
: `approveDevicePairing returned status=${(approved as any)?.status ?? "null"}`,
|
|
151
|
+
});
|
|
152
|
+
if (succeeded) {
|
|
153
|
+
log.info(`Auto-approved device ${normalizedDeviceId}`);
|
|
154
|
+
return { status: "ok", detail: "Device was pending, auto-approved", devicePaired: true };
|
|
155
|
+
}
|
|
156
|
+
} catch (err) {
|
|
157
|
+
log.error(`approveDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
158
|
+
(result.repairActions ??= []).push({
|
|
159
|
+
component: "devicePairing",
|
|
160
|
+
action: "approveDevicePairing",
|
|
161
|
+
result: "failed",
|
|
162
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
status: "degraded",
|
|
167
|
+
detail: "Device pending but auto-approve failed",
|
|
168
|
+
devicePaired: false,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (pendingDevice) {
|
|
173
|
+
return { status: "pending", detail: "Device is pending approval", devicePaired: false };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { status: "not_found", detail: `Device ${normalizedDeviceId} not registered`, devicePaired: false };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function checkNodePairing(
|
|
180
|
+
nodeDeviceId: string,
|
|
181
|
+
selfHeal: boolean,
|
|
182
|
+
result: HealthCheckResult,
|
|
183
|
+
log: ReturnType<typeof createFridayNextLogger>,
|
|
184
|
+
): Promise<HealthComponentStatus> {
|
|
185
|
+
const normalizedNodeId = nodeDeviceId.trim().toUpperCase();
|
|
186
|
+
|
|
187
|
+
let listData, listNodePairing, approveNodePairing;
|
|
188
|
+
try {
|
|
189
|
+
({ listNodePairing, approveNodePairing } = await loadNodePairingModule());
|
|
190
|
+
} catch (err) {
|
|
191
|
+
log.error(`loadNodePairingModule failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
192
|
+
return {
|
|
193
|
+
status: "failed",
|
|
194
|
+
detail: `loadNodePairingModule failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
195
|
+
nodePaired: false,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
listData = await listNodePairing();
|
|
201
|
+
} catch (err) {
|
|
202
|
+
log.error(`listNodePairing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
203
|
+
return {
|
|
204
|
+
status: "failed",
|
|
205
|
+
detail: `listNodePairing failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
206
|
+
nodePaired: false,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const pairedNodes: Array<{ nodeId: string; caps?: string[]; commands?: string[] }> = listData?.paired ?? [];
|
|
211
|
+
const pairedMatch = pairedNodes.find(
|
|
212
|
+
(entry) => entry.nodeId?.trim().toUpperCase() === normalizedNodeId,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (pairedMatch) {
|
|
216
|
+
const caps = pairedMatch.caps ?? [];
|
|
217
|
+
const commands = pairedMatch.commands ?? [];
|
|
218
|
+
const hasRequiredCaps = REQUIRED_NODE_CAPS.every((c) => caps.includes(c));
|
|
219
|
+
const hasRequiredCommands = REQUIRED_NODE_COMMANDS.every((c) => commands.includes(c));
|
|
220
|
+
const capsValid = caps.length > 0 && hasRequiredCaps;
|
|
221
|
+
const commandsValid = commands.length > 0 && hasRequiredCommands;
|
|
222
|
+
|
|
223
|
+
if (capsValid && commandsValid) {
|
|
224
|
+
return {
|
|
225
|
+
status: "ok",
|
|
226
|
+
detail: `Node paired with ${caps.length} caps, ${commands.length} commands`,
|
|
227
|
+
nodePaired: true,
|
|
228
|
+
capsCount: caps.length,
|
|
229
|
+
commandsCount: commands.length,
|
|
230
|
+
capsValid: true,
|
|
231
|
+
commandsValid: true,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
status: "degraded",
|
|
237
|
+
detail: `Node paired but caps/commands incomplete: caps=${caps.length} (valid=${capsValid}), commands=${commands.length} (valid=${commandsValid})`,
|
|
238
|
+
nodePaired: true,
|
|
239
|
+
capsCount: caps.length,
|
|
240
|
+
commandsCount: commands.length,
|
|
241
|
+
capsValid,
|
|
242
|
+
commandsValid,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const pendingNodes: Array<{ nodeId: string; requestId: string }> = listData?.pending ?? [];
|
|
247
|
+
const pendingMatch = pendingNodes.find(
|
|
248
|
+
(entry) => entry.nodeId?.trim().toUpperCase() === normalizedNodeId,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
if (pendingMatch && selfHeal) {
|
|
252
|
+
try {
|
|
253
|
+
const callerScopes = ["operator.admin", "operator.pairing", "operator.read", "operator.write"];
|
|
254
|
+
const approved = await approveNodePairing(pendingMatch.requestId, { callerScopes });
|
|
255
|
+
const succeeded = approved != null && !("status" in approved && (approved as any).status === "forbidden") && "requestId" in approved;
|
|
256
|
+
(result.repairActions ??= []).push({
|
|
257
|
+
component: "nodePairing",
|
|
258
|
+
action: "approveNodePairing",
|
|
259
|
+
result: succeeded ? "ok" : "failed",
|
|
260
|
+
detail: succeeded
|
|
261
|
+
? `Auto-approved node ${normalizedNodeId}`
|
|
262
|
+
: `approveNodePairing returned status=${(approved as any)?.status ?? "null"}`,
|
|
263
|
+
});
|
|
264
|
+
if (succeeded) {
|
|
265
|
+
log.info(`Auto-approved node ${normalizedNodeId}`);
|
|
266
|
+
return { status: "ok", detail: "Node was pending, auto-approved", nodePaired: true };
|
|
267
|
+
}
|
|
268
|
+
} catch (err) {
|
|
269
|
+
log.error(`approveNodePairing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
270
|
+
(result.repairActions ??= []).push({
|
|
271
|
+
component: "nodePairing",
|
|
272
|
+
action: "approveNodePairing",
|
|
273
|
+
result: "failed",
|
|
274
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
status: "degraded",
|
|
279
|
+
detail: "Node pending but auto-approve failed",
|
|
280
|
+
nodePaired: false,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (pendingMatch) {
|
|
285
|
+
return { status: "pending", detail: "Node is pending approval", nodePaired: false };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { status: "not_found", detail: `Node ${normalizedNodeId} not registered`, nodePaired: false };
|
|
289
|
+
}
|
package/src/http/server.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { handleSessionsDelete } from "./handlers/sessions-delete.js";
|
|
|
17
17
|
import { handleSessionsSettings } from "./handlers/sessions-settings.js";
|
|
18
18
|
import { handleModelsList } from "./handlers/models-list.js";
|
|
19
19
|
import { handleStatus } from "./handlers/status.js";
|
|
20
|
+
import { handleHealth } from "./handlers/health.js";
|
|
20
21
|
import { applyCorsHeaders } from "./middleware/cors.js";
|
|
21
22
|
import { resolveFridayNextConfig } from "../config.js";
|
|
22
23
|
import { getHostOpenClawConfigSnapshot } from "../host-config.js";
|
|
@@ -85,6 +86,11 @@ async function handleFridayNextRoute(
|
|
|
85
86
|
return await handleStatus(req, res);
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
// Route: GET /friday-next/health?deviceId=...&nodeDeviceId=...&selfHeal=true
|
|
90
|
+
if (req.method === "GET" && pathname === "/friday-next/health") {
|
|
91
|
+
return await handleHealth(req, res);
|
|
92
|
+
}
|
|
93
|
+
|
|
88
94
|
// Not found
|
|
89
95
|
return false;
|
|
90
96
|
}
|
package/src/run-metadata.ts
CHANGED
|
@@ -6,11 +6,17 @@ type RunRoute = {
|
|
|
6
6
|
|
|
7
7
|
export type RunMetadata = {
|
|
8
8
|
modelName?: string;
|
|
9
|
+
modelProvider?: string;
|
|
9
10
|
totalTokens?: number;
|
|
10
11
|
/** Tokens counted toward the model context window (prompt-side: input + cache read + cache write when present). */
|
|
11
12
|
contextTokensUsed?: number;
|
|
12
13
|
/** Resolved model context window limit when the runtime exposes it. */
|
|
13
14
|
contextWindowMax?: number;
|
|
15
|
+
/** Detailed token breakdown captured from agent event usage (current run, not stale store read). */
|
|
16
|
+
inputTokens?: number;
|
|
17
|
+
outputTokens?: number;
|
|
18
|
+
cacheReadTokens?: number;
|
|
19
|
+
cacheWriteTokens?: number;
|
|
14
20
|
};
|
|
15
21
|
|
|
16
22
|
const runRouteById = new Map<string, RunRoute>();
|
|
@@ -142,6 +148,12 @@ export function ingestAgentEventMetadata(runId: string, data: Record<string, unk
|
|
|
142
148
|
undefined;
|
|
143
149
|
if (modelName) next.modelName = modelName;
|
|
144
150
|
|
|
151
|
+
const modelProvider =
|
|
152
|
+
(typeof data.modelProvider === "string" && data.modelProvider.trim()) ||
|
|
153
|
+
(typeof data.provider === "string" && data.provider.trim()) ||
|
|
154
|
+
undefined;
|
|
155
|
+
if (modelProvider) next.modelProvider = modelProvider;
|
|
156
|
+
|
|
145
157
|
const usage = recordValue(data.usage);
|
|
146
158
|
const totalTokens =
|
|
147
159
|
finiteNumber(data.totalTokens) ??
|
|
@@ -153,6 +165,16 @@ export function ingestAgentEventMetadata(runId: string, data: Record<string, unk
|
|
|
153
165
|
next.totalTokens = Math.floor(totalTokens);
|
|
154
166
|
}
|
|
155
167
|
|
|
168
|
+
const usageForTokens = usage ?? data;
|
|
169
|
+
const input = pickInputTokens(usageForTokens);
|
|
170
|
+
if (typeof input === "number" && input >= 0) next.inputTokens = Math.floor(input);
|
|
171
|
+
const output = pickOutputTokens(usageForTokens);
|
|
172
|
+
if (typeof output === "number" && output >= 0) next.outputTokens = Math.floor(output);
|
|
173
|
+
const cacheRead = pickCacheRead(usageForTokens);
|
|
174
|
+
if (typeof cacheRead === "number" && cacheRead >= 0) next.cacheReadTokens = Math.floor(cacheRead);
|
|
175
|
+
const cacheWrite = pickCacheWrite(usageForTokens);
|
|
176
|
+
if (typeof cacheWrite === "number" && cacheWrite >= 0) next.cacheWriteTokens = Math.floor(cacheWrite);
|
|
177
|
+
|
|
156
178
|
const usageForContext = usage ?? data;
|
|
157
179
|
const ctxUsed = contextTokensFromUsageRecord(usageForContext);
|
|
158
180
|
if (typeof ctxUsed === "number" && ctxUsed > 0) {
|
|
@@ -171,9 +193,14 @@ export function ingestAgentEventMetadata(runId: string, data: Record<string, unk
|
|
|
171
193
|
|
|
172
194
|
if (
|
|
173
195
|
next.modelName ||
|
|
196
|
+
next.modelProvider ||
|
|
174
197
|
typeof next.totalTokens === "number" ||
|
|
175
198
|
typeof next.contextTokensUsed === "number" ||
|
|
176
|
-
typeof next.contextWindowMax === "number"
|
|
199
|
+
typeof next.contextWindowMax === "number" ||
|
|
200
|
+
typeof next.inputTokens === "number" ||
|
|
201
|
+
typeof next.outputTokens === "number" ||
|
|
202
|
+
typeof next.cacheReadTokens === "number" ||
|
|
203
|
+
typeof next.cacheWriteTokens === "number"
|
|
177
204
|
) {
|
|
178
205
|
setRunMetadata(runId, next);
|
|
179
206
|
}
|