@velaro/cli 1.4.57 → 1.4.59
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 +11 -0
- package/bin/velaro.js +6 -0
- package/lib/commands/ai-config.js +37 -2
- package/lib/commands/index-move.js +170 -0
- package/lib/commands/magento.js +29 -0
- package/lib/commands/routing.js +119 -23
- package/lib/commands/scraper.js +27 -0
- package/lib/commands/support.js +14 -1
- package/lib/commands/tour-policy.js +61 -0
- package/lib/commands/workflow.js +49 -20
- package/lib/routing-inactivity.js +160 -0
- package/lib/routing-inactivity.test.js +101 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -104,6 +104,17 @@ client using the old key; the command does not edit client configurations.
|
|
|
104
104
|
velaro mcp-key rotate 2
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
### `velaro routing <subcommand>`
|
|
108
|
+
Auto-routing policy and the agent inactivity chain (v20 only).
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
velaro routing policy get # includes reassignment mode + chain
|
|
112
|
+
velaro routing policy set --reassignment-mode Reassign --chain true --backup-team 5 --backup-after 120 --no-agent-after 300 --terminal-action EndChat
|
|
113
|
+
velaro routing simulate --no-agent-seconds 400 --step 1 # DRY RUN, nothing is sent
|
|
114
|
+
velaro routing precedence # which rule wins + conflict warnings
|
|
115
|
+
velaro routing inactivity-transfers --days 30 --agent-name Sam
|
|
116
|
+
```
|
|
117
|
+
|
|
107
118
|
### `velaro kb article <subcommand>`
|
|
108
119
|
Manage your help.velaro.com knowledge base articles. Full reference (including the REST/MCP equivalents and the screenshot pipeline): [`docs/architecture/kb-publishing-system.md`](../docs/architecture/kb-publishing-system.md).
|
|
109
120
|
|
package/bin/velaro.js
CHANGED
|
@@ -9,6 +9,7 @@ import { kbCommand } from '../lib/commands/kb.js';
|
|
|
9
9
|
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
|
+
import { indexMoveCommand } from '../lib/commands/index-move.js';
|
|
12
13
|
import { productCommand } from '../lib/commands/product.js';
|
|
13
14
|
import { mcpKeyCommand } from '../lib/commands/mcp-key.js';
|
|
14
15
|
import { opsCommand } from '../lib/commands/ops.js';
|
|
@@ -83,6 +84,8 @@ import { recruitingCommand } from '../lib/commands/recruiting.js';
|
|
|
83
84
|
import { surveyCommand } from '../lib/commands/survey.js';
|
|
84
85
|
import { bundleCommand } from '../lib/commands/bundle.js';
|
|
85
86
|
import { featureDiscoveryCommand } from '../lib/commands/feature-discovery.js';
|
|
87
|
+
import { tourPolicyCommand } from '../lib/commands/tour-policy.js';
|
|
88
|
+
import { scraperCommand } from '../lib/commands/scraper.js';
|
|
86
89
|
import { campaignsCommand } from '../lib/commands/campaigns.js';
|
|
87
90
|
import { contactsCommand } from '../lib/commands/contacts.js';
|
|
88
91
|
import { contactListCommand } from '../lib/commands/contact-list.js';
|
|
@@ -104,6 +107,7 @@ await yargs(hideBin(process.argv))
|
|
|
104
107
|
.command(botCommand)
|
|
105
108
|
.command(indexCommand)
|
|
106
109
|
.command(ingestCommand)
|
|
110
|
+
.command(indexMoveCommand)
|
|
107
111
|
.command(productCommand)
|
|
108
112
|
.command(mcpKeyCommand)
|
|
109
113
|
.command(opsCommand)
|
|
@@ -178,6 +182,8 @@ await yargs(hideBin(process.argv))
|
|
|
178
182
|
.command(surveyCommand)
|
|
179
183
|
.command(bundleCommand)
|
|
180
184
|
.command({ command: 'feature-discovery', desc: 'View and act on feature-discovery suggestions for this site', builder: featureDiscoveryCommand, handler: () => {} })
|
|
185
|
+
.command({ command: 'scraper', desc: 'Velaro staff: scraper / upload / chunk usage for any site', builder: scraperCommand, handler: () => {} })
|
|
186
|
+
.command({ command: 'tour-policy', desc: 'Velaro staff: guided-tour auto-open policy (versioned, rollback-able)', builder: tourPolicyCommand, handler: () => {} })
|
|
181
187
|
.command(campaignsCommand)
|
|
182
188
|
.command(contactsCommand)
|
|
183
189
|
.command(contactListCommand)
|
|
@@ -21,7 +21,9 @@ export const aiConfigCommand = {
|
|
|
21
21
|
.command(aiConfigSetChannelOverrideCommand)
|
|
22
22
|
.command(aiConfigListChannelOverridesCommand)
|
|
23
23
|
.command(aiConfigStaffListCommand)
|
|
24
|
-
.
|
|
24
|
+
.command(aiConfigResolvedModelCommand)
|
|
25
|
+
.command(aiConfigModelCallsCommand)
|
|
26
|
+
.demandCommand(1, 'Specify a subcommand: list, get, create, set-channel-override, list-channel-overrides, staff-list, resolved-model, model-calls'),
|
|
25
27
|
handler: () => {},
|
|
26
28
|
};
|
|
27
29
|
|
|
@@ -44,7 +46,7 @@ const aiConfigListCommand = {
|
|
|
44
46
|
const id = `[${c.id}]`.padStart(idW);
|
|
45
47
|
const name = (c.name ?? '').padEnd(nameW);
|
|
46
48
|
const stat = (c.status ?? '—').padEnd(7);
|
|
47
|
-
console.log(`${id} ${name} ${stat} ${c.aiModel
|
|
49
|
+
console.log(`${id} ${name} ${stat} ${c.aiModel || 'Velaro Default'}`);
|
|
48
50
|
}
|
|
49
51
|
console.log(`\n${rows.length} configuration(s)`);
|
|
50
52
|
}),
|
|
@@ -191,3 +193,36 @@ const aiConfigSetChannelOverrideCommand = {
|
|
|
191
193
|
console.log(`Channel override set: ai-config=${argv.id} channel=${argv.channel} mode=${argv.mode}`);
|
|
192
194
|
}),
|
|
193
195
|
};
|
|
196
|
+
|
|
197
|
+
// ── resolved-model ────────────────────────────────────────────────────────────
|
|
198
|
+
// Voice preview (GET avatar/voices/{voiceId}/preview) is intentionally UI-only: it streams binary
|
|
199
|
+
// audio for a browser <audio> element and has no useful CLI/MCP shape.
|
|
200
|
+
|
|
201
|
+
const aiConfigResolvedModelCommand = {
|
|
202
|
+
command: 'resolved-model <id>',
|
|
203
|
+
describe: 'Show which model an AI configuration runs on (model name is returned to Velaro staff only)',
|
|
204
|
+
builder: (y) => y.positional('id', { type: 'number', describe: 'AI configuration ID' }),
|
|
205
|
+
handler: runCommand(async (argv) => {
|
|
206
|
+
const r = await get(`/AIConfiguration/${argv.id}/resolved-model`);
|
|
207
|
+
if (!r?.isStaff) { console.log('Velaro Default (model name is visible to Velaro staff only)'); return; }
|
|
208
|
+
console.log(`resolved: ${r.resolvedModel ?? 'unknown'} ${r.usingDefault ? '(Velaro Default)' : `(explicit: ${r.explicitModel})`}`);
|
|
209
|
+
}),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// ── model-calls ───────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
const aiConfigModelCallsCommand = {
|
|
215
|
+
command: 'model-calls',
|
|
216
|
+
describe: 'Velaro staff only: recent AI calls on the current site with the model each call used',
|
|
217
|
+
builder: (y) => y
|
|
218
|
+
.option('role', { type: 'string', describe: 'AI role filter, e.g. chatbot' })
|
|
219
|
+
.option('limit', { type: 'number', default: 50 }),
|
|
220
|
+
handler: runCommand(async (argv) => {
|
|
221
|
+
const qs = new URLSearchParams({ limit: String(argv.limit) });
|
|
222
|
+
if (argv.role) qs.set('role', argv.role);
|
|
223
|
+
const rows = (await get(`/AIConfiguration/model-calls?${qs}`)) ?? [];
|
|
224
|
+
if (!rows.length) { console.log('No AI calls found.'); return; }
|
|
225
|
+
for (const r of rows)
|
|
226
|
+
console.log(`${new Date(r.calledAt).toISOString()} ${String(r.role).padEnd(22)} ${String(r.modelId).padEnd(28)} in ${r.inputTokens} out ${r.outputTokens} $${Number(r.estimatedCostUsd).toFixed(4)}`);
|
|
227
|
+
}),
|
|
228
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// velaro index-move — move or split knowledge between indexes with no rescan and no OCR, check an
|
|
2
|
+
// index for integrity, and watch/resume/undo a move. Wraps velaro-messaging IndexMoveController
|
|
3
|
+
// (docs/architecture/index-move-and-integrity.md there). The site always comes from your login,
|
|
4
|
+
// never from an argument. Same routes the admin "Knowledge Setup" page uses.
|
|
5
|
+
import { messagingGet, messagingPost } from '../api.js';
|
|
6
|
+
import { runCommand } from '../run.js';
|
|
7
|
+
|
|
8
|
+
const enc = encodeURIComponent;
|
|
9
|
+
const n = (v) => (v ?? 0).toLocaleString('en-US');
|
|
10
|
+
|
|
11
|
+
function selectorFrom(argv) {
|
|
12
|
+
const sel = {};
|
|
13
|
+
if (argv.all) sel.all = true;
|
|
14
|
+
if (argv['job-id']) sel.jobId = argv['job-id'];
|
|
15
|
+
if (argv['url-prefix']) sel.urlPrefix = argv['url-prefix'];
|
|
16
|
+
if (argv['source-type']) sel.sourceType = argv['source-type'];
|
|
17
|
+
if (argv['file-name']) sel.fileNames = [].concat(argv['file-name']);
|
|
18
|
+
if (argv['doc-id']) sel.docIds = [].concat(argv['doc-id']).map(Number);
|
|
19
|
+
return sel;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function moveBody(argv) {
|
|
23
|
+
const selector = selectorFrom(argv);
|
|
24
|
+
if (Object.keys(selector).length === 0) {
|
|
25
|
+
throw new Error('Give a selector: --all, --job-id, --url-prefix, --file-name, --doc-id or --source-type.');
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
fromIndex: argv.from,
|
|
29
|
+
toIndex: argv.to,
|
|
30
|
+
kind: argv.copy ? 'copy' : 'move',
|
|
31
|
+
skipBlocked: !!argv['skip-blocked'],
|
|
32
|
+
selector,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function printPlan(p) {
|
|
37
|
+
const q = p.quotaEffect ?? {};
|
|
38
|
+
console.log(`\n${p.kind}: ${p.fromIndex} -> ${p.toIndex}`);
|
|
39
|
+
console.log(` Documents: ${n(p.docs)} Chunks: ${n(p.chunks)} Pages: ${n(p.pages)}`);
|
|
40
|
+
console.log(` Copied as is: ${n(p.copyVectorsDocs)} Re-split from saved text: ${n(p.rechunkDocs)} Already there: ${n(p.alreadyInTargetDocs)} Blocked: ${n(p.blockedDocs)}`);
|
|
41
|
+
console.log(` Storage: ${n(q.indexedChunksCurrent)} -> ${n((q.indexedChunksCurrent ?? 0) + (q.indexedChunksDelta ?? 0))} of ${n(q.indexedChunksMax)} chunks`);
|
|
42
|
+
console.log(' No rescan, no OCR.');
|
|
43
|
+
for (const r of p.reasons ?? []) console.log(` Problem: ${r}`);
|
|
44
|
+
for (const w of p.warnings ?? []) console.log(` Note: ${w}`);
|
|
45
|
+
console.log(` Can run: ${p.canExecute ? 'yes' : 'no'}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function printStatus(s) {
|
|
49
|
+
console.log(`\nMove ${s.moveId} (${s.kind}): ${s.fromIndex} -> ${s.toIndex}`);
|
|
50
|
+
console.log(` Status: ${s.status} Documents: ${s.docsDone}/${s.totalDocs} done, ${s.docsSkipped} skipped, ${s.docsFailed} failed`);
|
|
51
|
+
if (s.errorMessage) console.log(` Error: ${s.errorMessage}`);
|
|
52
|
+
const can = [s.canResume && 'resume', s.canAbort && 'abort', s.canUndo && 'undo'].filter(Boolean);
|
|
53
|
+
if (can.length) console.log(` You can: ${can.join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const out = (argv, data, printer) => {
|
|
57
|
+
if (argv.json) console.log(JSON.stringify(data, null, 2));
|
|
58
|
+
else printer(data);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const indexMoveCommand = {
|
|
62
|
+
command: 'index-move',
|
|
63
|
+
describe: 'Move/split content between knowledge indexes (no rescan, no OCR), check index integrity',
|
|
64
|
+
builder: (yargs) => yargs
|
|
65
|
+
.command('preview', 'Show what a move would do. Writes nothing.', (y) => y
|
|
66
|
+
.option('from', { type: 'string', demandOption: true })
|
|
67
|
+
.option('to', { type: 'string', demandOption: true })
|
|
68
|
+
.option('all', { type: 'boolean' })
|
|
69
|
+
.option('job-id', { type: 'string' })
|
|
70
|
+
.option('url-prefix', { type: 'string' })
|
|
71
|
+
.option('source-type', { type: 'string' })
|
|
72
|
+
.option('file-name', { type: 'string' })
|
|
73
|
+
.option('doc-id', { type: 'number' })
|
|
74
|
+
.option('copy', { type: 'boolean', describe: 'Copy instead of move' })
|
|
75
|
+
.option('skip-blocked', { type: 'boolean' })
|
|
76
|
+
.option('json', { type: 'boolean' }),
|
|
77
|
+
runCommand(async (a) => out(a, await messagingPost('/IndexMove/Preview', moveBody(a)), printPlan)))
|
|
78
|
+
.command('execute', 'Run a move. Preview it first.', (y) => y
|
|
79
|
+
.option('from', { type: 'string', demandOption: true })
|
|
80
|
+
.option('to', { type: 'string', demandOption: true })
|
|
81
|
+
.option('all', { type: 'boolean' })
|
|
82
|
+
.option('job-id', { type: 'string' })
|
|
83
|
+
.option('url-prefix', { type: 'string' })
|
|
84
|
+
.option('source-type', { type: 'string' })
|
|
85
|
+
.option('file-name', { type: 'string' })
|
|
86
|
+
.option('doc-id', { type: 'number' })
|
|
87
|
+
.option('copy', { type: 'boolean' })
|
|
88
|
+
.option('skip-blocked', { type: 'boolean' })
|
|
89
|
+
.option('json', { type: 'boolean' }),
|
|
90
|
+
runCommand(async (a) => {
|
|
91
|
+
const r = await messagingPost('/IndexMove/Execute', moveBody(a));
|
|
92
|
+
out(a, r, (d) => console.log(`Started move ${d.moveId}. Check it with: velaro index-move status --id ${d.moveId}`));
|
|
93
|
+
}))
|
|
94
|
+
.command('split', 'Move detected manuals out of a general index into a troubleshooting index', (y) => y
|
|
95
|
+
.option('from', { type: 'string', demandOption: true })
|
|
96
|
+
.option('to', { type: 'string', demandOption: true, describe: 'An existing troubleshooting-purpose index' })
|
|
97
|
+
.option('doc-id', { type: 'number', describe: 'Only these documents (default: every detected manual)' })
|
|
98
|
+
.option('skip-blocked', { type: 'boolean' })
|
|
99
|
+
.option('execute', { type: 'boolean', describe: 'Run it. Without this, only previews.' })
|
|
100
|
+
.option('json', { type: 'boolean' }),
|
|
101
|
+
runCommand(async (a) => {
|
|
102
|
+
const body = { fromIndex: a.from, toIndex: a.to, skipBlocked: !!a['skip-blocked'] };
|
|
103
|
+
if (a['doc-id'] !== undefined) body.docIds = [].concat(a['doc-id']).map(Number);
|
|
104
|
+
if (a.execute) {
|
|
105
|
+
const r = await messagingPost('/IndexMove/Split/Execute', body);
|
|
106
|
+
out(a, r, (d) => console.log(`Started split ${d.moveId}. Check it with: velaro index-move status --id ${d.moveId}`));
|
|
107
|
+
} else {
|
|
108
|
+
const r = await messagingPost('/IndexMove/Split/Preview', body);
|
|
109
|
+
out(a, r, (d) => printPlan(d.plan));
|
|
110
|
+
}
|
|
111
|
+
}))
|
|
112
|
+
.command('status', 'Show one move (--id) or the recent moves', (y) => y
|
|
113
|
+
.option('id', { type: 'string' })
|
|
114
|
+
.option('json', { type: 'boolean' }),
|
|
115
|
+
runCommand(async (a) => {
|
|
116
|
+
if (a.id) {
|
|
117
|
+
out(a, await messagingGet(`/IndexMove/Moves/${enc(a.id)}`), printStatus);
|
|
118
|
+
} else {
|
|
119
|
+
out(a, await messagingGet('/IndexMove/Moves?take=25'), (list) => list.forEach(printStatus));
|
|
120
|
+
}
|
|
121
|
+
}))
|
|
122
|
+
.command('resume', 'Resume a stopped move', (y) => y.option('id', { type: 'string', demandOption: true }),
|
|
123
|
+
runCommand(async (a) => {
|
|
124
|
+
await messagingPost(`/IndexMove/Moves/${enc(a.id)}/Resume`);
|
|
125
|
+
console.log(`Resumed move ${a.id}.`);
|
|
126
|
+
}))
|
|
127
|
+
.command('abort', 'Cancel a queued or failed move that has not started removing content', (y) => y.option('id', { type: 'string', demandOption: true }),
|
|
128
|
+
runCommand(async (a) => {
|
|
129
|
+
printStatus(await messagingPost(`/IndexMove/Moves/${enc(a.id)}/Abort`));
|
|
130
|
+
}))
|
|
131
|
+
.command('undo', 'Move a completed move back', (y) => y.option('id', { type: 'string', demandOption: true }),
|
|
132
|
+
runCommand(async (a) => {
|
|
133
|
+
const r = await messagingPost(`/IndexMove/Moves/${enc(a.id)}/Undo`);
|
|
134
|
+
console.log(`Started undo as move ${r.moveId}.`);
|
|
135
|
+
}))
|
|
136
|
+
.command('mixed', 'Check whether an index holds manual-like documents', (y) => y
|
|
137
|
+
.option('index', { type: 'string', demandOption: true })
|
|
138
|
+
.option('json', { type: 'boolean' }),
|
|
139
|
+
runCommand(async (a) => {
|
|
140
|
+
const r = await messagingGet(`/IndexMove/MixedContent?index=${enc(a.index)}`);
|
|
141
|
+
out(a, r, (d) => {
|
|
142
|
+
console.log(`\n${d.indexName}: ${n(d.manualLikeDocs)} of ${n(d.totalDocs)} documents look like manuals (${n(d.manualLikeChunks)} chunks). ${d.mixed ? 'Recommendation: ' + (d.recommendation ?? 'split') : 'No split needed.'}`);
|
|
143
|
+
for (const c of d.candidates ?? []) console.log(` #${c.docId} ${c.title ?? c.sourceId} ${n(c.chunks)} chunks`);
|
|
144
|
+
});
|
|
145
|
+
}))
|
|
146
|
+
.command('integrity', 'Read-only integrity report for an index', (y) => y
|
|
147
|
+
.option('index', { type: 'string', demandOption: true })
|
|
148
|
+
.option('json', { type: 'boolean' }),
|
|
149
|
+
runCommand(async (a) => {
|
|
150
|
+
const r = await messagingGet(`/IndexMove/Integrity?index=${enc(a.index)}`);
|
|
151
|
+
out(a, r, (d) => {
|
|
152
|
+
const f = d.findings ?? [];
|
|
153
|
+
console.log(f.length === 0 ? 'No problems found.' : `${f.length} finding(s)${d.truncated ? ' (partial: index is very large)' : ''}:`);
|
|
154
|
+
for (const x of f) console.log(` ${x.kind} ${x.sourceType ?? ''} ${x.sourceId ?? ''}`);
|
|
155
|
+
});
|
|
156
|
+
}))
|
|
157
|
+
.command('repair', 'Repair an index. Additive: fixes counts and records, never deletes content.', (y) => y
|
|
158
|
+
.option('index', { type: 'string', demandOption: true })
|
|
159
|
+
.option('remove-duplicate-records', { type: 'boolean', describe: 'Also remove duplicate tracking rows (rows only)' })
|
|
160
|
+
.option('json', { type: 'boolean' }),
|
|
161
|
+
runCommand(async (a) => {
|
|
162
|
+
const r = await messagingPost('/IndexMove/Integrity/Repair', {
|
|
163
|
+
indexName: a.index,
|
|
164
|
+
removeDuplicateRecords: !!a['remove-duplicate-records'],
|
|
165
|
+
});
|
|
166
|
+
out(a, r, () => console.log('Repair finished. Run "velaro index-move integrity --index ' + a.index + '" to confirm.'));
|
|
167
|
+
}))
|
|
168
|
+
.demandCommand(1, 'Specify a subcommand: preview, execute, split, status, resume, abort, undo, mixed, integrity, repair'),
|
|
169
|
+
handler: () => {},
|
|
170
|
+
};
|
package/lib/commands/magento.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* velaro magento orders Search orders by email or order ID
|
|
8
8
|
* velaro magento products Search the product catalog
|
|
9
9
|
* velaro magento logs View integration activity logs
|
|
10
|
+
* velaro magento diagnose Live connection diagnostic (token, /rest/V1, scopes)
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { request } from '../api.js';
|
|
@@ -152,6 +153,27 @@ async function logsHandler(argv) {
|
|
|
152
153
|
console.log('');
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
// ── diagnose ──────────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
async function diagnoseHandler(argv) {
|
|
159
|
+
const siteId = argv['site-id'];
|
|
160
|
+
if (!siteId) {
|
|
161
|
+
console.error(`${RED}✗ --site-id is required${RST}`);
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
// Messaging DiagController: GET diagnostics/test-magento?siteId=<id>
|
|
167
|
+
const r = await request('GET', `/diagnostics/test-magento?siteId=${encodeURIComponent(siteId)}`);
|
|
168
|
+
console.log(`\n${BOLD}Magento diagnostic (site ${siteId})${RST}\n`);
|
|
169
|
+
console.log(JSON.stringify(r, null, 2));
|
|
170
|
+
console.log('');
|
|
171
|
+
} catch (e) {
|
|
172
|
+
console.error(`\n${RED}✗ Diagnostic failed: ${e.message}${RST}\n`);
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
155
177
|
// ── command export ────────────────────────────────────────────────────────────
|
|
156
178
|
|
|
157
179
|
export const magentoCommand = {
|
|
@@ -205,6 +227,13 @@ export const magentoCommand = {
|
|
|
205
227
|
.option('page-size', { describe: 'Results per page (default: 25)', type: 'number', default: 25 }),
|
|
206
228
|
handler: logsHandler,
|
|
207
229
|
})
|
|
230
|
+
.command({
|
|
231
|
+
command: 'diagnose',
|
|
232
|
+
describe: 'Live Magento connection diagnostic for a site',
|
|
233
|
+
builder: yy => yy
|
|
234
|
+
.option('site-id', { describe: 'Site ID to diagnose', type: 'number' }),
|
|
235
|
+
handler: diagnoseHandler,
|
|
236
|
+
})
|
|
208
237
|
.demandCommand(1, 'Specify a subcommand. Try: velaro magento --help'),
|
|
209
238
|
handler: () => {},
|
|
210
239
|
};
|
package/lib/commands/routing.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import { messagingGet, messagingPut, messagingDel, get as adminGet } from '../api.js';
|
|
1
|
+
import { messagingGet, messagingPut, messagingPost, messagingDel, get as adminGet } from '../api.js';
|
|
2
|
+
import {
|
|
3
|
+
TERMINAL_ACTION_NAMES, buildPolicyUpdate, validatePolicyUpdate, describeChain,
|
|
4
|
+
describeSimulation, describeTransfers,
|
|
5
|
+
} from '../routing-inactivity.js';
|
|
2
6
|
|
|
3
7
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
4
8
|
|
|
@@ -26,8 +30,18 @@ const TERMINAL_LABELS = {
|
|
|
26
30
|
3: 'Queue',
|
|
27
31
|
4: 'Offline Message',
|
|
28
32
|
5: 'No Action',
|
|
33
|
+
6: 'End Chat',
|
|
34
|
+
7: 'Notify Manager',
|
|
35
|
+
8: 'Run Workflow',
|
|
29
36
|
};
|
|
30
37
|
|
|
38
|
+
// The API returns the terminal action as a NAME (e.g. "EndChat"); older builds returned an int.
|
|
39
|
+
function terminalText(v) {
|
|
40
|
+
if (v == null) return 'Default (Survey)';
|
|
41
|
+
if (typeof v === 'number') return TERMINAL_LABELS[v] ?? String(v);
|
|
42
|
+
return String(v);
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
// ── velaro routing engine get ─────────────────────────────────────────────────
|
|
32
46
|
|
|
33
47
|
const engineGetCommand = {
|
|
@@ -129,10 +143,8 @@ const policyGetCommand = {
|
|
|
129
143
|
console.log(` ${DIM}Agent follow-up flag: ${RESET}${p.agentFollowUpFlagEnabled ? `${GRN}yes${RESET}` : 'no'}`);
|
|
130
144
|
console.log(` ${DIM}Queue unavailable grace: ${RESET}${p.queueUnavailableGracePeriodSeconds}s`);
|
|
131
145
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
: 'Default (Survey)';
|
|
135
|
-
console.log(` ${DIM}Terminal action: ${RESET}${terminalLabel}`);
|
|
146
|
+
console.log(` ${DIM}Terminal action: ${RESET}${terminalText(p.terminalActionOverride)}`);
|
|
147
|
+
for (const line of describeChain(p)) console.log(` ${line}`);
|
|
136
148
|
console.log();
|
|
137
149
|
},
|
|
138
150
|
};
|
|
@@ -168,26 +180,37 @@ const policySetCommand = {
|
|
|
168
180
|
describe: 'Prefer previous agent on timeout (true/false)',
|
|
169
181
|
})
|
|
170
182
|
.option('terminal-action', {
|
|
171
|
-
type: '
|
|
172
|
-
describe:
|
|
173
|
-
})
|
|
183
|
+
type: 'string',
|
|
184
|
+
describe: `Terminal action, name or 0-8: ${TERMINAL_ACTION_NAMES.map((n, i) => `${i}=${n}`).join(' ')}`,
|
|
185
|
+
})
|
|
186
|
+
.option('exhaustion-message', { type: 'string', describe: 'Visitor message when the final action runs' })
|
|
187
|
+
.option('reassignment-mode', {
|
|
188
|
+
type: 'string', choices: ['Observe', 'QueueOnly', 'Reassign'],
|
|
189
|
+
describe: 'Observe = log only (default), QueueOnly = return chat to the queue, Reassign = move to another agent',
|
|
190
|
+
})
|
|
191
|
+
.option('chain', { type: 'boolean', describe: 'Turn the agent inactivity chain on/off' })
|
|
192
|
+
.option('backup-team', { type: 'number', describe: 'Backup group (team) id to offer the chat to' })
|
|
193
|
+
.option('backup-after', { type: 'number', describe: 'Seconds with no same-group agent before the backup group (0-7200)' })
|
|
194
|
+
.option('no-agent-after', { type: 'number', describe: 'Seconds with no agent available before the final action (0-14400)' })
|
|
195
|
+
.option('presence-trigger', { type: 'boolean', describe: 'Reroute when the agent goes Away/Offline' })
|
|
196
|
+
.option('alert-on-release', { type: 'boolean', describe: 'Alert available agents when a chat is released' })
|
|
197
|
+
.option('notify-managers', { type: 'boolean', describe: 'Notify managers on inactivity' })
|
|
198
|
+
.option('cooldown', { type: 'number', describe: 'Seconds between moves of the same chat (0-3600)' })
|
|
199
|
+
.option('queue-escalation-minutes', { type: 'number', describe: 'Minutes in queue before escalation alert (1-240)' })
|
|
200
|
+
.option('terminal-workflow', { type: 'number', describe: 'Workflow id to run when the terminal action is RunWorkflow' })
|
|
201
|
+
.option('visitor-text', { type: 'string', describe: 'Visitor status text (max 500). Tokens: {{releasedFromAgent}} {{reason}} {{teamName}}' })
|
|
202
|
+
.option('agent-text', { type: 'string', describe: 'Agent status text (max 500)' }),
|
|
174
203
|
handler: async (argv) => {
|
|
175
204
|
// Load current policy first, then merge
|
|
176
205
|
const current = await messagingGet('/AutoRoutingPolicy');
|
|
177
206
|
|
|
178
|
-
const updated =
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
preferPreviousAgentOnTimeout: argv['prefer-previous'] ?? current.preferPreviousAgentOnTimeout,
|
|
186
|
-
terminalActionOverride:
|
|
187
|
-
argv['terminal-action'] !== undefined
|
|
188
|
-
? argv['terminal-action']
|
|
189
|
-
: current.terminalActionOverride,
|
|
190
|
-
};
|
|
207
|
+
const updated = buildPolicyUpdate(current, argv);
|
|
208
|
+
const problems = validatePolicyUpdate(updated);
|
|
209
|
+
if (problems.length) {
|
|
210
|
+
for (const m of problems) console.error(` ${RED}x${RESET} ${m}`);
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
191
214
|
|
|
192
215
|
await messagingPut('/AutoRoutingPolicy', updated);
|
|
193
216
|
console.log(`\n ${GRN}✓${RESET} Routing policy updated.\n`);
|
|
@@ -279,7 +302,7 @@ const explainCommand = {
|
|
|
279
302
|
console.log(` Initial timeout: ${p.initialResponseTimeoutSeconds}s`);
|
|
280
303
|
console.log(` Idle timeout: ${p.idleAfterResponseTimeoutSeconds}s`);
|
|
281
304
|
console.log(` Max reroutes: ${p.maxRerouteAttempts}`);
|
|
282
|
-
console.log(` Terminal action: ${p.terminalActionOverride
|
|
305
|
+
console.log(` Terminal action: ${terminalText(p.terminalActionOverride)}`);
|
|
283
306
|
console.log(` Anti-bounce: ${!p.allowBounceBack ? `${GRN}ON${RESET}` : `${DIM}off${RESET}`}`);
|
|
284
307
|
console.log(` Prefer prev agent: ${p.preferPreviousAgentOnTimeout ? `${GRN}yes${RESET}` : 'no'}`);
|
|
285
308
|
}
|
|
@@ -287,6 +310,76 @@ const explainCommand = {
|
|
|
287
310
|
},
|
|
288
311
|
};
|
|
289
312
|
|
|
313
|
+
// ── velaro routing simulate ───────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
const simulateCommand = {
|
|
316
|
+
command: 'simulate',
|
|
317
|
+
describe: 'DRY RUN: what would the agent inactivity chain do for a mocked scenario (sends nothing)',
|
|
318
|
+
builder: (y) => y
|
|
319
|
+
.option('team', { type: 'number', describe: 'Group id whose policy to simulate (default: site default)' })
|
|
320
|
+
.option('no-agent-seconds', { type: 'number', default: 0, describe: 'Seconds nobody has been available' })
|
|
321
|
+
.option('attempts-used', { type: 'number', default: 0, describe: 'Moves already used on the chat' })
|
|
322
|
+
.option('step', { type: 'number', default: 0, describe: '0 nothing tried, 1 backup group offered, 2 final action applied' })
|
|
323
|
+
.option('since-last-transfer', { type: 'number', describe: 'Seconds since the chat was last moved' })
|
|
324
|
+
.option('already-handled', { type: 'boolean', default: false, describe: 'Simulate the same silence delivered twice' })
|
|
325
|
+
.option('agent-name', { type: 'string', describe: 'Silent agent name for the alert text' }),
|
|
326
|
+
handler: async (argv) => {
|
|
327
|
+
const r = await messagingPost('/Routing/Inactivity/simulate', {
|
|
328
|
+
teamId: argv.team ?? null,
|
|
329
|
+
noAgentSeconds: argv['no-agent-seconds'],
|
|
330
|
+
attemptsUsed: argv['attempts-used'],
|
|
331
|
+
inactivityStep: argv.step,
|
|
332
|
+
secondsSinceLastTransfer: argv['since-last-transfer'] ?? null,
|
|
333
|
+
sameEpisodeAlreadyHandled: argv['already-handled'],
|
|
334
|
+
fromAgentName: argv['agent-name'] ?? null,
|
|
335
|
+
});
|
|
336
|
+
console.log();
|
|
337
|
+
for (const l of describeSimulation(r)) console.log(` ${l}`);
|
|
338
|
+
console.log();
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// ── velaro routing precedence ─────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
const precedenceCommand = {
|
|
345
|
+
command: 'precedence',
|
|
346
|
+
describe: 'Which routing rule wins when an agent goes silent, plus policy conflict warnings',
|
|
347
|
+
builder: (y) => y,
|
|
348
|
+
handler: async () => {
|
|
349
|
+
const [defaults, conflicts] = await Promise.all([
|
|
350
|
+
messagingGet('/Routing/Inactivity/defaults'),
|
|
351
|
+
messagingGet('/Routing/Inactivity/conflicts'),
|
|
352
|
+
]);
|
|
353
|
+
console.log(`\n${BOLD}Evaluation order (top to bottom)${RESET}`);
|
|
354
|
+
for (const p of defaults.precedence ?? []) console.log(` ${p}`);
|
|
355
|
+
const list = conflicts.conflicts ?? [];
|
|
356
|
+
console.log(`\n${BOLD}Warnings${RESET}`);
|
|
357
|
+
if (!list.length) console.log(' None. No group policy cancels out the site default.');
|
|
358
|
+
for (const c of list) console.log(` [${String(c.severity).toUpperCase()}] ${c.teamId != null ? `group ${c.teamId}` : 'site default'}: ${c.message}`);
|
|
359
|
+
console.log();
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// ── velaro routing inactivity-transfers ───────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
const inactivityTransfersCommand = {
|
|
366
|
+
command: 'inactivity-transfers',
|
|
367
|
+
describe: 'Count agent inactivity transfers (chats taken from a silent agent), total, per agent, per day',
|
|
368
|
+
builder: (y) => y
|
|
369
|
+
.option('days', { type: 'number', default: 30, describe: 'Lookback in days (1-92)' })
|
|
370
|
+
.option('agent-name', { type: 'string', describe: 'Agent display name (partial match)' })
|
|
371
|
+
.option('agent-id', { type: 'number', describe: 'Exact agent id' }),
|
|
372
|
+
handler: async (argv) => {
|
|
373
|
+
let path = `RoutingDiagnostics/inactivity-transfers?days=${argv.days}`;
|
|
374
|
+
if (argv['agent-id'] != null) path += `&agentId=${argv['agent-id']}`;
|
|
375
|
+
if (argv['agent-name']) path += `&agentName=${encodeURIComponent(argv['agent-name'])}`;
|
|
376
|
+
const r = await adminGet(path);
|
|
377
|
+
console.log();
|
|
378
|
+
for (const l of describeTransfers(r)) console.log(` ${l}`);
|
|
379
|
+
console.log();
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
|
|
290
383
|
// ── top-level routing command ─────────────────────────────────────────────────
|
|
291
384
|
|
|
292
385
|
export const routingCommand = {
|
|
@@ -298,7 +391,10 @@ export const routingCommand = {
|
|
|
298
391
|
.command(policyCommand)
|
|
299
392
|
.command(decisionLogCommand)
|
|
300
393
|
.command(explainCommand)
|
|
301
|
-
.
|
|
394
|
+
.command(simulateCommand)
|
|
395
|
+
.command(precedenceCommand)
|
|
396
|
+
.command(inactivityTransfersCommand)
|
|
397
|
+
.demandCommand(1, 'Specify a subcommand: engine, circuit-breaker, policy, decision-log, explain, simulate, precedence, inactivity-transfers')
|
|
302
398
|
.strict(),
|
|
303
399
|
handler: () => {},
|
|
304
400
|
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// velaro scraper usage --site <id> — Velaro staff only. Scraper / upload / chunk consumption for
|
|
2
|
+
// any site (GET ScraperJobs/StaffUsage/{siteId}; IsVelaroAdminAsync + super-admin enforced
|
|
3
|
+
// server-side). The site is always explicit in the route, never derived from the caller's auth.
|
|
4
|
+
import { request } from '../api.js';
|
|
5
|
+
|
|
6
|
+
const n = (v) => (v ?? 0).toLocaleString('en-US');
|
|
7
|
+
|
|
8
|
+
async function usage(argv) {
|
|
9
|
+
const u = await request('GET', `ScraperJobs/StaffUsage/${encodeURIComponent(argv.site)}`);
|
|
10
|
+
if (argv.json) { console.log(JSON.stringify(u, null, 2)); return; }
|
|
11
|
+
console.log(`\nScraper usage, site ${u.siteId}${u.isTrial ? ' (trial)' : ''}`);
|
|
12
|
+
console.log(` Crawl pages this month: ${n(u.pagesThisMonthEffective)} / ${n(u.pagesBudgetPerMonth)} (admin ledger ${n(u.pagesThisMonthAdminLedger)}, messaging ledger ${u.pagesThisMonthMessagingLedger ?? 'n/a'})`);
|
|
13
|
+
console.log(` Upload pages this month: ${n(u.uploadPagesThisMonth)} / ${n(u.uploadPagesBudgetPerMonth)}`);
|
|
14
|
+
console.log(` OCR pages this month: ${n(u.ocrPagesThisMonth)}`);
|
|
15
|
+
console.log(` Jobs today: ${n(u.jobsToday)} / ${n(u.maxJobsPerDay)}`);
|
|
16
|
+
console.log(` Chunks indexed: ${n(u.chunksIndexed)} / ${n(u.maxIndexedChunkTotal)}`);
|
|
17
|
+
for (const i of u.chunksByIndex ?? []) console.log(` ${i.indexName}: ${n(i.chunks)} chunks, ${n(i.documents)} docs`);
|
|
18
|
+
for (const note of u.notes ?? []) console.log(` Note: ${note}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function scraperCommand(yargs) {
|
|
22
|
+
return yargs
|
|
23
|
+
.command('usage', 'Show scraper/upload/chunk usage for a site (staff only)', (y) => y
|
|
24
|
+
.option('site', { type: 'number', demandOption: true, describe: 'Target site id' })
|
|
25
|
+
.option('json', { type: 'boolean', describe: 'Print raw JSON' }), (a) => usage(a))
|
|
26
|
+
.demandCommand(1, 'Specify a subcommand: usage');
|
|
27
|
+
}
|
package/lib/commands/support.js
CHANGED
|
@@ -105,8 +105,21 @@ async function listDeployments(args) {
|
|
|
105
105
|
console.log('Use the embed key as test-frame.html?siteId=<site>&deploymentId=<embed-key>.');
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
async function tourInsights(args) {
|
|
109
|
+
const days = Math.min(365, Math.max(1, args.days ?? 30));
|
|
110
|
+
const path = args.site
|
|
111
|
+
? `SupportTools/sites/${args.site}/tour-events?days=${days}`
|
|
112
|
+
: `SupportTools/tour-insights?days=${days}`;
|
|
113
|
+
const result = await request('GET', path);
|
|
114
|
+
console.log(JSON.stringify(result, null, 2));
|
|
115
|
+
}
|
|
116
|
+
|
|
108
117
|
export function supportCommand(yargs) {
|
|
109
118
|
return yargs
|
|
119
|
+
.command('tour-insights', 'Moshky setup-tour funnel + scrubbed feedback across all sites, or one site\'s tour timeline with --site (Velaro staff, read-only)', y => y
|
|
120
|
+
.option('site', { type: 'number', desc: 'Optional site ID: show that site\'s tour events and feedback instead of the funnel' })
|
|
121
|
+
.option('days', { type: 'number', default: 30, desc: 'Look-back window in days (max 365)' })
|
|
122
|
+
, a => tourInsights(a))
|
|
110
123
|
.command('deployments', 'List a site’s widget embed keys and survey assignments (Velaro staff, read-only)', y => y
|
|
111
124
|
.option('site', { type: 'number', demandOption: true, desc: 'Target site ID' })
|
|
112
125
|
.option('json', { type: 'boolean', default: false, desc: 'Print the complete JSON response' })
|
|
@@ -142,5 +155,5 @@ export function supportCommand(yargs) {
|
|
|
142
155
|
.option('channel', { type: 'string', desc: 'Conversation source, e.g. Web, TwilioSms (default Web)' })
|
|
143
156
|
.option('overrideIndexes', { type: 'string', desc: 'Comma-separated index names to override the config\'s own IndexName for this query' })
|
|
144
157
|
, a => chatQuery(a))
|
|
145
|
-
.demandCommand(1, 'Specify a subcommand: deployments | impersonate | agent-lookup | toggle-flag | scraper-create-job | chat-query | send-email');
|
|
158
|
+
.demandCommand(1, 'Specify a subcommand: deployments | impersonate | agent-lookup | toggle-flag | scraper-create-job | chat-query | send-email | tour-insights');
|
|
146
159
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// velaro tour-policy — Velaro staff only. Guided-tour auto-open policy, resolved
|
|
2
|
+
// site override, then global default, then built-in default. Every write is versioned
|
|
3
|
+
// (last 20) and reversible. Site is always explicit in the route (TourPolicy/sites/{siteId}/...),
|
|
4
|
+
// never derived from the caller's auth context; omit --site for the global default (Velaro Admin).
|
|
5
|
+
import { request } from '../api.js';
|
|
6
|
+
|
|
7
|
+
const enc = encodeURIComponent;
|
|
8
|
+
const base = (argv) =>
|
|
9
|
+
argv.site ? `TourPolicy/sites/${argv.site}/${enc(argv.tour)}` : `TourPolicy/global/${enc(argv.tour)}`;
|
|
10
|
+
|
|
11
|
+
const fmt = (p) =>
|
|
12
|
+
p ? `${p.enabled ? 'ON' : 'OFF'}, ${p.maxAutoOpens} opens, ${p.minHoursBetweenAutoOpens}h apart, ${p.windowDays}d window${p.source ? ` (source: ${p.source})` : ''}` : '(none)';
|
|
13
|
+
|
|
14
|
+
async function get(argv) {
|
|
15
|
+
const r = await request('GET', base(argv));
|
|
16
|
+
console.log(`\nTour ${r.tourKey}, ${argv.site ? `site ${argv.site}` : 'global default'}`);
|
|
17
|
+
console.log(` Stored: ${fmt(r.stored)}`);
|
|
18
|
+
if (argv.site) console.log(` Effective: ${fmt(r.effective)}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function set(argv) {
|
|
22
|
+
const body = {};
|
|
23
|
+
if (argv.enabled !== undefined) body.enabled = argv.enabled;
|
|
24
|
+
if (argv.maxAutoOpens !== undefined) body.maxAutoOpens = argv.maxAutoOpens;
|
|
25
|
+
if (argv.minHours !== undefined) body.minHoursBetweenAutoOpens = argv.minHours;
|
|
26
|
+
if (argv.windowDays !== undefined) body.windowDays = argv.windowDays;
|
|
27
|
+
const r = await request('PUT', base(argv), body);
|
|
28
|
+
console.log(`Saved as version ${r.version.version}. Undo with: velaro tour-policy rollback --tour ${argv.tour}${argv.site ? ` --site ${argv.site}` : ''} --version <n>`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function history(argv) {
|
|
32
|
+
const r = await request('GET', `${base(argv)}/history`);
|
|
33
|
+
if (!r.versions?.length) { console.log('No history yet.'); return; }
|
|
34
|
+
for (const v of r.versions) {
|
|
35
|
+
const restored = v.restoredFromVersion ? ` (from v${v.restoredFromVersion})` : '';
|
|
36
|
+
console.log(`v${v.version} ${v.action}${restored} by ${v.by} at ${v.at}: ${fmt(v.oldPolicy)} -> ${fmt(v.newPolicy)}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function rollback(argv) {
|
|
41
|
+
const r = await request('POST', `${base(argv)}/rollback`, { version: argv.version });
|
|
42
|
+
console.log(`Restored v${argv.version} as new version ${r.version.version}.`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const common = (y) => y
|
|
46
|
+
.option('tour', { type: 'string', default: 'CommerceWelcome', describe: 'CommerceWelcome or ShopifyWelcome' })
|
|
47
|
+
.option('site', { type: 'number', describe: 'Site id. Omit for the global default (Velaro Admin).' });
|
|
48
|
+
|
|
49
|
+
export function tourPolicyCommand(yargs) {
|
|
50
|
+
return yargs
|
|
51
|
+
.command('get', 'Show stored and effective tour policy', common, (a) => get(a))
|
|
52
|
+
.command('set', 'Set tour policy (partial: omitted fields keep their value)', (y) => common(y)
|
|
53
|
+
.option('enabled', { type: 'boolean' })
|
|
54
|
+
.option('max-auto-opens', { type: 'number', alias: 'maxAutoOpens', describe: '0-20' })
|
|
55
|
+
.option('min-hours', { type: 'number', describe: 'Hours between auto-opens, 0-720' })
|
|
56
|
+
.option('window-days', { type: 'number', alias: 'windowDays', describe: '1-365' }), (a) => set(a))
|
|
57
|
+
.command('history', 'List the last 20 versions, newest first', common, (a) => history(a))
|
|
58
|
+
.command('rollback', 'Restore the state after a version (recorded as a new version)', (y) => common(y)
|
|
59
|
+
.option('version', { type: 'number', demandOption: true }), (a) => rollback(a))
|
|
60
|
+
.demandCommand(1, 'Specify a subcommand: get | set | history | rollback');
|
|
61
|
+
}
|
package/lib/commands/workflow.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { getCredentials, messagingGet, messagingPost, messagingPut } from '../api.js';
|
|
3
3
|
import { requireFeature } from '../subscription.js';
|
|
4
4
|
import { runCommand } from '../run.js';
|
|
5
5
|
|
|
@@ -20,6 +20,9 @@ export const workflowCommand = {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
// ── list ───────────────────────────────────────────────────────────────────────
|
|
23
|
+
// NOTE: every /Workflows/* route lives on the messaging API (WorkflowsController in
|
|
24
|
+
// velaro-messaging), never the admin API — these commands were 404ing in every environment
|
|
25
|
+
// until fixed 2026-09-27 because they called the admin-API-only get()/post() helpers.
|
|
23
26
|
|
|
24
27
|
const workflowListCommand = {
|
|
25
28
|
command: 'list',
|
|
@@ -27,7 +30,7 @@ const workflowListCommand = {
|
|
|
27
30
|
handler: runCommand(async () => {
|
|
28
31
|
await requireFeature('enableAutomation', 'Workflows');
|
|
29
32
|
|
|
30
|
-
const workflows = await
|
|
33
|
+
const workflows = await messagingGet('/Workflows/List');
|
|
31
34
|
const rows = (workflows ?? []).filter(w => !w.isTemplate);
|
|
32
35
|
|
|
33
36
|
if (!rows.length) { console.log('No workflows found.'); return; }
|
|
@@ -56,14 +59,17 @@ const workflowGetCommand = {
|
|
|
56
59
|
handler: runCommand(async (argv) => {
|
|
57
60
|
await requireFeature('enableAutomation', 'Workflows');
|
|
58
61
|
|
|
59
|
-
const w = await
|
|
62
|
+
const w = await messagingGet(`/Workflows/${argv.id}`);
|
|
60
63
|
if (!w) { console.error(`Workflow ${argv.id} not found.`); process.exit(1); }
|
|
61
64
|
|
|
62
65
|
console.log(`\n[${w.id}] ${w.name}`);
|
|
63
66
|
console.log(` Status: ${w.enabled ? 'enabled' : 'disabled'}`);
|
|
64
67
|
console.log(` Trigger: ${w.triggerType ?? '—'}`);
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
// WorkflowViewModel.Nodes serializes as a JSON object keyed by node id
|
|
69
|
+
// (Dictionary<int, WorkflowNode>), not an array.
|
|
70
|
+
const nodeCount = w.nodes ? Object.keys(w.nodes).length : 0;
|
|
71
|
+
if (nodeCount) {
|
|
72
|
+
console.log(` Nodes: ${nodeCount}`);
|
|
67
73
|
}
|
|
68
74
|
}),
|
|
69
75
|
};
|
|
@@ -100,7 +106,7 @@ const workflowCreateCommand = {
|
|
|
100
106
|
};
|
|
101
107
|
}
|
|
102
108
|
|
|
103
|
-
const id = await
|
|
109
|
+
const id = await messagingPost('/Workflows', body);
|
|
104
110
|
console.log(`Workflow created: ${id}`);
|
|
105
111
|
console.log(`View: velaro workflow get ${id}`);
|
|
106
112
|
}),
|
|
@@ -114,7 +120,16 @@ const workflowEnableCommand = {
|
|
|
114
120
|
builder: (y) => y.positional('id', { type: 'number', describe: 'Workflow ID' }),
|
|
115
121
|
handler: runCommand(async (argv) => {
|
|
116
122
|
await requireFeature('enableAutomation', 'Workflows');
|
|
117
|
-
|
|
123
|
+
|
|
124
|
+
const w = await messagingGet(`/Workflows/${argv.id}`);
|
|
125
|
+
if (!w) { console.error(`Workflow ${argv.id} not found.`); process.exit(1); }
|
|
126
|
+
|
|
127
|
+
if (w.enabled) {
|
|
128
|
+
console.log(`Workflow ${argv.id} is already enabled.`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await messagingPost(`/Workflows/toggle/${argv.id}`, null);
|
|
118
133
|
console.log(`Workflow ${argv.id} enabled.`);
|
|
119
134
|
}),
|
|
120
135
|
};
|
|
@@ -126,7 +141,7 @@ const workflowDisableCommand = {
|
|
|
126
141
|
handler: runCommand(async (argv) => {
|
|
127
142
|
await requireFeature('enableAutomation', 'Workflows');
|
|
128
143
|
|
|
129
|
-
const w = await
|
|
144
|
+
const w = await messagingGet(`/Workflows/${argv.id}`);
|
|
130
145
|
if (!w) { console.error(`Workflow ${argv.id} not found.`); process.exit(1); }
|
|
131
146
|
|
|
132
147
|
if (!w.enabled) {
|
|
@@ -134,12 +149,17 @@ const workflowDisableCommand = {
|
|
|
134
149
|
return;
|
|
135
150
|
}
|
|
136
151
|
|
|
137
|
-
await
|
|
152
|
+
await messagingPost(`/Workflows/toggle/${argv.id}`, null);
|
|
138
153
|
console.log(`Workflow ${argv.id} disabled.`);
|
|
139
154
|
}),
|
|
140
155
|
};
|
|
141
156
|
|
|
142
157
|
// ── node-options ───────────────────────────────────────────────────────────────
|
|
158
|
+
// The backend has no per-node route — WorkflowsController only exposes [HttpPut] on the
|
|
159
|
+
// whole workflow (PutAsync(WorkflowViewModel)). This command previously POSTed to a
|
|
160
|
+
// nonexistent `/Workflows/{workflowId}/node/{nodeId}` route; it now reads the full workflow,
|
|
161
|
+
// mutates the one node in its Nodes dictionary, and PUTs the whole WorkflowViewModel back —
|
|
162
|
+
// the same read-modify-write the admin canvas UI performs on every node edit.
|
|
143
163
|
|
|
144
164
|
const workflowNodeOptionsCommand = {
|
|
145
165
|
command: 'node-options <workflowId> <nodeId>',
|
|
@@ -156,10 +176,14 @@ const workflowNodeOptionsCommand = {
|
|
|
156
176
|
handler: runCommand(async (argv) => {
|
|
157
177
|
await requireFeature('enableAutomation', 'Workflows');
|
|
158
178
|
|
|
159
|
-
const w = await
|
|
179
|
+
const w = await messagingGet(`/Workflows/${argv.workflowId}`);
|
|
160
180
|
if (!w) { console.error(`Workflow ${argv.workflowId} not found.`); process.exit(1); }
|
|
161
181
|
|
|
162
|
-
|
|
182
|
+
// Nodes is a Dictionary<int, WorkflowNode> — serializes as a JSON object keyed by
|
|
183
|
+
// node id string, not an array.
|
|
184
|
+
const nodes = w.nodes ?? {};
|
|
185
|
+
const nodeKey = String(argv.nodeId);
|
|
186
|
+
const node = nodes[nodeKey];
|
|
163
187
|
if (!node) { console.error(`Node ${argv.nodeId} not found in workflow ${argv.workflowId}.`); process.exit(1); }
|
|
164
188
|
if (node.actionType !== 'askQuestion' || node.questionType !== 'multiSelect') {
|
|
165
189
|
console.error(`Node ${argv.nodeId} is not a multiSelect ask-question node (found: ${node.actionType}/${node.questionType}).`);
|
|
@@ -168,6 +192,16 @@ const workflowNodeOptionsCommand = {
|
|
|
168
192
|
|
|
169
193
|
const hasChanges = argv.options || argv.min != null || argv.max != null || argv['dynamic-var'] || argv.strategy;
|
|
170
194
|
|
|
195
|
+
// WorkflowNode (velaro-messaging) has no MinSelections/MaxSelections/OptionsSource/
|
|
196
|
+
// DynamicOptionsVariable properties and no [JsonExtensionData] catch-all, so these fields
|
|
197
|
+
// are silently dropped on save — confirmed by reading WorkflowNode's source. Refuse rather
|
|
198
|
+
// than print a false "Node updated." The admin canvas (MultiSelectEditor.tsx) has the same
|
|
199
|
+
// gap; the real fix is adding those properties server-side.
|
|
200
|
+
if (argv.min != null || argv.max != null || argv['dynamic-var']) {
|
|
201
|
+
console.error('--min, --max, and --dynamic-var are not supported yet: WorkflowNode has no backing fields for them, so the server silently drops them on save. Only --options and --strategy actually persist.');
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
|
|
171
205
|
if (!hasChanges) {
|
|
172
206
|
// Read mode — print current config
|
|
173
207
|
console.log(`\nNode ${argv.nodeId} — multiSelect options:`);
|
|
@@ -184,21 +218,16 @@ const workflowNodeOptionsCommand = {
|
|
|
184
218
|
return;
|
|
185
219
|
}
|
|
186
220
|
|
|
187
|
-
// Update mode
|
|
221
|
+
// Update mode — only --options and --strategy have backing fields on WorkflowNode today.
|
|
188
222
|
const updated = { ...node };
|
|
189
223
|
if (argv.options) {
|
|
190
224
|
updated.options = JSON.parse(argv.options);
|
|
191
225
|
updated.optionsSource = 'static';
|
|
192
226
|
}
|
|
193
|
-
if (argv
|
|
194
|
-
updated.dynamicOptionsVariable = argv['dynamic-var'];
|
|
195
|
-
updated.optionsSource = 'dynamic';
|
|
196
|
-
}
|
|
197
|
-
if (argv.min != null) updated.minSelections = argv.min;
|
|
198
|
-
if (argv.max != null) updated.maxSelections = argv.max;
|
|
199
|
-
if (argv.strategy) updated.largeListStrategy = argv.strategy;
|
|
227
|
+
if (argv.strategy) updated.largeListStrategy = argv.strategy;
|
|
200
228
|
|
|
201
|
-
|
|
229
|
+
const updatedWorkflow = { ...w, nodes: { ...nodes, [nodeKey]: updated } };
|
|
230
|
+
await messagingPut('/Workflows', updatedWorkflow);
|
|
202
231
|
console.log(`Node ${argv.nodeId} updated.`);
|
|
203
232
|
}),
|
|
204
233
|
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Pure logic for `velaro routing ...` agent inactivity chain commands (velaro-messaging PR #1471).
|
|
2
|
+
// v20 ONLY. No I/O here so it can be unit tested (see routing-inactivity.test.js).
|
|
3
|
+
|
|
4
|
+
// Persisted as ints by messaging (append-only enum); the API accepts and returns the NAME.
|
|
5
|
+
export const TERMINAL_ACTION_NAMES = [
|
|
6
|
+
'Survey', 'CreateTicket', 'SurveyAndCreateTicket', 'Queue', 'OfflineMessage', 'NoOp',
|
|
7
|
+
'EndChat', 'NotifyManager', 'RunWorkflow',
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export function terminalLabel(v) {
|
|
11
|
+
if (v === null || v === undefined || v === '') return 'Default (Survey)';
|
|
12
|
+
if (typeof v === 'number') return TERMINAL_ACTION_NAMES[v] ?? String(v);
|
|
13
|
+
return String(v);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Accepts an int (0-8) or a name (any case). Returns the enum name, or null for "default". Throws on junk. */
|
|
17
|
+
export function terminalForSave(v) {
|
|
18
|
+
if (v === null || v === undefined || v === '') return null;
|
|
19
|
+
if (typeof v === 'number' || /^\d+$/.test(String(v))) {
|
|
20
|
+
const name = TERMINAL_ACTION_NAMES[Number(v)];
|
|
21
|
+
if (!name) throw new Error(`Unknown terminal action ${v}. Use 0-8: ${TERMINAL_ACTION_NAMES.map((n, i) => `${i}=${n}`).join(' ')}`);
|
|
22
|
+
return name;
|
|
23
|
+
}
|
|
24
|
+
const hit = TERMINAL_ACTION_NAMES.find(n => n.toLowerCase() === String(v).toLowerCase());
|
|
25
|
+
if (!hit) throw new Error(`Unknown terminal action "${v}". Use one of: ${TERMINAL_ACTION_NAMES.join(', ')}`);
|
|
26
|
+
return hit;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// CLI flag -> AutoRoutingPolicy.InactivityChain field
|
|
30
|
+
export const CHAIN_FLAGS = {
|
|
31
|
+
'chain': 'enabled',
|
|
32
|
+
'backup-team': 'backupTeamId',
|
|
33
|
+
'backup-after': 'backupTeamAfterSeconds',
|
|
34
|
+
'no-agent-after': 'noTargetActionAfterSeconds',
|
|
35
|
+
'presence-trigger': 'rerouteOnAgentUnavailable',
|
|
36
|
+
'alert-on-release': 'alertAvailableAgentsOnRelease',
|
|
37
|
+
'notify-managers': 'notifyManagersOnInactivity',
|
|
38
|
+
'cooldown': 'rerouteCooldownSeconds',
|
|
39
|
+
'queue-escalation-minutes': 'queueEscalationAfterMinutes',
|
|
40
|
+
'terminal-workflow': 'terminalWorkflowId',
|
|
41
|
+
'visitor-text': 'visitorStatusText',
|
|
42
|
+
'agent-text': 'agentStatusText',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Merges CLI flags onto the current policy and returns the PUT body.
|
|
47
|
+
* reassignmentMode is ALWAYS sent (an absent value is treated as Observe by the API and would silently
|
|
48
|
+
* switch reassignment off). The chain block is only sent when the server reports one or a chain flag is set.
|
|
49
|
+
*/
|
|
50
|
+
export function buildPolicyUpdate(current, argv) {
|
|
51
|
+
const merged = { ...current };
|
|
52
|
+
const map = {
|
|
53
|
+
'initial-timeout': 'initialResponseTimeoutSeconds',
|
|
54
|
+
'idle-timeout': 'idleAfterResponseTimeoutSeconds',
|
|
55
|
+
'grace': 'idleAlertGraceSeconds',
|
|
56
|
+
'max-reroutes': 'maxRerouteAttempts',
|
|
57
|
+
'allow-bounceback': 'allowBounceBack',
|
|
58
|
+
'prefer-previous': 'preferPreviousAgentOnTimeout',
|
|
59
|
+
'exhaustion-message': 'exhaustionMessageTemplate',
|
|
60
|
+
};
|
|
61
|
+
for (const [flag, field] of Object.entries(map)) if (argv[flag] !== undefined) merged[field] = argv[flag];
|
|
62
|
+
|
|
63
|
+
merged.terminalActionOverride = terminalForSave(
|
|
64
|
+
argv['terminal-action'] !== undefined ? argv['terminal-action'] : current.terminalActionOverride
|
|
65
|
+
);
|
|
66
|
+
// Actions 6-8 exist only on a messaging build with the inactivity chain; an older one would store a different value.
|
|
67
|
+
if (['EndChat', 'NotifyManager', 'RunWorkflow'].includes(merged.terminalActionOverride) && !('inactivityChain' in current))
|
|
68
|
+
throw new Error(`Terminal action ${merged.terminalActionOverride} needs a messaging build with the agent inactivity chain; this one does not report it. Use 0-5.`);
|
|
69
|
+
|
|
70
|
+
const mode = argv['reassignment-mode'] ?? current.reassignmentMode ?? 'Observe';
|
|
71
|
+
if (!['Observe', 'QueueOnly', 'Reassign'].includes(mode)) {
|
|
72
|
+
throw new Error(`Unknown reassignment mode "${mode}". Use Observe, QueueOnly or Reassign.`);
|
|
73
|
+
}
|
|
74
|
+
merged.reassignmentMode = mode;
|
|
75
|
+
|
|
76
|
+
const patched = Object.keys(CHAIN_FLAGS).filter(f => argv[f] !== undefined);
|
|
77
|
+
if (current.inactivityChain || patched.length) {
|
|
78
|
+
const chain = { ...(current.inactivityChain ?? {}) };
|
|
79
|
+
for (const f of patched) chain[CHAIN_FLAGS[f]] = argv[f];
|
|
80
|
+
merged.inactivityChain = chain;
|
|
81
|
+
}
|
|
82
|
+
return merged;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Client-side mirror of the server's cross-field rules, so the CLI fails before the round trip. */
|
|
86
|
+
export function validatePolicyUpdate(p) {
|
|
87
|
+
const errors = [];
|
|
88
|
+
const c = p.inactivityChain;
|
|
89
|
+
// RunWorkflow with no workflow is invalid whatever the chain switch says: the final action also runs
|
|
90
|
+
// when the chain is off (hop cap reached), so it would have nothing to run.
|
|
91
|
+
if (p.terminalActionOverride === 'RunWorkflow' && (!c || c.terminalWorkflowId == null))
|
|
92
|
+
errors.push('Terminal action RunWorkflow needs --terminal-workflow <id>.');
|
|
93
|
+
if (!c || !c.enabled) return errors;
|
|
94
|
+
if ((c.backupTeamId != null || c.noTargetActionAfterSeconds != null) && p.reassignmentMode !== 'Reassign')
|
|
95
|
+
errors.push("The backup group and no-agent-available steps need --reassignment-mode Reassign.");
|
|
96
|
+
if (c.backupTeamId != null && c.noTargetActionAfterSeconds != null && c.noTargetActionAfterSeconds < (c.backupTeamAfterSeconds ?? 120))
|
|
97
|
+
errors.push('--no-agent-after must be at least as long as --backup-after (the backup group step runs first).');
|
|
98
|
+
return errors;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function describeChain(p) {
|
|
102
|
+
const c = p.inactivityChain;
|
|
103
|
+
if (!c) return ['(This messaging build does not report the inactivity chain.)'];
|
|
104
|
+
const out = [
|
|
105
|
+
`Reassignment mode: ${p.reassignmentMode ?? 'Observe'}`,
|
|
106
|
+
`Inactivity chain: ${c.enabled ? 'ON' : 'off'}`,
|
|
107
|
+
];
|
|
108
|
+
if (c.enabled) {
|
|
109
|
+
out.push(
|
|
110
|
+
` Backup group: ${c.backupTeamId ?? 'none'}${c.backupTeamId != null ? ` after ${c.backupTeamAfterSeconds}s` : ''}`,
|
|
111
|
+
` Final action after: ${c.noTargetActionAfterSeconds != null ? `${c.noTargetActionAfterSeconds}s with no agent available` : 'only when the hop cap is used up'}`,
|
|
112
|
+
` Cooldown: ${c.rerouteCooldownSeconds}s`,
|
|
113
|
+
` Presence trigger: ${c.rerouteOnAgentUnavailable}`,
|
|
114
|
+
` Alert on release: ${c.alertAvailableAgentsOnRelease}`,
|
|
115
|
+
` Notify managers: ${c.notifyManagersOnInactivity}`,
|
|
116
|
+
` Queue escalation: ${c.queueEscalationAfterMinutes != null ? `${c.queueEscalationAfterMinutes} min` : 'never'}`,
|
|
117
|
+
` Terminal workflow: ${c.terminalWorkflowId ?? 'none'}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const GATE = {
|
|
124
|
+
NotEnabled: "The inactivity chain is off for this rule, so today's behavior applies and nothing new happens.",
|
|
125
|
+
Proceed: 'Allowed to move the chat.',
|
|
126
|
+
SkipDedupe: 'Blocked: this exact silence was already handled (double-transfer guard).',
|
|
127
|
+
SkipCooldown: 'Blocked: the chat was moved too recently (cooldown).',
|
|
128
|
+
HopCapReached: 'Hop cap reached: no more moves, so the final action applies.',
|
|
129
|
+
};
|
|
130
|
+
const ACTION = {
|
|
131
|
+
LegacyDefer: 'Nothing new is configured, so the chat stays with its current owner.',
|
|
132
|
+
Hold: 'Still inside the wait window, nothing happens yet.',
|
|
133
|
+
WidenToBackupTeam: 'The chat is offered to the backup group.',
|
|
134
|
+
ApplyTerminal: 'The final action runs.',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export function describeSimulation(r) {
|
|
138
|
+
const lines = [
|
|
139
|
+
'DRY RUN (nothing sent, no live chat touched)',
|
|
140
|
+
`Settings used: ${r.ruleSource}`,
|
|
141
|
+
`Safety gate: ${GATE[r.gate] ?? r.gate}`,
|
|
142
|
+
`Decision: ${ACTION[r.noTargetAction] ?? r.noTargetAction}`,
|
|
143
|
+
];
|
|
144
|
+
if (r.explanation) lines.push(`Why: ${r.explanation}`);
|
|
145
|
+
if (r.nextStepAtSeconds != null) lines.push(`Next step after ${r.nextStepAtSeconds}s with no agent available.`);
|
|
146
|
+
lines.push(`Visitor would see: "${r.visitorMessage}"`, `Agents would see: "${r.queueAlert || r.agentStatus}"`);
|
|
147
|
+
for (const l of r.otherRulesThatMatchedButLost ?? []) lines.push(`Also matched but lost: ${l.rule} (${l.reason})`);
|
|
148
|
+
return lines;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function describeTransfers(r) {
|
|
152
|
+
if (r.note && !r.total && !(r.byAgent ?? []).length) return [r.note];
|
|
153
|
+
const lines = [`Agent inactivity transfers, site ${r.siteId}, last ${r.days} days: ${r.total}${r.capped ? ' (capped at 5000)' : ''}`];
|
|
154
|
+
if (r.note) lines.push(r.note);
|
|
155
|
+
if (r.matchedAgentsCapped) lines.push('The name matched more agents than are counted here. Use a longer name or --agent-id.');
|
|
156
|
+
for (const a of r.byAgent ?? []) lines.push(` ${a.name}${a.agentId != null ? ` (id ${a.agentId})` : ''}: ${a.count}`);
|
|
157
|
+
if ((r.byDay ?? []).length) lines.push('', 'By day (Pacific): ' + r.byDay.map(d => `${d.date}=${d.count}`).join(', '));
|
|
158
|
+
if (!r.total) lines.push('None. Check that the inactivity chain is on and reassignment mode is not Observe.');
|
|
159
|
+
return lines;
|
|
160
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
buildPolicyUpdate, validatePolicyUpdate, terminalForSave, terminalLabel,
|
|
5
|
+
describeChain, describeSimulation, describeTransfers,
|
|
6
|
+
} from './routing-inactivity.js';
|
|
7
|
+
|
|
8
|
+
const current = {
|
|
9
|
+
enabled: true, maxRerouteAttempts: 3, terminalActionOverride: 'Survey', reassignmentMode: 'Reassign',
|
|
10
|
+
inactivityChain: { enabled: true, backupTeamId: 5, backupTeamAfterSeconds: 120, noTargetActionAfterSeconds: 300, rerouteCooldownSeconds: 60 },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
test('terminal action accepts ints and names, rejects junk', () => {
|
|
14
|
+
assert.equal(terminalForSave(6), 'EndChat');
|
|
15
|
+
assert.equal(terminalForSave('7'), 'NotifyManager');
|
|
16
|
+
assert.equal(terminalForSave('runworkflow'), 'RunWorkflow');
|
|
17
|
+
assert.equal(terminalForSave(undefined), null);
|
|
18
|
+
assert.throws(() => terminalForSave(9), /Unknown terminal action/);
|
|
19
|
+
assert.throws(() => terminalForSave('Bogus'), /Unknown terminal action/);
|
|
20
|
+
assert.equal(terminalLabel(null), 'Default (Survey)');
|
|
21
|
+
assert.equal(terminalLabel(8), 'RunWorkflow');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('an update with no chain flags keeps the saved mode and chain (mode is always sent)', () => {
|
|
25
|
+
const body = buildPolicyUpdate(current, { 'max-reroutes': 4 });
|
|
26
|
+
assert.equal(body.maxRerouteAttempts, 4);
|
|
27
|
+
assert.equal(body.reassignmentMode, 'Reassign');
|
|
28
|
+
assert.deepEqual(body.inactivityChain, current.inactivityChain);
|
|
29
|
+
assert.equal(body.terminalActionOverride, 'Survey');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('a server without reassignmentMode still sends Observe explicitly and no chain block', () => {
|
|
33
|
+
const body = buildPolicyUpdate({ enabled: true }, { 'max-reroutes': 2 });
|
|
34
|
+
assert.equal(body.reassignmentMode, 'Observe');
|
|
35
|
+
assert.equal('inactivityChain' in body, false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('chain flags patch the chain block', () => {
|
|
39
|
+
const body = buildPolicyUpdate(current, { 'cooldown': 90, 'chain': false, 'visitor-text': 'Hi' });
|
|
40
|
+
assert.equal(body.inactivityChain.rerouteCooldownSeconds, 90);
|
|
41
|
+
assert.equal(body.inactivityChain.enabled, false);
|
|
42
|
+
assert.equal(body.inactivityChain.visitorStatusText, 'Hi');
|
|
43
|
+
assert.equal(body.inactivityChain.backupTeamId, 5);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('chain flags create the block on a server that had none', () => {
|
|
47
|
+
const body = buildPolicyUpdate({ enabled: true }, { chain: true, 'backup-team': 5, 'reassignment-mode': 'Reassign' });
|
|
48
|
+
assert.equal(body.inactivityChain.enabled, true);
|
|
49
|
+
assert.equal(body.reassignmentMode, 'Reassign');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('rejects an unknown reassignment mode', () => {
|
|
53
|
+
assert.throws(() => buildPolicyUpdate(current, { 'reassignment-mode': 'Yolo' }), /Unknown reassignment mode/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('validation mirrors the server cross-field rules', () => {
|
|
57
|
+
assert.deepEqual(validatePolicyUpdate(buildPolicyUpdate(current, {})), []);
|
|
58
|
+
assert.match(validatePolicyUpdate(buildPolicyUpdate(current, { 'reassignment-mode': 'QueueOnly' }))[0], /Reassign/);
|
|
59
|
+
assert.match(validatePolicyUpdate(buildPolicyUpdate(current, { 'backup-after': 600 }))[0], /at least as long/);
|
|
60
|
+
assert.match(validatePolicyUpdate(buildPolicyUpdate(current, { 'terminal-action': 'RunWorkflow' }))[0], /--terminal-workflow/);
|
|
61
|
+
assert.deepEqual(validatePolicyUpdate(buildPolicyUpdate(current, { chain: false, 'reassignment-mode': 'Observe' })), []);
|
|
62
|
+
// RunWorkflow without a workflow is rejected even with the chain off (the final action still runs at the hop cap).
|
|
63
|
+
assert.match(
|
|
64
|
+
validatePolicyUpdate(buildPolicyUpdate(current, { chain: false, 'reassignment-mode': 'Observe', 'terminal-action': 'RunWorkflow' }))[0],
|
|
65
|
+
/--terminal-workflow/
|
|
66
|
+
);
|
|
67
|
+
assert.deepEqual(
|
|
68
|
+
validatePolicyUpdate(buildPolicyUpdate(current, { 'terminal-action': 'RunWorkflow', 'terminal-workflow': 9 })),
|
|
69
|
+
[]
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('terminal actions 6-8 are rejected when the current policy has no inactivityChain block', () => {
|
|
74
|
+
const legacy = { enabled: true, maxRerouteAttempts: 3, terminalActionOverride: 'Survey', reassignmentMode: 'Observe' };
|
|
75
|
+
assert.throws(() => buildPolicyUpdate(legacy, { 'terminal-action': 'EndChat' }), /inactivity chain/);
|
|
76
|
+
assert.throws(() => buildPolicyUpdate(legacy, { 'terminal-action': 8 }), /inactivity chain/);
|
|
77
|
+
assert.equal(buildPolicyUpdate(legacy, { 'terminal-action': 3 }).terminalActionOverride, 'Queue');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('describeTransfers keeps results when a site override was ignored', () => {
|
|
81
|
+
const lines = describeTransfers({
|
|
82
|
+
siteId: 1, days: 30, total: 2, siteOverrideIgnored: true,
|
|
83
|
+
note: 'siteIdOverride=5 was ignored because only Velaro admins may query another site. These results are for site 1.',
|
|
84
|
+
byAgent: [{ agentId: 7, name: 'Sam', count: 2 }], byDay: [{ date: '2026-09-24', count: 2 }],
|
|
85
|
+
}).join('\n');
|
|
86
|
+
assert.match(lines, /was ignored/);
|
|
87
|
+
assert.match(lines, /Sam \(id 7\): 2/);
|
|
88
|
+
assert.match(lines, /By day \(Pacific\): 2026-09-24=2/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('describe helpers render the chain, a simulation and a transfer count', () => {
|
|
92
|
+
assert.match(describeChain(current).join('\n'), /Inactivity chain:\s+ON/);
|
|
93
|
+
assert.match(describeChain({}).join('\n'), /does not report/);
|
|
94
|
+
const sim = describeSimulation({ ruleSource: 'SiteDefault', gate: 'SkipCooldown', noTargetAction: 'Hold', explanation: 'x', nextStepAtSeconds: 60, visitorMessage: 'v', agentStatus: 'a', queueAlert: '', otherRulesThatMatchedButLost: [{ rule: 'R', reason: 'why' }] });
|
|
95
|
+
assert.match(sim.join('\n'), /DRY RUN/);
|
|
96
|
+
assert.match(sim.join('\n'), /cooldown/);
|
|
97
|
+
assert.match(sim.join('\n'), /Also matched but lost: R \(why\)/);
|
|
98
|
+
assert.match(describeTransfers({ siteId: 1, days: 30, total: 0, byAgent: [], byDay: [] }).join('\n'), /None\./);
|
|
99
|
+
assert.match(describeTransfers({ siteId: 1, days: 30, total: 2, byAgent: [{ agentId: 7, name: 'Sam', count: 2 }], byDay: [] }).join('\n'), /Sam \(id 7\): 2/);
|
|
100
|
+
assert.deepEqual(describeTransfers({ note: 'No agent found' }), ['No agent found']);
|
|
101
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velaro/cli",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.59",
|
|
4
4
|
"description": "Velaro Workspace v20 — command-line interface for managing bots, knowledge base ingestion, MCP API keys, search indexes, and ops health.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"@azure/identity": "^4.4.1",
|
|
18
18
|
"@azure/keyvault-secrets": "^4.9.0",
|
|
19
19
|
"mssql": "^12.7.0",
|
|
20
|
-
"pdfkit": "^0.
|
|
20
|
+
"pdfkit": "^0.20.2",
|
|
21
21
|
"yargs": "^17.7.2"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
@@ -33,5 +33,8 @@
|
|
|
33
33
|
"lib/",
|
|
34
34
|
"scripts/",
|
|
35
35
|
"assets/"
|
|
36
|
-
]
|
|
36
|
+
],
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"license-checker-rseidelsohn": "^4.4.2"
|
|
39
|
+
}
|
|
37
40
|
}
|