@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.
@@ -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 ? "no runs" : runs.map((run) => `${run.runId} ${run.status} ${run.workflowId} ${run.createdAt}`).join("\n"),
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));
@@ -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.54";
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 determineOutcome(text: string, allowedOutcomes: readonly string[]): string {
86
- const normalized = text.trim();
87
- const jsonMatch = /"outcome"\s*:\s*"([^"]+)"/.exec(normalized);
88
- if (jsonMatch && allowedOutcomes.includes(jsonMatch[1]!)) {
89
- return jsonMatch[1]!;
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
- for (const outcome of allowedOutcomes) {
92
- const regex = new RegExp(`\\b${outcome}\\b`, "i");
93
- if (regex.test(normalized)) {
94
- return outcome;
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 (allowedOutcomes.includes("passed")) return "passed";
98
- return allowedOutcomes[0] ?? "completed";
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
- outcome = active.allowedOutcomes.includes("cancelled")
259
- ? "cancelled"
260
- : (active.allowedOutcomes.includes("failed") ? "failed" : active.allowedOutcomes[0]!);
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
- const outcome = allowedOutcomes.includes("cancelled") ? "cancelled" : (allowedOutcomes.includes("failed") ? "failed" : allowedOutcomes[0]!);
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
- const runs = context.eventStore.runsForProject(requestedProjectId, 50);
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
  }