@velaro/cli 1.4.96 → 1.4.98
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/bin/velaro.js +6 -0
- package/lib/commands/canned-learning.js +8 -5
- package/lib/commands/chat-window.js +47 -0
- package/lib/commands/db.js +1 -0
- package/lib/commands/deploy-state.js +26 -0
- package/lib/commands/logs.js +27 -21
- package/lib/commands/ops.js +32 -13
- package/lib/commands/safety.js +111 -0
- package/lib/deploy-state.js +408 -0
- package/lib/deploy-state.test.js +133 -0
- package/lib/log-search.js +159 -50
- package/lib/log-search.test.js +288 -43
- package/package.json +1 -1
package/bin/velaro.js
CHANGED
|
@@ -10,10 +10,12 @@ import { botCommand } from '../lib/commands/bot.js';
|
|
|
10
10
|
import { indexCommand } from '../lib/commands/index.js';
|
|
11
11
|
import { ingestCommand } from '../lib/commands/ingest.js';
|
|
12
12
|
import { indexMoveCommand } from '../lib/commands/index-move.js';
|
|
13
|
+
import { safetyCommand } from '../lib/commands/safety.js';
|
|
13
14
|
import { knowledgeAdvisorCommand } from '../lib/commands/knowledge-advisor.js';
|
|
14
15
|
import { productCommand } from '../lib/commands/product.js';
|
|
15
16
|
import { mcpKeyCommand } from '../lib/commands/mcp-key.js';
|
|
16
17
|
import { opsCommand } from '../lib/commands/ops.js';
|
|
18
|
+
import { deployStateCommand } from '../lib/commands/deploy-state.js';
|
|
17
19
|
import { checkCommand } from '../lib/commands/check.js';
|
|
18
20
|
import { logsCommand } from '../lib/commands/logs.js';
|
|
19
21
|
import { aiModelsCommand } from '../lib/commands/ai-models.js';
|
|
@@ -109,6 +111,7 @@ import { paymentRecoveryCommand } from '../lib/commands/payment-recovery.js';
|
|
|
109
111
|
import { recruitingCommand } from '../lib/commands/recruiting.js';
|
|
110
112
|
import { surveyCommand } from '../lib/commands/survey.js';
|
|
111
113
|
import { bundleCommand } from '../lib/commands/bundle.js';
|
|
114
|
+
import { chatWindowCommand } from '../lib/commands/chat-window.js';
|
|
112
115
|
import { featureDiscoveryCommand } from '../lib/commands/feature-discovery.js';
|
|
113
116
|
import { tourPolicyCommand } from '../lib/commands/tour-policy.js';
|
|
114
117
|
import { scraperCommand } from '../lib/commands/scraper.js';
|
|
@@ -141,10 +144,12 @@ await yargs(hideBin(process.argv))
|
|
|
141
144
|
.command(indexCommand)
|
|
142
145
|
.command(ingestCommand)
|
|
143
146
|
.command(indexMoveCommand)
|
|
147
|
+
.command(safetyCommand)
|
|
144
148
|
.command(knowledgeAdvisorCommand)
|
|
145
149
|
.command(productCommand)
|
|
146
150
|
.command(mcpKeyCommand)
|
|
147
151
|
.command(opsCommand)
|
|
152
|
+
.command(deployStateCommand)
|
|
148
153
|
.command(checkCommand)
|
|
149
154
|
.command(logsCommand)
|
|
150
155
|
.command(aiModelsCommand)
|
|
@@ -242,6 +247,7 @@ await yargs(hideBin(process.argv))
|
|
|
242
247
|
.command(recruitingCommand)
|
|
243
248
|
.command(surveyCommand)
|
|
244
249
|
.command(bundleCommand)
|
|
250
|
+
.command(chatWindowCommand)
|
|
245
251
|
.command({ command: 'feature-discovery', desc: 'View and act on feature-discovery suggestions for this site', builder: featureDiscoveryCommand, handler: () => {} })
|
|
246
252
|
.command({ command: 'scraper', desc: 'Velaro staff: scraper / upload / chunk usage for any site', builder: scraperCommand, handler: () => {} })
|
|
247
253
|
.command({ command: 'tour-policy', desc: 'Velaro staff: guided-tour auto-open policy (versioned, rollback-able)', builder: tourPolicyCommand, handler: () => {} })
|
|
@@ -3,22 +3,25 @@ import { runCommand } from '../run.js';
|
|
|
3
3
|
|
|
4
4
|
const print = (data) => console.log(JSON.stringify(data, null, 2));
|
|
5
5
|
|
|
6
|
-
// velaro canned-learning <list|generate|approve|reject|rollback|edits|record-edit> (REST: messaging /CannedLearning/*)
|
|
6
|
+
// velaro canned-learning <list|generate|approve|reject|rollback|edits|record-edit|teams> (REST: messaging /CannedLearning/*)
|
|
7
7
|
export const cannedLearningCommand = {
|
|
8
8
|
command: 'canned-learning <action> [id]',
|
|
9
9
|
describe: 'AI canned message learning: list/generate suggestions, approve/reject/rollback (human review)',
|
|
10
10
|
builder: (y) => y
|
|
11
|
-
.positional('action', { choices: ['list', 'generate', 'approve', 'reject', 'rollback', 'edits', 'record-edit'] })
|
|
11
|
+
.positional('action', { choices: ['list', 'generate', 'approve', 'reject', 'rollback', 'edits', 'record-edit', 'teams'] })
|
|
12
12
|
.positional('id', { type: 'number' })
|
|
13
|
+
.option('team-id', { type: 'number', describe: 'list/edits: only this team; generate: run for this team (team managers must pass one they manage)' })
|
|
13
14
|
.option('state', { type: 'string', default: 'pending_review', describe: 'pending_review|approved|rejected|rolled_back|all' })
|
|
14
15
|
.option('text', { type: 'string', describe: 'approve: edited replacement text (defaults to the AI proposal)' })
|
|
15
16
|
.option('notes', { type: 'string', describe: 'rollback: reason' })
|
|
16
17
|
.option('final-text', { type: 'string', describe: 'record-edit: the text the agent actually sent (redacted server-side)' }),
|
|
17
18
|
handler: runCommand(async (a) => {
|
|
18
19
|
const base = '/CannedLearning';
|
|
19
|
-
|
|
20
|
-
if (a.action === '
|
|
21
|
-
if (a.action === '
|
|
20
|
+
const teamQs = a.teamId != null ? `&teamId=${encodeURIComponent(a.teamId)}` : '';
|
|
21
|
+
if (a.action === 'list') return print(await messagingGet(`${base}/suggestions?state=${encodeURIComponent(a.state)}${teamQs}`));
|
|
22
|
+
if (a.action === 'generate') return print(await messagingPost(`${base}/generate`, { teamId: a.teamId ?? null }));
|
|
23
|
+
if (a.action === 'edits') return print(await messagingGet(`${base}/edit-events${a.teamId != null ? `?teamId=${encodeURIComponent(a.teamId)}` : ''}`));
|
|
24
|
+
if (a.action === 'teams') return print(await messagingGet(`${base}/teams`));
|
|
22
25
|
if (a.action === 'record-edit') {
|
|
23
26
|
if (a.id == null || !a.finalText) throw new Error('record-edit requires a canned response id and --final-text');
|
|
24
27
|
return print(await messagingPost(`${base}/edit-events`, { cannedResponseId: a.id, finalText: a.finalText }));
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// velaro chat-window — per-page launcher label + opening greeting (ChatWindow/* on velaro-messaging).
|
|
2
|
+
// Site isolation is enforced server-side (ClientControllerBase SiteId), never passed by the client.
|
|
3
|
+
// To show different copy on different pages: create one chat window per page type, set its
|
|
4
|
+
// label/greeting here, then pick it with a Select Chat Window workflow node.
|
|
5
|
+
import { messagingGet, messagingPut } from '../api.js';
|
|
6
|
+
import { runCommand } from '../run.js';
|
|
7
|
+
|
|
8
|
+
export const chatWindowCommand = {
|
|
9
|
+
command: 'chat-window <subcommand>',
|
|
10
|
+
describe: 'Chat window launcher label and greeting — list, set-copy',
|
|
11
|
+
builder: (yargs) =>
|
|
12
|
+
yargs
|
|
13
|
+
.command({
|
|
14
|
+
command: 'list',
|
|
15
|
+
describe: 'List chat windows with launcher label and greeting',
|
|
16
|
+
handler: runCommand(async () => {
|
|
17
|
+
const rows = (await messagingGet('/ChatWindow/list')) ?? [];
|
|
18
|
+
if (!rows.length) { console.log('No chat windows found.'); return; }
|
|
19
|
+
for (const w of rows) {
|
|
20
|
+
console.log(`${w.id} ${w.name ?? '(unnamed)'} [${w.languageConfig?.language ?? '?'}]`);
|
|
21
|
+
console.log(` launcherText: ${w.styleConfig?.launcherText ?? '(none)'}`);
|
|
22
|
+
console.log(` greeting: ${w.languageConfig?.greeting ?? '(none)'}`);
|
|
23
|
+
}
|
|
24
|
+
console.log(`\n${rows.length} chat window(s)`);
|
|
25
|
+
}),
|
|
26
|
+
})
|
|
27
|
+
.command({
|
|
28
|
+
command: 'set-copy <id>',
|
|
29
|
+
describe: 'Set launcher label and/or greeting on one chat window (empty string clears)',
|
|
30
|
+
builder: (y) => y
|
|
31
|
+
.positional('id', { type: 'number', describe: 'Chat window ID' })
|
|
32
|
+
.option('launcher-text', { type: 'string', describe: 'Launcher label, max 60 chars' })
|
|
33
|
+
.option('greeting', { type: 'string', describe: 'Opening greeting, max 256 chars' }),
|
|
34
|
+
handler: runCommand(async (argv) => {
|
|
35
|
+
const body = {};
|
|
36
|
+
if (argv.launcherText !== undefined) body.launcherText = argv.launcherText;
|
|
37
|
+
if (argv.greeting !== undefined) body.greeting = argv.greeting;
|
|
38
|
+
if (!Object.keys(body).length) throw new Error('Pass --launcher-text and/or --greeting.');
|
|
39
|
+
const r = await messagingPut(`/ChatWindow/${argv.id}/launcher-copy`, body);
|
|
40
|
+
console.log(`Updated chat window ${r.id}.`);
|
|
41
|
+
console.log(` launcherText: ${r.launcherText ?? '(none)'}`);
|
|
42
|
+
console.log(` greeting: ${r.greeting ?? '(none)'}`);
|
|
43
|
+
}),
|
|
44
|
+
})
|
|
45
|
+
.demandCommand(1, 'Specify a subcommand: list, set-copy'),
|
|
46
|
+
handler: () => {},
|
|
47
|
+
};
|
package/lib/commands/db.js
CHANGED
|
@@ -26,6 +26,7 @@ const DB_SECRETS = {
|
|
|
26
26
|
staging: { vault: 'velaro-eastus-stg-kv', secret: 'KnowledgeBaseConnectionString-Staging' },
|
|
27
27
|
},
|
|
28
28
|
messaging: {
|
|
29
|
+
staging: { vault: 'velaro-eastus-stg-kv', secret: 'MessagingConnectionString-Staging' },
|
|
29
30
|
prod: { vault: 'velaro-eastus-prd-kv', secret: 'MessagingReadOnlyConnectionString' },
|
|
30
31
|
},
|
|
31
32
|
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { collectAll, render, PRODUCTS } from '../deploy-state.js';
|
|
2
|
+
|
|
3
|
+
// velaro deploy-state: what is on the staging environment, on staging slot 1 of production and in
|
|
4
|
+
// production for Admin and Messaging, what is merged to dev but not in production, which gate is
|
|
5
|
+
// waiting and the merge-freeze file. Uses only `gh` (logged in) and public /Version endpoints.
|
|
6
|
+
export const deployStateCommand = {
|
|
7
|
+
command: 'deploy-state',
|
|
8
|
+
describe: 'Where each push landed: staging environment, staging slot 1 of production, production (Velaro staff, needs gh)',
|
|
9
|
+
builder: (yargs) => yargs
|
|
10
|
+
.option('product', { type: 'string', choices: ['admin', 'messaging'], describe: 'Only one product (default both)' })
|
|
11
|
+
.option('json', { type: 'boolean', describe: 'Print the raw state as JSON' })
|
|
12
|
+
.option('compact', { type: 'boolean', describe: 'One line per place' }),
|
|
13
|
+
handler: async (argv) => {
|
|
14
|
+
try {
|
|
15
|
+
const products = argv.product
|
|
16
|
+
? [Object.keys(PRODUCTS).find((n) => n.toLowerCase() === argv.product)]
|
|
17
|
+
: undefined;
|
|
18
|
+
const state = await collectAll({ products });
|
|
19
|
+
console.log(argv.json ? JSON.stringify(state, null, 2) : render(state, { compact: !!argv.compact }));
|
|
20
|
+
} catch (e) {
|
|
21
|
+
console.error(`deploy-state failed: ${e.message}`);
|
|
22
|
+
console.error('This command needs the GitHub CLI (gh) installed and logged in with access to velaro/velaro-admin and velaro/velaro-messaging. Run: gh auth login');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
};
|
package/lib/commands/logs.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getCredentials } from '../api.js';
|
|
2
2
|
import { readConfig, ENVS } from '../config.js';
|
|
3
|
-
import { completeLogSearch, describeCompleteness, describeRawMeta } from '../log-search.js';
|
|
3
|
+
import { completeLogSearch, describeCompleteness, describeRawMeta, fetchJsonRetry } from '../log-search.js';
|
|
4
4
|
|
|
5
5
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
6
6
|
|
|
@@ -57,6 +57,10 @@ async function requireAdmin(creds) {
|
|
|
57
57
|
// ── search handler ────────────────────────────────────────────────────────────
|
|
58
58
|
|
|
59
59
|
async function searchLogs(argv) {
|
|
60
|
+
try { await searchLogsInner(argv); } catch (err) { console.error(err?.message ?? String(err)); process.exit(1); }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function searchLogsInner(argv) {
|
|
60
64
|
const creds = await getCredentials();
|
|
61
65
|
await requireAdmin(creds);
|
|
62
66
|
const msgBase = await getMsgBase(argv);
|
|
@@ -68,14 +72,8 @@ async function searchLogs(argv) {
|
|
|
68
72
|
process.exit(1);
|
|
69
73
|
}
|
|
70
74
|
const headers = { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) };
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (!res.ok) {
|
|
74
|
-
console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
|
|
75
|
-
process.exit(1);
|
|
76
|
-
}
|
|
77
|
-
return res.json();
|
|
78
|
-
};
|
|
75
|
+
// 40 s: the server's own scan budget is 12 s (+6 s AI search) per request, and a 429 (scan gate full) is retried with Retry-After.
|
|
76
|
+
const getJson = (query) => fetchJsonRetry(`${msgBase}/superadmin/logs/search?${query}`, () => ({ headers, signal: AbortSignal.timeout(40000) }));
|
|
79
77
|
|
|
80
78
|
// --raw: the server's own q= search (kept for parity). Proven 2026-10-14 to under-report (q=[HttpFallback]
|
|
81
79
|
// returned 0-2 rows while 136 existed), so it now always prints the server's searchPath/scannedRows/truncated.
|
|
@@ -110,6 +108,8 @@ async function searchLogs(argv) {
|
|
|
110
108
|
regex: argv.regex ? argv.query : undefined,
|
|
111
109
|
logger: argv.logger,
|
|
112
110
|
maxRequests: argv.maxRequests ?? 200,
|
|
111
|
+
maxSeconds: argv.maxSeconds ?? 120,
|
|
112
|
+
wanted: argv.take ?? 50,
|
|
113
113
|
});
|
|
114
114
|
console.log(`${result.complete ? DIM : YEL}${describeCompleteness(result)}${RESET}`);
|
|
115
115
|
if (!result.entries.length) { console.log('No results.'); return; }
|
|
@@ -238,21 +238,26 @@ async function diagnoseLogs(argv) {
|
|
|
238
238
|
|
|
239
239
|
console.log(`\n${BOLD}── Bot Diagnostic — last ${window} ──────────────────────────────${RESET2}`);
|
|
240
240
|
|
|
241
|
-
// Run error search and perf/routing search in parallel
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
241
|
+
// Run error search and perf/routing search in parallel. FIXED 2026-10-14: the old calls searched the TEXT "level:ERROR" and the
|
|
242
|
+
// TEXT "WF-AI-ROUTE OR PERF-SLOW", which no log line contains, so this printed "No errors" no matter what. Errors are now the
|
|
243
|
+
// server-side level filter and the perf/routing pair is a regex; the server's scan metadata is surfaced so an unverified
|
|
244
|
+
// empty answer is never shown as a clean bill of health.
|
|
245
|
+
const notes = [];
|
|
246
|
+
async function search(label, p0) {
|
|
247
|
+
const p = new URLSearchParams({ from: from.toISOString(), take: '50', ...p0 });
|
|
248
|
+
const resp = await fetchJsonRetry(`${msgBase}/superadmin/logs/search?${p}`, () => ({
|
|
245
249
|
headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
|
|
246
|
-
signal: AbortSignal.timeout(
|
|
247
|
-
});
|
|
248
|
-
if (!
|
|
249
|
-
|
|
250
|
+
signal: AbortSignal.timeout(40000),
|
|
251
|
+
})).catch((err) => { notes.push(`${label}: search failed (${err.message})`); return null; });
|
|
252
|
+
if (resp && !Array.isArray(resp) && resp.truncated !== false) notes.push(`${label}: ${describeRawMeta(resp)}`);
|
|
253
|
+
else if (Array.isArray(resp)) notes.push(`${label}: ${describeRawMeta(resp)}`);
|
|
254
|
+
return resp ? extractLogEntries(resp) : [];
|
|
250
255
|
}
|
|
251
256
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
257
|
+
// One at a time: the server runs at most 2 exact scans per instance.
|
|
258
|
+
const errors = await search('errors', { level: 'ERROR' });
|
|
259
|
+
const perfAndRouting = await search('perf/routing', { q: 'WF-AI-ROUTE|PERF-SLOW', regex: 'true' });
|
|
260
|
+
for (const n of notes) console.log(` \x1b[33m!\x1b[0m ${n}`);
|
|
256
261
|
|
|
257
262
|
// ── Errors ──
|
|
258
263
|
console.log(`\n${BOLD}Errors${RESET2}`);
|
|
@@ -724,6 +729,7 @@ export const logsCommand = {
|
|
|
724
729
|
.option('to', { describe: 'ISO end time (default now)', type: 'string' })
|
|
725
730
|
.option('raw', { describe: 'Use the server q= search (can under-report; prints searchPath/scannedRows/truncated)', boolean: true, default: false })
|
|
726
731
|
.option('max-requests', { describe: 'Request budget for the complete read', default: 200, type: 'number' })
|
|
732
|
+
.option('max-seconds', { describe: 'Wall-clock budget for the complete read', default: 120, type: 'number' })
|
|
727
733
|
.option('take', { describe: 'Max lines to print (complete read) / server page size (--raw, max 500)', default: 50, type: 'number' })
|
|
728
734
|
.option('env', { describe: 'staging or prod', type: 'string' }),
|
|
729
735
|
handler: searchLogs,
|
package/lib/commands/ops.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { describeRawMeta, fetchRetry429 } from '../log-search.js';
|
|
1
2
|
import { get, del, post, getCredentials } from '../api.js';
|
|
2
3
|
import { readConfig, ENVS } from '../config.js';
|
|
4
|
+
import { collectAll, render as renderDeployState } from '../deploy-state.js';
|
|
3
5
|
|
|
4
6
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
5
7
|
|
|
@@ -153,6 +155,17 @@ export const opsCommand = {
|
|
|
153
155
|
}
|
|
154
156
|
}
|
|
155
157
|
|
|
158
|
+
// ── 1b. Where each push landed: staging environment, staging slot 1 of production, production ──
|
|
159
|
+
// Same data as `velaro deploy-state` (needs gh). Never blocks the rest of ops if gh is missing.
|
|
160
|
+
console.log('\n\x1b[1m── Where each push landed ──────────────────────────────────\x1b[0m');
|
|
161
|
+
try {
|
|
162
|
+
const state = await collectAll();
|
|
163
|
+
console.log(renderDeployState(state, { compact: true }).split('\n').map(l => ' ' + l).join('\n'));
|
|
164
|
+
console.log(' Full detail: velaro deploy-state');
|
|
165
|
+
} catch (e) {
|
|
166
|
+
console.log(` ${WARN} Deploy state unavailable (${e.message}). Needs gh logged in; run velaro deploy-state.`);
|
|
167
|
+
}
|
|
168
|
+
|
|
156
169
|
// ── 2. API health (admin + messaging, both envs) ──────────────────────────
|
|
157
170
|
const prodMsgBase = cfg.envs?.prod?.messagingApiBase ?? ENVS.prod.messagingApiBase;
|
|
158
171
|
const stagingMsgBase = cfg.envs?.staging?.messagingApiBase ?? ENVS.staging.messagingApiBase;
|
|
@@ -236,19 +249,25 @@ export const opsCommand = {
|
|
|
236
249
|
const stagingMsgBaseLocal = cfg.envs?.staging?.messagingApiBase ?? ENVS.staging.messagingApiBase;
|
|
237
250
|
const from1h = new Date(Date.now() - 3600000).toISOString();
|
|
238
251
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
+
// One search at a time: the server runs at most 2 exact scans per instance. 429 (gate full) is retried honouring Retry-After.
|
|
253
|
+
const errRes = await fetchRetry429(`${stagingMsgBaseLocal}/superadmin/logs/search?level=ERROR&from=${from1h}&take=5`, () => ({
|
|
254
|
+
headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
|
|
255
|
+
signal: AbortSignal.timeout(40000),
|
|
256
|
+
}));
|
|
257
|
+
const perfRes = await fetchRetry429(`${stagingMsgBaseLocal}/superadmin/logs/search?q=${encodeURIComponent('WF-AI-ROUTE|PERF-SLOW')}®ex=true&from=${from1h}&take=20`, () => ({
|
|
258
|
+
headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
|
|
259
|
+
signal: AbortSignal.timeout(40000),
|
|
260
|
+
}));
|
|
261
|
+
|
|
262
|
+
// FIXED 2026-10-14: both searches used to look for the literal TEXT "level:ERROR" / "WF-AI-ROUTE OR PERF-SLOW" (never in any log
|
|
263
|
+
// line), so this check always said "0 errors". Errors are now the server-side level filter, perf/routing a regex, and an
|
|
264
|
+
// unverified or failed scan is reported instead of being counted as a clean result.
|
|
265
|
+
const errBody = errRes.ok ? await errRes.json().catch(() => null) : null;
|
|
266
|
+
const perfBody = perfRes.ok ? await perfRes.json().catch(() => null) : null;
|
|
267
|
+
const errors = errBody ? extractLogEntries(errBody) : [];
|
|
268
|
+
const perfLogs = perfBody ? extractLogEntries(perfBody) : [];
|
|
269
|
+
const scanNote = (label, body, ok) => (!ok || !body) ? `${label}: search failed or timed out (HTTP ${ok ? 'ok' : 'error'}), NOT verified` : (body.truncated === false ? null : `${label}: ${describeRawMeta(body)}`);
|
|
270
|
+
for (const n of [scanNote('errors', errBody, errRes.ok), scanNote('perf/routing', perfBody, perfRes.ok)].filter(Boolean)) console.log(` ${WARN} ${n}`);
|
|
252
271
|
|
|
253
272
|
const perfCount = perfLogs.filter(e => (e.Message ?? e.message ?? '').includes('PERF-SLOW')).length;
|
|
254
273
|
const routeCount = perfLogs.filter(e => (e.Message ?? e.message ?? '').includes('WF-AI-ROUTE')).length;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// velaro safety — see safety events, acknowledge them, and manage alert settings. Wraps velaro-messaging
|
|
2
|
+
// SafetyEventsController. The site always comes from your login, never from an argument. Same routes the
|
|
3
|
+
// admin "Safety and alerts" page uses. Needs the EnableTroubleshootingKnowledge entitlement.
|
|
4
|
+
import { messagingGet, messagingPost, messagingPut } from '../api.js';
|
|
5
|
+
import { runCommand } from '../run.js';
|
|
6
|
+
|
|
7
|
+
const enc = encodeURIComponent;
|
|
8
|
+
|
|
9
|
+
const out = (argv, data, printer) => {
|
|
10
|
+
if (argv.json) console.log(JSON.stringify(data, null, 2));
|
|
11
|
+
else printer(data);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const list = (v) => (v === undefined ? undefined : [].concat(v).flatMap((x) => String(x).split(',')).map((s) => s.trim()).filter(Boolean));
|
|
15
|
+
|
|
16
|
+
function printEvents(r) {
|
|
17
|
+
const items = r.items ?? [];
|
|
18
|
+
if (!items.length) { console.log('No safety events.'); return; }
|
|
19
|
+
for (const e of items) {
|
|
20
|
+
console.log(`[${e.id}] ${e.createdUtc} ${e.type} (${e.channel}) ${e.status}`);
|
|
21
|
+
if (e.reason) console.log(` Why: ${e.reason}`);
|
|
22
|
+
if (e.excerpt) console.log(` "${e.excerpt}"`);
|
|
23
|
+
}
|
|
24
|
+
console.log(`\n${items.length} of ${r.total ?? items.length} shown.`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function printSummary(s) {
|
|
28
|
+
console.log('Totals:');
|
|
29
|
+
for (const [k, v] of Object.entries(s.totals ?? {})) console.log(` ${k}: ${v}`);
|
|
30
|
+
console.log('\nBy day:');
|
|
31
|
+
for (const d of s.days ?? []) console.log(` ${d.date} ${JSON.stringify(d.counts)}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function printSettings(s) {
|
|
35
|
+
console.log(`Alerts: ${s.enabled ? 'on' : 'off'}`);
|
|
36
|
+
console.log(`Email recipients: ${(s.emailRecipients ?? []).join(', ') || '(none)'}`);
|
|
37
|
+
console.log(`SMS recipients: ${(s.smsRecipients ?? []).join(', ') || '(none)'}`);
|
|
38
|
+
console.log(`Alert right away: ${(s.immediateTypes ?? []).join(', ') || '(none)'}`);
|
|
39
|
+
console.log(`Summary email: ${s.digest}`);
|
|
40
|
+
console.log(`Count alert: ${s.threshold?.count ?? 0} events in ${s.threshold?.windowMinutes ?? 60} minutes`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const safetyCommand = {
|
|
44
|
+
command: 'safety',
|
|
45
|
+
describe: 'Safety events and alerts: list events, acknowledge, alert settings, test alert, ask Velaro for help',
|
|
46
|
+
builder: (yargs) => yargs
|
|
47
|
+
.command('events', 'List safety events', (y) => y
|
|
48
|
+
.option('from', { type: 'string', describe: 'ISO start' })
|
|
49
|
+
.option('to', { type: 'string', describe: 'ISO end' })
|
|
50
|
+
.option('type', { type: 'string' })
|
|
51
|
+
.option('status', { type: 'string', choices: ['open', 'acknowledged'] })
|
|
52
|
+
.option('take', { type: 'number', default: 25 })
|
|
53
|
+
.option('skip', { type: 'number', default: 0 })
|
|
54
|
+
.option('json', { type: 'boolean' }),
|
|
55
|
+
runCommand(async (a) => {
|
|
56
|
+
const q = new URLSearchParams();
|
|
57
|
+
for (const k of ['from', 'to', 'type', 'status']) if (a[k]) q.set(k, a[k]);
|
|
58
|
+
q.set('take', String(a.take));
|
|
59
|
+
q.set('skip', String(a.skip));
|
|
60
|
+
out(a, await messagingGet(`/SafetyEvents?${q}`), printEvents);
|
|
61
|
+
}))
|
|
62
|
+
.command('summary', 'Counts by type and by day', (y) => y
|
|
63
|
+
.option('days', { type: 'number', default: 7 })
|
|
64
|
+
.option('json', { type: 'boolean' }),
|
|
65
|
+
runCommand(async (a) => out(a, await messagingGet(`/SafetyEvents/summary?days=${a.days}`), printSummary)))
|
|
66
|
+
.command('ack', 'Mark an event as reviewed', (y) => y.option('id', { type: 'string', demandOption: true }),
|
|
67
|
+
runCommand(async (a) => {
|
|
68
|
+
await messagingPost(`/SafetyEvents/${enc(a.id)}/acknowledge`);
|
|
69
|
+
console.log(`Event ${a.id} marked as reviewed.`);
|
|
70
|
+
}))
|
|
71
|
+
.command('settings <action>', 'Read or change alert settings (get | set)', (y) => y
|
|
72
|
+
.positional('action', { choices: ['get', 'set'] })
|
|
73
|
+
.option('enabled', { type: 'boolean' })
|
|
74
|
+
.option('email', { type: 'string', describe: 'Email recipients, comma separated (replaces the list)' })
|
|
75
|
+
.option('sms', { type: 'string', describe: 'SMS recipients in +15551234567 form, comma separated (replaces the list)' })
|
|
76
|
+
.option('immediate', { type: 'string', describe: 'Event types that alert right away, comma separated (replaces the list)' })
|
|
77
|
+
.option('digest', { type: 'string', choices: ['off', 'daily', 'weekly'] })
|
|
78
|
+
.option('threshold-count', { type: 'number', describe: 'Alert at this many events inside the window. 0 turns it off.' })
|
|
79
|
+
.option('threshold-window', { type: 'number', describe: 'Window in minutes, 1 to 10080' })
|
|
80
|
+
.option('json', { type: 'boolean' }),
|
|
81
|
+
runCommand(async (a) => {
|
|
82
|
+
const cur = (await messagingGet('/SafetyEvents/settings')) ?? {};
|
|
83
|
+
if (a.action === 'get') return out(a, cur, printSettings);
|
|
84
|
+
const next = { ...cur };
|
|
85
|
+
if (a.enabled !== undefined) next.enabled = a.enabled;
|
|
86
|
+
if (a.email !== undefined) next.emailRecipients = list(a.email);
|
|
87
|
+
if (a.sms !== undefined) next.smsRecipients = list(a.sms);
|
|
88
|
+
if (a.immediate !== undefined) next.immediateTypes = list(a.immediate);
|
|
89
|
+
if (a.digest !== undefined) next.digest = a.digest;
|
|
90
|
+
if (a['threshold-count'] !== undefined || a['threshold-window'] !== undefined) {
|
|
91
|
+
next.threshold = {
|
|
92
|
+
count: a['threshold-count'] ?? cur.threshold?.count ?? 0,
|
|
93
|
+
windowMinutes: a['threshold-window'] ?? cur.threshold?.windowMinutes ?? 60,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
out(a, await messagingPut('/SafetyEvents/settings', next), (s) => { console.log('Saved.\n'); printSettings(s); });
|
|
97
|
+
}))
|
|
98
|
+
.command('test', 'Send a test alert to the configured recipients', (y) => y,
|
|
99
|
+
runCommand(async () => {
|
|
100
|
+
await messagingPost('/SafetyEvents/settings/test');
|
|
101
|
+
console.log('Test alert sent. Check the inboxes and phones on the recipient list.');
|
|
102
|
+
}))
|
|
103
|
+
.command('need-help', 'Tell the Velaro team the bot is going off the rails', (y) => y
|
|
104
|
+
.option('message', { type: 'string', demandOption: true, describe: 'What is happening' }),
|
|
105
|
+
runCommand(async (a) => {
|
|
106
|
+
await messagingPost('/SafetyEvents/need-help', { message: a.message });
|
|
107
|
+
console.log('Sent. The Velaro team has been alerted.');
|
|
108
|
+
}))
|
|
109
|
+
.demandCommand(1, 'Specify a subcommand: events, summary, ack, settings, test, need-help'),
|
|
110
|
+
handler: () => {},
|
|
111
|
+
};
|