@descix/cli 1.0.10 → 1.0.12
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/bin/descix.js +1 -0
- package/bin/mcp-server.js +44 -9
- package/lib/commands/corpus.js +147 -38
- package/lib/commands/kb.js +19 -18
- package/lib/core/RetrievalCanary.js +128 -0
- package/lib/core/syncState.js +144 -0
- package/lib/mcp-origin-stamp.js +84 -0
- package/lib/verb-invokes.generated.json +2 -1
- package/package.json +1 -1
package/bin/descix.js
CHANGED
|
@@ -2187,6 +2187,7 @@ corpusCommand
|
|
|
2187
2187
|
.option('--dry-run', 'Enumerate would-be-purged file_ids and would-be-upserted chunks without ANY Pinecone writes. Exit 0 if no drift, 1 if drift. Read-only.')
|
|
2188
2188
|
.option('--show-walk', 'Print the resolved ref + the first 50 walked files BEFORE any Pinecone operations. Useful for verifying --ref / manifest source resolution.')
|
|
2189
2189
|
.option('--yes', 'Skip the interactive purge confirmation in --rebuild mode. Use in scripting/CI.')
|
|
2190
|
+
.option('--skip-retrieval-canary', 'Skip the post-sync retrieval canary that confirms a synced chunk actually retrieves before reporting success (measured 2026-09-17: presence in Pinecone is not the same as searchable — the index can lag ~20 minutes after a large upsert/purge). Skipping costs nothing but SAVES one credit-metered query_knowledge_base call and up to ~25s; the sync then reports success on presence only, same as before this check existed. Prefer leaving this ON for anything user-facing; use it for CI/cost-sensitive automation that will verify separately.')
|
|
2190
2191
|
.action(async (options) => {
|
|
2191
2192
|
try {
|
|
2192
2193
|
const apiClient = new DeSciXApiClient();
|
package/bin/mcp-server.js
CHANGED
|
@@ -23,6 +23,7 @@ import { WalletFileManager } from '../lib/wallet-file.js';
|
|
|
23
23
|
import { CLI_VERSION } from '../lib/cli-version.js';
|
|
24
24
|
import { checkDevCert } from '@descix/app-sdk/dev';
|
|
25
25
|
import { resolveGatewayCertContext } from '../lib/dev-cert-resolver.js';
|
|
26
|
+
import { originStampFor, stampToolResult } from '../lib/mcp-origin-stamp.js';
|
|
26
27
|
import * as fs from 'fs/promises';
|
|
27
28
|
import * as path from 'path';
|
|
28
29
|
// WS-MCP-SSOT-TIER2 (audit §5.B-1): the curated HTTP-valid core tools are the SINGLE SOURCE
|
|
@@ -153,7 +154,7 @@ try {
|
|
|
153
154
|
// descix_doctor — local diagnostic (no backend call)
|
|
154
155
|
// -------------------------------------------------------------------------
|
|
155
156
|
|
|
156
|
-
async function runDoctor(args) {
|
|
157
|
+
async function runDoctor(args, originStamp) {
|
|
157
158
|
const verifyRemote = args?.verify_remote !== false;
|
|
158
159
|
const report = {
|
|
159
160
|
auth: { connected: false },
|
|
@@ -199,7 +200,12 @@ try {
|
|
|
199
200
|
app_id: freshContext.appId,
|
|
200
201
|
community_id: freshContext.communityId,
|
|
201
202
|
kb_id: freshContext.kbId || 'General',
|
|
202
|
-
|
|
203
|
+
// ONE OWNER: `freshConfig.apiUrl` is a legacy top-level key WorkspaceConfig never
|
|
204
|
+
// populates (the real value lives at `env.apiUrl`, read via `resolveOrigin()`) — the
|
|
205
|
+
// `||` here was always falling through to `apiClient.baseUrl`. Read the resolved
|
|
206
|
+
// origin stamp directly rather than keeping a dead alternative that looked like a
|
|
207
|
+
// second source.
|
|
208
|
+
api_url: originStamp?.origin ?? apiClient.baseUrl,
|
|
203
209
|
local_apps: [
|
|
204
210
|
platform.appId,
|
|
205
211
|
...products.map(p => p.appId),
|
|
@@ -361,6 +367,13 @@ try {
|
|
|
361
367
|
report.status = 'warnings';
|
|
362
368
|
}
|
|
363
369
|
|
|
370
|
+
// The same origin stamp every other tool result carries (see mcp-origin-stamp.js) — doctor
|
|
371
|
+
// is the diagnostic surface, so it states the fact explicitly rather than only via the
|
|
372
|
+
// appended content block every tool gets.
|
|
373
|
+
if (originStamp) {
|
|
374
|
+
report.origin_stamp = originStamp;
|
|
375
|
+
}
|
|
376
|
+
|
|
364
377
|
return report;
|
|
365
378
|
}
|
|
366
379
|
|
|
@@ -407,13 +420,35 @@ try {
|
|
|
407
420
|
const { name, arguments: args } = request.params;
|
|
408
421
|
console.error(`[MCP] tools/call — ${name}`);
|
|
409
422
|
|
|
423
|
+
// Resolve the origin stamp ONCE per call, from the one owner (api-client.js's own
|
|
424
|
+
// resolution — the same baseUrl/originSource the CLI's `env:` line reports), and carry it
|
|
425
|
+
// onto EVERY result this call returns, success or error. MEASURED 2026-09-17: two sessions
|
|
426
|
+
// spent hours reading DEV through this connector while believing they were reading PROD
|
|
427
|
+
// because no tool result said where it came from — see mcp-origin-stamp.js.
|
|
428
|
+
let originStamp = null;
|
|
429
|
+
let originStampError = null;
|
|
410
430
|
try {
|
|
431
|
+
await apiClient.ensureInitialized();
|
|
432
|
+
originStamp = originStampFor(apiClient);
|
|
433
|
+
} catch (err) {
|
|
434
|
+
// The origin itself failed to resolve (e.g. a configured-but-invalid origin). There is
|
|
435
|
+
// nothing valid to stamp — the error's own message already names the fix — so this call
|
|
436
|
+
// fails loud below rather than stamping a guess.
|
|
437
|
+
originStampError = err;
|
|
438
|
+
}
|
|
439
|
+
const withStamp = (result) => (originStamp ? stampToolResult(result, originStamp) : result);
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
if (originStampError) {
|
|
443
|
+
throw originStampError;
|
|
444
|
+
}
|
|
445
|
+
|
|
411
446
|
// descix_doctor is handled locally
|
|
412
447
|
if (name === 'descix_doctor') {
|
|
413
|
-
const report = await runDoctor(args);
|
|
414
|
-
return {
|
|
448
|
+
const report = await runDoctor(args, originStamp);
|
|
449
|
+
return withStamp({
|
|
415
450
|
content: [{ type: 'text', text: JSON.stringify(report, null, 2) }],
|
|
416
|
-
};
|
|
451
|
+
});
|
|
417
452
|
}
|
|
418
453
|
|
|
419
454
|
// Merge default context for convenience (caller can override). Only auto-fill when the
|
|
@@ -455,12 +490,12 @@ try {
|
|
|
455
490
|
});
|
|
456
491
|
|
|
457
492
|
const result = await apiClient.invoke(name, params);
|
|
458
|
-
return {
|
|
493
|
+
return withStamp({
|
|
459
494
|
content: [{
|
|
460
495
|
type: 'text',
|
|
461
496
|
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
|
|
462
497
|
}],
|
|
463
|
-
};
|
|
498
|
+
});
|
|
464
499
|
} catch (error) {
|
|
465
500
|
console.error(`[MCP] Tool error: ${error.message}`);
|
|
466
501
|
// WS-HEADLESS-MVP-A3: when the backend attached structured error fields (code +
|
|
@@ -471,10 +506,10 @@ try {
|
|
|
471
506
|
const structured = (error.code || error.data)
|
|
472
507
|
? `\n${JSON.stringify({ code: error.code, data: error.data }, null, 2)}`
|
|
473
508
|
: '';
|
|
474
|
-
return {
|
|
509
|
+
return withStamp({
|
|
475
510
|
content: [{ type: 'text', text: `Error: ${error.message}${structured}` }],
|
|
476
511
|
isError: true,
|
|
477
|
-
};
|
|
512
|
+
});
|
|
478
513
|
}
|
|
479
514
|
});
|
|
480
515
|
|
package/lib/commands/corpus.js
CHANGED
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
isReportedCount, accumulateReportedCount, UNREPORTED_COUNT
|
|
33
33
|
} from '../core/Syncer.js';
|
|
34
34
|
import { lintManifestForDenyClasses, assertNoViolations } from '../core/CorpusDenyLint.js';
|
|
35
|
+
import { pickCanaryChunk, runRetrievalCanary, DEFAULT_CANARY_DELAYS_MS } from '../core/RetrievalCanary.js';
|
|
36
|
+
import { loadSyncState, saveSyncState, syncStatePath, syncFailureLogPath } from '../core/syncState.js';
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* Resolve community_id for corpus sync — same precedence as site upload / update kb:
|
|
@@ -206,35 +208,12 @@ export function renderCount(value, unknownText = 'an unreported number of') {
|
|
|
206
208
|
}
|
|
207
209
|
|
|
208
210
|
/**
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
211
|
+
* Sync state is owned by lib/core/syncState.js and keyed by the ORIGIN the sync talked to.
|
|
212
|
+
* The unscoped local helpers that used to live here are DELETED, not wrapped: they were the
|
|
213
|
+
* defect (one file per KB name, no environment anywhere in it), and a compat path that still
|
|
214
|
+
* resolved `.descix/sync-state/<KB>.json` would keep producing the silent under-sync this
|
|
215
|
+
* module exists to end. See that file's header for the measurement.
|
|
214
216
|
*/
|
|
215
|
-
async function saveSyncState(appRoot, kbName, state) {
|
|
216
|
-
const stateDir = path.join(appRoot, '.descix', 'sync-state');
|
|
217
|
-
await fs.mkdir(stateDir, { recursive: true });
|
|
218
|
-
const statePath = path.join(stateDir, `${kbName}.json`);
|
|
219
|
-
await fs.writeFile(statePath, JSON.stringify(state, null, 2));
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Load sync state from a previous corpus sync.
|
|
224
|
-
*
|
|
225
|
-
* @param {string} appRoot - App root directory
|
|
226
|
-
* @param {string} kbName - Knowledge base name
|
|
227
|
-
* @returns {Promise<Object|null>} Sync state or null if no previous sync
|
|
228
|
-
*/
|
|
229
|
-
async function loadSyncState(appRoot, kbName) {
|
|
230
|
-
const statePath = path.join(appRoot, '.descix', 'sync-state', `${kbName}.json`);
|
|
231
|
-
try {
|
|
232
|
-
const raw = await fs.readFile(statePath, 'utf-8');
|
|
233
|
-
return JSON.parse(raw);
|
|
234
|
-
} catch {
|
|
235
|
-
return null;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
217
|
|
|
239
218
|
/**
|
|
240
219
|
* Detect the current git branch in workspaceRoot.
|
|
@@ -397,6 +376,13 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
397
376
|
|
|
398
377
|
spinner.succeed(`Found ${manifests.length} manifest(s)`);
|
|
399
378
|
|
|
379
|
+
// THE ORIGIN THIS SYNC IS TALKING TO — resolved once, from the client that will actually
|
|
380
|
+
// carry the upserts, and used to key every sync-state read and write below. Taken from the
|
|
381
|
+
// client rather than re-derived from the workspace: a re-derivation could name a different
|
|
382
|
+
// origin than the one receiving the chunks, which is precisely the lie that state file
|
|
383
|
+
// told for months. Awaited because baseUrl is loaded lazily.
|
|
384
|
+
const syncOrigin = await apiClient.ensureBaseUrl();
|
|
385
|
+
|
|
400
386
|
// Deliverable B: branch-mismatch advisory. We only emit this when:
|
|
401
387
|
// - no --ref override was supplied, AND
|
|
402
388
|
// - every loaded manifest's every source uses the default "main", AND
|
|
@@ -467,6 +453,11 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
467
453
|
let totalChunksCreated = 0;
|
|
468
454
|
let totalFilesSkipped = 0;
|
|
469
455
|
let totalFilesDeleted = 0;
|
|
456
|
+
// The retrieval canary's verdict per KB — a sync/rebuild does not get to report a bare
|
|
457
|
+
// success while any of these are unverified (see RetrievalCanary.js). Collected across
|
|
458
|
+
// manifests so ONE loud failure at the end names every KB still unconfirmed, rather than
|
|
459
|
+
// failing fast on the first and leaving later manifests unsynced.
|
|
460
|
+
const canaryResults = [];
|
|
470
461
|
|
|
471
462
|
for (const rawManifest of manifests) {
|
|
472
463
|
// ── Deliverable B: resolve --ref override (CLI > manifest > default) ──
|
|
@@ -643,7 +634,16 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
643
634
|
// Deliverable A note: in dry-run + rebuild we ALSO null previousState so
|
|
644
635
|
// the count below reflects what a real rebuild would upsert (all files),
|
|
645
636
|
// not what an incremental sync would upsert.
|
|
646
|
-
|
|
637
|
+
// State is per-ORIGIN. The blob-SHA skip below is only sound against the origin that
|
|
638
|
+
// actually received those SHAs — see lib/core/syncState.js.
|
|
639
|
+
const loadedState = await loadSyncState(appRoot, kbName, syncOrigin);
|
|
640
|
+
let previousState = loadedState.state;
|
|
641
|
+
if (loadedState.reason === 'unkeyed-legacy' || loadedState.reason === 'origin-mismatch') {
|
|
642
|
+
// LOUD, because the consequence of not saying it is a full re-walk the operator did not
|
|
643
|
+
// ask for, and silence here is what made the original defect invisible for months.
|
|
644
|
+
console.log(chalk.yellow(` ⚠ No sync state for ${syncOrigin} — walking the FULL corpus.`));
|
|
645
|
+
console.log(chalk.gray(` ${loadedState.detail}`));
|
|
646
|
+
}
|
|
647
647
|
if (options.rebuild) {
|
|
648
648
|
previousState = null;
|
|
649
649
|
}
|
|
@@ -651,9 +651,9 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
651
651
|
const previousChunkCount = readPreviousChunkCount(previousState);
|
|
652
652
|
|
|
653
653
|
if (previousBlobShas.size > 0) {
|
|
654
|
-
console.log(chalk.gray(` Previous sync: ${previousBlobShas.size} files, ${previousChunkCount ?? 'an unknown number of'} chunks`));
|
|
654
|
+
console.log(chalk.gray(` Previous sync to ${syncOrigin}: ${previousBlobShas.size} files, ${previousChunkCount ?? 'an unknown number of'} chunks`));
|
|
655
655
|
} else {
|
|
656
|
-
console.log(chalk.gray(
|
|
656
|
+
console.log(chalk.gray(` First sync to ${syncOrigin} (no previous state for this origin)`));
|
|
657
657
|
}
|
|
658
658
|
|
|
659
659
|
// 3c. Compute delta — compare by blob SHA against local sync state
|
|
@@ -816,10 +816,10 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
816
816
|
|
|
817
817
|
// Write failure log to .descix if any batches failed
|
|
818
818
|
if (syncFailures.length > 0) {
|
|
819
|
-
const
|
|
820
|
-
await fs.mkdir(
|
|
821
|
-
const failLogPath = path.join(failLogDir, `${kbName}-failures.json`);
|
|
819
|
+
const failLogPath = syncFailureLogPath(appRoot, kbName, syncOrigin);
|
|
820
|
+
await fs.mkdir(path.dirname(failLogPath), { recursive: true });
|
|
822
821
|
await fs.writeFile(failLogPath, JSON.stringify({
|
|
822
|
+
origin: syncOrigin,
|
|
823
823
|
timestamp: new Date().toISOString(),
|
|
824
824
|
commit: commitSha,
|
|
825
825
|
failures: syncFailures,
|
|
@@ -877,9 +877,52 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
877
877
|
}
|
|
878
878
|
}
|
|
879
879
|
|
|
880
|
+
// 3f-2. RETRIEVAL CANARY (measured 2026-09-17): presence in Pinecone (reconciled above)
|
|
881
|
+
// is NOT searchability — the PROD index can take ~20 minutes to catch up after a
|
|
882
|
+
// purge+re-upsert, during which every status surface still says healthy. A sync does not
|
|
883
|
+
// get to report bare success until a chunk it just wrote actually RETRIEVES. Bounded
|
|
884
|
+
// (RetrievalCanary.js) rather than a loop: query_knowledge_base is credit-metered.
|
|
885
|
+
let retrievalCanary = null;
|
|
886
|
+
if (allChunks.length > 0 && options.skipRetrievalCanary) {
|
|
887
|
+
console.log(chalk.gray(' [canary] Skipped (--skip-retrieval-canary): searchability NOT confirmed.'));
|
|
888
|
+
} else if (allChunks.length > 0) {
|
|
889
|
+
const canaryChunk = pickCanaryChunk(allChunks, syncFailures);
|
|
890
|
+
if (canaryChunk) {
|
|
891
|
+
const canarySpinner = ora(' [canary] Confirming a synced chunk actually retrieves...').start();
|
|
892
|
+
// TEST SEAM ONLY: options._canaryDelaysMs / options._canarySleep let tests exercise
|
|
893
|
+
// this path without real wall-clock waits or a live query_knowledge_base backend.
|
|
894
|
+
// Production callers never set these — runRetrievalCanary defaults to the real
|
|
895
|
+
// bounded backoff and real timers when they are undefined.
|
|
896
|
+
const result = await runRetrievalCanary(apiClient, {
|
|
897
|
+
appId, kbName, chunk: canaryChunk,
|
|
898
|
+
...(options._canaryDelaysMs ? { delaysMs: options._canaryDelaysMs } : {}),
|
|
899
|
+
...(options._canarySleep ? { sleep: options._canarySleep } : {}),
|
|
900
|
+
});
|
|
901
|
+
retrievalCanary = { ...result, checked_at: new Date().toISOString() };
|
|
902
|
+
if (result.ok) {
|
|
903
|
+
canarySpinner.succeed(
|
|
904
|
+
` [canary] Confirmed searchable after ${(result.elapsedMs / 1000).toFixed(1)}s ` +
|
|
905
|
+
`(attempt ${result.attempts}/${DEFAULT_CANARY_DELAYS_MS.length})`
|
|
906
|
+
);
|
|
907
|
+
} else {
|
|
908
|
+
canarySpinner.warn(
|
|
909
|
+
` [canary] NOT YET SEARCHABLE after ${(result.elapsedMs / 1000).toFixed(1)}s and ` +
|
|
910
|
+
`${result.attempts} attempt(s)${result.lastError ? ` (last error: ${result.lastError})` : ''}. ` +
|
|
911
|
+
`The vectors ARE present in Pinecone (reconciled above) but the index has not caught up yet — ` +
|
|
912
|
+
`this is a known ~20-minute propagation delay after a large upsert/purge. ` +
|
|
913
|
+
`Re-run 'descix kb corpus sync -a ${appId} -k ${kbName}' later to re-check ` +
|
|
914
|
+
`(unchanged files are cheap; this canary re-runs).`
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
canaryResults.push({ kbName, ...retrievalCanary });
|
|
918
|
+
} else {
|
|
919
|
+
console.log(chalk.yellow(' [canary] Skipped: every upsert batch failed, nothing new to confirm.'));
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
880
923
|
// 3g. Save sync state with blob SHAs for future delta computation
|
|
881
924
|
const allSyncedBlobShas = [...localBlobShas]; // All current files
|
|
882
|
-
await saveSyncState(appRoot, kbName, {
|
|
925
|
+
await saveSyncState(appRoot, kbName, syncOrigin, {
|
|
883
926
|
last_sync_commit: commitSha,
|
|
884
927
|
// Per-source provenance: WHICH repo, at WHICH ref, and the RESOLVED COMMIT SHA actually
|
|
885
928
|
// synced. `ref` may be a mutable branch, so the ref alone is not a record of what was
|
|
@@ -894,7 +937,10 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
894
937
|
files_unchanged: unchangedCount,
|
|
895
938
|
chunks_upserted: isReportedCount(upserted) ? upserted : null,
|
|
896
939
|
chunks_deleted: isReportedCount(deleted) ? deleted : null,
|
|
897
|
-
synced_blob_shas: allSyncedBlobShas
|
|
940
|
+
synced_blob_shas: allSyncedBlobShas,
|
|
941
|
+
// The retrieval canary's verdict — read this, not `total_chunks`, to know whether the
|
|
942
|
+
// last sync's content was CONFIRMED searchable or only confirmed present.
|
|
943
|
+
retrieval_canary: retrievalCanary
|
|
898
944
|
});
|
|
899
945
|
|
|
900
946
|
totalFilesProcessed += newOrChangedFiles.length;
|
|
@@ -935,6 +981,26 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
935
981
|
const deletedLine = ` Chunks deleted: ${renderCount(totalFilesDeleted)}`;
|
|
936
982
|
console.log(totalFilesDeleted === 0 ? chalk.gray(deletedLine) : chalk.white(deletedLine));
|
|
937
983
|
console.log('');
|
|
984
|
+
|
|
985
|
+
// NEVER A BARE SUCCESS: if any KB's retrieval canary did not confirm searchability, this
|
|
986
|
+
// command fails loud here rather than letting the green summary above stand as the last
|
|
987
|
+
// word. The summary itself is left intact (it is a true report of what was written) —
|
|
988
|
+
// this is a SEPARATE fact ("can it be found yet") that gets its own loud verdict.
|
|
989
|
+
const unverified = canaryResults.filter((r) => !r.ok);
|
|
990
|
+
if (unverified.length > 0) {
|
|
991
|
+
throw new Error(
|
|
992
|
+
`Sync wrote vectors successfully, but ${unverified.length} KB(s) did NOT confirm ` +
|
|
993
|
+
`searchable within the canary window:\n` +
|
|
994
|
+
unverified.map((r) =>
|
|
995
|
+
` - ${r.kbName}: not found after ${(r.elapsedMs / 1000).toFixed(1)}s / ${r.attempts} attempt(s)` +
|
|
996
|
+
(r.lastError ? ` (last error: ${r.lastError})` : '')
|
|
997
|
+
).join('\n') +
|
|
998
|
+
`\nThe vectors ARE present in Pinecone (reconciled above) — this is the store's own ` +
|
|
999
|
+
`~20-minute post-upsert propagation delay, not data loss. Re-run the same sync command ` +
|
|
1000
|
+
`later to re-check; unchanged files make the re-run cheap and it re-runs this canary.`
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
938
1004
|
return { dryRun: false };
|
|
939
1005
|
|
|
940
1006
|
} catch (error) {
|
|
@@ -976,6 +1042,19 @@ export async function runCorpusStatus(apiClient, options) {
|
|
|
976
1042
|
|
|
977
1043
|
const appRoot = appConfig.absolutePath;
|
|
978
1044
|
|
|
1045
|
+
// Sync state is per-ORIGIN, so status cannot report it without knowing which origin is
|
|
1046
|
+
// being asked about. Resolved from the same client the sync would use — never re-derived
|
|
1047
|
+
// here, which would let status describe one origin while sync wrote another.
|
|
1048
|
+
if (!apiClient) {
|
|
1049
|
+
spinner.fail('Origin required');
|
|
1050
|
+
throw new Error(
|
|
1051
|
+
'corpus status needs an API client to know WHICH origin\'s sync state to report. '
|
|
1052
|
+
+ 'Sync state is keyed by origin (see lib/core/syncState.js), so there is no '
|
|
1053
|
+
+ 'origin-independent status to print.',
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
const statusOrigin = await apiClient.ensureBaseUrl();
|
|
1057
|
+
|
|
979
1058
|
// Load manifests
|
|
980
1059
|
const manifests = await loadManifests(appRoot, workspaceRoot, options.kb || null);
|
|
981
1060
|
|
|
@@ -992,11 +1071,20 @@ export async function runCorpusStatus(apiClient, options) {
|
|
|
992
1071
|
const refResolution = resolveRef(rawManifest, options.ref || null);
|
|
993
1072
|
const manifest = refResolution.manifest;
|
|
994
1073
|
const kbName = manifest.kb_name;
|
|
995
|
-
|
|
1074
|
+
// Status reports state for ONE origin — the one this invocation is pointed at. A status
|
|
1075
|
+
// that merged or guessed across origins would be the same unscoped claim the state file
|
|
1076
|
+
// used to make.
|
|
1077
|
+
const loadedState = await loadSyncState(appRoot, kbName, statusOrigin);
|
|
1078
|
+
const syncState = loadedState.state;
|
|
996
1079
|
|
|
997
1080
|
console.log(chalk.cyan(`\nCorpus Status: ${appId} / ${kbName}`));
|
|
998
1081
|
console.log(chalk.gray('─'.repeat(50)));
|
|
1082
|
+
console.log(chalk.white(` Origin: ${statusOrigin}`));
|
|
999
1083
|
console.log(chalk.white(` Resolved ref: ${refResolution.resolvedRef} (source=${refResolution.source})`));
|
|
1084
|
+
if (loadedState.reason === 'unkeyed-legacy' || loadedState.reason === 'origin-mismatch') {
|
|
1085
|
+
console.log(chalk.yellow(` ⚠ No sync state for this origin.`));
|
|
1086
|
+
console.log(chalk.gray(` ${loadedState.detail}`));
|
|
1087
|
+
}
|
|
1000
1088
|
|
|
1001
1089
|
// File count from a fresh walk so the operator sees the LIVE file count
|
|
1002
1090
|
// at the resolved ref, not the count from the last sync. This is the
|
|
@@ -1017,11 +1105,32 @@ export async function runCorpusStatus(apiClient, options) {
|
|
|
1017
1105
|
console.log(chalk.white(` Last sync: ${syncState.timestamp}`));
|
|
1018
1106
|
console.log(chalk.white(` Commit: ${syncState.last_sync_commit?.substring(0, 8) || 'unknown'}`));
|
|
1019
1107
|
console.log(chalk.white(` Files tracked: ${syncState.synced_files_count}`));
|
|
1108
|
+
// NAME THE SOURCE OF THIS NUMBER: this is a LOCAL RECORD of what the store reported
|
|
1109
|
+
// AT THE TIME OF THE LAST SYNC's reconcile — not a fresh read of the store now. It can be
|
|
1110
|
+
// stale (the store's contents can change from other syncs/purges) and, even when fresh,
|
|
1111
|
+
// "present" is not "searchable" (see the Retrieval line below). `descix kb doctor -a
|
|
1112
|
+
// <app> -k <kb> --live` reads Pinecone directly for the current truth.
|
|
1020
1113
|
console.log(chalk.white(
|
|
1021
|
-
` Chunks
|
|
1114
|
+
` Chunks (local record, as of last sync): ${Number.isFinite(syncState.total_chunks)
|
|
1022
1115
|
? syncState.total_chunks
|
|
1023
1116
|
: 'unknown (the last sync reported no live count)'}`
|
|
1024
1117
|
));
|
|
1118
|
+
// Retrieval canary verdict from the last sync (see RetrievalCanary.js) — the only field
|
|
1119
|
+
// in this file that speaks to SEARCHABILITY rather than presence.
|
|
1120
|
+
const canary = syncState.retrieval_canary;
|
|
1121
|
+
if (canary === undefined) {
|
|
1122
|
+
console.log(chalk.gray(' Retrieval: unknown (synced before the retrieval-canary check existed)'));
|
|
1123
|
+
} else if (canary === null) {
|
|
1124
|
+
console.log(chalk.gray(' Retrieval: not checked (last sync upserted nothing new)'));
|
|
1125
|
+
} else if (canary.ok) {
|
|
1126
|
+
console.log(chalk.green(` Retrieval: CONFIRMED searchable (as of last sync, ${canary.checked_at})`));
|
|
1127
|
+
} else {
|
|
1128
|
+
console.log(chalk.yellow(
|
|
1129
|
+
` Retrieval: NOT CONFIRMED as of last sync (${canary.checked_at}) — vectors were ` +
|
|
1130
|
+
`present but did not retrieve within the canary window. Re-run 'descix kb corpus sync ` +
|
|
1131
|
+
`-a ${appId} -k ${kbName}' to re-check.`
|
|
1132
|
+
));
|
|
1133
|
+
}
|
|
1025
1134
|
|
|
1026
1135
|
// Quick change detection: walk and compare blob SHAs
|
|
1027
1136
|
if (options.verbose) {
|
package/lib/commands/kb.js
CHANGED
|
@@ -33,6 +33,7 @@ import * as driveADC from '../google-storage-adc.js';
|
|
|
33
33
|
// commands' next steps are read by a developer as instructions, so they must name the live verb
|
|
34
34
|
// and nothing else.
|
|
35
35
|
import { CANONICAL_KB_SYNC } from './retired-kb-sync.js';
|
|
36
|
+
import { loadSyncState, syncStatePath } from '../core/syncState.js';
|
|
36
37
|
|
|
37
38
|
// ============ Pull Command ============
|
|
38
39
|
|
|
@@ -346,24 +347,24 @@ export async function runKbDoctor(apiClient, options) {
|
|
|
346
347
|
}
|
|
347
348
|
}
|
|
348
349
|
|
|
349
|
-
// (b) Local sync-state total_chunks
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
throw
|
|
350
|
+
// (b) Local sync-state total_chunks, for THIS ORIGIN.
|
|
351
|
+
//
|
|
352
|
+
// The path is resolved by its owner (lib/core/syncState.js), never rebuilt here: doctor
|
|
353
|
+
// compares a LOCAL record against a LIVE store, so reading one origin's state while querying
|
|
354
|
+
// another's store would manufacture the very drift this command exists to detect.
|
|
355
|
+
const doctorOrigin = await apiClient.ensureBaseUrl();
|
|
356
|
+
const statePathForOrigin = syncStatePath(appRoot, kbName, doctorOrigin);
|
|
357
|
+
const loadedState = await loadSyncState(appRoot, kbName, doctorOrigin);
|
|
358
|
+
const syncStateRaw = loadedState.state;
|
|
359
|
+
if (!syncStateRaw) {
|
|
360
|
+
console.log(chalk.yellow(` ⚠ No local sync-state for ${doctorOrigin} at ${statePathForOrigin}`));
|
|
361
|
+
if (loadedState.detail) console.log(chalk.gray(` ${loadedState.detail}`));
|
|
362
|
+
console.log(chalk.gray(` Run 'descix kb corpus sync -a ${appId} -k ${kbName}' against this origin first.`));
|
|
363
|
+
process.exit(2);
|
|
364
|
+
}
|
|
365
|
+
const localChunks = syncStateRaw.total_chunks;
|
|
366
|
+
if (typeof localChunks !== 'number') {
|
|
367
|
+
throw new Error(`sync-state has no total_chunks: ${statePathForOrigin}`);
|
|
367
368
|
}
|
|
368
369
|
|
|
369
370
|
// (c) Report
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RetrievalCanary — a corpus sync does not report success until a KNOWN chunk it just
|
|
3
|
+
* wrote actually RETRIEVES from the store.
|
|
4
|
+
*
|
|
5
|
+
* MEASURED 2026-09-17: `descix kb corpus sync --rebuild` purges and re-upserts, then
|
|
6
|
+
* reports success and a reconciled live count, while the PROD search index takes ~20
|
|
7
|
+
* minutes to catch up. Every status surface said healthy over a KB that returned zero
|
|
8
|
+
* results for hours. The reconcile step (`get_kb_rag_status {reconcile:true}`, driven from
|
|
9
|
+
* `corpus.js::runCorpusSync`) counts by Pinecone ID PREFIX — presence, not searchability —
|
|
10
|
+
* so a clean reconcile is not evidence the index can answer a query yet.
|
|
11
|
+
*
|
|
12
|
+
* THE CANARY: after upserting, pick ONE chunk that was actually upserted (never one from a
|
|
13
|
+
* failed batch), run `query_knowledge_base` scoped to that chunk's own document
|
|
14
|
+
* (`file_filter`) using the chunk's own text as the query, and check the result set for a
|
|
15
|
+
* row whose metadata identifies THIS chunk (blob_sha + chunk_idx — the two fields
|
|
16
|
+
* `corpus.js::chunkCorpusFile` always writes, so this needs no second identity scheme).
|
|
17
|
+
*
|
|
18
|
+
* BOUNDED, NOT A LOOP: `query_knowledge_base` is a real, credit-metered read (WS-HEADLESS-MVP-A3
|
|
19
|
+
* flat debit per call). A canary that polled every few seconds for the full ~20-minute
|
|
20
|
+
* propagation window would multiply that cost by hundreds of calls for a single sync. This
|
|
21
|
+
* canary makes a SMALL, FIXED number of attempts (`DEFAULT_CANARY_DELAYS_MS`, 3 calls across
|
|
22
|
+
* ~25s) — enough to catch the common case where the index catches up quickly — and REFUSES to
|
|
23
|
+
* claim success if it does not, rather than waiting out the full window synchronously inside a
|
|
24
|
+
* CLI command. See corpus.js's hand-back for the case this does not close and the remedy it
|
|
25
|
+
* names when it doesn't (re-run the sync; unchanged blob SHAs make the re-run cheap and it
|
|
26
|
+
* re-runs this same canary).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Fixed, small backoff schedule — see the module doc for why this is bounded, not a loop. */
|
|
30
|
+
export const DEFAULT_CANARY_DELAYS_MS = [3000, 7000, 15000];
|
|
31
|
+
|
|
32
|
+
/** Pinecone metadata budget note in chunkCorpusFile caps chunk text at 35000 bytes; a canary
|
|
33
|
+
* query only needs enough text to be a near-exact self-match, so it is truncated far below that
|
|
34
|
+
* — a long query costs more to embed for no better discrimination.
|
|
35
|
+
*/
|
|
36
|
+
const MAX_CANARY_QUERY_CHARS = 300;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The query text for a canary check: the chunk's own text is guaranteed to retrieve itself
|
|
40
|
+
* with the highest similarity of anything in the index, so no second corpus of "known good"
|
|
41
|
+
* queries is needed.
|
|
42
|
+
*
|
|
43
|
+
* @param {{text: string}} chunk
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function canaryQueryText(chunk) {
|
|
47
|
+
return String(chunk.text || '').trim().slice(0, MAX_CANARY_QUERY_CHARS);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Does this `query_knowledge_base` result row identify the given chunk?
|
|
52
|
+
*
|
|
53
|
+
* Matches on `blob_sha` + `chunk_idx` — the two fields `chunkCorpusFile()` always writes as
|
|
54
|
+
* Pinecone metadata, content-addressed and stable regardless of any server-side text
|
|
55
|
+
* normalization. This is the SAME identity the sync's own delta computation uses, not a
|
|
56
|
+
* second one invented for this check.
|
|
57
|
+
*
|
|
58
|
+
* @param {{metadata?: Record<string, any>}} row - one row of `query_knowledge_base`'s `results`
|
|
59
|
+
* @param {{blob_sha: string, chunk_idx: number}} chunk
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
export function chunkMatchesCanary(row, chunk) {
|
|
63
|
+
const md = row?.metadata || {};
|
|
64
|
+
return md.blob_sha === chunk.blob_sha && md.chunk_idx === chunk.chunk_idx;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pick one upserted chunk to canary-check: the first chunk that was NOT part of a batch the
|
|
69
|
+
* store reported as failed. Returns null when there is nothing safe to check (every batch
|
|
70
|
+
* failed) — the caller's existing batch-failure reporting already covers that case loudly;
|
|
71
|
+
* this module does not re-report it.
|
|
72
|
+
*
|
|
73
|
+
* @param {Array<{id: string}>} allChunks - every chunk this sync attempted to upsert
|
|
74
|
+
* @param {Array<{chunks: string[]}>} syncFailures - failed batches, each naming its chunk ids
|
|
75
|
+
* @returns {object|null}
|
|
76
|
+
*/
|
|
77
|
+
export function pickCanaryChunk(allChunks, syncFailures) {
|
|
78
|
+
const failedIds = new Set((syncFailures || []).flatMap((f) => f.chunks || []));
|
|
79
|
+
return allChunks.find((c) => !failedIds.has(c.id)) || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run the bounded-backoff retrieval canary against a freshly-upserted chunk.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} apiClient - an initialized DeSciXApiClient
|
|
86
|
+
* @param {object} args
|
|
87
|
+
* @param {string} args.appId
|
|
88
|
+
* @param {string} args.kbName
|
|
89
|
+
* @param {object} args.chunk - one entry from `allChunks` (must carry text/file_id/blob_sha/chunk_idx)
|
|
90
|
+
* @param {number[]} [args.delaysMs] - injectable for tests; production default is
|
|
91
|
+
* DEFAULT_CANARY_DELAYS_MS
|
|
92
|
+
* @param {(ms: number) => Promise<void>} [args.sleep] - injectable for tests (real timers by default)
|
|
93
|
+
* @returns {Promise<{ok: boolean, attempts: number, elapsedMs: number, lastError: string|null}>}
|
|
94
|
+
*/
|
|
95
|
+
export async function runRetrievalCanary(apiClient, {
|
|
96
|
+
appId, kbName, chunk,
|
|
97
|
+
delaysMs = DEFAULT_CANARY_DELAYS_MS,
|
|
98
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
99
|
+
}) {
|
|
100
|
+
const startedAt = Date.now();
|
|
101
|
+
let attempts = 0;
|
|
102
|
+
let lastError = null;
|
|
103
|
+
|
|
104
|
+
for (const delay of delaysMs) {
|
|
105
|
+
attempts++;
|
|
106
|
+
await sleep(delay);
|
|
107
|
+
try {
|
|
108
|
+
const resp = await apiClient.invoke('query_knowledge_base', {
|
|
109
|
+
app_id: appId,
|
|
110
|
+
kb_id: kbName,
|
|
111
|
+
query: canaryQueryText(chunk),
|
|
112
|
+
limit: 5,
|
|
113
|
+
file_filter: chunk.file_id,
|
|
114
|
+
}, { allowGuest: false });
|
|
115
|
+
const data = resp?.message || resp;
|
|
116
|
+
const results = data?.results || [];
|
|
117
|
+
if (results.some((row) => chunkMatchesCanary(row, chunk))) {
|
|
118
|
+
return { ok: true, attempts, elapsedMs: Date.now() - startedAt, lastError: null };
|
|
119
|
+
}
|
|
120
|
+
} catch (err) {
|
|
121
|
+
// A transient failure on ONE attempt does not end the canary early — the schedule is
|
|
122
|
+
// already bounded by delaysMs.length, so this is not an unbounded retry-on-error loop.
|
|
123
|
+
lastError = err.message;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { ok: false, attempts, elapsedMs: Date.now() - startedAt, lastError };
|
|
128
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* syncState — THE owner of where a KB's local sync state lives, and which ORIGIN it describes.
|
|
3
|
+
*
|
|
4
|
+
* ── The defect this closes (measured 2026-09-17, two apps) ───────────────────
|
|
5
|
+
* State used to live at `.descix/sync-state/<KB>.json` — one file per KB NAME, with no
|
|
6
|
+
* environment and no origin anywhere in the path or the payload. `corpus sync` skips a file
|
|
7
|
+
* when its blob SHA appears in that state, and nothing asked WHICH ORIGIN had received those
|
|
8
|
+
* SHAs. Syncing one KB to two environments therefore produced two failures from one cause:
|
|
9
|
+
*
|
|
10
|
+
* UNDER-SYNC the second origin skips files it never received, as "unchanged"
|
|
11
|
+
* BLINDING the first origin's drift becomes invisible, because the second origin's
|
|
12
|
+
* commit was written over the shared state — a stale KB reporting healthy
|
|
13
|
+
*
|
|
14
|
+
* Measured: GodsWorld on DEV took 2 of 8 files and then reported PROD clean ("Drift: NONE")
|
|
15
|
+
* over a PROD KB two files behind. Independently, daita/General's one state file described
|
|
16
|
+
* DEV (886 chunks, matching DEV's 886 vectors) while PROD sat on a different corpus (2545
|
|
17
|
+
* vectors, 2026-05-29); a PROD dry-run against it declared 43 files "Unchanged" that PROD had
|
|
18
|
+
* never received at those SHAs.
|
|
19
|
+
*
|
|
20
|
+
* ── The fix ─────────────────────────────────────────────────────────────────
|
|
21
|
+
* State is keyed by the ORIGIN the sync actually talked to — the same fact `lib/origin.js`
|
|
22
|
+
* owns and that every MCP result is stamped with. The origin is used because it is the fact
|
|
23
|
+
* that is ALWAYS available: an env NAME is not (a workspace `env.apiUrl` or a DESCIX_API_URL
|
|
24
|
+
* carries no label), and keying on a name would also miss a repointed workspace — same label,
|
|
25
|
+
* different origin — which is the same lie one level up. The full origin is recorded INSIDE
|
|
26
|
+
* the file as well, so a slug collision is caught rather than trusted.
|
|
27
|
+
*
|
|
28
|
+
* ── Migration: an unkeyed file belongs to NOBODY ─────────────────────────────
|
|
29
|
+
* A legacy `.descix/sync-state/<KB>.json` records a sync to an origin nobody wrote down.
|
|
30
|
+
* Adopting it for whichever origin asks first would hard-code today's accident as tomorrow's
|
|
31
|
+
* truth — exactly the bug, re-created by the fix. So it is reported as UNKNOWN-ORIGIN and
|
|
32
|
+
* forces one full walk, for every origin, and is never read as state and never deleted: it is
|
|
33
|
+
* the only surviving record of what was synced before keying existed.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import path from 'path';
|
|
37
|
+
import fs from 'fs/promises';
|
|
38
|
+
|
|
39
|
+
/** Directory holding every origin's state for an app. */
|
|
40
|
+
export function syncStateRoot(appRoot) {
|
|
41
|
+
return path.join(appRoot, '.descix', 'sync-state');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A filename-safe token for an origin. ONE derivation, used for the directory name; the exact
|
|
46
|
+
* origin is recorded in the file, so this only has to be stable and readable, not injective.
|
|
47
|
+
*
|
|
48
|
+
* `https://dev.descix.net` -> `dev.descix.net`
|
|
49
|
+
* `https://localhost:4000` -> `localhost-4000`
|
|
50
|
+
*
|
|
51
|
+
* @param {string} origin an absolute http(s) origin, already normalized by lib/origin.js
|
|
52
|
+
* @returns {string}
|
|
53
|
+
* @throws {Error} on an empty or non-string origin — a state file keyed by "undefined" is
|
|
54
|
+
* exactly the unscoped file this module exists to abolish, so it must never be writable.
|
|
55
|
+
*/
|
|
56
|
+
export function originKey(origin) {
|
|
57
|
+
if (typeof origin !== 'string' || origin.trim() === '') {
|
|
58
|
+
throw new Error(
|
|
59
|
+
'syncState: refusing to key sync state by an empty origin. The caller must pass the '
|
|
60
|
+
+ 'origin the sync actually talked to (apiClient.baseUrl) — an unkeyed state file is the '
|
|
61
|
+
+ 'defect this module exists to prevent.',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return origin
|
|
65
|
+
.trim()
|
|
66
|
+
.replace(/^https?:\/\//, '')
|
|
67
|
+
.replace(/\/+$/, '')
|
|
68
|
+
.replace(/[^A-Za-z0-9._-]/g, '-');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Where this origin's state for this KB lives. */
|
|
72
|
+
export function syncStatePath(appRoot, kbName, origin) {
|
|
73
|
+
return path.join(syncStateRoot(appRoot), originKey(origin), `${kbName}.json`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Where a failed-batch log for this origin lives. Keyed for the same reason the state is: a
|
|
78
|
+
* failure log named only by KB let a DEV run overwrite the record of which PROD batches failed,
|
|
79
|
+
* so the surviving file described an environment nobody could identify from it.
|
|
80
|
+
*/
|
|
81
|
+
export function syncFailureLogPath(appRoot, kbName, origin) {
|
|
82
|
+
return path.join(syncStateRoot(appRoot), originKey(origin), `${kbName}-failures.json`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Where an unkeyed (pre-origin-keying) file would be. Read only to DETECT it. */
|
|
86
|
+
export function legacySyncStatePath(appRoot, kbName) {
|
|
87
|
+
return path.join(syncStateRoot(appRoot), `${kbName}.json`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Load the state for THIS origin, or report why there is none.
|
|
92
|
+
*
|
|
93
|
+
* @returns {Promise<{state: Object|null, reason: 'loaded'|'first-sync'|'unkeyed-legacy'|'origin-mismatch', detail?: string}>}
|
|
94
|
+
* `state` is null for every reason but 'loaded'. A null state means a FULL walk, which is
|
|
95
|
+
* always safe: it re-upserts rather than purging, and it is self-correcting.
|
|
96
|
+
*/
|
|
97
|
+
export async function loadSyncState(appRoot, kbName, origin) {
|
|
98
|
+
const statePath = syncStatePath(appRoot, kbName, origin);
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(await fs.readFile(statePath, 'utf-8'));
|
|
101
|
+
// Defensive: the directory is a slug, the payload is exact. They can only disagree through
|
|
102
|
+
// a slug collision, and trusting the slug in that case would resurrect the original bug.
|
|
103
|
+
if (parsed.origin && parsed.origin !== origin) {
|
|
104
|
+
return {
|
|
105
|
+
state: null,
|
|
106
|
+
reason: 'origin-mismatch',
|
|
107
|
+
detail: `${statePath} records origin ${parsed.origin}, but this sync is talking to ${origin}. `
|
|
108
|
+
+ 'Treating it as no state and walking the full corpus.',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return { state: parsed, reason: 'loaded' };
|
|
112
|
+
} catch {
|
|
113
|
+
// No state for this origin. Is there an unkeyed file from before keying existed?
|
|
114
|
+
const legacy = legacySyncStatePath(appRoot, kbName);
|
|
115
|
+
try {
|
|
116
|
+
await fs.access(legacy);
|
|
117
|
+
return {
|
|
118
|
+
state: null,
|
|
119
|
+
reason: 'unkeyed-legacy',
|
|
120
|
+
detail: `${legacy} was written before sync state was keyed by origin, so it does not say `
|
|
121
|
+
+ `WHICH environment it describes. It is not being read — this sync walks the full corpus `
|
|
122
|
+
+ `once for ${origin}, and writes its own state at ${statePath}. The old file is left `
|
|
123
|
+
+ `alone as the only record of what came before.`,
|
|
124
|
+
};
|
|
125
|
+
} catch {
|
|
126
|
+
return { state: null, reason: 'first-sync' };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Persist state for THIS origin. The origin is written into the payload as well as the path.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} appRoot
|
|
135
|
+
* @param {string} kbName
|
|
136
|
+
* @param {string} origin
|
|
137
|
+
* @param {Object} state the same shape as before, minus provenance this function adds
|
|
138
|
+
*/
|
|
139
|
+
export async function saveSyncState(appRoot, kbName, origin, state) {
|
|
140
|
+
const statePath = syncStatePath(appRoot, kbName, origin);
|
|
141
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
142
|
+
await fs.writeFile(statePath, JSON.stringify({ origin, ...state }, null, 2));
|
|
143
|
+
return statePath;
|
|
144
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ORIGIN STAMP EVERY MCP TOOL RESULT CARRIES.
|
|
3
|
+
*
|
|
4
|
+
* MEASURED 2026-09-17: two sessions spent hours investigating a knowledge base as broken.
|
|
5
|
+
* Both were reading DEV through the MCP connector while believing they were reading PROD,
|
|
6
|
+
* because tool results said nothing about where they came from. `bin/mcp-server.js` returned
|
|
7
|
+
* every result as a bare `JSON.stringify(result)` — no origin, no environment. The envelope
|
|
8
|
+
* carries `community_id`/`app_id`, which READ like provenance and are not: they say which app,
|
|
9
|
+
* never which deployment of the platform answered.
|
|
10
|
+
*
|
|
11
|
+
* By contrast the CLI prints `env: <name> (<source>) <origin>` on EVERY network-bound command,
|
|
12
|
+
* unprompted (`lib/environment-report.js` `reportEnvironment()`), sourced from the ONE owner of
|
|
13
|
+
* "which origin did this invocation resolve to" (`lib/origin.js` `resolveOrigin()`, consumed by
|
|
14
|
+
* `DeSciXApiClient.initialize()` and kept on the client as `baseUrl` + `originSource`).
|
|
15
|
+
*
|
|
16
|
+
* This module adds NO second derivation. It reads the SAME two facts the CLI already keeps on
|
|
17
|
+
* an initialized `DeSciXApiClient` and combines them with the SAME env-name owner
|
|
18
|
+
* (`environmentNameFor`) the CLI's own status line uses. There is exactly one answer to "what
|
|
19
|
+
* origin, what environment" for a given client, and every surface — the CLI's stderr line, the
|
|
20
|
+
* MCP stamp — reads it from here.
|
|
21
|
+
*/
|
|
22
|
+
import { environmentNameFor } from './environment-report.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the origin stamp for an MCP tool result FROM the one owner: an already-initialized
|
|
26
|
+
* `DeSciXApiClient`'s `baseUrl` + `originSource` (set once, in `initialize()`, by
|
|
27
|
+
* `resolveOrigin()` — see `api-client.js`).
|
|
28
|
+
*
|
|
29
|
+
* @param {import('./api-client.js').DeSciXApiClient} apiClient - must already be initialized
|
|
30
|
+
* (`ensureInitialized()` resolved) so `baseUrl`/`originSource` are populated. Calling this
|
|
31
|
+
* before initialization is a caller defect, not a state to paper over with a guess.
|
|
32
|
+
* @returns {{origin: string, source: string, environment: string}}
|
|
33
|
+
*/
|
|
34
|
+
export function originStampFor(apiClient) {
|
|
35
|
+
const origin = apiClient?.baseUrl;
|
|
36
|
+
const source = apiClient?.originSource;
|
|
37
|
+
if (!origin || !source) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'originStampFor: apiClient has no resolved baseUrl/originSource — ' +
|
|
40
|
+
'call ensureInitialized() before stamping a result.',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return { origin, source, environment: environmentNameFor(origin) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The ONE spelling of the human-visible stamp line. Mirrors `formatEnvLine()`
|
|
48
|
+
* (`environment-report.js`) in shape so a reader who has seen the CLI's own `env:` line
|
|
49
|
+
* recognizes this as the same fact, not a second convention.
|
|
50
|
+
*
|
|
51
|
+
* @param {{origin: string, source: string, environment: string}} stamp
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function formatOriginStampLine({ origin, source, environment }) {
|
|
55
|
+
return `[DeSciX origin] env: ${environment} (${source}) ${origin}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Attach the origin stamp to an MCP `CallToolResult` — success or error alike.
|
|
60
|
+
*
|
|
61
|
+
* TWO PLACES, DELIBERATELY, per the same fact:
|
|
62
|
+
* 1. An appended `content` block (visible text). This is the fix for the actual measured
|
|
63
|
+
* failure: the reader is an LLM/agent consuming the tool's TEXT output, not a program
|
|
64
|
+
* inspecting `_meta` — a stamp only a machine can see would not have caught the two
|
|
65
|
+
* sessions this closes. The ORIGINAL content entries are left untouched and in place, so
|
|
66
|
+
* an existing caller's `JSON.parse(content[0].text)` keeps working exactly as before —
|
|
67
|
+
* this only ever APPENDS a new block, never rewrites or reorders the old one.
|
|
68
|
+
* 2. `_meta.descix_origin` — the MCP-spec extension point (`CallToolResult._meta`, a sibling
|
|
69
|
+
* of `content`, not nested inside it) for a programmatic caller that wants the fact as
|
|
70
|
+
* structured data without re-parsing the human-readable line.
|
|
71
|
+
*
|
|
72
|
+
* @param {{content?: Array<object>, isError?: boolean, [k: string]: any}} result
|
|
73
|
+
* @param {{origin: string, source: string, environment: string}} stamp
|
|
74
|
+
* @returns {object} a NEW result object; `result` is not mutated.
|
|
75
|
+
*/
|
|
76
|
+
export function stampToolResult(result, stamp) {
|
|
77
|
+
const content = Array.isArray(result.content) ? [...result.content] : [];
|
|
78
|
+
content.push({ type: 'text', text: formatOriginStampLine(stamp) });
|
|
79
|
+
return {
|
|
80
|
+
...result,
|
|
81
|
+
content,
|
|
82
|
+
_meta: { ...(result._meta || {}), descix_origin: stamp },
|
|
83
|
+
};
|
|
84
|
+
}
|