doc2vec 2.10.5 → 2.11.0
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 +98 -0
- package/dist/content-processor.js +122 -22
- 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/doc2vec.js +121 -28
- package/dist/logger.js +89 -0
- 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 +7 -2
|
@@ -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;
|