@egoistmachines/opencode-switchboard 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/README.md +125 -0
- package/config.schema.json +81 -0
- package/package.json +38 -0
- package/src/cli.js +41 -0
- package/src/config.js +82 -0
- package/src/context.js +203 -0
- package/src/host.js +11 -0
- package/src/hostedTransport.js +271 -0
- package/src/index.js +12 -0
- package/src/localTransport.js +213 -0
- package/src/outcomes.js +104 -0
- package/src/plugin.js +223 -0
- package/src/shared/atomicFile.js +32 -0
- package/src/shared/cache.js +34 -0
- package/src/shared/cli.js +103 -0
- package/src/shared/client.js +195 -0
- package/src/shared/config.js +105 -0
- package/src/shared/credentials.js +326 -0
- package/src/shared/format.js +88 -0
- package/src/shared/policy.js +599 -0
- package/src/shared/transport.js +256 -0
- package/src/status.js +186 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { PassportAuthError } from "./credentials.js";
|
|
2
|
+
|
|
3
|
+
// The host-neutral authenticated-request engine both plane surfaces share (the
|
|
4
|
+
// /agent/prefetch client in src/client.js and the tool-policy reporter in
|
|
5
|
+
// src/policy.js). Everything transport-shaped lives here ONCE: bearer
|
|
6
|
+
// headers, redirect refusal, per-class backoff, the forbidden
|
|
7
|
+
// probe-then-latch, the client-side request budget, the single 401
|
|
8
|
+
// refresh-retry, and terminal credential verdicts with their recheck window.
|
|
9
|
+
// The first version of the reporter re-implemented all of it, which meant the
|
|
10
|
+
// next transport fix had to land twice or the surfaces silently diverged.
|
|
11
|
+
//
|
|
12
|
+
// Each surface keeps its OWN transport instance on purpose. The backend
|
|
13
|
+
// throttles per route (prefetch 60/min, policy check 600/min, ...), so budget
|
|
14
|
+
// state must not be pooled: a chatty policy reporter must never spend the
|
|
15
|
+
// prefetch budget, and a prefetch 429 must not black out the audit trail.
|
|
16
|
+
// The cost is that a plane-closed 403 is probed once per surface per window,
|
|
17
|
+
// which is two requests where one would do; that is cheaper than the shared
|
|
18
|
+
// state machine that could tell those cases apart.
|
|
19
|
+
|
|
20
|
+
const BACKOFF_MS = {
|
|
21
|
+
// 429 is the backend telling us our own cadence is wrong; pause for at
|
|
22
|
+
// least one cache window so the next call is served from cache anyway.
|
|
23
|
+
rate_limited: 60_000,
|
|
24
|
+
// 403/404 mean the plane is closed to this install: the agent backend is
|
|
25
|
+
// disabled on that deployment (it ships behind a flag and Express answers
|
|
26
|
+
// its default 404 while it is dark), or this client is not an agent-connect
|
|
27
|
+
// registration. Nothing the plugin does will change that soon, so a REPEAT
|
|
28
|
+
// latches this long window. A single 403 can also be one transient store
|
|
29
|
+
// blip during the backend's token verification (it deliberately leaves a
|
|
30
|
+
// failed client lookup uncached so a blip costs one read, not a lockout),
|
|
31
|
+
// so the first one only pauses for `forbidden_probe` and the next read
|
|
32
|
+
// re-probes instead of amplifying the blip into a five-minute blackout.
|
|
33
|
+
forbidden: 5 * 60 * 1000,
|
|
34
|
+
forbidden_probe: 60_000,
|
|
35
|
+
// 400 means we sent a shape the backend refuses: a version skew, not a
|
|
36
|
+
// transient fault. Back off hard and say so once.
|
|
37
|
+
invalid_request: 5 * 60 * 1000,
|
|
38
|
+
unavailable: 30_000,
|
|
39
|
+
auth: 60_000,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// A terminal verdict (not installed, refresh token dead) stops network
|
|
43
|
+
// traffic, but the documented recovery is the owner rewriting the credentials
|
|
44
|
+
// file, and a long-running gateway must pick that up without a restart. Probe
|
|
45
|
+
// again after this window; an unchanged file re-latches at the cost of at
|
|
46
|
+
// most one stat (not_installed) or one token POST (invalid_grant) per window.
|
|
47
|
+
const TERMINAL_RECHECK_MS = 5 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
export function createPlaneTransport({
|
|
50
|
+
config,
|
|
51
|
+
credentials,
|
|
52
|
+
fetchImpl = globalThis.fetch,
|
|
53
|
+
logger = null,
|
|
54
|
+
now = () => Date.now(),
|
|
55
|
+
// Names this surface in the once-per-window warnings, so an operator
|
|
56
|
+
// grepping a log can tell a prefetch outage from a policy one.
|
|
57
|
+
label,
|
|
58
|
+
// Client-side share of this surface's per-route backend budget.
|
|
59
|
+
budgetMax,
|
|
60
|
+
budgetWindowMs = 60_000,
|
|
61
|
+
}) {
|
|
62
|
+
let backoffUntil = 0;
|
|
63
|
+
let backoffReason = null;
|
|
64
|
+
// Consecutive plane-closed answers, so one transient 403 (a store blip on
|
|
65
|
+
// the backend's verification path) is probed past quickly while a genuinely
|
|
66
|
+
// closed plane still converges to the long backoff on its second answer.
|
|
67
|
+
let forbiddenStreak = 0;
|
|
68
|
+
let terminalReason = null;
|
|
69
|
+
let terminalRecheckAt = 0;
|
|
70
|
+
const requestLog = [];
|
|
71
|
+
const pendingReservations = new Set();
|
|
72
|
+
|
|
73
|
+
const pruneRequestLog = () => {
|
|
74
|
+
const cutoff = now() - budgetWindowMs;
|
|
75
|
+
while (requestLog.length && requestLog[0] <= cutoff) requestLog.shift();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Reserve before credential loading yields. Pending reservations do not
|
|
79
|
+
// expire, because a slow credential provider must not let later callers
|
|
80
|
+
// take the same slots and then release a burst larger than the route budget.
|
|
81
|
+
const reserveRequest = () => {
|
|
82
|
+
pruneRequestLog();
|
|
83
|
+
if (requestLog.length + pendingReservations.size >= budgetMax) return null;
|
|
84
|
+
const reservation = {};
|
|
85
|
+
pendingReservations.add(reservation);
|
|
86
|
+
return reservation;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const startRequest = (reservation) => {
|
|
90
|
+
pendingReservations.delete(reservation);
|
|
91
|
+
requestLog.push(now());
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const releaseRequest = (reservation) => {
|
|
95
|
+
pendingReservations.delete(reservation);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const backOff = (reason, detail, ms = BACKOFF_MS[reason] ?? BACKOFF_MS.unavailable) => {
|
|
99
|
+
const alreadyBackedOff = backoffUntil > now() && backoffReason === reason;
|
|
100
|
+
backoffUntil = now() + ms;
|
|
101
|
+
backoffReason = reason;
|
|
102
|
+
if (!alreadyBackedOff) logger?.warn?.(`ai-passport: ${label} ${reason}${detail ? ` (${detail})` : ""}`);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const latchTerminal = (err) => {
|
|
106
|
+
terminalReason = err.code;
|
|
107
|
+
terminalRecheckAt = now() + TERMINAL_RECHECK_MS;
|
|
108
|
+
// Log the closed reason code, not an error message that may have been
|
|
109
|
+
// created by a filesystem implementation or another credential provider.
|
|
110
|
+
logger?.warn?.(`ai-passport: credentials ${err.code}`);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const requestOnce = async ({ baseUrl, path, method, accessToken, body, timeoutMs }) => {
|
|
114
|
+
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
115
|
+
method,
|
|
116
|
+
headers: {
|
|
117
|
+
authorization: `Bearer ${accessToken}`,
|
|
118
|
+
accept: "application/json",
|
|
119
|
+
...(body ? { "content-type": "application/json" } : {}),
|
|
120
|
+
},
|
|
121
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
122
|
+
// Never follow a redirect while carrying a bearer: no Passport endpoint
|
|
123
|
+
// answers one, so a 3xx is a middlebox (captive portal, proxy)
|
|
124
|
+
// answering in the backend's place, and following it hands the
|
|
125
|
+
// credential to whatever host it points at. The 3xx status falls
|
|
126
|
+
// through to the generic branch below and degrades like any outage.
|
|
127
|
+
redirect: "manual",
|
|
128
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
129
|
+
});
|
|
130
|
+
const text = await response.text();
|
|
131
|
+
let payload = null;
|
|
132
|
+
let malformed = false;
|
|
133
|
+
try {
|
|
134
|
+
payload = text ? JSON.parse(text) : null;
|
|
135
|
+
} catch {
|
|
136
|
+
malformed = true;
|
|
137
|
+
}
|
|
138
|
+
return { status: response.status, payload, malformed };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
/**
|
|
143
|
+
* One authenticated request. Resolves to the 200's JSON object, or null
|
|
144
|
+
* when nothing was knowable (terminal, backoff, budget, outage, refusal);
|
|
145
|
+
* the failure class has already been recorded and warned about here, so
|
|
146
|
+
* callers only decide what "no answer" means for their surface. Never
|
|
147
|
+
* throws.
|
|
148
|
+
*/
|
|
149
|
+
async request({ path, method = "POST", body = null, timeoutMs }) {
|
|
150
|
+
if (terminalReason) {
|
|
151
|
+
if (now() < terminalRecheckAt) return null;
|
|
152
|
+
// Clear the verdict and fall through: the credentials layer re-reads
|
|
153
|
+
// the file on mtime change, so a reinstall is picked up here.
|
|
154
|
+
terminalReason = null;
|
|
155
|
+
}
|
|
156
|
+
if (backoffUntil > now()) return null;
|
|
157
|
+
const initialReservation = reserveRequest();
|
|
158
|
+
if (!initialReservation) return null;
|
|
159
|
+
|
|
160
|
+
let baseUrl;
|
|
161
|
+
let accessToken;
|
|
162
|
+
try {
|
|
163
|
+
baseUrl = config.baseUrl ?? (await credentials.baseUrl());
|
|
164
|
+
accessToken = await credentials.accessToken();
|
|
165
|
+
} catch (err) {
|
|
166
|
+
releaseRequest(initialReservation);
|
|
167
|
+
if (err instanceof PassportAuthError && err.terminal) {
|
|
168
|
+
// Not installed, or the refresh token is genuinely dead. Say it
|
|
169
|
+
// once and stop touching the network until the recheck window
|
|
170
|
+
// probes for the owner's reinstall.
|
|
171
|
+
latchTerminal(err);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
backOff("auth", err?.code ?? "error");
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let result;
|
|
179
|
+
let retried = false;
|
|
180
|
+
let retryReservation = null;
|
|
181
|
+
let retryStarted = false;
|
|
182
|
+
try {
|
|
183
|
+
startRequest(initialReservation);
|
|
184
|
+
result = await requestOnce({ baseUrl, path, method, accessToken, body, timeoutMs });
|
|
185
|
+
if (result.status === 401) {
|
|
186
|
+
// The stored access token outlived its hour, or the server rotated
|
|
187
|
+
// out from under us. One forced refresh, one retry, then give up
|
|
188
|
+
// for this window; the refresh path itself owns the terminal cases.
|
|
189
|
+
retryReservation = reserveRequest();
|
|
190
|
+
if (!retryReservation) return null;
|
|
191
|
+
retried = true;
|
|
192
|
+
const refreshed = await credentials.accessToken({ force: true });
|
|
193
|
+
startRequest(retryReservation);
|
|
194
|
+
retryStarted = true;
|
|
195
|
+
result = await requestOnce({ baseUrl, path, method, accessToken: refreshed, body, timeoutMs });
|
|
196
|
+
}
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (retryReservation && !retryStarted) releaseRequest(retryReservation);
|
|
199
|
+
if (err instanceof PassportAuthError && err.terminal) {
|
|
200
|
+
latchTerminal(err);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
// AbortSignal.timeout rejects with TimeoutError; treat every
|
|
204
|
+
// transport failure the same way, because the caller's answer is the
|
|
205
|
+
// same.
|
|
206
|
+
backOff("unavailable", err?.name ?? "error");
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (result.status === 200) {
|
|
211
|
+
if (result.malformed || !result.payload || typeof result.payload !== "object" || Array.isArray(result.payload)) {
|
|
212
|
+
// A 200 whose body is not the plane's JSON object is a middlebox
|
|
213
|
+
// answering in the backend's place (captive portal, proxy error
|
|
214
|
+
// page), not an answer. Treating it as one would have the surface
|
|
215
|
+
// cache a lie; degrade like any other transport fault instead.
|
|
216
|
+
backOff("unavailable", "malformed_200");
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
backoffUntil = 0;
|
|
220
|
+
backoffReason = null;
|
|
221
|
+
forbiddenStreak = 0;
|
|
222
|
+
return result.payload;
|
|
223
|
+
}
|
|
224
|
+
if (result.status === 429) {
|
|
225
|
+
backOff("rate_limited");
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
if (result.status === 403 || result.status === 404) {
|
|
229
|
+
// 403 is the scope guard refusing this install; 404 is a deployment
|
|
230
|
+
// that never mounted the plane. Both mean the same thing to the
|
|
231
|
+
// owner, and neither is an outage.
|
|
232
|
+
forbiddenStreak += 1;
|
|
233
|
+
backOff(
|
|
234
|
+
"forbidden",
|
|
235
|
+
`${result.status}: ask the owner to enable the AI Passport agent backend`,
|
|
236
|
+
forbiddenStreak > 1 ? BACKOFF_MS.forbidden : BACKOFF_MS.forbidden_probe
|
|
237
|
+
);
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
if (result.status === 401) {
|
|
241
|
+
backOff("auth", retried ? "401 after refresh" : "401");
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
if (result.status === 400) {
|
|
245
|
+
backOff("invalid_request", String(result.payload?.error ?? "bad_request"));
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
backOff("unavailable", String(result.status));
|
|
249
|
+
return null;
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
state() {
|
|
253
|
+
return { backoffUntil, backoffReason, terminalReason };
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
package/src/status.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { GOVERNED_CATEGORIES } from "./config.js";
|
|
6
|
+
import { PROPOSAL_STATUSES, READ_STATUSES } from "./outcomes.js";
|
|
7
|
+
|
|
8
|
+
const EMPTY_COUNTS = Object.freeze({ rows: 0, skippedCategories: 0 });
|
|
9
|
+
const EMPTY_HANDOFF_COUNTS = Object.freeze({ claimed: 0, none: 0, disabled: 0, deliveryFailed: 0 });
|
|
10
|
+
const HANDOFF_REASONS = new Set(["missing_session_id", "delivery_failed"]);
|
|
11
|
+
|
|
12
|
+
async function writeStatusFile(filePath, value) {
|
|
13
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
14
|
+
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
15
|
+
try {
|
|
16
|
+
await writeFile(tempPath, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
17
|
+
await chmod(tempPath, 0o600);
|
|
18
|
+
await rename(tempPath, filePath);
|
|
19
|
+
} finally {
|
|
20
|
+
await rm(tempPath, { force: true }).catch(() => {});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function readStatusFile(filePath) {
|
|
25
|
+
try {
|
|
26
|
+
const value = JSON.parse(await readFile(filePath, "utf8"));
|
|
27
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createStatusTracker({
|
|
34
|
+
filePath,
|
|
35
|
+
config,
|
|
36
|
+
ambientSupported = true,
|
|
37
|
+
transportKind = "local",
|
|
38
|
+
handoffEnabled = config.handoff.enabled && transportKind === "local",
|
|
39
|
+
writeStatus = writeStatusFile,
|
|
40
|
+
}) {
|
|
41
|
+
const activeMode = ["local", "hosted", "unavailable"].includes(transportKind) ? transportKind : "unavailable";
|
|
42
|
+
let state = {
|
|
43
|
+
ambient: { enabled: config.ambient.enabled, supported: ambientSupported },
|
|
44
|
+
handoff: {
|
|
45
|
+
enabled: handoffEnabled,
|
|
46
|
+
lastReason: null,
|
|
47
|
+
counts: { ...EMPTY_HANDOFF_COUNTS, disabled: handoffEnabled ? 0 : 1 },
|
|
48
|
+
},
|
|
49
|
+
categoriesRequested: [...config.categories],
|
|
50
|
+
lastOutcomeClass: null,
|
|
51
|
+
transportKind: activeMode,
|
|
52
|
+
counts: { ...EMPTY_COUNTS },
|
|
53
|
+
};
|
|
54
|
+
let pending = Promise.resolve();
|
|
55
|
+
|
|
56
|
+
const persist = () => {
|
|
57
|
+
pending = pending.then(() => writeStatus(filePath, state)).catch(() => {});
|
|
58
|
+
return pending;
|
|
59
|
+
};
|
|
60
|
+
persist();
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
recordOutcome(outcome) {
|
|
64
|
+
if (!outcome || typeof outcome.status !== "string") return;
|
|
65
|
+
state = {
|
|
66
|
+
...state,
|
|
67
|
+
lastOutcomeClass: outcome.status,
|
|
68
|
+
counts: {
|
|
69
|
+
rows: Array.isArray(outcome.rows) ? outcome.rows.length : 0,
|
|
70
|
+
skippedCategories: Array.isArray(outcome.skipped_categories) ? outcome.skipped_categories.length : 0,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
return persist();
|
|
74
|
+
},
|
|
75
|
+
recordUnavailable() {
|
|
76
|
+
state = {
|
|
77
|
+
...state,
|
|
78
|
+
lastOutcomeClass: "unavailable",
|
|
79
|
+
counts: { ...EMPTY_COUNTS },
|
|
80
|
+
};
|
|
81
|
+
return persist();
|
|
82
|
+
},
|
|
83
|
+
recordHandoffOutcome(outcome) {
|
|
84
|
+
if (outcome !== "claimed" && outcome !== "none") return;
|
|
85
|
+
state = {
|
|
86
|
+
...state,
|
|
87
|
+
handoff: {
|
|
88
|
+
...state.handoff,
|
|
89
|
+
counts: { ...state.handoff.counts, [outcome]: state.handoff.counts[outcome] + 1 },
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
return persist();
|
|
93
|
+
},
|
|
94
|
+
recordHandoffReason(reason) {
|
|
95
|
+
if (!HANDOFF_REASONS.has(reason)) return;
|
|
96
|
+
state = { ...state, handoff: { ...state.handoff, lastReason: reason } };
|
|
97
|
+
return persist();
|
|
98
|
+
},
|
|
99
|
+
recordHandoffDeliveryFailure() {
|
|
100
|
+
state = {
|
|
101
|
+
...state,
|
|
102
|
+
handoff: {
|
|
103
|
+
...state.handoff,
|
|
104
|
+
lastReason: "delivery_failed",
|
|
105
|
+
counts: { ...state.handoff.counts, deliveryFailed: state.handoff.counts.deliveryFailed + 1 },
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
return persist();
|
|
109
|
+
},
|
|
110
|
+
setAmbientSupported(supported) {
|
|
111
|
+
state = { ...state, ambient: { ...state.ambient, supported: Boolean(supported) } };
|
|
112
|
+
return persist();
|
|
113
|
+
},
|
|
114
|
+
snapshot() {
|
|
115
|
+
return structuredClone(state);
|
|
116
|
+
},
|
|
117
|
+
settled() {
|
|
118
|
+
return pending;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function buildStatusReport({ config, transportStatus, persisted }) {
|
|
124
|
+
const knownOutcomes = new Set([...READ_STATUSES, ...PROPOSAL_STATUSES]);
|
|
125
|
+
const persistedOutcome = knownOutcomes.has(persisted?.lastOutcomeClass) ? persisted.lastOutcomeClass : null;
|
|
126
|
+
const transportOutcome = knownOutcomes.has(transportStatus?.lastOutcome) ? transportStatus.lastOutcome : null;
|
|
127
|
+
const activeMode = ["local", "hosted", "unavailable"].includes(persisted?.transportKind)
|
|
128
|
+
? persisted.transportKind
|
|
129
|
+
: "unavailable";
|
|
130
|
+
const wouldSelectOnRestart = transportStatus?.discoveryFound
|
|
131
|
+
? "local"
|
|
132
|
+
: config.hostedFallback.enabled
|
|
133
|
+
? "hosted"
|
|
134
|
+
: "unavailable";
|
|
135
|
+
const handoffEnabled = persisted?.handoff?.enabled ?? (config.handoff.enabled && activeMode === "local");
|
|
136
|
+
const handoffCount = (name, fallback = 0) => {
|
|
137
|
+
const value = persisted?.handoff?.counts?.[name];
|
|
138
|
+
return Number.isInteger(value) && value >= 0 ? value : fallback;
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
transportKind: activeMode,
|
|
142
|
+
wouldSelectOnRestart,
|
|
143
|
+
discoveryRecord: transportStatus?.discoveryFound ? "found" : "missing",
|
|
144
|
+
paired: Boolean(transportStatus?.paired),
|
|
145
|
+
ambient: {
|
|
146
|
+
enabled: persisted?.ambient?.enabled ?? config.ambient.enabled,
|
|
147
|
+
supported: persisted?.ambient?.supported ?? true,
|
|
148
|
+
},
|
|
149
|
+
handoff: {
|
|
150
|
+
enabled: handoffEnabled,
|
|
151
|
+
lastReason: HANDOFF_REASONS.has(persisted?.handoff?.lastReason) ? persisted.handoff.lastReason : null,
|
|
152
|
+
counts: {
|
|
153
|
+
claimed: handoffCount("claimed"),
|
|
154
|
+
none: handoffCount("none"),
|
|
155
|
+
disabled: handoffCount("disabled", handoffEnabled ? 0 : 1),
|
|
156
|
+
deliveryFailed: handoffCount("deliveryFailed"),
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
categoriesRequested: Array.isArray(persisted?.categoriesRequested)
|
|
160
|
+
? persisted.categoriesRequested.filter((category) => GOVERNED_CATEGORIES.includes(category))
|
|
161
|
+
: [...config.categories],
|
|
162
|
+
lastOutcomeClass: persistedOutcome ?? transportOutcome,
|
|
163
|
+
counts: {
|
|
164
|
+
rows: Number.isInteger(persisted?.counts?.rows) && persisted.counts.rows >= 0 ? persisted.counts.rows : 0,
|
|
165
|
+
skippedCategories: Number.isInteger(persisted?.counts?.skippedCategories) && persisted.counts.skippedCategories >= 0
|
|
166
|
+
? persisted.counts.skippedCategories
|
|
167
|
+
: 0,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function formatStatusReport(report) {
|
|
173
|
+
return [
|
|
174
|
+
`active transport: ${report.transportKind}`,
|
|
175
|
+
`would select on restart: ${report.wouldSelectOnRestart}`,
|
|
176
|
+
`discovery record: ${report.discoveryRecord}`,
|
|
177
|
+
`paired: ${report.paired ? "yes" : "no"}`,
|
|
178
|
+
`ambient: ${report.ambient.enabled ? "on" : "off"}, ${report.ambient.supported ? "supported" : "unsupported"}`,
|
|
179
|
+
`hand-offs: ${report.handoff.enabled ? "on" : "off"}; claimed=${report.handoff.counts.claimed}, ` +
|
|
180
|
+
`none=${report.handoff.counts.none}, disabled=${report.handoff.counts.disabled}, ` +
|
|
181
|
+
`delivery_failed=${report.handoff.counts.deliveryFailed}, reason=${report.handoff.lastReason ?? "none"}`,
|
|
182
|
+
`categories requested: ${report.categoriesRequested.join(", ")}`,
|
|
183
|
+
`last outcome: ${report.lastOutcomeClass ?? "none"}`,
|
|
184
|
+
`counts: rows=${report.counts.rows}, skipped_categories=${report.counts.skippedCategories}`,
|
|
185
|
+
].join("\n");
|
|
186
|
+
}
|