@remnic/core 9.3.691 → 9.3.693
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/dist/access-boundary.d.ts +6 -5
- package/dist/access-boundary.js +12 -12
- package/dist/access-cli.js +71 -18
- package/dist/access-cli.js.map +1 -1
- package/dist/access-http.d.ts +5 -4
- package/dist/access-http.js +16 -16
- package/dist/access-mcp.d.ts +7 -4
- package/dist/access-mcp.js +15 -15
- package/dist/access-operations.d.ts +12 -6
- package/dist/access-operations.js +16 -14
- package/dist/access-schema.js +3 -3
- package/dist/{access-service-CKnFdxH-.d.ts → access-service-DxCpgyVW.d.ts} +96 -2
- package/dist/access-service.d.ts +5 -4
- package/dist/access-service.js +11 -11
- package/dist/access-surface-catalog.d.ts +5 -4
- package/dist/access-surface-catalog.js +2 -0
- package/dist/access-surface-catalog.js.map +1 -1
- package/dist/bootstrap.d.ts +4 -3
- package/dist/{chunk-QNG35H27.js → chunk-4IQNAOP3.js} +5 -141
- package/dist/chunk-4IQNAOP3.js.map +1 -0
- package/dist/{chunk-VH2DHBHG.js → chunk-5HGPDRQZ.js} +2 -2
- package/dist/chunk-5HGPDRQZ.js.map +1 -0
- package/dist/chunk-6C2BMUZA.js +444 -0
- package/dist/chunk-6C2BMUZA.js.map +1 -0
- package/dist/{chunk-UUMYB3FR.js → chunk-6ZNIUGXB.js} +10 -10
- package/dist/{chunk-IPJD7WJH.js → chunk-E6BOD3UJ.js} +2 -2
- package/dist/{chunk-TJ5IIQIR.js → chunk-F7BVJ3IE.js} +40 -369
- package/dist/chunk-F7BVJ3IE.js.map +1 -0
- package/dist/{chunk-D3DNGQNQ.js → chunk-FM7KLRKN.js} +2 -2
- package/dist/{chunk-7MOTEVAA.js → chunk-HRUULBBV.js} +3 -1
- package/dist/chunk-HRUULBBV.js.map +1 -0
- package/dist/chunk-HXNMDPHN.js +149 -0
- package/dist/chunk-HXNMDPHN.js.map +1 -0
- package/dist/{chunk-7UVDIWLL.js → chunk-JNKH2XWQ.js} +36 -6
- package/dist/chunk-JNKH2XWQ.js.map +1 -0
- package/dist/{chunk-IPLYGWQF.js → chunk-KQAFEZQX.js} +5 -5
- package/dist/{chunk-OOO7ZKOD.js → chunk-KYH53EFD.js} +3 -3
- package/dist/{chunk-LQ6JI4VH.js → chunk-LD53WPMU.js} +4 -4
- package/dist/{chunk-KO6OL4I7.js → chunk-MQTPUPLP.js} +297 -17
- package/dist/chunk-MQTPUPLP.js.map +1 -0
- package/dist/{chunk-TSGREOYY.js → chunk-RHWFMBO5.js} +33 -6
- package/dist/chunk-RHWFMBO5.js.map +1 -0
- package/dist/{chunk-5CEJH5ZN.js → chunk-TAPSNIMA.js} +4 -4
- package/dist/{chunk-K7I24ZO2.js → chunk-THKNO6OX.js} +22 -21
- package/dist/{chunk-K7I24ZO2.js.map → chunk-THKNO6OX.js.map} +1 -1
- package/dist/{chunk-QLRYXOAD.js → chunk-UDJLF3BO.js} +2 -2
- package/dist/{chunk-NCRDCTEJ.js → chunk-WYB44HHV.js} +21 -7
- package/dist/chunk-WYB44HHV.js.map +1 -0
- package/dist/{chunk-WRFKZEO6.js → chunk-YQGHW5ML.js} +4 -4
- package/dist/{cli-CrSW_KRq.d.ts → cli-DoQsv4ut.d.ts} +2 -2
- package/dist/cli.d.ts +6 -5
- package/dist/cli.js +26 -25
- package/dist/contradiction/index.js +4 -4
- package/dist/explicit-capture.d.ts +4 -3
- package/dist/index.d.ts +6 -5
- package/dist/index.js +33 -31
- package/dist/index.js.map +1 -1
- package/dist/mcp-memory-inspector-app.d.ts +5 -4
- package/dist/namespaces/migrate.js +8 -8
- package/dist/namespaces/search.js +7 -7
- package/dist/operator-toolkit.js +11 -10
- package/dist/orchestration/maintenance.d.ts +112 -0
- package/dist/orchestration/maintenance.js +39 -0
- package/dist/orchestration/maintenance.js.map +1 -0
- package/dist/{orchestrator-DvswGK76.d.ts → orchestrator-CNJCYjZ1.d.ts} +9 -33
- package/dist/orchestrator.d.ts +2 -1
- package/dist/orchestrator.js +13 -11
- package/dist/schemas.d.ts +28 -28
- package/dist/search/factory.js +6 -6
- package/dist/search/index.js +11 -11
- package/dist/search/lancedb-backend.js +2 -2
- package/dist/search/meilisearch-backend.js +2 -2
- package/dist/search/orama-backend.js +2 -2
- package/dist/transfer/autodetect.js +1 -1
- package/dist/transfer/backup.js +1 -1
- package/dist/transfer/capsule-export.js +2 -2
- package/dist/transfer/types.d.ts +12 -12
- package/package.json +12 -2
- package/src/access-boundary.ts +2 -1
- package/src/access-cli.ts +65 -2
- package/src/access-http.ts +20 -0
- package/src/access-mcp.ts +29 -0
- package/src/access-operations.ts +46 -0
- package/src/access-service.ts +55 -0
- package/src/access-surface-catalog.test.ts +1 -1
- package/src/access-surface-catalog.ts +2 -0
- package/src/cli.ts +1 -0
- package/src/coding/coding-namespace.ts +1 -1
- package/src/coding/session-delta-surfaces.test.ts +383 -0
- package/src/coding/session-delta-surfaces.ts +325 -0
- package/src/coding/session-delta.test.ts +411 -0
- package/src/coding/session-delta.ts +442 -0
- package/src/orchestration/maintenance.test.ts +738 -0
- package/src/orchestration/maintenance.ts +557 -0
- package/src/orchestrator-flush.test.ts +0 -599
- package/src/orchestrator.ts +33 -419
- package/dist/chunk-7MOTEVAA.js.map +0 -1
- package/dist/chunk-7UVDIWLL.js.map +0 -1
- package/dist/chunk-KO6OL4I7.js.map +0 -1
- package/dist/chunk-NCRDCTEJ.js.map +0 -1
- package/dist/chunk-QNG35H27.js.map +0 -1
- package/dist/chunk-TJ5IIQIR.js.map +0 -1
- package/dist/chunk-TSGREOYY.js.map +0 -1
- package/dist/chunk-VH2DHBHG.js.map +0 -1
- /package/dist/{chunk-UUMYB3FR.js.map → chunk-6ZNIUGXB.js.map} +0 -0
- /package/dist/{chunk-IPJD7WJH.js.map → chunk-E6BOD3UJ.js.map} +0 -0
- /package/dist/{chunk-D3DNGQNQ.js.map → chunk-FM7KLRKN.js.map} +0 -0
- /package/dist/{chunk-IPLYGWQF.js.map → chunk-KQAFEZQX.js.map} +0 -0
- /package/dist/{chunk-OOO7ZKOD.js.map → chunk-KYH53EFD.js.map} +0 -0
- /package/dist/{chunk-LQ6JI4VH.js.map → chunk-LD53WPMU.js.map} +0 -0
- /package/dist/{chunk-5CEJH5ZN.js.map → chunk-TAPSNIMA.js.map} +0 -0
- /package/dist/{chunk-QLRYXOAD.js.map → chunk-UDJLF3BO.js.map} +0 -0
- /package/dist/{chunk-WRFKZEO6.js.map → chunk-YQGHW5ML.js.map} +0 -0
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colocated suite for MaintenanceScheduler (issue #1526 PR1).
|
|
3
|
+
*
|
|
4
|
+
* These tests were ported from orchestrator-flush.test.ts /
|
|
5
|
+
* orchestrator-characterization.test.ts where they previously poked
|
|
6
|
+
* `orchestrator.runQmdMaintenance()` and its private cadence fields directly.
|
|
7
|
+
* Now that the cadence + singleflight state lives on the scheduler, they
|
|
8
|
+
* construct a real MaintenanceScheduler and drive it through its public API
|
|
9
|
+
* (requestQmdMaintenanceForTool arms the debounced pending flag exactly as the
|
|
10
|
+
* orchestrator does in production; runQmdMaintenance runs the pass).
|
|
11
|
+
*
|
|
12
|
+
* Fixture-cast note: NamespaceCatalog and NamespaceSearchRouter are classes
|
|
13
|
+
* with private cache state, so a partial stub cannot structurally satisfy them.
|
|
14
|
+
* Each cast below bridges a stub that only implements the surface
|
|
15
|
+
* runQmdMaintenance actually invokes; the reason is documented at each site.
|
|
16
|
+
*/
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { mkdir, mkdtemp, rm, symlink } from "node:fs/promises";
|
|
22
|
+
|
|
23
|
+
import { MaintenanceScheduler } from "./maintenance.js";
|
|
24
|
+
import type { PluginConfig } from "../types.js";
|
|
25
|
+
import type { SearchBackend } from "../search/port.js";
|
|
26
|
+
import type {
|
|
27
|
+
NamespaceSearchRouter,
|
|
28
|
+
NamespaceUpdateResult,
|
|
29
|
+
} from "../namespaces/search.js";
|
|
30
|
+
import type { NamespaceCatalog } from "../namespaces/catalog.js";
|
|
31
|
+
import { namespaceIdentityToken } from "../namespaces/identity.js";
|
|
32
|
+
import { readNamespaceMaintenanceStatuses } from "../maintenance/namespace-planner.js";
|
|
33
|
+
|
|
34
|
+
/** Build a fixture PluginConfig. Cast bridges a partial fixture to the full
|
|
35
|
+
* contract — only the fields the scheduler/planner read are populated. */
|
|
36
|
+
function fixtureConfig(overrides: Record<string, unknown>): PluginConfig {
|
|
37
|
+
return {
|
|
38
|
+
// Arm the debounced QMD maintenance gate the way the orchestrator does.
|
|
39
|
+
qmdMaintenanceEnabled: true,
|
|
40
|
+
// Keep the debounce timer from firing during the synchronous test pass;
|
|
41
|
+
// dispose() clears it in finally.
|
|
42
|
+
qmdMaintenanceDebounceMs: 60_000,
|
|
43
|
+
...overrides,
|
|
44
|
+
} as unknown as PluginConfig;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Minimal QMD stub — runQmdMaintenance's namespaces branch never touches qmd
|
|
48
|
+
* directly; only isAvailable() is checked when arming pending work. */
|
|
49
|
+
function stubQmd(): SearchBackend {
|
|
50
|
+
return { isAvailable: () => true } as unknown as SearchBackend;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildScheduler(opts: {
|
|
54
|
+
config: PluginConfig;
|
|
55
|
+
catalog: NamespaceCatalog;
|
|
56
|
+
router: NamespaceSearchRouter;
|
|
57
|
+
}): MaintenanceScheduler {
|
|
58
|
+
return new MaintenanceScheduler({
|
|
59
|
+
config: opts.config,
|
|
60
|
+
getQmd: () => stubQmd(),
|
|
61
|
+
namespaceSearchRouter: opts.router,
|
|
62
|
+
namespaceCatalog: opts.catalog,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
67
|
+
// runQmdMaintenance — namespace union, cadence, and failure semantics
|
|
68
|
+
// (ported from orchestrator-flush.test.ts; behavior unchanged)
|
|
69
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
test("runQmdMaintenance updates and embeds cataloged dynamic namespaces (NGnei)", async () => {
|
|
72
|
+
const updateArgs: string[] = [];
|
|
73
|
+
const updateCalls: Array<{ namespaces: string[]; strict: boolean | undefined }> = [];
|
|
74
|
+
const embedArgs: string[] = [];
|
|
75
|
+
const embedCalls: string[][] = [];
|
|
76
|
+
const memoryDir = path.join(os.tmpdir(), "remnic-qmd-maintenance-ngnei");
|
|
77
|
+
const dynamicNamespace = "project-origin-dynamic";
|
|
78
|
+
const dynamicStorageDir = path.join(
|
|
79
|
+
memoryDir,
|
|
80
|
+
"namespaces",
|
|
81
|
+
namespaceIdentityToken(dynamicNamespace),
|
|
82
|
+
);
|
|
83
|
+
await mkdir(path.join(dynamicStorageDir, "facts"), { recursive: true });
|
|
84
|
+
|
|
85
|
+
const config = fixtureConfig({
|
|
86
|
+
memoryDir,
|
|
87
|
+
namespacesEnabled: true,
|
|
88
|
+
defaultNamespace: "default",
|
|
89
|
+
sharedNamespace: "shared",
|
|
90
|
+
namespacePolicies: [],
|
|
91
|
+
maintenanceNamespaceLockStaleMs: 100,
|
|
92
|
+
qmdAutoEmbedEnabled: true,
|
|
93
|
+
qmdEmbedMinIntervalMs: 0,
|
|
94
|
+
});
|
|
95
|
+
// Fixture: catalog stub implements only listNamespaces + enabled.
|
|
96
|
+
const catalog = {
|
|
97
|
+
enabled: true,
|
|
98
|
+
async listNamespaces() {
|
|
99
|
+
return [
|
|
100
|
+
{ namespace: "default" },
|
|
101
|
+
{
|
|
102
|
+
namespace: dynamicNamespace,
|
|
103
|
+
identityToken: namespaceIdentityToken(dynamicNamespace),
|
|
104
|
+
kind: "project",
|
|
105
|
+
createdAt: "2026-04-12T12:00:00.000Z",
|
|
106
|
+
storageDir: dynamicStorageDir,
|
|
107
|
+
discoveredBy: "write",
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
},
|
|
111
|
+
} as unknown as NamespaceCatalog;
|
|
112
|
+
const router = {
|
|
113
|
+
async updateNamespacesDetailed(
|
|
114
|
+
ns: string[],
|
|
115
|
+
_execution: unknown,
|
|
116
|
+
options: { strict?: boolean } | undefined,
|
|
117
|
+
): Promise<NamespaceUpdateResult> {
|
|
118
|
+
updateCalls.push({ namespaces: [...ns], strict: options?.strict });
|
|
119
|
+
updateArgs.push(...ns);
|
|
120
|
+
return { backendCount: ns.length, eligibleNamespaces: ns };
|
|
121
|
+
},
|
|
122
|
+
async embedNamespaces(ns: string[]): Promise<void> {
|
|
123
|
+
embedCalls.push([...ns]);
|
|
124
|
+
embedArgs.push(...ns);
|
|
125
|
+
},
|
|
126
|
+
} as unknown as NamespaceSearchRouter;
|
|
127
|
+
|
|
128
|
+
const scheduler = buildScheduler({ config, catalog, router });
|
|
129
|
+
try {
|
|
130
|
+
scheduler.requestQmdMaintenanceForTool("test");
|
|
131
|
+
await scheduler.runQmdMaintenance();
|
|
132
|
+
|
|
133
|
+
assert.ok(updateArgs.length > 0, "updateNamespaces must be called");
|
|
134
|
+
assert.equal(updateCalls.length, 1, "global QMD maintenance must batch selected namespaces into one update call");
|
|
135
|
+
assert.equal(updateCalls[0]?.strict, true, "recurring QMD maintenance must use strict update semantics");
|
|
136
|
+
assert.ok(
|
|
137
|
+
updateArgs.includes(dynamicNamespace),
|
|
138
|
+
"QMD update must cover the cataloged dynamic namespace, not just configured ones",
|
|
139
|
+
);
|
|
140
|
+
assert.ok(
|
|
141
|
+
updateArgs.includes("default") && updateArgs.includes("shared"),
|
|
142
|
+
"configured namespaces remain covered",
|
|
143
|
+
);
|
|
144
|
+
assert.ok(
|
|
145
|
+
embedArgs.includes(dynamicNamespace),
|
|
146
|
+
"QMD embed must cover the cataloged dynamic namespace",
|
|
147
|
+
);
|
|
148
|
+
assert.equal(embedCalls.length, 1, "QMD embed must batch all selected namespaces into one router call");
|
|
149
|
+
assert.deepEqual(new Set(embedCalls[0]), new Set(["default", "shared", dynamicNamespace]));
|
|
150
|
+
} finally {
|
|
151
|
+
scheduler.dispose();
|
|
152
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("runQmdMaintenance tracks namespace embed cadence across budget rotation", async () => {
|
|
157
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-qmd-namespace-embed-cadence-"));
|
|
158
|
+
const updateCalls: string[][] = [];
|
|
159
|
+
const embedCalls: string[][] = [];
|
|
160
|
+
|
|
161
|
+
const config = fixtureConfig({
|
|
162
|
+
memoryDir,
|
|
163
|
+
namespacesEnabled: true,
|
|
164
|
+
defaultNamespace: "default",
|
|
165
|
+
sharedNamespace: "shared",
|
|
166
|
+
namespacePolicies: [{ name: "project-a" }, { name: "project-b" }],
|
|
167
|
+
maintenanceMaxNamespacesPerCycle: 3,
|
|
168
|
+
maintenanceNamespaceLockStaleMs: 100,
|
|
169
|
+
qmdAutoEmbedEnabled: true,
|
|
170
|
+
qmdEmbedMinIntervalMs: 60_000,
|
|
171
|
+
});
|
|
172
|
+
const catalog = {
|
|
173
|
+
enabled: false,
|
|
174
|
+
async listNamespaces(): Promise<never[]> {
|
|
175
|
+
throw new Error("catalog disabled - must not be read");
|
|
176
|
+
},
|
|
177
|
+
} as unknown as NamespaceCatalog;
|
|
178
|
+
const router = {
|
|
179
|
+
async updateNamespacesDetailed(ns: string[]): Promise<NamespaceUpdateResult> {
|
|
180
|
+
updateCalls.push([...ns]);
|
|
181
|
+
return { backendCount: ns.length, eligibleNamespaces: ns };
|
|
182
|
+
},
|
|
183
|
+
async embedNamespaces(ns: string[]): Promise<void> {
|
|
184
|
+
embedCalls.push([...ns]);
|
|
185
|
+
},
|
|
186
|
+
} as unknown as NamespaceSearchRouter;
|
|
187
|
+
|
|
188
|
+
const scheduler = buildScheduler({ config, catalog, router });
|
|
189
|
+
try {
|
|
190
|
+
scheduler.requestQmdMaintenanceForTool("test");
|
|
191
|
+
await scheduler.runQmdMaintenance();
|
|
192
|
+
// Re-arm pending for the second budget rotation (the debounce timer is
|
|
193
|
+
// already armed from the first call, so this just re-sets the flag).
|
|
194
|
+
scheduler.requestQmdMaintenanceForTool("test");
|
|
195
|
+
await scheduler.runQmdMaintenance();
|
|
196
|
+
|
|
197
|
+
assert.deepEqual(updateCalls, [
|
|
198
|
+
["default", "shared", "project-a"],
|
|
199
|
+
["default", "shared", "project-b"],
|
|
200
|
+
]);
|
|
201
|
+
assert.deepEqual(
|
|
202
|
+
embedCalls,
|
|
203
|
+
[["default", "shared", "project-a"], ["project-b"]],
|
|
204
|
+
"a global embed timestamp must not suppress embeddings for newly budgeted namespaces",
|
|
205
|
+
);
|
|
206
|
+
} finally {
|
|
207
|
+
scheduler.dispose();
|
|
208
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("runQmdMaintenance skips cataloged dynamic namespaces whose live root is unsafe", async () => {
|
|
213
|
+
const updateArgs: string[] = [];
|
|
214
|
+
const updateCalls: string[][] = [];
|
|
215
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-qmd-unsafe-root-"));
|
|
216
|
+
const outsideDir = await mkdtemp(path.join(os.tmpdir(), "remnic-qmd-unsafe-target-"));
|
|
217
|
+
const dynamicNamespace = "project-origin-symlinked";
|
|
218
|
+
const liveLegacyRoot = path.join(memoryDir, "namespaces", dynamicNamespace);
|
|
219
|
+
const catalogSafeRoot = path.join(
|
|
220
|
+
memoryDir,
|
|
221
|
+
"namespaces",
|
|
222
|
+
namespaceIdentityToken(dynamicNamespace),
|
|
223
|
+
);
|
|
224
|
+
await mkdir(path.dirname(liveLegacyRoot), { recursive: true });
|
|
225
|
+
await symlink(outsideDir, liveLegacyRoot, "dir");
|
|
226
|
+
|
|
227
|
+
const config = fixtureConfig({
|
|
228
|
+
memoryDir,
|
|
229
|
+
namespacesEnabled: true,
|
|
230
|
+
defaultNamespace: "default",
|
|
231
|
+
sharedNamespace: "shared",
|
|
232
|
+
namespacePolicies: [],
|
|
233
|
+
maintenanceNamespaceLockStaleMs: 100,
|
|
234
|
+
qmdAutoEmbedEnabled: false,
|
|
235
|
+
qmdEmbedMinIntervalMs: 0,
|
|
236
|
+
});
|
|
237
|
+
const catalog = {
|
|
238
|
+
enabled: true,
|
|
239
|
+
async listNamespaces() {
|
|
240
|
+
return [
|
|
241
|
+
{
|
|
242
|
+
namespace: dynamicNamespace,
|
|
243
|
+
identityToken: namespaceIdentityToken(dynamicNamespace),
|
|
244
|
+
kind: "project",
|
|
245
|
+
createdAt: "2026-04-12T12:00:00.000Z",
|
|
246
|
+
storageDir: catalogSafeRoot,
|
|
247
|
+
discoveredBy: "write",
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
},
|
|
251
|
+
} as unknown as NamespaceCatalog;
|
|
252
|
+
const router = {
|
|
253
|
+
async updateNamespacesDetailed(ns: string[]): Promise<NamespaceUpdateResult> {
|
|
254
|
+
updateCalls.push([...ns]);
|
|
255
|
+
updateArgs.push(...ns);
|
|
256
|
+
return { backendCount: ns.length, eligibleNamespaces: ns };
|
|
257
|
+
},
|
|
258
|
+
async embedNamespaces(): Promise<void> {},
|
|
259
|
+
} as unknown as NamespaceSearchRouter;
|
|
260
|
+
|
|
261
|
+
const scheduler = buildScheduler({ config, catalog, router });
|
|
262
|
+
try {
|
|
263
|
+
scheduler.requestQmdMaintenanceForTool("test");
|
|
264
|
+
await scheduler.runQmdMaintenance();
|
|
265
|
+
|
|
266
|
+
assert.ok(updateArgs.length > 0, "updateNamespaces must be called");
|
|
267
|
+
assert.equal(updateCalls.length, 1, "global QMD maintenance must update once for the locked namespace set");
|
|
268
|
+
assert.deepEqual(
|
|
269
|
+
[...updateArgs].sort(),
|
|
270
|
+
["default", "shared"],
|
|
271
|
+
"cataloged dynamic namespaces are skipped when the live router root differs from the catalog-sanitized root",
|
|
272
|
+
);
|
|
273
|
+
} finally {
|
|
274
|
+
scheduler.dispose();
|
|
275
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
276
|
+
await rm(outsideDir, { recursive: true, force: true });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("runQmdMaintenance treats zero namespace updates as failed maintenance", async () => {
|
|
281
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-qmd-zero-update-"));
|
|
282
|
+
let markMaintenanceCalls = 0;
|
|
283
|
+
let embedCalls = 0;
|
|
284
|
+
|
|
285
|
+
const config = fixtureConfig({
|
|
286
|
+
memoryDir,
|
|
287
|
+
namespacesEnabled: true,
|
|
288
|
+
defaultNamespace: "default",
|
|
289
|
+
sharedNamespace: "shared",
|
|
290
|
+
namespacePolicies: [],
|
|
291
|
+
maintenanceNamespaceLockStaleMs: 100,
|
|
292
|
+
qmdAutoEmbedEnabled: false,
|
|
293
|
+
qmdEmbedMinIntervalMs: 0,
|
|
294
|
+
});
|
|
295
|
+
const catalog = {
|
|
296
|
+
enabled: true,
|
|
297
|
+
async listNamespaces() {
|
|
298
|
+
return [{ namespace: "default" }];
|
|
299
|
+
},
|
|
300
|
+
async markMaintenance() {
|
|
301
|
+
markMaintenanceCalls += 1;
|
|
302
|
+
},
|
|
303
|
+
} as unknown as NamespaceCatalog;
|
|
304
|
+
const router = {
|
|
305
|
+
async updateNamespacesDetailed(): Promise<NamespaceUpdateResult> {
|
|
306
|
+
return { backendCount: 0, eligibleNamespaces: [] };
|
|
307
|
+
},
|
|
308
|
+
async embedNamespaces(): Promise<void> {
|
|
309
|
+
embedCalls += 1;
|
|
310
|
+
},
|
|
311
|
+
} as unknown as NamespaceSearchRouter;
|
|
312
|
+
|
|
313
|
+
const scheduler = buildScheduler({ config, catalog, router });
|
|
314
|
+
try {
|
|
315
|
+
scheduler.requestQmdMaintenanceForTool("test");
|
|
316
|
+
await scheduler.runQmdMaintenance();
|
|
317
|
+
|
|
318
|
+
const statuses = await readNamespaceMaintenanceStatuses(config);
|
|
319
|
+
assert.ok(
|
|
320
|
+
statuses.some((status) => status.namespace === "default" && status.state === "failed"),
|
|
321
|
+
"zero updates should be recorded as failed maintenance, not a successful run",
|
|
322
|
+
);
|
|
323
|
+
assert.equal(markMaintenanceCalls, 0);
|
|
324
|
+
assert.equal(embedCalls, 0);
|
|
325
|
+
} finally {
|
|
326
|
+
scheduler.dispose();
|
|
327
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("runQmdMaintenance treats partial namespace update eligibility as failed maintenance", async () => {
|
|
332
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-qmd-partial-update-"));
|
|
333
|
+
let markMaintenanceCalls = 0;
|
|
334
|
+
let embedCalls = 0;
|
|
335
|
+
|
|
336
|
+
const config = fixtureConfig({
|
|
337
|
+
memoryDir,
|
|
338
|
+
namespacesEnabled: true,
|
|
339
|
+
defaultNamespace: "default",
|
|
340
|
+
sharedNamespace: "shared",
|
|
341
|
+
namespacePolicies: [],
|
|
342
|
+
maintenanceNamespaceLockStaleMs: 100,
|
|
343
|
+
qmdAutoEmbedEnabled: false,
|
|
344
|
+
qmdEmbedMinIntervalMs: 0,
|
|
345
|
+
});
|
|
346
|
+
const catalog = {
|
|
347
|
+
enabled: true,
|
|
348
|
+
async listNamespaces() {
|
|
349
|
+
return [{ namespace: "default" }];
|
|
350
|
+
},
|
|
351
|
+
async markMaintenance() {
|
|
352
|
+
markMaintenanceCalls += 1;
|
|
353
|
+
},
|
|
354
|
+
} as unknown as NamespaceCatalog;
|
|
355
|
+
const router = {
|
|
356
|
+
async updateNamespacesDetailed(ns: string[]): Promise<NamespaceUpdateResult> {
|
|
357
|
+
assert.ok(ns.includes("default") && ns.includes("shared"));
|
|
358
|
+
return { backendCount: 1, eligibleNamespaces: ["default"] };
|
|
359
|
+
},
|
|
360
|
+
async embedNamespaces(): Promise<void> {
|
|
361
|
+
embedCalls += 1;
|
|
362
|
+
},
|
|
363
|
+
} as unknown as NamespaceSearchRouter;
|
|
364
|
+
|
|
365
|
+
const scheduler = buildScheduler({ config, catalog, router });
|
|
366
|
+
try {
|
|
367
|
+
scheduler.requestQmdMaintenanceForTool("test");
|
|
368
|
+
await scheduler.runQmdMaintenance();
|
|
369
|
+
|
|
370
|
+
const statuses = await readNamespaceMaintenanceStatuses(config);
|
|
371
|
+
assert.ok(
|
|
372
|
+
statuses.some((status) => status.namespace === "default" && status.state === "failed"),
|
|
373
|
+
"partial update eligibility should not be recorded as successful maintenance",
|
|
374
|
+
);
|
|
375
|
+
assert.ok(
|
|
376
|
+
statuses.some((status) => status.namespace === "shared" && status.state === "failed"),
|
|
377
|
+
"ineligible selected namespaces should not be rotated as maintained",
|
|
378
|
+
);
|
|
379
|
+
assert.equal(markMaintenanceCalls, 0);
|
|
380
|
+
assert.equal(embedCalls, 0);
|
|
381
|
+
} finally {
|
|
382
|
+
scheduler.dispose();
|
|
383
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("runQmdMaintenance treats namespace embed errors as failed maintenance", async () => {
|
|
388
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-qmd-embed-failure-"));
|
|
389
|
+
let markMaintenanceCalls = 0;
|
|
390
|
+
|
|
391
|
+
const config = fixtureConfig({
|
|
392
|
+
memoryDir,
|
|
393
|
+
namespacesEnabled: true,
|
|
394
|
+
defaultNamespace: "default",
|
|
395
|
+
sharedNamespace: "shared",
|
|
396
|
+
namespacePolicies: [],
|
|
397
|
+
maintenanceNamespaceLockStaleMs: 100,
|
|
398
|
+
qmdAutoEmbedEnabled: true,
|
|
399
|
+
qmdEmbedMinIntervalMs: 0,
|
|
400
|
+
});
|
|
401
|
+
const catalog = {
|
|
402
|
+
enabled: true,
|
|
403
|
+
async listNamespaces() {
|
|
404
|
+
return [{ namespace: "default" }];
|
|
405
|
+
},
|
|
406
|
+
async markMaintenance() {
|
|
407
|
+
markMaintenanceCalls += 1;
|
|
408
|
+
},
|
|
409
|
+
} as unknown as NamespaceCatalog;
|
|
410
|
+
const router = {
|
|
411
|
+
async updateNamespacesDetailed(ns: string[]): Promise<NamespaceUpdateResult> {
|
|
412
|
+
return { backendCount: 1, eligibleNamespaces: ns };
|
|
413
|
+
},
|
|
414
|
+
async embedNamespaces(): Promise<void> {
|
|
415
|
+
throw Object.assign(new Error("embed failed"), { code: "EQMD" });
|
|
416
|
+
},
|
|
417
|
+
} as unknown as NamespaceSearchRouter;
|
|
418
|
+
|
|
419
|
+
const scheduler = buildScheduler({ config, catalog, router });
|
|
420
|
+
try {
|
|
421
|
+
scheduler.requestQmdMaintenanceForTool("test");
|
|
422
|
+
await scheduler.runQmdMaintenance();
|
|
423
|
+
|
|
424
|
+
const statuses = await readNamespaceMaintenanceStatuses(config);
|
|
425
|
+
assert.ok(
|
|
426
|
+
statuses.some((status) => status.namespace === "default" && status.state === "failed"),
|
|
427
|
+
"embed failures should not be recorded as successful namespace maintenance",
|
|
428
|
+
);
|
|
429
|
+
assert.equal(markMaintenanceCalls, 0);
|
|
430
|
+
} finally {
|
|
431
|
+
scheduler.dispose();
|
|
432
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("runQmdMaintenance records QMD min-interval throttles as skipped maintenance", async () => {
|
|
437
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-qmd-throttled-update-"));
|
|
438
|
+
let markMaintenanceCalls = 0;
|
|
439
|
+
let embedCalls = 0;
|
|
440
|
+
let updateCalls = 0;
|
|
441
|
+
|
|
442
|
+
const config = fixtureConfig({
|
|
443
|
+
memoryDir,
|
|
444
|
+
namespacesEnabled: true,
|
|
445
|
+
defaultNamespace: "default",
|
|
446
|
+
sharedNamespace: "shared",
|
|
447
|
+
namespacePolicies: [],
|
|
448
|
+
maintenanceNamespaceLockStaleMs: 100,
|
|
449
|
+
qmdAutoEmbedEnabled: false,
|
|
450
|
+
qmdEmbedMinIntervalMs: 0,
|
|
451
|
+
});
|
|
452
|
+
const catalog = {
|
|
453
|
+
enabled: true,
|
|
454
|
+
async listNamespaces() {
|
|
455
|
+
return [{ namespace: "default" }];
|
|
456
|
+
},
|
|
457
|
+
async markMaintenance() {
|
|
458
|
+
markMaintenanceCalls += 1;
|
|
459
|
+
},
|
|
460
|
+
} as unknown as NamespaceCatalog;
|
|
461
|
+
const router = {
|
|
462
|
+
async updateNamespacesDetailed(
|
|
463
|
+
_ns: string[],
|
|
464
|
+
_execution: unknown,
|
|
465
|
+
options: { strict?: boolean } | undefined,
|
|
466
|
+
): Promise<NamespaceUpdateResult> {
|
|
467
|
+
updateCalls += 1;
|
|
468
|
+
assert.equal(options?.strict, true, "recurring maintenance must use strict QMD updates");
|
|
469
|
+
throw new Error("QMD update skipped by global min-interval gate");
|
|
470
|
+
},
|
|
471
|
+
async embedNamespaces(): Promise<void> {
|
|
472
|
+
embedCalls += 1;
|
|
473
|
+
},
|
|
474
|
+
} as unknown as NamespaceSearchRouter;
|
|
475
|
+
|
|
476
|
+
const scheduler = buildScheduler({ config, catalog, router });
|
|
477
|
+
try {
|
|
478
|
+
scheduler.requestQmdMaintenanceForTool("test");
|
|
479
|
+
await scheduler.runQmdMaintenance();
|
|
480
|
+
|
|
481
|
+
const statuses = await readNamespaceMaintenanceStatuses(config);
|
|
482
|
+
assert.equal(updateCalls, 1, "strict global QMD maintenance should be attempted once");
|
|
483
|
+
assert.ok(
|
|
484
|
+
statuses.some(
|
|
485
|
+
(status) =>
|
|
486
|
+
status.namespace === "default" &&
|
|
487
|
+
status.state === "skipped" &&
|
|
488
|
+
status.reason === "throttled",
|
|
489
|
+
),
|
|
490
|
+
"QMD min-interval throttles should be recorded as skipped maintenance",
|
|
491
|
+
);
|
|
492
|
+
assert.ok(
|
|
493
|
+
statuses.some(
|
|
494
|
+
(status) =>
|
|
495
|
+
status.namespace === "shared" &&
|
|
496
|
+
status.state === "skipped" &&
|
|
497
|
+
status.reason === "throttled",
|
|
498
|
+
),
|
|
499
|
+
"every selected namespace should receive the throttled skip status",
|
|
500
|
+
);
|
|
501
|
+
assert.equal(markMaintenanceCalls, 0);
|
|
502
|
+
assert.equal(embedCalls, 0);
|
|
503
|
+
} finally {
|
|
504
|
+
scheduler.dispose();
|
|
505
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("runQmdMaintenance still embeds when due update is throttled", async () => {
|
|
510
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-qmd-throttled-update-embed-"));
|
|
511
|
+
let markMaintenanceCalls = 0;
|
|
512
|
+
let updateCalls = 0;
|
|
513
|
+
const embedCalls: string[][] = [];
|
|
514
|
+
|
|
515
|
+
const config = fixtureConfig({
|
|
516
|
+
memoryDir,
|
|
517
|
+
namespacesEnabled: true,
|
|
518
|
+
defaultNamespace: "default",
|
|
519
|
+
sharedNamespace: "shared",
|
|
520
|
+
namespacePolicies: [],
|
|
521
|
+
maintenanceNamespaceLockStaleMs: 100,
|
|
522
|
+
qmdAutoEmbedEnabled: true,
|
|
523
|
+
qmdEmbedMinIntervalMs: 0,
|
|
524
|
+
});
|
|
525
|
+
const catalog = {
|
|
526
|
+
enabled: true,
|
|
527
|
+
async listNamespaces() {
|
|
528
|
+
return [{ namespace: "default" }];
|
|
529
|
+
},
|
|
530
|
+
async markMaintenance() {
|
|
531
|
+
markMaintenanceCalls += 1;
|
|
532
|
+
},
|
|
533
|
+
} as unknown as NamespaceCatalog;
|
|
534
|
+
const router = {
|
|
535
|
+
async updateNamespacesDetailed(
|
|
536
|
+
_ns: string[],
|
|
537
|
+
_execution: unknown,
|
|
538
|
+
options: { strict?: boolean } | undefined,
|
|
539
|
+
): Promise<NamespaceUpdateResult> {
|
|
540
|
+
updateCalls += 1;
|
|
541
|
+
assert.equal(options?.strict, true, "recurring maintenance must use strict QMD updates");
|
|
542
|
+
throw new Error("QMD update skipped by global min-interval gate");
|
|
543
|
+
},
|
|
544
|
+
async embedNamespaces(
|
|
545
|
+
ns: string[],
|
|
546
|
+
options: { strict?: boolean } | undefined,
|
|
547
|
+
): Promise<void> {
|
|
548
|
+
assert.equal(options?.strict, true, "due embed retries must surface embed failures");
|
|
549
|
+
embedCalls.push([...ns]);
|
|
550
|
+
},
|
|
551
|
+
} as unknown as NamespaceSearchRouter;
|
|
552
|
+
|
|
553
|
+
const scheduler = buildScheduler({ config, catalog, router });
|
|
554
|
+
try {
|
|
555
|
+
scheduler.requestQmdMaintenanceForTool("test");
|
|
556
|
+
await scheduler.runQmdMaintenance();
|
|
557
|
+
|
|
558
|
+
const statuses = await readNamespaceMaintenanceStatuses(config);
|
|
559
|
+
assert.equal(updateCalls, 1, "strict global QMD maintenance should be attempted once");
|
|
560
|
+
assert.deepEqual(embedCalls, [["default", "shared"]]);
|
|
561
|
+
assert.ok(
|
|
562
|
+
statuses.every((status) => status.state === "skipped" && status.reason === "throttled"),
|
|
563
|
+
"a throttled update should still be recorded as skipped after the due embed retry",
|
|
564
|
+
);
|
|
565
|
+
assert.equal(markMaintenanceCalls, 0);
|
|
566
|
+
} finally {
|
|
567
|
+
scheduler.dispose();
|
|
568
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("runQmdMaintenance treats strict namespace update errors as failed maintenance", async () => {
|
|
573
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-qmd-update-failure-"));
|
|
574
|
+
let markMaintenanceCalls = 0;
|
|
575
|
+
let embedCalls = 0;
|
|
576
|
+
let updateCalls = 0;
|
|
577
|
+
|
|
578
|
+
const config = fixtureConfig({
|
|
579
|
+
memoryDir,
|
|
580
|
+
namespacesEnabled: true,
|
|
581
|
+
defaultNamespace: "default",
|
|
582
|
+
sharedNamespace: "shared",
|
|
583
|
+
namespacePolicies: [],
|
|
584
|
+
maintenanceNamespaceLockStaleMs: 100,
|
|
585
|
+
qmdAutoEmbedEnabled: true,
|
|
586
|
+
qmdEmbedMinIntervalMs: 0,
|
|
587
|
+
});
|
|
588
|
+
const catalog = {
|
|
589
|
+
enabled: true,
|
|
590
|
+
async listNamespaces() {
|
|
591
|
+
return [{ namespace: "default" }];
|
|
592
|
+
},
|
|
593
|
+
async markMaintenance() {
|
|
594
|
+
markMaintenanceCalls += 1;
|
|
595
|
+
},
|
|
596
|
+
} as unknown as NamespaceCatalog;
|
|
597
|
+
const router = {
|
|
598
|
+
async updateNamespacesDetailed(
|
|
599
|
+
_ns: string[],
|
|
600
|
+
_execution: unknown,
|
|
601
|
+
options: { strict?: boolean } | undefined,
|
|
602
|
+
): Promise<NamespaceUpdateResult> {
|
|
603
|
+
updateCalls += 1;
|
|
604
|
+
assert.equal(options?.strict, true, "recurring maintenance must require strict update failure propagation");
|
|
605
|
+
throw new Error("qmd exploded");
|
|
606
|
+
},
|
|
607
|
+
async embedNamespaces(): Promise<void> {
|
|
608
|
+
embedCalls += 1;
|
|
609
|
+
},
|
|
610
|
+
} as unknown as NamespaceSearchRouter;
|
|
611
|
+
|
|
612
|
+
const scheduler = buildScheduler({ config, catalog, router });
|
|
613
|
+
try {
|
|
614
|
+
scheduler.requestQmdMaintenanceForTool("test");
|
|
615
|
+
await scheduler.runQmdMaintenance();
|
|
616
|
+
|
|
617
|
+
const statuses = await readNamespaceMaintenanceStatuses(config);
|
|
618
|
+
assert.equal(updateCalls, 1, "strict global QMD maintenance should be attempted once");
|
|
619
|
+
assert.ok(
|
|
620
|
+
statuses.some((status) => status.namespace === "default" && status.state === "failed"),
|
|
621
|
+
"strict update errors should be recorded as failed maintenance",
|
|
622
|
+
);
|
|
623
|
+
assert.equal(markMaintenanceCalls, 0);
|
|
624
|
+
assert.equal(embedCalls, 0);
|
|
625
|
+
} finally {
|
|
626
|
+
scheduler.dispose();
|
|
627
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test("runQmdMaintenance falls back to configured namespaces when the catalog is disabled (NGnei)", async () => {
|
|
632
|
+
const updateArgs: string[] = [];
|
|
633
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-qmd-disabled-catalog-"));
|
|
634
|
+
|
|
635
|
+
const config = fixtureConfig({
|
|
636
|
+
memoryDir,
|
|
637
|
+
namespacesEnabled: true,
|
|
638
|
+
defaultNamespace: "default",
|
|
639
|
+
sharedNamespace: "shared",
|
|
640
|
+
namespacePolicies: [],
|
|
641
|
+
maintenanceNamespaceLockStaleMs: 100,
|
|
642
|
+
qmdAutoEmbedEnabled: false,
|
|
643
|
+
qmdEmbedMinIntervalMs: 0,
|
|
644
|
+
});
|
|
645
|
+
const catalog = {
|
|
646
|
+
enabled: false,
|
|
647
|
+
async listNamespaces(): Promise<never[]> {
|
|
648
|
+
throw new Error("catalog disabled — must not be read");
|
|
649
|
+
},
|
|
650
|
+
} as unknown as NamespaceCatalog;
|
|
651
|
+
const router = {
|
|
652
|
+
async updateNamespacesDetailed(ns: string[]): Promise<NamespaceUpdateResult> {
|
|
653
|
+
updateArgs.push(...ns);
|
|
654
|
+
return { backendCount: ns.length, eligibleNamespaces: ns };
|
|
655
|
+
},
|
|
656
|
+
async embedNamespaces(): Promise<void> {},
|
|
657
|
+
} as unknown as NamespaceSearchRouter;
|
|
658
|
+
|
|
659
|
+
const scheduler = buildScheduler({ config, catalog, router });
|
|
660
|
+
try {
|
|
661
|
+
scheduler.requestQmdMaintenanceForTool("test");
|
|
662
|
+
await scheduler.runQmdMaintenance();
|
|
663
|
+
|
|
664
|
+
assert.ok(updateArgs.length > 0, "updateNamespaces must be called");
|
|
665
|
+
assert.deepEqual(
|
|
666
|
+
[...updateArgs].sort(),
|
|
667
|
+
["default", "shared"],
|
|
668
|
+
"a disabled catalog covers exactly the configured set",
|
|
669
|
+
);
|
|
670
|
+
} finally {
|
|
671
|
+
scheduler.dispose();
|
|
672
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("MaintenanceScheduler reads a runtime-swapped qmd backend via getQmd (regression for captured-by-value)", async () => {
|
|
677
|
+
// Regression for issue #1526 PR1: when MaintenanceSchedulerDeps.qmd was a
|
|
678
|
+
// fixed reference captured at construction, the orchestrator's runtime swap
|
|
679
|
+
// `this.qmd = new NoopSearchBackend()` (initialize / startupSearchSync,
|
|
680
|
+
// invoked when the collection is missing) left the scheduler running
|
|
681
|
+
// debounced maintenance against the disposed/stale backend. The live
|
|
682
|
+
// `getQmd: () => this.qmd` accessor must observe the swap so maintenance
|
|
683
|
+
// short-circuits once the backend reports unavailable.
|
|
684
|
+
const calls: string[] = [];
|
|
685
|
+
const liveBackend = {
|
|
686
|
+
isAvailable: () => true,
|
|
687
|
+
async update() {
|
|
688
|
+
calls.push("live.update");
|
|
689
|
+
},
|
|
690
|
+
async embed() {
|
|
691
|
+
calls.push("live.embed");
|
|
692
|
+
},
|
|
693
|
+
} as unknown as SearchBackend;
|
|
694
|
+
const noopBackend = {
|
|
695
|
+
isAvailable: () => false,
|
|
696
|
+
async update() {
|
|
697
|
+
calls.push("noop.update");
|
|
698
|
+
},
|
|
699
|
+
async embed() {
|
|
700
|
+
calls.push("noop.embed");
|
|
701
|
+
},
|
|
702
|
+
} as unknown as SearchBackend;
|
|
703
|
+
const holder: { backend: SearchBackend } = { backend: liveBackend };
|
|
704
|
+
|
|
705
|
+
const config = fixtureConfig({
|
|
706
|
+
namespacesEnabled: false,
|
|
707
|
+
qmdAutoEmbedEnabled: false,
|
|
708
|
+
});
|
|
709
|
+
const catalog = { enabled: false } as unknown as NamespaceCatalog;
|
|
710
|
+
const router = {} as unknown as NamespaceSearchRouter;
|
|
711
|
+
const scheduler = new MaintenanceScheduler({
|
|
712
|
+
config,
|
|
713
|
+
getQmd: () => holder.backend,
|
|
714
|
+
namespaceSearchRouter: router,
|
|
715
|
+
namespaceCatalog: catalog,
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
try {
|
|
719
|
+
// Sanity: arming + running while the live backend is present invokes it.
|
|
720
|
+
scheduler.requestQmdMaintenanceForTool("test");
|
|
721
|
+
await scheduler.runQmdMaintenance();
|
|
722
|
+
assert.deepEqual(calls, ["live.update"]);
|
|
723
|
+
|
|
724
|
+
// The orchestrator swaps to a Noop backend at runtime when the collection
|
|
725
|
+
// is missing — the scheduler must observe the new reference, not the one
|
|
726
|
+
// captured at construction.
|
|
727
|
+
holder.backend = noopBackend;
|
|
728
|
+
scheduler.requestQmdMaintenanceForTool("test");
|
|
729
|
+
await scheduler.runQmdMaintenance();
|
|
730
|
+
assert.deepEqual(
|
|
731
|
+
calls,
|
|
732
|
+
["live.update"],
|
|
733
|
+
"after the swap to NoopSearchBackend, requestQmdMaintenance must short-circuit (isAvailable===false) and never touch the disposed backend",
|
|
734
|
+
);
|
|
735
|
+
} finally {
|
|
736
|
+
scheduler.dispose();
|
|
737
|
+
}
|
|
738
|
+
});
|