@geraldmaron/construct 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/agents/prompts/cx-ai-engineer.md +6 -26
- package/agents/prompts/cx-architect.md +1 -0
- package/agents/prompts/cx-business-strategist.md +2 -0
- package/agents/prompts/cx-data-analyst.md +6 -26
- package/agents/prompts/cx-docs-keeper.md +1 -31
- package/agents/prompts/cx-explorer.md +1 -0
- package/agents/prompts/cx-orchestrator.md +40 -112
- package/agents/prompts/cx-platform-engineer.md +2 -22
- package/agents/prompts/cx-product-manager.md +2 -1
- package/agents/prompts/cx-qa.md +0 -20
- package/agents/prompts/cx-rd-lead.md +2 -0
- package/agents/prompts/cx-researcher.md +77 -31
- package/agents/prompts/cx-security.md +11 -49
- package/agents/prompts/cx-sre.md +9 -43
- package/agents/prompts/cx-ux-researcher.md +1 -0
- package/agents/role-manifests.json +4 -4
- package/bin/construct +23 -0
- package/db/schema/004_recommendations.sql +46 -0
- package/db/schema/005_strategy.sql +21 -0
- package/lib/auto-docs.mjs +1 -2
- package/lib/beads-automation.mjs +16 -7
- package/lib/cli-commands.mjs +8 -2
- package/lib/embed/conflict-detection.mjs +26 -9
- package/lib/embed/customer-profiles.mjs +37 -17
- package/lib/embed/daemon.mjs +10 -8
- package/lib/embed/recommendation-store.mjs +213 -14
- package/lib/embed/workspaces.mjs +53 -18
- package/lib/gates-audit.mjs +3 -3
- package/lib/health-check.mjs +1 -1
- package/lib/hooks/pre-compact.mjs +3 -0
- package/lib/hooks/read-tracker.mjs +10 -101
- package/lib/host-capabilities.mjs +90 -1
- package/lib/init-update.mjs +246 -131
- package/lib/intent-classifier.mjs +1 -0
- package/lib/knowledge/layout.mjs +10 -0
- package/lib/mcp/tools/telemetry.mjs +30 -78
- package/lib/model-router.mjs +61 -1
- package/lib/ollama-manager.mjs +1 -1
- package/lib/opencode-telemetry.mjs +4 -5
- package/lib/orchestration-policy.mjs +9 -0
- package/lib/parity.mjs +124 -21
- package/lib/prompt-composer.js +106 -29
- package/lib/read-tracker-store.mjs +149 -0
- package/lib/server/index.mjs +76 -0
- package/lib/server/telemetry-login.mjs +17 -25
- package/lib/service-manager.mjs +30 -22
- package/lib/services/local-postgres.mjs +15 -0
- package/lib/services/telemetry-backend.mjs +1 -2
- package/lib/setup.mjs +8 -43
- package/lib/status.mjs +51 -5
- package/lib/storage/backend.mjs +12 -2
- package/lib/strategy-store.mjs +371 -0
- package/lib/telemetry/backends/local.mjs +6 -4
- package/lib/telemetry/client.mjs +185 -0
- package/lib/telemetry/ingest.mjs +13 -5
- package/lib/telemetry/team-rollup.mjs +9 -2
- package/lib/worker/trace.mjs +17 -27
- package/package.json +5 -2
- package/rules/common/research.md +44 -12
- package/skills/docs/backlog-proposal-workflow.md +2 -2
- package/skills/docs/customer-profile-workflow.md +1 -1
- package/skills/docs/evidence-ingest-workflow.md +5 -5
- package/skills/docs/prfaq-workflow.md +1 -1
- package/skills/docs/product-intelligence-review.md +1 -1
- package/skills/docs/product-intelligence-workflow.md +3 -3
- package/skills/docs/product-signal-workflow.md +48 -18
- package/skills/docs/research-workflow.md +26 -14
- package/skills/docs/strategy-workflow.md +36 -0
- package/skills/roles/data-analyst.product-intelligence.md +1 -1
- package/skills/roles/researcher.md +28 -15
- package/skills/routing.md +8 -1
- package/templates/docs/research-brief.md +63 -9
- package/templates/docs/strategy.md +36 -0
- package/templates/homebrew/construct.rb +6 -6
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/hooks/read-tracker-store.mjs — batched persistence for read-efficiency tracking.
|
|
3
|
+
*
|
|
4
|
+
* The Read hook runs in a fresh process for every file read. Persisting the
|
|
5
|
+
* full session-efficiency store on every invocation is correct but noisy and
|
|
6
|
+
* expensive. This module appends compact JSONL deltas per read, then folds
|
|
7
|
+
* them into the durable session summary when the delta log is flushed.
|
|
8
|
+
*/
|
|
9
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
|
|
13
|
+
const SESSION_IDLE_RESET_MS = 2 * 60 * 60 * 1000;
|
|
14
|
+
const REPEATED_READ_WARNING_THRESHOLD = 5;
|
|
15
|
+
const REPEATED_READ_TIER2_THRESHOLD = 8;
|
|
16
|
+
const LARGE_READ_WARNING_THRESHOLD = 3;
|
|
17
|
+
const TOTAL_BYTES_WARNING_THRESHOLD = 750_000;
|
|
18
|
+
const FILES_LRU_CAP = 200;
|
|
19
|
+
const EFFICIENCY_LARGE_READ_LIMIT = 400;
|
|
20
|
+
|
|
21
|
+
export function readTrackerPaths(env = process.env) {
|
|
22
|
+
const home = env.CX_HOME_OVERRIDE || env.HOME || homedir();
|
|
23
|
+
const cxDir = join(home, '.cx');
|
|
24
|
+
return {
|
|
25
|
+
cxDir,
|
|
26
|
+
efficiencyStore: join(cxDir, 'session-efficiency.json'),
|
|
27
|
+
deltaLog: join(cxDir, 'session-efficiency.reads.jsonl'),
|
|
28
|
+
warnFlags: join(cxDir, 'warn-flags.txt'),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function freshEfficiencyStats(nowIso) {
|
|
33
|
+
return {
|
|
34
|
+
sessionStartedAt: nowIso,
|
|
35
|
+
lastUpdatedAt: nowIso,
|
|
36
|
+
readCount: 0,
|
|
37
|
+
uniqueFileCount: 0,
|
|
38
|
+
repeatedReadCount: 0,
|
|
39
|
+
largeReadCount: 0,
|
|
40
|
+
totalBytesRead: 0,
|
|
41
|
+
warnings: {},
|
|
42
|
+
files: {},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function loadEfficiencyStats(nowIso, env = process.env) {
|
|
47
|
+
const { efficiencyStore } = readTrackerPaths(env);
|
|
48
|
+
const fresh = freshEfficiencyStats(nowIso);
|
|
49
|
+
try {
|
|
50
|
+
const existing = JSON.parse(readFileSync(efficiencyStore, 'utf8'));
|
|
51
|
+
const lastUpdated = new Date(existing.lastUpdatedAt || 0).getTime();
|
|
52
|
+
if (!lastUpdated || Date.now() - lastUpdated > SESSION_IDLE_RESET_MS) return fresh;
|
|
53
|
+
return { ...fresh, ...existing, warnings: existing.warnings || {}, files: existing.files || {} };
|
|
54
|
+
} catch {
|
|
55
|
+
return fresh;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function appendWarning(message, env = process.env) {
|
|
60
|
+
try {
|
|
61
|
+
const { warnFlags } = readTrackerPaths(env);
|
|
62
|
+
appendFileSync(warnFlags, `${message}\n`);
|
|
63
|
+
} catch { /* best effort */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function topRepeatedPath(files) {
|
|
67
|
+
return Object.entries(files || {})
|
|
68
|
+
.map(([filePath, value]) => ({ filePath, count: Number(value?.count || 0) }))
|
|
69
|
+
.filter((entry) => entry.count > 1)
|
|
70
|
+
.sort((a, b) => b.count - a.count || a.filePath.localeCompare(b.filePath))[0];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function applyReadDelta(stats, delta, env = process.env) {
|
|
74
|
+
const existingFile = stats.files[delta.path];
|
|
75
|
+
const isLargeRead = Number(delta.limit || 0) > EFFICIENCY_LARGE_READ_LIMIT;
|
|
76
|
+
|
|
77
|
+
stats.readCount += 1;
|
|
78
|
+
stats.totalBytesRead += Number(delta.size || 0);
|
|
79
|
+
if (isLargeRead) stats.largeReadCount += 1;
|
|
80
|
+
if (existingFile) stats.repeatedReadCount += 1;
|
|
81
|
+
else stats.uniqueFileCount += 1;
|
|
82
|
+
|
|
83
|
+
stats.files[delta.path] = {
|
|
84
|
+
count: Number(existingFile?.count || 0) + 1,
|
|
85
|
+
size: Number(delta.size || 0),
|
|
86
|
+
lastReadAt: delta.ts,
|
|
87
|
+
lastRequestedLimit: Number(delta.limit || 0),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const fileEntries = Object.entries(stats.files);
|
|
91
|
+
if (fileEntries.length > FILES_LRU_CAP) {
|
|
92
|
+
fileEntries.sort((a, b) => (a[1]?.lastReadAt || '').localeCompare(b[1]?.lastReadAt || ''));
|
|
93
|
+
const drop = fileEntries.length - FILES_LRU_CAP;
|
|
94
|
+
for (let i = 0; i < drop; i++) delete stats.files[fileEntries[i][0]];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (stats.repeatedReadCount >= REPEATED_READ_WARNING_THRESHOLD && !stats.warnings.repeatedReads) {
|
|
98
|
+
const top = topRepeatedPath(stats.files);
|
|
99
|
+
const topNote = top ? ` Top repeat: ${top.filePath} (${top.count}x).` : '';
|
|
100
|
+
appendWarning(`Efficiency: ${stats.repeatedReadCount} repeated reads this session.${topNote} Use rg or construct distill before re-reading more files.`, env);
|
|
101
|
+
stats.warnings.repeatedReads = delta.ts;
|
|
102
|
+
if (stats.repeatedReadCount >= REPEATED_READ_TIER2_THRESHOLD && !stats.warnings.repeatedReadsTier2) {
|
|
103
|
+
appendWarning(`Efficiency: ${stats.repeatedReadCount} repeated reads this session — consider using construct distill or rg before re-reading entire files.`, env);
|
|
104
|
+
stats.warnings.repeatedReadsTier2 = delta.ts;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (stats.largeReadCount >= LARGE_READ_WARNING_THRESHOLD && !stats.warnings.largeReads) {
|
|
109
|
+
appendWarning(`Efficiency: ${stats.largeReadCount} large reads this session — prefer rg/glob plus targeted reads under 400 lines.`, env);
|
|
110
|
+
stats.warnings.largeReads = delta.ts;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (stats.totalBytesRead >= TOTAL_BYTES_WARNING_THRESHOLD && !stats.warnings.totalBytes) {
|
|
114
|
+
appendWarning(`Efficiency: ${Math.round(stats.totalBytesRead / 1024)} KB read this session — consider distill/query-focused retrieval or compact context before continuing.`, env);
|
|
115
|
+
stats.warnings.totalBytes = delta.ts;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
stats.lastUpdatedAt = delta.ts;
|
|
119
|
+
return stats;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function recordReadDelta(delta, env = process.env) {
|
|
123
|
+
const { cxDir, deltaLog } = readTrackerPaths(env);
|
|
124
|
+
mkdirSync(cxDir, { recursive: true });
|
|
125
|
+
appendFileSync(deltaLog, `${JSON.stringify(delta)}\n`, 'utf8');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function flushReadTrackerDeltas({ nowIso = new Date().toISOString(), env = process.env } = {}) {
|
|
129
|
+
const { cxDir, deltaLog, efficiencyStore } = readTrackerPaths(env);
|
|
130
|
+
mkdirSync(cxDir, { recursive: true });
|
|
131
|
+
const stats = loadEfficiencyStats(nowIso, env);
|
|
132
|
+
if (!existsSync(deltaLog)) return stats;
|
|
133
|
+
|
|
134
|
+
const lines = readFileSync(deltaLog, 'utf8')
|
|
135
|
+
.split('\n')
|
|
136
|
+
.map((line) => line.trim())
|
|
137
|
+
.filter(Boolean);
|
|
138
|
+
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
try {
|
|
141
|
+
const delta = JSON.parse(line);
|
|
142
|
+
applyReadDelta(stats, delta, env);
|
|
143
|
+
} catch { /* best effort */ }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
writeFileSync(efficiencyStore, `${JSON.stringify(stats, null, 2)}\n`);
|
|
147
|
+
rmSync(deltaLog, { force: true });
|
|
148
|
+
return stats;
|
|
149
|
+
}
|
package/lib/server/index.mjs
CHANGED
|
@@ -1500,6 +1500,82 @@ const server = createServer(async (req, res) => {
|
|
|
1500
1500
|
return;
|
|
1501
1501
|
}
|
|
1502
1502
|
|
|
1503
|
+
// ── Recommendations API ────────────────────────────────────────────────────
|
|
1504
|
+
if (url.pathname === '/api/recommendations') {
|
|
1505
|
+
if (req.method === 'GET') {
|
|
1506
|
+
const { listActiveRecommendations, recommendationStats } = await import('../embed/recommendation-store.mjs');
|
|
1507
|
+
const priority = url.searchParams.get('priority') || undefined;
|
|
1508
|
+
const type = url.searchParams.get('type') || undefined;
|
|
1509
|
+
const limit = Number(url.searchParams.get('limit') || 50);
|
|
1510
|
+
const items = listActiveRecommendations({ priority, type, limit });
|
|
1511
|
+
const stats = recommendationStats();
|
|
1512
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1513
|
+
res.end(JSON.stringify({ items, stats }));
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
if (req.method === 'PATCH' || req.method === 'POST') {
|
|
1517
|
+
const chunks = [];
|
|
1518
|
+
await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
|
|
1519
|
+
let body;
|
|
1520
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
|
|
1521
|
+
const action = body.action || url.searchParams.get('action');
|
|
1522
|
+
const dedupKey = body.dedupKey || url.searchParams.get('dedupKey');
|
|
1523
|
+
if (!action || !dedupKey) {
|
|
1524
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1525
|
+
res.end(JSON.stringify({ error: 'action and dedupKey are required' }));
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
const { dismissRecommendation, reviveRecommendation } = await import('../embed/recommendation-store.mjs');
|
|
1529
|
+
try {
|
|
1530
|
+
if (action === 'dismiss') {
|
|
1531
|
+
const result = dismissRecommendation(dedupKey, { reason: body.reason, suppressDays: body.suppressDays });
|
|
1532
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1533
|
+
res.end(JSON.stringify(result));
|
|
1534
|
+
} else if (action === 'revive') {
|
|
1535
|
+
const result = reviveRecommendation(dedupKey, { signalCount: body.signalCount, sourceSignalIds: body.sourceSignalIds });
|
|
1536
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1537
|
+
res.end(JSON.stringify(result));
|
|
1538
|
+
} else {
|
|
1539
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1540
|
+
res.end(JSON.stringify({ error: `unknown action: ${action}` }));
|
|
1541
|
+
}
|
|
1542
|
+
} catch (err) {
|
|
1543
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1544
|
+
res.end(JSON.stringify({ error: err?.message || 'not found' }));
|
|
1545
|
+
}
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// ── Strategy API ───────────────────────────────────────────────────────────
|
|
1551
|
+
if (url.pathname === '/api/strategy') {
|
|
1552
|
+
if (req.method === 'GET') {
|
|
1553
|
+
const { readAllStrategies } = await import('../strategy-store.mjs');
|
|
1554
|
+
const result = await readAllStrategies(process.env);
|
|
1555
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1556
|
+
res.end(JSON.stringify({ scopes: Object.fromEntries(result) }));
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
if (req.method === 'PUT' || req.method === 'POST') {
|
|
1560
|
+
const chunks = [];
|
|
1561
|
+
await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
|
|
1562
|
+
let body;
|
|
1563
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
|
|
1564
|
+
const content = (body.content || '').trim();
|
|
1565
|
+
const scope = (body.scope || 'product').trim();
|
|
1566
|
+
if (!content) {
|
|
1567
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1568
|
+
res.end(JSON.stringify({ error: 'content is required' }));
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
const { writeStrategy } = await import('../strategy-store.mjs');
|
|
1572
|
+
await writeStrategy(content, scope, { updatedBy: 'dashboard', env: process.env });
|
|
1573
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1574
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1503
1579
|
if (url.pathname === '/api/snapshots') {
|
|
1504
1580
|
handleSnapshots(req, res);
|
|
1505
1581
|
return;
|
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/server/telemetry-login.mjs — magic-link bridge for a
|
|
2
|
+
* lib/server/telemetry-login.mjs — magic-link bridge for a configured telemetry UI.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the backend, then returns an HTML auto-submit form that POSTs the seeded
|
|
8
|
-
* credentials to NextAuth's callback.
|
|
9
|
-
*
|
|
10
|
-
* The seeded creds are NOT secret — they are deterministic local defaults
|
|
11
|
-
* for zero-touch local development. Not used when CONSTRUCT_TELEMETRY_URL
|
|
12
|
-
* points at a remote backend.
|
|
4
|
+
* CONSTRUCT_TELEMETRY_URL may point at a Langfuse-compatible deployment.
|
|
5
|
+
* The dashboard link can use this bridge for one-click sign-in by fetching a
|
|
6
|
+
* CSRF token and returning an HTML auto-submit form for NextAuth's callback.
|
|
13
7
|
*/
|
|
14
8
|
|
|
15
9
|
const HTML_HEADERS = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' };
|
|
16
10
|
|
|
17
11
|
/**
|
|
18
|
-
* Resolve the telemetry
|
|
12
|
+
* Resolve the configured telemetry UI URL from env.
|
|
19
13
|
*/
|
|
20
14
|
function resolveTelemetryUrl(env = process.env) {
|
|
21
15
|
return (
|
|
@@ -45,10 +39,10 @@ function escapeHtml(value) {
|
|
|
45
39
|
*/
|
|
46
40
|
export function renderAutoSubmitForm({ csrfToken, callbackUrl, redirectAfter, email, password }) {
|
|
47
41
|
return `<!doctype html>
|
|
48
|
-
<html><head><meta charset="utf-8"><title>Signing in to telemetry
|
|
42
|
+
<html><head><meta charset="utf-8"><title>Signing in to telemetry UI…</title>
|
|
49
43
|
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;color:#666;background:#fff}</style>
|
|
50
44
|
</head><body>
|
|
51
|
-
<p>Signing you in to telemetry
|
|
45
|
+
<p>Signing you in to telemetry UI…</p>
|
|
52
46
|
<form id="tf" method="POST" action="${escapeHtml(callbackUrl)}">
|
|
53
47
|
<input type="hidden" name="csrfToken" value="${escapeHtml(csrfToken)}">
|
|
54
48
|
<input type="hidden" name="email" value="${escapeHtml(email)}">
|
|
@@ -63,8 +57,8 @@ export function renderAutoSubmitForm({ csrfToken, callbackUrl, redirectAfter, em
|
|
|
63
57
|
/**
|
|
64
58
|
* HTTP handler: GET /api/services/telemetry/login
|
|
65
59
|
*
|
|
66
|
-
* Returns an HTML page that immediately POSTs credentials to the
|
|
67
|
-
* NextAuth callback. Returns 502 when the
|
|
60
|
+
* Returns an HTML page that immediately POSTs credentials to the configured
|
|
61
|
+
* NextAuth callback. Returns 502 when the endpoint is unreachable.
|
|
68
62
|
*/
|
|
69
63
|
export async function handleTelemetryLogin(req, res, {
|
|
70
64
|
baseUrl,
|
|
@@ -76,8 +70,8 @@ export async function handleTelemetryLogin(req, res, {
|
|
|
76
70
|
|
|
77
71
|
if (!resolvedBaseUrl) {
|
|
78
72
|
res.writeHead(503, HTML_HEADERS);
|
|
79
|
-
res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry
|
|
80
|
-
`<pre>CONSTRUCT_TELEMETRY_URL is not set. Configure a telemetry
|
|
73
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry UI not configured</title>` +
|
|
74
|
+
`<pre>CONSTRUCT_TELEMETRY_URL is not set. Configure a Langfuse-compatible telemetry UI to use this feature.</pre>`);
|
|
81
75
|
return;
|
|
82
76
|
}
|
|
83
77
|
|
|
@@ -87,9 +81,9 @@ export async function handleTelemetryLogin(req, res, {
|
|
|
87
81
|
});
|
|
88
82
|
if (!csrfResponse.ok) {
|
|
89
83
|
res.writeHead(502, HTML_HEADERS);
|
|
90
|
-
res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry
|
|
91
|
-
`<pre>Could not reach telemetry
|
|
92
|
-
`
|
|
84
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry UI unreachable</title>` +
|
|
85
|
+
`<pre>Could not reach telemetry UI at ${escapeHtml(resolvedBaseUrl)}. Status: ${csrfResponse.status}. ` +
|
|
86
|
+
`Check CONSTRUCT_TELEMETRY_URL and the configured service.</pre>`);
|
|
93
87
|
return;
|
|
94
88
|
}
|
|
95
89
|
const { csrfToken } = await csrfResponse.json();
|
|
@@ -99,10 +93,8 @@ export async function handleTelemetryLogin(req, res, {
|
|
|
99
93
|
res.end(renderAutoSubmitForm({ csrfToken, callbackUrl, redirectAfter, email, password }));
|
|
100
94
|
} catch (err) {
|
|
101
95
|
res.writeHead(502, HTML_HEADERS);
|
|
102
|
-
res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry
|
|
103
|
-
`<pre>Could not reach telemetry
|
|
104
|
-
`
|
|
96
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>Telemetry UI unreachable</title>` +
|
|
97
|
+
`<pre>Could not reach telemetry UI: ${escapeHtml(err?.message || 'unknown error')}. ` +
|
|
98
|
+
`Check CONSTRUCT_TELEMETRY_URL and the configured service.</pre>`);
|
|
105
99
|
}
|
|
106
100
|
}
|
|
107
|
-
|
|
108
|
-
|
package/lib/service-manager.mjs
CHANGED
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
pruneStashDir,
|
|
21
21
|
verifyTelemetryKeys,
|
|
22
22
|
isRemoteTelemetry,
|
|
23
|
-
|
|
24
|
-
} from './
|
|
23
|
+
} from './services/local-postgres.mjs';
|
|
24
|
+
import { resolveTraceBackend, telemetryProviderLabel } from './telemetry/client.mjs';
|
|
25
25
|
|
|
26
26
|
const CONSTRUCT_PG_COMPOSE_DIR = 'services/postgres';
|
|
27
27
|
const CONSTRUCT_PG_CONTAINER = 'construct-postgres';
|
|
@@ -464,7 +464,7 @@ export async function startServices({
|
|
|
464
464
|
// On macOS, auto-launch Docker Desktop when the CLI is present but the
|
|
465
465
|
// daemon is down. Polls up to 60s so downstream Postgres startup sees a working
|
|
466
466
|
// compose runner instead of "Docker not available".
|
|
467
|
-
await tryStartDockerDaemonFn({ env: process.env });
|
|
467
|
+
await tryStartDockerDaemonFn({ env: { ...process.env, HOME: homeDir }, timeoutMs: 5000 });
|
|
468
468
|
|
|
469
469
|
writeEnvValues(envPath, {
|
|
470
470
|
DASHBOARD_PORT: String(ports.dashboard),
|
|
@@ -475,7 +475,7 @@ export async function startServices({
|
|
|
475
475
|
// Construct Postgres — start if DATABASE_URL points to managed container
|
|
476
476
|
const liveEnv = loadConstructEnvFn({ rootDir, homeDir });
|
|
477
477
|
const results = [];
|
|
478
|
-
const pressureReport = runPressureReleaseFn({ env: liveEnv });
|
|
478
|
+
const pressureReport = runPressureReleaseFn({ env: { ...liveEnv, HOME: homeDir } });
|
|
479
479
|
if (pressureReport?.killed?.length) {
|
|
480
480
|
results.push({
|
|
481
481
|
name: 'Pressure Guard',
|
|
@@ -527,31 +527,39 @@ export async function startServices({
|
|
|
527
527
|
status: dashboard.reused ? 'reused' : 'started',
|
|
528
528
|
});
|
|
529
529
|
|
|
530
|
-
// Telemetry backend — delegated to lib/services/telemetry-backend.mjs.
|
|
531
|
-
// Remote URLs short-circuit inside startManagedServices and return `configured`.
|
|
532
|
-
|
|
533
530
|
const telemetryUrl = liveEnv.CONSTRUCT_TELEMETRY_URL ?? '';
|
|
534
|
-
|
|
531
|
+
const traceBackend = resolveTraceBackend(liveEnv);
|
|
532
|
+
if (traceBackend === 'local') {
|
|
533
|
+
results.push({
|
|
534
|
+
name: 'Telemetry',
|
|
535
|
+
url: path.join(rootDir, '.cx', 'traces'),
|
|
536
|
+
status: 'configured',
|
|
537
|
+
note: 'local JSONL traces; remote export not configured',
|
|
538
|
+
});
|
|
539
|
+
} else if (traceBackend === 'none') {
|
|
540
|
+
results.push({
|
|
541
|
+
name: 'Telemetry',
|
|
542
|
+
url: path.join(rootDir, '.cx', 'traces'),
|
|
543
|
+
status: 'configured',
|
|
544
|
+
note: 'remote export disabled; local JSONL traces preserved',
|
|
545
|
+
});
|
|
546
|
+
} else if (traceBackend === 'otel') {
|
|
547
|
+
const endpoint = liveEnv.CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT || '';
|
|
548
|
+
results.push({
|
|
549
|
+
name: 'Telemetry',
|
|
550
|
+
url: endpoint || undefined,
|
|
551
|
+
status: endpoint ? 'configured' : 'unavailable',
|
|
552
|
+
note: endpoint ? `OTLP export (${telemetryProviderLabel(liveEnv)})` : 'CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT not set',
|
|
553
|
+
});
|
|
554
|
+
} else if (telemetryUrl && (isRemoteTelemetry(telemetryUrl) || traceBackend === 'langfuse' || traceBackend === 'http')) {
|
|
535
555
|
results.push({
|
|
536
556
|
name: 'Telemetry',
|
|
537
557
|
url: telemetryUrl,
|
|
538
558
|
status: 'configured',
|
|
539
|
-
note:
|
|
559
|
+
note: `${telemetryProviderLabel(liveEnv)} export — ${telemetryUrl}`,
|
|
540
560
|
});
|
|
541
561
|
} else {
|
|
542
|
-
|
|
543
|
-
if (composeRunner) {
|
|
544
|
-
const svc = await startManagedServices({
|
|
545
|
-
rootDir,
|
|
546
|
-
homeDir,
|
|
547
|
-
env: liveEnv,
|
|
548
|
-
composeRunner,
|
|
549
|
-
spawnDetached: spawnDetachedFn,
|
|
550
|
-
});
|
|
551
|
-
results.push({ name: 'Telemetry', url: svc.url, status: svc.status, note: svc.note });
|
|
552
|
-
} else {
|
|
553
|
-
results.push({ name: 'Telemetry', status: 'unavailable', note: 'Docker not available' });
|
|
554
|
-
}
|
|
562
|
+
results.push({ name: 'Telemetry', status: 'unavailable', note: 'remote export requested but endpoint is not configured' });
|
|
555
563
|
}
|
|
556
564
|
|
|
557
565
|
// Memory (cm)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/services/local-postgres.mjs — local Postgres helper exports.
|
|
3
|
+
*
|
|
4
|
+
* Compatibility wrappers live in telemetry-backend.mjs until downstream imports
|
|
5
|
+
* move, but new service-management code should use this name.
|
|
6
|
+
*/
|
|
7
|
+
export {
|
|
8
|
+
POSTGRES_LOCAL_PORT,
|
|
9
|
+
isRemoteTelemetry,
|
|
10
|
+
servicesComposePath,
|
|
11
|
+
pruneStashDir,
|
|
12
|
+
verifyPostgresHealth,
|
|
13
|
+
verifyTelemetryKeys,
|
|
14
|
+
startManagedServices,
|
|
15
|
+
} from './telemetry-backend.mjs';
|
|
@@ -18,7 +18,7 @@ import path from 'node:path';
|
|
|
18
18
|
import { getUserEnvPath, writeEnvValues } from '../env-config.mjs';
|
|
19
19
|
|
|
20
20
|
/** Local Postgres port (within Construct's 54329-54339 reserved block). */
|
|
21
|
-
export const POSTGRES_LOCAL_PORT =
|
|
21
|
+
export const POSTGRES_LOCAL_PORT = 54329;
|
|
22
22
|
|
|
23
23
|
export function isRemoteTelemetry(url = '') {
|
|
24
24
|
if (!url) return false;
|
|
@@ -175,4 +175,3 @@ export async function startManagedServices({
|
|
|
175
175
|
};
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
|
package/lib/setup.mjs
CHANGED
|
@@ -25,10 +25,6 @@ import { createSqlClient, closeSqlClient } from './storage/backend.mjs';
|
|
|
25
25
|
import { getEmbeddingModelInfo, warmupEmbeddingModel } from './storage/embeddings-engine.mjs';
|
|
26
26
|
import { restoreConstructDb } from './storage/postgres-backup.mjs';
|
|
27
27
|
import { buildPressureGuardValues, installPressureGuardLaunchAgent, loadPressureGuardLaunchAgent } from './runtime-pressure.mjs';
|
|
28
|
-
import {
|
|
29
|
-
isRemoteTelemetry,
|
|
30
|
-
startManagedServices,
|
|
31
|
-
} from './services/telemetry-backend.mjs';
|
|
32
28
|
import { consentToInstall } from './setup-prompts.mjs';
|
|
33
29
|
|
|
34
30
|
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
|
|
@@ -86,9 +82,8 @@ export function defaultVectorIndexPath(homeDir = HOME) {
|
|
|
86
82
|
export async function buildManagedSetupValues({ homeDir = HOME, env = process.env, databaseUrl = '' } = {}) {
|
|
87
83
|
const modelInfo = await getEmbeddingModelInfo({ env });
|
|
88
84
|
|
|
89
|
-
// Local telemetry backend
|
|
90
85
|
const values = {
|
|
91
|
-
CONSTRUCT_TRACE_BACKEND: env.CONSTRUCT_TRACE_BACKEND || '
|
|
86
|
+
CONSTRUCT_TRACE_BACKEND: env.CONSTRUCT_TRACE_BACKEND || 'local',
|
|
92
87
|
CONSTRUCT_VECTOR_INDEX_PATH: env.CONSTRUCT_VECTOR_INDEX_PATH || defaultVectorIndexPath(homeDir),
|
|
93
88
|
CONSTRUCT_VECTOR_MODEL: env.CONSTRUCT_VECTOR_MODEL || modelInfo.model,
|
|
94
89
|
...buildPressureGuardValues({ env }),
|
|
@@ -533,37 +528,10 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
|
|
|
533
528
|
console.log(` ${dockerInstallHint()}`);
|
|
534
529
|
}
|
|
535
530
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
if (dockerAvailable) {
|
|
541
|
-
const svcConsent = await consentToInstall({
|
|
542
|
-
name: 'telemetry',
|
|
543
|
-
isYes,
|
|
544
|
-
alreadyConfigured: isRemoteTelemetry(process.env.CONSTRUCT_TELEMETRY_URL || ''),
|
|
545
|
-
alreadyConfiguredNote: 'CONSTRUCT_TELEMETRY_URL points to a remote instance — local stack not started.',
|
|
546
|
-
envPath,
|
|
547
|
-
});
|
|
548
|
-
if (svcConsent.decision) {
|
|
549
|
-
try {
|
|
550
|
-
telemetryResult = await startManagedServices({
|
|
551
|
-
rootDir,
|
|
552
|
-
homeDir,
|
|
553
|
-
env: process.env,
|
|
554
|
-
composeRunner: dockerRunner,
|
|
555
|
-
spawnDetached: spawnDetachedForSetup,
|
|
556
|
-
});
|
|
557
|
-
console.log(`Telemetry: ${telemetryResult.status} — ${telemetryResult.note || ''}`);
|
|
558
|
-
} catch (err) {
|
|
559
|
-
telemetryResult = { status: 'error', note: err?.message || 'spin-up failed' };
|
|
560
|
-
console.log(`Telemetry: error — ${telemetryResult.note}`);
|
|
561
|
-
}
|
|
562
|
-
} else {
|
|
563
|
-
telemetryResult = { status: 'skipped', note: svcConsent.note };
|
|
564
|
-
console.log(`Telemetry: skipped (${svcConsent.note})`);
|
|
565
|
-
}
|
|
566
|
-
}
|
|
531
|
+
const telemetryResult = process.env.CONSTRUCT_TELEMETRY_URL
|
|
532
|
+
? { status: 'configured', note: `remote export configured (${process.env.CONSTRUCT_TELEMETRY_URL})` }
|
|
533
|
+
: { status: 'local', note: 'local JSONL traces in .cx/traces; remote export optional' };
|
|
534
|
+
console.log(`Telemetry: ${telemetryResult.note}`);
|
|
567
535
|
|
|
568
536
|
fs.mkdirSync(path.dirname(defaultVectorIndexPath(homeDir)), { recursive: true });
|
|
569
537
|
|
|
@@ -790,18 +758,15 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
|
|
|
790
758
|
// Local-service summary — surfaces running local services. Same pattern
|
|
791
759
|
// as `supabase start`, which prints all local URLs + keys after spin-up.
|
|
792
760
|
|
|
793
|
-
const hasLocalTelemetry = telemetryResult?.status === 'started' || telemetryResult?.status === 'degraded';
|
|
794
761
|
const hasLocalPostgres = serviceResult?.status === 'ok' && serviceResult?.databaseUrl?.includes('127.0.0.1');
|
|
795
762
|
const vectorBackend = hasLocalPostgres
|
|
796
763
|
? { label: 'Postgres + pgvector', detail: `${serviceResult.databaseUrl} (384d embeddings, ${managedValues.CONSTRUCT_VECTOR_MODEL})` }
|
|
797
764
|
: { label: 'JSON fallback', detail: `${managedValues.CONSTRUCT_VECTOR_INDEX_PATH} (${managedValues.CONSTRUCT_VECTOR_MODEL})` };
|
|
798
765
|
|
|
799
766
|
console.log('\nLocal services:');
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
console.log(` Telemetry: not started`);
|
|
804
|
-
}
|
|
767
|
+
console.log(' Traces: local JSONL (.cx/traces)');
|
|
768
|
+
if (process.env.CONSTRUCT_TELEMETRY_URL) console.log(` Telemetry: remote export (${process.env.CONSTRUCT_TELEMETRY_URL})`);
|
|
769
|
+
else console.log(' Telemetry: remote export not configured');
|
|
805
770
|
if (hasLocalPostgres) {
|
|
806
771
|
console.log(` Postgres: ${serviceResult.databaseUrl}`);
|
|
807
772
|
} else if (process.env.DATABASE_URL) {
|
package/lib/status.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { describeSqlStore, sqlStoreHealth } from './storage/sql-store.mjs';
|
|
|
24
24
|
import { describeVectorStore } from './storage/vector-store.mjs';
|
|
25
25
|
import { describeSqlStoreHealth } from './storage/sql-store.mjs';
|
|
26
26
|
import { triggerAutoBackfillIfSparse } from './telemetry/backfill.mjs';
|
|
27
|
+
import { resolveTraceBackend, telemetryProviderLabel } from './telemetry/client.mjs';
|
|
27
28
|
const TOTAL_BYTES_WARNING_THRESHOLD = 750_000;
|
|
28
29
|
|
|
29
30
|
function readJSON(path) {
|
|
@@ -317,18 +318,27 @@ async function defaultProbeService(service) {
|
|
|
317
318
|
}
|
|
318
319
|
|
|
319
320
|
async function fetchTelemetryStatus(env, { timeout = 2500 } = {}) {
|
|
321
|
+
const backend = resolveTraceBackend(env);
|
|
322
|
+
if (backend === 'local') return { status: 'healthy', summary: 'Local JSONL tracing enabled · remote export not configured', backend, provider: telemetryProviderLabel(env) };
|
|
323
|
+
if (backend === 'none') return { status: 'disabled', summary: 'Remote telemetry disabled · local JSONL tracing preserved', backend, provider: telemetryProviderLabel(env) };
|
|
324
|
+
if (backend === 'otel') {
|
|
325
|
+
const endpoint = (env.CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT ?? '').replace(/\/$/, '');
|
|
326
|
+
if (!endpoint) return { status: 'unavailable', summary: 'OTLP endpoint not configured', backend, provider: telemetryProviderLabel(env) };
|
|
327
|
+
return { status: 'configured', summary: `OTLP export configured · ${endpoint}`, backend, provider: telemetryProviderLabel(env) };
|
|
328
|
+
}
|
|
320
329
|
const baseUrl = (env.CONSTRUCT_TELEMETRY_URL ?? '').replace(/\/$/, '');
|
|
321
330
|
const key = env.CONSTRUCT_TELEMETRY_PUBLIC_KEY;
|
|
322
331
|
const secret = env.CONSTRUCT_TELEMETRY_SECRET_KEY;
|
|
323
|
-
if (!key || !secret) return { status: 'unavailable', summary: '
|
|
332
|
+
if (backend === 'langfuse' && (!key || !secret)) return { status: 'unavailable', summary: 'Langfuse credentials not configured', backend, provider: telemetryProviderLabel(env) };
|
|
324
333
|
if (!baseUrl) return { status: 'unavailable', summary: 'CONSTRUCT_TELEMETRY_URL not set' };
|
|
325
334
|
const headers = {
|
|
326
|
-
Authorization: `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}
|
|
335
|
+
...(key && secret ? { Authorization: `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}` } : {}),
|
|
327
336
|
};
|
|
328
337
|
const controller = new AbortController();
|
|
329
338
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
330
339
|
try {
|
|
331
|
-
const
|
|
340
|
+
const tracesPath = backend === 'http' ? '/traces?limit=25' : '/api/public/traces?limit=25';
|
|
341
|
+
const res = await fetch(`${baseUrl}${tracesPath}`, { headers, signal: controller.signal });
|
|
332
342
|
if (res.status === 401 || res.status === 403) {
|
|
333
343
|
return { status: 'credentials-invalid', summary: `Telemetry credentials rejected (HTTP ${res.status}) — run: construct init` };
|
|
334
344
|
}
|
|
@@ -359,6 +369,8 @@ async function fetchTelemetryStatus(env, { timeout = 2500 } = {}) {
|
|
|
359
369
|
|
|
360
370
|
return {
|
|
361
371
|
status,
|
|
372
|
+
backend,
|
|
373
|
+
provider: telemetryProviderLabel(env),
|
|
362
374
|
total,
|
|
363
375
|
rich: counts.rich,
|
|
364
376
|
partial: counts.partial,
|
|
@@ -478,6 +490,40 @@ export function buildPublicHealthSurface({
|
|
|
478
490
|
}
|
|
479
491
|
|
|
480
492
|
function traceBackendDefinition(env) {
|
|
493
|
+
const backend = resolveTraceBackend(env);
|
|
494
|
+
const provider = telemetryProviderLabel(env);
|
|
495
|
+
if (backend === 'local' || backend === 'none') {
|
|
496
|
+
return {
|
|
497
|
+
id: 'telemetry',
|
|
498
|
+
name: 'Telemetry',
|
|
499
|
+
url: 'local://.cx/traces',
|
|
500
|
+
runtime: 'local',
|
|
501
|
+
note: backend === 'none' ? 'Remote disabled; local JSONL preserved' : 'Local JSONL traces',
|
|
502
|
+
healthyMessage: 'Local trace capture enabled',
|
|
503
|
+
impactsOverall: false,
|
|
504
|
+
selfCheck: {
|
|
505
|
+
status: backend === 'none' ? 'disabled' : 'healthy',
|
|
506
|
+
message: backend === 'none' ? 'Remote telemetry disabled' : 'Writing .cx/traces/*.jsonl',
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
if (backend === 'otel') {
|
|
511
|
+
const endpoint = (env.CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT ?? '').replace(/\/$/, '');
|
|
512
|
+
return {
|
|
513
|
+
id: 'telemetry',
|
|
514
|
+
name: 'Telemetry',
|
|
515
|
+
url: endpoint || '(not configured)',
|
|
516
|
+
probeUrl: '',
|
|
517
|
+
runtime: 'remote',
|
|
518
|
+
note: `${provider} trace export`,
|
|
519
|
+
healthyMessage: 'Configured',
|
|
520
|
+
impactsOverall: false,
|
|
521
|
+
selfCheck: {
|
|
522
|
+
status: endpoint ? 'configured' : 'unavailable',
|
|
523
|
+
message: endpoint ? `OTLP export configured: ${endpoint}` : 'CONSTRUCT_OTEL_EXPORTER_OTLP_ENDPOINT not set',
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
}
|
|
481
527
|
const url = (env.CONSTRUCT_TELEMETRY_URL ?? '').replace(/\/$/, '');
|
|
482
528
|
const pubKey = env.CONSTRUCT_TELEMETRY_PUBLIC_KEY || '';
|
|
483
529
|
const secKey = env.CONSTRUCT_TELEMETRY_SECRET_KEY || '';
|
|
@@ -486,10 +532,10 @@ function traceBackendDefinition(env) {
|
|
|
486
532
|
id: 'telemetry',
|
|
487
533
|
name: 'Telemetry',
|
|
488
534
|
url: url || '(not configured)',
|
|
489
|
-
probeUrl: url ? `${url}/api/public/traces?limit=1` : '',
|
|
535
|
+
probeUrl: url ? `${url}${backend === 'http' ? '/traces?limit=1' : '/api/public/traces?limit=1'}` : '',
|
|
490
536
|
probeHeaders: auth ? { Authorization: auth } : undefined,
|
|
491
537
|
runtime: 'live',
|
|
492
|
-
note:
|
|
538
|
+
note: `${provider} trace export`,
|
|
493
539
|
healthyMessage: 'Reachable',
|
|
494
540
|
impactsOverall: false,
|
|
495
541
|
};
|
package/lib/storage/backend.mjs
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* lib/storage/backend.mjs — shared storage backend helpers for SQL/vector wiring.
|
|
4
|
+
*
|
|
5
|
+
* postgres is an optional peer dep. Top-level await catches the case where it is not
|
|
6
|
+
* installed (fresh installs, test temp dirs) so the module loads cleanly everywhere.
|
|
4
7
|
*/
|
|
5
|
-
import postgres from 'postgres';
|
|
6
8
|
import { resolveDatabaseUrl } from '../env-config.mjs';
|
|
7
9
|
|
|
10
|
+
let postgres = null;
|
|
11
|
+
try {
|
|
12
|
+
const mod = await import('postgres');
|
|
13
|
+
postgres = mod.default ?? mod;
|
|
14
|
+
} catch {
|
|
15
|
+
// postgres not installed — SQL features unavailable; createSqlClient returns null
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
function cleanUrl(url) {
|
|
9
19
|
return String(url || '').trim();
|
|
10
20
|
}
|
|
11
21
|
|
|
12
22
|
export function createSqlClient(env = process.env) {
|
|
13
23
|
const databaseUrl = cleanUrl(resolveDatabaseUrl(env));
|
|
14
|
-
if (!databaseUrl) return null;
|
|
24
|
+
if (!databaseUrl || !postgres) return null;
|
|
15
25
|
return postgres(databaseUrl, {
|
|
16
26
|
max: Number.parseInt(env.CONSTRUCT_DB_POOL_SIZE || '5', 10),
|
|
17
27
|
idle_timeout: Number.parseInt(env.CONSTRUCT_DB_IDLE_TIMEOUT_MS || '30000', 10),
|