@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.
@@ -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
+ }
@@ -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
  }
@@ -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
  }