@kontextmind/kxm 0.7.50 → 0.7.52
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 +85 -8
- package/docs/configuration.md +1 -1
- package/docs/operations.md +195 -19
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +100 -8
- package/plugins/kxm/dist/extension.js +4 -3
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +74 -3
- package/plugins/kxm/dist/runtime.js +76 -3
- package/plugins/kxm/dist/server.js +66 -2
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/hub.ts +84 -5
- package/plugins/kxm/src/database.ts +206 -1
- package/plugins/kxm/src/hub-binding.ts +22 -0
- package/plugins/kxm/src/hub-env.ts +28 -0
- package/plugins/kxm/src/intake.ts +86 -35
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/runtime-store.ts +8 -1
- package/plugins/kxm/src/session-work.ts +9 -3
|
@@ -17645,12 +17645,76 @@ function openDatabase(file, description, spec) {
|
|
|
17645
17645
|
}
|
|
17646
17646
|
}
|
|
17647
17647
|
var activeTransactions = /* @__PURE__ */ new WeakSet();
|
|
17648
|
-
|
|
17648
|
+
var TRANSACTION_BUSY_BACKOFF_MS = 1e3;
|
|
17649
|
+
function monotonicNowMs() {
|
|
17650
|
+
return Number(process.hrtime.bigint() / 1000000n);
|
|
17651
|
+
}
|
|
17652
|
+
var transactionThrottles = /* @__PURE__ */ new WeakMap();
|
|
17653
|
+
function finiteNow(clock, label) {
|
|
17654
|
+
const now = clock();
|
|
17655
|
+
if (typeof now !== "number" || !Number.isFinite(now)) {
|
|
17656
|
+
throw databaseError(
|
|
17657
|
+
"runtime_transaction_clock_invalid",
|
|
17658
|
+
"transaction",
|
|
17659
|
+
`${label} must return a finite monotonic number; got ${String(now)}`
|
|
17660
|
+
);
|
|
17661
|
+
}
|
|
17662
|
+
return now;
|
|
17663
|
+
}
|
|
17664
|
+
var CONTENTION_PRIMARY_CODES = [5, 6, 15];
|
|
17665
|
+
var CONTENTION_SYMBOLIC_NAMES = /^SQLITE_(?:BUSY|LOCKED|PROTOCOL)(?:_[A-Z0-9]+)?$/;
|
|
17666
|
+
var SQLITE_RESULT_NAMES = /^SQLITE_[A-Z][A-Z0-9_]*$/;
|
|
17667
|
+
var CONTENTION_MESSAGES = /^(?:database is locked|database table is locked|locking protocol|SQLITE_BUSY|SQLITE_LOCKED|SQLITE_PROTOCOL)(?:$|[\s.:])/i;
|
|
17668
|
+
function isTransactionContention(error) {
|
|
17669
|
+
const carrier = error;
|
|
17670
|
+
for (const value of [carrier?.errcode, carrier?.errCode, carrier?.errno]) {
|
|
17671
|
+
if (typeof value === "number" && Number.isInteger(value)) return CONTENTION_PRIMARY_CODES.includes(value & 255);
|
|
17672
|
+
}
|
|
17673
|
+
for (const value of [carrier?.code, carrier?.name]) {
|
|
17674
|
+
if (typeof value === "string" && SQLITE_RESULT_NAMES.test(value)) {
|
|
17675
|
+
return CONTENTION_SYMBOLIC_NAMES.test(value);
|
|
17676
|
+
}
|
|
17677
|
+
}
|
|
17678
|
+
return CONTENTION_MESSAGES.test(error instanceof Error ? error.message : String(error));
|
|
17679
|
+
}
|
|
17680
|
+
function withDatabaseTransaction(database, work, mode = "IMMEDIATE", clock = monotonicNowMs) {
|
|
17649
17681
|
if (activeTransactions.has(database)) {
|
|
17650
17682
|
throw databaseError("runtime_transaction_nested", "transaction", "nested transactions are not allowed");
|
|
17651
17683
|
}
|
|
17684
|
+
if (mode !== "DEFERRED") {
|
|
17685
|
+
const deadlines = transactionThrottles.get(database);
|
|
17686
|
+
const until = deadlines?.get(clock);
|
|
17687
|
+
if (until !== void 0) {
|
|
17688
|
+
const remaining = until - finiteNow(clock, "the transaction clock");
|
|
17689
|
+
if (remaining > 0) {
|
|
17690
|
+
throw databaseError(
|
|
17691
|
+
"runtime_transaction_busy",
|
|
17692
|
+
"transaction",
|
|
17693
|
+
`a previous BEGIN was blocked on this database; retry deferred ${String(remaining)}ms`
|
|
17694
|
+
);
|
|
17695
|
+
}
|
|
17696
|
+
deadlines?.delete(clock);
|
|
17697
|
+
}
|
|
17698
|
+
}
|
|
17699
|
+
try {
|
|
17700
|
+
database.exec(`BEGIN ${mode}`);
|
|
17701
|
+
} catch (error) {
|
|
17702
|
+
if (!isTransactionContention(error)) throw error;
|
|
17703
|
+
const now = finiteNow(clock, "the transaction clock");
|
|
17704
|
+
let deadlines = transactionThrottles.get(database);
|
|
17705
|
+
if (deadlines === void 0) {
|
|
17706
|
+
deadlines = /* @__PURE__ */ new WeakMap();
|
|
17707
|
+
transactionThrottles.set(database, deadlines);
|
|
17708
|
+
}
|
|
17709
|
+
deadlines.set(clock, now + TRANSACTION_BUSY_BACKOFF_MS);
|
|
17710
|
+
throw databaseError(
|
|
17711
|
+
"runtime_transaction_busy",
|
|
17712
|
+
"transaction",
|
|
17713
|
+
`BEGIN ${mode} blocked by another transaction: ${error instanceof Error ? error.message : String(error)}`
|
|
17714
|
+
);
|
|
17715
|
+
}
|
|
17652
17716
|
activeTransactions.add(database);
|
|
17653
|
-
|
|
17717
|
+
if (mode !== "DEFERRED") transactionThrottles.get(database)?.delete(clock);
|
|
17654
17718
|
try {
|
|
17655
17719
|
const result = work();
|
|
17656
17720
|
database.exec("COMMIT");
|
|
@@ -18558,7 +18622,14 @@ var KxmRunEventStore = class {
|
|
|
18558
18622
|
`).get(projectId, coordinatorId, idempotencyKey);
|
|
18559
18623
|
return row ? intakeFromRow(row) : void 0;
|
|
18560
18624
|
}
|
|
18561
|
-
/**
|
|
18625
|
+
/**
|
|
18626
|
+
* Intake rows in the given dispatch states, in arrival order (replay-safe).
|
|
18627
|
+
*
|
|
18628
|
+
* `rowid` gives same-store arrival order, which is what queue priority needs
|
|
18629
|
+
* here. It is **not** a durable sequence: this repository backs stores up with
|
|
18630
|
+
* `VACUUM INTO`, and a vacuum may renumber implicit rowids. An explicit
|
|
18631
|
+
* immutable arrival sequence is tracked in the plan's schema-v6 follow-ups.
|
|
18632
|
+
*/
|
|
18562
18633
|
intakeInStates(projectId, states, limit = 100) {
|
|
18563
18634
|
if (states.length === 0) return [];
|
|
18564
18635
|
const placeholders = states.map(() => "?").join(", ");
|
|
@@ -17816,12 +17816,76 @@ function openDatabase(file, description, spec) {
|
|
|
17816
17816
|
}
|
|
17817
17817
|
}
|
|
17818
17818
|
var activeTransactions = /* @__PURE__ */ new WeakSet();
|
|
17819
|
-
|
|
17819
|
+
var TRANSACTION_BUSY_BACKOFF_MS = 1e3;
|
|
17820
|
+
function monotonicNowMs() {
|
|
17821
|
+
return Number(process.hrtime.bigint() / 1000000n);
|
|
17822
|
+
}
|
|
17823
|
+
var transactionThrottles = /* @__PURE__ */ new WeakMap();
|
|
17824
|
+
function finiteNow(clock, label) {
|
|
17825
|
+
const now = clock();
|
|
17826
|
+
if (typeof now !== "number" || !Number.isFinite(now)) {
|
|
17827
|
+
throw databaseError(
|
|
17828
|
+
"runtime_transaction_clock_invalid",
|
|
17829
|
+
"transaction",
|
|
17830
|
+
`${label} must return a finite monotonic number; got ${String(now)}`
|
|
17831
|
+
);
|
|
17832
|
+
}
|
|
17833
|
+
return now;
|
|
17834
|
+
}
|
|
17835
|
+
var CONTENTION_PRIMARY_CODES = [5, 6, 15];
|
|
17836
|
+
var CONTENTION_SYMBOLIC_NAMES = /^SQLITE_(?:BUSY|LOCKED|PROTOCOL)(?:_[A-Z0-9]+)?$/;
|
|
17837
|
+
var SQLITE_RESULT_NAMES = /^SQLITE_[A-Z][A-Z0-9_]*$/;
|
|
17838
|
+
var CONTENTION_MESSAGES = /^(?:database is locked|database table is locked|locking protocol|SQLITE_BUSY|SQLITE_LOCKED|SQLITE_PROTOCOL)(?:$|[\s.:])/i;
|
|
17839
|
+
function isTransactionContention(error) {
|
|
17840
|
+
const carrier = error;
|
|
17841
|
+
for (const value of [carrier?.errcode, carrier?.errCode, carrier?.errno]) {
|
|
17842
|
+
if (typeof value === "number" && Number.isInteger(value)) return CONTENTION_PRIMARY_CODES.includes(value & 255);
|
|
17843
|
+
}
|
|
17844
|
+
for (const value of [carrier?.code, carrier?.name]) {
|
|
17845
|
+
if (typeof value === "string" && SQLITE_RESULT_NAMES.test(value)) {
|
|
17846
|
+
return CONTENTION_SYMBOLIC_NAMES.test(value);
|
|
17847
|
+
}
|
|
17848
|
+
}
|
|
17849
|
+
return CONTENTION_MESSAGES.test(error instanceof Error ? error.message : String(error));
|
|
17850
|
+
}
|
|
17851
|
+
function withDatabaseTransaction(database, work, mode = "IMMEDIATE", clock = monotonicNowMs) {
|
|
17820
17852
|
if (activeTransactions.has(database)) {
|
|
17821
17853
|
throw databaseError("runtime_transaction_nested", "transaction", "nested transactions are not allowed");
|
|
17822
17854
|
}
|
|
17855
|
+
if (mode !== "DEFERRED") {
|
|
17856
|
+
const deadlines = transactionThrottles.get(database);
|
|
17857
|
+
const until = deadlines?.get(clock);
|
|
17858
|
+
if (until !== void 0) {
|
|
17859
|
+
const remaining = until - finiteNow(clock, "the transaction clock");
|
|
17860
|
+
if (remaining > 0) {
|
|
17861
|
+
throw databaseError(
|
|
17862
|
+
"runtime_transaction_busy",
|
|
17863
|
+
"transaction",
|
|
17864
|
+
`a previous BEGIN was blocked on this database; retry deferred ${String(remaining)}ms`
|
|
17865
|
+
);
|
|
17866
|
+
}
|
|
17867
|
+
deadlines?.delete(clock);
|
|
17868
|
+
}
|
|
17869
|
+
}
|
|
17870
|
+
try {
|
|
17871
|
+
database.exec(`BEGIN ${mode}`);
|
|
17872
|
+
} catch (error) {
|
|
17873
|
+
if (!isTransactionContention(error)) throw error;
|
|
17874
|
+
const now = finiteNow(clock, "the transaction clock");
|
|
17875
|
+
let deadlines = transactionThrottles.get(database);
|
|
17876
|
+
if (deadlines === void 0) {
|
|
17877
|
+
deadlines = /* @__PURE__ */ new WeakMap();
|
|
17878
|
+
transactionThrottles.set(database, deadlines);
|
|
17879
|
+
}
|
|
17880
|
+
deadlines.set(clock, now + TRANSACTION_BUSY_BACKOFF_MS);
|
|
17881
|
+
throw databaseError(
|
|
17882
|
+
"runtime_transaction_busy",
|
|
17883
|
+
"transaction",
|
|
17884
|
+
`BEGIN ${mode} blocked by another transaction: ${error instanceof Error ? error.message : String(error)}`
|
|
17885
|
+
);
|
|
17886
|
+
}
|
|
17823
17887
|
activeTransactions.add(database);
|
|
17824
|
-
|
|
17888
|
+
if (mode !== "DEFERRED") transactionThrottles.get(database)?.delete(clock);
|
|
17825
17889
|
try {
|
|
17826
17890
|
const result = work();
|
|
17827
17891
|
database.exec("COMMIT");
|
|
@@ -18998,7 +19062,14 @@ var KxmRunEventStore = class {
|
|
|
18998
19062
|
`).get(projectId, coordinatorId, idempotencyKey);
|
|
18999
19063
|
return row ? intakeFromRow(row) : void 0;
|
|
19000
19064
|
}
|
|
19001
|
-
/**
|
|
19065
|
+
/**
|
|
19066
|
+
* Intake rows in the given dispatch states, in arrival order (replay-safe).
|
|
19067
|
+
*
|
|
19068
|
+
* `rowid` gives same-store arrival order, which is what queue priority needs
|
|
19069
|
+
* here. It is **not** a durable sequence: this repository backs stores up with
|
|
19070
|
+
* `VACUUM INTO`, and a vacuum may renumber implicit rowids. An explicit
|
|
19071
|
+
* immutable arrival sequence is tracked in the plan's schema-v6 follow-ups.
|
|
19072
|
+
*/
|
|
19002
19073
|
intakeInStates(projectId, states, limit = 100) {
|
|
19003
19074
|
if (states.length === 0) return [];
|
|
19004
19075
|
const placeholders = states.map(() => "?").join(", ");
|
|
@@ -30289,6 +30360,7 @@ export {
|
|
|
30289
30360
|
SUBAGENT_TYPES,
|
|
30290
30361
|
SteelClient,
|
|
30291
30362
|
SubagentManager,
|
|
30363
|
+
TRANSACTION_BUSY_BACKOFF_MS,
|
|
30292
30364
|
VIEWPORT_PRESETS,
|
|
30293
30365
|
WIN_NPM_INNER_EXE,
|
|
30294
30366
|
acceptKxmRun,
|
|
@@ -30344,6 +30416,7 @@ export {
|
|
|
30344
30416
|
hashKxmTokenProof,
|
|
30345
30417
|
isKnownHarnessId,
|
|
30346
30418
|
isKxmRuntimeContextClosed,
|
|
30419
|
+
isTransactionContention,
|
|
30347
30420
|
isWindowsHarnessShim,
|
|
30348
30421
|
kxmDeclaredExecutorIds,
|
|
30349
30422
|
kxmDeclaredRepositoryIds,
|
|
@@ -11537,12 +11537,76 @@ function openDatabase(file, description, spec) {
|
|
|
11537
11537
|
}
|
|
11538
11538
|
}
|
|
11539
11539
|
var activeTransactions = /* @__PURE__ */ new WeakSet();
|
|
11540
|
-
|
|
11540
|
+
var TRANSACTION_BUSY_BACKOFF_MS = 1e3;
|
|
11541
|
+
function monotonicNowMs() {
|
|
11542
|
+
return Number(process.hrtime.bigint() / 1000000n);
|
|
11543
|
+
}
|
|
11544
|
+
var transactionThrottles = /* @__PURE__ */ new WeakMap();
|
|
11545
|
+
function finiteNow(clock, label) {
|
|
11546
|
+
const now = clock();
|
|
11547
|
+
if (typeof now !== "number" || !Number.isFinite(now)) {
|
|
11548
|
+
throw databaseError(
|
|
11549
|
+
"runtime_transaction_clock_invalid",
|
|
11550
|
+
"transaction",
|
|
11551
|
+
`${label} must return a finite monotonic number; got ${String(now)}`
|
|
11552
|
+
);
|
|
11553
|
+
}
|
|
11554
|
+
return now;
|
|
11555
|
+
}
|
|
11556
|
+
var CONTENTION_PRIMARY_CODES = [5, 6, 15];
|
|
11557
|
+
var CONTENTION_SYMBOLIC_NAMES = /^SQLITE_(?:BUSY|LOCKED|PROTOCOL)(?:_[A-Z0-9]+)?$/;
|
|
11558
|
+
var SQLITE_RESULT_NAMES = /^SQLITE_[A-Z][A-Z0-9_]*$/;
|
|
11559
|
+
var CONTENTION_MESSAGES = /^(?:database is locked|database table is locked|locking protocol|SQLITE_BUSY|SQLITE_LOCKED|SQLITE_PROTOCOL)(?:$|[\s.:])/i;
|
|
11560
|
+
function isTransactionContention(error) {
|
|
11561
|
+
const carrier = error;
|
|
11562
|
+
for (const value of [carrier?.errcode, carrier?.errCode, carrier?.errno]) {
|
|
11563
|
+
if (typeof value === "number" && Number.isInteger(value)) return CONTENTION_PRIMARY_CODES.includes(value & 255);
|
|
11564
|
+
}
|
|
11565
|
+
for (const value of [carrier?.code, carrier?.name]) {
|
|
11566
|
+
if (typeof value === "string" && SQLITE_RESULT_NAMES.test(value)) {
|
|
11567
|
+
return CONTENTION_SYMBOLIC_NAMES.test(value);
|
|
11568
|
+
}
|
|
11569
|
+
}
|
|
11570
|
+
return CONTENTION_MESSAGES.test(error instanceof Error ? error.message : String(error));
|
|
11571
|
+
}
|
|
11572
|
+
function withDatabaseTransaction(database, work, mode = "IMMEDIATE", clock = monotonicNowMs) {
|
|
11541
11573
|
if (activeTransactions.has(database)) {
|
|
11542
11574
|
throw databaseError("runtime_transaction_nested", "transaction", "nested transactions are not allowed");
|
|
11543
11575
|
}
|
|
11576
|
+
if (mode !== "DEFERRED") {
|
|
11577
|
+
const deadlines = transactionThrottles.get(database);
|
|
11578
|
+
const until = deadlines?.get(clock);
|
|
11579
|
+
if (until !== void 0) {
|
|
11580
|
+
const remaining = until - finiteNow(clock, "the transaction clock");
|
|
11581
|
+
if (remaining > 0) {
|
|
11582
|
+
throw databaseError(
|
|
11583
|
+
"runtime_transaction_busy",
|
|
11584
|
+
"transaction",
|
|
11585
|
+
`a previous BEGIN was blocked on this database; retry deferred ${String(remaining)}ms`
|
|
11586
|
+
);
|
|
11587
|
+
}
|
|
11588
|
+
deadlines?.delete(clock);
|
|
11589
|
+
}
|
|
11590
|
+
}
|
|
11591
|
+
try {
|
|
11592
|
+
database.exec(`BEGIN ${mode}`);
|
|
11593
|
+
} catch (error) {
|
|
11594
|
+
if (!isTransactionContention(error)) throw error;
|
|
11595
|
+
const now = finiteNow(clock, "the transaction clock");
|
|
11596
|
+
let deadlines = transactionThrottles.get(database);
|
|
11597
|
+
if (deadlines === void 0) {
|
|
11598
|
+
deadlines = /* @__PURE__ */ new WeakMap();
|
|
11599
|
+
transactionThrottles.set(database, deadlines);
|
|
11600
|
+
}
|
|
11601
|
+
deadlines.set(clock, now + TRANSACTION_BUSY_BACKOFF_MS);
|
|
11602
|
+
throw databaseError(
|
|
11603
|
+
"runtime_transaction_busy",
|
|
11604
|
+
"transaction",
|
|
11605
|
+
`BEGIN ${mode} blocked by another transaction: ${error instanceof Error ? error.message : String(error)}`
|
|
11606
|
+
);
|
|
11607
|
+
}
|
|
11544
11608
|
activeTransactions.add(database);
|
|
11545
|
-
|
|
11609
|
+
if (mode !== "DEFERRED") transactionThrottles.get(database)?.delete(clock);
|
|
11546
11610
|
try {
|
|
11547
11611
|
const result = work();
|
|
11548
11612
|
database.exec("COMMIT");
|
package/plugins/kxm/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
|
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { redactSecrets } from "../redact.ts";
|
|
5
5
|
import { defaultProjectName } from "../project-name.ts";
|
|
6
|
-
import { resolveClientHubAuthToken } from "../hub-env.ts";
|
|
6
|
+
import { hasClientHubCredential, resolveClientHubAuthToken } from "../hub-env.ts";
|
|
7
7
|
import { agentWorker, type Worker } from "../envelope.ts";
|
|
8
8
|
import { MESH_TUI_PANELS, runMeshTui, type MeshTuiPanel } from "../tui.ts";
|
|
9
9
|
import { formatSessionBriefText, loadSessionBriefAsync, type SessionHubStatus } from "../session-work.ts";
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
HubBindingError,
|
|
13
13
|
hubBindingFile,
|
|
14
14
|
probeHubHealth,
|
|
15
|
+
hubBindingScope,
|
|
15
16
|
readHubBinding,
|
|
16
17
|
removeHubBinding,
|
|
17
18
|
validateHubUrl,
|
|
@@ -104,8 +105,22 @@ export async function refreshKxmUpdateNotice(runtime: Runtime, config?: KxmUpdat
|
|
|
104
105
|
export async function cmdStatus(runtime: Runtime): Promise<number> {
|
|
105
106
|
const health = await hubGet(`${runtime.serverUrl}/health`, runtime.fetchImpl);
|
|
106
107
|
const ready = await hubGet(`${runtime.serverUrl}/ready`, runtime.fetchImpl);
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
// Scope on the status line deliberately: "attached across a network" and "attached on
|
|
109
|
+
// this box" are otherwise indistinguishable, and only one of them ships a token.
|
|
110
|
+
// Scope is a property of the URL actually contacted, not of whichever file the
|
|
111
|
+
// binding came from: KXM_SERVER_URL overrides the binding, and labelling the binding
|
|
112
|
+
// while probing an override would report "loopback" about a remote request.
|
|
113
|
+
const effectiveScope = hubBindingScope(runtime.serverUrl);
|
|
114
|
+
const overridden = Boolean(runtime.boundHubUrl && runtime.boundHubUrl !== runtime.serverUrl);
|
|
115
|
+
const payload = {
|
|
116
|
+
ok: health.ok && ready.ok,
|
|
117
|
+
command: "hub view",
|
|
118
|
+
target: { url: runtime.serverUrl, scope: effectiveScope, ...(overridden ? { source: "env" } : {}) },
|
|
119
|
+
health: health.body,
|
|
120
|
+
ready: ready.body,
|
|
121
|
+
};
|
|
122
|
+
print(runtime.io, runtime.json, payload,
|
|
123
|
+
`hub health=${health.ok} ready=${ready.ok} · ${effectiveScope} hub${overridden ? " (KXM_SERVER_URL)" : ""}`);
|
|
109
124
|
return payload.ok ? 0 : 1;
|
|
110
125
|
}
|
|
111
126
|
|
|
@@ -191,6 +206,10 @@ export function formatHubBindHealth(health: HubHealth): string {
|
|
|
191
206
|
return "health=unknown (no reply within 300 ms)";
|
|
192
207
|
}
|
|
193
208
|
|
|
209
|
+
/** Kept in one place so the JSON payload and the prose line cannot drift apart. */
|
|
210
|
+
const HUB_BIND_UNAUTHENTICATED_HINT =
|
|
211
|
+
"export KXM_AUTH_TOKEN (or point KXM_STATE_HOME at the hub-env record that already holds one), then re-run; the hub itself requires a token beyond loopback";
|
|
212
|
+
|
|
194
213
|
export async function cmdHubBind(runtime: Runtime, rawUrl: string): Promise<number> {
|
|
195
214
|
let url: string;
|
|
196
215
|
try {
|
|
@@ -207,14 +226,73 @@ export async function cmdHubBind(runtime: Runtime, rawUrl: string): Promise<numb
|
|
|
207
226
|
}
|
|
208
227
|
throw error;
|
|
209
228
|
}
|
|
229
|
+
const scope = hubBindingScope(url);
|
|
230
|
+
// Scope-scoped on purpose, and only for **this command**. A remote binding puts a
|
|
231
|
+
// bearer on a network path, so it is refused when nothing can authenticate it; a
|
|
232
|
+
// stored-but-unusable URL otherwise reads later like a network fault and gets debugged
|
|
233
|
+
// as one. Loopback is not consulted here because a loopback URL puts nothing on a wire —
|
|
234
|
+
// not because credentials are never resolved locally: other client paths still call
|
|
235
|
+
// `resolveClientHubAuthToken` regardless of scope, so a damaged host record can still
|
|
236
|
+
// fail `kxm peer list` on loopback. Narrowing the claim is the point; the first version
|
|
237
|
+
// of this guard checked the record *before* the scope and refused loopback binds that had
|
|
238
|
+
// always worked, which is a regression against behaviour predating this slice.
|
|
239
|
+
if (scope === "remote") {
|
|
240
|
+
// The project that will actually authenticate: a record holding only another
|
|
241
|
+
// project's token cannot authorise this one.
|
|
242
|
+
const bindProject = defaultProjectName(runtime.dirs.workdir, runtime.env) || "project";
|
|
243
|
+
let credentialReady = false;
|
|
244
|
+
try {
|
|
245
|
+
credentialReady = hasClientHubCredential(runtime.env, bindProject);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
// A malformed record is a readable configuration failure, not an uncaught throw
|
|
248
|
+
// past a user-facing entry point.
|
|
249
|
+
print(
|
|
250
|
+
runtime.io,
|
|
251
|
+
runtime.json,
|
|
252
|
+
{
|
|
253
|
+
ok: false,
|
|
254
|
+
command: "hub bind",
|
|
255
|
+
error: "hub_credential_unreadable",
|
|
256
|
+
url,
|
|
257
|
+
scope,
|
|
258
|
+
nextAction: "repair_hub_env_record",
|
|
259
|
+
hint: `${error instanceof Error ? error.message : String(error)}; no binding was written`,
|
|
260
|
+
},
|
|
261
|
+
`cannot read the hub credential: ${error instanceof Error ? error.message : String(error)}`,
|
|
262
|
+
);
|
|
263
|
+
return 2;
|
|
264
|
+
}
|
|
265
|
+
if (!credentialReady) {
|
|
266
|
+
print(
|
|
267
|
+
runtime.io,
|
|
268
|
+
runtime.json,
|
|
269
|
+
{
|
|
270
|
+
ok: false,
|
|
271
|
+
command: "hub bind",
|
|
272
|
+
error: "hub_bind_unauthenticated",
|
|
273
|
+
url,
|
|
274
|
+
scope,
|
|
275
|
+
project: bindProject,
|
|
276
|
+
// The hint belongs in the payload, not only the prose line: under --json the
|
|
277
|
+
// prose is suppressed, and a refusal that names no next step gets debugged by
|
|
278
|
+
// reading source.
|
|
279
|
+
nextAction: "export_kxm_auth_token",
|
|
280
|
+
hint: `${HUB_BIND_UNAUTHENTICATED_HINT} (needs a token for project ${bindProject})`,
|
|
281
|
+
},
|
|
282
|
+
`refusing to bind remote hub ${url} with no credential for project ${bindProject}; ${HUB_BIND_UNAUTHENTICATED_HINT}`,
|
|
283
|
+
);
|
|
284
|
+
return 2;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
210
287
|
const file = hubBindingFile(runtime.env);
|
|
211
288
|
if (runtime.dryRun) {
|
|
212
|
-
print(runtime.io, runtime.json, { ok: true, command: "hub bind", dryRun: true, url, file }, `would bind hub ${url}`);
|
|
289
|
+
print(runtime.io, runtime.json, { ok: true, command: "hub bind", dryRun: true, url, scope, file }, `would bind hub ${url} (${scope})`);
|
|
213
290
|
return 0;
|
|
214
291
|
}
|
|
215
292
|
writeHubBinding({ schema: HUB_BINDING_SCHEMA, url, boundAt: new Date().toISOString() }, runtime.env);
|
|
216
293
|
const { health, probeMs } = await probeHubHealth(url, runtime.fetchImpl);
|
|
217
|
-
print(runtime.io, runtime.json, { ok: true, command: "hub bind", url, file, health, probeMs },
|
|
294
|
+
print(runtime.io, runtime.json, { ok: true, command: "hub bind", url, scope, file, health, probeMs },
|
|
295
|
+
`bound hub ${url} · ${scope} · ${formatHubBindHealth(health)}${scope === "remote" ? " · token leaves this machine" : ""}`);
|
|
218
296
|
return 0;
|
|
219
297
|
}
|
|
220
298
|
|
|
@@ -540,6 +618,7 @@ export async function cmdSessionBrief(runtime: Runtime, options: { status?: bool
|
|
|
540
618
|
evidence: health === "unknown" ? "timeout" : "probed",
|
|
541
619
|
online: health === "on",
|
|
542
620
|
url: targetUrl,
|
|
621
|
+
scope: hubBindingScope(targetUrl),
|
|
543
622
|
};
|
|
544
623
|
} else {
|
|
545
624
|
hub = { state: "off", evidence: "unconfigured", online: false };
|
|
@@ -213,21 +213,226 @@ export function openDatabase(file: string, description: string, spec: DatabaseSc
|
|
|
213
213
|
|
|
214
214
|
const activeTransactions = new WeakSet<DatabaseSync>();
|
|
215
215
|
|
|
216
|
+
/**
|
|
217
|
+
* How long a connection refuses to retry a `BEGIN` that lost the write race.
|
|
218
|
+
*
|
|
219
|
+
* SQLite's busy timeout is per connection, so a `BEGIN` against a locked
|
|
220
|
+
* database waits the full timeout before failing. Callers that recover from a
|
|
221
|
+
* lost write open several transactions in a row; without this window each of
|
|
222
|
+
* them pays the timeout, and a suite run showed that turning one 4-second test
|
|
223
|
+
* into 17 minutes. The old code got that speed by accident — it left the
|
|
224
|
+
* connection permanently marked as in-transaction after a failed `BEGIN`, which
|
|
225
|
+
* is the defect fixed below — so the backoff replaces the fast-fail without
|
|
226
|
+
* re-introducing the poison.
|
|
227
|
+
*
|
|
228
|
+
* Two limits, both deliberate:
|
|
229
|
+
* - It is a **throttle, not a queue**. A caller whose lock cleared 1 ms later is
|
|
230
|
+
* still refused for the rest of the window; the refusal is explicit
|
|
231
|
+
* (`runtime_transaction_busy`, message says `retry deferred`) and bounded by
|
|
232
|
+
* this constant. A retry after the window may pay the busy timeout again.
|
|
233
|
+
* - It is **per connection object, in this process**. It is not a cross-process
|
|
234
|
+
* backoff and does not leak to another connection to the same file.
|
|
235
|
+
*/
|
|
236
|
+
export const TRANSACTION_BUSY_BACKOFF_MS = 1_000;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Monotonic elapsed time, deliberately not `Date.now()`.
|
|
240
|
+
*
|
|
241
|
+
* A wall-clock step backwards would otherwise keep a long-gone write lock
|
|
242
|
+
* refusing transactions until real time caught up, and a step forward would end
|
|
243
|
+
* the throttle early. `hrtime.bigint()` is relative to an arbitrary past origin
|
|
244
|
+
* and never moves backwards, so the window is bounded by
|
|
245
|
+
* {@link TRANSACTION_BUSY_BACKOFF_MS} no matter what the system clock does.
|
|
246
|
+
*
|
|
247
|
+
* Injectable: production callers never pass it, and a test drives it directly so
|
|
248
|
+
* the window is stepped rather than raced or slept through.
|
|
249
|
+
*/
|
|
250
|
+
export type MonotonicClock = () => number;
|
|
251
|
+
|
|
252
|
+
function monotonicNowMs(): number {
|
|
253
|
+
return Number(process.hrtime.bigint() / 1_000_000n);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Pending throttle per connection, **keyed by the clock that armed it**.
|
|
258
|
+
*
|
|
259
|
+
* The nesting is the fix, not tidiness. A deadline is a number from *some* clock,
|
|
260
|
+
* and the injectable `clock` exists only so a test can step the window; comparing
|
|
261
|
+
* a deadline armed by one clock against a reading taken from another is how an
|
|
262
|
+
* injected "one hour from now" throttles a production caller that never passed a
|
|
263
|
+
* clock at all — and how that same caller could clear a deadline it never armed.
|
|
264
|
+
* Each clock domain therefore gets its own deadline and can only read, expire or
|
|
265
|
+
* replace its own. The inner map is **weak in the clock**: it keeps no otherwise
|
|
266
|
+
* unreachable clock function alive, so an attempt that builds a fresh closure per
|
|
267
|
+
* call leaves nothing behind once that closure is collected. A clock that *is*
|
|
268
|
+
* retained keeps its entry until it expires or is replaced — collection is neither
|
|
269
|
+
* immediate nor size-bounded, and no committed test measures any of this, because no
|
|
270
|
+
* production caller passes a clock.
|
|
271
|
+
*/
|
|
272
|
+
const transactionThrottles = new WeakMap<DatabaseSync, WeakMap<MonotonicClock, number>>();
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* A clock reading this helper can reason about.
|
|
276
|
+
*
|
|
277
|
+
* Scope, stated as measured rather than as a slogan. The clock is read at **two call
|
|
278
|
+
* sites**: checking an existing deadline on a non-`DEFERRED` attempt, and arming after a
|
|
279
|
+
* contended `BEGIN` failure. Reads per call, each reproduced by an assertion in
|
|
280
|
+
* `test/core/intake.test.ts`: clean success with no pending entry 0; successful
|
|
281
|
+
* `DEFERRED`, pending entry present or not, 0; fresh contention 1; refusal inside a window
|
|
282
|
+
* 1; an expired deadline followed by renewed contention 2 in that call; a permanent
|
|
283
|
+
* `BEGIN` failure with no pending entry 0; a nested-transaction rejection 0.
|
|
284
|
+
*
|
|
285
|
+
* Which means it is wrong in both directions to say either "every `BEGIN` validates the
|
|
286
|
+
* clock" or "the *only* transaction that skips it is a clean uncontended success" — the
|
|
287
|
+
* second was mine, twice, and the second correction to it was still a slogan. The tests
|
|
288
|
+
* count; the prose only points at them. Production cannot reach the guard at all, because
|
|
289
|
+
* the default is `hrtime`; it exists so the injectable seam cannot become a silent
|
|
290
|
+
* bypass.
|
|
291
|
+
*/
|
|
292
|
+
function finiteNow(clock: MonotonicClock, label: string): number {
|
|
293
|
+
const now = clock();
|
|
294
|
+
if (typeof now !== "number" || !Number.isFinite(now)) {
|
|
295
|
+
throw databaseError(
|
|
296
|
+
"runtime_transaction_clock_invalid",
|
|
297
|
+
"transaction",
|
|
298
|
+
`${label} must return a finite monotonic number; got ${String(now)}`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return now;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Is this `BEGIN` failure contention for the write lock, as opposed to a
|
|
306
|
+
* programming or environment error?
|
|
307
|
+
*
|
|
308
|
+
* Only contention may be retried, and only contention earns the throttle window.
|
|
309
|
+
* Anything else — "cannot start a transaction within a transaction", a closed
|
|
310
|
+
* connection, a miscompiled statement — must surface unchanged, or a permanent
|
|
311
|
+
* bug looks like a transient one and the caller retries forever.
|
|
312
|
+
*
|
|
313
|
+
* SQLite's **numeric result code decides** where one exists, because it is stable
|
|
314
|
+
* across versions while message text is not: the primary code is `code & 0xff`, so
|
|
315
|
+
* extended forms land on their primaries — `SQLITE_BUSY_RECOVERY` (261) and
|
|
316
|
+
* `SQLITE_BUSY_SNAPSHOT` (517) on `SQLITE_BUSY` (5), `SQLITE_LOCKED_SHAREDCACHE`
|
|
317
|
+
* (262) on `SQLITE_LOCKED` (6). `SQLITE_PROTOCOL` (15) is included deliberately:
|
|
318
|
+
* SQLite raises it when repeated attempts to start a transaction under WAL exhaust
|
|
319
|
+
* the retry count, which is a lock-acquisition retry condition, not a broken
|
|
320
|
+
* database.
|
|
321
|
+
*
|
|
322
|
+
* Where both runtimes expose one, **the number wins over the text**: Node spells it
|
|
323
|
+
* `errcode`, `bun:sqlite` spells it `errno` (verified on Bun 1.3.14, where a shared-
|
|
324
|
+
* cache `BEGIN` arrives as `errno: 262`, `code: "SQLITE_LOCKED_SHAREDCACHE"`,
|
|
325
|
+
* message "database schema is locked: shared"). A symbolic `code`/`name` of the form
|
|
326
|
+
* `SQLITE_BUSY*`/`SQLITE_LOCKED*`/`SQLITE_PROTOCOL*` is accepted next, and bare
|
|
327
|
+
* message matching is the last resort — applied only when neither exists, so a
|
|
328
|
+
* wrapper that merely quotes "database is locked" alongside a permanent code is not
|
|
329
|
+
* mistaken for contention.
|
|
330
|
+
*
|
|
331
|
+
* Deliberately **not** contention: `SQLITE_FULL` / "database or disk is full",
|
|
332
|
+
* "unable to open database file", and WAL shared-memory I/O failures. Those stay
|
|
333
|
+
* broken until something outside this connection changes.
|
|
334
|
+
*/
|
|
335
|
+
/** Node: `errcode`. Bun: `errno`. Both spell the extended code as a number. */
|
|
336
|
+
const CONTENTION_PRIMARY_CODES: readonly number[] = [5, 6, 15];
|
|
337
|
+
/** `bun:sqlite` puts the symbolic name in `code`; Node puts its own kind there. */
|
|
338
|
+
const CONTENTION_SYMBOLIC_NAMES = /^SQLITE_(?:BUSY|LOCKED|PROTOCOL)(?:_[A-Z0-9]+)?$/;
|
|
339
|
+
/** Any SQLite result name. If one is present it decides, so text cannot argue. */
|
|
340
|
+
const SQLITE_RESULT_NAMES = /^SQLITE_[A-Z][A-Z0-9_]*$/;
|
|
341
|
+
const CONTENTION_MESSAGES =
|
|
342
|
+
/^(?:database is locked|database table is locked|locking protocol|SQLITE_BUSY|SQLITE_LOCKED|SQLITE_PROTOCOL)(?:$|[\s.:])/i;
|
|
343
|
+
|
|
344
|
+
export function isTransactionContention(error: unknown): boolean {
|
|
345
|
+
const carrier = error as { errcode?: unknown; errCode?: unknown; errno?: unknown; code?: unknown; name?: unknown }
|
|
346
|
+
| undefined;
|
|
347
|
+
// A numeric code wins first, then a symbolic result name, and both win **over the
|
|
348
|
+
// message**: `errno` is what `bun:sqlite` exposes for the extended result code while
|
|
349
|
+
// its `code` field holds the symbolic name, and Node spells the number `errcode`.
|
|
350
|
+
// All three properties are read; what the list orders is which **integer value
|
|
351
|
+
// decides** — a present `errcode` outranks `errCode`, which outranks `errno`, and a
|
|
352
|
+
// later number is never consulted. Text is consulted only when the error carries
|
|
353
|
+
// neither a number nor a SQLite result name, so no wrapper quoting an older
|
|
354
|
+
// "database is locked" can outvote a code on either runtime.
|
|
355
|
+
for (const value of [carrier?.errcode, carrier?.errCode, carrier?.errno]) {
|
|
356
|
+
if (typeof value === "number" && Number.isInteger(value)) return CONTENTION_PRIMARY_CODES.includes(value & 0xff);
|
|
357
|
+
}
|
|
358
|
+
for (const value of [carrier?.code, carrier?.name]) {
|
|
359
|
+
// A result **name** is as authoritative as a number, and in both directions:
|
|
360
|
+
// `SQLITE_FULL` wearing a "database is locked" message is not contention. Node
|
|
361
|
+
// spells its own error kind `ERR_SQLITE_ERROR`, which is deliberately not a
|
|
362
|
+
// SQLite result name and so never reaches a verdict here.
|
|
363
|
+
if (typeof value === "string" && SQLITE_RESULT_NAMES.test(value)) {
|
|
364
|
+
return CONTENTION_SYMBOLIC_NAMES.test(value);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return CONTENTION_MESSAGES.test(error instanceof Error ? error.message : String(error));
|
|
368
|
+
}
|
|
369
|
+
|
|
216
370
|
export function withDatabaseTransaction<T>(
|
|
217
371
|
database: DatabaseSync,
|
|
218
372
|
work: () => T,
|
|
219
373
|
mode: "IMMEDIATE" | "DEFERRED" | "EXCLUSIVE" = "IMMEDIATE",
|
|
374
|
+
clock: MonotonicClock = monotonicNowMs,
|
|
220
375
|
): T {
|
|
221
376
|
if (activeTransactions.has(database)) {
|
|
222
377
|
throw databaseError("runtime_transaction_nested", "transaction", "nested transactions are not allowed");
|
|
223
378
|
}
|
|
379
|
+
// A DEFERRED BEGIN takes no write lock, so it is exempt from the *check*: refusing
|
|
380
|
+
// it would deny legitimate work over a contention it did not ask for. Exempt from
|
|
381
|
+
// the check only — shared-cache schema locks can still make a deferred `BEGIN`
|
|
382
|
+
// fail, and that failure arms a deadline like any other, because the next attempt
|
|
383
|
+
// would stall the same way.
|
|
384
|
+
if (mode !== "DEFERRED") {
|
|
385
|
+
const deadlines = transactionThrottles.get(database);
|
|
386
|
+
const until = deadlines?.get(clock);
|
|
387
|
+
if (until !== undefined) {
|
|
388
|
+
const remaining = until - finiteNow(clock, "the transaction clock");
|
|
389
|
+
if (remaining > 0) {
|
|
390
|
+
throw databaseError(
|
|
391
|
+
"runtime_transaction_busy",
|
|
392
|
+
"transaction",
|
|
393
|
+
`a previous BEGIN was blocked on this database; retry deferred ${String(remaining)}ms`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
deadlines?.delete(clock);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Claim the marker only once BEGIN has succeeded. If BEGIN throws — another
|
|
400
|
+
// writer held the database past the busy timeout — a connection that never
|
|
401
|
+
// entered a transaction must not be left permanently marked as inside one,
|
|
402
|
+
// which would fail every later transaction on it with a misleading
|
|
403
|
+
// "nested" error. `finally` cannot cover this: it only runs after the try.
|
|
404
|
+
try {
|
|
405
|
+
database.exec(`BEGIN ${mode}`);
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (!isTransactionContention(error)) throw error;
|
|
408
|
+
const now = finiteNow(clock, "the transaction clock");
|
|
409
|
+
let deadlines = transactionThrottles.get(database);
|
|
410
|
+
if (deadlines === undefined) {
|
|
411
|
+
deadlines = new WeakMap<MonotonicClock, number>();
|
|
412
|
+
transactionThrottles.set(database, deadlines);
|
|
413
|
+
}
|
|
414
|
+
deadlines.set(clock, now + TRANSACTION_BUSY_BACKOFF_MS);
|
|
415
|
+
throw databaseError(
|
|
416
|
+
"runtime_transaction_busy",
|
|
417
|
+
"transaction",
|
|
418
|
+
`BEGIN ${mode} blocked by another transaction: ${error instanceof Error ? error.message : String(error)}`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
224
421
|
activeTransactions.add(database);
|
|
225
|
-
|
|
422
|
+
// A successful write-mode BEGIN means this connection is holding the slot, so any
|
|
423
|
+
// earlier contention is over. A DEFERRED success proves nothing about the write
|
|
424
|
+
// lock and must not clear a throttle that another caller's contention armed.
|
|
425
|
+
if (mode !== "DEFERRED") transactionThrottles.get(database)?.delete(clock);
|
|
226
426
|
try {
|
|
227
427
|
const result = work();
|
|
228
428
|
database.exec("COMMIT");
|
|
229
429
|
return result;
|
|
230
430
|
} catch (error) {
|
|
431
|
+
// A failed ROLLBACK usually means the connection is gone. Clearing the marker
|
|
432
|
+
// is bookkeeping, not proof: it does **not** show SQLite exited the
|
|
433
|
+
// transaction, and nothing here invalidates a handle whose rollback failed.
|
|
434
|
+
// Pre-existing, named rather than papered over — the caller receives the
|
|
435
|
+
// original error.
|
|
231
436
|
try { database.exec("ROLLBACK"); } catch { /* ignore rollback error if connection dead */ }
|
|
232
437
|
throw error;
|
|
233
438
|
} finally {
|