doc2vec 2.10.4 → 2.10.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/controller/config-registry.js +277 -0
- package/dist/controller/events.js +29 -0
- package/dist/controller/index.js +139 -0
- package/dist/controller/job-runner.js +314 -0
- package/dist/controller/migrations.js +55 -0
- package/dist/controller/notifier.js +106 -0
- package/dist/controller/scheduler.js +54 -0
- package/dist/controller/server.js +288 -0
- package/dist/controller/store.js +186 -0
- package/dist/controller/types.js +25 -0
- package/dist/database.js +79 -46
- package/dist/doc2vec.js +20 -4
- package/dist/ui/assets/index-BVg6n7Si.css +1 -0
- package/dist/ui/assets/index-D-QOw3rk.js +126 -0
- package/dist/ui/index.html +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.createServer = createServer;
|
|
40
|
+
const express_1 = __importDefault(require("express"));
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const config_registry_1 = require("./config-registry");
|
|
44
|
+
const types_1 = require("./types");
|
|
45
|
+
const SSE_HEARTBEAT_MS = 15000;
|
|
46
|
+
function getVersion() {
|
|
47
|
+
try {
|
|
48
|
+
return require('../../package.json').version;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return 'unknown';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function sseInit(res) {
|
|
55
|
+
res.writeHead(200, {
|
|
56
|
+
'Content-Type': 'text/event-stream',
|
|
57
|
+
'Cache-Control': 'no-cache',
|
|
58
|
+
'Connection': 'keep-alive',
|
|
59
|
+
'X-Accel-Buffering': 'no',
|
|
60
|
+
});
|
|
61
|
+
res.write(':ok\n\n');
|
|
62
|
+
}
|
|
63
|
+
function sseSend(res, event, data) {
|
|
64
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
65
|
+
}
|
|
66
|
+
function createServer(deps) {
|
|
67
|
+
const { store, registry, scheduler, runner, events, readWrite, logger } = deps;
|
|
68
|
+
const app = (0, express_1.default)();
|
|
69
|
+
app.use(express_1.default.json({ limit: '2mb' }));
|
|
70
|
+
const requireReadWrite = (_req, res, next) => {
|
|
71
|
+
if (!readWrite) {
|
|
72
|
+
res.status(403).json({ error: 'controller is running in read-only mode' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
next();
|
|
76
|
+
};
|
|
77
|
+
const parseId = (raw) => {
|
|
78
|
+
const id = Number(raw);
|
|
79
|
+
if (!Number.isInteger(id) || id <= 0)
|
|
80
|
+
throw new types_1.ValidationError('invalid id');
|
|
81
|
+
return id;
|
|
82
|
+
};
|
|
83
|
+
const enrichConfig = (config, lastRuns) => ({
|
|
84
|
+
...config,
|
|
85
|
+
next_run: scheduler.nextRun(config.id),
|
|
86
|
+
last_run: lastRuns.get(config.id) ?? null,
|
|
87
|
+
busy: runner.isBusy(config.id),
|
|
88
|
+
});
|
|
89
|
+
// ------------------------------------------------------------------ meta
|
|
90
|
+
app.get('/api/health', (_req, res) => {
|
|
91
|
+
res.json({ status: 'ok', mode: readWrite ? 'rw' : 'ro', version: getVersion() });
|
|
92
|
+
});
|
|
93
|
+
// ------------------------------------------------------------------ configs
|
|
94
|
+
app.get('/api/configs', async (_req, res) => {
|
|
95
|
+
const lastRuns = await store.getLastRuns();
|
|
96
|
+
res.json(registry.list().map(c => enrichConfig(c, lastRuns)));
|
|
97
|
+
});
|
|
98
|
+
app.get('/api/configs/:id', async (req, res) => {
|
|
99
|
+
const config = registry.get(parseId(String(req.params.id)));
|
|
100
|
+
if (!config)
|
|
101
|
+
throw new types_1.NotFoundError('config not found');
|
|
102
|
+
const lastRuns = await store.getLastRuns();
|
|
103
|
+
res.json(enrichConfig(config, lastRuns));
|
|
104
|
+
});
|
|
105
|
+
app.post('/api/configs', requireReadWrite, async (req, res) => {
|
|
106
|
+
const { filename, content } = req.body ?? {};
|
|
107
|
+
if (typeof filename !== 'string' || typeof content !== 'string') {
|
|
108
|
+
throw new types_1.ValidationError('body must contain filename and content strings');
|
|
109
|
+
}
|
|
110
|
+
const record = await registry.create(filename, content);
|
|
111
|
+
res.status(201).json(record);
|
|
112
|
+
});
|
|
113
|
+
app.put('/api/configs/:id', requireReadWrite, async (req, res) => {
|
|
114
|
+
const { content, baseHash } = req.body ?? {};
|
|
115
|
+
if (typeof content !== 'string' || typeof baseHash !== 'string') {
|
|
116
|
+
throw new types_1.ValidationError('body must contain content and baseHash strings');
|
|
117
|
+
}
|
|
118
|
+
const record = await registry.update(parseId(String(req.params.id)), content, baseHash);
|
|
119
|
+
res.json(record);
|
|
120
|
+
});
|
|
121
|
+
app.delete('/api/configs/:id', requireReadWrite, async (req, res) => {
|
|
122
|
+
await registry.delete(parseId(String(req.params.id)));
|
|
123
|
+
res.status(204).end();
|
|
124
|
+
});
|
|
125
|
+
app.post('/api/configs/validate', (req, res) => {
|
|
126
|
+
const { content } = req.body ?? {};
|
|
127
|
+
if (typeof content !== 'string') {
|
|
128
|
+
throw new types_1.ValidationError('body must contain a content string');
|
|
129
|
+
}
|
|
130
|
+
const meta = (0, config_registry_1.parseConfigMeta)(content, 'config.yaml');
|
|
131
|
+
res.json({
|
|
132
|
+
valid: !meta.parseError,
|
|
133
|
+
error: meta.parseError,
|
|
134
|
+
name: meta.name,
|
|
135
|
+
schedule: meta.schedule,
|
|
136
|
+
schedule_valid: meta.schedule ? (0, config_registry_1.isValidCron)(meta.schedule) : null,
|
|
137
|
+
sources: meta.sourceSummary,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
app.post('/api/configs/:id/run', async (req, res) => {
|
|
141
|
+
const config = registry.get(parseId(String(req.params.id)));
|
|
142
|
+
if (!config)
|
|
143
|
+
throw new types_1.NotFoundError('config not found');
|
|
144
|
+
const run = await runner.enqueue(config, 'manual');
|
|
145
|
+
res.status(202).json(run);
|
|
146
|
+
});
|
|
147
|
+
app.get('/api/configs/:id/stats', async (req, res) => {
|
|
148
|
+
const id = parseId(String(req.params.id));
|
|
149
|
+
if (!registry.get(id))
|
|
150
|
+
throw new types_1.NotFoundError('config not found');
|
|
151
|
+
const days = Number(req.query.days) || 30;
|
|
152
|
+
res.json(await store.getConfigStats(id, days));
|
|
153
|
+
});
|
|
154
|
+
// ------------------------------------------------------------------ runs
|
|
155
|
+
app.get('/api/runs', async (req, res) => {
|
|
156
|
+
res.json(await store.listRuns({
|
|
157
|
+
configId: req.query.configId !== undefined ? parseId(String(req.query.configId)) : undefined,
|
|
158
|
+
status: req.query.status !== undefined ? String(req.query.status) : undefined,
|
|
159
|
+
limit: req.query.limit !== undefined ? Number(req.query.limit) : undefined,
|
|
160
|
+
before: req.query.before !== undefined ? Number(req.query.before) : undefined,
|
|
161
|
+
}));
|
|
162
|
+
});
|
|
163
|
+
app.get('/api/runs/:id', async (req, res) => {
|
|
164
|
+
const run = await store.getRun(parseId(String(req.params.id)));
|
|
165
|
+
if (!run)
|
|
166
|
+
throw new types_1.NotFoundError('run not found');
|
|
167
|
+
const config = registry.get(run.config_id) ?? await store.getConfig(run.config_id);
|
|
168
|
+
res.json({ ...run, config_name: config?.name ?? null });
|
|
169
|
+
});
|
|
170
|
+
app.get('/api/runs/:id/logs', async (req, res) => {
|
|
171
|
+
const id = parseId(String(req.params.id));
|
|
172
|
+
if (!await store.getRun(id))
|
|
173
|
+
throw new types_1.NotFoundError('run not found');
|
|
174
|
+
const afterSeq = Number(req.query.afterSeq) || 0;
|
|
175
|
+
const limit = Number(req.query.limit) || 1000;
|
|
176
|
+
res.json(await store.getLogs(id, afterSeq, limit));
|
|
177
|
+
});
|
|
178
|
+
app.post('/api/runs/:id/cancel', async (req, res) => {
|
|
179
|
+
res.json(await runner.cancel(parseId(String(req.params.id))));
|
|
180
|
+
});
|
|
181
|
+
// ------------------------------------------------------------------ SSE
|
|
182
|
+
app.get('/api/events', (req, res) => {
|
|
183
|
+
sseInit(res);
|
|
184
|
+
const onRunUpdate = (run) => sseSend(res, 'run:update', run);
|
|
185
|
+
const onConfigUpdate = () => sseSend(res, 'config:update', {});
|
|
186
|
+
events.on('run:update', onRunUpdate);
|
|
187
|
+
events.on('config:update', onConfigUpdate);
|
|
188
|
+
const heartbeat = setInterval(() => res.write(':hb\n\n'), SSE_HEARTBEAT_MS);
|
|
189
|
+
req.on('close', () => {
|
|
190
|
+
clearInterval(heartbeat);
|
|
191
|
+
events.off('run:update', onRunUpdate);
|
|
192
|
+
events.off('config:update', onConfigUpdate);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
app.get('/api/runs/:id/logs/stream', async (req, res) => {
|
|
196
|
+
const id = parseId(String(req.params.id));
|
|
197
|
+
const run = await store.getRun(id);
|
|
198
|
+
if (!run)
|
|
199
|
+
throw new types_1.NotFoundError('run not found');
|
|
200
|
+
sseInit(res);
|
|
201
|
+
let lastSeq = Number(req.query.afterSeq) || 0;
|
|
202
|
+
let replayDone = false;
|
|
203
|
+
const pending = [];
|
|
204
|
+
const sendRow = (row) => {
|
|
205
|
+
if (row.seq > lastSeq) {
|
|
206
|
+
lastSeq = row.seq;
|
|
207
|
+
sseSend(res, 'log', row);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
const onLog = (payload) => {
|
|
211
|
+
if (payload.runId !== id)
|
|
212
|
+
return;
|
|
213
|
+
if (!replayDone) {
|
|
214
|
+
pending.push(...payload.lines);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
payload.lines.forEach(sendRow);
|
|
218
|
+
};
|
|
219
|
+
const onRunUpdate = (updated) => {
|
|
220
|
+
if (updated.id !== id)
|
|
221
|
+
return;
|
|
222
|
+
sseSend(res, 'run:update', updated);
|
|
223
|
+
if (['succeeded', 'failed', 'canceled', 'skipped'].includes(updated.status)) {
|
|
224
|
+
sseSend(res, 'end', { status: updated.status });
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
events.on('run:log', onLog);
|
|
228
|
+
events.on('run:update', onRunUpdate);
|
|
229
|
+
// Replay history from the DB, then flush anything that streamed in meanwhile
|
|
230
|
+
try {
|
|
231
|
+
let batch;
|
|
232
|
+
do {
|
|
233
|
+
batch = await store.getLogs(id, lastSeq, 5000);
|
|
234
|
+
batch.forEach(sendRow);
|
|
235
|
+
} while (batch.length === 5000);
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
replayDone = true;
|
|
239
|
+
pending.forEach(sendRow);
|
|
240
|
+
pending.length = 0;
|
|
241
|
+
}
|
|
242
|
+
const current = await store.getRun(id);
|
|
243
|
+
if (current && ['succeeded', 'failed', 'canceled', 'skipped'].includes(current.status)) {
|
|
244
|
+
sseSend(res, 'end', { status: current.status });
|
|
245
|
+
}
|
|
246
|
+
const heartbeat = setInterval(() => res.write(':hb\n\n'), SSE_HEARTBEAT_MS);
|
|
247
|
+
req.on('close', () => {
|
|
248
|
+
clearInterval(heartbeat);
|
|
249
|
+
events.off('run:log', onLog);
|
|
250
|
+
events.off('run:update', onRunUpdate);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
// ------------------------------------------------------------------ static UI
|
|
254
|
+
const uiDir = path.resolve(__dirname, '..', 'ui');
|
|
255
|
+
if (fs.existsSync(path.join(uiDir, 'index.html'))) {
|
|
256
|
+
app.use(express_1.default.static(uiDir));
|
|
257
|
+
// SPA fallback for client-side routes (anything that's not /api)
|
|
258
|
+
app.use((req, res, next) => {
|
|
259
|
+
if (req.method === 'GET' && !req.path.startsWith('/api')) {
|
|
260
|
+
res.sendFile(path.join(uiDir, 'index.html'));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
next();
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
logger.warn(`UI assets not found at ${uiDir} — API only (build them with 'npm run build:ui')`);
|
|
268
|
+
}
|
|
269
|
+
// ------------------------------------------------------------------ errors
|
|
270
|
+
app.use((err, _req, res, _next) => {
|
|
271
|
+
if (res.headersSent)
|
|
272
|
+
return;
|
|
273
|
+
if (err instanceof types_1.ValidationError) {
|
|
274
|
+
res.status(400).json({ error: err.message });
|
|
275
|
+
}
|
|
276
|
+
else if (err instanceof types_1.NotFoundError) {
|
|
277
|
+
res.status(404).json({ error: err.message });
|
|
278
|
+
}
|
|
279
|
+
else if (err instanceof types_1.ConflictError) {
|
|
280
|
+
res.status(409).json({ error: err.message });
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
logger.error('Unhandled API error:', err);
|
|
284
|
+
res.status(500).json({ error: 'internal error' });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
return app;
|
|
288
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ControllerStore = void 0;
|
|
4
|
+
const pg_1 = require("pg");
|
|
5
|
+
const migrations_1 = require("./migrations");
|
|
6
|
+
/**
|
|
7
|
+
* All Postgres access for controller mode: configs, runs, and run logs.
|
|
8
|
+
*/
|
|
9
|
+
class ControllerStore {
|
|
10
|
+
constructor(databaseUrl, logger) {
|
|
11
|
+
this.pool = new pg_1.Pool({ connectionString: databaseUrl, max: 10 });
|
|
12
|
+
this.logger = logger;
|
|
13
|
+
}
|
|
14
|
+
async init() {
|
|
15
|
+
await this.migrate();
|
|
16
|
+
await this.markOrphanedRuns();
|
|
17
|
+
}
|
|
18
|
+
async close() {
|
|
19
|
+
await this.pool.end();
|
|
20
|
+
}
|
|
21
|
+
async migrate() {
|
|
22
|
+
const client = await this.pool.connect();
|
|
23
|
+
try {
|
|
24
|
+
await client.query(`CREATE TABLE IF NOT EXISTS d2v_schema_migrations (
|
|
25
|
+
version INT PRIMARY KEY,
|
|
26
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
27
|
+
)`);
|
|
28
|
+
const { rows } = await client.query('SELECT COALESCE(MAX(version), 0) AS version FROM d2v_schema_migrations');
|
|
29
|
+
const current = Number(rows[0].version);
|
|
30
|
+
for (let i = current; i < migrations_1.MIGRATIONS.length; i++) {
|
|
31
|
+
const version = i + 1;
|
|
32
|
+
this.logger.info(`Applying migration ${version}`);
|
|
33
|
+
await client.query('BEGIN');
|
|
34
|
+
try {
|
|
35
|
+
await client.query(migrations_1.MIGRATIONS[i]);
|
|
36
|
+
await client.query('INSERT INTO d2v_schema_migrations (version) VALUES ($1)', [version]);
|
|
37
|
+
await client.query('COMMIT');
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
await client.query('ROLLBACK');
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
client.release();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Runs left 'queued'/'running' by a previous controller process can never finish — fail them. */
|
|
50
|
+
async markOrphanedRuns() {
|
|
51
|
+
const { rowCount } = await this.pool.query(`UPDATE d2v_runs
|
|
52
|
+
SET status = 'failed', error = 'controller restarted while run was in progress', finished_at = NOW()
|
|
53
|
+
WHERE status IN ('queued', 'running')`);
|
|
54
|
+
if (rowCount) {
|
|
55
|
+
this.logger.warn(`Marked ${rowCount} orphaned run(s) from a previous controller process as failed`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ------------------------------------------------------------------ configs
|
|
59
|
+
async upsertConfig(input) {
|
|
60
|
+
const { rows } = await this.pool.query(`INSERT INTO d2v_configs (path, name, content, content_hash, schedule, source_summary, parse_error)
|
|
61
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
62
|
+
ON CONFLICT (path) DO UPDATE SET
|
|
63
|
+
name = EXCLUDED.name,
|
|
64
|
+
content = EXCLUDED.content,
|
|
65
|
+
content_hash = EXCLUDED.content_hash,
|
|
66
|
+
schedule = EXCLUDED.schedule,
|
|
67
|
+
source_summary = EXCLUDED.source_summary,
|
|
68
|
+
parse_error = EXCLUDED.parse_error,
|
|
69
|
+
deleted_at = NULL,
|
|
70
|
+
updated_at = NOW()
|
|
71
|
+
RETURNING *`, [input.path, input.name, input.content, input.contentHash, input.schedule,
|
|
72
|
+
JSON.stringify(input.sourceSummary), input.parseError]);
|
|
73
|
+
return rows[0];
|
|
74
|
+
}
|
|
75
|
+
async markConfigDeleted(path) {
|
|
76
|
+
await this.pool.query('UPDATE d2v_configs SET deleted_at = NOW(), updated_at = NOW() WHERE path = $1', [path]);
|
|
77
|
+
}
|
|
78
|
+
async listConfigs(includeDeleted = false) {
|
|
79
|
+
const { rows } = await this.pool.query(`SELECT * FROM d2v_configs ${includeDeleted ? '' : 'WHERE deleted_at IS NULL'} ORDER BY name`);
|
|
80
|
+
return rows;
|
|
81
|
+
}
|
|
82
|
+
async getConfig(id) {
|
|
83
|
+
const { rows } = await this.pool.query('SELECT * FROM d2v_configs WHERE id = $1', [id]);
|
|
84
|
+
return rows[0] ?? null;
|
|
85
|
+
}
|
|
86
|
+
// ------------------------------------------------------------------ runs
|
|
87
|
+
async createRun(configId, configHash, trigger, status, error) {
|
|
88
|
+
const terminal = status === 'skipped';
|
|
89
|
+
const { rows } = await this.pool.query(`INSERT INTO d2v_runs (config_id, config_hash, trigger, status, error, finished_at)
|
|
90
|
+
VALUES ($1, $2, $3, $4, $5, ${terminal ? 'NOW()' : 'NULL'})
|
|
91
|
+
RETURNING *`, [configId, configHash, trigger, status, error ?? null]);
|
|
92
|
+
return rows[0];
|
|
93
|
+
}
|
|
94
|
+
async markRunStarted(runId, pid) {
|
|
95
|
+
const { rows } = await this.pool.query(`UPDATE d2v_runs SET status = 'running', pid = $2, started_at = NOW() WHERE id = $1 RETURNING *`, [runId, pid ?? null]);
|
|
96
|
+
return rows[0];
|
|
97
|
+
}
|
|
98
|
+
async finishRun(runId, outcome) {
|
|
99
|
+
const { rows } = await this.pool.query(`UPDATE d2v_runs
|
|
100
|
+
SET status = $2, exit_code = $3, error = $4, stats = $5, finished_at = NOW()
|
|
101
|
+
WHERE id = $1 RETURNING *`, [runId, outcome.status, outcome.exitCode ?? null, outcome.error ?? null, JSON.stringify(outcome.stats ?? {})]);
|
|
102
|
+
return rows[0];
|
|
103
|
+
}
|
|
104
|
+
async getRun(runId) {
|
|
105
|
+
const { rows } = await this.pool.query('SELECT * FROM d2v_runs WHERE id = $1', [runId]);
|
|
106
|
+
return rows[0] ?? null;
|
|
107
|
+
}
|
|
108
|
+
async listRuns(filter = {}) {
|
|
109
|
+
const conditions = [];
|
|
110
|
+
const params = [];
|
|
111
|
+
if (filter.configId !== undefined) {
|
|
112
|
+
params.push(filter.configId);
|
|
113
|
+
conditions.push(`config_id = $${params.length}`);
|
|
114
|
+
}
|
|
115
|
+
if (filter.status !== undefined) {
|
|
116
|
+
params.push(filter.status);
|
|
117
|
+
conditions.push(`status = $${params.length}`);
|
|
118
|
+
}
|
|
119
|
+
if (filter.before !== undefined) {
|
|
120
|
+
params.push(filter.before);
|
|
121
|
+
conditions.push(`id < $${params.length}`);
|
|
122
|
+
}
|
|
123
|
+
params.push(Math.min(filter.limit ?? 50, 500));
|
|
124
|
+
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
125
|
+
const { rows } = await this.pool.query(`SELECT * FROM d2v_runs ${where} ORDER BY id DESC LIMIT $${params.length}`, params);
|
|
126
|
+
return rows;
|
|
127
|
+
}
|
|
128
|
+
/** Latest run per config, for the dashboard list. */
|
|
129
|
+
async getLastRuns() {
|
|
130
|
+
const { rows } = await this.pool.query(`SELECT DISTINCT ON (config_id) * FROM d2v_runs ORDER BY config_id, id DESC`);
|
|
131
|
+
return new Map(rows.map((r) => [r.config_id, r]));
|
|
132
|
+
}
|
|
133
|
+
// ------------------------------------------------------------------ logs
|
|
134
|
+
async insertLogs(runId, rows) {
|
|
135
|
+
if (rows.length === 0)
|
|
136
|
+
return;
|
|
137
|
+
const values = [];
|
|
138
|
+
const placeholders = rows.map((row, i) => {
|
|
139
|
+
const base = i * 6;
|
|
140
|
+
values.push(runId, row.seq, row.ts, row.level, row.module, row.message);
|
|
141
|
+
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6})`;
|
|
142
|
+
});
|
|
143
|
+
await this.pool.query(`INSERT INTO d2v_run_logs (run_id, seq, ts, level, module, message) VALUES ${placeholders.join(', ')}
|
|
144
|
+
ON CONFLICT DO NOTHING`, values);
|
|
145
|
+
}
|
|
146
|
+
async getLogs(runId, afterSeq = 0, limit = 1000) {
|
|
147
|
+
const { rows } = await this.pool.query(`SELECT seq, ts, level, module, message FROM d2v_run_logs
|
|
148
|
+
WHERE run_id = $1 AND seq > $2 ORDER BY seq LIMIT $3`, [runId, afterSeq, Math.min(limit, 5000)]);
|
|
149
|
+
return rows.map((r) => ({ ...r, seq: Number(r.seq) }));
|
|
150
|
+
}
|
|
151
|
+
async pruneOldLogs(retentionDays) {
|
|
152
|
+
const { rowCount } = await this.pool.query(`DELETE FROM d2v_run_logs USING d2v_runs
|
|
153
|
+
WHERE d2v_run_logs.run_id = d2v_runs.id
|
|
154
|
+
AND d2v_runs.finished_at < NOW() - make_interval(days => $1)`, [retentionDays]);
|
|
155
|
+
return rowCount ?? 0;
|
|
156
|
+
}
|
|
157
|
+
// ------------------------------------------------------------------ stats
|
|
158
|
+
/** Daily run counts by status + duration stats, for the config stats page. */
|
|
159
|
+
async getConfigStats(configId, days) {
|
|
160
|
+
const boundedDays = Math.min(Math.max(days, 1), 365);
|
|
161
|
+
const [daily, recent, totals] = await Promise.all([
|
|
162
|
+
this.pool.query(`SELECT date_trunc('day', queued_at)::date::text AS day, status, COUNT(*)::int AS count,
|
|
163
|
+
AVG(EXTRACT(EPOCH FROM (finished_at - started_at)))::float AS avg_duration_s
|
|
164
|
+
FROM d2v_runs
|
|
165
|
+
WHERE config_id = $1 AND queued_at > NOW() - make_interval(days => $2)
|
|
166
|
+
GROUP BY 1, 2 ORDER BY 1`, [configId, boundedDays]),
|
|
167
|
+
this.pool.query(`SELECT id, status, started_at, EXTRACT(EPOCH FROM (finished_at - started_at))::float AS duration_s
|
|
168
|
+
FROM d2v_runs
|
|
169
|
+
WHERE config_id = $1 AND status IN ('succeeded', 'failed') AND started_at IS NOT NULL AND finished_at IS NOT NULL
|
|
170
|
+
ORDER BY id DESC LIMIT 50`, [configId]),
|
|
171
|
+
this.pool.query(`SELECT COUNT(*)::int AS total,
|
|
172
|
+
COUNT(*) FILTER (WHERE status = 'succeeded')::int AS succeeded,
|
|
173
|
+
COUNT(*) FILTER (WHERE status = 'failed')::int AS failed,
|
|
174
|
+
COUNT(*) FILTER (WHERE status = 'skipped')::int AS skipped,
|
|
175
|
+
COUNT(*) FILTER (WHERE status = 'canceled')::int AS canceled
|
|
176
|
+
FROM d2v_runs
|
|
177
|
+
WHERE config_id = $1 AND queued_at > NOW() - make_interval(days => $2)`, [configId, boundedDays]),
|
|
178
|
+
]);
|
|
179
|
+
return {
|
|
180
|
+
daily: daily.rows,
|
|
181
|
+
recentDurations: recent.rows.reverse(),
|
|
182
|
+
totals: totals.rows[0],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
exports.ControllerStore = ControllerStore;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Shared types for controller mode
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.ValidationError = exports.NotFoundError = exports.ConflictError = void 0;
|
|
5
|
+
class ConflictError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'ConflictError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
exports.ConflictError = ConflictError;
|
|
12
|
+
class NotFoundError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'NotFoundError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
exports.NotFoundError = NotFoundError;
|
|
19
|
+
class ValidationError extends Error {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'ValidationError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.ValidationError = ValidationError;
|
package/dist/database.js
CHANGED
|
@@ -215,7 +215,13 @@ class DatabaseManager {
|
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
catch (error) {
|
|
218
|
-
|
|
218
|
+
// Best-effort by design: a failed metadata write errs toward the
|
|
219
|
+
// safe direction (redundant reprocessing next run — etag/lastmod/
|
|
220
|
+
// watermark stay behind, never skip ahead), so we warn rather than
|
|
221
|
+
// fail the job. Genuine disk/connection failures surface earlier at
|
|
222
|
+
// the chunk-write stage (storeChunkInQdrant), which shares this
|
|
223
|
+
// collection and throws.
|
|
224
|
+
logger.warn(`Failed to update metadata value for ${key} (continuing; will retry next run):`, error);
|
|
219
225
|
}
|
|
220
226
|
}
|
|
221
227
|
static async getLastRunDate(dbConnection, repo, defaultDate, logger) {
|
|
@@ -436,7 +442,11 @@ class DatabaseManager {
|
|
|
436
442
|
});
|
|
437
443
|
}
|
|
438
444
|
catch (error) {
|
|
439
|
-
|
|
445
|
+
// Rethrow so the failure propagates to the top-level handler and the
|
|
446
|
+
// job exits non-zero. Swallowing here previously let a sync "succeed"
|
|
447
|
+
// while chunks silently failed to store (e.g. Qdrant disk full).
|
|
448
|
+
console.error(`Error storing chunk in Qdrant (collection ${collectionName}, url ${chunk.metadata.url}):`, error);
|
|
449
|
+
throw error;
|
|
440
450
|
}
|
|
441
451
|
}
|
|
442
452
|
static removeObsoleteChunksSQLite(db, visitedUrls, urlPrefix, logger) {
|
|
@@ -518,7 +528,11 @@ class DatabaseManager {
|
|
|
518
528
|
}
|
|
519
529
|
}
|
|
520
530
|
catch (error) {
|
|
531
|
+
// Rethrow so a failed cleanup fails the job instead of silently
|
|
532
|
+
// leaving orphans behind. This is idempotent and recomputed each
|
|
533
|
+
// run, so a rerun retries it cleanly.
|
|
521
534
|
logger.error(`Error removing obsolete chunks from Qdrant:`, error);
|
|
535
|
+
throw error;
|
|
522
536
|
}
|
|
523
537
|
}
|
|
524
538
|
static hasColumn(db, columnName) {
|
|
@@ -561,7 +575,12 @@ class DatabaseManager {
|
|
|
561
575
|
logger.info(`Deleted chunks from Qdrant for URL ${url}`);
|
|
562
576
|
}
|
|
563
577
|
catch (error) {
|
|
578
|
+
// Rethrow so the failure propagates and the job exits non-zero.
|
|
579
|
+
// A swallowed delete leaves stale chunks behind while the sync
|
|
580
|
+
// reports success. Callers that treat deletion as best-effort
|
|
581
|
+
// (e.g. 404 cleanup) wrap this call in their own try/catch.
|
|
564
582
|
logger.error(`Error deleting chunks from Qdrant for URL ${url}:`, error);
|
|
583
|
+
throw error;
|
|
565
584
|
}
|
|
566
585
|
}
|
|
567
586
|
/**
|
|
@@ -726,53 +745,64 @@ class DatabaseManager {
|
|
|
726
745
|
urlPrefix = `file://${cleanedDirPrefix}`;
|
|
727
746
|
}
|
|
728
747
|
logger.debug(`Checking for obsolete chunks with URL prefix: ${urlPrefix}`);
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
}
|
|
748
|
+
// Scroll through ALL matching points via next_page_offset. A single
|
|
749
|
+
// capped scroll would only examine the first page, so obsolete
|
|
750
|
+
// chunks beyond it (in collections larger than the page limit) would
|
|
751
|
+
// never be removed.
|
|
752
|
+
const filter = {
|
|
753
|
+
must: [
|
|
754
|
+
{
|
|
755
|
+
key: "url",
|
|
756
|
+
match: {
|
|
757
|
+
text: urlPrefix
|
|
740
758
|
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
759
|
+
}
|
|
760
|
+
],
|
|
761
|
+
must_not: [
|
|
762
|
+
{
|
|
763
|
+
key: "is_metadata",
|
|
764
|
+
match: {
|
|
765
|
+
value: true
|
|
748
766
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
}
|
|
752
|
-
const obsoletePointIds =
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
//
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
filePath
|
|
767
|
+
}
|
|
768
|
+
]
|
|
769
|
+
};
|
|
770
|
+
const obsoletePointIds = [];
|
|
771
|
+
let offset = undefined;
|
|
772
|
+
do {
|
|
773
|
+
const response = await client.scroll(collectionName, {
|
|
774
|
+
limit: 1000,
|
|
775
|
+
offset,
|
|
776
|
+
with_payload: true,
|
|
777
|
+
with_vector: false,
|
|
778
|
+
filter
|
|
779
|
+
});
|
|
780
|
+
for (const point of response.points) {
|
|
781
|
+
const url = point.payload?.url;
|
|
782
|
+
// Double check it's not a metadata record
|
|
783
|
+
if (point.payload?.is_metadata === true) {
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
if (!url || !url.startsWith(urlPrefix)) {
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
let filePath;
|
|
790
|
+
if (isRewriteMode) {
|
|
791
|
+
// URL rewrite mode: extract relative path and construct full file path
|
|
792
|
+
const config = pathConfig;
|
|
793
|
+
const relativePath = url.substring(urlPrefix.length + 1); // +1 for the '/'
|
|
794
|
+
filePath = path.join(config.path, relativePath);
|
|
795
|
+
}
|
|
796
|
+
else {
|
|
797
|
+
// Direct file path mode: remove file:// prefix to match with processedFiles
|
|
798
|
+
filePath = url.startsWith('file://') ? url.substring(7) : '';
|
|
799
|
+
}
|
|
800
|
+
if (filePath && !processedFiles.has(filePath)) {
|
|
801
|
+
obsoletePointIds.push(point.id);
|
|
802
|
+
}
|
|
772
803
|
}
|
|
773
|
-
|
|
774
|
-
})
|
|
775
|
-
.map((point) => point.id);
|
|
804
|
+
offset = response.next_page_offset;
|
|
805
|
+
} while (offset !== null && offset !== undefined);
|
|
776
806
|
if (obsoletePointIds.length > 0) {
|
|
777
807
|
await client.delete(collectionName, {
|
|
778
808
|
points: obsoletePointIds,
|
|
@@ -784,7 +814,10 @@ class DatabaseManager {
|
|
|
784
814
|
}
|
|
785
815
|
}
|
|
786
816
|
catch (error) {
|
|
817
|
+
// Rethrow so a failed cleanup fails the job instead of silently
|
|
818
|
+
// leaving orphans behind (idempotent — retried cleanly next run).
|
|
787
819
|
logger.error(`Error removing obsolete chunks from Qdrant:`, error);
|
|
820
|
+
throw error;
|
|
788
821
|
}
|
|
789
822
|
}
|
|
790
823
|
}
|