@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
|
@@ -16,11 +16,14 @@ import { diffKxmProjectAgainstRevision, formatKxmPermissionDiff } from "../permi
|
|
|
16
16
|
import { readKxmLocalBindings, kxmUserStateRoot } from "../bindings.ts";
|
|
17
17
|
import { loadKxmProject } from "../project-config.ts";
|
|
18
18
|
import {
|
|
19
|
+
attachKxmSupervisor,
|
|
19
20
|
ensureKxmSupervisor,
|
|
20
21
|
kxmRuntimeRequest,
|
|
21
22
|
kxmSupervisorStatus,
|
|
22
23
|
} from "../runtime-supervisor.ts";
|
|
23
|
-
import { kxmRuntimePaths } from "../runtime-store.ts";
|
|
24
|
+
import { kxmRuntimePaths, runtimeError } from "../runtime-store.ts";
|
|
25
|
+
import { assembleTenantStatus, formatTenantStatus } from "../tenant-status.ts";
|
|
26
|
+
import { resolveClientAdminAuthToken } from "../hub-env.ts";
|
|
24
27
|
import {
|
|
25
28
|
formatHarnessInventory,
|
|
26
29
|
probeHarnessesAsync,
|
|
@@ -568,12 +571,14 @@ export async function cmdKxmRunList(runtime: Runtime): Promise<number> {
|
|
|
568
571
|
const projectId = String(bundle.project.value.id);
|
|
569
572
|
const supervisor = await ensureKxmSupervisor({ env: runtime.env });
|
|
570
573
|
const result = await kxmRuntimeRequest(supervisor, "GET", `/v1/projects/${encodeURIComponent(projectId)}/runs?projectRoot=${encodeURIComponent(projectRoot)}`);
|
|
571
|
-
const runs = (result.runs ?? []) as Array<{ runId: string; status: string; workflowId: string; createdAt: string }>;
|
|
574
|
+
const runs = (result.runs ?? []) as Array<{ runId: string; status: string; workflowId: string; createdAt: string; projectionError?: string }>;
|
|
572
575
|
print(
|
|
573
576
|
runtime.io,
|
|
574
577
|
runtime.json,
|
|
575
578
|
{ ok: true, command: "runs list", runs },
|
|
576
|
-
runs.length === 0
|
|
579
|
+
runs.length === 0
|
|
580
|
+
? "no runs"
|
|
581
|
+
: runs.map((run) => `${run.runId} ${run.status} ${run.workflowId} ${run.createdAt}${run.projectionError ? ` [state unverified: ${run.projectionError}]` : ""}`).join("\n"),
|
|
577
582
|
);
|
|
578
583
|
return 0;
|
|
579
584
|
} catch (error) {
|
|
@@ -586,6 +591,72 @@ export async function cmdKxmRunList(runtime: Runtime): Promise<number> {
|
|
|
586
591
|
}
|
|
587
592
|
}
|
|
588
593
|
|
|
594
|
+
export async function cmdTenantStatus(runtime: Runtime): Promise<number> {
|
|
595
|
+
let projectRoot: string | undefined;
|
|
596
|
+
let projectId: string;
|
|
597
|
+
try {
|
|
598
|
+
projectRoot = discoverKxmProjectRoot(runtime.cwd) ?? undefined;
|
|
599
|
+
if (!projectRoot) {
|
|
600
|
+
print(runtime.io, runtime.json, { ok: false, command: "tenant status", error: "project_required" }, "kxm tenant status requires a KXM project (run kxm init first)");
|
|
601
|
+
return 1;
|
|
602
|
+
}
|
|
603
|
+
const bundle = loadKxmProject(projectRoot, {});
|
|
604
|
+
projectId = String(bundle.project.value.id);
|
|
605
|
+
const root = projectRoot;
|
|
606
|
+
|
|
607
|
+
const payload = await assembleTenantStatus({
|
|
608
|
+
project: projectId,
|
|
609
|
+
hubUrl: runtime.serverUrl,
|
|
610
|
+
// Admin-scoped route: resolve the admin credential only (a project token would 401),
|
|
611
|
+
// and resolve it inside the hub source so a malformed persisted record degrades that
|
|
612
|
+
// one source instead of aborting the Runtime read with it.
|
|
613
|
+
resolveAdminToken: () => resolveClientAdminAuthToken(runtime.env),
|
|
614
|
+
fetchImpl: runtime.fetchImpl,
|
|
615
|
+
runtime: {
|
|
616
|
+
listRuns: async () => {
|
|
617
|
+
// Attach only: a status read must never conjure a supervisor. "Nothing is
|
|
618
|
+
// running" is an answer the portal can render, not a condition to repair.
|
|
619
|
+
const handle = await attachKxmSupervisor({ env: runtime.env });
|
|
620
|
+
if (!handle) {
|
|
621
|
+
throw runtimeError("runtime_supervisor_not_running", "runtime", "no live runtime supervisor on this box");
|
|
622
|
+
}
|
|
623
|
+
const result = await kxmRuntimeRequest(handle, "GET", `/v1/projects/${encodeURIComponent(projectId)}/runs?projectRoot=${encodeURIComponent(root)}`);
|
|
624
|
+
const runs = (result.runs ?? []) as Array<Record<string, unknown>>;
|
|
625
|
+
return runs.map((run) => ({
|
|
626
|
+
runId: String(run.runId ?? ""),
|
|
627
|
+
status: String(run.status ?? "unknown"),
|
|
628
|
+
homeRuntimeId: String(run.homeRuntimeId ?? ""),
|
|
629
|
+
...(typeof run.workflowId === "string" ? { workflowId: run.workflowId } : {}),
|
|
630
|
+
...(typeof run.createdAt === "string" ? { createdAt: run.createdAt } : {}),
|
|
631
|
+
...(typeof run.updatedAt === "string" ? { updatedAt: run.updatedAt } : {}),
|
|
632
|
+
...(typeof run.projectionError === "string" ? { projectionError: run.projectionError } : {}),
|
|
633
|
+
source: (typeof run.projectionError === "string" ? "runtime-cached" : "runtime-authoritative") as "runtime-cached" | "runtime-authoritative",
|
|
634
|
+
}));
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
if (payload.hub.state !== "ok" && payload.runtime.state !== "ok") {
|
|
640
|
+
print(
|
|
641
|
+
runtime.io,
|
|
642
|
+
runtime.json,
|
|
643
|
+
{ ok: false, command: "tenant status", error: "tenant_status_no_source", payload },
|
|
644
|
+
`neither source could be read: hub ${payload.hub.reason ?? "unknown"}, runtime ${payload.runtime.reason ?? "unknown"}`,
|
|
645
|
+
);
|
|
646
|
+
return 1;
|
|
647
|
+
}
|
|
648
|
+
print(runtime.io, runtime.json, { ok: true, command: "tenant status", ...payload }, formatTenantStatus(payload));
|
|
649
|
+
return 0;
|
|
650
|
+
} catch (error) {
|
|
651
|
+
if (error instanceof KxmConfigError) {
|
|
652
|
+
print(runtime.io, runtime.json, { ok: false, command: "tenant status", error: "tenant_status_failed", issues: error.issues }, `tenant status failed: ${error.message}`);
|
|
653
|
+
return 1;
|
|
654
|
+
}
|
|
655
|
+
print(runtime.io, runtime.json, { ok: false, command: "tenant status", error: "tenant_status_io_failed" }, "tenant status failed because a local operation did not complete");
|
|
656
|
+
return 1;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
589
660
|
export async function cmdHarnessList(runtime: Runtime): Promise<number> {
|
|
590
661
|
const inventory = await probeHarnessesAsync({ env: runtime.env });
|
|
591
662
|
print(runtime.io, runtime.json, { ok: true, command: "harness list", ...inventory }, formatHarnessInventory(inventory));
|
package/plugins/kxm/src/cli.ts
CHANGED
|
@@ -107,6 +107,7 @@ cmdBackup,
|
|
|
107
107
|
cmdKxmRunReceipt,
|
|
108
108
|
cmdKxmRunCancel,
|
|
109
109
|
cmdKxmRunList,
|
|
110
|
+
cmdTenantStatus,
|
|
110
111
|
cmdHarnessList,
|
|
111
112
|
cmdRouteChange,
|
|
112
113
|
cmdModelsScreen,
|
|
@@ -395,6 +396,13 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
|
|
|
395
396
|
result.code = await cmdKxmRunList(runtimeFrom(ctx, this));
|
|
396
397
|
});
|
|
397
398
|
|
|
399
|
+
const tenantCmd = addGlobalOptions(program.command("tenant").description("Composed tenant reads for machine clients (portal)"));
|
|
400
|
+
tenantCmd.helpCommand("help", "Show tenant help");
|
|
401
|
+
addGlobalOptions(tenantCmd.command("status").description("Read hub metadata and authoritative Runtime run state as one labeled view"))
|
|
402
|
+
.action(async function tenantStatusAction(this: Command) {
|
|
403
|
+
result.code = await cmdTenantStatus(runtimeFrom(ctx, this));
|
|
404
|
+
});
|
|
405
|
+
|
|
398
406
|
const modelsCmd = addGlobalOptions(program.command("models").description("Manage model catalogs, roles, and route state"));
|
|
399
407
|
modelsCmd.action(async function modelsScreenAction(this: Command) { result.code = await cmdModelsScreen(runtimeFrom(ctx, this)); });
|
|
400
408
|
modelsCmd.helpCommand("help", "Show models help");
|
|
@@ -234,3 +234,16 @@ export function resolveClientHubAuthToken(env: NodeJS.ProcessEnv, project: strin
|
|
|
234
234
|
const record = readHubEnvRecord(env);
|
|
235
235
|
return record?.projectTokens?.[project]?.trim() || record?.authToken?.trim() || undefined;
|
|
236
236
|
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Admin-scoped hub reads (`/v1/ops/snapshot`, …) are rejected with 401 by a project token,
|
|
240
|
+
* so this variant resolves the admin credential only: explicit `KXM_AUTH_TOKEN`, else the
|
|
241
|
+
* persisted record's admin token. It never falls back to a project token the route would
|
|
242
|
+
* refuse — handing the portal a 401 dressed as "configured" is worse than handing it none.
|
|
243
|
+
* Throws on a malformed persisted record, same as `resolveClientHubAuthToken`.
|
|
244
|
+
*/
|
|
245
|
+
export function resolveClientAdminAuthToken(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
246
|
+
const envToken = env.KXM_AUTH_TOKEN?.trim();
|
|
247
|
+
if (envToken) return envToken;
|
|
248
|
+
return readHubEnvRecord(env)?.authToken?.trim() || undefined;
|
|
249
|
+
}
|
|
@@ -8,7 +8,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
|
|
|
8
8
|
import { deliverInboxNotification } from "./inbox.ts";
|
|
9
9
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
10
10
|
|
|
11
|
-
const VERSION = "0.7.
|
|
11
|
+
const VERSION = "0.7.57";
|
|
12
12
|
const inbox = new Map<string, MessageRecord>();
|
|
13
13
|
const notifiedInbox = new Set<string>();
|
|
14
14
|
let meshClient: HubClient | undefined;
|
|
@@ -82,20 +82,62 @@ function extractText(content: unknown): string {
|
|
|
82
82
|
return "";
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
function
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
function asJsonObject(text: string): Record<string, unknown> | undefined {
|
|
86
|
+
try {
|
|
87
|
+
const parsed: unknown = JSON.parse(text);
|
|
88
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : undefined;
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined;
|
|
90
91
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function outcomeField(value: Record<string, unknown>): string | undefined {
|
|
95
|
+
const outcome = value.outcome;
|
|
96
|
+
return typeof outcome === "string" ? outcome : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The result a reply actually declares, or `undefined` when it declares none.
|
|
101
|
+
*
|
|
102
|
+
* Two shapes count: the whole reply is one JSON object, or a **standalone** result object on a
|
|
103
|
+
* line of its own — and when there is more than one of those, the **last** one wins, because
|
|
104
|
+
* that is where a reply puts its answer after showing an example. If anything in the tail after
|
|
105
|
+
* that declaration still looks like an outcome key, the reply is **ambiguous and settles
|
|
106
|
+
* `failed`**. What is deliberately not accepted: an outcome *word* anywhere in prose, and an
|
|
107
|
+
* object embedded mid-sentence, so
|
|
108
|
+
* `Example: {"outcome": "passed"}. Actual result: {"outcome": "failed"}` declares nothing at
|
|
109
|
+
* all and settles as `failed` rather than letting the illustration outrank the answer.
|
|
110
|
+
*/
|
|
111
|
+
function declaredOutcomeOf(text: string): string | undefined {
|
|
112
|
+
const trimmed = text.trim();
|
|
113
|
+
if (!trimmed) return undefined;
|
|
114
|
+
const whole = asJsonObject(trimmed);
|
|
115
|
+
if (whole) return outcomeField(whole);
|
|
116
|
+
const lines = trimmed.split(/\r?\n/);
|
|
117
|
+
let declaration: { index: number; outcome: string } | undefined;
|
|
118
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
119
|
+
const outcome = outcomeField(asJsonObject(lines[index]!.trim()) ?? {});
|
|
120
|
+
if (outcome !== undefined) declaration = { index, outcome };
|
|
96
121
|
}
|
|
97
|
-
if (
|
|
98
|
-
|
|
122
|
+
if (!declaration) return undefined;
|
|
123
|
+
// Ambiguity after the declaration fails closed. Anything in the tail that still looks like
|
|
124
|
+
// an outcome key — an inline `Actual result: {"outcome": "failed"}` on the next line, a
|
|
125
|
+
// pretty-printed object, or a second mention — means we cannot tell which one the reply is
|
|
126
|
+
// reporting, and guessing is exactly the behaviour this function exists to remove. Trailing
|
|
127
|
+
// prose that says nothing about outcomes is fine, which is what lets a real reply put its
|
|
128
|
+
// usage or sign-off after the result block.
|
|
129
|
+
const tail = lines.slice(declaration.index + 1).join("\n");
|
|
130
|
+
if (/"outcome"\s*:/.test(tail)) return undefined;
|
|
131
|
+
return declaration.outcome;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function determineOutcome(text: string, allowedOutcomes: readonly string[]): string {
|
|
135
|
+
// Structured result only. What does **not** count: an outcome *word* anywhere in the text —
|
|
136
|
+
// "the tests did not pass" used to settle a step as `passed` — and an empty or unstructured
|
|
137
|
+
// reply, which used to default to success. Undeclared here means `failed`; if the step does
|
|
138
|
+
// not declare `failed` the engine records `outcome_unknown` and terminates as `failed` anyway.
|
|
139
|
+
const declared = declaredOutcomeOf(text);
|
|
140
|
+
return declared !== undefined && allowedOutcomes.includes(declared) ? declared : "failed";
|
|
99
141
|
}
|
|
100
142
|
|
|
101
143
|
export class PiSession {
|
|
@@ -255,9 +297,11 @@ export class PiSession {
|
|
|
255
297
|
const isAborted = active.aborted || active.signal?.aborted;
|
|
256
298
|
let outcome: string;
|
|
257
299
|
if (isAborted) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
300
|
+
// A cancel is a cancel. The old chain fell through to `allowedOutcomes[0]` when the
|
|
301
|
+
// step declared neither `cancelled` nor `failed`, so aborting a step whose only
|
|
302
|
+
// declared outcome was `passed` reported `passed`. Returning `cancelled` instead lets
|
|
303
|
+
// the engine record `outcome_unknown` and terminate `failed` — never a borrowed success.
|
|
304
|
+
outcome = "cancelled";
|
|
261
305
|
} else {
|
|
262
306
|
outcome = determineOutcome(active.text, active.allowedOutcomes);
|
|
263
307
|
}
|
|
@@ -316,8 +360,8 @@ export class PiSession {
|
|
|
316
360
|
throw new Error("pi_session_busy");
|
|
317
361
|
}
|
|
318
362
|
if (signal?.aborted) {
|
|
319
|
-
|
|
320
|
-
return { outcome, text: "aborted", usage: {} };
|
|
363
|
+
// Same rule as the in-flight abort: never fall back to the first declared outcome.
|
|
364
|
+
return { outcome: "cancelled", text: "aborted", usage: {} };
|
|
321
365
|
}
|
|
322
366
|
|
|
323
367
|
this.status = "busy";
|
|
@@ -179,6 +179,28 @@ export interface KxmSupervisorHandle {
|
|
|
179
179
|
started: boolean;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Attach to a live supervisor, or return `undefined`. Never starts one.
|
|
184
|
+
*
|
|
185
|
+
* `ensureKxmSupervisor` is for operators: an absent supervisor is a problem to fix, so it
|
|
186
|
+
* spawns. A poller — the portal tenant read, a status screen — has the opposite contract:
|
|
187
|
+
* polling must not conjure a daemon, and "nothing is running" is an answer to report, not
|
|
188
|
+
* a condition to repair. Returns `undefined` for every not-running shape: no claim, a
|
|
189
|
+
* supervisor registered but dead, a missing token file, or a probe that fails the
|
|
190
|
+
* token-proof challenge.
|
|
191
|
+
*/
|
|
192
|
+
export async function attachKxmSupervisor(
|
|
193
|
+
options: { stateRoot?: string; env?: NodeJS.ProcessEnv } = {},
|
|
194
|
+
): Promise<KxmSupervisorHandle | undefined> {
|
|
195
|
+
const paths = kxmRuntimePaths(options.stateRoot !== undefined ? { stateRoot: options.stateRoot } : { ...(options.env ? { env: options.env } : {}) });
|
|
196
|
+
const status = kxmSupervisorStatus(paths);
|
|
197
|
+
if (!status.running || !status.port || !status.runtimeId) return undefined;
|
|
198
|
+
const token = readKxmSupervisorToken(paths);
|
|
199
|
+
if (!token) return undefined;
|
|
200
|
+
if (!(await probeSupervisor(status.port, status.runtimeId, token))) return undefined;
|
|
201
|
+
return { runtimeId: status.runtimeId, port: status.port, token, started: false };
|
|
202
|
+
}
|
|
203
|
+
|
|
182
204
|
/** Ensure a supervisor is running: reuse a live one, otherwise auto-start. */
|
|
183
205
|
export async function ensureKxmSupervisor(
|
|
184
206
|
options: { stateRoot?: string; env?: NodeJS.ProcessEnv; spawnImpl?: (scriptPath: string, env: NodeJS.ProcessEnv) => number } = {},
|
|
@@ -658,7 +680,25 @@ async function startKxmRuntimeSupervisorInner(
|
|
|
658
680
|
sendJson(response, 400, { ok: false, error: "runtime_request_invalid", message: `project ${requestedProjectId} is not the bound project ${context.projectId}` });
|
|
659
681
|
return;
|
|
660
682
|
}
|
|
661
|
-
|
|
683
|
+
// The stored `runs` row is a cache, not the state: per-run reads fold the event
|
|
684
|
+
// log (`projectKxmRunReadOnly`) precisely because the row can be stale. A listing
|
|
685
|
+
// that returned raw rows would let every consumer — including the portal's
|
|
686
|
+
// tenant read — present cached status as authoritative. Folding replays each
|
|
687
|
+
// run's events; workflows are transition-bounded, so this stays cheap at the
|
|
688
|
+
// 50-run cap. A run that refuses to fold is returned with its cached row plus
|
|
689
|
+
// `projectionError`, so one corrupt run cannot make the listing lie by omission.
|
|
690
|
+
const runs = context.eventStore.runsForProject(requestedProjectId, 50).map((stored) => {
|
|
691
|
+
try {
|
|
692
|
+
return projectKxmRunReadOnly(context, stored.runId);
|
|
693
|
+
} catch (error) {
|
|
694
|
+
return {
|
|
695
|
+
...stored,
|
|
696
|
+
projectionError: error instanceof KxmConfigError
|
|
697
|
+
? (error.issues[0]?.code ?? "runtime_projection_failed")
|
|
698
|
+
: "runtime_projection_failed",
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
});
|
|
662
702
|
sendJson(response, 200, { ok: true, runs });
|
|
663
703
|
return;
|
|
664
704
|
}
|