doc2vec 2.12.0 → 2.13.1
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 +63 -4
- package/dist/content-processor.js +53 -11
- package/dist/controller/migrations.js +5 -0
- package/dist/controller/server.js +50 -3
- package/dist/controller/store.js +39 -2
- package/dist/doc2vec.js +116 -21
- package/dist/ui/assets/index-C-uNubcD.css +1 -0
- package/dist/ui/assets/index-zutKK2b4.js +126 -0
- package/dist/ui/index.html +2 -2
- package/dist/utils.js +58 -0
- package/package.json +1 -1
- package/dist/ui/assets/index-CZSZsL3G.css +0 -1
- package/dist/ui/assets/index-vglJgkwy.js +0 -126
package/README.md
CHANGED
|
@@ -6,7 +6,12 @@ This project provides a configurable tool (`doc2vec`) to crawl specified website
|
|
|
6
6
|
|
|
7
7
|
The primary goal is to prepare documentation content for Retrieval-Augmented Generation (RAG) systems or semantic search applications.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Run it two ways:
|
|
10
|
+
|
|
11
|
+
* **One-shot sync** — `doc2vec run config.yaml` processes every source in a config file and exits. Ideal for a cron job or CI step.
|
|
12
|
+
* **[Controller mode](#controller-mode)** — `doc2vec controller ./configs/` stays running: it schedules each config on its own cron expression, keeps run history and per-source statistics in Postgres, and serves a web UI with live log streaming, searchable run logs, and chunk inspection. Deploy it once and manage all your sources from the browser.
|
|
13
|
+
|
|
14
|
+
[](#controller-mode)
|
|
10
15
|
|
|
11
16
|
## Key Features
|
|
12
17
|
|
|
@@ -52,7 +57,7 @@ The primary goal is to prepare documentation content for Retrieval-Augmented Gen
|
|
|
52
57
|
* **Incremental Updates:** For GitHub and Zendesk sources, tracks the last run date to only fetch new or updated issues/tickets.
|
|
53
58
|
* **Cleanup:** Removes obsolete chunks from the database corresponding to pages or files that are no longer found during processing.
|
|
54
59
|
* **Configuration:** Driven by a YAML configuration file (`config.yaml`) specifying sites, repositories, local directories, Zendesk instances, database types, metadata, and other parameters.
|
|
55
|
-
* **[Controller Mode](#controller-mode):** Optionally runs as a long-lived controller that schedules sync jobs from multiple config files (cron `schedule` field), persists run history and statistics in Postgres, and serves a web UI with live log streaming — read-only (configs from files/ConfigMap) or read-write (create/edit configs from the UI).
|
|
60
|
+
* **[Controller Mode](#controller-mode):** Optionally runs as a long-lived controller that schedules sync jobs from multiple config files (cron `schedule` field), persists run history and statistics in Postgres, and serves a web UI with live log streaming, whole-run log search, and chunk inspection — read-only (configs from files/ConfigMap) or read-write (create/edit configs from the UI).
|
|
56
61
|
* **Structured Logging:** Uses a custom logger (`logger.ts`) with levels, timestamps, colors, progress bars, and child loggers for clear execution monitoring.
|
|
57
62
|
|
|
58
63
|
## Chunk Metadata & Page Reconstruction
|
|
@@ -446,7 +451,19 @@ Besides the one-shot sync, doc2vec can run as a **long-lived controller** that s
|
|
|
446
451
|
doc2vec controller --database-url postgres://user:pass@host:5432/doc2vec ./configs/
|
|
447
452
|
```
|
|
448
453
|
|
|
449
|
-
Open http://localhost:8080 to see the dashboard:
|
|
454
|
+
Open http://localhost:8080 to see the dashboard ([screenshot above](#doc2vec)): every config with its schedule, next run, and the outcome of its last run, plus a **Run now** button.
|
|
455
|
+
|
|
456
|
+
Each config gets its own page with the full run history, per-source results, and the raw YAML:
|
|
457
|
+
|
|
458
|
+

|
|
459
|
+
|
|
460
|
+
…and a **Stats** tab with runs per day, success rate, and duration trend over the last 7/30/90 days:
|
|
461
|
+
|
|
462
|
+

|
|
463
|
+
|
|
464
|
+
A run page shows what the sync did — duration, exit code, warning and error counts, per-source chunk counts and errors — above the run's logs.
|
|
465
|
+
|
|
466
|
+

|
|
450
467
|
|
|
451
468
|
### Scheduling
|
|
452
469
|
|
|
@@ -462,6 +479,20 @@ sources:
|
|
|
462
479
|
|
|
463
480
|
Configs without a `schedule` are still listed in the UI and can be triggered manually with **Run now**. Each run executes `doc2vec run <config.yaml>` as a child process; overlapping runs of the same config are skipped, and `--max-parallel` (default 1) caps how many sync jobs run at once — keep it low, since website sources launch a headless Chromium.
|
|
464
481
|
|
|
482
|
+
### Logs
|
|
483
|
+
|
|
484
|
+
Every line a sync job writes is stored in Postgres and streamed to the run page as it happens (`● live`), with follow-on-scroll and a `↓ Follow` button when you scroll away.
|
|
485
|
+
|
|
486
|
+
Long runs produce a lot of output, so the browser only keeps the last 10,000 lines in memory — the header tells you when that is the case (`30,000 lines (showing last 10,000)`). Everything else stays one query away:
|
|
487
|
+
|
|
488
|
+
* **Level chips** (`ERROR 4`, `WARN 93`, …) count the whole run, not the visible window, and clicking one filters **server-side** — so the 4 errors from the first minute of a 50-minute run are one click away. Results page in with **Load 2,000 more**.
|
|
489
|
+
* **Keyword filter** matches the message or the module, case-insensitively, across the whole run.
|
|
490
|
+
* **↓ download log** streams the complete log as plain text (`run-<id>.log`) for grepping locally or attaching to an issue. With a filter active it becomes **↓ download matches** and applies the same filter.
|
|
491
|
+
|
|
492
|
+
While a run is live, new lines matching the active filter keep arriving in the filtered view.
|
|
493
|
+
|
|
494
|
+
Run logs are pruned after `--log-retention-days` (default 14); run history and statistics are kept indefinitely.
|
|
495
|
+
|
|
465
496
|
### Flags
|
|
466
497
|
|
|
467
498
|
| Flag | Default | Description |
|
|
@@ -533,7 +564,22 @@ The controller detects ConfigMap updates without a restart. Health endpoint: `GE
|
|
|
533
564
|
|
|
534
565
|
### API
|
|
535
566
|
|
|
536
|
-
The UI is backed by a small REST API you can also use directly:
|
|
567
|
+
The UI is backed by a small REST API you can also use directly:
|
|
568
|
+
|
|
569
|
+
| Endpoint | Description |
|
|
570
|
+
|----------|-------------|
|
|
571
|
+
| `GET /api/configs` | Configs with schedule, next run, and last run |
|
|
572
|
+
| `POST /api/configs/:id/run` | Trigger a run now |
|
|
573
|
+
| `GET /api/configs/:id/stats?days=30` | Run counts, success rate, duration history |
|
|
574
|
+
| `GET /api/configs/:id/chunks?product_name=&url=` | Inspect the stored chunks for a URL |
|
|
575
|
+
| `GET /api/runs?configId=&status=&limit=&before=` | Run history |
|
|
576
|
+
| `GET /api/runs/:id/logs?afterSeq=&limit=&levels=&q=` | Log lines, filtered by level (csv) and/or keyword |
|
|
577
|
+
| `GET /api/runs/:id/logs/counts` | Line totals per level for the whole run |
|
|
578
|
+
| `GET /api/runs/:id/logs/download?levels=&q=` | Whole log (or the matching lines) as plain text |
|
|
579
|
+
| `POST /api/runs/:id/cancel` | Cancel a running job |
|
|
580
|
+
| `GET /api/health` | Status, mode, version |
|
|
581
|
+
|
|
582
|
+
Server-sent events: `GET /api/events` (run and config updates) and `GET /api/runs/:id/logs/stream?tail=10000` (replays the last `tail` stored lines, then tails live output).
|
|
537
583
|
|
|
538
584
|
## Database Options
|
|
539
585
|
|
|
@@ -721,6 +767,19 @@ If you don't specify a config path, it will look for config.yaml in the current
|
|
|
721
767
|
|
|
722
768
|
## Recent Changes
|
|
723
769
|
|
|
770
|
+
### Code Scans: Dangling Symlinks and Incomplete Walks
|
|
771
|
+
- **Dangling symlinks no longer abort a directory:** `statSync` follows symlinks, so a link whose target isn't in the clone (common in vendored forks, e.g. `crates/cel-fork/cel/README.md`) threw `ENOENT` and abandoned every remaining entry in that directory. Broken entries are now skipped with a warning and the walk continues
|
|
772
|
+
- **Incomplete scans never delete chunks:** if a directory genuinely can't be listed, the scan is reported as incomplete — obsolete-file cleanup, the tracked-file list, the last-mtime marker, and the last-synced git SHA are all skipped, so unscanned files keep their chunks and the next run rescans them
|
|
773
|
+
- **Incomplete scans fail the source:** a partial ingest is reported as a failed source instead of a silent success
|
|
774
|
+
- **Quieter crawls:** planned browser restarts (initial launch, the every-50-pages recycle) log at `info`; only unplanned restarts (disconnected or degraded browser) warn
|
|
775
|
+
|
|
776
|
+
### Searchable Controller Run Logs
|
|
777
|
+
- **Server-side log filtering:** Level chips and the keyword filter now query Postgres instead of the browser's in-memory buffer, so errors and warnings from the start of a long run are reachable (`GET /api/runs/:id/logs?levels=error,warn&q=...`, paged with **Load 2,000 more**)
|
|
778
|
+
- **Whole-run level counts:** New `GET /api/runs/:id/logs/counts` endpoint backs the level chips, which previously only counted the lines still held in the browser
|
|
779
|
+
- **Plain-text download:** `GET /api/runs/:id/logs/download` streams the full log (or just the matching lines) as `run-<id>.log`
|
|
780
|
+
- **Bounded replay:** The log stream accepts `?tail=N` so opening a 30,000-line run replays only the trailing window instead of the entire history
|
|
781
|
+
- **Index:** New `d2v_run_logs (run_id, level, seq)` index keeps level filtering fast on long runs
|
|
782
|
+
|
|
724
783
|
### Postgres Markdown Store
|
|
725
784
|
- **New feature:** Optionally store the generated markdown for each crawled website URL in a Postgres table (`url`, `product_name`, `markdown`, `updated_at`)
|
|
726
785
|
- **Top-level configuration:** Configure Postgres connection once at the top level via `connection_string` or individual `host`/`port`/`database`/`user`/`password` fields, with environment variable substitution support
|
|
@@ -462,8 +462,14 @@ class ContentProcessor {
|
|
|
462
462
|
let pagesSinceRestart = 0;
|
|
463
463
|
// Restart the browser every N pages to prevent memory accumulation
|
|
464
464
|
const PAGES_BETWEEN_RESTARTS = 50;
|
|
465
|
-
|
|
466
|
-
|
|
465
|
+
// Planned restarts (first launch, the every-N-pages recycle) are routine
|
|
466
|
+
// and would otherwise dominate a crawl's warning count; only unplanned
|
|
467
|
+
// ones — a disconnected or degraded browser — deserve a warning.
|
|
468
|
+
const restartBrowser = async (reason, planned = false) => {
|
|
469
|
+
if (planned)
|
|
470
|
+
logger.info(`Restarting browser: ${reason}`);
|
|
471
|
+
else
|
|
472
|
+
logger.warn(`Restarting browser: ${reason}`);
|
|
467
473
|
if (browser) {
|
|
468
474
|
try {
|
|
469
475
|
await browser.close();
|
|
@@ -485,11 +491,11 @@ class ContentProcessor {
|
|
|
485
491
|
const reason = needsBrowserRestart
|
|
486
492
|
? `protocol errors persisting after page recreation (${consecutiveProtocolErrors} consecutive)`
|
|
487
493
|
: browser ? 'browser disconnected' : 'initial launch';
|
|
488
|
-
await restartBrowser(reason);
|
|
494
|
+
await restartBrowser(reason, !browser && !needsBrowserRestart);
|
|
489
495
|
}
|
|
490
496
|
else if (pagesSinceRestart >= PAGES_BETWEEN_RESTARTS) {
|
|
491
497
|
// Periodic restart to prevent memory accumulation from long crawls
|
|
492
|
-
await restartBrowser(`periodic restart after ${pagesSinceRestart} pages
|
|
498
|
+
await restartBrowser(`periodic restart after ${pagesSinceRestart} pages`, true);
|
|
493
499
|
}
|
|
494
500
|
else if (pageNeedsRecreation) {
|
|
495
501
|
// The previous page.evaluate or navigation timed out / errored.
|
|
@@ -1539,7 +1545,17 @@ class ContentProcessor {
|
|
|
1539
1545
|
let skippedFiles = 0;
|
|
1540
1546
|
for (const file of files) {
|
|
1541
1547
|
const filePath = path.join(dirPath, file);
|
|
1542
|
-
|
|
1548
|
+
// A dangling symlink makes statSync throw ENOENT; skip the
|
|
1549
|
+
// entry rather than abandoning the rest of the directory
|
|
1550
|
+
let stat;
|
|
1551
|
+
try {
|
|
1552
|
+
stat = fs.statSync(filePath);
|
|
1553
|
+
}
|
|
1554
|
+
catch (error) {
|
|
1555
|
+
logger.warn(`Skipping unreadable entry ${filePath}:`, error);
|
|
1556
|
+
skippedFiles++;
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1543
1559
|
// Skip already visited paths
|
|
1544
1560
|
if (visitedPaths.has(filePath)) {
|
|
1545
1561
|
logger.debug(`Skipping already visited path: ${filePath}`);
|
|
@@ -1629,6 +1645,7 @@ class ContentProcessor {
|
|
|
1629
1645
|
const excludeExtensions = config.exclude_extensions || [];
|
|
1630
1646
|
const encoding = config.encoding || 'utf8';
|
|
1631
1647
|
let maxMtime = 0;
|
|
1648
|
+
let incomplete = false;
|
|
1632
1649
|
try {
|
|
1633
1650
|
const files = fs.readdirSync(dirPath);
|
|
1634
1651
|
let processedFiles = 0;
|
|
@@ -1638,7 +1655,19 @@ class ContentProcessor {
|
|
|
1638
1655
|
const trackFiles = options?.trackFiles;
|
|
1639
1656
|
for (const file of files) {
|
|
1640
1657
|
const filePath = path.join(dirPath, file);
|
|
1641
|
-
|
|
1658
|
+
// statSync follows symlinks, so a dangling one (common in
|
|
1659
|
+
// vendored forks) throws ENOENT. Skipping the entry keeps the
|
|
1660
|
+
// rest of the directory — letting it throw used to abandon
|
|
1661
|
+
// every sibling after it, and cleanup then deleted their chunks.
|
|
1662
|
+
let stat;
|
|
1663
|
+
try {
|
|
1664
|
+
stat = fs.statSync(filePath);
|
|
1665
|
+
}
|
|
1666
|
+
catch (error) {
|
|
1667
|
+
logger.warn(`Skipping unreadable entry ${filePath}:`, error);
|
|
1668
|
+
skippedFiles++;
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1642
1671
|
if (visitedPaths.has(filePath)) {
|
|
1643
1672
|
logger.debug(`Skipping already visited path: ${filePath}`);
|
|
1644
1673
|
continue;
|
|
@@ -1650,6 +1679,7 @@ class ContentProcessor {
|
|
|
1650
1679
|
processedFiles += childResult.processedFiles;
|
|
1651
1680
|
skippedFiles += childResult.skippedFiles;
|
|
1652
1681
|
maxMtime = Math.max(maxMtime, childResult.maxMtime);
|
|
1682
|
+
incomplete = incomplete || childResult.incomplete;
|
|
1653
1683
|
}
|
|
1654
1684
|
else {
|
|
1655
1685
|
logger.debug(`Skipping directory ${filePath} (recursive=false)`);
|
|
@@ -1694,11 +1724,14 @@ class ContentProcessor {
|
|
|
1694
1724
|
}
|
|
1695
1725
|
}
|
|
1696
1726
|
logger.info(`Code directory processed. Processed: ${processedFiles}, Skipped: ${skippedFiles}`);
|
|
1697
|
-
return { processedFiles, skippedFiles, maxMtime };
|
|
1727
|
+
return { processedFiles, skippedFiles, maxMtime, incomplete };
|
|
1698
1728
|
}
|
|
1699
1729
|
catch (error) {
|
|
1730
|
+
// The directory could not be listed at all — report the scan as
|
|
1731
|
+
// incomplete so the caller skips cleanup instead of treating every
|
|
1732
|
+
// file under this path as deleted
|
|
1700
1733
|
logger.error(`Error reading code directory ${dirPath}:`, error);
|
|
1701
|
-
return { processedFiles: 0, skippedFiles: 0, maxMtime };
|
|
1734
|
+
return { processedFiles: 0, skippedFiles: 0, maxMtime, incomplete: true };
|
|
1702
1735
|
}
|
|
1703
1736
|
}
|
|
1704
1737
|
async getTokenChunker(chunkSize) {
|
|
@@ -1841,7 +1874,9 @@ class ContentProcessor {
|
|
|
1841
1874
|
if (!content) {
|
|
1842
1875
|
continue;
|
|
1843
1876
|
}
|
|
1844
|
-
|
|
1877
|
+
// See chunkMarkdown: an unpaired surrogate makes the store's JSON
|
|
1878
|
+
// body invalid, so sanitize before the id is derived from the text.
|
|
1879
|
+
const searchableText = utils_1.Utils.stripLoneSurrogates(contextPrefix + content);
|
|
1845
1880
|
const chunkId = utils_1.Utils.generateHash(`${url}::${searchableText}`);
|
|
1846
1881
|
documentChunks.push({
|
|
1847
1882
|
content: searchableText,
|
|
@@ -1913,7 +1948,10 @@ class ContentProcessor {
|
|
|
1913
1948
|
// to searches for parent topics even if the body doesn't mention them.
|
|
1914
1949
|
const breadcrumbs = hierarchy.filter(h => h).join(" > ");
|
|
1915
1950
|
const contextPrefix = breadcrumbs ? `[Topic: ${breadcrumbs}]\n` : "";
|
|
1916
|
-
|
|
1951
|
+
// Sanitize before hashing so the id and the stored text always agree:
|
|
1952
|
+
// an unpaired surrogate (from a split pair, or straight from the
|
|
1953
|
+
// source) makes the JSON body invalid and the write fails.
|
|
1954
|
+
const searchableText = utils_1.Utils.stripLoneSurrogates(contextPrefix + content.trim());
|
|
1917
1955
|
const chunkId = utils_1.Utils.generateHash(searchableText);
|
|
1918
1956
|
const chunk = {
|
|
1919
1957
|
content: searchableText,
|
|
@@ -1954,7 +1992,11 @@ class ContentProcessor {
|
|
|
1954
1992
|
// If the section is a massive guide, split it but keep headers on every sub-piece.
|
|
1955
1993
|
const overlapSize = Math.floor(MAX_CHARS * OVERLAP_PERCENT);
|
|
1956
1994
|
for (let i = 0; i < trimmedBuffer.length; i += (MAX_CHARS - overlapSize)) {
|
|
1957
|
-
|
|
1995
|
+
// Slice on code points, not code units: a boundary falling
|
|
1996
|
+
// inside a surrogate pair (emoji, some CJK) would leave a
|
|
1997
|
+
// lone surrogate, which Qdrant's JSON parser rejects — the
|
|
1998
|
+
// chunk then fails to store and is silently lost.
|
|
1999
|
+
const subContent = utils_1.Utils.sliceSafe(trimmedBuffer, i, i + MAX_CHARS);
|
|
1958
2000
|
chunks.push(createDocumentChunk(subContent, topicHierarchy));
|
|
1959
2001
|
}
|
|
1960
2002
|
}
|
|
@@ -52,4 +52,9 @@ exports.MIGRATIONS = [
|
|
|
52
52
|
PRIMARY KEY (run_id, seq)
|
|
53
53
|
);
|
|
54
54
|
`,
|
|
55
|
+
// 2: index for server-side log filtering by level (error/warn lookups on
|
|
56
|
+
// long runs, where the client only keeps a trailing window in memory)
|
|
57
|
+
`
|
|
58
|
+
CREATE INDEX IF NOT EXISTS d2v_run_logs_level_idx ON d2v_run_logs (run_id, level, seq);
|
|
59
|
+
`,
|
|
55
60
|
];
|
|
@@ -44,6 +44,16 @@ const chunk_inspector_1 = require("./chunk-inspector");
|
|
|
44
44
|
const config_registry_1 = require("./config-registry");
|
|
45
45
|
const types_1 = require("./types");
|
|
46
46
|
const SSE_HEARTBEAT_MS = 15000;
|
|
47
|
+
const LOG_PAGE_SIZE = 5000;
|
|
48
|
+
const LOG_LEVELS = new Set(['error', 'warn', 'info', 'debug']);
|
|
49
|
+
/** Shared parsing for the `levels` (csv) and `q` log query parameters. */
|
|
50
|
+
function parseLogFilter(req) {
|
|
51
|
+
const levels = typeof req.query.levels === 'string'
|
|
52
|
+
? req.query.levels.split(',').map(l => l.trim().toLowerCase()).filter(l => LOG_LEVELS.has(l))
|
|
53
|
+
: [];
|
|
54
|
+
const keyword = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
|
55
|
+
return { levels, keyword: keyword || undefined };
|
|
56
|
+
}
|
|
47
57
|
function getVersion() {
|
|
48
58
|
try {
|
|
49
59
|
return require('../../package.json').version;
|
|
@@ -202,7 +212,39 @@ function createServer(deps) {
|
|
|
202
212
|
throw new types_1.NotFoundError('run not found');
|
|
203
213
|
const afterSeq = Number(req.query.afterSeq) || 0;
|
|
204
214
|
const limit = Number(req.query.limit) || 1000;
|
|
205
|
-
res.json(await store.getLogs(id, afterSeq, limit));
|
|
215
|
+
res.json(await store.getLogs(id, afterSeq, limit, parseLogFilter(req)));
|
|
216
|
+
});
|
|
217
|
+
/** Whole-run line totals per level — the UI can only hold a trailing window. */
|
|
218
|
+
app.get('/api/runs/:id/logs/counts', async (req, res) => {
|
|
219
|
+
const id = parseId(String(req.params.id));
|
|
220
|
+
if (!await store.getRun(id))
|
|
221
|
+
throw new types_1.NotFoundError('run not found');
|
|
222
|
+
res.json(await store.countLogsByLevel(id));
|
|
223
|
+
});
|
|
224
|
+
// Full log as a plain-text download: the escape hatch when the viewer's
|
|
225
|
+
// in-memory window or filters aren't enough (grep it locally, attach it).
|
|
226
|
+
app.get('/api/runs/:id/logs/download', async (req, res) => {
|
|
227
|
+
const id = parseId(String(req.params.id));
|
|
228
|
+
if (!await store.getRun(id))
|
|
229
|
+
throw new types_1.NotFoundError('run not found');
|
|
230
|
+
const filter = parseLogFilter(req);
|
|
231
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
232
|
+
res.setHeader('Content-Disposition', `attachment; filename="run-${id}.log"`);
|
|
233
|
+
let afterSeq = 0;
|
|
234
|
+
let batch;
|
|
235
|
+
do {
|
|
236
|
+
batch = await store.getLogs(id, afterSeq, LOG_PAGE_SIZE, filter);
|
|
237
|
+
for (const row of batch) {
|
|
238
|
+
const module = row.module ? ` [${row.module}]` : '';
|
|
239
|
+
// pg hands back TIMESTAMPTZ as a Date; JSON responses serialise
|
|
240
|
+
// it for us, a text body has to do it explicitly
|
|
241
|
+
const ts = new Date(row.ts).toISOString();
|
|
242
|
+
res.write(`${ts} ${row.level.toUpperCase().padEnd(5)}${module} ${row.message}\n`);
|
|
243
|
+
}
|
|
244
|
+
if (batch.length > 0)
|
|
245
|
+
afterSeq = batch[batch.length - 1].seq;
|
|
246
|
+
} while (batch.length === LOG_PAGE_SIZE);
|
|
247
|
+
res.end();
|
|
206
248
|
});
|
|
207
249
|
app.post('/api/runs/:id/cancel', async (req, res) => {
|
|
208
250
|
res.json(await runner.cancel(parseId(String(req.params.id))));
|
|
@@ -227,7 +269,12 @@ function createServer(deps) {
|
|
|
227
269
|
if (!run)
|
|
228
270
|
throw new types_1.NotFoundError('run not found');
|
|
229
271
|
sseInit(res);
|
|
272
|
+
// `tail` keeps the replay to the trailing window the viewer can hold —
|
|
273
|
+
// older lines stay reachable through the filter/download endpoints.
|
|
274
|
+
const tail = Number(req.query.tail) || 0;
|
|
230
275
|
let lastSeq = Number(req.query.afterSeq) || 0;
|
|
276
|
+
if (!lastSeq && tail > 0)
|
|
277
|
+
lastSeq = await store.getTailStartSeq(id, tail);
|
|
231
278
|
let replayDone = false;
|
|
232
279
|
const pending = [];
|
|
233
280
|
const sendRow = (row) => {
|
|
@@ -259,9 +306,9 @@ function createServer(deps) {
|
|
|
259
306
|
try {
|
|
260
307
|
let batch;
|
|
261
308
|
do {
|
|
262
|
-
batch = await store.getLogs(id, lastSeq,
|
|
309
|
+
batch = await store.getLogs(id, lastSeq, LOG_PAGE_SIZE);
|
|
263
310
|
batch.forEach(sendRow);
|
|
264
|
-
} while (batch.length ===
|
|
311
|
+
} while (batch.length === LOG_PAGE_SIZE);
|
|
265
312
|
}
|
|
266
313
|
finally {
|
|
267
314
|
replayDone = true;
|
package/dist/controller/store.js
CHANGED
|
@@ -3,6 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.ControllerStore = void 0;
|
|
4
4
|
const pg_1 = require("pg");
|
|
5
5
|
const migrations_1 = require("./migrations");
|
|
6
|
+
/** Neutralise LIKE wildcards so a keyword search stays a literal substring match. */
|
|
7
|
+
function escapeLike(value) {
|
|
8
|
+
return value.replace(/[\\%_]/g, ch => `\\${ch}`);
|
|
9
|
+
}
|
|
6
10
|
/**
|
|
7
11
|
* All Postgres access for controller mode: configs, runs, and run logs.
|
|
8
12
|
*/
|
|
@@ -143,11 +147,44 @@ class ControllerStore {
|
|
|
143
147
|
await this.pool.query(`INSERT INTO d2v_run_logs (run_id, seq, ts, level, module, message) VALUES ${placeholders.join(', ')}
|
|
144
148
|
ON CONFLICT DO NOTHING`, values);
|
|
145
149
|
}
|
|
146
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Page through a run's logs. Filtering happens here rather than in the
|
|
152
|
+
* browser so that a search covers the whole run: the UI only holds a
|
|
153
|
+
* trailing window of a long run's output in memory.
|
|
154
|
+
*/
|
|
155
|
+
async getLogs(runId, afterSeq = 0, limit = 1000, filter = {}) {
|
|
156
|
+
const values = [runId, afterSeq];
|
|
157
|
+
let where = 'run_id = $1 AND seq > $2';
|
|
158
|
+
if (filter.levels?.length) {
|
|
159
|
+
values.push(filter.levels);
|
|
160
|
+
where += ` AND level = ANY($${values.length})`;
|
|
161
|
+
}
|
|
162
|
+
if (filter.keyword) {
|
|
163
|
+
values.push(`%${escapeLike(filter.keyword)}%`);
|
|
164
|
+
where += ` AND (message ILIKE $${values.length} ESCAPE '\\' OR module ILIKE $${values.length} ESCAPE '\\')`;
|
|
165
|
+
}
|
|
166
|
+
values.push(Math.min(limit, 5000));
|
|
147
167
|
const { rows } = await this.pool.query(`SELECT seq, ts, level, module, message FROM d2v_run_logs
|
|
148
|
-
WHERE
|
|
168
|
+
WHERE ${where} ORDER BY seq LIMIT $${values.length}`, values);
|
|
149
169
|
return rows.map((r) => ({ ...r, seq: Number(r.seq) }));
|
|
150
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Seq to replay from so that only the last `tail` lines follow, i.e. the
|
|
173
|
+
* seq of the (tail + 1)-th newest line — 0 when the run is shorter.
|
|
174
|
+
*/
|
|
175
|
+
async getTailStartSeq(runId, tail) {
|
|
176
|
+
const { rows } = await this.pool.query(`SELECT seq FROM d2v_run_logs WHERE run_id = $1 ORDER BY seq DESC OFFSET $2 LIMIT 1`, [runId, Math.max(0, tail)]);
|
|
177
|
+
return rows.length > 0 ? Number(rows[0].seq) : 0;
|
|
178
|
+
}
|
|
179
|
+
/** Per-level line totals for a whole run, for the log viewer's filter chips. */
|
|
180
|
+
async countLogsByLevel(runId) {
|
|
181
|
+
const { rows } = await this.pool.query(`SELECT level, COUNT(*)::bigint AS count FROM d2v_run_logs
|
|
182
|
+
WHERE run_id = $1 GROUP BY level`, [runId]);
|
|
183
|
+
const counts = {};
|
|
184
|
+
for (const row of rows)
|
|
185
|
+
counts[row.level] = Number(row.count);
|
|
186
|
+
return counts;
|
|
187
|
+
}
|
|
151
188
|
async pruneOldLogs(retentionDays) {
|
|
152
189
|
const { rowCount } = await this.pool.query(`DELETE FROM d2v_run_logs USING d2v_runs
|
|
153
190
|
WHERE d2v_run_logs.run_id = d2v_runs.id
|
package/dist/doc2vec.js
CHANGED
|
@@ -254,22 +254,31 @@ class Doc2Vec {
|
|
|
254
254
|
// Get the last run date from the database
|
|
255
255
|
const startDate = sourceConfig.start_date || '2025-01-01';
|
|
256
256
|
const lastRunDate = await database_1.DatabaseManager.getLastRunDate(dbConnection, repo, `${startDate}T00:00:00Z`, logger);
|
|
257
|
-
|
|
257
|
+
// Retryable: 5xx, 429, rate-limited 403s, and network/timeout errors.
|
|
258
|
+
// Everything else is deterministic — the same request fails identically,
|
|
259
|
+
// so retrying only burns wall-clock. In particular a 422 from deep
|
|
260
|
+
// page-number pagination used to cost ~75s of backoff before failing.
|
|
261
|
+
const isRetryableStatus = (status) => status === undefined || status >= 500 || status === 429;
|
|
262
|
+
const requestWithRetry = async (url, params, retries = 5, delay = 5000) => {
|
|
258
263
|
for (let attempt = 0; attempt < retries; attempt++) {
|
|
259
264
|
try {
|
|
260
265
|
// Only log on retries to reduce noise during pagination
|
|
261
266
|
if (attempt > 0) {
|
|
262
267
|
logger.debug(`GitHub API retry: ${url} (attempt ${attempt + 1}/${retries})`);
|
|
263
268
|
}
|
|
269
|
+
// Callers need the response headers (Link) for cursor
|
|
270
|
+
// pagination, so the whole response is returned.
|
|
264
271
|
const response = await axios_1.default.get(url, {
|
|
265
272
|
headers: {
|
|
266
273
|
Authorization: `token ${GITHUB_TOKEN}`,
|
|
267
274
|
Accept: 'application/vnd.github.v3+json',
|
|
268
275
|
},
|
|
269
|
-
|
|
276
|
+
// Subsequent pages come from a Link URL that already
|
|
277
|
+
// carries its query string — adding params would duplicate it
|
|
278
|
+
...(params && { params }),
|
|
270
279
|
timeout: 30000, // 30 second timeout
|
|
271
280
|
});
|
|
272
|
-
return response
|
|
281
|
+
return response;
|
|
273
282
|
}
|
|
274
283
|
catch (error) {
|
|
275
284
|
// Enhanced error logging for debugging
|
|
@@ -304,6 +313,13 @@ class Doc2Vec {
|
|
|
304
313
|
throw error;
|
|
305
314
|
}
|
|
306
315
|
}
|
|
316
|
+
else if (!isRetryableStatus(error.response?.status)) {
|
|
317
|
+
// Deterministic failure (422, 404, 401, ...) — surface the
|
|
318
|
+
// API's own message, which explains what to do instead.
|
|
319
|
+
const apiMessage = error.response?.data?.message;
|
|
320
|
+
logger.error(`GitHub API returned ${error.response.status} (not retryable): ${apiMessage || error.message}`);
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
307
323
|
else {
|
|
308
324
|
// For non-403 errors, wait before retrying (exponential backoff)
|
|
309
325
|
if (attempt < retries - 1) {
|
|
@@ -320,23 +336,45 @@ class Doc2Vec {
|
|
|
320
336
|
}
|
|
321
337
|
throw new Error('Max retries reached');
|
|
322
338
|
};
|
|
339
|
+
/**
|
|
340
|
+
* Walk every issue updated since `sinceDate` by following GitHub's
|
|
341
|
+
* `Link: rel="next"` cursor URLs.
|
|
342
|
+
*
|
|
343
|
+
* Page-number pagination (`?page=N`) cannot be used here: GitHub caps it
|
|
344
|
+
* on this endpoint and answers HTTP 422 ("Pagination with the page
|
|
345
|
+
* parameter is not supported for large datasets, please use cursor based
|
|
346
|
+
* pagination") once the offset grows past its limit. That limit is well
|
|
347
|
+
* below the size of a full-window rebuild on a busy repo, so page-based
|
|
348
|
+
* paging aborted the whole source and left the collection empty. The
|
|
349
|
+
* Link URL already contains the opaque `after=` cursor and is passed
|
|
350
|
+
* through verbatim.
|
|
351
|
+
*/
|
|
323
352
|
const fetchAllIssues = async (sinceDate) => {
|
|
324
353
|
let issues = [];
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
354
|
+
// sort=updated&direction=asc makes the walk stable: results are
|
|
355
|
+
// ordered by the same field `since` filters on, so a page boundary
|
|
356
|
+
// can't shuffle items in or out mid-walk.
|
|
357
|
+
let url = GITHUB_API_URL;
|
|
358
|
+
let params = {
|
|
359
|
+
per_page: 100,
|
|
360
|
+
state: 'all',
|
|
361
|
+
since: sinceDate,
|
|
362
|
+
sort: 'updated',
|
|
363
|
+
direction: 'asc',
|
|
364
|
+
};
|
|
365
|
+
let page = 0;
|
|
366
|
+
while (url) {
|
|
367
|
+
page++;
|
|
328
368
|
// Log progress every 10 pages to reduce noise
|
|
329
369
|
if (page === 1 || page % 10 === 0) {
|
|
330
370
|
logger.debug(`Fetching issues page ${page}... (${issues.length} issues so far)`);
|
|
331
371
|
}
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
since: sinceDate,
|
|
337
|
-
});
|
|
338
|
-
if (data.length === 0)
|
|
372
|
+
const response = await requestWithRetry(url, params);
|
|
373
|
+
const data = response.data;
|
|
374
|
+
if (!Array.isArray(data)) {
|
|
375
|
+
logger.warn(`Unexpected non-array response while paginating issues for ${repo}; stopping the walk`);
|
|
339
376
|
break;
|
|
377
|
+
}
|
|
340
378
|
// The `since` param already scopes results to issues whose
|
|
341
379
|
// updated_at is after sinceDate — i.e. anything created, closed,
|
|
342
380
|
// reopened, edited, or newly commented since the last run. Do NOT
|
|
@@ -344,19 +382,45 @@ class Doc2Vec {
|
|
|
344
382
|
// received a new comment has an old created_at and would be
|
|
345
383
|
// dropped, leaving its status and comments permanently stale.
|
|
346
384
|
issues = issues.concat(data);
|
|
347
|
-
|
|
385
|
+
const nextUrl = utils_1.Utils.parseNextLink(response.headers?.link ?? response.headers?.Link);
|
|
386
|
+
// Guard against a server echoing the same cursor forever
|
|
387
|
+
if (!nextUrl || nextUrl === url)
|
|
348
388
|
break;
|
|
349
|
-
|
|
389
|
+
url = nextUrl;
|
|
390
|
+
params = undefined; // the cursor URL is already fully-formed
|
|
350
391
|
}
|
|
392
|
+
logger.info(`Fetched ${issues.length} issues/PRs for ${repo} across ${page} page(s)`);
|
|
351
393
|
return issues;
|
|
352
394
|
};
|
|
353
395
|
const fetchIssueComments = async (issueNumber) => {
|
|
354
|
-
|
|
355
|
-
|
|
396
|
+
// GitHub returns 30 comments per page by default and paginates the
|
|
397
|
+
// rest behind Link headers; requesting the first page only silently
|
|
398
|
+
// truncated long discussions, so the stored chunks were missing the
|
|
399
|
+
// newest comments.
|
|
400
|
+
const comments = [];
|
|
401
|
+
let url = `${GITHUB_API_URL}/${issueNumber}/comments`;
|
|
402
|
+
let params = { per_page: 100 };
|
|
403
|
+
while (url) {
|
|
404
|
+
const response = await requestWithRetry(url, params);
|
|
405
|
+
if (!Array.isArray(response.data))
|
|
406
|
+
break;
|
|
407
|
+
comments.push(...response.data);
|
|
408
|
+
const nextUrl = utils_1.Utils.parseNextLink(response.headers?.link ?? response.headers?.Link);
|
|
409
|
+
if (!nextUrl || nextUrl === url)
|
|
410
|
+
break;
|
|
411
|
+
url = nextUrl;
|
|
412
|
+
params = undefined;
|
|
413
|
+
}
|
|
414
|
+
return comments;
|
|
356
415
|
};
|
|
357
416
|
const generateMarkdownForIssue = async (issue) => {
|
|
358
417
|
const comments = await fetchIssueComments(issue.number);
|
|
359
|
-
|
|
418
|
+
// This endpoint returns both issues and pull requests; only PRs carry
|
|
419
|
+
// a `pull_request` object. Labelling every item "Issue #N" made
|
|
420
|
+
// retrieved PR chunks read as issues, so use the real kind.
|
|
421
|
+
const itemLabel = issue.pull_request ? 'PR' : 'Issue';
|
|
422
|
+
let md = `# ${itemLabel} #${issue.number}: ${issue.title}\n\n`;
|
|
423
|
+
md += `- **Type:** ${issue.pull_request ? 'Pull request' : 'Issue'}\n`;
|
|
360
424
|
md += `- **Author:** ${issue.user.login}\n`;
|
|
361
425
|
md += `- **State:** ${issue.state}\n`;
|
|
362
426
|
md += `- **Created on:** ${new Date(issue.created_at).toDateString()}\n`;
|
|
@@ -373,6 +437,9 @@ class Doc2Vec {
|
|
|
373
437
|
}
|
|
374
438
|
return md;
|
|
375
439
|
};
|
|
440
|
+
// Chunks that could not be stored (e.g. a rejected Qdrant upsert).
|
|
441
|
+
// Tallied across all issues and escalated once the walk completes.
|
|
442
|
+
let failedChunks = 0;
|
|
376
443
|
// Process a single issue and store its chunks
|
|
377
444
|
const processIssue = async (issue) => {
|
|
378
445
|
const issueNumber = issue.number;
|
|
@@ -461,7 +528,11 @@ class Doc2Vec {
|
|
|
461
528
|
}
|
|
462
529
|
}
|
|
463
530
|
catch (error) {
|
|
464
|
-
|
|
531
|
+
// Keep going so one bad chunk can't cost the whole repo,
|
|
532
|
+
// but remember it: the count is escalated below so the run
|
|
533
|
+
// fails and the last-run date is not advanced.
|
|
534
|
+
failedChunks++;
|
|
535
|
+
logger.error(`Error processing chunk ${chunkId} for ${url} in Qdrant:`, error);
|
|
465
536
|
}
|
|
466
537
|
}
|
|
467
538
|
}
|
|
@@ -474,6 +545,12 @@ class Doc2Vec {
|
|
|
474
545
|
logger.info(`Processing issue ${i + 1}/${issues.length}`);
|
|
475
546
|
await processIssue(issues[i]);
|
|
476
547
|
}
|
|
548
|
+
// A chunk that failed to store is silent data loss, so refuse to advance
|
|
549
|
+
// the last-run date: the next run reprocesses this window instead of
|
|
550
|
+
// treating the gap as synced. Everything that did store is kept.
|
|
551
|
+
if (failedChunks > 0) {
|
|
552
|
+
throw new Error(`${failedChunks} chunk(s) failed to store for ${repo}; not advancing the last-run date so the next run retries them`);
|
|
553
|
+
}
|
|
477
554
|
// Update the last run date in the database after processing all issues
|
|
478
555
|
await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger, this.embeddingDimension, syncStartDate);
|
|
479
556
|
logger.info(`Successfully processed ${issues.length} issues`);
|
|
@@ -974,6 +1051,10 @@ class Doc2Vec {
|
|
|
974
1051
|
let lastMtimeKey;
|
|
975
1052
|
let trackedFiles;
|
|
976
1053
|
let maxObservedMtime = 0;
|
|
1054
|
+
// Set when a directory could not be listed: the files we saw are only a
|
|
1055
|
+
// subset of what is on disk, so nothing may be treated as deleted and no
|
|
1056
|
+
// "last scanned" marker may advance past this run
|
|
1057
|
+
let scanIncomplete = false;
|
|
977
1058
|
if (config.source === 'local_directory') {
|
|
978
1059
|
if (!config.path) {
|
|
979
1060
|
logger.error('Code source type local_directory requires a path.');
|
|
@@ -1055,13 +1136,20 @@ class Doc2Vec {
|
|
|
1055
1136
|
mtimeCutoff,
|
|
1056
1137
|
trackFiles: trackedFiles
|
|
1057
1138
|
});
|
|
1139
|
+
scanIncomplete = scanResult.incomplete;
|
|
1140
|
+
if (scanIncomplete) {
|
|
1141
|
+
logger.error('Directory scan was incomplete — skipping cleanup so unscanned files keep their chunks');
|
|
1142
|
+
}
|
|
1058
1143
|
if (trackedFiles) {
|
|
1059
1144
|
maxObservedMtime = scanResult.maxMtime;
|
|
1060
1145
|
}
|
|
1061
1146
|
}
|
|
1062
1147
|
finally {
|
|
1063
1148
|
logger.section('CLEANUP');
|
|
1064
|
-
if (
|
|
1149
|
+
if (scanIncomplete) {
|
|
1150
|
+
logger.warn('Skipping cleanup and last-scanned markers: the scan did not cover the whole tree');
|
|
1151
|
+
}
|
|
1152
|
+
else if (incrementalMode) {
|
|
1065
1153
|
if (deleteUrls.length > 0) {
|
|
1066
1154
|
logger.info(`Cleaning up ${deleteUrls.length} deleted/renamed files`);
|
|
1067
1155
|
for (const url of deleteUrls) {
|
|
@@ -1120,7 +1208,9 @@ class Doc2Vec {
|
|
|
1120
1208
|
this.counters.items_deleted += removed.items;
|
|
1121
1209
|
this.counters.chunks_deleted += removed.chunks;
|
|
1122
1210
|
}
|
|
1123
|
-
|
|
1211
|
+
// Storing the SHA after an incomplete scan would make the next run
|
|
1212
|
+
// diff from it and never revisit the files this run missed
|
|
1213
|
+
if (config.source === 'github' && basePath && repoBranch && !scanIncomplete) {
|
|
1124
1214
|
const headSha = await this.getRepoHeadSha(basePath, logger);
|
|
1125
1215
|
if (headSha) {
|
|
1126
1216
|
const shaKey = this.buildCodeShaMetadataKey(config.repo, repoBranch);
|
|
@@ -1137,6 +1227,11 @@ class Doc2Vec {
|
|
|
1137
1227
|
}
|
|
1138
1228
|
}
|
|
1139
1229
|
}
|
|
1230
|
+
// Fail the source rather than reporting a partial ingest as a success —
|
|
1231
|
+
// the collection is still serving whatever it had for the unscanned files
|
|
1232
|
+
if (scanIncomplete) {
|
|
1233
|
+
throw new Error(`Code scan of ${basePath} was incomplete: at least one directory could not be read`);
|
|
1234
|
+
}
|
|
1140
1235
|
logger.info(`Finished processing code source (${config.source})`);
|
|
1141
1236
|
}
|
|
1142
1237
|
buildCodeShaMetadataKey(repo, branch) {
|