@remits/remits-cli 0.1.104 → 0.1.107
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/index.js +461 -23
- package/package.json +1 -1
- package/skills/remits-cli/SKILL.md +40 -5
package/index.js
CHANGED
|
@@ -29,6 +29,7 @@ const ACCOUNT_REPO_INDEX_FILE = path.join(SESSION_DIR, 'account-repos.json');
|
|
|
29
29
|
const SERVICE_STATE_FILE = path.join(SESSION_DIR, 'service-state.json');
|
|
30
30
|
const ISSUES_DIR = path.join(SESSION_DIR, 'issues');
|
|
31
31
|
const WEBSOCKET_STATE_FILE = path.join(SESSION_DIR, 'websocket-state.json');
|
|
32
|
+
const LISTENER_REGISTRATION_STATE_FILE = path.join(SESSION_DIR, 'listener-registration-state.json');
|
|
32
33
|
const AUTO_UPDATE_LOCK_DIR = path.join(SESSION_DIR, 'auto-update.lock');
|
|
33
34
|
const AUTO_UPDATE_DISABLED_VALUES = new Set(['0', 'false', 'no', 'off']);
|
|
34
35
|
const REMITS_CLI_PACKAGE_NAME = '@remits/remits-cli';
|
|
@@ -45,6 +46,10 @@ const WEBSOCKET_RECONNECT_DELAY_MS = Number(process.env.REMITS_CLI_WEBSOCKET_REC
|
|
|
45
46
|
const WEBSOCKET_HEARTBEAT_MS = Number(process.env.REMITS_CLI_WEBSOCKET_HEARTBEAT_MS || 10000);
|
|
46
47
|
const WEBSOCKET_CONNECTION_TIMEOUT_MS = Number(process.env.REMITS_CLI_WEBSOCKET_CONNECTION_TIMEOUT_MS || 15000);
|
|
47
48
|
const WEBSOCKET_WATCHDOG_GRACE_MS = Number(process.env.REMITS_CLI_WEBSOCKET_WATCHDOG_GRACE_MS || 30000);
|
|
49
|
+
const SUPPORT_TICKET_DASHBOARD_REFRESH_MS = Number(process.env.REMITS_CLI_SUPPORT_TICKET_REFRESH_MS || 30000);
|
|
50
|
+
const SUPPORT_TICKET_DASHBOARD_LIMIT = Number(process.env.REMITS_CLI_SUPPORT_TICKET_LIMIT || 20);
|
|
51
|
+
const LISTENER_REGISTRATION_INTERVAL_MS = Number(process.env.REMITS_CLI_REGISTRATION_INTERVAL_MS || 5 * 60 * 1000);
|
|
52
|
+
const LISTENER_REGISTRATION_MIN_INTERVAL_MS = Number(process.env.REMITS_CLI_REGISTRATION_MIN_INTERVAL_MS || 5 * 60 * 1000);
|
|
48
53
|
const ACCOUNT_SCAN_EXCLUDE_DIRS = new Set([
|
|
49
54
|
'.git',
|
|
50
55
|
'.gradle',
|
|
@@ -74,7 +79,7 @@ const ACCOUNT_SCAN_EXCLUDE_DIRS = new Set([
|
|
|
74
79
|
|
|
75
80
|
// Flags that are legitimately repeatable accumulate into an array instead of last-wins. Every other
|
|
76
81
|
// flag keeps last-wins so existing callers are unaffected.
|
|
77
|
-
const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved']);
|
|
82
|
+
const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved', 'names']);
|
|
78
83
|
|
|
79
84
|
function parseArgs(argv) {
|
|
80
85
|
const out = { _: [] };
|
|
@@ -1273,6 +1278,156 @@ function discoverAccountRepos() {
|
|
|
1273
1278
|
};
|
|
1274
1279
|
}
|
|
1275
1280
|
|
|
1281
|
+
function listenerRegistrationRepoAccounts() {
|
|
1282
|
+
return listAccountRepoEntries(readAccountRepoIndex())
|
|
1283
|
+
.sort((a, b) => Number(a.accountId) - Number(b.accountId))
|
|
1284
|
+
.map((entry) => ({
|
|
1285
|
+
accountId: Number(entry.accountId),
|
|
1286
|
+
name: entry.name || null,
|
|
1287
|
+
type: entry.type || null,
|
|
1288
|
+
platformAccountId: entry.platformAccountId || null,
|
|
1289
|
+
routingScope: ['PLATFORM', 'PRODUCT'].includes(String(entry.type || '').toUpperCase())
|
|
1290
|
+
? 'self_and_descendants'
|
|
1291
|
+
: 'self',
|
|
1292
|
+
source: entry.source || null,
|
|
1293
|
+
updatedAt: entry.updatedAt || null
|
|
1294
|
+
}));
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function readListenerRegistrationState() {
|
|
1298
|
+
if (!fs.existsSync(LISTENER_REGISTRATION_STATE_FILE)) {
|
|
1299
|
+
return {};
|
|
1300
|
+
}
|
|
1301
|
+
try {
|
|
1302
|
+
const data = JSON.parse(fs.readFileSync(LISTENER_REGISTRATION_STATE_FILE, 'utf8'));
|
|
1303
|
+
return data && typeof data === 'object' ? data : {};
|
|
1304
|
+
} catch (_) {
|
|
1305
|
+
return {};
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function writeListenerRegistrationState(data) {
|
|
1310
|
+
ensureSessionDir();
|
|
1311
|
+
atomicWriteFile(LISTENER_REGISTRATION_STATE_FILE, JSON.stringify(data || {}, null, 2));
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function listenerRegistrationStateKey(session) {
|
|
1315
|
+
const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
|
|
1316
|
+
const userId = session.user && session.user.id ? session.user.id : 'unknown';
|
|
1317
|
+
return baseUrl + '|' + String(userId);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function serviceRegistrationState() {
|
|
1321
|
+
const state = readServiceState();
|
|
1322
|
+
return {
|
|
1323
|
+
running: isListenerRunning(),
|
|
1324
|
+
pid: state && state.pid ? state.pid : (fs.existsSync(LISTENER_PID_FILE) ? fs.readFileSync(LISTENER_PID_FILE, 'utf8').trim() : null),
|
|
1325
|
+
startedAt: state && state.startedAt ? state.startedAt : runtimeState.service.startedAt,
|
|
1326
|
+
dashboardUrl: state && state.dashboardUrl ? state.dashboardUrl : runtimeState.service.dashboardUrl
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function listenerRegistrationPayload(session, options = {}) {
|
|
1331
|
+
const service = serviceRegistrationState();
|
|
1332
|
+
return {
|
|
1333
|
+
client: 'remits-cli',
|
|
1334
|
+
reason: options.reason || 'heartbeat',
|
|
1335
|
+
baseUrl: normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL),
|
|
1336
|
+
accountId: Number(session.accountId),
|
|
1337
|
+
dataMode: normalizeDataMode(session.dataMode),
|
|
1338
|
+
websocketTopic: session.websocketTopic || (session.user && session.user.uuid ? String(session.user.uuid).replace(/-/g, '') : null),
|
|
1339
|
+
dashboardUrl: service.dashboardUrl || null,
|
|
1340
|
+
agent: getPreferredAgent(),
|
|
1341
|
+
service,
|
|
1342
|
+
repoAccounts: listenerRegistrationRepoAccounts()
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function listenerRegistrationSignature(payload) {
|
|
1347
|
+
return sha256(stableStringify({
|
|
1348
|
+
baseUrl: payload.baseUrl,
|
|
1349
|
+
accountId: payload.accountId,
|
|
1350
|
+
dataMode: payload.dataMode,
|
|
1351
|
+
websocketTopic: payload.websocketTopic,
|
|
1352
|
+
dashboardUrl: payload.dashboardUrl,
|
|
1353
|
+
agent: payload.agent,
|
|
1354
|
+
serviceRunning: payload.service && payload.service.running,
|
|
1355
|
+
repoAccounts: (payload.repoAccounts || []).map((entry) => ({
|
|
1356
|
+
accountId: entry.accountId,
|
|
1357
|
+
type: entry.type,
|
|
1358
|
+
routingScope: entry.routingScope,
|
|
1359
|
+
updatedAt: entry.updatedAt
|
|
1360
|
+
}))
|
|
1361
|
+
}));
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function shouldRegisterCliListener(session, payload, options = {}) {
|
|
1365
|
+
if (options.force === true) {
|
|
1366
|
+
return true;
|
|
1367
|
+
}
|
|
1368
|
+
const state = readListenerRegistrationState();
|
|
1369
|
+
const key = listenerRegistrationStateKey(session);
|
|
1370
|
+
const existing = state[key];
|
|
1371
|
+
if (!existing) {
|
|
1372
|
+
return true;
|
|
1373
|
+
}
|
|
1374
|
+
const lastRegisteredAtMs = Number(existing.lastRegisteredAtMs || 0);
|
|
1375
|
+
if (!lastRegisteredAtMs || Date.now() - lastRegisteredAtMs >= LISTENER_REGISTRATION_MIN_INTERVAL_MS) {
|
|
1376
|
+
return true;
|
|
1377
|
+
}
|
|
1378
|
+
return existing.signature !== listenerRegistrationSignature(payload);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function rememberCliListenerRegistration(session, payload) {
|
|
1382
|
+
const state = readListenerRegistrationState();
|
|
1383
|
+
const key = listenerRegistrationStateKey(session);
|
|
1384
|
+
state[key] = {
|
|
1385
|
+
baseUrl: payload.baseUrl,
|
|
1386
|
+
userId: session.user && session.user.id ? session.user.id : null,
|
|
1387
|
+
signature: listenerRegistrationSignature(payload),
|
|
1388
|
+
accountCount: Array.isArray(payload.repoAccounts) ? payload.repoAccounts.length : 0,
|
|
1389
|
+
lastRegisteredAt: new Date().toISOString(),
|
|
1390
|
+
lastRegisteredAtMs: Date.now()
|
|
1391
|
+
};
|
|
1392
|
+
writeListenerRegistrationState(state);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
async function registerCliListenerSession(session, options = {}) {
|
|
1396
|
+
if (!session || !session.token) {
|
|
1397
|
+
return null;
|
|
1398
|
+
}
|
|
1399
|
+
const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
|
|
1400
|
+
const payload = listenerRegistrationPayload(session, options);
|
|
1401
|
+
if (!shouldRegisterCliListener(session, payload, options)) {
|
|
1402
|
+
return { success: true, skipped: true, reason: 'recently_registered' };
|
|
1403
|
+
}
|
|
1404
|
+
const api = buildAxios(baseUrl, session.token, parsePositiveInt(options.timeoutMs, 5000));
|
|
1405
|
+
const response = await api.post('/cli/register', payload);
|
|
1406
|
+
rememberCliListenerRegistration(session, payload);
|
|
1407
|
+
return response.data;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
async function bestEffortRegisterCliListeners(options = {}) {
|
|
1411
|
+
const sessions = listSessionsFromStore(readAllSessions()).filter((session) => session && session.token);
|
|
1412
|
+
if (!sessions.length) {
|
|
1413
|
+
return [];
|
|
1414
|
+
}
|
|
1415
|
+
const results = await Promise.all(sessions.map(async (session) => {
|
|
1416
|
+
try {
|
|
1417
|
+
const data = await registerCliListenerSession(session, options);
|
|
1418
|
+
return { success: true, accountId: session.accountId, baseUrl: session.baseUrl, data };
|
|
1419
|
+
} catch (err) {
|
|
1420
|
+
appendGlobalActivityLog('listener', 'registration.failed', {
|
|
1421
|
+
accountId: session.accountId,
|
|
1422
|
+
baseUrl: normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL),
|
|
1423
|
+
error: describeError(err)
|
|
1424
|
+
}, 'error');
|
|
1425
|
+
return { success: false, accountId: session.accountId, baseUrl: session.baseUrl, error: describeError(err) };
|
|
1426
|
+
}
|
|
1427
|
+
}));
|
|
1428
|
+
return results;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1276
1431
|
function resolvePreferredAccountId(cwd, flags, session) {
|
|
1277
1432
|
// Explicit --account-id wins over the repo's account-info.json so users can
|
|
1278
1433
|
// target a child account (e.g. a customer) from a parent-account repo.
|
|
@@ -1572,6 +1727,42 @@ function changedComponentsFromWorkingTree(cwd) {
|
|
|
1572
1727
|
});
|
|
1573
1728
|
}
|
|
1574
1729
|
|
|
1730
|
+
/**
|
|
1731
|
+
* Components touched by commits between `ref` and HEAD.
|
|
1732
|
+
*
|
|
1733
|
+
* `changedComponentsFromWorkingTree` reads `git status`, i.e. UNCOMMITTED edits only — which is empty
|
|
1734
|
+
* at exactly the moment `--changed-only` is meant to run, because the documented flow is
|
|
1735
|
+
* commit -> push -> sync (sync reads the pushed remote, so it cannot see uncommitted work at all).
|
|
1736
|
+
* The gate was therefore unusable in the flow it exists to guard: it refused every plan with
|
|
1737
|
+
* "plan touches N component(s) this working tree did not edit". `--changed-since <ref>` supplies the
|
|
1738
|
+
* base so the same guard works after the commit.
|
|
1739
|
+
*/
|
|
1740
|
+
function changedComponentsSinceRef(cwd, ref) {
|
|
1741
|
+
let output = '';
|
|
1742
|
+
try {
|
|
1743
|
+
output = execSync('git diff --name-only ' + JSON.stringify(ref) + '...HEAD -- components', {
|
|
1744
|
+
cwd,
|
|
1745
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
1746
|
+
}).toString();
|
|
1747
|
+
} catch (_) {
|
|
1748
|
+
return null;
|
|
1749
|
+
}
|
|
1750
|
+
const byKey = new Map();
|
|
1751
|
+
for (const rawLine of output.split(/\r?\n/)) {
|
|
1752
|
+
const filePath = rawLine.trim();
|
|
1753
|
+
if (!filePath) continue;
|
|
1754
|
+
const info = componentPathInfo(cwd, filePath);
|
|
1755
|
+
if (!info) continue;
|
|
1756
|
+
if (!byKey.has(info.key)) {
|
|
1757
|
+
byKey.set(info.key, { type: info.type, id: info.id, name: info.name, fields: [], paths: [] });
|
|
1758
|
+
}
|
|
1759
|
+
const entry = byKey.get(info.key);
|
|
1760
|
+
if (!entry.fields.includes(info.field)) entry.fields.push(info.field);
|
|
1761
|
+
if (!entry.paths.includes(info.path)) entry.paths.push(info.path);
|
|
1762
|
+
}
|
|
1763
|
+
return Array.from(byKey.values());
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1575
1766
|
function parsePositiveInt(value, fallback) {
|
|
1576
1767
|
const parsed = Number(value);
|
|
1577
1768
|
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
@@ -1951,6 +2142,9 @@ async function authCommand(flags) {
|
|
|
1951
2142
|
});
|
|
1952
2143
|
console.log('Authenticated as user', session.user ? session.user.id : 'unknown', 'for account', session.accountId);
|
|
1953
2144
|
console.log('Data mode:', session.dataMode);
|
|
2145
|
+
try {
|
|
2146
|
+
await bestEffortRegisterCliListeners({ cwd, reason: 'auth' });
|
|
2147
|
+
} catch (_) { /* best-effort */ }
|
|
1954
2148
|
|
|
1955
2149
|
try {
|
|
1956
2150
|
const branchName = currentBranch(cwd);
|
|
@@ -2309,7 +2503,7 @@ async function syncComponentsCommand(flags) {
|
|
|
2309
2503
|
throw new Error(previewResponse.message || 'Server sync dry-run failed');
|
|
2310
2504
|
}
|
|
2311
2505
|
|
|
2312
|
-
preflightGate = evaluateSyncGates(previewResponse, flags, changedFromWorkingTree);
|
|
2506
|
+
preflightGate = evaluateSyncGates(previewResponse, flags, changedFromWorkingTree, cwd);
|
|
2313
2507
|
|
|
2314
2508
|
if (namesOnly) {
|
|
2315
2509
|
printSessionResolutionWarning(sessionContext);
|
|
@@ -2332,7 +2526,7 @@ async function syncComponentsCommand(flags) {
|
|
|
2332
2526
|
}
|
|
2333
2527
|
|
|
2334
2528
|
const summary = buildSyncSummary(response);
|
|
2335
|
-
const gate = preflightGate || evaluateSyncGates(response, flags, changedFromWorkingTree);
|
|
2529
|
+
const gate = preflightGate || evaluateSyncGates(response, flags, changedFromWorkingTree, cwd);
|
|
2336
2530
|
summary.gates = gate.checks;
|
|
2337
2531
|
|
|
2338
2532
|
if (flagEnabled(flags.json)) {
|
|
@@ -2505,7 +2699,7 @@ function parseExpectedRemoved(flags) {
|
|
|
2505
2699
|
}
|
|
2506
2700
|
|
|
2507
2701
|
// Fail-closed gates. Each returns a violation string or null; the command exits non-zero if any fire.
|
|
2508
|
-
function evaluateSyncGates(response, flags, changedFromWorkingTree) {
|
|
2702
|
+
function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
|
|
2509
2703
|
const entries = syncPlanEntries(response);
|
|
2510
2704
|
const results = ((response && response.sync) || {}).syncResults || {};
|
|
2511
2705
|
const removed = entries.filter((e) => e.bucket === 'removed');
|
|
@@ -2536,13 +2730,19 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree) {
|
|
|
2536
2730
|
}
|
|
2537
2731
|
|
|
2538
2732
|
if (flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly)) {
|
|
2539
|
-
|
|
2733
|
+
const since = flags['changed-since'] || flags.changedSince;
|
|
2734
|
+
const fromRef = since ? changedComponentsSinceRef(cwd, String(since)) : null;
|
|
2735
|
+
if (since && fromRef === null) {
|
|
2736
|
+
violations.push('--changed-since: could not diff against "' + since + '". Check the ref exists (git fetch first for a remote ref).');
|
|
2737
|
+
checks.changedOnly = false;
|
|
2738
|
+
} else if (changedFromWorkingTree === null && fromRef === null) {
|
|
2540
2739
|
violations.push('--changed-only: this checkout is not a git working tree, so the changed set cannot be established.');
|
|
2541
2740
|
checks.changedOnly = false;
|
|
2542
2741
|
} else {
|
|
2742
|
+
const changedSet = (changedFromWorkingTree || []).concat(fromRef || []);
|
|
2543
2743
|
// A component the checkout edited is identified by type + id, or type + name for `new_` files.
|
|
2544
|
-
const allowedIds = new Set(
|
|
2545
|
-
const allowedNames = new Set(
|
|
2744
|
+
const allowedIds = new Set(changedSet.filter((c) => c.id != null).map((c) => c.type + ':' + c.id));
|
|
2745
|
+
const allowedNames = new Set(changedSet.filter((c) => c.name).map((c) => c.type + ':' + String(c.name).toLowerCase()));
|
|
2546
2746
|
const unexpected = entries.filter((entry) => {
|
|
2547
2747
|
if (entry.id != null && allowedIds.has(entry.type + ':' + entry.id)) return false;
|
|
2548
2748
|
if (entry.name && allowedNames.has(entry.type + ':' + String(entry.name).toLowerCase())) return false;
|
|
@@ -2550,8 +2750,15 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree) {
|
|
|
2550
2750
|
});
|
|
2551
2751
|
checks.changedOnly = unexpected.length === 0;
|
|
2552
2752
|
if (unexpected.length) {
|
|
2553
|
-
|
|
2554
|
-
|
|
2753
|
+
// Name the most likely cause instead of only the symptom. An empty changed set with a non-empty
|
|
2754
|
+
// plan is the ordinary post-commit state, not evidence that the plan is dangerous.
|
|
2755
|
+
const hint = (!changedSet.length && !since)
|
|
2756
|
+
? '\n The changed set is EMPTY: --changed-only reads UNCOMMITTED edits, and the documented flow ' +
|
|
2757
|
+
'commits and pushes before syncing. Pass --changed-since <ref> (e.g. the commit you branched ' +
|
|
2758
|
+
'from, or origin/main) so the gate can see committed work.'
|
|
2759
|
+
: '';
|
|
2760
|
+
violations.push('--changed-only: plan touches ' + unexpected.length + ' component(s) this checkout did not change: ' +
|
|
2761
|
+
unexpected.slice(0, 20).map(syncEntryToken).join(', ') + (unexpected.length > 20 ? ', ...' : '') + hint);
|
|
2555
2762
|
}
|
|
2556
2763
|
}
|
|
2557
2764
|
}
|
|
@@ -2571,6 +2778,36 @@ function buildSyncSummary(response) {
|
|
|
2571
2778
|
const errors = Array.isArray(results.errors) ? results.errors : [];
|
|
2572
2779
|
const skipped = Array.isArray(results.skipped) ? results.skipped : [];
|
|
2573
2780
|
|
|
2781
|
+
// A sync that SHORT-CIRCUITED on the cached branch SHA did no work at all, and the server says so
|
|
2782
|
+
// (`skipped: true` plus a message). Rendering it through the normal shape below printed
|
|
2783
|
+
// `overridden: 0, added: 0, removed: []` — which is what a sync that ran and found nothing to do
|
|
2784
|
+
// prints, so the two were indistinguishable. That matters because the cache can hold a SHA whose
|
|
2785
|
+
// overlays were since removed by another path: an ordinary re-sync then reports zeros forever and
|
|
2786
|
+
// the operator has no reason to suspect the branch was never re-read. Report the skip as itself.
|
|
2787
|
+
// `syncResults` absent is the fallback signal, so this still reads correctly against a server that
|
|
2788
|
+
// predates the explicit `skipped` flag rather than silently falling back to the misleading zeros.
|
|
2789
|
+
const shortCircuited = sync.skipped === true
|
|
2790
|
+
|| (sync.syncResults == null && !!sync.message && sync.dryRun !== true);
|
|
2791
|
+
if (shortCircuited) {
|
|
2792
|
+
return {
|
|
2793
|
+
success: response && response.success === true,
|
|
2794
|
+
accountId: response && response.accountId,
|
|
2795
|
+
branchName: sync.branchName || (response && response.branchName),
|
|
2796
|
+
mode: sync.mode || 'variant',
|
|
2797
|
+
skipped: true,
|
|
2798
|
+
reason: sync.message || 'branch head matches the cached sync SHA; the branch was not re-read',
|
|
2799
|
+
branchHeadSha: sync.branchHeadSha || sync.postSyncSha || null,
|
|
2800
|
+
warnings: [
|
|
2801
|
+
// Scoped to COMPONENTS deliberately: the short-circuit still refreshes the delivered guides and
|
|
2802
|
+
// account metadata, so "nothing happened" would be its own small untruth.
|
|
2803
|
+
'NO COMPONENTS WERE SYNCED. This is the cached-SHA short-circuit, not an empty plan — the ' +
|
|
2804
|
+
'branch was never re-read.',
|
|
2805
|
+
'Use --dry-run to see the real plan (a dry run always re-reads the branch), or ' +
|
|
2806
|
+
'--force-tombstones to re-read and apply it.'
|
|
2807
|
+
]
|
|
2808
|
+
};
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2574
2811
|
// A TRUNK sync and a VARIANT sync report in different vocabularies, and this summary only ever spoke
|
|
2575
2812
|
// variant. So the single most consequential line of a promotion — "component X was CREATED on trunk as
|
|
2576
2813
|
// id N" — printed as `added: 0`, and the runbook's definition of done ("components created, with their
|
|
@@ -3153,7 +3390,26 @@ async function testCommand(flags) {
|
|
|
3153
3390
|
throw new Error('Missing --test <id-or-name>');
|
|
3154
3391
|
}
|
|
3155
3392
|
|
|
3156
|
-
|
|
3393
|
+
// Test case names are English prose, so COMMAS occur in them naturally. A comma-delimited selector
|
|
3394
|
+
// therefore split a legitimate name in half, matched nothing, and (before the server learned to
|
|
3395
|
+
// report unmatched selectors) reported a clean run of zero cases. `|` is the delimiter now; the
|
|
3396
|
+
// comma still works when no `|` is present, so existing invocations keep behaving as before.
|
|
3397
|
+
// `--names` may also be repeated, which needs no delimiter at all.
|
|
3398
|
+
const rawNames = flags.names === undefined || flags.names === null
|
|
3399
|
+
? []
|
|
3400
|
+
: (Array.isArray(flags.names) ? flags.names : [flags.names]);
|
|
3401
|
+
const repeated = rawNames.length > 1;
|
|
3402
|
+
const names = rawNames
|
|
3403
|
+
.flatMap((value) => {
|
|
3404
|
+
const text = String(value);
|
|
3405
|
+
if (text.includes('|')) return text.split('|');
|
|
3406
|
+
// Comma-splitting is the legacy behaviour, kept so existing invocations still work. It is applied
|
|
3407
|
+
// ONLY to a single --names value: repeating the flag is already unambiguous, so splitting there
|
|
3408
|
+
// would take a deliberate literal name back apart.
|
|
3409
|
+
return repeated ? [text] : text.split(',');
|
|
3410
|
+
})
|
|
3411
|
+
.map((s) => s.trim())
|
|
3412
|
+
.filter(Boolean);
|
|
3157
3413
|
|
|
3158
3414
|
const api = buildAxios(baseUrl, session.token);
|
|
3159
3415
|
// --as-account runs the Test AS a (descendant) subscriber account, so that account's relationship
|
|
@@ -3209,6 +3465,23 @@ async function testCommand(flags) {
|
|
|
3209
3465
|
}
|
|
3210
3466
|
|
|
3211
3467
|
console.log('Final status:', JSON.stringify(status, null, 2));
|
|
3468
|
+
|
|
3469
|
+
// A selector that matched no case is a mis-specified run, not a passing one. Say so in the terminal
|
|
3470
|
+
// and exit non-zero, or "0 passed, 0 failed" reads exactly like a suite where everything passed.
|
|
3471
|
+
const unmatched = (status.result && status.result.unmatchedTestNames) || [];
|
|
3472
|
+
if (unmatched.length) {
|
|
3473
|
+
console.error('');
|
|
3474
|
+
console.error('No test case matched: ' + unmatched.map((n) => '"' + n + '"').join(', '));
|
|
3475
|
+
const declared = (status.result && status.result.declaredTestNames) || [];
|
|
3476
|
+
if (declared.length) {
|
|
3477
|
+
console.error('Cases declared by this suite:');
|
|
3478
|
+
declared.forEach((n) => console.error(' - ' + n));
|
|
3479
|
+
}
|
|
3480
|
+
console.error('--names is delimited by "|" (a comma still works when no "|" is present), so a case');
|
|
3481
|
+
console.error('name containing a comma must be passed with "|" or by repeating --names.');
|
|
3482
|
+
process.exitCode = 1;
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3212
3485
|
if (status.status !== 'completed') {
|
|
3213
3486
|
process.exitCode = 1;
|
|
3214
3487
|
} else if (status.result && status.result.failed > 0) {
|
|
@@ -3912,6 +4185,10 @@ let runtimeState = {
|
|
|
3912
4185
|
websocket: {},
|
|
3913
4186
|
lastAction: null
|
|
3914
4187
|
};
|
|
4188
|
+
let supportTicketSnapshotCache = {
|
|
4189
|
+
fetchedAtMs: 0,
|
|
4190
|
+
value: null
|
|
4191
|
+
};
|
|
3915
4192
|
|
|
3916
4193
|
function listenerSessionEntriesFromStore(store) {
|
|
3917
4194
|
return listSessionsFromStore(store).filter((session) => session && session.token && session.websocketTopic);
|
|
@@ -4299,6 +4576,7 @@ function exportIssueBundle(snapshot) {
|
|
|
4299
4576
|
copyFileIfExists(SESSIONS_FILE, path.join(globalDir, 'sessions.json'));
|
|
4300
4577
|
copyFileIfExists(CONFIG_FILE, path.join(globalDir, 'config.json'));
|
|
4301
4578
|
copyFileIfExists(SERVICE_STATE_FILE, path.join(globalDir, 'service-state.json'));
|
|
4579
|
+
copyFileIfExists(LISTENER_REGISTRATION_STATE_FILE, path.join(globalDir, 'listener-registration-state.json'));
|
|
4302
4580
|
copyFileIfExists(ACCOUNT_REPO_INDEX_FILE, path.join(globalDir, 'account-repos.json'));
|
|
4303
4581
|
copyFileIfExists(DISPATCH_STATE_FILE, path.join(globalDir, 'dispatch-panes.json'));
|
|
4304
4582
|
copyFileIfExists(TMUX_ACTIVITY_LOG_FILE, path.join(globalDir, 'tmux-activity.log'));
|
|
@@ -4340,6 +4618,7 @@ function buildGlobalFileSnapshot() {
|
|
|
4340
4618
|
{ label: 'config.json', path: CONFIG_FILE, mode: 'json' },
|
|
4341
4619
|
{ label: 'service-state.json', path: SERVICE_STATE_FILE, mode: 'json' },
|
|
4342
4620
|
{ label: 'websocket-state.json', path: WEBSOCKET_STATE_FILE, mode: 'json' },
|
|
4621
|
+
{ label: 'listener-registration-state.json', path: LISTENER_REGISTRATION_STATE_FILE, mode: 'json' },
|
|
4343
4622
|
{ label: 'dispatch-panes.json', path: DISPATCH_STATE_FILE, mode: 'json' },
|
|
4344
4623
|
{ label: 'account-repos.json', path: ACCOUNT_REPO_INDEX_FILE, mode: 'json' },
|
|
4345
4624
|
{ label: 'tmux-activity.log (tail)', path: TMUX_ACTIVITY_LOG_FILE, mode: 'tail' }
|
|
@@ -4396,7 +4675,140 @@ function buildRepoSnapshot(entry) {
|
|
|
4396
4675
|
};
|
|
4397
4676
|
}
|
|
4398
4677
|
|
|
4399
|
-
function
|
|
4678
|
+
function compactSupportTicketRow(row) {
|
|
4679
|
+
if (!row || typeof row !== 'object') {
|
|
4680
|
+
return null;
|
|
4681
|
+
}
|
|
4682
|
+
return {
|
|
4683
|
+
ticketId: row.ticketId || row.id || null,
|
|
4684
|
+
accountId: row.accountId || row.account_id || null,
|
|
4685
|
+
accountName: row.accountName || null,
|
|
4686
|
+
subject: row.subject || null,
|
|
4687
|
+
priority: row.priority || null,
|
|
4688
|
+
status: row.status || null,
|
|
4689
|
+
type: row.type || null,
|
|
4690
|
+
source: row.source || null,
|
|
4691
|
+
assignedTo: row.assignedTo || null,
|
|
4692
|
+
implementationAccountId: row.implementationAccountId || null,
|
|
4693
|
+
implementationAccountName: row.implementationAccountName || null,
|
|
4694
|
+
affectedComponent: row.affectedComponent || null,
|
|
4695
|
+
updatedAt: row.updatedAt || row.updated || row.lastActivityAt || null,
|
|
4696
|
+
activityAt: row.activityAt || row.lastActivityAt || null,
|
|
4697
|
+
activityLabel: row.activityLabel || null
|
|
4698
|
+
};
|
|
4699
|
+
}
|
|
4700
|
+
|
|
4701
|
+
function supportTicketQueueInput(session) {
|
|
4702
|
+
return {
|
|
4703
|
+
action: 'list',
|
|
4704
|
+
accountId: Number(session.accountId),
|
|
4705
|
+
includeChildren: true,
|
|
4706
|
+
includeRoot: true,
|
|
4707
|
+
sortBy: 'triage',
|
|
4708
|
+
limit: Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)
|
|
4709
|
+
};
|
|
4710
|
+
}
|
|
4711
|
+
|
|
4712
|
+
async function fetchSupportTicketQueueForSession(session) {
|
|
4713
|
+
const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
|
|
4714
|
+
const api = buildAxios(baseUrl, session.token, 12000);
|
|
4715
|
+
const response = await api.post('/cli/tool', {
|
|
4716
|
+
token: session.token,
|
|
4717
|
+
accountId: Number(session.accountId),
|
|
4718
|
+
branchName: '',
|
|
4719
|
+
dataMode: 'prod',
|
|
4720
|
+
name: 'mcp_support_ticket_queue',
|
|
4721
|
+
callId: 'dashboard-support-ticket-queue-' + crypto.randomUUID(),
|
|
4722
|
+
input: supportTicketQueueInput(session)
|
|
4723
|
+
}).then((r) => r.data);
|
|
4724
|
+
|
|
4725
|
+
const result = response && response.result ? response.result : {};
|
|
4726
|
+
const tickets = Array.isArray(result.tickets)
|
|
4727
|
+
? result.tickets.map(compactSupportTicketRow).filter(Boolean)
|
|
4728
|
+
: [];
|
|
4729
|
+
return {
|
|
4730
|
+
success: response && response.success !== false && response.toolSuccess !== false && result.success !== false,
|
|
4731
|
+
baseUrl,
|
|
4732
|
+
accountId: Number(session.accountId),
|
|
4733
|
+
userId: session.user && session.user.id ? session.user.id : null,
|
|
4734
|
+
dataMode: result.dataMode || response.dataMode || 'prod',
|
|
4735
|
+
totalCount: result.totalCount || tickets.length,
|
|
4736
|
+
count: result.count || tickets.length,
|
|
4737
|
+
counts: result.counts || {},
|
|
4738
|
+
recommendedTicket: compactSupportTicketRow(result.recommendedTicket),
|
|
4739
|
+
tickets,
|
|
4740
|
+
searchedAccounts: result.searchedAccounts || [],
|
|
4741
|
+
message: response.toolMessage || result.message || null
|
|
4742
|
+
};
|
|
4743
|
+
}
|
|
4744
|
+
|
|
4745
|
+
async function collectSupportTicketSnapshot() {
|
|
4746
|
+
const nowMs = Date.now();
|
|
4747
|
+
if (supportTicketSnapshotCache.value &&
|
|
4748
|
+
nowMs - supportTicketSnapshotCache.fetchedAtMs < SUPPORT_TICKET_DASHBOARD_REFRESH_MS) {
|
|
4749
|
+
return supportTicketSnapshotCache.value;
|
|
4750
|
+
}
|
|
4751
|
+
|
|
4752
|
+
const sessions = listSessionsFromStore(readAllSessions())
|
|
4753
|
+
.filter((session) => session && session.token && session.accountId);
|
|
4754
|
+
const seen = new Set();
|
|
4755
|
+
const queueSessions = sessions.filter((session) => {
|
|
4756
|
+
const key = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL) + '|' + String(session.accountId) + '|' +
|
|
4757
|
+
String(session.user && session.user.id ? session.user.id : '');
|
|
4758
|
+
if (seen.has(key)) {
|
|
4759
|
+
return false;
|
|
4760
|
+
}
|
|
4761
|
+
seen.add(key);
|
|
4762
|
+
return true;
|
|
4763
|
+
}).slice(0, 12);
|
|
4764
|
+
|
|
4765
|
+
const queues = [];
|
|
4766
|
+
const errors = [];
|
|
4767
|
+
await Promise.all(queueSessions.map(async (session) => {
|
|
4768
|
+
try {
|
|
4769
|
+
queues.push(await fetchSupportTicketQueueForSession(session));
|
|
4770
|
+
} catch (err) {
|
|
4771
|
+
errors.push({
|
|
4772
|
+
baseUrl: normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL),
|
|
4773
|
+
accountId: Number(session.accountId),
|
|
4774
|
+
userId: session.user && session.user.id ? session.user.id : null,
|
|
4775
|
+
message: describeError(err)
|
|
4776
|
+
});
|
|
4777
|
+
}
|
|
4778
|
+
}));
|
|
4779
|
+
|
|
4780
|
+
const ticketsById = {};
|
|
4781
|
+
for (const queue of queues) {
|
|
4782
|
+
for (const ticket of queue.tickets || []) {
|
|
4783
|
+
const key = String(ticket.ticketId || '');
|
|
4784
|
+
if (!key || ticketsById[key]) {
|
|
4785
|
+
continue;
|
|
4786
|
+
}
|
|
4787
|
+
ticketsById[key] = {
|
|
4788
|
+
...ticket,
|
|
4789
|
+
queueAccountId: queue.accountId,
|
|
4790
|
+
baseUrl: queue.baseUrl
|
|
4791
|
+
};
|
|
4792
|
+
}
|
|
4793
|
+
}
|
|
4794
|
+
|
|
4795
|
+
const value = {
|
|
4796
|
+
fetchedAt: new Date(nowMs).toISOString(),
|
|
4797
|
+
refreshMs: SUPPORT_TICKET_DASHBOARD_REFRESH_MS,
|
|
4798
|
+
queueCount: queues.length,
|
|
4799
|
+
totalCount: queues.reduce((sum, queue) => sum + Number(queue.totalCount || 0), 0),
|
|
4800
|
+
tickets: Object.values(ticketsById).slice(0, Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)),
|
|
4801
|
+
queues,
|
|
4802
|
+
errors
|
|
4803
|
+
};
|
|
4804
|
+
supportTicketSnapshotCache = {
|
|
4805
|
+
fetchedAtMs: nowMs,
|
|
4806
|
+
value
|
|
4807
|
+
};
|
|
4808
|
+
return value;
|
|
4809
|
+
}
|
|
4810
|
+
|
|
4811
|
+
async function collectDashboardSnapshot() {
|
|
4400
4812
|
const accountRepoIndex = readAccountRepoIndex();
|
|
4401
4813
|
const repoEntries = listAccountRepoEntries(accountRepoIndex)
|
|
4402
4814
|
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')))
|
|
@@ -4451,6 +4863,7 @@ function collectDashboardSnapshot() {
|
|
|
4451
4863
|
globalFiles,
|
|
4452
4864
|
repos: repoEntries,
|
|
4453
4865
|
platformRepo,
|
|
4866
|
+
supportTickets: await collectSupportTicketSnapshot(),
|
|
4454
4867
|
issues,
|
|
4455
4868
|
lastAction: runtimeState.lastAction
|
|
4456
4869
|
};
|
|
@@ -4506,6 +4919,9 @@ function renderDashboardHtml(snapshot) {
|
|
|
4506
4919
|
'<div id="last-action" class="empty">No dashboard actions have been triggered yet.</div>' +
|
|
4507
4920
|
'</section>' +
|
|
4508
4921
|
'</div>' +
|
|
4922
|
+
'<div class="section-grid">' +
|
|
4923
|
+
'<section class="panel"><h2>Support Tickets</h2><div id="support-ticket-section" class="accordion"></div></section>' +
|
|
4924
|
+
'</div>' +
|
|
4509
4925
|
'<div class="section-grid">' +
|
|
4510
4926
|
'<section class="panel"><h2>Issues & Escalation</h2><div id="issues-section" class="accordion"></div></section>' +
|
|
4511
4927
|
'</div>' +
|
|
@@ -4536,9 +4952,10 @@ function renderDashboardHtml(snapshot) {
|
|
|
4536
4952
|
'function summaryItem(key,value){return \'<div class="summary-item"><span class="k">\'+escapeHtml(key)+\'</span><span class="v">\'+escapeHtml(value)+\'</span></div>\';}' +
|
|
4537
4953
|
'function codeBlock(key,content,extraClass){return \'<pre class="code-block \'+(extraClass||"")+\'" data-scroll-key="\'+escapeHtml(key)+\'">\'+escapeHtml(content||"[missing]")+\'</pre>\';}' +
|
|
4538
4954
|
'function detailsBlock(key,title,meta,bodyHtml,openByDefault){return \'<details class="item" data-key="\'+escapeHtml(key)+\'"\'+(openByDefault?" open":"")+\'><summary><div class="summary-main"><div class="summary-text"><span class="summary-title">\'+escapeHtml(title)+\'</span><span class="summary-meta">\'+meta+\'</span></div></div><span class="summary-caret">›</span></summary><div class="item-body">\'+bodyHtml+\'</div></details>\';}' +
|
|
4539
|
-
'function renderStatusCards(state){const wsValues=Object.values((state.websocket&&state.websocket.connections)||{});const connectedCount=wsValues.filter(function(entry){return entry.status==="connected";}).length;const warnCount=wsValues.filter(function(entry){return entry.status==="connecting";}).length;const errorCount=wsValues.filter(function(entry){return entry.status==="error"||entry.status==="disconnected";}).length;const repoCount=(state.repos||[]).length;const issueCount=(state.issues||[]).length;const criticalIssueCount=(state.issues||[]).filter(function(issue){return issue.severity==="bad";}).length;const tmuxHealthy=state.tmux&&state.tmux.available&&state.tmux.sessionExists;const cards=[' +
|
|
4955
|
+
'function renderStatusCards(state){const wsValues=Object.values((state.websocket&&state.websocket.connections)||{});const connectedCount=wsValues.filter(function(entry){return entry.status==="connected";}).length;const warnCount=wsValues.filter(function(entry){return entry.status==="connecting";}).length;const errorCount=wsValues.filter(function(entry){return entry.status==="error"||entry.status==="disconnected";}).length;const repoCount=(state.repos||[]).length;const issueCount=(state.issues||[]).length;const criticalIssueCount=(state.issues||[]).filter(function(issue){return issue.severity==="bad";}).length;const ticketState=state.supportTickets||{};const ticketErrors=(ticketState.errors||[]).length;const ticketTotal=ticketState.totalCount||0;const tmuxHealthy=state.tmux&&state.tmux.available&&state.tmux.sessionExists;const cards=[' +
|
|
4540
4956
|
'{title:"Service",value:state.service&&state.service.running?"Running":"Stopped",label:"Daemon status",tone:statusTone(state.service&&state.service.running),sub:(state.service&&state.service.dashboardUrl?state.service.dashboardUrl:"No dashboard URL yet")},' +
|
|
4541
4957
|
'{title:"Websockets",value:String(connectedCount)+"/"+String(wsValues.length||0),label:"Connected topics groups",tone:errorCount? "bad" : (warnCount? "warn" : "ok"),sub: errorCount? String(errorCount)+" unhealthy connection(s)" : (warnCount? String(warnCount)+" connecting" : "All connections healthy")},' +
|
|
4958
|
+
'{title:"Tickets",value:String(ticketTotal),label:"Open support queue",tone:ticketErrors?"warn":"ok",sub:ticketErrors?String(ticketErrors)+" queue fetch error(s)":"Fetched "+String(ticketState.queueCount||0)+" queue(s)"},' +
|
|
4542
4959
|
'{title:"Tmux",value:tmuxHealthy?"Ready":"Attention",label:"Dispatch workspace",tone:tmuxHealthy?"ok":(state.tmux&&state.tmux.available?"warn":"bad"),sub:state.tmux&&state.tmux.available?(state.tmux.sessionExists?String((state.tmux.livePanes||[]).length)+" live pane(s)":"tmux installed, session missing"):"tmux not installed"},' +
|
|
4543
4960
|
'{title:"Issues",value:String(issueCount),label:"Detected escalation signals",tone:criticalIssueCount?"bad":(issueCount?"warn":"ok"),sub:issueCount?(criticalIssueCount?String(criticalIssueCount)+" critical signal(s)":"Review before continuing"):"No issue signals detected"},' +
|
|
4544
4961
|
'{title:"Repos",value:String(repoCount),label:"Indexed account repos",tone:repoCount?"ok":"warn",sub:"Last scan: "+formatTime(state.discovery&&state.discovery.scannedAt)}' +
|
|
@@ -4549,15 +4966,18 @@ function renderDashboardHtml(snapshot) {
|
|
|
4549
4966
|
'summaryItem("Repo Matches",String(state.discovery&&state.discovery.matches||0)),' +
|
|
4550
4967
|
'summaryItem("Connections",String(ws.length)),' +
|
|
4551
4968
|
'summaryItem("Topics",String(topics)),' +
|
|
4969
|
+
'summaryItem("Open Tickets",String(state.supportTickets&&state.supportTickets.totalCount||0)),' +
|
|
4552
4970
|
'summaryItem("Tracked Panes",String(tracked))' +
|
|
4553
4971
|
'].join("");}' +
|
|
4554
4972
|
'function renderLastAction(state){const target=document.getElementById("last-action");if(!state.lastAction){target.className="empty";target.textContent="No dashboard actions have been triggered yet.";return;}target.className="summary-item";target.innerHTML=\'<span class="k">Last Action</span><span class="v">\'+escapeHtml(state.lastAction.action||"unknown")+\'</span><div class="muted" style="margin-top:8px;">\'+escapeHtml(formatTime(state.lastAction.at))+\'</div>\'+(state.lastAction.bundlePath?\'<div class="muted" style="margin-top:8px;word-break:break-word;"><strong>Bundle:</strong> \'+escapeHtml(state.lastAction.bundlePath)+\'</div>\':"");}' +
|
|
4555
4973
|
'function renderIssues(state){const target=document.getElementById("issues-section");const issues=state.issues||[];if(!issues.length){target.innerHTML=\'<div class="empty">No automatic issue signals are currently detected. If something still feels wrong, you can still click "Export Issue Bundle" to prepare a package for a Remits system admin.</div>\';return;}target.innerHTML=issues.map(function(issue,index){const tone=issue.severity==="bad"?"bad":"warn";const meta=badge(issue.severity==="bad"?"critical":"warning",tone)+\' <span class="muted">\'+escapeHtml(issue.scope+(issue.repoAccountId?(" account "+issue.repoAccountId):""))+\'</span>\';const evidence=(issue.evidence||[]).map(function(line){return "<li>"+escapeHtml(line)+"</li>";}).join("");const body=\'<div class="stack"><div class="muted">\'+escapeHtml(issue.summary||"")+\'</div><div class="mini-card"><strong>Recommendation</strong>\'+escapeHtml(issue.recommendation||"") + \'</div><div><strong style="display:block;margin-bottom:8px;">Evidence</strong><ul style="margin:0;padding-left:18px;display:grid;gap:8px;">\'+(evidence||"<li>No evidence lines captured.</li>")+\'</ul></div></div>\';return detailsBlock("issue:"+issue.id,(index+1)+". "+issue.title,meta,body,index===0);}).join("");}' +
|
|
4974
|
+
'function renderSupportTickets(state){const target=document.getElementById("support-ticket-section");const support=state.supportTickets||{};const tickets=support.tickets||[];const queues=support.queues||[];const errors=support.errors||[];let html=\'<div class="mini-grid"><div class="mini-card"><strong>Total Open</strong>\'+escapeHtml(String(support.totalCount||0))+\'</div><div class="mini-card"><strong>Queues</strong>\'+escapeHtml(String(support.queueCount||0))+\'</div><div class="mini-card"><strong>Fetched</strong>\'+escapeHtml(formatTime(support.fetchedAt))+\'</div></div>\';if(errors.length){html+=detailsBlock("support:errors","Queue fetch errors",badge(String(errors.length)+" error(s)","warn"),codeBlock("support-errors",JSON.stringify(errors,null,2),"log"),false);}if(!queues.length&&!errors.length){target.innerHTML=html+\'<div class="empty">No authenticated support-ticket queue is available yet.</div>\';return;}const recs=queues.map(function(queue){return queue.recommendedTicket?Object.assign({queueAccountId:queue.accountId,baseUrl:queue.baseUrl},queue.recommendedTicket):null;}).filter(Boolean);if(recs.length){const recBody=recs.map(function(ticket,index){return supportTicketDetails(ticket,"support-rec:"+index);}).join("");html+=detailsBlock("support:recommended","Recommended next tickets",badge(String(recs.length)+" queue(s)","ok"),recBody,true);}if(tickets.length){html+=tickets.map(function(ticket,index){const meta=badge(ticket.priority||"priority","warn")+\' \'+badge(ticket.status||"status",ticket.status==="open"?"ok":"warn")+\' <span class="muted">\'+escapeHtml(ticket.accountName||ticket.accountId||"unknown account")+\'</span>\';return detailsBlock("support-ticket:"+ticket.ticketId,String(ticket.ticketId||"ticket")+" "+(ticket.subject||""),meta,supportTicketDetails(ticket,"support-ticket:"+index),index===0&&!recs.length);}).join("");}else{html+=\'<div class="empty">No open support tickets were returned by the current queues.</div>\';}target.innerHTML=html;}' +
|
|
4975
|
+
'function supportTicketDetails(ticket,key){const command="remits-cli tool --name mcp_support_ticket --input "+JSON.stringify(JSON.stringify({action:"read",ticketId:String(ticket.ticketId||"")}))+" --data-mode prod";return \'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(ticket.ticketId||"n/a")+\'</div><div class="mini-card"><strong>Account</strong>\'+escapeHtml((ticket.accountName||"n/a")+" ("+(ticket.accountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Type</strong>\'+escapeHtml(ticket.type||"n/a")+\'</div><div class="mini-card"><strong>Assigned</strong>\'+escapeHtml(ticket.assignedTo||"unassigned")+\'</div><div class="mini-card"><strong>Implementation</strong>\'+escapeHtml((ticket.implementationAccountName||"n/a")+" ("+(ticket.implementationAccountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Activity</strong>\'+escapeHtml(ticket.activityLabel||formatTime(ticket.activityAt||ticket.updatedAt))+\'</div></div><div class="muted">\'+escapeHtml(ticket.affectedComponent||"No affected component recorded.")+\'</div>\'+codeBlock(key+":cmd",command)+\'</div>\';}' +
|
|
4556
4976
|
'function renderWebsockets(state){const entries=Object.entries((state.websocket&&state.websocket.connections)||{});const target=document.getElementById("websocket-section");if(!entries.length){target.innerHTML=\'<div class="empty">No websocket connections are registered yet.</div>\';return;}target.innerHTML=entries.map(function(pair){const baseUrl=pair[0];const info=pair[1]||{};const topicEntries=Object.entries(info.topics||{});const recentMessages=Array.isArray(info.recentMessages)?info.recentMessages:[];const tone=info.status==="connected"?"ok":(info.status==="connecting"?"warn":"bad");const summaryMeta=badge(info.status||"unknown",tone)+\' <span class="muted">\' + escapeHtml(String(topicEntries.length)) + \' topic(s)</span>\' + \' <span class="muted">\' + escapeHtml(String(recentMessages.length)) + \' recent message(s)</span>\';const topicBody=topicEntries.length?topicEntries.map(function(topicPair){const topic=topicPair[0];const topicInfo=topicPair[1]||{};const mini=\'<div class="mini-grid"><div class="mini-card"><strong>Accounts</strong>\'+escapeHtml((topicInfo.accountIds||[]).join(", ")||"n/a")+\'</div><div class="mini-card"><strong>Subscribed</strong>\'+escapeHtml(formatTime(topicInfo.subscribedAt))+\'</div><div class="mini-card"><strong>Last Message</strong>\'+escapeHtml(formatTime(topicInfo.lastMessageAt))+\'</div></div>\';return detailsBlock("ws-topic:"+baseUrl+":"+topic,topic,"topic subscription",mini,false);}).join(""):\'<div class="empty">No topic subscriptions recorded.</div>\';const messageBody=recentMessages.length?recentMessages.map(function(message,index){const messageMeta=badge(message.duplicate?"duplicate":"handled",message.duplicate?"warn":"ok")+\' <span class="muted">\'+escapeHtml(message.source||"live")+\'</span>\' + \' <span class="muted">\'+escapeHtml(message.type||"unknown-type")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Message ID</strong>\'+escapeHtml(message.messageId||"n/a")+\'</div><div class="mini-card"><strong>Timestamp</strong>\'+escapeHtml(message.timestamp?String(message.timestamp):"n/a")+\'</div><div class="mini-card"><strong>Received</strong>\'+escapeHtml(formatTime(message.receivedAt))+\'</div><div class="mini-card"><strong>Accounts</strong>\'+escapeHtml((message.accountIds||[]).join(", ")||"n/a")+\'</div></div>\'+codeBlock("ws-msg:"+baseUrl+":"+(message.messageId||index),JSON.stringify(message.payload||{},null,2)) + \'</div>\';return detailsBlock("ws-message:"+baseUrl+":"+(message.messageId||index),(message.type||"message")+" "+(message.messageId||""),messageMeta,body,false);}).join(""):\'<div class="empty">No recent websocket messages have been recorded yet.</div>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Status</strong>\'+escapeHtml(info.status||"unknown")+\'</div><div class="mini-card"><strong>Connected</strong>\'+escapeHtml(formatTime(info.connectedAt))+\'</div><div class="mini-card"><strong>Last Message</strong>\'+escapeHtml(formatTime(info.lastMessageAt))+\'</div><div class="mini-card"><strong>Last Error</strong>\'+escapeHtml(info.lastError||"none")+\'</div></div><div class="stack">\'+detailsBlock("ws-recent:"+baseUrl,"Recent messages","latest live and missed-message traffic",messageBody,false)+topicBody+\'</div></div>\';return detailsBlock("ws:"+baseUrl,baseUrl,summaryMeta,body,entries.length===1);}).join("");}' +
|
|
4557
4977
|
'function renderTmux(state){const target=document.getElementById("tmux-section");const panes=state.tmux&&state.tmux.livePanes||[];const trackedText=state.tmux&&state.tmux.trackedPanes?state.tmux.trackedPanes:"{}";const launchSupported=state.tmux&&state.tmux.terminalLaunchSupported;const attachCommand="tmux attach -t "+(state.tmux&&state.tmux.sessionName||"remits-listener");const health=\'<div class="mini-grid"><div class="mini-card"><strong>tmux Installed</strong>\'+escapeHtml(String(state.tmux&&state.tmux.available))+\'</div><div class="mini-card"><strong>Session Exists</strong>\'+escapeHtml(String(state.tmux&&state.tmux.sessionExists))+\'</div><div class="mini-card"><strong>Session Name</strong>\'+escapeHtml(state.tmux&&state.tmux.sessionName||"n/a")+\'</div><div class="mini-card"><strong>Live Panes</strong>\'+escapeHtml(String(panes.length))+\'</div><div class="mini-card"><strong>Open from Dashboard</strong>\'+escapeHtml(launchSupported?"supported":"not supported")+\'</div></div><div class="muted" style="margin-bottom:14px;">\'+escapeHtml(launchSupported?"Use the Open Tmux Terminal button to bring Terminal forward and attach automatically.":"Dashboard-to-terminal launch is not supported on this platform. Run the attach command manually.")+\'</div>\';const quickAttach=detailsBlock("tmux:attach","Quick attach command","manual fallback",codeBlock("tmux-attach-cmd",attachCommand),false);const paneSection=detailsBlock("tmux:panes","Live panes",escapeHtml(String(panes.length))+" pane(s)",codeBlock("tmux-live-panes",JSON.stringify(panes,null,2),"log"),false);const trackedSection=detailsBlock("tmux:tracked","Tracked pane routing","ticket/task to pane mapping",codeBlock("tmux-tracked",trackedText),false);const logFile=(state.globalFiles||[]).find(function(file){return file.label==="tmux-activity.log (tail)";});const logSection=detailsBlock("tmux:activity","Activity tail","stream of tmux and websocket events",codeBlock("tmux-log",logFile&&logFile.content||"[missing]","log"),true);target.innerHTML=health+quickAttach+paneSection+trackedSection+logSection;const openButton=document.querySelector(\'[data-action="open-tmux"]\');if(openButton){openButton.disabled=!launchSupported||!(state.tmux&&state.tmux.available);}}' +
|
|
4558
4978
|
'function renderGlobalFiles(state){const target=document.getElementById("global-files");const files=(state.globalFiles||[]).filter(function(file){return file.label!=="tmux-activity.log (tail)";});if(!files.length){target.innerHTML=\'<div class="empty">No global files found.</div>\';return;}target.innerHTML=files.map(function(file){const tone=file.exists?"ok":"warn";const meta=badge(file.exists?"present":"missing",tone)+\' <span class="muted">\'+escapeHtml(file.updatedAt?formatTime(file.updatedAt):"not found")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Path</strong>\'+escapeHtml(file.path||"")+\'</div><div class="mini-card"><strong>Size</strong>\'+escapeHtml(String(file.size||0))+\' bytes</div><div class="mini-card"><strong>Updated</strong>\'+escapeHtml(formatTime(file.updatedAt))+\'</div></div>\'+codeBlock("global:"+file.label,file.content)+(file.label==="service-state.json"?\'<div class="muted">Use this file to discover the dashboard URL and runtime websocket summary.</div>\':"")+\'</div>\';return detailsBlock("global:"+file.label,file.label,meta,body,false);}).join("");}' +
|
|
4559
4979
|
'function renderRepos(state){const target=document.getElementById("repo-section");const repos=state.repos||[];if(!repos.length){target.innerHTML=\'<div class="empty">No indexed account repos were found. Use "Rescan Account Repos" to rebuild the index.</div>\';return;}target.innerHTML=repos.map(function(repo){const tone=repo.exists?"ok":"warn";const meta=badge(repo.exists?"available":"missing",tone)+\' <span class="muted">type: \'+escapeHtml(repo.type||"n/a")+\'</span>\';const files=(repo.files||[]).map(function(file){const fileMeta=badge(file.exists?"present":"missing",file.exists?"ok":"warn")+\' <span class="muted">\'+escapeHtml(file.updatedAt?formatTime(file.updatedAt):"not found")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Path</strong>\'+escapeHtml(file.path||"")+\'</div><div class="mini-card"><strong>Size</strong>\'+escapeHtml(String(file.size||0))+\' bytes</div><div class="mini-card"><strong>Updated</strong>\'+escapeHtml(formatTime(file.updatedAt))+\'</div></div>\'+codeBlock("repo:"+repo.accountId+":"+file.label,file.content,file.label.indexOf("session log")>=0?"log":"")+\'</div>\';return detailsBlock("repo-file:"+repo.accountId+":"+file.label,file.label,fileMeta,body,false);}).join("");const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Directory</strong>\'+escapeHtml(repo.directory||"")+\'</div><div class="mini-card"><strong>Source</strong>\'+escapeHtml(repo.source||"n/a")+\'</div><div class="mini-card"><strong>Platform Account</strong>\'+escapeHtml(String(repo.platformAccountId||"n/a"))+\'</div></div><div class="repo-files">\'+files+\'</div></div>\';return detailsBlock("repo:"+repo.accountId,(repo.name||"Unknown")+" ("+repo.accountId+")",meta,body,false);}).join("");}' +
|
|
4560
|
-
'function renderAll(state){const detailState=saveDetailsState();const scrollState=saveScrollState();dashboardState=state;document.getElementById("generated-at").textContent=formatTime(state.generatedAt);renderStatusCards(state);renderSummary(state);renderLastAction(state);renderIssues(state);renderWebsockets(state);renderTmux(state);renderGlobalFiles(state);renderRepos(state);restoreDetailsState(detailState);restoreScrollState(scrollState);}' +
|
|
4980
|
+
'function renderAll(state){const detailState=saveDetailsState();const scrollState=saveScrollState();dashboardState=state;document.getElementById("generated-at").textContent=formatTime(state.generatedAt);renderStatusCards(state);renderSummary(state);renderLastAction(state);renderSupportTickets(state);renderIssues(state);renderWebsockets(state);renderTmux(state);renderGlobalFiles(state);renderRepos(state);restoreDetailsState(detailState);restoreScrollState(scrollState);}' +
|
|
4561
4981
|
'async function fetchState(){const res=await fetch("/api/state",{cache:"no-store"});if(!res.ok)throw new Error("State refresh failed with "+res.status);return res.json();}' +
|
|
4562
4982
|
'function showToast(message){const toast=document.getElementById("toast");toast.textContent=message;toast.classList.add("show");clearTimeout(showToast._timer);showToast._timer=setTimeout(function(){toast.classList.remove("show");},2400);}' +
|
|
4563
4983
|
'async function triggerAction(action,button){if(actionInFlight)return;actionInFlight=true;button.disabled=true;try{const res=await fetch("/action",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},body:"action="+encodeURIComponent(action)});if(!res.ok)throw new Error("Action failed with "+res.status);const payload=await res.json();renderAll(payload.state);if(payload.result&&payload.result.bundlePath){showToast("Issue bundle prepared at "+payload.result.bundlePath);}else{showToast(action.replace(/-/g," ")+" complete");}}catch(err){showToast(err.message||String(err));}finally{actionInFlight=false;button.disabled=false;}}' +
|
|
@@ -4616,9 +5036,10 @@ function startDashboardServer(preferredPort) {
|
|
|
4616
5036
|
const requestUrl = new URL(req.url, 'http://' + DASHBOARD_HOST + ':' + (runtimeState.service.dashboardPort || preferredPort));
|
|
4617
5037
|
if (req.method === 'GET' && requestUrl.pathname === '/api/state') {
|
|
4618
5038
|
try {
|
|
5039
|
+
const snapshot = await collectDashboardSnapshot();
|
|
4619
5040
|
res.statusCode = 200;
|
|
4620
5041
|
res.setHeader('Content-Type', 'application/json');
|
|
4621
|
-
res.end(JSON.stringify(
|
|
5042
|
+
res.end(JSON.stringify(snapshot, null, 2));
|
|
4622
5043
|
} catch (err) {
|
|
4623
5044
|
sendDashboardError(res, err, true);
|
|
4624
5045
|
}
|
|
@@ -4641,11 +5062,13 @@ function startDashboardServer(preferredPort) {
|
|
|
4641
5062
|
at: new Date().toISOString()
|
|
4642
5063
|
};
|
|
4643
5064
|
if (action === 'export-issues') {
|
|
4644
|
-
const bundlePath = exportIssueBundle(collectDashboardSnapshot());
|
|
5065
|
+
const bundlePath = exportIssueBundle(await collectDashboardSnapshot());
|
|
4645
5066
|
actionResult = { bundlePath };
|
|
4646
5067
|
runtimeState.lastAction.bundlePath = bundlePath;
|
|
4647
5068
|
} else if (action === 'rescan-repos') {
|
|
4648
5069
|
recordDiscoverySummary(discoverAccountRepos());
|
|
5070
|
+
supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
|
|
5071
|
+
await bestEffortRegisterCliListeners({ reason: 'dashboard.rescan' });
|
|
4649
5072
|
} else if (action === 'open-tmux') {
|
|
4650
5073
|
openTmuxSessionForUser();
|
|
4651
5074
|
} else if (action === 'start-tmux') {
|
|
@@ -4662,7 +5085,7 @@ function startDashboardServer(preferredPort) {
|
|
|
4662
5085
|
success: true,
|
|
4663
5086
|
action,
|
|
4664
5087
|
result: actionResult,
|
|
4665
|
-
state: collectDashboardSnapshot()
|
|
5088
|
+
state: await collectDashboardSnapshot()
|
|
4666
5089
|
}));
|
|
4667
5090
|
return;
|
|
4668
5091
|
}
|
|
@@ -4678,9 +5101,10 @@ function startDashboardServer(preferredPort) {
|
|
|
4678
5101
|
|
|
4679
5102
|
if (req.method === 'GET' && requestUrl.pathname === '/') {
|
|
4680
5103
|
try {
|
|
5104
|
+
const snapshot = await collectDashboardSnapshot();
|
|
4681
5105
|
res.statusCode = 200;
|
|
4682
5106
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
4683
|
-
res.end(renderDashboardHtml(
|
|
5107
|
+
res.end(renderDashboardHtml(snapshot));
|
|
4684
5108
|
} catch (err) {
|
|
4685
5109
|
sendDashboardError(res, err, false);
|
|
4686
5110
|
}
|
|
@@ -5209,14 +5633,15 @@ function processInboundWebsocketMessage(client, baseUrl, topic, accountIds, user
|
|
|
5209
5633
|
const acctId = payload.accountId || accountIds[0];
|
|
5210
5634
|
console.log('[ws][TestSuite][account:' + acctId + ']', JSON.stringify(payload));
|
|
5211
5635
|
}
|
|
5212
|
-
} else if (data.type === 'remits-cli') {
|
|
5636
|
+
} else if (data.type === 'remits-cli' || data.type === 'cli_listener' || data.type === 'listener') {
|
|
5213
5637
|
const payload = data.payload || data;
|
|
5214
5638
|
const dispatchAccountId = resolveDispatchAccountId(payload, accountIds[0]);
|
|
5215
|
-
console.log('[ws][
|
|
5216
|
-
appendGlobalActivityLog('tmux', '
|
|
5639
|
+
console.log('[ws][listener][account:' + dispatchAccountId + ']', JSON.stringify(payload));
|
|
5640
|
+
appendGlobalActivityLog('tmux', 'listener_delivery.received', {
|
|
5217
5641
|
baseUrl,
|
|
5218
5642
|
topic,
|
|
5219
5643
|
accountId: dispatchAccountId,
|
|
5644
|
+
messageType: data.type,
|
|
5220
5645
|
source,
|
|
5221
5646
|
messageId,
|
|
5222
5647
|
message: summarizeDispatchMessage(payload)
|
|
@@ -5618,6 +6043,8 @@ async function listenCommand(flags) {
|
|
|
5618
6043
|
recordDiscoverySummary(discoverAccountRepos());
|
|
5619
6044
|
const dashboardPort = Number(flags.port || flags.dashboardPort || DEFAULT_DASHBOARD_PORT);
|
|
5620
6045
|
await startDashboardServer(dashboardPort);
|
|
6046
|
+
await bestEffortRegisterCliListeners({ reason: 'listener.start' });
|
|
6047
|
+
let lastListenerRegistrationAt = Date.now();
|
|
5621
6048
|
|
|
5622
6049
|
// Create the tmux session immediately so the user can attach right away
|
|
5623
6050
|
try {
|
|
@@ -5705,6 +6132,11 @@ async function listenCommand(flags) {
|
|
|
5705
6132
|
activeSessionSignature = currentSignature;
|
|
5706
6133
|
}
|
|
5707
6134
|
|
|
6135
|
+
if (Date.now() - lastListenerRegistrationAt >= LISTENER_REGISTRATION_INTERVAL_MS) {
|
|
6136
|
+
await bestEffortRegisterCliListeners({ reason: 'listener.heartbeat' });
|
|
6137
|
+
lastListenerRegistrationAt = Date.now();
|
|
6138
|
+
}
|
|
6139
|
+
|
|
5708
6140
|
await runManagedWebsocketWatchdog();
|
|
5709
6141
|
updateRuntimeServiceState();
|
|
5710
6142
|
} catch (err) {
|
|
@@ -5977,6 +6409,8 @@ function printComponentsHelp(subcommand) {
|
|
|
5977
6409
|
console.log('');
|
|
5978
6410
|
console.log('Agent safety gates (each exits non-zero instead of printing a wall of JSON):');
|
|
5979
6411
|
console.log(' --changed-only dry-run first; fail unless every planned write is a component this checkout changed');
|
|
6412
|
+
console.log(' --changed-since <ref> pair with --changed-only after committing: the changed set becomes');
|
|
6413
|
+
console.log(' the components touched between <ref> and HEAD, plus uncommitted edits');
|
|
5980
6414
|
console.log(' --names-only dry-run first; print only "type id name" lines and write nothing');
|
|
5981
6415
|
console.log(' --fail-on-removed dry-run first; fail if the plan removes/tombstones anything');
|
|
5982
6416
|
console.log(' --fail-on-errors dry-run first; fail if the server reported any per-component sync error');
|
|
@@ -6037,7 +6471,7 @@ function printComponentsHelp(subcommand) {
|
|
|
6037
6471
|
}
|
|
6038
6472
|
|
|
6039
6473
|
function printTestHelp() {
|
|
6040
|
-
console.log('Usage: remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names
|
|
6474
|
+
console.log('Usage: remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none]');
|
|
6041
6475
|
console.log('');
|
|
6042
6476
|
console.log('Runs a Test component against the staged/variant world for this checkout.');
|
|
6043
6477
|
console.log('Examples:');
|
|
@@ -6045,7 +6479,8 @@ function printTestHelp() {
|
|
|
6045
6479
|
console.log(' remits-cli test run --test "Merchant Statements" --names "managed account case"');
|
|
6046
6480
|
console.log('');
|
|
6047
6481
|
console.log('Notes:');
|
|
6048
|
-
console.log(' --names is
|
|
6482
|
+
console.log(' --names is delimited by "|" (comma also works when no "|" is present), and may be repeated.');
|
|
6483
|
+
console.log(' A selector that matches no case fails the run rather than reporting 0 passed / 0 failed.');
|
|
6049
6484
|
console.log(' --as-account changes the execution account so subscriber branch edges apply.');
|
|
6050
6485
|
console.log(' --variant-branch <name> probes a committed variant branch; use "none" or "trunk"');
|
|
6051
6486
|
console.log(' to force production/subscription semantics from a variant checkout.');
|
|
@@ -6095,6 +6530,9 @@ async function main() {
|
|
|
6095
6530
|
|
|
6096
6531
|
// Best-effort: update account-repo index whenever running from inside a remits repo
|
|
6097
6532
|
try { updateAccountRepoIndex(process.cwd()); } catch (_) { /* not in account repo */ }
|
|
6533
|
+
if (!wantsJson) {
|
|
6534
|
+
try { await bestEffortRegisterCliListeners({ cwd: process.cwd(), reason: 'command' }); } catch (_) { /* best-effort */ }
|
|
6535
|
+
}
|
|
6098
6536
|
|
|
6099
6537
|
// Auto-start the background service if not already running.
|
|
6100
6538
|
// Skip for lifecycle subcommands and help.
|
|
@@ -6146,7 +6584,7 @@ async function main() {
|
|
|
6146
6584
|
console.log(' remits-cli components branch <name> --subscribe <accountId> [--dry-run] [--confirm-primary-edge]');
|
|
6147
6585
|
console.log(' remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk');
|
|
6148
6586
|
console.log(' remits-cli components branch <name> --retire [--force] # delete the branch\'s overlays');
|
|
6149
|
-
console.log(' remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names
|
|
6587
|
+
console.log(' remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME]');
|
|
6150
6588
|
console.log(' remits-cli token [--base-url URL] [--branch BRANCH] [--path embeddable/path] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none]');
|
|
6151
6589
|
console.log(' remits-cli token inspect --token <token|tokenKey|URL> [--base-url URL] [--account-id ID]');
|
|
6152
6590
|
console.log('');
|
package/package.json
CHANGED
|
@@ -339,6 +339,14 @@ file is catastrophic.
|
|
|
339
339
|
- Local branch is committed and pushed; sync will read the intended remote commit.
|
|
340
340
|
- Local filenames and live inventory (`mcp_account_view`) **agree on id and name for every component**: no live
|
|
341
341
|
component appears locally under a different id, and no expected component is missing a repo file.
|
|
342
|
+
> **Do not run this comparison against `account-info.json` alone — it will report false orphans.** That
|
|
343
|
+
> file omits `auxiliary: true` components by design, so every auxiliary component looks like a repo file
|
|
344
|
+
> with no DB row, i.e. exactly the "a trunk sync will CREATE a duplicate" signal this check exists to
|
|
345
|
+
> catch. It also omits README- and AGENT-purpose Prompts, which live at the repo root and as `.md`
|
|
346
|
+
> sidecars in `components/agents/` rather than in `components/prompts/`, so they look like DB rows with
|
|
347
|
+
> no repo file — the "will be DELETED" signal. Both are benign. Before treating a flagged component as
|
|
348
|
+
> drift, confirm against the live row: `mcp_component_view`, or
|
|
349
|
+
> `mcp_run_action controlAction:"describe"` for an Action. A component that answers is not an orphan.
|
|
342
350
|
- You can state the expected create/update/delete set. **If any delete or renumber is unexpected, stop.**
|
|
343
351
|
|
|
344
352
|
### If something looks wrong — stop, don't paper over
|
|
@@ -895,7 +903,16 @@ Tests run on the platform against your staged snapshot. They stream results in r
|
|
|
895
903
|
|
|
896
904
|
Important test-runner constraints:
|
|
897
905
|
- `remits-cli test run` now defaults to `test` dataMode unless you explicitly pass `--data-mode prod`.
|
|
898
|
-
-
|
|
906
|
+
- **`--names` is delimited by `|`, and may be repeated.** A comma still splits a single `--names` value
|
|
907
|
+
(legacy behaviour), which is why a case name containing a comma used to be cut in half and match
|
|
908
|
+
nothing. Prefer `|` or repetition whenever a name might contain punctuation:
|
|
909
|
+
```bash
|
|
910
|
+
remits-cli test run --test 13 --names "a case, with a comma|another case"
|
|
911
|
+
remits-cli test run --test 13 --names "a case, with a comma" --names "another case"
|
|
912
|
+
```
|
|
913
|
+
- **A selector that matches no case FAILS the run.** It used to report `0 passed, 0 failed`,
|
|
914
|
+
`completed`, and exit 0 — indistinguishable from a suite where everything passed. The run now names
|
|
915
|
+
the unmatched selectors and lists the cases the suite actually declared, and exits non-zero.
|
|
899
916
|
|
|
900
917
|
If no relevant Test component exists yet, consider creating one. Test components live in `components/tests/` and follow the same component structure. They provide permanent regression protection — every test you write today saves debugging time tomorrow.
|
|
901
918
|
|
|
@@ -1617,6 +1634,15 @@ a confirm-gated override when the removal guard refuses. Preview there is the sa
|
|
|
1617
1634
|
- A test/tool response reports `testComponentSource` as `staged` | `variant` | `db`, so you can see which
|
|
1618
1635
|
layer the run resolved without reading logs.
|
|
1619
1636
|
|
|
1637
|
+
> **A populated staging cache makes a variant look broken.** Anything carrying a CLI `TestMode` — a
|
|
1638
|
+
> `remits-cli token` URL, `/s/<tokenKey>/...`, an `X-Auth-Token` request, a script-loader embed — resolves
|
|
1639
|
+
> the STAGED layer, which outranks the variant. So with components staged under the same branch/user, a
|
|
1640
|
+
> tokenized page reports `Component Branch: <branch>` but `Variant Applied: none` and renders trunk, while
|
|
1641
|
+
> an anonymous `?account_id=` request to the same page reports the variant. That is the documented
|
|
1642
|
+
> precedence (staged -> variant -> trunk) working correctly, and it reads exactly like "tokens break
|
|
1643
|
+
> variant resolution". **Run `remits-cli components clear --all` before verifying variant resolution
|
|
1644
|
+
> through any tokenized entry point**, or check `remits-cli components status` first.
|
|
1645
|
+
|
|
1620
1646
|
## Production Support Workflow
|
|
1621
1647
|
|
|
1622
1648
|
Switch to prod mode for investigations:
|
|
@@ -2804,7 +2830,7 @@ remits-cli data-mode [set test|prod]
|
|
|
2804
2830
|
remits-cli components stage [--branch <name>] [--data-mode test|prod] [--json|--verbose]
|
|
2805
2831
|
remits-cli components status [--branch <name>] [--component-type <type>] [--component-id <id>] [--json|--verbose]
|
|
2806
2832
|
remits-cli components clear [--branch <name>] [--component-type <type>] [--component-id <id>] [--all] [--json|--verbose] # id alone scopes to one component when unambiguous (ids are type-local; add --component-type if the same id is staged in multiple families); no filter clears the whole branch scope; --all forces the full wipe
|
|
2807
|
-
remits-cli components sync [--branch <name>] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] # gated; on trunk = full repo->DB reconcile, on a variant branch = ComponentVariant overlays only
|
|
2833
|
+
remits-cli components sync [--branch <name>] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] [--changed-only [--changed-since <ref>]] # gated; on trunk = full repo->DB reconcile, on a variant branch = ComponentVariant overlays only
|
|
2808
2834
|
remits-cli components commit [--message "msg"] [--data-mode test|prod] [--force-tombstones]
|
|
2809
2835
|
remits-cli components branches [--json] # branches carrying committed variants, with counts + drift
|
|
2810
2836
|
remits-cli components branch <name> [--json] # one branch: overridden / added / removed, drift flags, subscribers
|
|
@@ -2813,7 +2839,7 @@ remits-cli components branch <name> --subscribers [--json]
|
|
|
2813
2839
|
remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>] [--dry-run] [--confirm-primary-edge] # make an account resolve this branch
|
|
2814
2840
|
remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk
|
|
2815
2841
|
remits-cli components branch <name> --retire [--force] # delete the branch's overlays
|
|
2816
|
-
remits-cli test run --test <id|name> [--branch <stagingScope>] [--names "a
|
|
2842
|
+
remits-cli test run --test <id|name> [--branch <stagingScope>] [--names "a|b"] [--watch true|false] [--data-mode test|prod] [--as-account <ID>] [--variant-branch <name|none>]
|
|
2817
2843
|
remits-cli token [--path <embeddablePathOrId>] [--data-mode test|prod] [--as-account <ID>] [--variant-branch <name|none>]
|
|
2818
2844
|
remits-cli token inspect --token <token|tokenKey|URL> # inspect token metadata, safety/dataMode evidence, and full context
|
|
2819
2845
|
remits-cli tools [--branch <name>] [--data-mode test|prod] [--variant-branch <name|none>]
|
|
@@ -2823,7 +2849,8 @@ remits-cli tool status --call-id <callId> [--data-mode test|prod]
|
|
|
2823
2849
|
|
|
2824
2850
|
For tests specifically:
|
|
2825
2851
|
- If `--data-mode` is omitted, `remits-cli test run` uses `test`.
|
|
2826
|
-
- `--names` is
|
|
2852
|
+
- `--names` is `|`-delimited (a comma still splits a single value) and may be repeated; an unmatched
|
|
2853
|
+
selector fails the run instead of reporting zero cases as success.
|
|
2827
2854
|
- `--as-account <ID>` runs AS a descendant subscriber so its edge selects the component branch
|
|
2828
2855
|
(*"what does customer X get?"*); `--variant-branch <name>` probes a branch from any checkout
|
|
2829
2856
|
(*"what does branch Y look like?"*), and `--variant-branch none` forces production/subscription semantics.
|
|
@@ -2841,10 +2868,16 @@ For tests specifically:
|
|
|
2841
2868
|
- **Fail-closed sync gates.** On non-trunk variant branches these flags now force a server dry-run first,
|
|
2842
2869
|
evaluate that plan before any overlay row is written, and only then run the mutating sync when the plan
|
|
2843
2870
|
passes. On trunk, there is no safe dry-run plan, so do not treat these as scoped commit controls:
|
|
2844
|
-
- `--changed-only` — fail unless every planned write is a component **this checkout actually
|
|
2871
|
+
- `--changed-only` — fail unless every planned write is a component **this checkout actually changed**.
|
|
2845
2872
|
This is the strongest guard against a sync that quietly rewrites components you never touched. It
|
|
2846
2873
|
also fails when the checkout is not a git working tree, because "git could not answer" must never
|
|
2847
2874
|
be read as "nothing changed".
|
|
2875
|
+
> **Pair it with `--changed-since <ref>` after you have committed.** On its own `--changed-only`
|
|
2876
|
+
> reads UNCOMMITTED edits, and the documented flow commits and pushes *before* syncing (sync reads
|
|
2877
|
+
> the pushed remote, so it cannot see uncommitted work at all) — so the changed set is empty at
|
|
2878
|
+
> exactly the moment the gate runs, and it refuses the whole plan. `--changed-since origin/main`
|
|
2879
|
+
> (or the commit you branched from) makes the changed set the components your commits touched.
|
|
2880
|
+
> The refusal message says this when it detects the empty-set case.
|
|
2848
2881
|
- `--fail-on-removed` — fail if the plan removes or tombstones anything.
|
|
2849
2882
|
- `--expected-removed <type:id>` — whitelist the removals you intend (repeatable, or comma-delimited,
|
|
2850
2883
|
e.g. `--expected-removed action:5,reader:9`). It **implies** `--fail-on-removed`, so any removal you
|
|
@@ -2902,6 +2935,8 @@ For tests specifically:
|
|
|
2902
2935
|
| A component vanished for one account after a variant-branch sync | Its file is missing from that branch, so the sync created a **tombstone** that hides it from subscribers. Restore the file on the branch and re-sync. Trunk is unaffected. |
|
|
2903
2936
|
| A variant Test suite fails wholesale, asserting trunk where you expect a variant | You are almost certainly running it from a **variant checkout**: `variantBranch` outranks every subscription, so the suite's own fixture accounts resolve YOUR branch. Re-run from trunk or with `--variant-branch none` before treating it as a regression. |
|
|
2904
2937
|
| `components sync` on a branch says "No changes detected" but trunk has moved | Re-run it; a trunk sync now invalidates the branch's cached verdict. If it still skips, the branch genuinely matches trunk - check `components branch <name>` for what is actually stored. |
|
|
2938
|
+
| A sync summary reports `skipped: true` with `NO COMPONENTS WERE SYNCED` | The branch head matches the cached sync SHA, so the branch was never re-read — this is NOT an empty plan. It matters when something else changed the overlays at that same SHA: an ordinary re-sync then answers "nothing to do" forever. `--dry-run` always re-reads the branch and shows the real plan; `--force-tombstones` re-reads and applies it. |
|
|
2939
|
+
| A branch sync stores overlays for components you never edited on the branch | **Trunk moved.** Sparseness compares the branch against CURRENT trunk, so editing a component on trunk without merging trunk into the branch turns it into a branch overlay on the next branch sync. The branch did not change. Merge trunk in, push, re-sync — the overlays prune. Check `components promotion` for the phase. |
|
|
2905
2940
|
| After promoting a `new_*` component to trunk, the branch still shows it as `added` | You have not re-synced the branch since the trunk sync. Merge trunk into the branch and `components sync`: the file adopts the promoted id and, if unchanged, removes its own overlay. If the branch carries BOTH `new_Foo.*` and `<id>_Foo.*`, the id file wins and the `new_` one is reported `skipped: superseded` - delete it. |
|
|
2906
2941
|
| After promoting a standalone Prompt, the branch still shows `OVERRIDDEN prompt:<id>` with only `purpose` changed | The trunk Prompt row does not match repo metadata. Ensure the Prompt sidecar has `description` and `purpose: CUSTOM`, run a platform build that imports Prompt `purpose`, re-sync trunk, merge back, and re-sync the branch. |
|
|
2907
2942
|
| A branch preview reports a `removed` component nobody deleted | Check whether that component has a file on **trunk**. A DB row with no trunk file is missing from every branch, so it reads as a removal everywhere (and the trunk sync tries to hard-delete it each run). Repair trunk, not the branch. |
|