@jhizzard/termdeck 0.3.9 → 0.4.2
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 -2
- package/package.json +1 -1
- package/packages/cli/src/forge.js +262 -0
- package/packages/cli/src/index.js +24 -0
- package/packages/client/public/app.js +206 -7
- package/packages/client/public/style.css +298 -2
- package/packages/server/src/forge-prompt.js +265 -0
- package/packages/server/src/index.js +120 -2
- package/packages/server/src/rag.js +29 -7
- package/packages/server/src/skill-installer.js +166 -0
- package/packages/server/src/transcripts.js +10 -1
|
@@ -57,7 +57,7 @@ const { RAGIntegration } = require('./rag');
|
|
|
57
57
|
const { createBridge } = require('./mnestra-bridge');
|
|
58
58
|
const { writeSessionLog } = require('./session-logger');
|
|
59
59
|
const { TranscriptWriter } = require('./transcripts');
|
|
60
|
-
const { createHealthHandler } = require('./preflight');
|
|
60
|
+
const { createHealthHandler, runPreflight } = require('./preflight');
|
|
61
61
|
const { themes, statusColors } = require('./themes');
|
|
62
62
|
const { loadConfig, addProject } = require('./config');
|
|
63
63
|
const { createAuthMiddleware, verifyWebSocketUpgrade, hasAuth } = require('./auth');
|
|
@@ -69,6 +69,11 @@ function createServer(config) {
|
|
|
69
69
|
|
|
70
70
|
app.use(express.json());
|
|
71
71
|
|
|
72
|
+
// First-run detection (Sprint 19 T3): true when ~/.termdeck/config.yaml
|
|
73
|
+
// does not exist. Surfaced on /api/config so the client can offer the
|
|
74
|
+
// setup wizard on first visit. T1's /api/setup endpoint may reuse this.
|
|
75
|
+
const firstRun = !fs.existsSync(path.join(os.homedir(), '.termdeck', 'config.yaml'));
|
|
76
|
+
|
|
72
77
|
// Optional token auth (Sprint 9 T3). Zero-op when no token is configured,
|
|
73
78
|
// so local users see no behavior change. Mounted before static + routes so
|
|
74
79
|
// unauthenticated requests never touch app.js / index.html.
|
|
@@ -133,8 +138,120 @@ function createServer(config) {
|
|
|
133
138
|
// ==================== REST API ====================
|
|
134
139
|
|
|
135
140
|
// GET /api/health - preflight health checks (Sprint 6 T1, wired by T3)
|
|
141
|
+
// SECURITY NOTE: Returns operational detail (memory counts, DB latency, project paths,
|
|
142
|
+
// RAG breaker state). Intentional for local-first use — TermDeck binds to 127.0.0.1 by
|
|
143
|
+
// default and the CLI guardrail blocks beyond-localhost binds without explicit opt-in.
|
|
144
|
+
// For any non-loopback deployment (Sprint 18+ remote story), gate this route behind auth
|
|
145
|
+
// or scope the response to a minimal {status, version} payload.
|
|
136
146
|
app.get('/api/health', createHealthHandler(config));
|
|
137
147
|
|
|
148
|
+
// GET /api/setup - setup wizard tier status (Sprint 19 T1)
|
|
149
|
+
// Reuses preflight checks (mnestra_reachable, rumen_recent) and pairs them
|
|
150
|
+
// with filesystem + config signals to classify which of the 4 TermDeck tiers
|
|
151
|
+
// the user has reached:
|
|
152
|
+
// 1. TermDeck running (always active when this handler responds)
|
|
153
|
+
// 2. Mnestra reachable + DATABASE_URL available (partial if only reachable)
|
|
154
|
+
// 3. Rumen job seen recently (partial if DATABASE_URL set but no recent job)
|
|
155
|
+
// 4. At least one project configured in config.yaml
|
|
156
|
+
// Cached for 60s so the setup UI can poll without re-running shell/PTY probes.
|
|
157
|
+
const SETUP_CONFIG_DIR = path.join(os.homedir(), '.termdeck');
|
|
158
|
+
const SETUP_SECRETS_PATH = path.join(SETUP_CONFIG_DIR, 'secrets.env');
|
|
159
|
+
const SETUP_CACHE_TTL_MS = 60_000;
|
|
160
|
+
let _setupCache = null;
|
|
161
|
+
let _setupCachedAt = 0;
|
|
162
|
+
|
|
163
|
+
app.get('/api/setup', async (req, res) => {
|
|
164
|
+
if (_setupCache && (Date.now() - _setupCachedAt) < SETUP_CACHE_TTL_MS) {
|
|
165
|
+
return res.json(_setupCache);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const preflight = await runPreflight(config);
|
|
170
|
+
const byName = {};
|
|
171
|
+
for (const c of preflight.checks) byName[c.name] = c;
|
|
172
|
+
|
|
173
|
+
const hasConfigFile = !firstRun;
|
|
174
|
+
const hasSecretsFile = fs.existsSync(SETUP_SECRETS_PATH);
|
|
175
|
+
const hasDatabaseUrl = !!process.env.DATABASE_URL;
|
|
176
|
+
const hasMnestraRunning = !!(byName.mnestra_reachable && byName.mnestra_reachable.passed);
|
|
177
|
+
const hasRumenDeployed = !!(byName.rumen_recent && byName.rumen_recent.passed);
|
|
178
|
+
const projectCount = Object.keys(config.projects || {}).length;
|
|
179
|
+
|
|
180
|
+
const tier1 = {
|
|
181
|
+
status: 'active',
|
|
182
|
+
detail: `TermDeck running on :${config.port || 3000}`
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
let tier2;
|
|
186
|
+
if (hasMnestraRunning && hasDatabaseUrl) {
|
|
187
|
+
tier2 = {
|
|
188
|
+
status: 'active',
|
|
189
|
+
detail: byName.mnestra_reachable.detail || 'Mnestra reachable'
|
|
190
|
+
};
|
|
191
|
+
} else if (hasMnestraRunning && !hasDatabaseUrl) {
|
|
192
|
+
tier2 = {
|
|
193
|
+
status: 'partial',
|
|
194
|
+
detail: 'Mnestra reachable but DATABASE_URL not set'
|
|
195
|
+
};
|
|
196
|
+
} else {
|
|
197
|
+
tier2 = {
|
|
198
|
+
status: 'not_configured',
|
|
199
|
+
detail: (byName.mnestra_reachable && byName.mnestra_reachable.detail) || 'Mnestra not reachable'
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let tier3;
|
|
204
|
+
if (hasRumenDeployed) {
|
|
205
|
+
tier3 = { status: 'active', detail: byName.rumen_recent.detail };
|
|
206
|
+
} else if (hasDatabaseUrl && byName.rumen_recent &&
|
|
207
|
+
/no completed Rumen jobs|stale/i.test(byName.rumen_recent.detail || '')) {
|
|
208
|
+
tier3 = { status: 'partial', detail: byName.rumen_recent.detail };
|
|
209
|
+
} else {
|
|
210
|
+
tier3 = {
|
|
211
|
+
status: 'not_configured',
|
|
212
|
+
detail: (byName.rumen_recent && byName.rumen_recent.detail) || 'Rumen not deployed'
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const tier4 = projectCount > 0
|
|
217
|
+
? { status: 'active', detail: `${projectCount} project${projectCount === 1 ? '' : 's'} configured` }
|
|
218
|
+
: { status: 'not_configured', detail: 'No project paths in config.yaml' };
|
|
219
|
+
|
|
220
|
+
const tiers = { 1: tier1, 2: tier2, 3: tier3, 4: tier4 };
|
|
221
|
+
|
|
222
|
+
// Current tier = highest contiguous tier with status active or partial.
|
|
223
|
+
let tier = 0;
|
|
224
|
+
for (let i = 1; i <= 4; i++) {
|
|
225
|
+
if (tiers[i].status === 'active' || tiers[i].status === 'partial') {
|
|
226
|
+
tier = i;
|
|
227
|
+
} else {
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const payload = {
|
|
233
|
+
tier,
|
|
234
|
+
tiers,
|
|
235
|
+
config: {
|
|
236
|
+
hasSecretsFile,
|
|
237
|
+
hasConfigFile,
|
|
238
|
+
hasDatabaseUrl,
|
|
239
|
+
hasMnestraRunning,
|
|
240
|
+
hasRumenDeployed,
|
|
241
|
+
projectCount
|
|
242
|
+
},
|
|
243
|
+
firstRun
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
_setupCache = payload;
|
|
247
|
+
_setupCachedAt = Date.now();
|
|
248
|
+
res.json(payload);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
console.error('[setup] /api/setup failed:', err.message);
|
|
251
|
+
res.status(500).json({ error: err.message });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
138
255
|
// GET /api/sessions - list all active sessions
|
|
139
256
|
app.get('/api/sessions', (req, res) => {
|
|
140
257
|
res.json(sessions.getAll());
|
|
@@ -429,7 +546,8 @@ function createServer(config) {
|
|
|
429
546
|
defaultTheme: config.defaultTheme,
|
|
430
547
|
ragEnabled: rag.enabled,
|
|
431
548
|
aiQueryAvailable: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey && config.rag?.openaiApiKey),
|
|
432
|
-
statusColors
|
|
549
|
+
statusColors,
|
|
550
|
+
firstRun
|
|
433
551
|
});
|
|
434
552
|
});
|
|
435
553
|
|
|
@@ -59,8 +59,12 @@ class RAGIntegration {
|
|
|
59
59
|
};
|
|
60
60
|
|
|
61
61
|
// Circuit breaker: track consecutive 404s per table name.
|
|
62
|
-
// After 3 consecutive 404s,
|
|
63
|
-
|
|
62
|
+
// After 3 consecutive 404s, open the breaker. The breaker auto-transitions
|
|
63
|
+
// to half-open after 5 minutes, allowing one retry attempt. A successful
|
|
64
|
+
// retry fully resets the breaker; a failed retry re-opens it for another
|
|
65
|
+
// 5-minute backoff window.
|
|
66
|
+
this._circuitBreaker = new Map(); // table -> { count, open, openedAt, halfOpen }
|
|
67
|
+
this._halfOpenDelayMs = 5 * 60 * 1000;
|
|
64
68
|
|
|
65
69
|
if (this.enabled) {
|
|
66
70
|
this._startSync();
|
|
@@ -142,22 +146,34 @@ class RAGIntegration {
|
|
|
142
146
|
}, this._projectFor(session));
|
|
143
147
|
}
|
|
144
148
|
|
|
145
|
-
// Circuit breaker check — returns true if pushes to this table are disabled
|
|
149
|
+
// Circuit breaker check — returns true if pushes to this table are disabled.
|
|
150
|
+
// Has a side effect: when the 5-minute half-open window has elapsed, flips
|
|
151
|
+
// the breaker to half-open and permits one retry attempt through.
|
|
146
152
|
_isCircuitOpen(table) {
|
|
147
153
|
const state = this._circuitBreaker.get(table);
|
|
148
|
-
|
|
154
|
+
if (!state || !state.open) return false;
|
|
155
|
+
if (state.halfOpen) return true; // retry already in flight — block concurrent pushes
|
|
156
|
+
|
|
157
|
+
const elapsed = Date.now() - (state.openedAt || 0);
|
|
158
|
+
if (elapsed >= this._halfOpenDelayMs) {
|
|
159
|
+
state.halfOpen = true;
|
|
160
|
+
console.log(`[rag] circuit breaker half-open for ${table}, retrying`);
|
|
161
|
+
return false; // allow one attempt through
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
149
164
|
}
|
|
150
165
|
|
|
151
166
|
// Record a 404 for a table; opens the breaker after 3 consecutive hits
|
|
152
167
|
_record404(table) {
|
|
153
168
|
let state = this._circuitBreaker.get(table);
|
|
154
169
|
if (!state) {
|
|
155
|
-
state = { count: 0, open: false };
|
|
170
|
+
state = { count: 0, open: false, openedAt: null, halfOpen: false };
|
|
156
171
|
this._circuitBreaker.set(table, state);
|
|
157
172
|
}
|
|
158
173
|
state.count += 1;
|
|
159
174
|
if (state.count >= 3 && !state.open) {
|
|
160
175
|
state.open = true;
|
|
176
|
+
state.openedAt = Date.now();
|
|
161
177
|
console.warn(`[rag] circuit breaker open for ${table} — disabling pushes (table may not exist in Supabase)`);
|
|
162
178
|
}
|
|
163
179
|
}
|
|
@@ -208,8 +224,14 @@ class RAGIntegration {
|
|
|
208
224
|
// Success — reset any accumulated 404 count for this table
|
|
209
225
|
this._resetCircuit(table);
|
|
210
226
|
} catch (err) {
|
|
211
|
-
|
|
212
|
-
if (
|
|
227
|
+
const state = this._circuitBreaker.get(table);
|
|
228
|
+
if (state && state.halfOpen) {
|
|
229
|
+
// Half-open retry failed — re-open for another 5-minute backoff window
|
|
230
|
+
state.halfOpen = false;
|
|
231
|
+
state.openedAt = Date.now();
|
|
232
|
+
console.warn(`[rag] circuit breaker re-opened for ${table} after half-open retry failed`);
|
|
233
|
+
} else if (!state || !state.open) {
|
|
234
|
+
// Log at warn (not error) to reduce noise — the circuit breaker handles persistence
|
|
213
235
|
console.warn('[rag] push to', table, 'failed:', err.message);
|
|
214
236
|
}
|
|
215
237
|
throw err; // Propagate to caller so sync loop knows this event failed
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Skill installer (Sprint 20 / SkillForge foundation).
|
|
2
|
+
// Writes generated skills to ~/.claude/skills/ as markdown files with frontmatter,
|
|
3
|
+
// lists installed skills, and removes them. Used by the `termdeck forge` CLI.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const FRONTMATTER_KEYS = ['name', 'description', 'trigger', 'source', 'generated'];
|
|
10
|
+
|
|
11
|
+
function getSkillsDir() {
|
|
12
|
+
const override = process.env.TERMDECK_SKILLS_DIR;
|
|
13
|
+
if (override && override.trim()) return path.resolve(override);
|
|
14
|
+
const home = os.homedir();
|
|
15
|
+
if (!home) {
|
|
16
|
+
return path.resolve(process.cwd(), '.claude', 'skills');
|
|
17
|
+
}
|
|
18
|
+
return path.join(home, '.claude', 'skills');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ensureSkillsDir() {
|
|
22
|
+
const dir = getSkillsDir();
|
|
23
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function validateName(name) {
|
|
28
|
+
if (!name || typeof name !== 'string') {
|
|
29
|
+
throw new Error('skill name is required');
|
|
30
|
+
}
|
|
31
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(name)) {
|
|
32
|
+
throw new Error(`invalid skill name: ${name} (use letters, digits, - or _)`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function skillPath(name) {
|
|
37
|
+
validateName(name);
|
|
38
|
+
return path.join(getSkillsDir(), `${name}.md`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function escapeFrontmatterValue(value) {
|
|
42
|
+
const str = String(value ?? '');
|
|
43
|
+
if (str.includes('\n')) {
|
|
44
|
+
return JSON.stringify(str);
|
|
45
|
+
}
|
|
46
|
+
if (/^[\s"'`]|[:#]\s|[\s"'`]$/.test(str)) {
|
|
47
|
+
return JSON.stringify(str);
|
|
48
|
+
}
|
|
49
|
+
return str;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildMarkdown(skill) {
|
|
53
|
+
const generated = skill.generated || new Date().toISOString();
|
|
54
|
+
const frontmatter = { ...skill, generated };
|
|
55
|
+
const lines = ['---'];
|
|
56
|
+
for (const key of FRONTMATTER_KEYS) {
|
|
57
|
+
if (frontmatter[key] === undefined || frontmatter[key] === null) continue;
|
|
58
|
+
lines.push(`${key}: ${escapeFrontmatterValue(frontmatter[key])}`);
|
|
59
|
+
}
|
|
60
|
+
lines.push('---');
|
|
61
|
+
lines.push('');
|
|
62
|
+
const body = (skill.content || skill.body || '').replace(/\s+$/, '');
|
|
63
|
+
if (body) {
|
|
64
|
+
lines.push(body);
|
|
65
|
+
lines.push('');
|
|
66
|
+
}
|
|
67
|
+
return lines.join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseFrontmatter(markdown) {
|
|
71
|
+
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
72
|
+
if (!match) return {};
|
|
73
|
+
const meta = {};
|
|
74
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
75
|
+
const m = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
|
|
76
|
+
if (!m) continue;
|
|
77
|
+
let value = m[2].trim();
|
|
78
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
79
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
80
|
+
try { value = JSON.parse(value); } catch (_) { value = value.slice(1, -1); }
|
|
81
|
+
}
|
|
82
|
+
meta[m[1]] = value;
|
|
83
|
+
}
|
|
84
|
+
return meta;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function installSkill(skill, options = {}) {
|
|
88
|
+
if (!skill || typeof skill !== 'object') {
|
|
89
|
+
throw new Error('installSkill: skill object is required');
|
|
90
|
+
}
|
|
91
|
+
validateName(skill.name);
|
|
92
|
+
const dir = ensureSkillsDir();
|
|
93
|
+
const filepath = path.join(dir, `${skill.name}.md`);
|
|
94
|
+
const exists = fs.existsSync(filepath);
|
|
95
|
+
if (exists && !options.overwrite) {
|
|
96
|
+
const err = new Error(`skill already exists: ${skill.name}`);
|
|
97
|
+
err.code = 'SKILL_EXISTS';
|
|
98
|
+
err.path = filepath;
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
const markdown = buildMarkdown(skill);
|
|
102
|
+
fs.writeFileSync(filepath, markdown, 'utf-8');
|
|
103
|
+
return { path: filepath, overwritten: exists };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function listInstalledSkills() {
|
|
107
|
+
const dir = getSkillsDir();
|
|
108
|
+
if (!fs.existsSync(dir)) return [];
|
|
109
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
110
|
+
const skills = [];
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
113
|
+
const filepath = path.join(dir, entry.name);
|
|
114
|
+
let meta = {};
|
|
115
|
+
let stat = null;
|
|
116
|
+
try {
|
|
117
|
+
const content = fs.readFileSync(filepath, 'utf-8');
|
|
118
|
+
meta = parseFrontmatter(content);
|
|
119
|
+
stat = fs.statSync(filepath);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
// skip unreadable file, keep going
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
skills.push({
|
|
125
|
+
name: meta.name || entry.name.replace(/\.md$/, ''),
|
|
126
|
+
description: meta.description || '',
|
|
127
|
+
trigger: meta.trigger || '',
|
|
128
|
+
source: meta.source || '',
|
|
129
|
+
generated: meta.generated || (stat ? stat.mtime.toISOString() : ''),
|
|
130
|
+
path: filepath
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
134
|
+
return skills;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function skillExists(name) {
|
|
138
|
+
try {
|
|
139
|
+
return fs.existsSync(skillPath(name));
|
|
140
|
+
} catch (_) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function removeSkill(name) {
|
|
146
|
+
const filepath = skillPath(name);
|
|
147
|
+
if (!fs.existsSync(filepath)) {
|
|
148
|
+
const err = new Error(`skill not found: ${name}`);
|
|
149
|
+
err.code = 'SKILL_NOT_FOUND';
|
|
150
|
+
err.path = filepath;
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
fs.unlinkSync(filepath);
|
|
154
|
+
return { path: filepath };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = {
|
|
158
|
+
getSkillsDir,
|
|
159
|
+
installSkill,
|
|
160
|
+
listInstalledSkills,
|
|
161
|
+
removeSkill,
|
|
162
|
+
skillExists,
|
|
163
|
+
// exposed for tests
|
|
164
|
+
_buildMarkdown: buildMarkdown,
|
|
165
|
+
_parseFrontmatter: parseFrontmatter
|
|
166
|
+
};
|
|
@@ -46,6 +46,8 @@ class TranscriptWriter {
|
|
|
46
46
|
// Lazy pool
|
|
47
47
|
this._pool = null;
|
|
48
48
|
this._poolFailed = false;
|
|
49
|
+
this._poolFailedAt = 0;
|
|
50
|
+
this._poolRetryMs = 30_000;
|
|
49
51
|
|
|
50
52
|
// Start flush timer
|
|
51
53
|
this._timer = null;
|
|
@@ -56,7 +58,13 @@ class TranscriptWriter {
|
|
|
56
58
|
|
|
57
59
|
// Lazy-init pg.Pool (same pattern as getRumenPool in index.js)
|
|
58
60
|
_getPool() {
|
|
59
|
-
if (this._pool
|
|
61
|
+
if (this._pool) return this._pool;
|
|
62
|
+
if (this._poolFailed) {
|
|
63
|
+
if (Date.now() - this._poolFailedAt < this._poolRetryMs) return null;
|
|
64
|
+
console.warn('[transcript] retrying pool creation after 30s cooldown');
|
|
65
|
+
this._poolFailed = false;
|
|
66
|
+
this._poolFailedAt = 0;
|
|
67
|
+
}
|
|
60
68
|
if (!pg || !this._databaseUrl) return null;
|
|
61
69
|
try {
|
|
62
70
|
this._pool = new pg.Pool({
|
|
@@ -72,6 +80,7 @@ class TranscriptWriter {
|
|
|
72
80
|
} catch (err) {
|
|
73
81
|
console.error('[transcript] pool creation failed:', err.message);
|
|
74
82
|
this._poolFailed = true;
|
|
83
|
+
this._poolFailedAt = Date.now();
|
|
75
84
|
return null;
|
|
76
85
|
}
|
|
77
86
|
}
|