@descix/cli 1.0.6 → 1.0.8
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/README.md +1 -1
- package/agent-assets/guides/README.md +1 -1
- package/agent-assets/guides/dynamic-site-pattern.md +17 -30
- package/agent-assets/guides/knowledge-only-pattern.md +11 -15
- package/agent-assets/guides/microservice-pattern.md +12 -14
- package/agent-assets/guides/static-site-pattern.md +12 -15
- package/agent-assets/instructions/app-asset-validation.md +1 -1
- package/agent-assets/instructions/onboarding-flow.md +4 -4
- package/agent-assets/reference/app-creation.md +36 -101
- package/agent-assets/reference/architecture.md +43 -51
- package/agent-assets/reference/local-dev.md +1 -6
- package/agent-assets/reference/mcp-integration.md +21 -23
- package/agent-assets/reference/workspace-config.md +97 -340
- package/bin/descix.js +337 -618
- package/lib/api-client.js +30 -22
- package/lib/command-visibility.js +213 -0
- package/lib/commands/airdrop.js +10 -0
- package/lib/commands/auth.js +16 -2
- package/lib/commands/config.js +13 -31
- package/lib/commands/corpus.js +163 -59
- package/lib/commands/health.js +116 -21
- package/lib/commands/init.js +17 -45
- package/lib/commands/kb.js +7 -0
- package/lib/commands/retired-kb-sync.js +5 -27
- package/lib/commands/status.js +0 -9
- package/lib/core/Chunker.js +1 -1
- package/lib/core/CorpusWalker.js +238 -47
- package/lib/core/ManifestLoader.js +68 -0
- package/lib/core/Syncer.js +147 -46
- package/lib/interactive.js +158 -0
- package/lib/origin.js +3 -3
- package/lib/sdk-assets.js +28 -0
- package/lib/verb-invokes.generated.json +233 -0
- package/lib/wallet-file.js +36 -37
- package/lib/workspace-config.js +266 -199
- package/lib/workspace-identity.js +0 -1
- package/package.json +7 -3
- package/templates/agent-claude.md +1 -3
- package/templates/agent-cline.md +1 -2
- package/templates/agent-copilot.md +1 -2
- package/templates/agent-cursor.md +1 -2
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +1 -1
- package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +2 -3
- package/agent-assets/rules/descix_mcp.template.mdc +0 -243
- package/lib/commands/briefer/errors.js +0 -88
- package/lib/commands/briefer/index.js +0 -319
- package/lib/commands/briefer/sources/canonical-sources.js +0 -129
- package/lib/commands/briefer/sources/entitlements.js +0 -220
- package/lib/commands/briefer/sources/environments.js +0 -205
- package/lib/commands/briefer/sources/identifiers.js +0 -214
- package/lib/commands/briefer/sources/microservice-deploy.js +0 -267
- package/lib/commands/briefer/sources/routing.js +0 -275
- package/lib/commands/briefer/sources/what-is-not.js +0 -223
- package/lib/commands/briefer/stitcher.js +0 -134
- package/lib/commands/briefer/util/source-reader.js +0 -502
- package/lib/commands/mcp.js +0 -273
- package/lib/commands/update.js +0 -581
- package/lib/wizard/setup.js +0 -503
- package/templates/descix_mcp.mdc +0 -122
package/lib/commands/corpus.js
CHANGED
|
@@ -27,7 +27,10 @@ import { WorkspaceConfig } from '../workspace-config.js';
|
|
|
27
27
|
import { loadManifests } from '../core/ManifestLoader.js';
|
|
28
28
|
import { walkCorpus } from '../core/CorpusWalker.js';
|
|
29
29
|
import { chunkFile, categorizeFile } from '../core/Chunker.js';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
upsertChunks, deleteStaleChunks, deleteStaleChunksByFileId, listRemoteFileIds, purgeKbScope,
|
|
32
|
+
isReportedCount, accumulateReportedCount, UNREPORTED_COUNT
|
|
33
|
+
} from '../core/Syncer.js';
|
|
31
34
|
import { lintManifestForDenyClasses, assertNoViolations } from '../core/CorpusDenyLint.js';
|
|
32
35
|
|
|
33
36
|
/**
|
|
@@ -151,6 +154,57 @@ async function chunkCorpusFile(fileEntry, context) {
|
|
|
151
154
|
}).filter(Boolean);
|
|
152
155
|
}
|
|
153
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Read the previously-synced chunk total out of sync state, for REPORTING only.
|
|
159
|
+
*
|
|
160
|
+
* Three states must not be conflated:
|
|
161
|
+
* no sync state at all -> 0, a fact about US (we have never synced), not the store
|
|
162
|
+
* total_chunks is a number -> that number, INCLUDING a legitimate 0
|
|
163
|
+
* total_chunks null/absent -> null: UNKNOWN, and said so. It is never coerced to 0.
|
|
164
|
+
*
|
|
165
|
+
* The file delta is computed from blob SHAs, never from this number, so an unknown
|
|
166
|
+
* previous total blocks nothing. The total is re-established on every clean sync by the
|
|
167
|
+
* store's own live read-back (`liveTotalFromReconcile`), never by arithmetic on it.
|
|
168
|
+
*
|
|
169
|
+
* @param {Object|null} previousState - the loaded sync state, or null on first sync
|
|
170
|
+
* @returns {number|null}
|
|
171
|
+
*/
|
|
172
|
+
export function readPreviousChunkCount(previousState) {
|
|
173
|
+
if (!previousState) return 0;
|
|
174
|
+
return Number.isFinite(previousState.total_chunks) ? previousState.total_chunks : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The chunk total to PERSIST: the store's MEASURED live count from the post-sync
|
|
179
|
+
* reconcile (`get_kb_rag_status {reconcile:true}` → `reconcileAfter`), or null when the
|
|
180
|
+
* store reported none. This is the ONE owner of `total_chunks`.
|
|
181
|
+
*
|
|
182
|
+
* Why not previous + upserted − deleted: the server's `kb_sync_chunks` answers
|
|
183
|
+
* `upserted_count: null` by ruling (Pinecone's upsert API returns no count) and publishes
|
|
184
|
+
* the measured `rag_vector_count` instead. An arithmetic total built on that null was null
|
|
185
|
+
* on every sync, including `--rebuild` (measured 2026-09-16: reconcile read 6882 live and
|
|
186
|
+
* the state still said unknown), so the "re-run with --rebuild" remedy was a loop.
|
|
187
|
+
*
|
|
188
|
+
* @param {Object|null|undefined} rec - the reconcile response's message
|
|
189
|
+
* @returns {number|null}
|
|
190
|
+
*/
|
|
191
|
+
export function liveTotalFromReconcile(rec) {
|
|
192
|
+
return Number.isFinite(rec?.reconcileAfter) ? rec.reconcileAfter : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Render a count for display: the number when the store reported it, otherwise a
|
|
197
|
+
* phrase that says so. Consumes the module's count owner rather than re-testing
|
|
198
|
+
* `>= 0` at each call site.
|
|
199
|
+
*
|
|
200
|
+
* @param {number} value
|
|
201
|
+
* @param {string} unknownText
|
|
202
|
+
* @returns {string}
|
|
203
|
+
*/
|
|
204
|
+
export function renderCount(value, unknownText = 'an unreported number of') {
|
|
205
|
+
return isReportedCount(value) ? String(value) : unknownText;
|
|
206
|
+
}
|
|
207
|
+
|
|
154
208
|
/**
|
|
155
209
|
* Save sync state after a successful corpus sync.
|
|
156
210
|
*
|
|
@@ -268,15 +322,21 @@ function resolveRef(manifest, cliRef) {
|
|
|
268
322
|
* @returns {Promise<boolean>}
|
|
269
323
|
*/
|
|
270
324
|
async function confirmYesNo(prompt) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
});
|
|
325
|
+
// Gated at construction by the ONE owner of "may I prompt". Under a non-TTY this throws a
|
|
326
|
+
// loud NonInteractiveError instead of hanging (open pipe/FIFO) or resolving nothing (/dev/null).
|
|
327
|
+
const { createPromptSession } = await import('../interactive.js');
|
|
328
|
+
const rl = createPromptSession({
|
|
329
|
+
what: 'descix kb corpus',
|
|
330
|
+
destructive: true,
|
|
331
|
+
nonInteractiveForm: ['descix kb corpus ... --yes # skips this confirmation']
|
|
279
332
|
});
|
|
333
|
+
try {
|
|
334
|
+
const answer = await rl.askRaw(`${prompt} [y/N] `);
|
|
335
|
+
const a = (answer || '').trim().toLowerCase();
|
|
336
|
+
return a === 'y' || a === 'yes';
|
|
337
|
+
} finally {
|
|
338
|
+
rl.close();
|
|
339
|
+
}
|
|
280
340
|
}
|
|
281
341
|
|
|
282
342
|
/**
|
|
@@ -392,16 +452,13 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
392
452
|
const missingKbNames = requestedKbNames.filter(name => !registeredKbNames.has(name));
|
|
393
453
|
if (missingKbNames.length > 0) {
|
|
394
454
|
// Hard-fail: every KB we are about to sync into must already be registered in Firestore.
|
|
395
|
-
// No hardcoded fallback. `app init` is the verb that
|
|
396
|
-
// git-mode app (init_git_mode_kb, bin/descix.js:1163). `kb create` is Drive-mode
|
|
397
|
-
// wired (create_skeleton_kb, :2087) and fails here — filed as its own defect.
|
|
455
|
+
// No hardcoded fallback. `app init --kb` is the verb that creates a KB (init_git_mode_kb).
|
|
398
456
|
spinner.fail(`Unregistered KB(s) for app "${appId}"`);
|
|
399
457
|
throw new Error(
|
|
400
458
|
`KnowledgeBase document(s) missing in Firestore for app "${appId}": ${missingKbNames.join(', ')}.\n` +
|
|
401
459
|
`Registered KBs for this app: ${[...registeredKbNames].join(', ') || '(none)'}.\n` +
|
|
402
460
|
`Syncing vectors into Pinecone without a matching KB doc would orphan them (chat queries hard-fail).\n` +
|
|
403
|
-
`Fix: run \`descix app init -a ${appId}
|
|
404
|
-
`(\`descix kb create\` currently routes through create_skeleton_kb and FAILS on a git-mode app.)`
|
|
461
|
+
`Fix: run \`descix app init -a ${appId} --kb <kb_name>\` for each missing KB — it creates the KB and needs no Drive folder.`
|
|
405
462
|
);
|
|
406
463
|
}
|
|
407
464
|
|
|
@@ -455,9 +512,18 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
455
512
|
|
|
456
513
|
// 3a. Walk corpus
|
|
457
514
|
const walkSpinner = ora(' Walking source directories...').start();
|
|
458
|
-
const { files, commitSha, provenance } = await walkCorpus(manifest, workspaceRoot);
|
|
515
|
+
const { files, commitSha, provenance, gateBoundary } = await walkCorpus(manifest, workspaceRoot);
|
|
459
516
|
walkSpinner.succeed(` Found ${files.length} files (commit: ${commitSha.substring(0, 8)})`);
|
|
460
517
|
|
|
518
|
+
// A gate prints its own COVERAGE BOUNDARY with its verdict — on GREEN as well as
|
|
519
|
+
// RED — so the reader of a pass can see where the pass stops without hunting for a
|
|
520
|
+
// report elsewhere. Every file above cleared the source gate.
|
|
521
|
+
console.log(chalk.gray(` [source-gate] PASS — all ${files.length} file(s) tracked by git at their manifest ref.`));
|
|
522
|
+
console.log(chalk.gray(` [source-gate] compares: ${gateBoundary.compares}`));
|
|
523
|
+
console.log(chalk.gray(` [source-gate] catches: ${gateBoundary.catches}`));
|
|
524
|
+
console.log(chalk.gray(` [source-gate] does NOT read: ${gateBoundary.does_not_read}`));
|
|
525
|
+
console.log(chalk.gray(` [source-gate] runs automatically: ${gateBoundary.runs_automatically}`));
|
|
526
|
+
|
|
461
527
|
// ── Deliverable A: --show-walk prints the resolved ref + first 50 files
|
|
462
528
|
// BEFORE any Pinecone read/write so the operator can sanity-check the
|
|
463
529
|
// walk results without trusting downstream output.
|
|
@@ -507,9 +573,13 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
507
573
|
const remote = await listRemoteFileIds(apiClient, appId, kbName);
|
|
508
574
|
rebuildRemoteUniqueCount = remote.unique_count;
|
|
509
575
|
rebuildRemoteTotalChunks = remote.total_chunks;
|
|
576
|
+
// `Pinecone reports N` is an ATTRIBUTION to the store, so it may only carry
|
|
577
|
+
// numbers the store actually reported. The file_ids list is always real
|
|
578
|
+
// (listRemoteFileIds throws rather than fabricating it); the counts may not be.
|
|
510
579
|
rebuildSpinner.succeed(
|
|
511
|
-
` [rebuild] Pinecone
|
|
512
|
-
|
|
580
|
+
` [rebuild] Pinecone enumerated ${remote.file_ids.length} corpus-scheme file_id(s); ` +
|
|
581
|
+
`reports ${renderCount(remote.unique_count)} unique / ` +
|
|
582
|
+
`${renderCount(remote.total_chunks)} enumerable chunk(s) (legacy orphans may exceed this)`
|
|
513
583
|
);
|
|
514
584
|
|
|
515
585
|
if (options.dryRun) {
|
|
@@ -521,9 +591,13 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
521
591
|
));
|
|
522
592
|
} else {
|
|
523
593
|
// Interactive confirmation guard. --yes skips for scripting/CI.
|
|
594
|
+
// A destructive-action prompt must not overstate its own certainty: if the
|
|
595
|
+
// store reported no count, say so rather than printing "≥-1".
|
|
524
596
|
console.log(chalk.yellow(
|
|
525
597
|
`\n About to FULL-SCOPE PURGE every vector in ${communityId}/${appId}/${kbName} ` +
|
|
526
|
-
|
|
598
|
+
(isReportedCount(rebuildRemoteTotalChunks)
|
|
599
|
+
? `(≥${rebuildRemoteTotalChunks} enumerable chunk(s); legacy orphans included), `
|
|
600
|
+
: `(the store reported no chunk count; ALL vectors in scope will go), `) +
|
|
527
601
|
`then re-upsert the full local walk (${files.length} file(s)).`
|
|
528
602
|
));
|
|
529
603
|
|
|
@@ -544,9 +618,9 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
544
618
|
rebuildScopePurged = purgeResult.purged_scope;
|
|
545
619
|
// deleteRAG via filter-based deleteMany returns -1 (count not knowable
|
|
546
620
|
// from the SDK). Surface honestly rather than fabricating a number.
|
|
547
|
-
rebuildPurged = purgeResult.deleted
|
|
621
|
+
rebuildPurged = purgeResult.deleted;
|
|
548
622
|
purgeSpinner.succeed(
|
|
549
|
-
purgeResult.deleted
|
|
623
|
+
isReportedCount(purgeResult.deleted)
|
|
550
624
|
? ` [rebuild] Purged ${purgeResult.deleted} vector(s) (full scope) and reset KB doc rag fields`
|
|
551
625
|
: ` [rebuild] Purged full scope (filter-based delete; SDK reports no count) and reset KB doc rag fields`
|
|
552
626
|
);
|
|
@@ -574,10 +648,10 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
574
648
|
previousState = null;
|
|
575
649
|
}
|
|
576
650
|
const previousBlobShas = new Set(previousState?.synced_blob_shas || []);
|
|
577
|
-
const previousChunkCount = previousState
|
|
651
|
+
const previousChunkCount = readPreviousChunkCount(previousState);
|
|
578
652
|
|
|
579
653
|
if (previousBlobShas.size > 0) {
|
|
580
|
-
console.log(chalk.gray(` Previous sync: ${previousBlobShas.size} files, ${previousChunkCount} chunks`));
|
|
654
|
+
console.log(chalk.gray(` Previous sync: ${previousBlobShas.size} files, ${previousChunkCount ?? 'an unknown number of'} chunks`));
|
|
581
655
|
} else {
|
|
582
656
|
console.log(chalk.gray(' First sync (no previous state)'));
|
|
583
657
|
}
|
|
@@ -640,7 +714,12 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
640
714
|
console.log(chalk.white(` Would upsert: ${allChunks.length} chunks from ${newOrChangedFiles.length} file(s)`));
|
|
641
715
|
console.log(chalk.white(` Would delete (stale-file): ${fileIdsToDelete.length} blob SHA(s)`));
|
|
642
716
|
if (options.rebuild) {
|
|
643
|
-
console.log(chalk.white(
|
|
717
|
+
console.log(chalk.white(
|
|
718
|
+
` Would purge (rebuild): FULL scope ${communityId}/${appId}/${kbName} ` +
|
|
719
|
+
(isReportedCount(rebuildRemoteTotalChunks)
|
|
720
|
+
? `(≥${rebuildRemoteTotalChunks} enumerable chunks; legacy orphans included)`
|
|
721
|
+
: `(the store reported no chunk count; all vectors in scope)`)
|
|
722
|
+
));
|
|
644
723
|
}
|
|
645
724
|
// Track for the summary exit-code computation at the end of the loop.
|
|
646
725
|
totalFilesProcessed += newOrChangedFiles.length;
|
|
@@ -648,7 +727,10 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
648
727
|
totalFilesSkipped += unchangedCount;
|
|
649
728
|
// Full-scope rebuild would purge ≥ the enumerable chunk count. Use it as the
|
|
650
729
|
// would-delete tally so dry-run reports drift whenever a purge would run.
|
|
651
|
-
|
|
730
|
+
// Only add a rebuild figure the store actually reported; an unknown count
|
|
731
|
+
// contributes nothing rather than silently subtracting one from the total.
|
|
732
|
+
totalFilesDeleted += fileIdsToDelete.length +
|
|
733
|
+
((options.rebuild && isReportedCount(rebuildRemoteTotalChunks)) ? rebuildRemoteTotalChunks : 0);
|
|
652
734
|
// Do NOT call upsertChunks, deleteStaleChunksByFileId, deleteStaleChunks,
|
|
653
735
|
// or saveSyncState in dry-run. Continue to next manifest.
|
|
654
736
|
continue;
|
|
@@ -675,7 +757,9 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
675
757
|
while (!success && retries < MAX_RETRIES) {
|
|
676
758
|
try {
|
|
677
759
|
const result = await upsertChunks(apiClient, communityId, appId, kbName, batch);
|
|
678
|
-
|
|
760
|
+
// Unknown-ness survives the running total: one unreportable batch makes
|
|
761
|
+
// the whole figure unstateable rather than quietly summing around it.
|
|
762
|
+
upserted = accumulateReportedCount(upserted, result.upserted);
|
|
679
763
|
success = true;
|
|
680
764
|
syncSpinner.text = ` Upserting chunks... ${i + batch.length}/${allChunks.length}`;
|
|
681
765
|
} catch (err) {
|
|
@@ -715,7 +799,7 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
715
799
|
if (!success) {
|
|
716
800
|
syncSpinner.warn(
|
|
717
801
|
` Batch ${i}-${i + batch.length} did not complete after ${MAX_RETRIES} retries. ` +
|
|
718
|
-
`Synced ${upserted} chunks so far. This run is BOUNDED (it did not hang) — ` +
|
|
802
|
+
`Synced ${renderCount(upserted)} chunks so far. This run is BOUNDED (it did not hang) — ` +
|
|
719
803
|
`re-run 'descix kb corpus sync -a ${appId} -k ${kbName}' to RESUME: id-keyed upserts ` +
|
|
720
804
|
`are idempotent so already-synced batches are cheap and the remainder continues.`
|
|
721
805
|
);
|
|
@@ -728,7 +812,7 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
728
812
|
}
|
|
729
813
|
}
|
|
730
814
|
|
|
731
|
-
syncSpinner.succeed(` Upserted: ${upserted} chunks`);
|
|
815
|
+
syncSpinner.succeed(` Upserted: ${renderCount(upserted)} chunks`);
|
|
732
816
|
|
|
733
817
|
// Write failure log to .descix if any batches failed
|
|
734
818
|
if (syncFailures.length > 0) {
|
|
@@ -752,59 +836,76 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
752
836
|
// (community_id, app_id, knowledgebase_name) — guarantees namespace isolation.
|
|
753
837
|
const result = await deleteStaleChunksByFileId(apiClient, communityId, appId, kbName, fileIdsToDelete);
|
|
754
838
|
deleted = result.deleted;
|
|
755
|
-
deleteSpinner.succeed(
|
|
839
|
+
deleteSpinner.succeed(
|
|
840
|
+
` Purged: ${renderCount(deleted)} chunk(s) across ${fileIdsToDelete.length} stale blob SHA(s)`
|
|
841
|
+
);
|
|
756
842
|
} catch (err) {
|
|
757
|
-
|
|
843
|
+
// The purge ERRORED: how many were removed is unknown. Leaving `deleted` at 0
|
|
844
|
+
// would persist "nothing was deleted" into sync state as though it were a fact.
|
|
845
|
+
deleted = UNREPORTED_COUNT;
|
|
846
|
+
deleteSpinner.warn(` Delete failed (deleted count unknown): ${err.message}`);
|
|
758
847
|
}
|
|
759
848
|
}
|
|
760
849
|
|
|
761
|
-
// 3f.
|
|
762
|
-
const allSyncedBlobShas = [...localBlobShas]; // All current files
|
|
763
|
-
await saveSyncState(appRoot, kbName, {
|
|
764
|
-
last_sync_commit: commitSha,
|
|
765
|
-
// Per-source provenance: WHICH repo, at WHICH ref, and the RESOLVED COMMIT SHA actually
|
|
766
|
-
// synced. `ref` may be a mutable branch, so the ref alone is not a record of what was
|
|
767
|
-
// synced — the resolved sha is, and it stays exact after the branch moves.
|
|
768
|
-
sources_provenance: provenance,
|
|
769
|
-
synced_files_count: files.length,
|
|
770
|
-
total_chunks: previousChunkCount + upserted - deleted,
|
|
771
|
-
timestamp: new Date().toISOString(),
|
|
772
|
-
files_upserted: newOrChangedFiles.length,
|
|
773
|
-
files_unchanged: unchangedCount,
|
|
774
|
-
chunks_upserted: upserted,
|
|
775
|
-
chunks_deleted: deleted,
|
|
776
|
-
synced_blob_shas: allSyncedBlobShas
|
|
777
|
-
});
|
|
778
|
-
|
|
779
|
-
// 3g. AUTO-RECONCILE the cached rag_vector_count to the TRUE live Pinecone
|
|
850
|
+
// 3f. AUTO-RECONCILE the cached rag_vector_count to the TRUE live Pinecone
|
|
780
851
|
// count after a COMPLETED, clean sync (no batch failures). This keeps the
|
|
781
852
|
// doctor's fast cached read truthful after every clean run, so drift only
|
|
782
853
|
// ever appears mid-flight (on interruption). Skipped if any batch failed
|
|
783
854
|
// (the run is partial — reconciling would bake in the partial state and the
|
|
784
855
|
// re-run will reconcile once complete). Non-fatal: a reconcile hiccup must
|
|
785
|
-
// not fail an otherwise-successful sync
|
|
856
|
+
// not fail an otherwise-successful sync — but then the persisted total is
|
|
857
|
+
// null, honestly, because nothing measured it.
|
|
858
|
+
let liveTotal = null;
|
|
786
859
|
if (syncFailures.length === 0) {
|
|
787
860
|
try {
|
|
788
861
|
const recRes = await apiClient.invoke('get_kb_rag_status', {
|
|
789
862
|
app_id: appId, kb_id: kbName, reconcile: true
|
|
790
863
|
}, { allowGuest: false });
|
|
791
864
|
const rec = recRes.message || recRes;
|
|
792
|
-
|
|
865
|
+
liveTotal = liveTotalFromReconcile(rec);
|
|
866
|
+
if (liveTotal !== null) {
|
|
793
867
|
const d = rec.reconcileAfter - rec.reconcileBefore;
|
|
794
868
|
console.log(chalk.gray(
|
|
795
869
|
` ⟳ Reconciled live count: cached ${rec.reconcileBefore} → live ${rec.reconcileAfter}` +
|
|
796
870
|
(d === 0 ? ' (already truthful)' : ` (${d >= 0 ? '+' : ''}${d})`)
|
|
797
871
|
));
|
|
872
|
+
} else {
|
|
873
|
+
console.log(chalk.yellow(' ⚠ Reconcile reported no live count; total_chunks stays unknown'));
|
|
798
874
|
}
|
|
799
875
|
} catch (recErr) {
|
|
800
876
|
console.log(chalk.yellow(` ⚠ Auto-reconcile skipped (non-fatal): ${recErr.message}`));
|
|
801
877
|
}
|
|
802
878
|
}
|
|
803
879
|
|
|
880
|
+
// 3g. Save sync state with blob SHAs for future delta computation
|
|
881
|
+
const allSyncedBlobShas = [...localBlobShas]; // All current files
|
|
882
|
+
await saveSyncState(appRoot, kbName, {
|
|
883
|
+
last_sync_commit: commitSha,
|
|
884
|
+
// Per-source provenance: WHICH repo, at WHICH ref, and the RESOLVED COMMIT SHA actually
|
|
885
|
+
// synced. `ref` may be a mutable branch, so the ref alone is not a record of what was
|
|
886
|
+
// synced — the resolved sha is, and it stays exact after the branch moves.
|
|
887
|
+
sources_provenance: provenance,
|
|
888
|
+
synced_files_count: files.length,
|
|
889
|
+
// The store's measured live count, or null — never a fabricated integer: this file
|
|
890
|
+
// is read by later runs, so a wrong number here outlives the console line.
|
|
891
|
+
total_chunks: liveTotal,
|
|
892
|
+
timestamp: new Date().toISOString(),
|
|
893
|
+
files_upserted: newOrChangedFiles.length,
|
|
894
|
+
files_unchanged: unchangedCount,
|
|
895
|
+
chunks_upserted: isReportedCount(upserted) ? upserted : null,
|
|
896
|
+
chunks_deleted: isReportedCount(deleted) ? deleted : null,
|
|
897
|
+
synced_blob_shas: allSyncedBlobShas
|
|
898
|
+
});
|
|
899
|
+
|
|
804
900
|
totalFilesProcessed += newOrChangedFiles.length;
|
|
805
|
-
totalChunksCreated += upserted;
|
|
806
901
|
totalFilesSkipped += unchangedCount;
|
|
807
|
-
|
|
902
|
+
// A run total is summed by the SAME owner the per-batch total uses (:754), so an
|
|
903
|
+
// unreported leg leaves the total unstateable instead of quietly summing around it.
|
|
904
|
+
// Contributing 0 keeps -1 out of the printed total by fabricating a number in its
|
|
905
|
+
// place — the same untruth, told where nobody can see it.
|
|
906
|
+
totalChunksCreated = accumulateReportedCount(totalChunksCreated, upserted);
|
|
907
|
+
totalFilesDeleted = accumulateReportedCount(totalFilesDeleted, deleted);
|
|
908
|
+
totalFilesDeleted = accumulateReportedCount(totalFilesDeleted, rebuildPurged);
|
|
808
909
|
}
|
|
809
910
|
|
|
810
911
|
// Summary
|
|
@@ -827,13 +928,12 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
827
928
|
|
|
828
929
|
console.log(chalk.green('\nCorpus sync complete:'));
|
|
829
930
|
console.log(chalk.white(` Files synced: ${totalFilesProcessed}`));
|
|
830
|
-
console.log(chalk.white(` Chunks created: ${totalChunksCreated}`));
|
|
931
|
+
console.log(chalk.white(` Chunks created: ${renderCount(totalChunksCreated)}`));
|
|
831
932
|
console.log(chalk.white(` Files unchanged: ${totalFilesSkipped}`));
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
}
|
|
933
|
+
// Only a zero the store actually REPORTED is dimmed as a no-op; an unreported total
|
|
934
|
+
// is stated as unknown in full white, never collapsed into the "nothing happened" line.
|
|
935
|
+
const deletedLine = ` Chunks deleted: ${renderCount(totalFilesDeleted)}`;
|
|
936
|
+
console.log(totalFilesDeleted === 0 ? chalk.gray(deletedLine) : chalk.white(deletedLine));
|
|
837
937
|
console.log('');
|
|
838
938
|
return { dryRun: false };
|
|
839
939
|
|
|
@@ -917,7 +1017,11 @@ export async function runCorpusStatus(apiClient, options) {
|
|
|
917
1017
|
console.log(chalk.white(` Last sync: ${syncState.timestamp}`));
|
|
918
1018
|
console.log(chalk.white(` Commit: ${syncState.last_sync_commit?.substring(0, 8) || 'unknown'}`));
|
|
919
1019
|
console.log(chalk.white(` Files tracked: ${syncState.synced_files_count}`));
|
|
920
|
-
console.log(chalk.white(
|
|
1020
|
+
console.log(chalk.white(
|
|
1021
|
+
` Chunks in store: ${Number.isFinite(syncState.total_chunks)
|
|
1022
|
+
? syncState.total_chunks
|
|
1023
|
+
: 'unknown (the last sync reported no live count)'}`
|
|
1024
|
+
));
|
|
921
1025
|
|
|
922
1026
|
// Quick change detection: walk and compare blob SHAs
|
|
923
1027
|
if (options.verbose) {
|
package/lib/commands/health.js
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* WS-CLI-HEALTH-ENV-PIVOT (2026-05-26):
|
|
5
5
|
* `descix health --env <env>` pivots the probe surface based on env:
|
|
6
|
-
* - dev (default):
|
|
6
|
+
* - dev (default): BOUND (lsof) AND SERVES (a route probe) per service, plus a gateway
|
|
7
|
+
* row from env.gateway.port. A bound port is not a health check: measured
|
|
8
|
+
* 2026-09-16, a port answering 500 to every request reported PASS, and the
|
|
9
|
+
* gateway had no row. A microservice's health path is read from its OWN served
|
|
10
|
+
* manifest (`GET /manifest` → service.healthEndpoint — cloud declares
|
|
11
|
+
* /api/health, powch /health), never assumed.
|
|
7
12
|
* - demo: gcloud probes against demo cloud resources + synthetic HTTPS
|
|
8
13
|
* probes against *.demo.descix.net hosts
|
|
9
14
|
* - prod: gcloud probes against prod cloud resources + synthetic HTTPS
|
|
@@ -28,6 +33,7 @@
|
|
|
28
33
|
import chalk from 'chalk';
|
|
29
34
|
import { exec } from 'child_process';
|
|
30
35
|
import { promisify } from 'util';
|
|
36
|
+
import { localUpstreamOrigin, resolveGatewayPort } from '@descix/app-sdk/dev';
|
|
31
37
|
import https from 'https';
|
|
32
38
|
import http from 'http';
|
|
33
39
|
import { WorkspaceConfig } from '../workspace-config.js';
|
|
@@ -67,17 +73,22 @@ async function defaultExec(cmd) {
|
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
/**
|
|
70
|
-
* Default
|
|
71
|
-
*
|
|
76
|
+
* Default HTTP(S) probe. Resolves with { status, body, error? } — never rejects.
|
|
77
|
+
* `insecure` tolerates the workspace's self-signed dev certs and is set ONLY by the DEV
|
|
78
|
+
* path; cloud probes keep TLS validation. Tests replace this with a mock.
|
|
72
79
|
*/
|
|
73
|
-
function defaultHttpsProbe(url, timeoutMs = 8000) {
|
|
80
|
+
function defaultHttpsProbe(url, timeoutMs = 8000, { insecure = false } = {}) {
|
|
74
81
|
return new Promise((resolve) => {
|
|
75
82
|
try {
|
|
76
83
|
const parsed = new URL(url);
|
|
77
84
|
const client = parsed.protocol === 'http:' ? http : https;
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
const opts = { timeout: timeoutMs };
|
|
86
|
+
if (insecure && parsed.protocol === 'https:') opts.rejectUnauthorized = false;
|
|
87
|
+
const req = client.get(url, opts, (res) => {
|
|
88
|
+
let body = '';
|
|
89
|
+
res.setEncoding('utf8');
|
|
90
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
91
|
+
res.on('end', () => resolve({ status: res.statusCode, body }));
|
|
81
92
|
});
|
|
82
93
|
req.on('error', (err) => resolve({ status: 0, error: err.message }));
|
|
83
94
|
req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });
|
|
@@ -90,29 +101,34 @@ function defaultHttpsProbe(url, timeoutMs = 8000) {
|
|
|
90
101
|
// ─── DEV path (UNCHANGED behavior) ───────────────────────────────────────────
|
|
91
102
|
|
|
92
103
|
/**
|
|
93
|
-
* Build the service registry from workspace.json for DEV
|
|
94
|
-
* Returns an array of { name, appId, port, type,
|
|
104
|
+
* Build the service registry from workspace.json for DEV checks.
|
|
105
|
+
* Returns an array of { name, appId, port, type, origin }. `origin` — scheme included — comes
|
|
106
|
+
* from the ONE owner of a local upstream's origin (@descix/app-sdk localOrigin), never a
|
|
107
|
+
* protocol default re-derived here. The gateway is a row too: it is the process a local platform
|
|
108
|
+
* developer most needs to see, and it used to be structurally invisible to this command.
|
|
95
109
|
*/
|
|
96
110
|
function buildServiceRegistry(wsConfig) {
|
|
97
111
|
const services = [];
|
|
98
112
|
const env = wsConfig.env || {};
|
|
99
113
|
|
|
100
114
|
if (env.platform) {
|
|
115
|
+
const name = env.platform.appId || 'platform';
|
|
101
116
|
if (env.platform.microservice?.port) {
|
|
102
117
|
services.push({
|
|
103
|
-
name
|
|
118
|
+
name,
|
|
104
119
|
appId: env.platform.appId,
|
|
105
120
|
port: env.platform.microservice.port,
|
|
106
121
|
type: 'microservice',
|
|
122
|
+
origin: localUpstreamOrigin(env.platform.microservice, 'env.platform.microservice'),
|
|
107
123
|
});
|
|
108
124
|
}
|
|
109
125
|
if (env.platform.site?.port) {
|
|
110
126
|
services.push({
|
|
111
|
-
name: `${
|
|
127
|
+
name: `${name}-site`,
|
|
112
128
|
appId: env.platform.appId,
|
|
113
129
|
port: env.platform.site.port,
|
|
114
130
|
type: 'site',
|
|
115
|
-
|
|
131
|
+
origin: localUpstreamOrigin(env.platform.site, 'env.platform.site'),
|
|
116
132
|
});
|
|
117
133
|
}
|
|
118
134
|
}
|
|
@@ -124,6 +140,7 @@ function buildServiceRegistry(wsConfig) {
|
|
|
124
140
|
appId: product.appId,
|
|
125
141
|
port: product.microservice.port,
|
|
126
142
|
type: 'microservice',
|
|
143
|
+
origin: localUpstreamOrigin(product.microservice, `env.products[${product.appId}].microservice`),
|
|
127
144
|
});
|
|
128
145
|
}
|
|
129
146
|
if (product.site?.port) {
|
|
@@ -132,14 +149,82 @@ function buildServiceRegistry(wsConfig) {
|
|
|
132
149
|
appId: product.appId,
|
|
133
150
|
port: product.site.port,
|
|
134
151
|
type: 'site',
|
|
135
|
-
|
|
152
|
+
origin: localUpstreamOrigin(product.site, `env.products[${product.appId}].site`),
|
|
136
153
|
});
|
|
137
154
|
}
|
|
138
155
|
}
|
|
139
156
|
|
|
157
|
+
// The gateway — port from its ONE owner (resolveGatewayPort: --port > env.gateway.port >
|
|
158
|
+
// the SDK default), origin from the same owner as every other row.
|
|
159
|
+
const { port: gatewayPort } = resolveGatewayPort(wsConfig);
|
|
160
|
+
services.push({
|
|
161
|
+
name: 'gateway',
|
|
162
|
+
appId: null,
|
|
163
|
+
port: gatewayPort,
|
|
164
|
+
type: 'gateway',
|
|
165
|
+
origin: localUpstreamOrigin({ port: gatewayPort }, 'env.gateway'),
|
|
166
|
+
});
|
|
167
|
+
|
|
140
168
|
return services;
|
|
141
169
|
}
|
|
142
170
|
|
|
171
|
+
/** What a DEV run reads, and what it does not — printed on green AND red, and carried in JSON. */
|
|
172
|
+
const DEV_PROBE_BOUNDARY =
|
|
173
|
+
'Probes: bound = lsof on the port; serves = microservices: GET /manifest then GET its ' +
|
|
174
|
+
'service.healthEndpoint (2xx); sites: GET / (2xx); gateway: GET /__descix/app-binding.json ' +
|
|
175
|
+
'(a JSON binding, not HTML). Not probed: authentication, any command, credits, the mesh ' +
|
|
176
|
+
'registration, or the deployed cloud — use --env demo|prod for that.';
|
|
177
|
+
|
|
178
|
+
/** A JSON body, or null when it is not one (a vite dev server answers 200 HTML for any path). */
|
|
179
|
+
function parseJsonBody(body) {
|
|
180
|
+
try {
|
|
181
|
+
const v = JSON.parse(body);
|
|
182
|
+
return v && typeof v === 'object' ? v : null;
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const is2xx = (status) => typeof status === 'number' && status >= 200 && status < 300;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* DEV mode: does the bound process actually SERVE? Resolves { serves, probe } where `probe` says
|
|
192
|
+
* exactly what was asked and what came back, so a FAIL line names its own reason.
|
|
193
|
+
*/
|
|
194
|
+
async function checkServesDev(svc, httpsProbeFn) {
|
|
195
|
+
const opts = { insecure: true };
|
|
196
|
+
try {
|
|
197
|
+
if (svc.type === 'microservice') {
|
|
198
|
+
const m = await httpsProbeFn(`${svc.origin}/manifest`, 8000, opts);
|
|
199
|
+
if (!is2xx(m.status)) {
|
|
200
|
+
return { serves: false, probe: `GET /manifest -> ${m.status || m.error || 'unreachable'}` };
|
|
201
|
+
}
|
|
202
|
+
const manifest = parseJsonBody(m.body);
|
|
203
|
+
const healthEndpoint = manifest?.service?.healthEndpoint;
|
|
204
|
+
if (typeof healthEndpoint !== 'string' || healthEndpoint === '') {
|
|
205
|
+
return { serves: false, probe: 'GET /manifest -> 200 but it names no service.healthEndpoint' };
|
|
206
|
+
}
|
|
207
|
+
const h = await httpsProbeFn(`${svc.origin}${healthEndpoint}`, 8000, opts);
|
|
208
|
+
return { serves: is2xx(h.status), probe: `GET ${healthEndpoint} -> ${h.status || h.error || 'unreachable'}` };
|
|
209
|
+
}
|
|
210
|
+
if (svc.type === 'gateway') {
|
|
211
|
+
const b = await httpsProbeFn(`${svc.origin}/__descix/app-binding.json`, 8000, opts);
|
|
212
|
+
const binding = is2xx(b.status) ? parseJsonBody(b.body) : null;
|
|
213
|
+
const isBinding = !!binding && typeof binding.mode === 'string';
|
|
214
|
+
return {
|
|
215
|
+
serves: isBinding,
|
|
216
|
+
probe: `GET /__descix/app-binding.json -> ${b.status || b.error || 'unreachable'}` +
|
|
217
|
+
(is2xx(b.status) && !isBinding ? ' (not a binding: something else is on this port)' : ''),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
// site
|
|
221
|
+
const s = await httpsProbeFn(`${svc.origin}/`, 8000, opts);
|
|
222
|
+
return { serves: is2xx(s.status) && !!s.body, probe: `GET / -> ${s.status || s.error || 'unreachable'}` };
|
|
223
|
+
} catch (err) {
|
|
224
|
+
return { serves: false, probe: `probe threw: ${err.message}` };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
143
228
|
/**
|
|
144
229
|
* DEV mode: check if a local port is listening (lsof).
|
|
145
230
|
*/
|
|
@@ -402,16 +487,16 @@ export async function runHealth(options = {}) {
|
|
|
402
487
|
}
|
|
403
488
|
|
|
404
489
|
if (env === 'dev') {
|
|
405
|
-
return runHealthDev(wsConfig, filterName, jsonOutput, execFn);
|
|
490
|
+
return runHealthDev(wsConfig, filterName, jsonOutput, execFn, httpsProbeFn);
|
|
406
491
|
}
|
|
407
492
|
|
|
408
493
|
return runHealthCloud(env, wsConfig, filterName, jsonOutput, execFn, httpsProbeFn);
|
|
409
494
|
}
|
|
410
495
|
|
|
411
496
|
/**
|
|
412
|
-
* DEV path —
|
|
497
|
+
* DEV path — bound AND serves, per service, plus the gateway.
|
|
413
498
|
*/
|
|
414
|
-
async function runHealthDev(wsConfig, filterName, jsonOutput, execFn) {
|
|
499
|
+
async function runHealthDev(wsConfig, filterName, jsonOutput, execFn, httpsProbeFn) {
|
|
415
500
|
let services = buildServiceRegistry(wsConfig);
|
|
416
501
|
|
|
417
502
|
if (filterName) {
|
|
@@ -432,13 +517,20 @@ async function runHealthDev(wsConfig, filterName, jsonOutput, execFn) {
|
|
|
432
517
|
const results = [];
|
|
433
518
|
const startTime = Date.now();
|
|
434
519
|
for (const svc of services) {
|
|
435
|
-
const
|
|
520
|
+
const bound = (await checkPortDev(svc.port, execFn)).healthy;
|
|
521
|
+
const { serves, probe } = bound
|
|
522
|
+
? await checkServesDev(svc, httpsProbeFn)
|
|
523
|
+
: { serves: false, probe: 'not bound' };
|
|
436
524
|
results.push({
|
|
437
525
|
service: svc.name,
|
|
438
526
|
appId: svc.appId,
|
|
439
527
|
type: svc.type,
|
|
440
528
|
port: svc.port,
|
|
441
|
-
|
|
529
|
+
bound,
|
|
530
|
+
serves,
|
|
531
|
+
serves_probe: probe,
|
|
532
|
+
healthy: bound && serves,
|
|
533
|
+
method: 'port+route',
|
|
442
534
|
});
|
|
443
535
|
}
|
|
444
536
|
const totalTime = Date.now() - startTime;
|
|
@@ -447,7 +539,8 @@ async function runHealthDev(wsConfig, filterName, jsonOutput, execFn) {
|
|
|
447
539
|
environment: 'dev',
|
|
448
540
|
timestamp: new Date().toISOString(),
|
|
449
541
|
check_duration_ms: totalTime,
|
|
450
|
-
probe_surface: 'local-port',
|
|
542
|
+
probe_surface: 'local-port+route',
|
|
543
|
+
probe_boundary: DEV_PROBE_BOUNDARY,
|
|
451
544
|
services: results,
|
|
452
545
|
all_healthy: results.every(r => r.healthy),
|
|
453
546
|
summary: {
|
|
@@ -466,10 +559,12 @@ async function runHealthDev(wsConfig, filterName, jsonOutput, execFn) {
|
|
|
466
559
|
console.log(chalk.cyan('='.repeat(50)));
|
|
467
560
|
console.log(chalk.gray(` Timestamp: ${output.timestamp}`));
|
|
468
561
|
console.log(chalk.gray(` Check duration: ${output.check_duration_ms}ms`));
|
|
469
|
-
console.log(chalk.gray(` Probe surface: local-port
|
|
562
|
+
console.log(chalk.gray(` Probe surface: local-port+route`));
|
|
563
|
+
console.log(chalk.gray(` ${DEV_PROBE_BOUNDARY}\n`));
|
|
470
564
|
|
|
471
565
|
for (const r of results) {
|
|
472
|
-
const
|
|
566
|
+
const yn = (v) => (v ? 'yes' : 'no');
|
|
567
|
+
const probeDetail = `${r.type}, port:${r.port}, bound:${yn(r.bound)}, serves:${yn(r.serves)}, ${r.serves_probe}`;
|
|
473
568
|
console.log(formatProbeLine(r.service, 'dev', r.healthy, probeDetail));
|
|
474
569
|
}
|
|
475
570
|
|