@kontextmind/kxm 0.7.77 → 0.7.79
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/docs/contracts/synchronization.md +31 -3
- package/docs/operations.md +27 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +63 -3
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +443 -47
- package/plugins/kxm/dist/runtime.js +444 -197
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/project.ts +57 -3
- package/plugins/kxm/src/cli.ts +4 -0
- package/plugins/kxm/src/database.ts +1 -1
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/runtime-store.ts +121 -10
- package/plugins/kxm/src/runtime-supervisor.ts +323 -42
|
@@ -27,10 +27,11 @@ import { createKxmOneShotProducer } from "./oneshot-producer.ts";
|
|
|
27
27
|
import { isRouteAdmitted } from "./routes.ts";
|
|
28
28
|
import { KxmRunScheduler, createKxmSimulatedProducer, recordDriveReceipt, recoverKxmRun, kxmDrivePollProjection } from "./engine.ts";
|
|
29
29
|
import { kxmDriveSession, kxmOpenDriveSessions } from "./runtime-owner.ts";
|
|
30
|
-
import { RuntimeHubClient } from "./client.ts";
|
|
30
|
+
import { RuntimeHubClient, HubHttpError, type SyncPushResponse } from "./client.ts";
|
|
31
31
|
import { readHubBinding } from "./hub-binding.ts";
|
|
32
32
|
import { resolveClientHubAuthToken } from "./hub-env.ts";
|
|
33
|
-
import {
|
|
33
|
+
import { createLogger } from "./logger.ts";
|
|
34
|
+
import type { KxmOutboxRow, KxmOutboxStatus } from "./runtime-store.ts";
|
|
34
35
|
|
|
35
36
|
/* ------------------------------------------------------------------ *
|
|
36
37
|
* Token management
|
|
@@ -364,6 +365,10 @@ export const DEFAULT_RUNTIME_SYNC_INTERVAL_MS = 10_000;
|
|
|
364
365
|
const MIN_RUNTIME_SYNC_INTERVAL_MS = 250;
|
|
365
366
|
const MAX_RUNTIME_SYNC_INTERVAL_MS = 60_000;
|
|
366
367
|
const OUTBOX_PUSH_BATCH = 32;
|
|
368
|
+
/** Hub body ceiling is 256 KiB; leave headroom for the JSON envelope. */
|
|
369
|
+
const OUTBOX_PUSH_BATCH_BYTES = 200_000;
|
|
370
|
+
/** How long the sync tick stops knocking on a hub that keeps failing. */
|
|
371
|
+
const RUNTIME_SYNC_MAX_BACKOFF_MS = 5 * 60_000;
|
|
367
372
|
|
|
368
373
|
/** How often the supervisor heartbeats and pushes its outbox. Keep it well
|
|
369
374
|
* under the hub's presence lease (30 s by default). */
|
|
@@ -375,19 +380,54 @@ export function runtimeSyncIntervalMs(env: NodeJS.ProcessEnv = process.env): num
|
|
|
375
380
|
return Math.min(MAX_RUNTIME_SYNC_INTERVAL_MS, Math.max(MIN_RUNTIME_SYNC_INTERVAL_MS, parsed));
|
|
376
381
|
}
|
|
377
382
|
|
|
383
|
+
export interface KxmOutboxRefusal {
|
|
384
|
+
runId: string;
|
|
385
|
+
sequence: number;
|
|
386
|
+
code: string;
|
|
387
|
+
}
|
|
388
|
+
|
|
378
389
|
export interface KxmOutboxSyncResult {
|
|
390
|
+
/** Rows carried to the hub in this pass, including rows later refused. */
|
|
379
391
|
pushed: number;
|
|
392
|
+
/** Rows the hub accepted or already held — the cursor advanced. */
|
|
380
393
|
acked: number;
|
|
381
|
-
|
|
382
|
-
|
|
394
|
+
/** Rows the hub refused in a way re-sending cannot change. */
|
|
395
|
+
refused: number;
|
|
396
|
+
/** Per-row refusals, oldest first, carrying the hub's own code. */
|
|
397
|
+
refusals: KxmOutboxRefusal[];
|
|
398
|
+
/** Rows the hub gave no answer for. Left pending, never acked. */
|
|
399
|
+
unconfirmed: number;
|
|
400
|
+
/** A transport failure ended the pass; the rest of the queue stays pending. */
|
|
401
|
+
blocked: boolean;
|
|
402
|
+
/** Why the pass was blocked, already reduced to an operator-safe line. */
|
|
403
|
+
blockedReason?: string;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** A hub answer that will not change if the same bytes are sent again. */
|
|
407
|
+
function isDurableRefusal(outcome: string): boolean {
|
|
408
|
+
return outcome === "conflict" || outcome === "rejected";
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** An HTTP refusal that says "this batch is too big", not "try again later". */
|
|
412
|
+
function isOversizeRefusal(error: unknown): boolean {
|
|
413
|
+
return error instanceof HubHttpError
|
|
414
|
+
&& (error.statusCode === 413 || error.code === "sync_batch_too_large" || error.code === "payload_too_large");
|
|
383
415
|
}
|
|
384
416
|
|
|
385
417
|
/**
|
|
386
|
-
* Push every pending outbox row to the bound hub in outbox order
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
418
|
+
* Push every pending outbox row to the bound hub in outbox order.
|
|
419
|
+
*
|
|
420
|
+
* Accepted and duplicate rows are acked. A row the hub **durably** refuses — a
|
|
421
|
+
* sequence already used with other bytes, a project id another hub project
|
|
422
|
+
* claimed, an event that fails the sync schema — is refused locally too: it
|
|
423
|
+
* leaves the pending queue with the hub's code recorded, because re-sending the
|
|
424
|
+
* same bytes can only re-raise the same alert. That is the difference between a
|
|
425
|
+
* retry and a stampede, and it is what keeps one unreachable row from parking
|
|
426
|
+
* every row behind it.
|
|
427
|
+
*
|
|
428
|
+
* A transport failure marks the pass `blocked` and returns: the rows stay
|
|
429
|
+
* pending for a real retry, with the failure carried back to the caller so it
|
|
430
|
+
* can be logged and backed off instead of swallowed.
|
|
391
431
|
*/
|
|
392
432
|
export async function syncKxmOutbox(
|
|
393
433
|
eventStore: KxmRunEventStore,
|
|
@@ -395,8 +435,74 @@ export async function syncKxmOutbox(
|
|
|
395
435
|
options: { now?: () => string; batchSize?: number } = {},
|
|
396
436
|
): Promise<KxmOutboxSyncResult> {
|
|
397
437
|
const now = options.now ?? (() => new Date().toISOString());
|
|
398
|
-
const batchSize = options.batchSize ?? OUTBOX_PUSH_BATCH;
|
|
399
|
-
const result: KxmOutboxSyncResult = { pushed: 0, acked: 0,
|
|
438
|
+
const batchSize = Math.max(1, options.batchSize ?? OUTBOX_PUSH_BATCH);
|
|
439
|
+
const result: KxmOutboxSyncResult = { pushed: 0, acked: 0, refused: 0, refusals: [], unconfirmed: 0, blocked: false };
|
|
440
|
+
|
|
441
|
+
const refuse = (row: KxmOutboxRow, code: string): void => {
|
|
442
|
+
if (eventStore.refuseOutbox([{ seq: row.seq, code }], now()) === 0) return;
|
|
443
|
+
result.refused += 1;
|
|
444
|
+
result.refusals.push({ runId: row.runId, sequence: row.sequence, code });
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Deliver one batch. Returns false only for a transient failure, where every
|
|
449
|
+
* row of the batch stays pending. One result per event, in order, is the
|
|
450
|
+
* hub's contract, so the answer for a row is found at its index and then
|
|
451
|
+
* verified against the row it claims to describe.
|
|
452
|
+
*/
|
|
453
|
+
const deliver = async (batch: readonly KxmOutboxRow[]): Promise<boolean> => {
|
|
454
|
+
const payload: unknown[] = [];
|
|
455
|
+
const sendable: KxmOutboxRow[] = [];
|
|
456
|
+
for (const row of batch) {
|
|
457
|
+
try {
|
|
458
|
+
payload.push(JSON.parse(row.syncEvent) as unknown);
|
|
459
|
+
sendable.push(row);
|
|
460
|
+
} catch {
|
|
461
|
+
// Written by the transform, so unparseable bytes are local corruption —
|
|
462
|
+
// durable, visible, and never a reason to stop the queue behind it.
|
|
463
|
+
refuse(row, "sync_row_unreadable");
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (sendable.length === 0) return true;
|
|
467
|
+
eventStore.markOutboxAttempted(sendable.map((row) => row.seq), now());
|
|
468
|
+
let response: SyncPushResponse;
|
|
469
|
+
try {
|
|
470
|
+
response = await client.pushSyncEvents(payload);
|
|
471
|
+
} catch (error) {
|
|
472
|
+
if (isOversizeRefusal(error)) {
|
|
473
|
+
if (sendable.length > 1) {
|
|
474
|
+
// Isolate the offender one row at a time so the batch never becomes
|
|
475
|
+
// a permanent head-of-line block.
|
|
476
|
+
for (const row of sendable) {
|
|
477
|
+
if (!(await deliver([row]))) return false;
|
|
478
|
+
}
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
refuse(sendable[0]!, "sync_row_too_large");
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
result.blockedReason = syncFailureText(error);
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
result.pushed += sendable.length;
|
|
488
|
+
const acked: number[] = [];
|
|
489
|
+
sendable.forEach((row, index) => {
|
|
490
|
+
const entry = response.results[index];
|
|
491
|
+
const describesRow = entry !== undefined
|
|
492
|
+
&& (entry.runId === undefined || entry.runId === row.runId)
|
|
493
|
+
&& (entry.sequence === undefined || entry.sequence === row.sequence);
|
|
494
|
+
if (!entry || !describesRow) {
|
|
495
|
+
result.unconfirmed += 1;
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (entry.outcome === "accepted" || entry.outcome === "duplicate") acked.push(row.seq);
|
|
499
|
+
else if (isDurableRefusal(entry.outcome)) refuse(row, entry.code ?? `sync_${entry.outcome}`);
|
|
500
|
+
else result.unconfirmed += 1;
|
|
501
|
+
});
|
|
502
|
+
result.acked += eventStore.ackOutbox(acked, now());
|
|
503
|
+
return true;
|
|
504
|
+
};
|
|
505
|
+
|
|
400
506
|
let afterSeq = 0;
|
|
401
507
|
for (;;) {
|
|
402
508
|
const rows = eventStore.pendingOutbox(batchSize, afterSeq);
|
|
@@ -405,8 +511,7 @@ export async function syncKxmOutbox(
|
|
|
405
511
|
// over its body ceiling (HTTP 413), and retrying the same oversized batch
|
|
406
512
|
// would permanently block the queue. Trim to the byte budget and leave the
|
|
407
513
|
// rest for the next iteration.
|
|
408
|
-
|
|
409
|
-
let byteBudget = MAX_BATCH_BYTES;
|
|
514
|
+
let byteBudget = OUTBOX_PUSH_BATCH_BYTES;
|
|
410
515
|
let sendCount = 0;
|
|
411
516
|
for (const row of rows) {
|
|
412
517
|
const rowBytes = Buffer.byteLength(row.syncEvent, "utf8") + 64; // JSON overhead
|
|
@@ -414,21 +519,12 @@ export async function syncKxmOutbox(
|
|
|
414
519
|
byteBudget -= rowBytes;
|
|
415
520
|
sendCount += 1;
|
|
416
521
|
}
|
|
417
|
-
const batch = rows.slice(0, sendCount);
|
|
418
|
-
if (batch.length === 0) batch.push(rows[0]!); // one oversized row: send alone, hub will 413
|
|
522
|
+
const batch = rows.slice(0, Math.max(1, sendCount));
|
|
419
523
|
afterSeq = batch[batch.length - 1]!.seq;
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const outcomes = new Map(response.results.map((entry) => [`${entry.runId}\u0000${entry.sequence}`, entry.outcome]));
|
|
424
|
-
const acked: number[] = [];
|
|
425
|
-
for (const row of batch) {
|
|
426
|
-
const outcome = outcomes.get(`${row.runId}\u0000${row.sequence}`);
|
|
427
|
-
if (outcome === "accepted" || outcome === "duplicate") acked.push(row.seq);
|
|
428
|
-
else if (outcome === "conflict") result.conflicts += 1;
|
|
429
|
-
else result.rejected += 1;
|
|
524
|
+
if (!(await deliver(batch))) {
|
|
525
|
+
result.blocked = true;
|
|
526
|
+
return result;
|
|
430
527
|
}
|
|
431
|
-
result.acked += eventStore.ackOutbox(acked, now());
|
|
432
528
|
}
|
|
433
529
|
}
|
|
434
530
|
|
|
@@ -437,9 +533,10 @@ function runtimeHubClientFor(context: KxmRuntimeContext, env: NodeJS.ProcessEnv)
|
|
|
437
533
|
const serverUrl = env.KXM_SERVER_URL?.trim() || readHubBinding(env)?.url;
|
|
438
534
|
if (!serverUrl) return undefined;
|
|
439
535
|
// Use the context's actual projectId — the same identity sync events carry — so the
|
|
440
|
-
// ops snapshot can join runs to their home Runtime.
|
|
441
|
-
//
|
|
442
|
-
//
|
|
536
|
+
// ops snapshot can join runs to their home Runtime. The npm package name (what an
|
|
537
|
+
// earlier build sent) never matches project.yaml's prj_* id: presence lands under one
|
|
538
|
+
// label and runs under the other, and the hub pins the project id to the first label
|
|
539
|
+
// it ever saw, which refuses every later push. See the supervisor sync gate.
|
|
443
540
|
const project = context.projectId;
|
|
444
541
|
const authToken = resolveClientHubAuthToken(env, project);
|
|
445
542
|
return new RuntimeHubClient({
|
|
@@ -450,6 +547,54 @@ function runtimeHubClientFor(context: KxmRuntimeContext, env: NodeJS.ProcessEnv)
|
|
|
450
547
|
});
|
|
451
548
|
}
|
|
452
549
|
|
|
550
|
+
/* ------------------------------------------------------------------ *
|
|
551
|
+
* Sync status: what the tick saw, readable without opening SQLite
|
|
552
|
+
* ------------------------------------------------------------------ */
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* One project's outbound sync state as the supervisor last observed it.
|
|
556
|
+
*
|
|
557
|
+
* `state` is the answer to "is this Runtime talking to the hub?":
|
|
558
|
+
* `ok` (rows are acking), `no_hub` (nothing bound — keeping rows locally is the
|
|
559
|
+
* design), `blocked` (a transport or credential failure; retryable, backing
|
|
560
|
+
* off), or `refusing` (the hub answered durably; an operator has to change
|
|
561
|
+
* something). `blocked` and `refusing` are the two states that mean run facts
|
|
562
|
+
* are not reaching the hub.
|
|
563
|
+
*/
|
|
564
|
+
export interface KxmProjectSyncStatus {
|
|
565
|
+
projectId: string;
|
|
566
|
+
projectRoot: string;
|
|
567
|
+
homeRuntimeId: string;
|
|
568
|
+
state: "ok" | "no_hub" | "blocked" | "refusing";
|
|
569
|
+
outbox: KxmOutboxStatus;
|
|
570
|
+
consecutiveFailures: number;
|
|
571
|
+
hubUrl?: string;
|
|
572
|
+
lastCompletedAt?: string;
|
|
573
|
+
lastPushed?: number;
|
|
574
|
+
lastAcked?: number;
|
|
575
|
+
lastRefused?: number;
|
|
576
|
+
lastUnconfirmed?: number;
|
|
577
|
+
lastError?: string;
|
|
578
|
+
nextAttemptAt?: string;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Exponential backoff on consecutive stalls, capped so a hub outage heals by itself. */
|
|
582
|
+
function syncBackoffMs(consecutiveStalls: number, intervalMs: number): number {
|
|
583
|
+
if (consecutiveStalls <= 0) return 0;
|
|
584
|
+
return Math.min(RUNTIME_SYNC_MAX_BACKOFF_MS, intervalMs * 2 ** Math.min(5, consecutiveStalls - 1));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** A failure worth showing an operator: bounded, and never the bearer token. */
|
|
588
|
+
function syncFailureText(error: unknown): string {
|
|
589
|
+
const code = error instanceof HubHttpError ? `${error.statusCode} ${error.code ?? "error"}` : undefined;
|
|
590
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
591
|
+
return `${code ? `${code}: ` : ""}${message}`.replace(/(kxm_[A-Za-z0-9_]+|[A-Za-z0-9._-]{40,})/g, "[redacted]").slice(0, 300);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function runtimeSupervisorLogFile(paths: KxmRuntimePaths): string {
|
|
595
|
+
return join(paths.runtimeDir, "logs", "kxm-runtime.jsonl");
|
|
596
|
+
}
|
|
597
|
+
|
|
453
598
|
export interface KxmRuntimeSupervisor {
|
|
454
599
|
server: Server;
|
|
455
600
|
port: number;
|
|
@@ -458,12 +603,13 @@ export interface KxmRuntimeSupervisor {
|
|
|
458
603
|
}
|
|
459
604
|
|
|
460
605
|
export async function startKxmRuntimeSupervisor(
|
|
461
|
-
options: { stateRoot?: string; port?: number; now?: () => string } = {},
|
|
606
|
+
options: { stateRoot?: string; port?: number; now?: () => string; env?: NodeJS.ProcessEnv } = {},
|
|
462
607
|
): Promise<KxmRuntimeSupervisor> {
|
|
463
608
|
const now = options.now ?? (() => new Date().toISOString());
|
|
609
|
+
const env = options.env ?? process.env;
|
|
464
610
|
const paths = kxmRuntimePaths(options.stateRoot !== undefined ? { stateRoot: options.stateRoot } : {});
|
|
465
611
|
try {
|
|
466
|
-
return await startKxmRuntimeSupervisorInner(paths, options.port, now);
|
|
612
|
+
return await startKxmRuntimeSupervisorInner(paths, options.port, now, env);
|
|
467
613
|
} catch (error) {
|
|
468
614
|
// Startup failures are recorded so auto-start clients see the real cause
|
|
469
615
|
// instead of a bare timeout. Conflict is not an error: the winner is
|
|
@@ -479,9 +625,19 @@ async function startKxmRuntimeSupervisorInner(
|
|
|
479
625
|
paths: KxmRuntimePaths,
|
|
480
626
|
requestedPortOption: number | undefined,
|
|
481
627
|
now: () => string,
|
|
628
|
+
env: NodeJS.ProcessEnv,
|
|
482
629
|
): Promise<KxmRuntimeSupervisor> {
|
|
483
630
|
mkdirSync(paths.runtimeDir, { recursive: true, mode: 0o700 });
|
|
484
631
|
mkdirSync(paths.projectsDir, { recursive: true, mode: 0o700 });
|
|
632
|
+
// The supervisor is spawned detached with stdio ignored, so this file is its
|
|
633
|
+
// only durable voice. Without it, a sync path that refuses every row is
|
|
634
|
+
// indistinguishable from a sync path that never ran.
|
|
635
|
+
const logger = createLogger({
|
|
636
|
+
component: "runtime",
|
|
637
|
+
path: runtimeSupervisorLogFile(paths),
|
|
638
|
+
stdout: false,
|
|
639
|
+
maxBytes: 2 * 1024 * 1024,
|
|
640
|
+
});
|
|
485
641
|
// The token is generated in memory and written only after the supervisor
|
|
486
642
|
// singleton is owned: a losing supervisor can never overwrite a winner's
|
|
487
643
|
// token and lock every client out.
|
|
@@ -494,16 +650,21 @@ async function startKxmRuntimeSupervisorInner(
|
|
|
494
650
|
let activeRuntimeId = runtimeId;
|
|
495
651
|
|
|
496
652
|
const contexts = new Map<string, KxmRuntimeContext>();
|
|
653
|
+
// Declared with the contexts rather than with the timer that fills it: the
|
|
654
|
+
// listener is up before the tick is armed, and `GET /v1/sync/status` must be
|
|
655
|
+
// answerable in that window rather than reach for a binding that is not there
|
|
656
|
+
// yet.
|
|
657
|
+
const syncStatuses = new Map<string, KxmProjectSyncStatus>();
|
|
497
658
|
const registerSyncCredentials = (context: KxmRuntimeContext): void => {
|
|
498
659
|
// Register credentials on the store's redactor at context creation —
|
|
499
660
|
// BEFORE any event can be appended — so the very first outbox row is
|
|
500
661
|
// already scrubbed. Registering on the sync tick leaves a window where
|
|
501
662
|
// appended events retain credentials.
|
|
502
|
-
const hubToken = resolveClientHubAuthToken(
|
|
663
|
+
const hubToken = resolveClientHubAuthToken(env, context.projectId);
|
|
503
664
|
if (hubToken) context.eventStore.syncRedactor.register(hubToken);
|
|
504
|
-
for (const key of Object.keys(
|
|
665
|
+
for (const key of Object.keys(env)) {
|
|
505
666
|
if ((key.startsWith("KXM_") && (key.endsWith("_TOKEN") || key.endsWith("_KEY"))) || key.endsWith("_API_KEY") || key.endsWith("_SECRET")) {
|
|
506
|
-
const value =
|
|
667
|
+
const value = env[key]?.trim();
|
|
507
668
|
if (value) context.eventStore.syncRedactor.register(value);
|
|
508
669
|
}
|
|
509
670
|
}
|
|
@@ -556,6 +717,42 @@ async function startKxmRuntimeSupervisorInner(
|
|
|
556
717
|
return;
|
|
557
718
|
}
|
|
558
719
|
|
|
720
|
+
// What the sync loop last saw, per project this Runtime owns. This is the
|
|
721
|
+
// surface that makes "the supervisor is running clean" mean something:
|
|
722
|
+
// pending, acked and refused rows, the hub's refusal codes, and when the
|
|
723
|
+
// next attempt is due.
|
|
724
|
+
if (request.method === "GET" && url.pathname === "/v1/sync/status") {
|
|
725
|
+
const projectRoot = url.searchParams.get("projectRoot");
|
|
726
|
+
const statuses = [...syncStatuses.values()]
|
|
727
|
+
.filter((status) => projectRoot === null || status.projectRoot === projectRoot)
|
|
728
|
+
.sort((left, right) => left.projectId.localeCompare(right.projectId));
|
|
729
|
+
sendJson(response, 200, { ok: true, runtimeId: activeRuntimeId, projects: statuses });
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Operator-only revive: a durably refused row stays refused until someone
|
|
734
|
+
// says the hub-side state has been corrected. The Runtime never decides on
|
|
735
|
+
// its own that a refusal has become retryable, because the refusal is
|
|
736
|
+
// usually the hub holding a claim this Runtime cannot see.
|
|
737
|
+
if (request.method === "POST" && url.pathname === "/v1/sync/retry") {
|
|
738
|
+
const body = await readJsonBody(request);
|
|
739
|
+
const projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : "";
|
|
740
|
+
const context = contextFor(projectRoot);
|
|
741
|
+
const key = projectRuntimeKey(context.projectRoot);
|
|
742
|
+
const retried = context.eventStore.retryRefusedOutbox();
|
|
743
|
+
const status = syncStatuses.get(key);
|
|
744
|
+
// Clear the backoff gate so the rows go out on the next tick, and let
|
|
745
|
+
// that tick re-derive the state from what the hub actually says.
|
|
746
|
+
if (status) {
|
|
747
|
+
const cleared: KxmProjectSyncStatus = { ...status, outbox: context.eventStore.outboxStatus(), consecutiveFailures: 0 };
|
|
748
|
+
delete (cleared as Partial<KxmProjectSyncStatus>).nextAttemptAt;
|
|
749
|
+
syncStatuses.set(key, cleared);
|
|
750
|
+
}
|
|
751
|
+
logger({ event: "runtime_sync_retry", projectId: context.projectId, retried });
|
|
752
|
+
sendJson(response, 200, { ok: true, projectId: context.projectId, retried, outbox: context.eventStore.outboxStatus() });
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
559
756
|
if (request.method === "POST" && url.pathname === "/v1/runs") {
|
|
560
757
|
const body = await readJsonBody(request);
|
|
561
758
|
const projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : "";
|
|
@@ -891,7 +1088,10 @@ async function startKxmRuntimeSupervisorInner(
|
|
|
891
1088
|
|
|
892
1089
|
// Outbound only: the supervisor pulls nothing and exposes nothing to the hub.
|
|
893
1090
|
// A tick that finds no bound hub, no credential or an unreachable hub does
|
|
894
|
-
//
|
|
1091
|
+
// not fail a run; outbox rows stay pending and local execution never waits on
|
|
1092
|
+
// it. "Never waits" is not "never says anything": every tick records what it
|
|
1093
|
+
// saw into the sync status this process serves at `GET /v1/sync/status`, and
|
|
1094
|
+
// a transport failure backs off instead of knocking every interval.
|
|
895
1095
|
//
|
|
896
1096
|
// Restart recovery: contexts are only populated on demand (a project request
|
|
897
1097
|
// opens one), so a restarted supervisor would see an empty map and silently
|
|
@@ -904,27 +1104,107 @@ async function startKxmRuntimeSupervisorInner(
|
|
|
904
1104
|
// A project whose checkout has moved or been deleted stays skipped; its
|
|
905
1105
|
// outbox rows remain pending and its presence expires, which is visible
|
|
906
1106
|
// in the ops snapshot as orphaned.
|
|
1107
|
+
logger.warn({
|
|
1108
|
+
event: "runtime_sync_context_unavailable",
|
|
1109
|
+
projectId: reg.projectId,
|
|
1110
|
+
message: `cannot reopen ${reg.projectRoot}: its outbox rows stay pending`,
|
|
1111
|
+
});
|
|
907
1112
|
}
|
|
908
1113
|
}
|
|
909
1114
|
|
|
1115
|
+
const recordSyncStatus = (context: KxmRuntimeContext, next: KxmProjectSyncStatus): void => {
|
|
1116
|
+
const key = projectRuntimeKey(context.projectRoot);
|
|
1117
|
+
const previous = syncStatuses.get(key);
|
|
1118
|
+
syncStatuses.set(key, next);
|
|
1119
|
+
const changed = previous === undefined || previous.state !== next.state
|
|
1120
|
+
|| (next.lastError !== undefined && previous?.lastError !== next.lastError)
|
|
1121
|
+
|| JSON.stringify(next.outbox.refusals) !== JSON.stringify(previous?.outbox.refusals ?? []);
|
|
1122
|
+
if (!changed) return;
|
|
1123
|
+
// One line per state change — never one per tick, which is how a stalled
|
|
1124
|
+
// sync turns its own log into noise. `refusing` and `blocked` are the two
|
|
1125
|
+
// states that mean "run facts are not reaching the hub", so they are the
|
|
1126
|
+
// states an operator must be able to see without opening SQLite.
|
|
1127
|
+
const stalledState = next.state === "blocked" || next.state === "refusing";
|
|
1128
|
+
const entry = {
|
|
1129
|
+
event: stalledState ? "runtime_sync_stalled" : "runtime_sync_state",
|
|
1130
|
+
projectId: next.projectId,
|
|
1131
|
+
runtimeId: next.homeRuntimeId,
|
|
1132
|
+
state: next.state,
|
|
1133
|
+
pending: next.outbox.pending,
|
|
1134
|
+
acked: next.outbox.acked,
|
|
1135
|
+
refused: next.outbox.refused,
|
|
1136
|
+
...(next.outbox.refusals.length > 0 ? { refusals: next.outbox.refusals } : {}),
|
|
1137
|
+
...(next.hubUrl ? { hubUrl: next.hubUrl } : {}),
|
|
1138
|
+
...(next.lastError ? { reason: next.lastError } : {}),
|
|
1139
|
+
...(next.nextAttemptAt ? { nextAttemptAt: next.nextAttemptAt } : {}),
|
|
1140
|
+
};
|
|
1141
|
+
if (stalledState) logger.warn(entry);
|
|
1142
|
+
else logger.info(entry);
|
|
1143
|
+
};
|
|
1144
|
+
|
|
910
1145
|
let syncing = false;
|
|
911
1146
|
const syncTimer = setInterval(() => {
|
|
912
1147
|
if (syncing || stopping) return;
|
|
913
1148
|
syncing = true;
|
|
914
1149
|
void (async () => {
|
|
915
1150
|
for (const context of [...contexts.values()]) {
|
|
916
|
-
|
|
1151
|
+
const key = projectRuntimeKey(context.projectRoot);
|
|
1152
|
+
const prior = syncStatuses.get(key);
|
|
1153
|
+
if (prior?.nextAttemptAt && Date.parse(prior.nextAttemptAt) > Date.parse(now())) continue;
|
|
1154
|
+
const base = {
|
|
1155
|
+
projectId: context.projectId,
|
|
1156
|
+
projectRoot: context.projectRoot,
|
|
1157
|
+
homeRuntimeId: context.homeRuntimeId,
|
|
1158
|
+
outbox: context.eventStore.outboxStatus(),
|
|
1159
|
+
consecutiveFailures: 0,
|
|
1160
|
+
};
|
|
917
1161
|
try {
|
|
918
|
-
const client = runtimeHubClientFor(context,
|
|
919
|
-
if (!client)
|
|
1162
|
+
const client = runtimeHubClientFor(context, env);
|
|
1163
|
+
if (!client) {
|
|
1164
|
+
// Not a failure: a box with no bound hub simply keeps its rows.
|
|
1165
|
+
recordSyncStatus(context, { ...base, state: "no_hub" });
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
920
1168
|
await client.heartbeat();
|
|
921
|
-
await syncKxmOutbox(context.eventStore, client, { now });
|
|
922
|
-
|
|
923
|
-
//
|
|
1169
|
+
const pass = await syncKxmOutbox(context.eventStore, client, { now });
|
|
1170
|
+
const outbox = context.eventStore.outboxStatus();
|
|
1171
|
+
// A refused row is out of the pending queue, so there is nothing left to
|
|
1172
|
+
// stampede over: back off only for failures a later pass could actually
|
|
1173
|
+
// fix. The `refusing` state persists for as long as the store holds
|
|
1174
|
+
// refusals, which is the part an operator has to see.
|
|
1175
|
+
const failures = pass.blocked || pass.unconfirmed > 0 ? (prior?.consecutiveFailures ?? 0) + 1 : 0;
|
|
1176
|
+
const delayMs = syncBackoffMs(failures, runtimeSyncIntervalMs(env));
|
|
1177
|
+
recordSyncStatus(context, {
|
|
1178
|
+
...base,
|
|
1179
|
+
hubUrl: client.options.serverUrl,
|
|
1180
|
+
outbox,
|
|
1181
|
+
state: pass.blocked ? "blocked" : (outbox.refused > 0 || pass.unconfirmed > 0) ? "refusing" : "ok",
|
|
1182
|
+
lastCompletedAt: now(),
|
|
1183
|
+
lastPushed: pass.pushed,
|
|
1184
|
+
lastAcked: pass.acked,
|
|
1185
|
+
lastRefused: pass.refused,
|
|
1186
|
+
lastUnconfirmed: pass.unconfirmed,
|
|
1187
|
+
consecutiveFailures: failures,
|
|
1188
|
+
...(pass.blockedReason !== undefined ? { lastError: pass.blockedReason } : {}),
|
|
1189
|
+
...(delayMs > 0 ? { nextAttemptAt: new Date(Date.parse(now()) + delayMs).toISOString() } : {}),
|
|
1190
|
+
});
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
// The hub is unreachable, unauthenticated or answering garbage. The
|
|
1193
|
+
// rows are still pending, so this is retryable — but it is never
|
|
1194
|
+
// silently retried: the reason is recorded and the tick backs off.
|
|
1195
|
+
const failures = (prior?.consecutiveFailures ?? 0) + 1;
|
|
1196
|
+
const delayMs = syncBackoffMs(failures, runtimeSyncIntervalMs(env));
|
|
1197
|
+
recordSyncStatus(context, {
|
|
1198
|
+
...base,
|
|
1199
|
+
state: "blocked",
|
|
1200
|
+
consecutiveFailures: failures,
|
|
1201
|
+
lastError: syncFailureText(error),
|
|
1202
|
+
nextAttemptAt: new Date(Date.parse(now()) + delayMs).toISOString(),
|
|
1203
|
+
});
|
|
924
1204
|
}
|
|
925
1205
|
}
|
|
926
1206
|
})().finally(() => { syncing = false; });
|
|
927
|
-
}, runtimeSyncIntervalMs());
|
|
1207
|
+
}, runtimeSyncIntervalMs(env));
|
|
928
1208
|
syncTimer.unref();
|
|
929
1209
|
|
|
930
1210
|
let stopping = false;
|
|
@@ -961,6 +1241,7 @@ async function startKxmRuntimeSupervisorInner(
|
|
|
961
1241
|
}
|
|
962
1242
|
for (const context of contexts.values()) closeKxmRuntimeContext(context);
|
|
963
1243
|
contexts.clear();
|
|
1244
|
+
logger.close();
|
|
964
1245
|
const closed = new Promise<void>((resolveStop) => server.close(() => resolveStop()));
|
|
965
1246
|
server.closeIdleConnections?.();
|
|
966
1247
|
await closed;
|