@descix/cli 1.0.1 → 1.0.4
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/LICENSE.md +671 -0
- package/README.md +43 -4
- package/agent-assets/guides/README.md +2 -2
- package/agent-assets/guides/dynamic-site-pattern.md +29 -14
- package/agent-assets/guides/knowledge-only-pattern.md +5 -5
- package/agent-assets/guides/microservice-pattern.md +48 -4
- package/agent-assets/guides/static-site-pattern.md +8 -0
- package/agent-assets/instructions/app-asset-validation.md +5 -5
- package/agent-assets/instructions/onboarding-flow.md +4 -4
- package/agent-assets/instructions/standard-folder-structure.md +20 -11
- package/agent-assets/reference/app-creation.md +42 -20
- package/agent-assets/reference/architecture.md +24 -22
- package/agent-assets/reference/local-dev.md +230 -67
- package/agent-assets/reference/mcp-integration.md +21 -15
- package/agent-assets/reference/platform-api-consumer.md +244 -0
- package/agent-assets/reference/workspace-config.md +7 -7
- package/agent-assets/rules/descix_mcp.template.mdc +8 -8
- package/bin/descix.js +2559 -1435
- package/bin/kb-deny-lint.mjs +91 -0
- package/bin/mcp-server.js +175 -91
- package/lib/agent-files.js +37 -25
- package/lib/api-client.js +223 -56
- package/lib/auth-guard.js +13 -3
- package/lib/chat-session.js +187 -0
- package/lib/cli-version.js +59 -0
- package/lib/commands/airdrop.js +251 -0
- package/lib/commands/auth.js +254 -72
- package/lib/commands/briefer/errors.js +88 -0
- package/lib/commands/briefer/index.js +319 -0
- package/lib/commands/briefer/sources/canonical-sources.js +129 -0
- package/lib/commands/briefer/sources/entitlements.js +220 -0
- package/lib/commands/briefer/sources/environments.js +205 -0
- package/lib/commands/briefer/sources/identifiers.js +214 -0
- package/lib/commands/briefer/sources/microservice-deploy.js +267 -0
- package/lib/commands/briefer/sources/routing.js +275 -0
- package/lib/commands/briefer/sources/what-is-not.js +223 -0
- package/lib/commands/briefer/stitcher.js +134 -0
- package/lib/commands/briefer/util/source-reader.js +502 -0
- package/lib/commands/config.js +210 -90
- package/lib/commands/corpus.js +978 -0
- package/lib/commands/credits.js +122 -0
- package/lib/commands/health.js +627 -0
- package/lib/commands/kb-list-render.js +32 -0
- package/lib/commands/kb.js +190 -413
- package/lib/commands/mcp.js +5 -5
- package/lib/commands/microservice-restart.js +359 -0
- package/lib/commands/model-config.js +313 -0
- package/lib/commands/retired-kb-sync.js +134 -0
- package/lib/commands/serve.js +26 -1
- package/lib/commands/status.js +23 -51
- package/lib/commands/update.js +36 -107
- package/lib/core/Chunker.js +491 -17
- package/lib/core/CorpusDenyLint.js +326 -0
- package/lib/core/CorpusWalker.js +357 -0
- package/lib/core/Hydrator.js +75 -29
- package/lib/core/ManifestLoader.js +533 -0
- package/lib/core/SiteWalker.js +218 -0
- package/lib/core/Syncer.js +163 -11
- package/lib/diagnostic-log.js +41 -0
- package/lib/environment-report.js +100 -0
- package/lib/global-config.js +4 -1
- package/lib/oauth-client.js +164 -0
- package/lib/origin.js +143 -0
- package/lib/service-api-client.js +95 -0
- package/lib/wallet-environment.js +49 -0
- package/lib/wallet-file.js +16 -2
- package/lib/wizard/setup.js +10 -9
- package/lib/workspace-config.js +380 -235
- package/lib/workspace-identity.js +92 -0
- package/lib/workspace-utils.js +41 -61
- package/package.json +19 -6
- package/templates/agent-claude.md +157 -9
- package/templates/agent-cline.md +68 -8
- package/templates/agent-copilot.md +68 -8
- package/templates/agent-cursor.md +68 -8
- package/templates/alignment-schema.json +20 -0
- package/templates/cloud-brain-hook.sh +20 -0
- package/templates/drive/README.md +1 -1
- package/templates/drive_templates/README.md +2 -2
- package/templates/scaffolds/README.md +6 -10
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +104 -149
- package/templates/scaffolds/microservice/app.js +51 -10
- package/templates/scaffolds/microservice/app.yaml +2 -2
- package/templates/scaffolds/microservice/defaults-config-dev.json +4 -0
- package/templates/scaffolds/microservice/dev-overrides.example.json +5 -0
- package/templates/scaffolds/microservice/manifest.json +0 -1
- package/templates/scaffolds/microservice/package.json +3 -3
- package/templates/scaffolds/microservice/scripts/register.js +56 -41
- package/templates/scaffolds/microservice/services/mcpClient.js +55 -65
- package/templates/scaffolds/microservice/services/utils.js +20 -238
- package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +24 -0
- package/lib/commands/app-wizard.js +0 -627
- package/lib/commands/folder.js +0 -291
- package/lib/core/PathContext.js +0 -335
- package/templates/scaffolds/microservice/dev-overrides.json.example +0 -5
- package/templates/scaffolds/site/DeSciXAppSDK.js +0 -62
- package/templates/scaffolds/site/README.md +0 -51
- package/templates/scaffolds/site/app.js +0 -22
- package/templates/scaffolds/site/index.html +0 -42
- package/templates/scaffolds/site/styles.css +0 -123
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Corpus Commands - Git-aware RAG corpus sync
|
|
3
|
+
*
|
|
4
|
+
* Commands for syncing git-managed source files to Pinecone RAG via manifests.
|
|
5
|
+
* Manifests declare a *view* over files — files stay in place, git does versioning.
|
|
6
|
+
*
|
|
7
|
+
* Commands:
|
|
8
|
+
* - corpus sync: Walk manifest sources, chunk changed files, sync to Pinecone
|
|
9
|
+
* - corpus status: Show sync state (files, chunks, last sync)
|
|
10
|
+
*
|
|
11
|
+
* Architecture:
|
|
12
|
+
* - ManifestLoader reads {app_root}/.descix/manifests/{kb_name}.json
|
|
13
|
+
* - CorpusWalker walks dirs, applies syncignore, computes git blob SHAs
|
|
14
|
+
* - Chunker (existing) chunks files by type (code, docs, generic)
|
|
15
|
+
* - Syncer (existing) pushes chunks to Pinecone via kb_sync_chunks API
|
|
16
|
+
* - Chunk IDs are content-addressed: {app_id}:{kb_name}:{blob_sha}:{chunk_idx}
|
|
17
|
+
* - CEO-D12: Single Pinecone namespace, metadata filtering (kb_name: "Corpus")
|
|
18
|
+
* - CEO-D16: Manual sync trigger only — no CI hooks
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import chalk from 'chalk';
|
|
22
|
+
import ora from 'ora';
|
|
23
|
+
import * as fs from 'fs/promises';
|
|
24
|
+
import * as path from 'path';
|
|
25
|
+
import { createRequire } from 'module';
|
|
26
|
+
import { WorkspaceConfig } from '../workspace-config.js';
|
|
27
|
+
import { loadManifests } from '../core/ManifestLoader.js';
|
|
28
|
+
import { walkCorpus } from '../core/CorpusWalker.js';
|
|
29
|
+
import { chunkFile, categorizeFile } from '../core/Chunker.js';
|
|
30
|
+
import { upsertChunks, deleteStaleChunks, deleteStaleChunksByFileId, listRemoteFileIds, purgeKbScope } from '../core/Syncer.js';
|
|
31
|
+
import { lintManifestForDenyClasses, assertNoViolations } from '../core/CorpusDenyLint.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve community_id for corpus sync — same precedence as site upload / update kb:
|
|
35
|
+
* CLI -c > workspace.json > Products registry (get_product_context). No hardcoded defaults.
|
|
36
|
+
*/
|
|
37
|
+
async function resolveCorpusCommunityId(apiClient, workspaceConfig, appId, options) {
|
|
38
|
+
const ctx = workspaceConfig.resolveContextWithOptions(options);
|
|
39
|
+
const appConfig = workspaceConfig.getAppByAppId(appId);
|
|
40
|
+
let communityId = ctx.communityId || appConfig?.communityId || null;
|
|
41
|
+
|
|
42
|
+
if (!communityId) {
|
|
43
|
+
const productCtx = await apiClient.invoke('get_product_context', { app_id: appId });
|
|
44
|
+
communityId = productCtx?.community_id ?? productCtx?.message?.community_id ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!communityId) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Could not resolve community_id for app "${appId}".\n` +
|
|
50
|
+
`Pass -c <community_id> or ensure Products/${appId} is registered (bootstrap / descix app init).`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return communityId;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read a file and chunk it using the existing Chunker.
|
|
59
|
+
* Returns chunk records with corpus-specific metadata and content-addressed IDs.
|
|
60
|
+
*
|
|
61
|
+
* Chunk ID format: {app_id}:{kb_name}:{blob_sha}:{chunk_idx}
|
|
62
|
+
* This is content-addressed — same file content = same blob SHA = same chunk IDs.
|
|
63
|
+
*
|
|
64
|
+
* @param {Object} fileEntry - From CorpusWalker { absolute_path, relative_path, blob_sha, source_entry, source_repo }
|
|
65
|
+
* @param {Object} context - { appId, communityId, kbName }
|
|
66
|
+
* @returns {Promise<Array>} Pinecone-ready chunk records
|
|
67
|
+
*/
|
|
68
|
+
async function chunkCorpusFile(fileEntry, context) {
|
|
69
|
+
const { appId, communityId, kbName } = context;
|
|
70
|
+
const { absolute_path, relative_path, blob_sha, source_entry, source_repo } = fileEntry;
|
|
71
|
+
|
|
72
|
+
// Read file content
|
|
73
|
+
const content = await fs.readFile(absolute_path, 'utf-8');
|
|
74
|
+
const fileName = path.basename(absolute_path);
|
|
75
|
+
|
|
76
|
+
// Determine MIME type from extension
|
|
77
|
+
let mimeType = 'text/plain';
|
|
78
|
+
if (fileName.endsWith('.md')) mimeType = 'text/markdown';
|
|
79
|
+
else if (fileName.endsWith('.js') || fileName.endsWith('.mjs')) mimeType = 'application/javascript';
|
|
80
|
+
else if (fileName.endsWith('.json')) mimeType = 'application/json';
|
|
81
|
+
else if (fileName.endsWith('.jsonl')) mimeType = 'application/jsonl';
|
|
82
|
+
|
|
83
|
+
// Skip empty files — they produce chunks with no text which fails validation
|
|
84
|
+
if (!content || content.trim().length === 0) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// M1 (2026-04-20): the 500KB silent-drop cap was removed after it was found
|
|
89
|
+
// to be the proximate cause of 77% of EGPT's concordance content missing from
|
|
90
|
+
// Pinecone. The Chunker now handles large files via content-aware splitting
|
|
91
|
+
// (structured-json / JS object-literal extraction / line-aware sliding window).
|
|
92
|
+
// A generous sanity cap protects against accidentally ingesting minified
|
|
93
|
+
// bundles or binary blobs that slipped past the manifest walker.
|
|
94
|
+
const MAX_SRC_BYTES = 20 * 1024 * 1024; // 20MB: anything bigger should be excluded in the manifest
|
|
95
|
+
if (content.length > MAX_SRC_BYTES) {
|
|
96
|
+
console.warn(` ⚠ Skipping ${fileName}: ${content.length} bytes exceeds ${MAX_SRC_BYTES} sanity cap. Exclude via manifest syncignore.`);
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Use existing Chunker
|
|
101
|
+
const rawChunks = chunkFile({ file_name: fileName, mime_type: mimeType, content });
|
|
102
|
+
|
|
103
|
+
// Pinecone metadata limit is 40KB per vector.
|
|
104
|
+
// text field is stored as metadata. Keep each chunk under 35KB text to leave room.
|
|
105
|
+
const MAX_CHUNK_TEXT_BYTES = 35000;
|
|
106
|
+
|
|
107
|
+
// Convert to Pinecone-ready records with corpus-specific metadata
|
|
108
|
+
// Filter out any chunks with empty text (safety net for edge cases)
|
|
109
|
+
return rawChunks.map((chunk, idx) => {
|
|
110
|
+
const chunkIdx = chunk.metadata.chunkIndex ?? idx;
|
|
111
|
+
// Content-addressed ID: same blob SHA = same IDs
|
|
112
|
+
const id = `${appId}:${kbName}:${blob_sha}:${chunkIdx}`;
|
|
113
|
+
|
|
114
|
+
let text = chunk.content.trim();
|
|
115
|
+
// Truncate if text exceeds Pinecone metadata budget
|
|
116
|
+
if (Buffer.byteLength(text, 'utf-8') > MAX_CHUNK_TEXT_BYTES) {
|
|
117
|
+
text = text.substring(0, MAX_CHUNK_TEXT_BYTES);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!text) return null; // Skip empty chunks
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
id,
|
|
124
|
+
text,
|
|
125
|
+
entity_type: 'CHUNK',
|
|
126
|
+
// Multi-tenancy fields (required for filtering)
|
|
127
|
+
community_id: communityId,
|
|
128
|
+
app_id: appId,
|
|
129
|
+
knowledgebase_name: kbName,
|
|
130
|
+
// file_id required by pineconeService validator — use blob_sha as content-addressed ID
|
|
131
|
+
file_id: `corpus:${blob_sha}`,
|
|
132
|
+
// Corpus-specific metadata
|
|
133
|
+
blob_sha,
|
|
134
|
+
source_path: relative_path,
|
|
135
|
+
source_repo,
|
|
136
|
+
tier: source_entry.tier,
|
|
137
|
+
doc_type: source_entry.doc_type,
|
|
138
|
+
// KB-curation metadata — rides into Pinecone so query surfaces can
|
|
139
|
+
// default-filter to publishable doc_classes and surface provenance.
|
|
140
|
+
doc_class: source_entry.doc_class,
|
|
141
|
+
license_basis: source_entry.license_basis,
|
|
142
|
+
synced_from_edit: source_entry.synced_from_edit,
|
|
143
|
+
// Chunk metadata
|
|
144
|
+
file_name: fileName,
|
|
145
|
+
mime_type: mimeType,
|
|
146
|
+
chunk_idx: chunkIdx,
|
|
147
|
+
total_chunks: rawChunks.length,
|
|
148
|
+
chunk_type: chunk.metadata.type,
|
|
149
|
+
chunk_title: chunk.metadata.title || chunk.metadata.name || null
|
|
150
|
+
};
|
|
151
|
+
}).filter(Boolean);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Save sync state after a successful corpus sync.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} appRoot - App root directory
|
|
158
|
+
* @param {string} kbName - Knowledge base name
|
|
159
|
+
* @param {Object} state - { last_sync_commit, sources_provenance, synced_files_count, total_chunks, timestamp }
|
|
160
|
+
*/
|
|
161
|
+
async function saveSyncState(appRoot, kbName, state) {
|
|
162
|
+
const stateDir = path.join(appRoot, '.descix', 'sync-state');
|
|
163
|
+
await fs.mkdir(stateDir, { recursive: true });
|
|
164
|
+
const statePath = path.join(stateDir, `${kbName}.json`);
|
|
165
|
+
await fs.writeFile(statePath, JSON.stringify(state, null, 2));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Load sync state from a previous corpus sync.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} appRoot - App root directory
|
|
172
|
+
* @param {string} kbName - Knowledge base name
|
|
173
|
+
* @returns {Promise<Object|null>} Sync state or null if no previous sync
|
|
174
|
+
*/
|
|
175
|
+
async function loadSyncState(appRoot, kbName) {
|
|
176
|
+
const statePath = path.join(appRoot, '.descix', 'sync-state', `${kbName}.json`);
|
|
177
|
+
try {
|
|
178
|
+
const raw = await fs.readFile(statePath, 'utf-8');
|
|
179
|
+
return JSON.parse(raw);
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Detect the current git branch in workspaceRoot.
|
|
187
|
+
* Returns the branch name (e.g., "ws-admin-b1") or null if detached / not a repo.
|
|
188
|
+
*
|
|
189
|
+
* Used by `corpus sync` to emit a one-line advisory when the operator is on a
|
|
190
|
+
* non-main branch and --ref was not provided — flags the silent "synced main
|
|
191
|
+
* not your branch" gotcha that surfaced in WS-CLI-MANIFEST-REF-FEATURE-BRANCH.
|
|
192
|
+
*/
|
|
193
|
+
function getCurrentBranch(workspaceRoot) {
|
|
194
|
+
// Lazy-require child_process via createRequire — corpus.js is ESM and lacks
|
|
195
|
+
// a synchronous import path, but execSync is itself sync and we want to
|
|
196
|
+
// avoid making the advisory async (it runs once before the manifest loop).
|
|
197
|
+
try {
|
|
198
|
+
// eslint-disable-next-line no-undef
|
|
199
|
+
const requireFn = createRequire(import.meta.url);
|
|
200
|
+
const { execSync } = requireFn('child_process');
|
|
201
|
+
const branch = execSync('git symbolic-ref --short HEAD', {
|
|
202
|
+
cwd: workspaceRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
203
|
+
}).trim();
|
|
204
|
+
return branch || null;
|
|
205
|
+
} catch {
|
|
206
|
+
// detached HEAD, no git repo, or git not installed — advisory is skipped
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolve the ref each manifest source should be walked at.
|
|
213
|
+
*
|
|
214
|
+
* Precedence (highest first):
|
|
215
|
+
* 1. CLI --ref flag (applies to all sources globally — explicit operator override).
|
|
216
|
+
* 2. Per-source manifest "ref" field, if explicitly set in the JSON.
|
|
217
|
+
* 3. ManifestLoader default "main".
|
|
218
|
+
*
|
|
219
|
+
* Returns a NEW manifest object (does not mutate the input) with each
|
|
220
|
+
* _resolvedSources[i].ref rewritten when --ref is supplied.
|
|
221
|
+
*
|
|
222
|
+
* Also returns metadata about the resolution so the sync output can name the
|
|
223
|
+
* ref used and so the status command can report it.
|
|
224
|
+
*
|
|
225
|
+
* @param {Object} manifest - Validated manifest from ManifestLoader
|
|
226
|
+
* @param {string|null} cliRef - Operator's --ref override, or null
|
|
227
|
+
* @returns {{ manifest: Object, resolvedRef: string, source: 'cli'|'manifest'|'default', allDefault: boolean }}
|
|
228
|
+
*/
|
|
229
|
+
function resolveRef(manifest, cliRef) {
|
|
230
|
+
// Detect whether every source uses the ManifestLoader default ("main") with
|
|
231
|
+
// no explicit override in the JSON. We can't see the raw JSON here, so we
|
|
232
|
+
// compare against the default. ManifestLoader rewrites src.ref to "main" if
|
|
233
|
+
// missing, so an explicit "main" in JSON is indistinguishable from default —
|
|
234
|
+
// intentional: the advisory triggers in both cases.
|
|
235
|
+
const allDefault = manifest._resolvedSources.every(s => s.ref === 'main');
|
|
236
|
+
|
|
237
|
+
if (cliRef) {
|
|
238
|
+
const rewritten = manifest._resolvedSources.map(s => ({ ...s, ref: cliRef }));
|
|
239
|
+
return {
|
|
240
|
+
manifest: { ...manifest, _resolvedSources: rewritten },
|
|
241
|
+
resolvedRef: cliRef,
|
|
242
|
+
source: 'cli',
|
|
243
|
+
allDefault
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// No CLI override — report the ref(s) the manifest itself declares.
|
|
248
|
+
// If all sources share one ref, that is the resolved ref; otherwise we
|
|
249
|
+
// surface "mixed" so the status output is honest.
|
|
250
|
+
const refs = [...new Set(manifest._resolvedSources.map(s => s.ref))];
|
|
251
|
+
const resolvedRef = refs.length === 1 ? refs[0] : `mixed(${refs.join(',')})`;
|
|
252
|
+
return {
|
|
253
|
+
manifest,
|
|
254
|
+
resolvedRef,
|
|
255
|
+
source: allDefault ? 'default' : 'manifest',
|
|
256
|
+
allDefault
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Prompt the operator for a y/N confirmation on stdin.
|
|
262
|
+
* Default-deny (returns false unless explicit y/yes).
|
|
263
|
+
*
|
|
264
|
+
* Bypassed when options.yes is set (for scripting). The caller is responsible
|
|
265
|
+
* for that bypass — this helper always actually prompts when called.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} prompt - The question to display (no trailing space needed)
|
|
268
|
+
* @returns {Promise<boolean>}
|
|
269
|
+
*/
|
|
270
|
+
async function confirmYesNo(prompt) {
|
|
271
|
+
const readline = await import('readline');
|
|
272
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
273
|
+
return new Promise(resolve => {
|
|
274
|
+
rl.question(`${prompt} [y/N] `, answer => {
|
|
275
|
+
rl.close();
|
|
276
|
+
const a = (answer || '').trim().toLowerCase();
|
|
277
|
+
resolve(a === 'y' || a === 'yes');
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Run corpus sync for an app.
|
|
284
|
+
*
|
|
285
|
+
* Algorithm:
|
|
286
|
+
* 1. Load WorkspaceConfig, find app by app_id, get localPath
|
|
287
|
+
* 2. Load manifest(s) from {localPath}/.descix/manifests/
|
|
288
|
+
* 3. For each manifest:
|
|
289
|
+
* a. Run CorpusWalker to get file list with blob SHAs
|
|
290
|
+
* b. Query Pinecone for existing chunks with this app_id + kb_name
|
|
291
|
+
* c. Compute delta: new/changed files (different blob SHA), deleted files
|
|
292
|
+
* d. For changed/new files: chunk using existing Chunker
|
|
293
|
+
* e. Sync chunks via existing Syncer (upsertChunks / deleteStaleChunks)
|
|
294
|
+
* f. Store sync state
|
|
295
|
+
*
|
|
296
|
+
* @param {Object} apiClient - DeSciXApiClient instance
|
|
297
|
+
* @param {Object} options - { app, kb, verbose }
|
|
298
|
+
*/
|
|
299
|
+
export async function runCorpusSync(apiClient, options) {
|
|
300
|
+
const spinner = ora('Loading workspace configuration...').start();
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
if (!apiClient) {
|
|
304
|
+
spinner.fail('Authentication required');
|
|
305
|
+
throw new Error('Run "descix login" first.');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 1. Load WorkspaceConfig
|
|
309
|
+
const workspaceConfig = await WorkspaceConfig.load();
|
|
310
|
+
const workspaceRoot = workspaceConfig.getWorkspaceRoot();
|
|
311
|
+
|
|
312
|
+
// Resolve app
|
|
313
|
+
const appId = options.app;
|
|
314
|
+
if (!appId) {
|
|
315
|
+
spinner.fail('App ID required');
|
|
316
|
+
throw new Error('Use -a <app_id> to specify the app.');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const appConfig = workspaceConfig.getAppByAppId(appId);
|
|
320
|
+
if (!appConfig) {
|
|
321
|
+
spinner.fail(`App not found: ${appId}`);
|
|
322
|
+
throw new Error(`App "${appId}" not found in workspace.json. Check env.products[].`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const appRoot = appConfig.absolutePath;
|
|
326
|
+
const communityId = await resolveCorpusCommunityId(apiClient, workspaceConfig, appId, options);
|
|
327
|
+
|
|
328
|
+
// 2. Load manifests
|
|
329
|
+
spinner.text = 'Loading manifests...';
|
|
330
|
+
const manifests = await loadManifests(appRoot, workspaceRoot, options.kb || null);
|
|
331
|
+
|
|
332
|
+
if (manifests.length === 0) {
|
|
333
|
+
spinner.fail('No manifests found');
|
|
334
|
+
const manifestsDir = path.join(appRoot, '.descix', 'manifests');
|
|
335
|
+
throw new Error(`No manifests found at ${manifestsDir}/\nCreate a manifest file first.`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
spinner.succeed(`Found ${manifests.length} manifest(s)`);
|
|
339
|
+
|
|
340
|
+
// Deliverable B: branch-mismatch advisory. We only emit this when:
|
|
341
|
+
// - no --ref override was supplied, AND
|
|
342
|
+
// - every loaded manifest's every source uses the default "main", AND
|
|
343
|
+
// - the operator's git HEAD is on a non-main branch.
|
|
344
|
+
// The advisory is informational — it does NOT block the sync. The operator
|
|
345
|
+
// is free to keep going (e.g., they really do want to sync from main while
|
|
346
|
+
// working on a feature branch). This is the silent gotcha that the
|
|
347
|
+
// WS-CLI-MANIFEST-REF-FEATURE-BRANCH scope doc surfaced.
|
|
348
|
+
if (!options.ref) {
|
|
349
|
+
const allManifestsDefault = manifests.every(m =>
|
|
350
|
+
m._resolvedSources.every(s => s.ref === 'main')
|
|
351
|
+
);
|
|
352
|
+
if (allManifestsDefault) {
|
|
353
|
+
const currentBranch = getCurrentBranch(workspaceRoot);
|
|
354
|
+
if (currentBranch && currentBranch !== 'main') {
|
|
355
|
+
console.log(chalk.yellow(
|
|
356
|
+
` Advisory: you are on branch "${currentBranch}" but every manifest is configured to walk "main". ` +
|
|
357
|
+
`Pass --ref ${currentBranch} to sync your branch instead.`
|
|
358
|
+
));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 3. Pre-check (fail-loud): verify each manifest's target KB document exists in Firestore.
|
|
364
|
+
// Without the KB doc, chat queries against this KB will hard-fail in prepare_chat_context.
|
|
365
|
+
// We surface this BEFORE wasting time syncing vectors that no chat path can reach.
|
|
366
|
+
//
|
|
367
|
+
// Uses list_knowledge_bases (returns the actual KBs registered for the app) — NOT get_app,
|
|
368
|
+
// which returns only the App doc and lacks any knowledgebases field. The prior pre-check
|
|
369
|
+
// was reading a non-existent field and printing a false-positive warning on every sync.
|
|
370
|
+
let registeredKbNames = null;
|
|
371
|
+
try {
|
|
372
|
+
const kbListResp = await apiClient.invoke('list_knowledge_bases', {
|
|
373
|
+
community_id: communityId,
|
|
374
|
+
app_id: appId
|
|
375
|
+
});
|
|
376
|
+
const kbListData = kbListResp?.message || kbListResp;
|
|
377
|
+
const kbs = kbListData?.knowledgebases || kbListData?.knowledge_bases || [];
|
|
378
|
+
registeredKbNames = new Set(
|
|
379
|
+
kbs.map(kb => kb.knowledgebase_name || kb.kb_name || kb.name).filter(Boolean)
|
|
380
|
+
);
|
|
381
|
+
} catch (kbErr) {
|
|
382
|
+
// If the call itself fails (network/auth), surface and bail — don't silently proceed.
|
|
383
|
+
spinner.fail(`Could not verify KB registry for app "${appId}": ${kbErr.message}`);
|
|
384
|
+
throw new Error(
|
|
385
|
+
`Failed to list KnowledgeBase docs for app "${appId}". ` +
|
|
386
|
+
`This is required so we can refuse to sync into an unregistered KB. ` +
|
|
387
|
+
`Underlying error: ${kbErr.message}`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const requestedKbNames = manifests.map(m => m.kb_name);
|
|
392
|
+
const missingKbNames = requestedKbNames.filter(name => !registeredKbNames.has(name));
|
|
393
|
+
if (missingKbNames.length > 0) {
|
|
394
|
+
// 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 actually creates a KB on a
|
|
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.
|
|
398
|
+
spinner.fail(`Unregistered KB(s) for app "${appId}"`);
|
|
399
|
+
throw new Error(
|
|
400
|
+
`KnowledgeBase document(s) missing in Firestore for app "${appId}": ${missingKbNames.join(', ')}.\n` +
|
|
401
|
+
`Registered KBs for this app: ${[...registeredKbNames].join(', ') || '(none)'}.\n` +
|
|
402
|
+
`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}\` — it creates the KB via init_git_mode_kb and needs no Drive folder.\n` +
|
|
404
|
+
`(\`descix kb create\` currently routes through create_skeleton_kb and FAILS on a git-mode app.)`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 4. Process each manifest
|
|
409
|
+
let totalFilesProcessed = 0;
|
|
410
|
+
let totalChunksCreated = 0;
|
|
411
|
+
let totalFilesSkipped = 0;
|
|
412
|
+
let totalFilesDeleted = 0;
|
|
413
|
+
|
|
414
|
+
for (const rawManifest of manifests) {
|
|
415
|
+
// ── Deliverable B: resolve --ref override (CLI > manifest > default) ──
|
|
416
|
+
const refResolution = resolveRef(rawManifest, options.ref || null);
|
|
417
|
+
const manifest = refResolution.manifest;
|
|
418
|
+
const kbName = manifest.kb_name;
|
|
419
|
+
|
|
420
|
+
console.log(chalk.cyan(`\nSyncing corpus: ${kbName}`));
|
|
421
|
+
// Always name the ref used so the operator can see exactly what was walked.
|
|
422
|
+
const refLabel = refResolution.source === 'cli'
|
|
423
|
+
? `${refResolution.resolvedRef} (--ref override)`
|
|
424
|
+
: refResolution.source === 'manifest'
|
|
425
|
+
? `${refResolution.resolvedRef} (manifest)`
|
|
426
|
+
: `${refResolution.resolvedRef} (default)`;
|
|
427
|
+
console.log(chalk.gray(` Ref: ${refLabel}`));
|
|
428
|
+
|
|
429
|
+
// ── K2: Tier-P deny-class lint (CEO-D-2026-07-11-KB-CURATION-RATIFIED) ──
|
|
430
|
+
// A publish_tier:"P" manifest is a PUBLISHING ACT. Before ANY walk or
|
|
431
|
+
// Pinecone read/write, run the canonical deny lint: print every exemption
|
|
432
|
+
// and warning (the review artifact, model §5.3), then FAIL LOUD on any
|
|
433
|
+
// violation. Non-Tier-P manifests (publish_tier "I"/absent) skip this
|
|
434
|
+
// entirely — current behavior unchanged.
|
|
435
|
+
if (manifest.publish_tier === 'P') {
|
|
436
|
+
const lintSpinner = ora(' [deny-lint] Linting Tier-P manifest for deny-classes...').start();
|
|
437
|
+
let denyReport;
|
|
438
|
+
try {
|
|
439
|
+
denyReport = await lintManifestForDenyClasses(manifest, workspaceRoot);
|
|
440
|
+
} catch (lintErr) {
|
|
441
|
+
lintSpinner.fail(` [deny-lint] Lint could not run: ${lintErr.message}`);
|
|
442
|
+
throw lintErr;
|
|
443
|
+
}
|
|
444
|
+
lintSpinner.stop();
|
|
445
|
+
// Print the review artifact — every exemption and warning, always.
|
|
446
|
+
for (const ex of denyReport.exemptions) console.log(chalk.yellow(` ${ex.line}`));
|
|
447
|
+
for (const w of denyReport.warnings) console.log(chalk.yellow(` ${w.line}`));
|
|
448
|
+
// Fail loud BEFORE the walk / any Pinecone op if there are violations.
|
|
449
|
+
assertNoViolations(denyReport);
|
|
450
|
+
console.log(chalk.green(
|
|
451
|
+
` [deny-lint] OK — ${denyReport.exemptions.length} exemption(s), ` +
|
|
452
|
+
`${denyReport.warnings.length} warning(s), 0 violation(s).`
|
|
453
|
+
));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// 3a. Walk corpus
|
|
457
|
+
const walkSpinner = ora(' Walking source directories...').start();
|
|
458
|
+
const { files, commitSha, provenance } = await walkCorpus(manifest, workspaceRoot);
|
|
459
|
+
walkSpinner.succeed(` Found ${files.length} files (commit: ${commitSha.substring(0, 8)})`);
|
|
460
|
+
|
|
461
|
+
// ── Deliverable A: --show-walk prints the resolved ref + first 50 files
|
|
462
|
+
// BEFORE any Pinecone read/write so the operator can sanity-check the
|
|
463
|
+
// walk results without trusting downstream output.
|
|
464
|
+
if (options.showWalk) {
|
|
465
|
+
console.log(chalk.cyan(` [show-walk] Resolved ref: ${refResolution.resolvedRef} (source=${refResolution.source})`));
|
|
466
|
+
console.log(chalk.cyan(` [show-walk] Walked ${files.length} file(s). First ${Math.min(50, files.length)}:`));
|
|
467
|
+
for (const f of files.slice(0, 50)) {
|
|
468
|
+
console.log(chalk.gray(` ${f.blob_sha.substring(0, 8)} ${f.relative_path}`));
|
|
469
|
+
}
|
|
470
|
+
if (files.length > 50) {
|
|
471
|
+
console.log(chalk.gray(` ... and ${files.length - 50} more`));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (files.length === 0) {
|
|
476
|
+
console.log(chalk.yellow(' No files found in sources. Skipping.'));
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// 3b-rebuild: When --rebuild is set, do a FULL metadata-scoped purge of the
|
|
481
|
+
// ENTIRE {community_id, app_id, knowledgebase_name} scope, then re-upsert the
|
|
482
|
+
// full local walk below. This is the WS-KB-CORPUS-SCOPEPURGE fix: the prior
|
|
483
|
+
// implementation enumerated `corpus:<blob_sha>` file_ids and purged only the
|
|
484
|
+
// ones absent from the current walk — but the BULK of orphan pollution came
|
|
485
|
+
// from the legacy `kb sync` path (chunk_ids, no `corpus:` file_id) which is
|
|
486
|
+
// INVISIBLE to that enumeration, so rebuild left those orphans live. The only
|
|
487
|
+
// primitive that clears them is a delete by Pinecone metadata filter
|
|
488
|
+
// {community_id, app_id, knowledgebase_name} — server-side that is
|
|
489
|
+
// kb_delete_chunks(purge_scope:true) → KnowledgeBase.deleteRAG(), which also
|
|
490
|
+
// resets the KB doc's rag_vector_count (the field `kb doctor` reads).
|
|
491
|
+
//
|
|
492
|
+
// We still enumerate file_ids first, but ONLY to report the live state for the
|
|
493
|
+
// dry-run preview / confirmation message. The actual purge is full-scope.
|
|
494
|
+
//
|
|
495
|
+
// Deliverable A preserved: --dry-run writes nothing, interactive confirmation
|
|
496
|
+
// guard intact, --yes skips it.
|
|
497
|
+
let rebuildPurged = 0;
|
|
498
|
+
let rebuildScopePurged = false;
|
|
499
|
+
let rebuildRemoteUniqueCount = 0;
|
|
500
|
+
let rebuildRemoteTotalChunks = 0;
|
|
501
|
+
if (options.rebuild) {
|
|
502
|
+
const rebuildSpinner = ora(' [rebuild] Enumerating Pinecone namespace...').start();
|
|
503
|
+
try {
|
|
504
|
+
// Report-only enumeration (corpus-scheme file_ids are a LOWER BOUND on the
|
|
505
|
+
// true vector count — legacy orphans may not appear here, which is exactly
|
|
506
|
+
// why we full-scope-purge rather than purge this list).
|
|
507
|
+
const remote = await listRemoteFileIds(apiClient, appId, kbName);
|
|
508
|
+
rebuildRemoteUniqueCount = remote.unique_count;
|
|
509
|
+
rebuildRemoteTotalChunks = remote.total_chunks;
|
|
510
|
+
rebuildSpinner.succeed(
|
|
511
|
+
` [rebuild] Pinecone reports ${remote.unique_count} corpus-scheme file_id(s) / ` +
|
|
512
|
+
`${remote.total_chunks} enumerable chunk(s) (legacy orphans may exceed this)`
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
if (options.dryRun) {
|
|
516
|
+
// DRY-RUN: describe the full-scope purge. No writes.
|
|
517
|
+
console.log(chalk.yellow(
|
|
518
|
+
` [rebuild][dry-run] WOULD purge the ENTIRE scope ` +
|
|
519
|
+
`${communityId}/${appId}/${kbName} (all vectors, any scheme), then re-upsert ` +
|
|
520
|
+
`the full local walk. No action taken.`
|
|
521
|
+
));
|
|
522
|
+
} else {
|
|
523
|
+
// Interactive confirmation guard. --yes skips for scripting/CI.
|
|
524
|
+
console.log(chalk.yellow(
|
|
525
|
+
`\n About to FULL-SCOPE PURGE every vector in ${communityId}/${appId}/${kbName} ` +
|
|
526
|
+
`(≥${rebuildRemoteTotalChunks} enumerable chunk(s); legacy orphans included), ` +
|
|
527
|
+
`then re-upsert the full local walk (${files.length} file(s)).`
|
|
528
|
+
));
|
|
529
|
+
|
|
530
|
+
let proceed = true;
|
|
531
|
+
if (!options.yes) {
|
|
532
|
+
const ok = await confirmYesNo(' Proceed with full-scope purge + rebuild?');
|
|
533
|
+
if (!ok) {
|
|
534
|
+
console.log(chalk.cyan(' [rebuild] Aborted by operator. No Pinecone changes made.'));
|
|
535
|
+
// Skip THIS manifest's rebuild + delta phases. Don't break the
|
|
536
|
+
// outer loop — operator might want to keep going for other KBs.
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (proceed) {
|
|
542
|
+
const purgeSpinner = ora(` [rebuild] Purging full scope ${communityId}/${appId}/${kbName}...`).start();
|
|
543
|
+
const purgeResult = await purgeKbScope(apiClient, communityId, appId, kbName);
|
|
544
|
+
rebuildScopePurged = purgeResult.purged_scope;
|
|
545
|
+
// deleteRAG via filter-based deleteMany returns -1 (count not knowable
|
|
546
|
+
// from the SDK). Surface honestly rather than fabricating a number.
|
|
547
|
+
rebuildPurged = purgeResult.deleted >= 0 ? purgeResult.deleted : 0;
|
|
548
|
+
purgeSpinner.succeed(
|
|
549
|
+
purgeResult.deleted >= 0
|
|
550
|
+
? ` [rebuild] Purged ${purgeResult.deleted} vector(s) (full scope) and reset KB doc rag fields`
|
|
551
|
+
: ` [rebuild] Purged full scope (filter-based delete; SDK reports no count) and reset KB doc rag fields`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
} catch (err) {
|
|
556
|
+
rebuildSpinner.fail(` [rebuild] Failed: ${err.message}`);
|
|
557
|
+
// Re-throw — rebuild is opt-in and explicit; silent partial failure would
|
|
558
|
+
// leave the namespace half-purged, exactly the silent-degradation we are closing.
|
|
559
|
+
throw err;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// 3b. Load previous sync state for delta computation
|
|
564
|
+
// We use local sync state (stored blob SHAs) instead of querying Pinecone,
|
|
565
|
+
// because Pinecone metadata queries don't scale (payload size limits).
|
|
566
|
+
// For --rebuild runs we INVALIDATE prior state so every file re-chunks + re-upserts
|
|
567
|
+
// (otherwise unchanged-by-blob-sha files would be skipped despite the purge above).
|
|
568
|
+
//
|
|
569
|
+
// Deliverable A note: in dry-run + rebuild we ALSO null previousState so
|
|
570
|
+
// the count below reflects what a real rebuild would upsert (all files),
|
|
571
|
+
// not what an incremental sync would upsert.
|
|
572
|
+
let previousState = await loadSyncState(appRoot, kbName);
|
|
573
|
+
if (options.rebuild) {
|
|
574
|
+
previousState = null;
|
|
575
|
+
}
|
|
576
|
+
const previousBlobShas = new Set(previousState?.synced_blob_shas || []);
|
|
577
|
+
const previousChunkCount = previousState?.total_chunks || 0;
|
|
578
|
+
|
|
579
|
+
if (previousBlobShas.size > 0) {
|
|
580
|
+
console.log(chalk.gray(` Previous sync: ${previousBlobShas.size} files, ${previousChunkCount} chunks`));
|
|
581
|
+
} else {
|
|
582
|
+
console.log(chalk.gray(' First sync (no previous state)'));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// 3c. Compute delta — compare by blob SHA against local sync state
|
|
586
|
+
const localBlobShas = new Set(files.map(f => f.blob_sha));
|
|
587
|
+
const newOrChangedFiles = files.filter(f => !previousBlobShas.has(f.blob_sha));
|
|
588
|
+
|
|
589
|
+
// Stale blob_shas: anything we synced previously that is no longer in the local
|
|
590
|
+
// walk. This covers BOTH cases:
|
|
591
|
+
// (a) File deleted from the source corpus entirely.
|
|
592
|
+
// (b) File modified in place — same source_path, new blob_sha → old blob_sha
|
|
593
|
+
// is no longer in localBlobShas.
|
|
594
|
+
// Each stale blob_sha maps to a file_id of `corpus:{blob_sha}` in Pinecone metadata
|
|
595
|
+
// and is purged via the deleteStaleChunksByFileId primitive (multi-tenancy filter).
|
|
596
|
+
const deletedBlobShas = [...previousBlobShas].filter(sha => !localBlobShas.has(sha));
|
|
597
|
+
const fileIdsToDelete = deletedBlobShas.map(sha => `corpus:${sha}`);
|
|
598
|
+
|
|
599
|
+
const unchangedCount = files.length - newOrChangedFiles.length;
|
|
600
|
+
|
|
601
|
+
if (options.verbose) {
|
|
602
|
+
console.log(chalk.gray(` New/changed files: ${newOrChangedFiles.length}`));
|
|
603
|
+
console.log(chalk.gray(` Unchanged files: ${unchangedCount}`));
|
|
604
|
+
console.log(chalk.gray(` Stale blob SHAs (delete): ${deletedBlobShas.length}`));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// 3d. Chunk new/changed files
|
|
608
|
+
const allChunks = [];
|
|
609
|
+
if (newOrChangedFiles.length > 0) {
|
|
610
|
+
const chunkSpinner = ora(` Chunking ${newOrChangedFiles.length} file(s)...`).start();
|
|
611
|
+
|
|
612
|
+
for (const fileEntry of newOrChangedFiles) {
|
|
613
|
+
try {
|
|
614
|
+
const chunks = await chunkCorpusFile(fileEntry, { appId, communityId, kbName });
|
|
615
|
+
allChunks.push(...chunks);
|
|
616
|
+
|
|
617
|
+
if (chunks.length === 0) {
|
|
618
|
+
// M1 (2026-04-20): always surface 0-chunk files even without -v.
|
|
619
|
+
// These are the silent failures that caused the 77% EGPT gap.
|
|
620
|
+
// 'kb doctor' scans sync logs for this exact string.
|
|
621
|
+
console.log(chalk.yellow(` ⚠ 0-chunk: ${fileEntry.relative_path} produced no chunks (check file type / content)`));
|
|
622
|
+
} else if (options.verbose) {
|
|
623
|
+
console.log(chalk.gray(` ${fileEntry.relative_path}: ${chunks.length} chunks`));
|
|
624
|
+
}
|
|
625
|
+
} catch (err) {
|
|
626
|
+
// Always surface errors — skipping silently is what produced the 2026-04-20 regression.
|
|
627
|
+
console.log(chalk.yellow(` ⚠ skipped: ${fileEntry.relative_path} (${err.message})`));
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
chunkSpinner.succeed(` Chunked: ${allChunks.length} chunks from ${newOrChangedFiles.length} files`);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Deliverable A: dry-run summary + early continue. We do all the
|
|
635
|
+
// counting work above (walk + chunk + remote enumeration) so the operator
|
|
636
|
+
// sees the EXACT plan that --rebuild (without --dry-run) would execute,
|
|
637
|
+
// but we never call upsertChunks / deleteStaleChunksByFileId.
|
|
638
|
+
if (options.dryRun) {
|
|
639
|
+
console.log(chalk.cyan(`\n [dry-run] Plan for ${kbName}:`));
|
|
640
|
+
console.log(chalk.white(` Would upsert: ${allChunks.length} chunks from ${newOrChangedFiles.length} file(s)`));
|
|
641
|
+
console.log(chalk.white(` Would delete (stale-file): ${fileIdsToDelete.length} blob SHA(s)`));
|
|
642
|
+
if (options.rebuild) {
|
|
643
|
+
console.log(chalk.white(` Would purge (rebuild): FULL scope ${communityId}/${appId}/${kbName} (≥${rebuildRemoteTotalChunks} enumerable chunks; legacy orphans included)`));
|
|
644
|
+
}
|
|
645
|
+
// Track for the summary exit-code computation at the end of the loop.
|
|
646
|
+
totalFilesProcessed += newOrChangedFiles.length;
|
|
647
|
+
totalChunksCreated += allChunks.length;
|
|
648
|
+
totalFilesSkipped += unchangedCount;
|
|
649
|
+
// Full-scope rebuild would purge ≥ the enumerable chunk count. Use it as the
|
|
650
|
+
// would-delete tally so dry-run reports drift whenever a purge would run.
|
|
651
|
+
totalFilesDeleted += fileIdsToDelete.length + (options.rebuild ? rebuildRemoteTotalChunks : 0);
|
|
652
|
+
// Do NOT call upsertChunks, deleteStaleChunksByFileId, deleteStaleChunks,
|
|
653
|
+
// or saveSyncState in dry-run. Continue to next manifest.
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// 3e. Sync to Pinecone with rate limiting
|
|
658
|
+
// Pinecone has a 1M token/min rate limit for embeddings.
|
|
659
|
+
// We batch in small groups with delays between to stay under the limit.
|
|
660
|
+
let upserted = 0;
|
|
661
|
+
let deleted = 0;
|
|
662
|
+
const syncFailures = [];
|
|
663
|
+
|
|
664
|
+
if (allChunks.length > 0) {
|
|
665
|
+
const syncSpinner = ora(` Upserting ${allChunks.length} chunks...`).start();
|
|
666
|
+
const BATCH_SIZE = 30; // Small batches to manage Pinecone embedding rate limits (1M tokens/min)
|
|
667
|
+
const BATCH_DELAY_MS = 5000; // 5 second delay between batches
|
|
668
|
+
const MAX_RETRIES = 5;
|
|
669
|
+
|
|
670
|
+
for (let i = 0; i < allChunks.length; i += BATCH_SIZE) {
|
|
671
|
+
const batch = allChunks.slice(i, i + BATCH_SIZE);
|
|
672
|
+
let retries = 0;
|
|
673
|
+
let success = false;
|
|
674
|
+
|
|
675
|
+
while (!success && retries < MAX_RETRIES) {
|
|
676
|
+
try {
|
|
677
|
+
const result = await upsertChunks(apiClient, communityId, appId, kbName, batch);
|
|
678
|
+
upserted += result.upserted;
|
|
679
|
+
success = true;
|
|
680
|
+
syncSpinner.text = ` Upserting chunks... ${i + batch.length}/${allChunks.length}`;
|
|
681
|
+
} catch (err) {
|
|
682
|
+
const msg = err.message || '';
|
|
683
|
+
const isRateLimit = msg.includes('429') || msg.includes('RESOURCE_EXHAUSTED');
|
|
684
|
+
// A per-batch upsert TIMEOUT (server raced out the wedged Pinecone call →
|
|
685
|
+
// UPSERT_TIMEOUT) or a transient network/gateway stall (ETIMEDOUT / 504 /
|
|
686
|
+
// 502 / 503 / ECONNRESET) is RESUMABLE — the upsert is id-keyed and
|
|
687
|
+
// idempotent, so retrying the same batch overwrites any partially-landed
|
|
688
|
+
// records. We must NOT skip it (that silently drops chunks). Retry with
|
|
689
|
+
// backoff like a rate-limit. This is what makes a re-run resume cleanly
|
|
690
|
+
// and a single rebuild never wedge for hours.
|
|
691
|
+
const isResumableStall =
|
|
692
|
+
msg.includes('UPSERT_TIMEOUT') ||
|
|
693
|
+
msg.includes('ETIMEDOUT') ||
|
|
694
|
+
msg.includes('timed out') ||
|
|
695
|
+
msg.includes('504') || msg.includes('502') || msg.includes('503') ||
|
|
696
|
+
msg.includes('ECONNRESET') || msg.includes('socket hang up');
|
|
697
|
+
|
|
698
|
+
if (isRateLimit || isResumableStall) {
|
|
699
|
+
retries++;
|
|
700
|
+
const backoff = BATCH_DELAY_MS * Math.pow(2, retries);
|
|
701
|
+
const reason = isRateLimit ? 'Rate limited' : 'Transient stall/timeout';
|
|
702
|
+
syncSpinner.text = ` ${reason}. Waiting ${backoff / 1000}s before retry ${retries}/${MAX_RETRIES} (batch ${i}-${i + batch.length})...`;
|
|
703
|
+
await new Promise(r => setTimeout(r, backoff));
|
|
704
|
+
} else {
|
|
705
|
+
// Genuine non-transient error (e.g. validation): log failed chunks,
|
|
706
|
+
// continue with remaining batches.
|
|
707
|
+
const failedIds = batch.map(c => c.id);
|
|
708
|
+
syncFailures.push({ error: err.message, chunks: failedIds });
|
|
709
|
+
syncSpinner.text = ` Batch failed (${failedIds.length} chunks): ${err.message.substring(0, 80)}. Continuing...`;
|
|
710
|
+
success = true; // Don't retry non-transient errors, move to next batch
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (!success) {
|
|
716
|
+
syncSpinner.warn(
|
|
717
|
+
` 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) — ` +
|
|
719
|
+
`re-run 'descix kb corpus sync -a ${appId} -k ${kbName}' to RESUME: id-keyed upserts ` +
|
|
720
|
+
`are idempotent so already-synced batches are cheap and the remainder continues.`
|
|
721
|
+
);
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Delay between batches to stay under rate limit
|
|
726
|
+
if (i + BATCH_SIZE < allChunks.length) {
|
|
727
|
+
await new Promise(r => setTimeout(r, BATCH_DELAY_MS));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
syncSpinner.succeed(` Upserted: ${upserted} chunks`);
|
|
732
|
+
|
|
733
|
+
// Write failure log to .descix if any batches failed
|
|
734
|
+
if (syncFailures.length > 0) {
|
|
735
|
+
const failLogDir = path.join(appRoot, '.descix', 'sync-state');
|
|
736
|
+
await fs.mkdir(failLogDir, { recursive: true });
|
|
737
|
+
const failLogPath = path.join(failLogDir, `${kbName}-failures.json`);
|
|
738
|
+
await fs.writeFile(failLogPath, JSON.stringify({
|
|
739
|
+
timestamp: new Date().toISOString(),
|
|
740
|
+
commit: commitSha,
|
|
741
|
+
failures: syncFailures,
|
|
742
|
+
}, null, 2));
|
|
743
|
+
console.log(chalk.yellow(` ⚠ ${syncFailures.length} batch(es) failed. Details: ${failLogPath}`));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (fileIdsToDelete.length > 0) {
|
|
748
|
+
const deleteSpinner = ora(` Purging stale chunks for ${fileIdsToDelete.length} blob SHA(s)...`).start();
|
|
749
|
+
try {
|
|
750
|
+
// Use file_id-based delete: each stale blob_sha maps to file_id `corpus:{sha}`,
|
|
751
|
+
// and pineconeService::deleteVectorsByFileId scopes by full multi-tenancy filter
|
|
752
|
+
// (community_id, app_id, knowledgebase_name) — guarantees namespace isolation.
|
|
753
|
+
const result = await deleteStaleChunksByFileId(apiClient, communityId, appId, kbName, fileIdsToDelete);
|
|
754
|
+
deleted = result.deleted;
|
|
755
|
+
deleteSpinner.succeed(` Purged: ${deleted} chunk(s) across ${fileIdsToDelete.length} stale blob SHA(s)`);
|
|
756
|
+
} catch (err) {
|
|
757
|
+
deleteSpinner.warn(` Delete failed: ${err.message}`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// 3f. Save sync state with blob SHAs for future delta computation
|
|
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
|
|
780
|
+
// count after a COMPLETED, clean sync (no batch failures). This keeps the
|
|
781
|
+
// doctor's fast cached read truthful after every clean run, so drift only
|
|
782
|
+
// ever appears mid-flight (on interruption). Skipped if any batch failed
|
|
783
|
+
// (the run is partial — reconciling would bake in the partial state and the
|
|
784
|
+
// re-run will reconcile once complete). Non-fatal: a reconcile hiccup must
|
|
785
|
+
// not fail an otherwise-successful sync.
|
|
786
|
+
if (syncFailures.length === 0) {
|
|
787
|
+
try {
|
|
788
|
+
const recRes = await apiClient.invoke('get_kb_rag_status', {
|
|
789
|
+
app_id: appId, kb_id: kbName, reconcile: true
|
|
790
|
+
}, { allowGuest: false });
|
|
791
|
+
const rec = recRes.message || recRes;
|
|
792
|
+
if (rec && typeof rec.reconcileAfter === 'number') {
|
|
793
|
+
const d = rec.reconcileAfter - rec.reconcileBefore;
|
|
794
|
+
console.log(chalk.gray(
|
|
795
|
+
` ⟳ Reconciled live count: cached ${rec.reconcileBefore} → live ${rec.reconcileAfter}` +
|
|
796
|
+
(d === 0 ? ' (already truthful)' : ` (${d >= 0 ? '+' : ''}${d})`)
|
|
797
|
+
));
|
|
798
|
+
}
|
|
799
|
+
} catch (recErr) {
|
|
800
|
+
console.log(chalk.yellow(` ⚠ Auto-reconcile skipped (non-fatal): ${recErr.message}`));
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
totalFilesProcessed += newOrChangedFiles.length;
|
|
805
|
+
totalChunksCreated += upserted;
|
|
806
|
+
totalFilesSkipped += unchangedCount;
|
|
807
|
+
totalFilesDeleted += deleted + rebuildPurged;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Summary
|
|
811
|
+
if (options.dryRun) {
|
|
812
|
+
const driftCount = totalFilesProcessed + totalFilesDeleted;
|
|
813
|
+
console.log(chalk.cyan('\nCorpus sync dry-run complete:'));
|
|
814
|
+
console.log(chalk.white(` Would-upsert files: ${totalFilesProcessed}`));
|
|
815
|
+
console.log(chalk.white(` Would-upsert chunks: ${totalChunksCreated}`));
|
|
816
|
+
console.log(chalk.white(` Would-delete refs: ${totalFilesDeleted}`));
|
|
817
|
+
console.log(chalk.white(` Unchanged files: ${totalFilesSkipped}`));
|
|
818
|
+
if (driftCount === 0) {
|
|
819
|
+
console.log(chalk.green(' Drift: NONE. No changes would be made.\n'));
|
|
820
|
+
// Return value drives the bin/descix.js wrapper's process.exit code.
|
|
821
|
+
return { dryRun: true, drift: false, driftCount: 0 };
|
|
822
|
+
} else {
|
|
823
|
+
console.log(chalk.yellow(` Drift: ${driftCount} change(s) would be applied.\n`));
|
|
824
|
+
return { dryRun: true, drift: true, driftCount };
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
console.log(chalk.green('\nCorpus sync complete:'));
|
|
829
|
+
console.log(chalk.white(` Files synced: ${totalFilesProcessed}`));
|
|
830
|
+
console.log(chalk.white(` Chunks created: ${totalChunksCreated}`));
|
|
831
|
+
console.log(chalk.white(` Files unchanged: ${totalFilesSkipped}`));
|
|
832
|
+
if (totalFilesDeleted > 0) {
|
|
833
|
+
console.log(chalk.white(` Chunks deleted: ${totalFilesDeleted}`));
|
|
834
|
+
} else {
|
|
835
|
+
console.log(chalk.gray(` Chunks deleted: 0`));
|
|
836
|
+
}
|
|
837
|
+
console.log('');
|
|
838
|
+
return { dryRun: false };
|
|
839
|
+
|
|
840
|
+
} catch (error) {
|
|
841
|
+
if (spinner.isSpinning) spinner.fail('Corpus sync failed');
|
|
842
|
+
throw error;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Show corpus sync status for an app.
|
|
848
|
+
*
|
|
849
|
+
* Reads sync state from .descix/sync-state/{kb_name}.json and shows:
|
|
850
|
+
* - Last sync commit + timestamp
|
|
851
|
+
* - Files tracked / chunks in Pinecone
|
|
852
|
+
* - Files changed since last sync (quick git diff check)
|
|
853
|
+
*
|
|
854
|
+
* @param {Object} apiClient - DeSciXApiClient instance (optional for offline status)
|
|
855
|
+
* @param {Object} options - { app, kb, verbose }
|
|
856
|
+
*/
|
|
857
|
+
export async function runCorpusStatus(apiClient, options) {
|
|
858
|
+
const spinner = ora('Loading workspace configuration...').start();
|
|
859
|
+
|
|
860
|
+
try {
|
|
861
|
+
// Load WorkspaceConfig
|
|
862
|
+
const workspaceConfig = await WorkspaceConfig.load();
|
|
863
|
+
const workspaceRoot = workspaceConfig.getWorkspaceRoot();
|
|
864
|
+
|
|
865
|
+
const appId = options.app;
|
|
866
|
+
if (!appId) {
|
|
867
|
+
spinner.fail('App ID required');
|
|
868
|
+
throw new Error('Use -a <app_id> to specify the app.');
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const appConfig = workspaceConfig.getAppByAppId(appId);
|
|
872
|
+
if (!appConfig) {
|
|
873
|
+
spinner.fail(`App not found: ${appId}`);
|
|
874
|
+
throw new Error(`App "${appId}" not found in workspace.json.`);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const appRoot = appConfig.absolutePath;
|
|
878
|
+
|
|
879
|
+
// Load manifests
|
|
880
|
+
const manifests = await loadManifests(appRoot, workspaceRoot, options.kb || null);
|
|
881
|
+
|
|
882
|
+
if (manifests.length === 0) {
|
|
883
|
+
spinner.info('No manifests found');
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
spinner.stop();
|
|
888
|
+
|
|
889
|
+
for (const rawManifest of manifests) {
|
|
890
|
+
// Apply the same ref resolution the sync command uses, so `status`
|
|
891
|
+
// reports the EXACT ref the next sync would walk.
|
|
892
|
+
const refResolution = resolveRef(rawManifest, options.ref || null);
|
|
893
|
+
const manifest = refResolution.manifest;
|
|
894
|
+
const kbName = manifest.kb_name;
|
|
895
|
+
const syncState = await loadSyncState(appRoot, kbName);
|
|
896
|
+
|
|
897
|
+
console.log(chalk.cyan(`\nCorpus Status: ${appId} / ${kbName}`));
|
|
898
|
+
console.log(chalk.gray('─'.repeat(50)));
|
|
899
|
+
console.log(chalk.white(` Resolved ref: ${refResolution.resolvedRef} (source=${refResolution.source})`));
|
|
900
|
+
|
|
901
|
+
// File count from a fresh walk so the operator sees the LIVE file count
|
|
902
|
+
// at the resolved ref, not the count from the last sync. This is the
|
|
903
|
+
// single most useful piece of information for diagnosing "why does this
|
|
904
|
+
// not match what I expect" and is cheap (no Pinecone read).
|
|
905
|
+
try {
|
|
906
|
+
const { files } = await walkCorpus(manifest, workspaceRoot);
|
|
907
|
+
console.log(chalk.white(` Live file count: ${files.length}`));
|
|
908
|
+
} catch (err) {
|
|
909
|
+
console.log(chalk.gray(` Live file count: (walk error: ${err.message})`));
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (!syncState) {
|
|
913
|
+
console.log(chalk.yellow(' Never synced. Run "descix kb corpus sync" first.'));
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
console.log(chalk.white(` Last sync: ${syncState.timestamp}`));
|
|
918
|
+
console.log(chalk.white(` Commit: ${syncState.last_sync_commit?.substring(0, 8) || 'unknown'}`));
|
|
919
|
+
console.log(chalk.white(` Files tracked: ${syncState.synced_files_count}`));
|
|
920
|
+
console.log(chalk.white(` Chunks in store: ${syncState.total_chunks}`));
|
|
921
|
+
|
|
922
|
+
// Quick change detection: walk and compare blob SHAs
|
|
923
|
+
if (options.verbose) {
|
|
924
|
+
try {
|
|
925
|
+
const { files } = await walkCorpus(manifest, workspaceRoot);
|
|
926
|
+
// Compare current blob SHAs against what we'd expect from last sync
|
|
927
|
+
console.log(chalk.white(` Current files: ${files.length}`));
|
|
928
|
+
|
|
929
|
+
// Check git diff since last sync commit
|
|
930
|
+
if (syncState.last_sync_commit && syncState.last_sync_commit !== 'unknown') {
|
|
931
|
+
const { execSync } = await import('child_process');
|
|
932
|
+
try {
|
|
933
|
+
const diffOutput = execSync(
|
|
934
|
+
`git diff --name-only "${syncState.last_sync_commit}" HEAD`,
|
|
935
|
+
{ cwd: workspaceRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
936
|
+
).trim();
|
|
937
|
+
|
|
938
|
+
if (diffOutput) {
|
|
939
|
+
const changedFiles = diffOutput.split('\n');
|
|
940
|
+
// Filter to files in our sources
|
|
941
|
+
const sourcePaths = manifest._resolvedSources.map(s => s.path);
|
|
942
|
+
const relevantChanges = changedFiles.filter(f =>
|
|
943
|
+
sourcePaths.some(sp => f.startsWith(sp))
|
|
944
|
+
);
|
|
945
|
+
|
|
946
|
+
if (relevantChanges.length > 0) {
|
|
947
|
+
console.log(chalk.yellow(` Changed since last sync: ${relevantChanges.length} file(s)`));
|
|
948
|
+
for (const f of relevantChanges.slice(0, 10)) {
|
|
949
|
+
console.log(chalk.gray(` - ${f}`));
|
|
950
|
+
}
|
|
951
|
+
if (relevantChanges.length > 10) {
|
|
952
|
+
console.log(chalk.gray(` ... and ${relevantChanges.length - 10} more`));
|
|
953
|
+
}
|
|
954
|
+
} else {
|
|
955
|
+
console.log(chalk.green(' No changes since last sync'));
|
|
956
|
+
}
|
|
957
|
+
} else {
|
|
958
|
+
console.log(chalk.green(' No changes since last sync'));
|
|
959
|
+
}
|
|
960
|
+
} catch {
|
|
961
|
+
console.log(chalk.gray(' (unable to compute changes since last sync)'));
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
} catch (err) {
|
|
965
|
+
console.log(chalk.gray(` (walk error: ${err.message})`));
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
console.log('');
|
|
971
|
+
|
|
972
|
+
} catch (error) {
|
|
973
|
+
if (spinner.isSpinning) spinner.fail('Status check failed');
|
|
974
|
+
throw error;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
export default { runCorpusSync, runCorpusStatus };
|