@kontextmind/kxm 0.7.54 → 0.7.57
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +47 -0
- package/docs/configuration.md +1 -0
- package/docs/contracts/lifecycles.md +16 -0
- package/docs/test-matrix.md +2 -0
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +253 -1
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +20 -1
- package/plugins/kxm/dist/runtime.js +52 -17
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/autocomplete.ts +2 -0
- package/plugins/kxm/src/cli/project.ts +74 -3
- package/plugins/kxm/src/cli.ts +8 -0
- package/plugins/kxm/src/hub-env.ts +13 -0
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/pi-producer.ts +61 -17
- package/plugins/kxm/src/runtime-supervisor.ts +41 -1
- package/plugins/kxm/src/tenant-status.ts +371 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { hubBindingScope } from "./hub-binding.ts";
|
|
2
|
+
|
|
3
|
+
export const TENANT_STATUS_SCHEMA = "kxm.tenant-status.v1" as const;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One upstream of the tenant view, with its own reachability. A source that could not be
|
|
7
|
+
* read is reported as `unavailable` with a stable reason — never silently filled from the
|
|
8
|
+
* other source, because the two sources answer different questions: the hub holds
|
|
9
|
+
* *metadata* (agent roster, message queue, its own workflow runs), the Runtime holds the
|
|
10
|
+
* *authoritative* run state on the box that owns the run. Blurring them is how a portal
|
|
11
|
+
* ends up rendering one as the other.
|
|
12
|
+
*/
|
|
13
|
+
export type TenantSourceState = "ok" | "unavailable";
|
|
14
|
+
|
|
15
|
+
export interface TenantSource<T> {
|
|
16
|
+
state: TenantSourceState;
|
|
17
|
+
/** When this source was read (or attempted), so a consumer can age the data itself. */
|
|
18
|
+
observedAt: string;
|
|
19
|
+
/** Stable machine code (`hub_unreachable`, `hub_timeout`, `runtime_supervisor_not_running`, …). */
|
|
20
|
+
reason?: string;
|
|
21
|
+
value?: T;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Hub-side roster entry: presence and liveness, per the hub's own observation. */
|
|
25
|
+
export interface TenantHubAgent {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
online: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TenantHubStage {
|
|
32
|
+
id: string;
|
|
33
|
+
status: string;
|
|
34
|
+
attempts?: number | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A run as the hub records it. The hub mints its own run ids at workflow start; this is a
|
|
39
|
+
* hub workflow run — metadata about work, not the Runtime's state for it.
|
|
40
|
+
*/
|
|
41
|
+
export interface TenantHubRun {
|
|
42
|
+
id: string;
|
|
43
|
+
status: string;
|
|
44
|
+
definitionId: string;
|
|
45
|
+
currentStage?: string | undefined;
|
|
46
|
+
targetAgentName?: string | undefined;
|
|
47
|
+
updatedAt?: string | undefined;
|
|
48
|
+
progress?: { done: number; total: number };
|
|
49
|
+
stages?: TenantHubStage[];
|
|
50
|
+
source: "hub-projection";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TenantHubPlan {
|
|
54
|
+
id: string;
|
|
55
|
+
runId: string;
|
|
56
|
+
summary: string;
|
|
57
|
+
createdAt: string;
|
|
58
|
+
stageId?: string | undefined;
|
|
59
|
+
severity?: string | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TenantHubValue {
|
|
63
|
+
project: string;
|
|
64
|
+
fetchedAt: string;
|
|
65
|
+
agents: TenantHubAgent[];
|
|
66
|
+
openMessageTotal: number;
|
|
67
|
+
runTotal: number;
|
|
68
|
+
runs: TenantHubRun[];
|
|
69
|
+
plans: TenantHubPlan[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A run as the Runtime that owns it records it. This is the authoritative state. */
|
|
73
|
+
export interface TenantRuntimeRun {
|
|
74
|
+
runId: string;
|
|
75
|
+
status: string;
|
|
76
|
+
homeRuntimeId: string;
|
|
77
|
+
workflowId?: string | undefined;
|
|
78
|
+
createdAt?: string | undefined;
|
|
79
|
+
updatedAt?: string | undefined;
|
|
80
|
+
/** Set when the stored row exists but the event-log fold refused; `status` is then the
|
|
81
|
+
* cached row, and a consumer must not present it as folded state. */
|
|
82
|
+
projectionError?: string | undefined;
|
|
83
|
+
/** `runtime-cached` exactly when `projectionError` is set: the row is the cache, not the
|
|
84
|
+
* folded state, and neither the label nor the cross-check may treat it as authoritative. */
|
|
85
|
+
source: "runtime-authoritative" | "runtime-cached";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface TenantRuntimeValue {
|
|
89
|
+
runs: TenantRuntimeRun[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface TenantRunDiscrepancy {
|
|
93
|
+
runId: string;
|
|
94
|
+
hubStatus: string;
|
|
95
|
+
runtimeStatus: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Cross-check between the two run populations. Hub runs and Runtime runs mint their ids
|
|
100
|
+
* independently (`newId("run")` on each side), so an id appearing in both means a hub run
|
|
101
|
+
* that this box drove — and only then is a status comparison meaningful. With no overlap
|
|
102
|
+
* the comparison is **unverified, not agreed**: "no matching ids" says the populations did
|
|
103
|
+
* not intersect, nothing more, and reporting agreement there would fabricate a green light
|
|
104
|
+
* out of an empty set.
|
|
105
|
+
*/
|
|
106
|
+
export interface TenantRunComparison {
|
|
107
|
+
state: "compared" | "unverified" | "unavailable";
|
|
108
|
+
reason?: string;
|
|
109
|
+
matched?: number;
|
|
110
|
+
/** Shared ids whose Runtime row failed to fold: present in both stores, but the
|
|
111
|
+
* authoritative side could not be read, so those runs are unverified rather than agreed. */
|
|
112
|
+
unverifiedFoldRuns?: number;
|
|
113
|
+
discrepancies?: TenantRunDiscrepancy[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface TenantStatusPayload {
|
|
117
|
+
schema: typeof TENANT_STATUS_SCHEMA;
|
|
118
|
+
project: string;
|
|
119
|
+
generatedAt: string;
|
|
120
|
+
hubUrl: string;
|
|
121
|
+
/** Reuses the `kxm hub bind` classification: `loopback` and `remote` ship different rules. */
|
|
122
|
+
bindingScope: "loopback" | "remote";
|
|
123
|
+
hub: TenantSource<TenantHubValue>;
|
|
124
|
+
runtime: TenantSource<TenantRuntimeValue>;
|
|
125
|
+
runComparison: TenantRunComparison;
|
|
126
|
+
/** True when any source is unavailable. Assembling a payload is not the same as seeing. */
|
|
127
|
+
degraded: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface TenantStatusRuntimeReader {
|
|
131
|
+
listRuns(): Promise<TenantRuntimeRun[]>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const DEFAULT_HUB_TIMEOUT_MS = 5_000;
|
|
135
|
+
|
|
136
|
+
function hubSourceReason(status: number): string {
|
|
137
|
+
if (status === 401 || status === 403) return "hub_unauthorized";
|
|
138
|
+
if (status === 404) return "hub_not_found";
|
|
139
|
+
return `hub_http_${status}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function asString(value: unknown): string | undefined {
|
|
143
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
147
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Compose the tenant view from the two authorities that actually hold the data.
|
|
152
|
+
*
|
|
153
|
+
* Both sources are read **concurrently and independently**: a hub that hangs to its
|
|
154
|
+
* deadline cannot delay or suppress the Runtime half of the view, and a Runtime that is
|
|
155
|
+
* not running cannot take the hub metadata down with it. The hub is read over HTTP
|
|
156
|
+
* (`/v1/ops/snapshot`, admin credential); the Runtime is read through an injected reader
|
|
157
|
+
* so this function stays pure and testable — the CLI passes a reader that attaches to the
|
|
158
|
+
* live supervisor, and a test passes whatever it likes.
|
|
159
|
+
*/
|
|
160
|
+
export async function assembleTenantStatus(input: {
|
|
161
|
+
project: string;
|
|
162
|
+
hubUrl: string;
|
|
163
|
+
/** Resolves the admin credential. May throw on a malformed persisted record; that
|
|
164
|
+
* failure belongs to the hub source alone and never aborts the Runtime read. */
|
|
165
|
+
resolveAdminToken: () => string | undefined;
|
|
166
|
+
fetchImpl: typeof fetch;
|
|
167
|
+
runtime: TenantStatusRuntimeReader;
|
|
168
|
+
hubTimeoutMs?: number | undefined;
|
|
169
|
+
now?: () => Date;
|
|
170
|
+
}): Promise<TenantStatusPayload> {
|
|
171
|
+
const now = input.now ?? ((): Date => new Date());
|
|
172
|
+
const base = input.hubUrl.replace(/\/$/, "");
|
|
173
|
+
const at = (): string => now().toISOString();
|
|
174
|
+
|
|
175
|
+
const readHub = async (): Promise<TenantSource<TenantHubValue>> => {
|
|
176
|
+
const attemptedAt = at();
|
|
177
|
+
let token: string | undefined;
|
|
178
|
+
try {
|
|
179
|
+
token = input.resolveAdminToken();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// A malformed hub-env record is a configuration failure with a specific repair.
|
|
182
|
+
// It belongs to this source; the Runtime read proceeds regardless.
|
|
183
|
+
void error;
|
|
184
|
+
return { state: "unavailable", observedAt: attemptedAt, reason: "hub_credential_unreadable" };
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const response = await input.fetchImpl(`${base}/v1/ops/snapshot?project=${encodeURIComponent(input.project)}`, {
|
|
188
|
+
headers: token ? { authorization: `Bearer ${token}` } : {},
|
|
189
|
+
signal: AbortSignal.timeout(input.hubTimeoutMs ?? DEFAULT_HUB_TIMEOUT_MS),
|
|
190
|
+
});
|
|
191
|
+
if (!response.ok) {
|
|
192
|
+
return { state: "unavailable", observedAt: attemptedAt, reason: hubSourceReason(response.status) };
|
|
193
|
+
}
|
|
194
|
+
let body: unknown;
|
|
195
|
+
try {
|
|
196
|
+
body = await response.json();
|
|
197
|
+
} catch (error) {
|
|
198
|
+
// A deadline can expire mid-body; that is a timeout, not malformed content, and
|
|
199
|
+
// classifying it as `hub_response_invalid` would hide the one fact that matters.
|
|
200
|
+
const name = error instanceof Error ? error.name : undefined;
|
|
201
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
202
|
+
return { state: "unavailable", observedAt: attemptedAt, reason: "hub_timeout" };
|
|
203
|
+
}
|
|
204
|
+
// Reachable but not a JSON document: a different failure than a dead socket.
|
|
205
|
+
return { state: "unavailable", observedAt: attemptedAt, reason: "hub_response_invalid" };
|
|
206
|
+
}
|
|
207
|
+
if (!isObject(body)
|
|
208
|
+
|| typeof body.project !== "string"
|
|
209
|
+
|| typeof body.fetchedAt !== "string"
|
|
210
|
+
|| !Array.isArray(body.agents)
|
|
211
|
+
|| !Array.isArray(body.runs)
|
|
212
|
+
|| !Array.isArray(body.plans ?? [])
|
|
213
|
+
|| typeof body.openMessageTotal !== "number"
|
|
214
|
+
|| typeof body.runTotal !== "number") {
|
|
215
|
+
// A 200 with an unusable body must not become a healthy empty snapshot.
|
|
216
|
+
return { state: "unavailable", observedAt: attemptedAt, reason: "hub_response_invalid" };
|
|
217
|
+
}
|
|
218
|
+
if (body.project !== input.project) {
|
|
219
|
+
return { state: "unavailable", observedAt: attemptedAt, reason: "hub_project_mismatch" };
|
|
220
|
+
}
|
|
221
|
+
const runRows = body.runs.filter(isObject);
|
|
222
|
+
const value: TenantHubValue = {
|
|
223
|
+
project: body.project,
|
|
224
|
+
fetchedAt: body.fetchedAt,
|
|
225
|
+
agents: body.agents.filter(isObject).map((agent) => ({
|
|
226
|
+
id: asString(agent.id) ?? "",
|
|
227
|
+
name: asString(agent.name) ?? "",
|
|
228
|
+
online: agent.online === true,
|
|
229
|
+
})),
|
|
230
|
+
openMessageTotal: body.openMessageTotal,
|
|
231
|
+
runTotal: body.runTotal,
|
|
232
|
+
runs: runRows.map((run) => {
|
|
233
|
+
const stages = Array.isArray(run.stages)
|
|
234
|
+
? run.stages.filter(isObject).map((stage) => ({
|
|
235
|
+
id: asString(stage.id) ?? "",
|
|
236
|
+
status: asString(stage.status) ?? "unknown",
|
|
237
|
+
...(typeof stage.attempts === "number" ? { attempts: stage.attempts } : {}),
|
|
238
|
+
}))
|
|
239
|
+
: undefined;
|
|
240
|
+
const done = stages?.filter((stage) => stage.status === "passed" || stage.status === "failed" || stage.status === "warning").length;
|
|
241
|
+
return {
|
|
242
|
+
id: asString(run.id) ?? "",
|
|
243
|
+
status: asString(run.status) ?? "unknown",
|
|
244
|
+
definitionId: asString(run.definitionId) ?? "",
|
|
245
|
+
...(asString(run.currentStage) ? { currentStage: asString(run.currentStage) } : {}),
|
|
246
|
+
...(asString(run.targetAgentName) ? { targetAgentName: asString(run.targetAgentName) } : {}),
|
|
247
|
+
...(asString(run.updatedAt) ? { updatedAt: asString(run.updatedAt) } : {}),
|
|
248
|
+
...(stages !== undefined ? { progress: { done: done ?? 0, total: stages.length }, stages } : {}),
|
|
249
|
+
source: "hub-projection" as const,
|
|
250
|
+
};
|
|
251
|
+
}),
|
|
252
|
+
plans: (Array.isArray(body.plans) ? body.plans : []).filter(isObject).map((plan) => ({
|
|
253
|
+
id: asString(plan.id) ?? "",
|
|
254
|
+
runId: asString(plan.runId) ?? "",
|
|
255
|
+
summary: asString(plan.summary) ?? "",
|
|
256
|
+
createdAt: asString(plan.createdAt) ?? "",
|
|
257
|
+
...(asString(plan.stageId) ? { stageId: asString(plan.stageId) } : {}),
|
|
258
|
+
...(asString(plan.severity) ? { severity: asString(plan.severity) } : {}),
|
|
259
|
+
})),
|
|
260
|
+
};
|
|
261
|
+
return { state: "ok", observedAt: attemptedAt, value };
|
|
262
|
+
} catch (error) {
|
|
263
|
+
const name = error instanceof Error ? error.name : undefined;
|
|
264
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
265
|
+
return { state: "unavailable", observedAt: attemptedAt, reason: "hub_timeout" };
|
|
266
|
+
}
|
|
267
|
+
return { state: "unavailable", observedAt: attemptedAt, reason: "hub_unreachable" };
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const readRuntime = async (): Promise<TenantSource<TenantRuntimeValue>> => {
|
|
272
|
+
const attemptedAt = at();
|
|
273
|
+
try {
|
|
274
|
+
const runs = await input.runtime.listRuns();
|
|
275
|
+
return { state: "ok", observedAt: attemptedAt, value: { runs } };
|
|
276
|
+
} catch (error) {
|
|
277
|
+
const code = error instanceof Error && "issues" in error
|
|
278
|
+
&& Array.isArray((error as { issues: Array<{ code?: unknown }> }).issues)
|
|
279
|
+
&& typeof (error as { issues: Array<{ code?: unknown }> }).issues[0]?.code === "string"
|
|
280
|
+
? (error as { issues: Array<{ code: string }> }).issues[0]!.code
|
|
281
|
+
: undefined;
|
|
282
|
+
return { state: "unavailable", observedAt: attemptedAt, reason: code ?? "runtime_unavailable" };
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const [hub, runtime] = await Promise.all([readHub(), readRuntime()]);
|
|
287
|
+
|
|
288
|
+
let runComparison: TenantRunComparison;
|
|
289
|
+
if (!hub.value || !runtime.value) {
|
|
290
|
+
runComparison = {
|
|
291
|
+
state: "unavailable",
|
|
292
|
+
reason: !hub.value ? `hub_${hub.reason ?? "unavailable"}` : `runtime_${runtime.reason ?? "unavailable"}`,
|
|
293
|
+
};
|
|
294
|
+
} else {
|
|
295
|
+
// Only cleanly folded rows are authoritative. A row whose fold failed is the cache, and
|
|
296
|
+
// counting it as agreement is precisely the lie this comparison exists to prevent: a
|
|
297
|
+
// corrupt event log under a hub run's id would otherwise print "agree".
|
|
298
|
+
const authoritative = new Map(
|
|
299
|
+
runtime.value.runs
|
|
300
|
+
.filter((run) => run.projectionError === undefined)
|
|
301
|
+
.map((run) => [run.runId, run.status] as const),
|
|
302
|
+
);
|
|
303
|
+
const hubIds = new Set(hub.value.runs.map((run) => run.id));
|
|
304
|
+
const foldFailed = runtime.value.runs.filter((run) => run.projectionError !== undefined && hubIds.has(run.runId));
|
|
305
|
+
const discrepancies: TenantRunDiscrepancy[] = [];
|
|
306
|
+
let matched = 0;
|
|
307
|
+
for (const projected of hub.value.runs) {
|
|
308
|
+
const runtimeStatus = authoritative.get(projected.id);
|
|
309
|
+
if (runtimeStatus === undefined) continue;
|
|
310
|
+
matched += 1;
|
|
311
|
+
if (runtimeStatus !== projected.status) {
|
|
312
|
+
discrepancies.push({ runId: projected.id, hubStatus: projected.status, runtimeStatus });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (matched > 0) {
|
|
316
|
+
runComparison = {
|
|
317
|
+
state: "compared",
|
|
318
|
+
matched,
|
|
319
|
+
...(foldFailed.length > 0 ? { unverifiedFoldRuns: foldFailed.length } : {}),
|
|
320
|
+
...(discrepancies.length > 0 ? { discrepancies } : {}),
|
|
321
|
+
};
|
|
322
|
+
} else {
|
|
323
|
+
runComparison = foldFailed.length > 0
|
|
324
|
+
? { state: "unverified", reason: "runtime_fold_failed", matched: 0, unverifiedFoldRuns: foldFailed.length }
|
|
325
|
+
: { state: "unverified", reason: "run_identity_link_absent", matched: 0 };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
schema: TENANT_STATUS_SCHEMA,
|
|
331
|
+
project: input.project,
|
|
332
|
+
generatedAt: at(),
|
|
333
|
+
hubUrl: input.hubUrl,
|
|
334
|
+
bindingScope: hubBindingScope(input.hubUrl),
|
|
335
|
+
hub,
|
|
336
|
+
runtime,
|
|
337
|
+
runComparison,
|
|
338
|
+
degraded: hub.state !== "ok" || runtime.state !== "ok",
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** One line per source for humans; the portal reads the JSON, operators read this. */
|
|
343
|
+
export function formatTenantStatus(payload: TenantStatusPayload): string {
|
|
344
|
+
const hubLine = payload.hub.state === "ok" && payload.hub.value
|
|
345
|
+
? `hub ${payload.hub.value.agents.filter((agent) => agent.online).length}/${payload.hub.value.agents.length} agents online, ${payload.hub.value.runs.length} hub runs, ${payload.hub.value.openMessageTotal} open messages`
|
|
346
|
+
: `hub unavailable (${payload.hub.reason ?? "unknown"})`;
|
|
347
|
+
const runtimeLine = payload.runtime.state === "ok" && payload.runtime.value
|
|
348
|
+
? (() => {
|
|
349
|
+
const cached = payload.runtime.value!.runs.filter((run) => run.projectionError !== undefined).length;
|
|
350
|
+
const authoritative = payload.runtime.value!.runs.length - cached;
|
|
351
|
+
// A fold-failed row is the cache; printing it under "authoritative" would put the
|
|
352
|
+
// lie back in exactly the place an operator reads first.
|
|
353
|
+
return `runtime ${authoritative} runs (authoritative, on this box)${cached > 0 ? `, ${cached} cached (fold failed, not state)` : ""}`;
|
|
354
|
+
})()
|
|
355
|
+
: `runtime unavailable (${payload.runtime.reason ?? "unknown"})`;
|
|
356
|
+
const comparisonLine = payload.runComparison.state === "compared"
|
|
357
|
+
? payload.runComparison.discrepancies && payload.runComparison.discrepancies.length > 0
|
|
358
|
+
? `cross-check: ${payload.runComparison.discrepancies.length} of ${payload.runComparison.matched} matched run(s) disagree${payload.runComparison.unverifiedFoldRuns ? ` (${payload.runComparison.unverifiedFoldRuns} unverified: fold failed)` : ""}`
|
|
359
|
+
: `cross-check: ${payload.runComparison.matched} matched run(s) agree${payload.runComparison.unverifiedFoldRuns ? ` (${payload.runComparison.unverifiedFoldRuns} unverified: fold failed)` : ""}`
|
|
360
|
+
: payload.runComparison.state === "unverified"
|
|
361
|
+
? payload.runComparison.reason === "runtime_fold_failed"
|
|
362
|
+
? `cross-check: unavailable — ${payload.runComparison.unverifiedFoldRuns ?? 0} shared run(s) could not be verified (runtime fold failed)`
|
|
363
|
+
: "cross-check: unavailable — no run id appears in both sources (independent id spaces)"
|
|
364
|
+
: `cross-check: unavailable (${payload.runComparison.reason ?? "unknown"})`;
|
|
365
|
+
return [
|
|
366
|
+
`tenant ${payload.project} @ ${payload.hubUrl} (${payload.bindingScope})`,
|
|
367
|
+
hubLine,
|
|
368
|
+
runtimeLine,
|
|
369
|
+
comparisonLine,
|
|
370
|
+
].join("\n");
|
|
371
|
+
}
|