@remits/remits-cli 0.1.110 → 0.1.113
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 +16 -17
- package/index.js +2674 -109
- package/package.json +1 -1
- package/skills/remits-cli/SKILL.md +467 -34
package/index.js
CHANGED
|
@@ -320,6 +320,7 @@ function resolveSessionIdentity(cwd, flags = {}) {
|
|
|
320
320
|
|
|
321
321
|
const dataMode = requestedDataMode || resolveDataMode(flags, session);
|
|
322
322
|
const branchName = flags.branch || flags.branchName || currentBranch(cwd);
|
|
323
|
+
const workspace = resolveWorkspace(cwd, flags);
|
|
323
324
|
const user = session && session.user ? session.user : {};
|
|
324
325
|
const repoName = accountNameFromAccountInfo(accountInfo);
|
|
325
326
|
const localAccountInfoAccountId = accountInfo ? Number(accountIdFromAccountInfo(accountInfo)) : null;
|
|
@@ -336,6 +337,8 @@ function resolveSessionIdentity(cwd, flags = {}) {
|
|
|
336
337
|
userId: user.id || session?.userId || null,
|
|
337
338
|
userName: user.name || user.username || user.email || null,
|
|
338
339
|
branchName,
|
|
340
|
+
workspace,
|
|
341
|
+
workspaceSource: workspaceSource(cwd, flags),
|
|
339
342
|
dataMode,
|
|
340
343
|
// `test run` resolves its lane from the FLAG ONLY (testRunCommand), never the stored session, so that
|
|
341
344
|
// an account whose session is parked on prod cannot have a test run silently follow it. Report both
|
|
@@ -358,6 +361,7 @@ function printSessionIdentity(identity, options = {}) {
|
|
|
358
361
|
console.log('Account ID:', identity.accountId || 'unresolved');
|
|
359
362
|
console.log('User ID:', 'unresolved');
|
|
360
363
|
console.log('Branch:', identity.branchName || 'unresolved');
|
|
364
|
+
console.log('Workspace:', identity.workspace || 'none (shared default lane)');
|
|
361
365
|
console.log('Data mode:', (identity.dataMode || DEFAULT_DATA_MODE) + ' (applies to tool/tools/token)');
|
|
362
366
|
console.log('Data mode (test run):', identity.testRunDataMode || DEFAULT_DATA_MODE);
|
|
363
367
|
console.log('Base URL:', identity.baseUrl);
|
|
@@ -382,6 +386,8 @@ function printSessionIdentity(identity, options = {}) {
|
|
|
382
386
|
console.log(' User:', identity.userName);
|
|
383
387
|
}
|
|
384
388
|
console.log(' Branch:', identity.branchName);
|
|
389
|
+
console.log(' Workspace:', (identity.workspace || 'none (shared default lane)') +
|
|
390
|
+
(identity.workspaceSource ? ' [' + identity.workspaceSource + ']' : ''));
|
|
385
391
|
console.log(' Data mode:', identity.dataMode, '(applies to tool/tools/token)');
|
|
386
392
|
// `test run` deliberately ignores the stored session lane and defaults to test, so a session sitting on
|
|
387
393
|
// prod would otherwise read as "prod" here while the next test run went to the test lane. Say so rather
|
|
@@ -528,10 +534,105 @@ function localStatePaths(cwd) {
|
|
|
528
534
|
toolsDir: path.join(base, 'tools'),
|
|
529
535
|
sessionsDir: path.join(base, 'sessions'),
|
|
530
536
|
toolResponsesDir: path.join(base, 'tool-responses'),
|
|
531
|
-
currentSessionFile: path.join(base, 'current-session.txt')
|
|
537
|
+
currentSessionFile: path.join(base, 'current-session.txt'),
|
|
538
|
+
workspaceFile: path.join(base, 'workspace')
|
|
532
539
|
};
|
|
533
540
|
}
|
|
534
541
|
|
|
542
|
+
// ---------------------------------------------------------------------------------------------------
|
|
543
|
+
// Staging workspace
|
|
544
|
+
// ---------------------------------------------------------------------------------------------------
|
|
545
|
+
//
|
|
546
|
+
// A workspace is an OPTIONAL second isolation axis for staging, alongside the git branch. It exists so
|
|
547
|
+
// several coding agents can iterate on the SAME branch at once without overwriting each other: staging is
|
|
548
|
+
// keyed by (account, cliUser, branch, workspace), and account and user are fixed for a given repo.
|
|
549
|
+
//
|
|
550
|
+
// It is stored in `.remits-cli/workspace`, which is PER-DIRECTORY and gitignored — so `git worktree add`
|
|
551
|
+
// gives each agent its own file, and `remits-cli workspace use --auto` names the lane after the worktree
|
|
552
|
+
// directory. That makes the ergonomic path a one-time setup per worktree rather than a flag on every
|
|
553
|
+
// command, which is what an agent will actually get right.
|
|
554
|
+
//
|
|
555
|
+
// Precedence: explicit --workspace > REMITS_WORKSPACE env > .remits-cli/workspace > none (shared lane).
|
|
556
|
+
|
|
557
|
+
const WORKSPACE_MAX_LENGTH = 48;
|
|
558
|
+
|
|
559
|
+
// MUST match CliComponentStaging.normalizeWorkspace on the server. Normalizing here too means the name an
|
|
560
|
+
// agent sees in its own output is the lane the server actually used, rather than a raw string the server
|
|
561
|
+
// silently rewrote.
|
|
562
|
+
function normalizeWorkspace(value) {
|
|
563
|
+
if (value === undefined || value === null) return null;
|
|
564
|
+
const trimmed = String(value).trim();
|
|
565
|
+
if (!trimmed) return null;
|
|
566
|
+
let normalized = trimmed
|
|
567
|
+
.toLowerCase()
|
|
568
|
+
.replace(/[^a-z0-9._-]/g, '-')
|
|
569
|
+
.replace(/-+/g, '-')
|
|
570
|
+
.replace(/^[-.]+/, '')
|
|
571
|
+
.replace(/[-.]+$/, '');
|
|
572
|
+
if (normalized.length > WORKSPACE_MAX_LENGTH) {
|
|
573
|
+
normalized = normalized.slice(0, WORKSPACE_MAX_LENGTH).replace(/[-.]+$/, '');
|
|
574
|
+
}
|
|
575
|
+
return normalized || null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function readWorkspaceFile(cwd) {
|
|
579
|
+
try {
|
|
580
|
+
const file = localStatePaths(cwd).workspaceFile;
|
|
581
|
+
if (!fs.existsSync(file)) return null;
|
|
582
|
+
return normalizeWorkspace(fs.readFileSync(file, 'utf8'));
|
|
583
|
+
} catch (_) {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function writeWorkspaceFile(cwd, workspace) {
|
|
589
|
+
const paths = ensureLocalState(cwd);
|
|
590
|
+
if (!workspace) {
|
|
591
|
+
try { if (fs.existsSync(paths.workspaceFile)) fs.unlinkSync(paths.workspaceFile); } catch (_) { /* best-effort */ }
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
atomicWriteFile(paths.workspaceFile, workspace + '\n');
|
|
595
|
+
return workspace;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// `--no-workspace` explicitly opts back into the shared default lane even when a workspace file exists,
|
|
599
|
+
// so a command can be run against the lane a teammate (or an older session) staged into without deleting
|
|
600
|
+
// local state.
|
|
601
|
+
function resolveWorkspace(cwd, flags = {}) {
|
|
602
|
+
if (flagEnabled(flags['no-workspace']) || flagEnabled(flags.noWorkspace)) {
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
const explicit = flags.workspace || flags.ws;
|
|
606
|
+
if (explicit !== undefined && explicit !== null && explicit !== true) {
|
|
607
|
+
return normalizeWorkspace(explicit);
|
|
608
|
+
}
|
|
609
|
+
return normalizeWorkspace(process.env.REMITS_WORKSPACE) || readWorkspaceFile(cwd);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Where the resolved workspace came from. Printed alongside it so an agent never has to guess whether the
|
|
613
|
+
// lane it is on was chosen deliberately or inherited from a file it forgot about.
|
|
614
|
+
function workspaceSource(cwd, flags = {}) {
|
|
615
|
+
if (flagEnabled(flags['no-workspace']) || flagEnabled(flags.noWorkspace)) return 'explicit --no-workspace';
|
|
616
|
+
const explicit = flags.workspace || flags.ws;
|
|
617
|
+
if (explicit !== undefined && explicit !== null && explicit !== true) return '--workspace';
|
|
618
|
+
if (normalizeWorkspace(process.env.REMITS_WORKSPACE)) return 'REMITS_WORKSPACE';
|
|
619
|
+
if (readWorkspaceFile(cwd)) return '.remits-cli/workspace';
|
|
620
|
+
return 'none (shared default lane)';
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// One line, printed by every command that stages, runs, or mints a token. The whole point of the feature
|
|
624
|
+
// is that several lanes can exist at once; a command that did not say which one it used would trade one
|
|
625
|
+
// kind of confusion for another.
|
|
626
|
+
function printStagingLane(branchName, workspace, source) {
|
|
627
|
+
if (workspace) {
|
|
628
|
+
console.log('Staging lane:', branchName + ' [workspace: ' + workspace + ']' +
|
|
629
|
+
(source ? ' (from ' + source + ')' : ''));
|
|
630
|
+
} else {
|
|
631
|
+
console.log('Staging lane:', branchName + ' [shared default lane]' +
|
|
632
|
+
(source ? ' (' + source + ')' : ''));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
535
636
|
function ensureLocalState(cwd) {
|
|
536
637
|
const paths = localStatePaths(cwd);
|
|
537
638
|
ensureDir(paths.base);
|
|
@@ -1147,13 +1248,45 @@ function countAccountRepos(index) {
|
|
|
1147
1248
|
return listAccountRepoEntries(index).length;
|
|
1148
1249
|
}
|
|
1149
1250
|
|
|
1251
|
+
/**
|
|
1252
|
+
* Fold discovered checkouts into the index, one entry per account.
|
|
1253
|
+
*
|
|
1254
|
+
* <p><b>Two checkouts can claim one account, and the naive merge picked a silent winner.</b> The index is
|
|
1255
|
+
* keyed by accountId, so a plain last-write-wins loop resolved a fork, a worktree or a dated backup copy
|
|
1256
|
+
* by scan order — which is alphabetical, which is meaningless. Measured on this machine: account 33 had
|
|
1257
|
+
* both `remits-invoice-automation` and `remits-invoice-automation copy-EOD-09302025`, and the index held
|
|
1258
|
+
* the dated copy. Four open tickets would have been worked in a month-old snapshot.</p>
|
|
1259
|
+
*
|
|
1260
|
+
* <p>So: a real checkout always beats a non-checkout (an unpacked export cannot win merely by sorting
|
|
1261
|
+
* later); an explicit `cwd` entry — the operator standing in the directory — always beats a scan; and
|
|
1262
|
+
* when two genuine checkouts remain, the incumbent is KEPT and the loser is recorded in `ambiguousWith`
|
|
1263
|
+
* rather than discarded. Ambiguity a person can see is recoverable; a silent winner is not.</p>
|
|
1264
|
+
*/
|
|
1150
1265
|
function mergeAccountRepoEntries(entries, options = {}) {
|
|
1151
1266
|
const nextIndex = options.existingIndex ? { ...options.existingIndex } : {};
|
|
1267
|
+
const rank = (entry) => (isAccountCheckout(entry.directory) ? 2 : 0) + (entry.source === 'cwd' ? 1 : 0);
|
|
1268
|
+
|
|
1152
1269
|
for (const entry of entries) {
|
|
1153
1270
|
if (!entry || !entry.accountId || !entry.directory) {
|
|
1154
1271
|
continue;
|
|
1155
1272
|
}
|
|
1156
|
-
|
|
1273
|
+
const key = String(entry.accountId);
|
|
1274
|
+
const current = nextIndex[key];
|
|
1275
|
+
if (!current || !current.directory || current.directory === entry.directory) {
|
|
1276
|
+
nextIndex[key] = { ...entry, ambiguousWith: (current && current.ambiguousWith) || [] };
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
const winner = rank(entry) > rank(current) ? entry : current;
|
|
1281
|
+
const loser = winner === entry ? current : entry;
|
|
1282
|
+
const seen = new Set([...((current.ambiguousWith) || []), ...((entry.ambiguousWith) || [])]);
|
|
1283
|
+
// Only a genuine rival is worth reporting. A non-checkout losing to a checkout is the system
|
|
1284
|
+
// working, not a decision the operator needs to review.
|
|
1285
|
+
if (isAccountCheckout(loser.directory) && isAccountCheckout(winner.directory)) {
|
|
1286
|
+
seen.add(loser.directory);
|
|
1287
|
+
}
|
|
1288
|
+
seen.delete(winner.directory);
|
|
1289
|
+
nextIndex[key] = { ...winner, ambiguousWith: [...seen] };
|
|
1157
1290
|
}
|
|
1158
1291
|
writeAccountRepoIndex(nextIndex);
|
|
1159
1292
|
return nextIndex;
|
|
@@ -1615,6 +1748,25 @@ function parsePositiveInt(value, fallback) {
|
|
|
1615
1748
|
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
1616
1749
|
}
|
|
1617
1750
|
|
|
1751
|
+
/**
|
|
1752
|
+
* How long to give a full stage, from how much it is actually uploading.
|
|
1753
|
+
*
|
|
1754
|
+
* A fixed 60s was fine for a small repo and wrong for a real one: the request completes server-side and
|
|
1755
|
+
* the client abandons it, which is indistinguishable at the terminal from a stage that never landed.
|
|
1756
|
+
* Roughly 60s of headroom per megabyte on top of a 60s floor, capped so a pathological payload cannot
|
|
1757
|
+
* hang a session forever. `--stage-timeout-ms` overrides it.
|
|
1758
|
+
*/
|
|
1759
|
+
function stageTimeoutMs(components, flags = {}) {
|
|
1760
|
+
const override = parsePositiveInt(flags['stage-timeout-ms'] || flags.stageTimeoutMs, 0);
|
|
1761
|
+
if (override) return override;
|
|
1762
|
+
let bytes = 0;
|
|
1763
|
+
for (const component of components || []) {
|
|
1764
|
+
try { bytes += Buffer.byteLength(JSON.stringify(component)); } catch (_) { /* unmeasurable is fine */ }
|
|
1765
|
+
}
|
|
1766
|
+
const megabytes = bytes / (1024 * 1024);
|
|
1767
|
+
return Math.min(600000, Math.max(60000, Math.round(60000 + (megabytes * 60000))));
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1618
1770
|
function buildAxios(baseUrl, token, timeoutMs = 60000) {
|
|
1619
1771
|
const headers = token ? { Authorization: 'Bearer ' + token } : {};
|
|
1620
1772
|
return axios.create({ baseURL: baseUrl, timeout: parsePositiveInt(timeoutMs, 60000), headers });
|
|
@@ -2059,24 +2211,54 @@ async function pushComponentsCommand(flags) {
|
|
|
2059
2211
|
const { session, accountId } = sessionContext;
|
|
2060
2212
|
const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
|
|
2061
2213
|
const branchName = flags.branch || currentBranch(cwd);
|
|
2214
|
+
const workspace = resolveWorkspace(cwd, flags);
|
|
2062
2215
|
const dataMode = resolveDataMode(flags, session);
|
|
2063
2216
|
const requestedMode = String(flags.mode || 'stage').toLowerCase();
|
|
2064
2217
|
const mode = requestedMode === 'push' ? 'stage' : requestedMode;
|
|
2065
|
-
const components = collectComponents(cwd);
|
|
2066
2218
|
const changedFromWorkingTree = changedComponentsFromWorkingTree(cwd);
|
|
2067
2219
|
|
|
2220
|
+
// --changed-only stages just the components this working tree edited, instead of re-uploading all of
|
|
2221
|
+
// them. Two reasons it matters here: it is what makes an iteration loop cheap on a large repo, and it
|
|
2222
|
+
// deliberately does NOT send `replace`, so the server's reconcile pass cannot delete the entries for
|
|
2223
|
+
// components this partial stage did not mention.
|
|
2224
|
+
const changedOnly = flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly);
|
|
2225
|
+
let components = collectComponents(cwd);
|
|
2226
|
+
if (changedOnly) {
|
|
2227
|
+
if (changedFromWorkingTree === null) {
|
|
2228
|
+
throw new Error('--changed-only needs a git working tree to establish the changed set, and this directory is not one.');
|
|
2229
|
+
}
|
|
2230
|
+
const wanted = new Set(changedFromWorkingTree.map((entry) =>
|
|
2231
|
+
entry.type + ':' + (entry.id ? 'id:' + entry.id : 'name:' + String(entry.name || '').toLowerCase())));
|
|
2232
|
+
components = components.filter((component) => wanted.has(
|
|
2233
|
+
component.type + ':' + (component.id ? 'id:' + component.id : 'name:' + String(component.name || '').toLowerCase())));
|
|
2234
|
+
if (!components.length) {
|
|
2235
|
+
console.log('--changed-only: this working tree has no uncommitted component edits; nothing to stage.');
|
|
2236
|
+
printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
|
|
2237
|
+
return { success: true, mode, updated: 0, unchanged: 0, skipped: [], changedOnly: true };
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2068
2241
|
if (requestedMode === 'push') {
|
|
2069
2242
|
console.log('Warning: `components push` is deprecated and currently behaves the same as `components stage`.');
|
|
2070
2243
|
}
|
|
2071
2244
|
|
|
2072
|
-
|
|
2245
|
+
// A full stage uploads the WHOLE repo, and a large one is megabytes: Merchant Statements is 9.6 MB
|
|
2246
|
+
// across 106 components. Against a remote platform that routinely runs past 60s — and the failure was
|
|
2247
|
+
// the worst possible shape, because the server had already stored everything: the CLI reported
|
|
2248
|
+
// "timeout ... ECONNABORTED" over a stage that had SUCCEEDED, so the caller would either re-upload it
|
|
2249
|
+
// or, worse, conclude staging was broken and go on reading trunk. Scaled by payload size, floored at
|
|
2250
|
+
// the old default, and overridable.
|
|
2251
|
+
const api = buildAxios(baseUrl, session.token, stageTimeoutMs(components, flags));
|
|
2073
2252
|
const response = await loggedPost(api, cwd, '/cli/components', {
|
|
2074
2253
|
token: session.token,
|
|
2075
2254
|
accountId,
|
|
2076
2255
|
branchName,
|
|
2256
|
+
workspace,
|
|
2077
2257
|
dataMode,
|
|
2078
2258
|
mode,
|
|
2079
|
-
|
|
2259
|
+
// A partial (--changed-only) stage must not trigger the server's reconcile pass: reconcile deletes
|
|
2260
|
+
// every staged entry absent from the manifest, which for a partial manifest is nearly all of them.
|
|
2261
|
+
replace: !changedOnly,
|
|
2080
2262
|
components
|
|
2081
2263
|
}).then((r) => r.data);
|
|
2082
2264
|
response.changedFromWorkingTree = changedFromWorkingTree || [];
|
|
@@ -2089,8 +2271,9 @@ async function pushComponentsCommand(flags) {
|
|
|
2089
2271
|
|
|
2090
2272
|
printSessionResolutionWarning(sessionContext);
|
|
2091
2273
|
printResolvedBaseUrl(baseUrl);
|
|
2274
|
+
printStagingLane(response.branchName || branchName, response.workspace || workspace, workspaceSource(cwd, flags));
|
|
2092
2275
|
console.log('Data mode:', response.dataMode || dataMode);
|
|
2093
|
-
console.log('Mode:', response.mode || mode);
|
|
2276
|
+
console.log('Mode:', response.mode || mode + (changedOnly ? ' (--changed-only)' : ''));
|
|
2094
2277
|
printStageSummary(response, flags);
|
|
2095
2278
|
}
|
|
2096
2279
|
|
|
@@ -2166,8 +2349,26 @@ function printStageSummary(response, flags) {
|
|
|
2166
2349
|
|
|
2167
2350
|
function printStatusSummary(response, flags) {
|
|
2168
2351
|
printBranchContext(response);
|
|
2352
|
+
printStagingLane(response.branchName, response.workspace, null);
|
|
2169
2353
|
console.log('Staged count:', response.stagedCount || 0);
|
|
2170
2354
|
printComponentTypeCounts(response.entries || []);
|
|
2355
|
+
|
|
2356
|
+
// Every lane staged on this branch, not just this one. An agent that believes it is working alone can
|
|
2357
|
+
// see here that it is not — which was impossible before workspaces existed, and is a large part of why
|
|
2358
|
+
// concurrent agents overwrote each other without anyone being able to observe it.
|
|
2359
|
+
const lanes = Array.isArray(response.workspaces) ? response.workspaces : [];
|
|
2360
|
+
if (lanes.length > 1 || (lanes.length === 1 && lanes[0].workspace !== (response.workspace || null))) {
|
|
2361
|
+
console.log('');
|
|
2362
|
+
console.log('Other staging lanes on branch "' + response.branchName + '" for this account/user:');
|
|
2363
|
+
lanes.forEach((lane) => {
|
|
2364
|
+
const isCurrent = (lane.workspace || null) === (response.workspace || null);
|
|
2365
|
+
const label = lane.workspace ? 'workspace ' + lane.workspace : 'shared default lane';
|
|
2366
|
+
const ttl = lane.ttlSeconds != null ? ' expires in ~' + Math.round(lane.ttlSeconds / 60) + 'm' : '';
|
|
2367
|
+
console.log(' ' + (isCurrent ? '* ' : ' ') + label + ' — ' + (lane.keyCount || 0) + ' staged key(s)' + ttl);
|
|
2368
|
+
});
|
|
2369
|
+
console.log(' (* = this command\'s lane)');
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2171
2372
|
printComponentCommandResponse('Components staging', response, flags);
|
|
2172
2373
|
}
|
|
2173
2374
|
|
|
@@ -2210,6 +2411,64 @@ function printClearSummary(response, flags) {
|
|
|
2210
2411
|
printComponentCommandResponse('Components clear', response, flags);
|
|
2211
2412
|
}
|
|
2212
2413
|
|
|
2414
|
+
/**
|
|
2415
|
+
* `remits-cli workspace [show|use <name>|clear]`
|
|
2416
|
+
*
|
|
2417
|
+
* The ergonomic entry point for the staging workspace lane. `use --auto` names the lane after the
|
|
2418
|
+
* checkout directory, which is what makes `git worktree add ../ms-agent-a` + one command enough to give
|
|
2419
|
+
* an agent its own isolated staging lane on the SAME branch.
|
|
2420
|
+
*/
|
|
2421
|
+
async function workspaceCommand(flags) {
|
|
2422
|
+
const cwd = process.cwd();
|
|
2423
|
+
ensureLocalState(cwd);
|
|
2424
|
+
const sub = String((flags._ && flags._[1]) || 'show').toLowerCase();
|
|
2425
|
+
|
|
2426
|
+
if (sub === 'clear' || sub === 'unset' || sub === 'none') {
|
|
2427
|
+
writeWorkspaceFile(cwd, null);
|
|
2428
|
+
console.log('Workspace cleared. This checkout now stages into the shared default lane for its branch.');
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
if (sub === 'use' || sub === 'set') {
|
|
2433
|
+
const auto = flagEnabled(flags.auto);
|
|
2434
|
+
const requested = auto ? path.basename(cwd) : (flags._ && flags._[2]) || flags.name || flags.workspace;
|
|
2435
|
+
if (!requested) {
|
|
2436
|
+
throw new Error('Usage: remits-cli workspace use <name> (or --auto to name it after this directory)');
|
|
2437
|
+
}
|
|
2438
|
+
const normalized = normalizeWorkspace(requested);
|
|
2439
|
+
if (!normalized) {
|
|
2440
|
+
throw new Error('Workspace name "' + requested + '" normalizes to nothing. Use letters, digits, dot, dash or underscore.');
|
|
2441
|
+
}
|
|
2442
|
+
writeWorkspaceFile(cwd, normalized);
|
|
2443
|
+
console.log('Workspace set to:', normalized);
|
|
2444
|
+
if (normalized !== String(requested).trim()) {
|
|
2445
|
+
// Say so rather than silently accepting a name that is not the lane. An agent that thinks it is on
|
|
2446
|
+
// "Agent A" and is actually on "agent-a" cannot reconcile its own output with a status listing.
|
|
2447
|
+
console.log(' (normalized from "' + String(requested).trim() + '")');
|
|
2448
|
+
}
|
|
2449
|
+
console.log('Stored in:', localStatePaths(cwd).workspaceFile, '(gitignored, per-checkout)');
|
|
2450
|
+
console.log('');
|
|
2451
|
+
console.log('Every stage / test run / token from this directory now uses this lane.');
|
|
2452
|
+
console.log('It does NOT change which branch a commit targets, or which committed variants run.');
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
if (sub !== 'show' && sub !== 'status') {
|
|
2457
|
+
throw new Error('Unknown workspace subcommand "' + sub + '". Expected show, use, or clear.');
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
const branchName = flags.branch || currentBranch(cwd);
|
|
2461
|
+
const workspace = resolveWorkspace(cwd, flags);
|
|
2462
|
+
printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
|
|
2463
|
+
console.log('');
|
|
2464
|
+
console.log('Precedence: --workspace > REMITS_WORKSPACE > .remits-cli/workspace > shared default lane');
|
|
2465
|
+
console.log('');
|
|
2466
|
+
console.log(' remits-cli workspace use <name> set this checkout\'s lane');
|
|
2467
|
+
console.log(' remits-cli workspace use --auto name it after this directory (' + normalizeWorkspace(path.basename(cwd)) + ')');
|
|
2468
|
+
console.log(' remits-cli workspace clear go back to the shared default lane');
|
|
2469
|
+
console.log(' remits-cli components status see every lane staged on this branch');
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2213
2472
|
async function statusComponentsCommand(flags) {
|
|
2214
2473
|
const cwd = process.cwd();
|
|
2215
2474
|
ensureLocalState(cwd);
|
|
@@ -2217,6 +2476,7 @@ async function statusComponentsCommand(flags) {
|
|
|
2217
2476
|
const { session, accountId } = sessionContext;
|
|
2218
2477
|
const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
|
|
2219
2478
|
const branchName = flags.branch || currentBranch(cwd);
|
|
2479
|
+
const workspace = resolveWorkspace(cwd, flags);
|
|
2220
2480
|
const dataMode = resolveDataMode(flags, session);
|
|
2221
2481
|
const api = buildAxios(baseUrl, session.token);
|
|
2222
2482
|
|
|
@@ -2224,6 +2484,7 @@ async function statusComponentsCommand(flags) {
|
|
|
2224
2484
|
token: session.token,
|
|
2225
2485
|
accountId,
|
|
2226
2486
|
branchName,
|
|
2487
|
+
workspace,
|
|
2227
2488
|
dataMode,
|
|
2228
2489
|
mode: 'status',
|
|
2229
2490
|
componentType: flags['component-type'] || flags.type,
|
|
@@ -2272,6 +2533,9 @@ async function clearComponentsCommand(flags) {
|
|
|
2272
2533
|
const response = await loggedPost(api, cwd, '/cli/components', {
|
|
2273
2534
|
token: session.token,
|
|
2274
2535
|
accountId,
|
|
2536
|
+
// A clear NEVER reaches beyond this lane, so `--all` means "all of MY staged entries", not
|
|
2537
|
+
// "everything staged on this branch by anyone".
|
|
2538
|
+
workspace: resolveWorkspace(cwd, flags),
|
|
2275
2539
|
branchName,
|
|
2276
2540
|
dataMode,
|
|
2277
2541
|
mode: 'clear',
|
|
@@ -2291,6 +2555,7 @@ async function clearComponentsCommand(flags) {
|
|
|
2291
2555
|
|
|
2292
2556
|
printSessionResolutionWarning(sessionContext);
|
|
2293
2557
|
printResolvedBaseUrl(baseUrl);
|
|
2558
|
+
printStagingLane(response.branchName || branchName, response.workspace, workspaceSource(cwd, flags));
|
|
2294
2559
|
console.log('Data mode:', response.dataMode || dataMode);
|
|
2295
2560
|
console.log('Mode:', response.mode || 'clear');
|
|
2296
2561
|
printClearSummary(response, flags);
|
|
@@ -2304,6 +2569,7 @@ async function syncComponentsCommand(flags) {
|
|
|
2304
2569
|
const { session, accountId } = sessionContext;
|
|
2305
2570
|
const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
|
|
2306
2571
|
const branchName = flags.branch || currentBranch(cwd);
|
|
2572
|
+
const workspace = resolveWorkspace(cwd, flags);
|
|
2307
2573
|
const dataMode = resolveDataMode(flags, session);
|
|
2308
2574
|
const forceTombstones = flagEnabled(flags['force-tombstones']) || flagEnabled(flags.forceTombstones);
|
|
2309
2575
|
const dryRun = flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun);
|
|
@@ -2325,6 +2591,11 @@ async function syncComponentsCommand(flags) {
|
|
|
2325
2591
|
token: session.token,
|
|
2326
2592
|
accountId,
|
|
2327
2593
|
branchName,
|
|
2594
|
+
// A successful sync clears the staging scope so stale staged aliases cannot shadow the freshly synced
|
|
2595
|
+
// DB rows. Sending the workspace keeps that clear scoped to THIS lane: without it, one agent's sync
|
|
2596
|
+
// would wipe every sibling agent's staged work on the same branch, which is the exact failure the
|
|
2597
|
+
// workspace lane exists to remove.
|
|
2598
|
+
workspace,
|
|
2328
2599
|
dataMode,
|
|
2329
2600
|
forceTombstones,
|
|
2330
2601
|
dryRun,
|
|
@@ -2338,6 +2609,7 @@ async function syncComponentsCommand(flags) {
|
|
|
2338
2609
|
token: session.token,
|
|
2339
2610
|
accountId,
|
|
2340
2611
|
branchName,
|
|
2612
|
+
workspace,
|
|
2341
2613
|
dataMode,
|
|
2342
2614
|
mode: 'status'
|
|
2343
2615
|
}).then((r) => r.data);
|
|
@@ -3230,6 +3502,7 @@ async function testCommand(flags) {
|
|
|
3230
3502
|
const { session, accountId } = sessionContext;
|
|
3231
3503
|
const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
|
|
3232
3504
|
const branchName = flags.branch || currentBranch(cwd);
|
|
3505
|
+
const workspace = resolveWorkspace(cwd, flags);
|
|
3233
3506
|
// Test runs must default to test dataMode even when the most-recent authenticated
|
|
3234
3507
|
// session for the account is prod. Production test runs therefore require an explicit
|
|
3235
3508
|
// --data-mode prod on the command line.
|
|
@@ -3285,6 +3558,7 @@ async function testCommand(flags) {
|
|
|
3285
3558
|
asAccountId,
|
|
3286
3559
|
variantBranch,
|
|
3287
3560
|
branchName,
|
|
3561
|
+
workspace,
|
|
3288
3562
|
dataMode,
|
|
3289
3563
|
testId: /^\d+$/.test(String(testRef)) ? Number(testRef) : undefined,
|
|
3290
3564
|
testName: /^\d+$/.test(String(testRef)) ? undefined : String(testRef),
|
|
@@ -3297,6 +3571,7 @@ async function testCommand(flags) {
|
|
|
3297
3571
|
|
|
3298
3572
|
printSessionResolutionWarning(sessionContext);
|
|
3299
3573
|
printResolvedBaseUrl(baseUrl);
|
|
3574
|
+
printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
|
|
3300
3575
|
console.log('Test run started:', start.taskId);
|
|
3301
3576
|
runtimeState.currentTestTaskId = start.taskId;
|
|
3302
3577
|
|
|
@@ -3354,6 +3629,7 @@ async function tokenCommand(flags) {
|
|
|
3354
3629
|
const { session, accountId } = sessionContext;
|
|
3355
3630
|
const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
|
|
3356
3631
|
const branchName = flags.branch || currentBranch(cwd);
|
|
3632
|
+
const workspace = resolveWorkspace(cwd, flags);
|
|
3357
3633
|
const dataMode = resolveDataMode(flags, session);
|
|
3358
3634
|
|
|
3359
3635
|
const api = buildAxios(baseUrl, session.token);
|
|
@@ -3371,6 +3647,7 @@ async function tokenCommand(flags) {
|
|
|
3371
3647
|
asAccountId: flags['as-account'] || flags['as-account-id'],
|
|
3372
3648
|
variantBranch,
|
|
3373
3649
|
branchName,
|
|
3650
|
+
workspace,
|
|
3374
3651
|
dataMode,
|
|
3375
3652
|
path: requestedPath
|
|
3376
3653
|
}).then((r) => r.data);
|
|
@@ -3396,6 +3673,8 @@ async function tokenCommand(flags) {
|
|
|
3396
3673
|
accountId: data.accountId,
|
|
3397
3674
|
branchName: data.branchName,
|
|
3398
3675
|
variantBranch: data.variantBranch,
|
|
3676
|
+
workspace: data.workspace !== undefined ? data.workspace : workspace,
|
|
3677
|
+
stagingLane: data.stagingLane,
|
|
3399
3678
|
dataMode: data.dataMode || dataMode,
|
|
3400
3679
|
shortBasePath: data.shortBasePath,
|
|
3401
3680
|
embeddableUrl
|
|
@@ -3461,6 +3740,7 @@ async function toolsCommand(flags) {
|
|
|
3461
3740
|
const { session, accountId } = sessionContext;
|
|
3462
3741
|
const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
|
|
3463
3742
|
const branchName = flags.branch || currentBranch(cwd);
|
|
3743
|
+
const workspace = resolveWorkspace(cwd, flags);
|
|
3464
3744
|
const dataMode = resolveDataMode(flags, session);
|
|
3465
3745
|
const variantBranch = flags['variant-branch'];
|
|
3466
3746
|
const api = buildAxios(baseUrl, session.token);
|
|
@@ -3470,6 +3750,7 @@ async function toolsCommand(flags) {
|
|
|
3470
3750
|
accountId,
|
|
3471
3751
|
variantBranch,
|
|
3472
3752
|
branchName,
|
|
3753
|
+
workspace,
|
|
3473
3754
|
dataMode
|
|
3474
3755
|
}).then((r) => r.data);
|
|
3475
3756
|
|
|
@@ -3507,6 +3788,51 @@ function parseToolInput(flags) {
|
|
|
3507
3788
|
return {};
|
|
3508
3789
|
}
|
|
3509
3790
|
|
|
3791
|
+
const TOOL_SCOPES = ['self', 'children', 'hierarchy', 'parents'];
|
|
3792
|
+
|
|
3793
|
+
// Hierarchy-scoped discovery flags, folded into the tool's own input.
|
|
3794
|
+
//
|
|
3795
|
+
// They are CONVENIENCE only: every one of them can be written inside --input, and a value already there
|
|
3796
|
+
// wins, because the explicit JSON is what the caller typed for this specific tool. The flags exist so the
|
|
3797
|
+
// overwhelmingly common shape — "search my repo account's children for this id" — does not require
|
|
3798
|
+
// hand-writing JSON.
|
|
3799
|
+
function applyScopeFlags(input, flags) {
|
|
3800
|
+
const merged = input && typeof input === 'object' ? input : {};
|
|
3801
|
+
|
|
3802
|
+
const scope = flags && (flags.scope || flags['tool-scope']);
|
|
3803
|
+
if (scope && merged.scope === undefined) {
|
|
3804
|
+
const normalized = String(scope).trim().toLowerCase();
|
|
3805
|
+
if (!TOOL_SCOPES.includes(normalized)) {
|
|
3806
|
+
throw new Error('Invalid --scope "' + scope + '". Valid values: ' + TOOL_SCOPES.join(', '));
|
|
3807
|
+
}
|
|
3808
|
+
merged.scope = normalized;
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
const targetAccountId = flags && (flags['target-account-id'] || flags.targetAccountId);
|
|
3812
|
+
if (targetAccountId && merged.targetAccountId === undefined) {
|
|
3813
|
+
merged.targetAccountId = String(targetAccountId).trim();
|
|
3814
|
+
}
|
|
3815
|
+
|
|
3816
|
+
const anchorAccountId = flags && (flags['anchor-account-id'] || flags.anchorAccountId);
|
|
3817
|
+
if (anchorAccountId && merged.anchorAccountId === undefined) {
|
|
3818
|
+
merged.anchorAccountId = String(anchorAccountId).trim();
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3821
|
+
const accountIds = flags && (flags['account-ids'] || flags.accountIds);
|
|
3822
|
+
if (accountIds && merged.accountIds === undefined) {
|
|
3823
|
+
const parsed = String(accountIds)
|
|
3824
|
+
.split(',')
|
|
3825
|
+
.map((part) => Number(String(part).trim()))
|
|
3826
|
+
.filter((value) => Number.isFinite(value) && value > 0);
|
|
3827
|
+
if (!parsed.length) {
|
|
3828
|
+
throw new Error('--account-ids expects a comma-separated list of numeric account ids');
|
|
3829
|
+
}
|
|
3830
|
+
merged.accountIds = parsed;
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
return merged;
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3510
3836
|
async function toolCommand(flags) {
|
|
3511
3837
|
const cwd = process.cwd();
|
|
3512
3838
|
ensureLocalState(cwd);
|
|
@@ -3525,7 +3851,7 @@ async function toolCommand(flags) {
|
|
|
3525
3851
|
const explicitFlagAccountId = Number(flags && flags['account-id']);
|
|
3526
3852
|
const accountIdExplicit = Number.isFinite(explicitFlagAccountId) && explicitFlagAccountId > 0;
|
|
3527
3853
|
|
|
3528
|
-
const input = parseToolInput(flags);
|
|
3854
|
+
const input = applyScopeFlags(parseToolInput(flags), flags);
|
|
3529
3855
|
const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
|
|
3530
3856
|
const branchName = flags.branch || currentBranch(cwd);
|
|
3531
3857
|
const dataMode = resolveDataMode(flags, session);
|
|
@@ -3591,7 +3917,12 @@ async function toolCommand(flags) {
|
|
|
3591
3917
|
token: session.token,
|
|
3592
3918
|
accountId,
|
|
3593
3919
|
accountIdExplicit,
|
|
3920
|
+
// The repo/session account, sent as a FIRST-CLASS value rather than left to be inferred from
|
|
3921
|
+
// `accountId`: a read tool may reinterpret its own `input.accountId`, and the scope root — what bounds
|
|
3922
|
+
// what the call may discover — has to survive that. The server validates it; it is never authorization.
|
|
3923
|
+
scopeRootAccountId: accountId,
|
|
3594
3924
|
branchName,
|
|
3925
|
+
workspace: resolveWorkspace(cwd, flags),
|
|
3595
3926
|
dataMode,
|
|
3596
3927
|
variantBranch,
|
|
3597
3928
|
name: String(toolName),
|
|
@@ -4415,6 +4746,7 @@ function exportIssueBundle(snapshot) {
|
|
|
4415
4746
|
agents: {
|
|
4416
4747
|
localCount: snapshot.agents && snapshot.agents.local ? snapshot.agents.local.length : 0,
|
|
4417
4748
|
registeredCount: snapshot.agents && snapshot.agents.registered ? snapshot.agents.registered.length : 0,
|
|
4749
|
+
workerCount: snapshot.agents && snapshot.agents.workers ? snapshot.agents.workers.length : 0,
|
|
4418
4750
|
errors: (snapshot.agents && snapshot.agents.errors) || []
|
|
4419
4751
|
},
|
|
4420
4752
|
repos: (snapshot.repos || []).map((repo) => ({
|
|
@@ -4528,6 +4860,31 @@ function buildRepoSnapshot(entry) {
|
|
|
4528
4860
|
};
|
|
4529
4861
|
}
|
|
4530
4862
|
|
|
4863
|
+
/**
|
|
4864
|
+
* The first argument that was actually SUPPLIED, empty string included.
|
|
4865
|
+
*
|
|
4866
|
+
* `a || b` cannot express this: an empty string is a real value on any field that can be cleared, and it
|
|
4867
|
+
* is falsy, so `||` silently turns "clear this" into "say nothing about this".
|
|
4868
|
+
*/
|
|
4869
|
+
function firstDefined(...values) {
|
|
4870
|
+
for (const value of values) {
|
|
4871
|
+
if (value !== undefined && value !== null) return value;
|
|
4872
|
+
}
|
|
4873
|
+
return undefined;
|
|
4874
|
+
}
|
|
4875
|
+
|
|
4876
|
+
/**
|
|
4877
|
+
* A planning flag's value, where a valueless flag means CLEAR.
|
|
4878
|
+
*
|
|
4879
|
+
* `parseArgs` reports both `--planned-in` and `--planned-in ""` as `true`, because an empty next argument
|
|
4880
|
+
* is indistinguishable from an absent one. A planning slot is a field an org must be able to blank, so the
|
|
4881
|
+
* valueless form is given that meaning rather than being sent to the server as the literal string "true".
|
|
4882
|
+
*/
|
|
4883
|
+
function planningFlagValue(value) {
|
|
4884
|
+
if (value === undefined || value === null) return undefined;
|
|
4885
|
+
return value === true ? '' : value;
|
|
4886
|
+
}
|
|
4887
|
+
|
|
4531
4888
|
function compactSupportTicketRow(row) {
|
|
4532
4889
|
if (!row || typeof row !== 'object') {
|
|
4533
4890
|
return null;
|
|
@@ -4545,9 +4902,18 @@ function compactSupportTicketRow(row) {
|
|
|
4545
4902
|
routedAgentId: row.routedAgentId || null,
|
|
4546
4903
|
routedAgentLabel: row.routedAgentLabel || null,
|
|
4547
4904
|
routedAt: row.routedAt || null,
|
|
4905
|
+
claimedAgentId: row.claimedAgentId || null,
|
|
4906
|
+
awaitingResponseSince: row.awaitingResponseSince || null,
|
|
4907
|
+
awaitingResponseQuestion: row.awaitingResponseQuestion || null,
|
|
4548
4908
|
implementationAccountId: row.implementationAccountId || null,
|
|
4549
4909
|
implementationAccountName: row.implementationAccountName || null,
|
|
4550
4910
|
affectedComponent: row.affectedComponent || null,
|
|
4911
|
+
workstream: row.workstream || null,
|
|
4912
|
+
plannedIn: row.plannedIn || null,
|
|
4913
|
+
boardStage: row.boardStage || null,
|
|
4914
|
+
rank: row.rank === undefined ? null : row.rank,
|
|
4915
|
+
size: row.size || null,
|
|
4916
|
+
blockedBy: row.blockedBy || null,
|
|
4551
4917
|
updatedAt: row.updatedAt || row.updated || row.lastActivityAt || null,
|
|
4552
4918
|
activityAt: row.activityAt || row.lastActivityAt || null,
|
|
4553
4919
|
activityLabel: row.activityLabel || null
|
|
@@ -4565,8 +4931,13 @@ function compactSupportTicketRow(row) {
|
|
|
4565
4931
|
async function fetchSupportTicketQueueForSession(session) {
|
|
4566
4932
|
const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
|
|
4567
4933
|
const api = buildAxios(baseUrl, session.token, 15000);
|
|
4934
|
+
// The lane is PINNED, not left to the server's default. Without it the queue this row claims to
|
|
4935
|
+
// describe is whichever lane the platform happened to choose, so a row labelled with a session's
|
|
4936
|
+
// lane could be showing the other one.
|
|
4937
|
+
const dataMode = normalizeDataMode(session.dataMode || 'prod');
|
|
4568
4938
|
const result = await api.post('/cli/tickets', {
|
|
4569
4939
|
accountId: Number(session.accountId),
|
|
4940
|
+
dataMode,
|
|
4570
4941
|
includeChildren: true,
|
|
4571
4942
|
sortBy: 'triage',
|
|
4572
4943
|
limit: Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)
|
|
@@ -4580,7 +4951,8 @@ async function fetchSupportTicketQueueForSession(session) {
|
|
|
4580
4951
|
baseUrl,
|
|
4581
4952
|
accountId: Number(session.accountId),
|
|
4582
4953
|
userId: session.user && session.user.id ? session.user.id : null,
|
|
4583
|
-
dataMode: (result && result.dataMode) ||
|
|
4954
|
+
dataMode: (result && result.dataMode) || dataMode,
|
|
4955
|
+
compartment: baseUrl + ' · ' + ((result && result.dataMode) || dataMode) + ' lane',
|
|
4584
4956
|
totalCount: (result && result.totalCount) || tickets.length,
|
|
4585
4957
|
count: (result && result.count) || tickets.length,
|
|
4586
4958
|
counts: (result && result.counts) || {},
|
|
@@ -4604,27 +4976,46 @@ async function fetchSupportTicketQueueForSession(session) {
|
|
|
4604
4976
|
* Goes through the same platform endpoint an operator Embeddable would use, so "route this ticket"
|
|
4605
4977
|
* has ONE implementation and one access check whichever surface asked for it.
|
|
4606
4978
|
*/
|
|
4607
|
-
|
|
4979
|
+
/**
|
|
4980
|
+
* Route a ticket from the dashboard, on the platform and lane that ticket actually lives on.
|
|
4981
|
+
*
|
|
4982
|
+
* <p>Selecting the session by {@code accountId} alone — and falling back to any authenticated session
|
|
4983
|
+
* at all — was a cross-platform write triggered from a button. Account 1 exists on production and on
|
|
4984
|
+
* a localhost instance both, so routing a localhost test fixture could reach production, or the
|
|
4985
|
+
* reverse. The ticket carries the compartment it came from; that is what decides where the write goes,
|
|
4986
|
+
* and if no session matches it we refuse rather than write somewhere plausible.</p>
|
|
4987
|
+
*/
|
|
4988
|
+
async function routeTicketFromDashboard({ ticketId, accountId, agentId, baseUrl, dataMode }) {
|
|
4608
4989
|
const numericAccountId = Number(accountId);
|
|
4609
4990
|
if (!ticketId || !Number.isFinite(numericAccountId)) {
|
|
4610
4991
|
throw new Error('Routing needs a ticketId and an accountId.');
|
|
4611
4992
|
}
|
|
4612
|
-
const
|
|
4613
|
-
|
|
4614
|
-
|
|
4993
|
+
const wantedBase = baseUrl ? normalizeBaseUrl(baseUrl) : null;
|
|
4994
|
+
const wantedMode = dataMode ? normalizeDataMode(dataMode) : null;
|
|
4995
|
+
const candidates = listSessionsFromStore(readAllSessions()).filter((entry) => {
|
|
4996
|
+
if (!entry || !entry.token) return false;
|
|
4997
|
+
if (wantedBase && normalizeBaseUrl(entry.baseUrl || DEFAULT_BASE_URL) !== wantedBase) return false;
|
|
4998
|
+
if (wantedMode && normalizeDataMode(entry.dataMode || 'prod') !== wantedMode) return false;
|
|
4999
|
+
return true;
|
|
5000
|
+
});
|
|
5001
|
+
const session = candidates.find((entry) => Number(entry.accountId) === numericAccountId) || candidates[0];
|
|
4615
5002
|
if (!session) {
|
|
4616
|
-
throw new Error('No authenticated session
|
|
5003
|
+
throw new Error('No authenticated session for ' + (wantedBase || 'that platform') +
|
|
5004
|
+
(wantedMode ? ' in the ' + wantedMode + ' lane' : '') + '. Run: remits-cli auth --base-url ' +
|
|
5005
|
+
(wantedBase || DEFAULT_BASE_URL));
|
|
4617
5006
|
}
|
|
4618
|
-
const
|
|
4619
|
-
const result = await buildAxios(
|
|
5007
|
+
const targetBaseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
|
|
5008
|
+
const result = await buildAxios(targetBaseUrl, session.token, 15000)
|
|
4620
5009
|
.post('/cli/ticketRoute', {
|
|
4621
5010
|
ticketId: String(ticketId),
|
|
4622
5011
|
accountId: numericAccountId,
|
|
4623
5012
|
agentId: agentId || null,
|
|
4624
|
-
dataMode: session.dataMode
|
|
5013
|
+
dataMode: wantedMode || session.dataMode
|
|
4625
5014
|
}).then((r) => r.data);
|
|
4626
5015
|
appendGlobalActivityLog('ticket', 'routed', {
|
|
4627
|
-
ticketId, accountId: numericAccountId, agentId: agentId || null,
|
|
5016
|
+
ticketId, accountId: numericAccountId, agentId: agentId || null,
|
|
5017
|
+
baseUrl: targetBaseUrl, dataMode: wantedMode || session.dataMode,
|
|
5018
|
+
success: result && result.success
|
|
4628
5019
|
});
|
|
4629
5020
|
if (result && result.success === false) {
|
|
4630
5021
|
throw new Error(result.message || 'Routing was refused.');
|
|
@@ -4657,7 +5048,12 @@ async function collectAgentSnapshot() {
|
|
|
4657
5048
|
.post('/cli/agents', { accountId: Number(session.accountId), dataMode: session.dataMode })
|
|
4658
5049
|
.then((r) => r.data);
|
|
4659
5050
|
for (const agent of (result && result.agents) || []) {
|
|
4660
|
-
|
|
5051
|
+
// Stamp the platform and lane it was found on. Agent ids are UUIDs so a collision is unlikely,
|
|
5052
|
+
// but a merged list with no provenance cannot tell a localhost fixture agent from a production
|
|
5053
|
+
// one — the same blending that made the ticket panel report 583.
|
|
5054
|
+
if (agent && agent.agentId) {
|
|
5055
|
+
byAgentId[baseUrl + '|' + agent.agentId] = { ...agent, baseUrl, lane: agent.dataMode || session.dataMode };
|
|
5056
|
+
}
|
|
4661
5057
|
}
|
|
4662
5058
|
} catch (err) {
|
|
4663
5059
|
errors.push({ baseUrl, accountId: Number(session.accountId), message: describeError(err) });
|
|
@@ -4670,12 +5066,110 @@ async function collectAgentSnapshot() {
|
|
|
4670
5066
|
registered: Object.values(byAgentId)
|
|
4671
5067
|
.sort((a, b) => Number(b.lastSeenAtMs || 0) - Number(a.lastSeenAtMs || 0)),
|
|
4672
5068
|
local: listLocalAgents(),
|
|
5069
|
+
// Workers are a purely LOCAL fact — the platform knows a ticket is claimed, not that a process on
|
|
5070
|
+
// this laptop is running it — so they are read from disk here rather than from any endpoint.
|
|
5071
|
+
workers: listLocalAgents().reduce((all, entry) => all.concat(
|
|
5072
|
+
listWorkers(entry.agentId).map((worker) => ({
|
|
5073
|
+
...worker,
|
|
5074
|
+
agentId: entry.agentId,
|
|
5075
|
+
label: entry.label || entry.agentId,
|
|
5076
|
+
alive: !!(worker.pid && processIsAlive(worker.pid))
|
|
5077
|
+
}))), []),
|
|
4673
5078
|
errors
|
|
4674
5079
|
};
|
|
4675
5080
|
agentSnapshotCache = { fetchedAtMs: nowMs, value };
|
|
4676
5081
|
return value;
|
|
4677
5082
|
}
|
|
4678
5083
|
|
|
5084
|
+
/**
|
|
5085
|
+
* Split the fetched queues into the compartments a number can honestly be reported for.
|
|
5086
|
+
*
|
|
5087
|
+
* <p>A compartment is one <b>platform and one data lane</b>. Blending them was the headline defect:
|
|
5088
|
+
* the panel summed a production platform's queue with a localhost test-lane queue full of suite
|
|
5089
|
+
* fixtures and presented the result as "OPEN". Prod showed 24; the panel showed 583.</p>
|
|
5090
|
+
*
|
|
5091
|
+
* <p>Three narrower defects rode along with it, and each would still be wrong after separating the
|
|
5092
|
+
* regions:</p>
|
|
5093
|
+
* <ul>
|
|
5094
|
+
* <li><b>The sum double-counted.</b> Two sessions whose account hierarchies overlap both return the
|
|
5095
|
+
* same ticket, and the totals were summed per queue while only the DISPLAYED list was deduped.
|
|
5096
|
+
* Overlap is detectable: {@code /cli/tickets} reports the accounts it actually scanned, so a
|
|
5097
|
+
* queue whose scanned set is contained in another's is dropped before anything is added up.</li>
|
|
5098
|
+
* <li><b>Ticket ids were deduped globally.</b> An id is unique within a platform, not across them —
|
|
5099
|
+
* so a localhost fixture with the same id as a real production ticket silently hid it. Keys are
|
|
5100
|
+
* per compartment now.</li>
|
|
5101
|
+
* <li><b>"Waiting" was total minus routed</b>, which is <i>unrouted</i>, not waiting. With a real
|
|
5102
|
+
* awaiting-a-reply concept on the record, that label was pointing at the wrong thing entirely.</li>
|
|
5103
|
+
* </ul>
|
|
5104
|
+
*/
|
|
5105
|
+
function buildTicketCompartments(queues) {
|
|
5106
|
+
const byCompartment = {};
|
|
5107
|
+
for (const queue of queues || []) {
|
|
5108
|
+
const key = queue.compartment || (queue.baseUrl + ' · ' + (queue.dataMode || 'prod') + ' lane');
|
|
5109
|
+
if (!byCompartment[key]) {
|
|
5110
|
+
byCompartment[key] = { compartment: key, baseUrl: queue.baseUrl, dataMode: queue.dataMode, queues: [] };
|
|
5111
|
+
}
|
|
5112
|
+
byCompartment[key].queues.push(queue);
|
|
5113
|
+
}
|
|
5114
|
+
|
|
5115
|
+
return Object.values(byCompartment).map((group) => {
|
|
5116
|
+
// Drop any queue whose scanned accounts are wholly contained in another's: its tickets are already
|
|
5117
|
+
// in that one, so keeping it would count them twice. Equal sets keep the first seen.
|
|
5118
|
+
const scanned = group.queues.map((queue) => new Set(
|
|
5119
|
+
(queue.searchedAccounts || []).map((entry) => String(entry.accountId || entry.accountScope || ''))));
|
|
5120
|
+
const independent = group.queues.filter((queue, i) => {
|
|
5121
|
+
if (!scanned[i].size) {
|
|
5122
|
+
return true; // nothing reported: cannot prove overlap, so keep it and report honestly below
|
|
5123
|
+
}
|
|
5124
|
+
return !group.queues.some((other, j) => {
|
|
5125
|
+
if (i === j || !scanned[j].size) return false;
|
|
5126
|
+
const contained = [...scanned[i]].every((id) => scanned[j].has(id));
|
|
5127
|
+
if (!contained) return false;
|
|
5128
|
+
// Identical scopes: keep the earlier one, drop the later, or both would drop each other.
|
|
5129
|
+
const identical = scanned[i].size === scanned[j].size;
|
|
5130
|
+
return identical ? j < i : true;
|
|
5131
|
+
});
|
|
5132
|
+
});
|
|
5133
|
+
|
|
5134
|
+
const ticketsById = {};
|
|
5135
|
+
for (const queue of independent) {
|
|
5136
|
+
for (const ticket of queue.tickets || []) {
|
|
5137
|
+
const id = String(ticket.ticketId || '');
|
|
5138
|
+
if (!id || ticketsById[id]) continue;
|
|
5139
|
+
ticketsById[id] = { ...ticket, queueAccountId: queue.accountId, baseUrl: queue.baseUrl,
|
|
5140
|
+
dataMode: queue.dataMode, compartment: group.compartment };
|
|
5141
|
+
}
|
|
5142
|
+
}
|
|
5143
|
+
const tickets = Object.values(ticketsById);
|
|
5144
|
+
|
|
5145
|
+
const open = independent.reduce((sum, queue) => sum + Number(queue.totalCount || 0), 0);
|
|
5146
|
+
const routed = independent.reduce((sum, queue) => sum + Number((queue.counts || {}).routed || 0), 0);
|
|
5147
|
+
const assigned = independent.reduce((sum, queue) => sum + Number((queue.counts || {}).assigned || 0), 0);
|
|
5148
|
+
|
|
5149
|
+
return {
|
|
5150
|
+
compartment: group.compartment,
|
|
5151
|
+
baseUrl: group.baseUrl,
|
|
5152
|
+
dataMode: group.dataMode,
|
|
5153
|
+
queueCount: independent.length,
|
|
5154
|
+
overlappingQueuesDropped: group.queues.length - independent.length,
|
|
5155
|
+
// Counted from queues proven to scan disjoint account scopes, so the same ticket is never added
|
|
5156
|
+
// twice. `scopeUnknown` says so out loud when a queue could not report what it scanned — a count
|
|
5157
|
+
// that MIGHT overlap must never read as one that cannot.
|
|
5158
|
+
scopeUnknown: independent.some((queue) => !(queue.searchedAccounts || []).length),
|
|
5159
|
+
open,
|
|
5160
|
+
routed,
|
|
5161
|
+
unrouted: Math.max(0, open - routed),
|
|
5162
|
+
assigned,
|
|
5163
|
+
// Only ever counted over the page actually retrieved, and labelled as such in the UI.
|
|
5164
|
+
awaitingReplyOnPage: tickets.filter((t) => t.awaitingResponseSince).length,
|
|
5165
|
+
accounts: independent.map((queue) => ({
|
|
5166
|
+
accountId: queue.accountId, open: Number(queue.totalCount || 0), scanned: (queue.searchedAccounts || []).length
|
|
5167
|
+
})),
|
|
5168
|
+
tickets: tickets.slice(0, Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100))
|
|
5169
|
+
};
|
|
5170
|
+
}).sort((a, b) => b.open - a.open);
|
|
5171
|
+
}
|
|
5172
|
+
|
|
4679
5173
|
async function collectSupportTicketSnapshot() {
|
|
4680
5174
|
const nowMs = Date.now();
|
|
4681
5175
|
if (supportTicketSnapshotCache.value &&
|
|
@@ -4711,34 +5205,11 @@ async function collectSupportTicketSnapshot() {
|
|
|
4711
5205
|
}
|
|
4712
5206
|
}));
|
|
4713
5207
|
|
|
4714
|
-
const ticketsById = {};
|
|
4715
|
-
for (const queue of queues) {
|
|
4716
|
-
for (const ticket of queue.tickets || []) {
|
|
4717
|
-
const key = String(ticket.ticketId || '');
|
|
4718
|
-
if (!key || ticketsById[key]) {
|
|
4719
|
-
continue;
|
|
4720
|
-
}
|
|
4721
|
-
ticketsById[key] = {
|
|
4722
|
-
...ticket,
|
|
4723
|
-
queueAccountId: queue.accountId,
|
|
4724
|
-
baseUrl: queue.baseUrl
|
|
4725
|
-
};
|
|
4726
|
-
}
|
|
4727
|
-
}
|
|
4728
|
-
|
|
4729
5208
|
const value = {
|
|
4730
5209
|
fetchedAt: new Date(nowMs).toISOString(),
|
|
4731
5210
|
refreshMs: SUPPORT_TICKET_DASHBOARD_REFRESH_MS,
|
|
4732
5211
|
queueCount: queues.length,
|
|
4733
|
-
|
|
4734
|
-
counts: queues.reduce((totals, queue) => {
|
|
4735
|
-
const counts = queue.counts || {};
|
|
4736
|
-
totals.routed = Number(totals.routed || 0) + Number(counts.routed || 0);
|
|
4737
|
-
totals.unrouted = Number(totals.unrouted || 0) + Number(counts.unrouted || 0);
|
|
4738
|
-
totals.assigned = Number(totals.assigned || 0) + Number(counts.assigned || 0);
|
|
4739
|
-
return totals;
|
|
4740
|
-
}, {}),
|
|
4741
|
-
tickets: Object.values(ticketsById).slice(0, Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)),
|
|
5212
|
+
compartments: buildTicketCompartments(queues),
|
|
4742
5213
|
queues,
|
|
4743
5214
|
errors
|
|
4744
5215
|
};
|
|
@@ -4793,7 +5264,8 @@ function renderDashboardHtml(snapshot) {
|
|
|
4793
5264
|
':root{--bg:#f5f1e8;--surface:#fffdf8;--surface-strong:#fff;--ink:#1f1c16;--muted:#6f685d;--line:#d9d0c0;--line-strong:#bbaa93;--green:#1f8f58;--green-soft:#e8f6ee;--yellow:#c38a16;--yellow-soft:#fff6db;--red:#c1493d;--red-soft:#fdeae7;--blue:#2456d3;--blue-soft:#eaf0ff;}' +
|
|
4794
5265
|
'*{box-sizing:border-box;}body{margin:0;font-family:Menlo,Monaco,monospace;background:radial-gradient(circle at top left,#fffdf8 0,#f5f1e8 48%,#ece4d7 100%);color:var(--ink);}h1,h2,h3,h4,summary{font-family:Georgia,serif;}button{font:inherit;}a{color:var(--blue);}main{max-width:1440px;margin:0 auto;padding:24px;}header.hero{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;margin-bottom:20px;padding:24px;border:1px solid var(--line);border-radius:22px;background:linear-gradient(135deg,#fffefb 0,#f8f1e3 100%);box-shadow:0 14px 40px rgba(72,53,22,.08);}header.hero p{margin:8px 0 0 0;max-width:760px;color:var(--muted);line-height:1.5;} .hero-meta{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end;} .pill{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid var(--line);border-radius:999px;background:rgba(255,255,255,.75);font-size:12px;} .status-grid,.section-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px;} .section-grid{margin-top:18px;} .status-card,.panel,.action-card{border:1px solid var(--line);border-radius:18px;background:var(--surface-strong);box-shadow:0 10px 28px rgba(72,53,22,.06);} .status-card{padding:18px;position:relative;overflow:hidden;} .status-card::after{content:"";position:absolute;inset:auto -40px -40px auto;width:120px;height:120px;border-radius:999px;background:rgba(0,0,0,.03);} .status-head{display:flex;align-items:center;gap:12px;margin-bottom:12px;} .status-dot{width:14px;height:14px;border-radius:50%;display:inline-block;box-shadow:0 0 0 4px rgba(0,0,0,.04);} .ok .status-dot{background:var(--green);} .warn .status-dot{background:var(--yellow);} .bad .status-dot{background:var(--red);} .status-value{font-size:30px;line-height:1;font-family:Georgia,serif;} .status-label{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);} .status-sub{margin-top:10px;color:var(--muted);font-size:13px;line-height:1.45;} .status-card.ok{background:linear-gradient(180deg,var(--surface-strong),var(--green-soft));} .status-card.warn{background:linear-gradient(180deg,var(--surface-strong),var(--yellow-soft));} .status-card.bad{background:linear-gradient(180deg,var(--surface-strong),var(--red-soft));}' +
|
|
4795
5266
|
'.action-card{padding:18px;margin:18px 0 0 0;background:linear-gradient(180deg,#fffdf9,#f7f1e6);} .action-row{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;} .action-btn{padding:10px 14px;border-radius:12px;border:1px solid var(--line-strong);background:var(--surface-strong);cursor:pointer;transition:transform .12s ease,background .12s ease,border-color .12s ease;} .action-btn:hover{transform:translateY(-1px);background:#fff7ea;} .action-btn:disabled{opacity:.55;cursor:wait;transform:none;} .panel{padding:18px;} .panel h2{margin:0 0 12px 0;font-size:22px;} .muted{color:var(--muted);} .summary-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-top:8px;} .summary-item{padding:12px;border:1px solid var(--line);border-radius:14px;background:#fcfaf4;} .summary-item .k{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:6px;} .summary-item .v{font-size:18px;font-family:Georgia,serif;}' +
|
|
4796
|
-
'.
|
|
5267
|
+
'.wlog{max-height:460px;overflow:auto;background:#1f1c16;color:#f4ead7;border-radius:12px;border:1px solid #3f382d;padding:12px;font-size:12px;line-height:1.5;} .wlog .ev{padding:6px 8px;border-radius:8px;margin-bottom:6px;white-space:pre-wrap;word-break:break-word;} .wlog .cmd{background:#2b271f;border-left:3px solid #8fb98f;} .wlog .cmd.bad{border-left-color:#d98b80;} .wlog .msg{background:#262219;border-left:3px solid #e0c07a;} .wlog .think{color:#a89f8c;font-style:italic;} .wlog .edit{background:#2b271f;border-left:3px solid #7aa7d9;} .wlog .res{background:#2f2a1f;border-left:3px solid #e0c07a;font-weight:600;} .wlog .raw{color:#d98b80;} .wlog .out{color:#b8ae99;display:block;margin-top:4px;max-height:180px;overflow:auto;} .wlog-bar{display:flex;align-items:center;gap:10px;margin-bottom:8px;flex-wrap:wrap;} '+
|
|
5268
|
+
'.accordion{display:grid;gap:14px;} details.group,details.item{border:1px solid var(--line);border-radius:16px;background:#fffdf9;overflow:hidden;} details.group[open],details.item[open]{border-color:var(--line-strong);box-shadow:0 10px 24px rgba(72,53,22,.05);} summary{list-style:none;cursor:pointer;padding:16px 18px;display:flex;align-items:center;justify-content:space-between;gap:16px;} summary::-webkit-details-marker{display:none;} .summary-main{display:flex;align-items:center;gap:12px;min-width:0;} .summary-text{display:flex;flex-direction:column;gap:4px;min-width:0;} .summary-title{font-size:18px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .summary-meta{font-size:12px;color:var(--muted);} .summary-caret{color:var(--muted);font-size:18px;} details[open] > summary .summary-caret{transform:rotate(90deg);} .group-body,.item-body{padding:0 18px 18px 18px;} .mini-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-bottom:14px;} .mini-card{padding:12px;border:1px solid var(--line);border-radius:12px;background:#fcfaf4;} .mini-card strong{display:block;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:6px;} .code-block{white-space:pre-wrap;overflow:auto;max-height:320px;padding:14px;border:1px solid var(--line);border-radius:12px;background:#f7f4ee;font-size:12px;line-height:1.45;} .code-block.log{max-height:420px;background:#1f1c16;color:#f4ead7;border-color:#3f382d;} .stack{display:grid;gap:12px;} .badge{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:999px;border:1px solid var(--line);font-size:11px;text-transform:uppercase;letter-spacing:.08em;} .badge.ok{background:var(--green-soft);border-color:#b9dfc9;color:#175a39;} .badge.warn{background:var(--yellow-soft);border-color:#edd69c;color:#895c08;} .badge.bad{background:var(--red-soft);border-color:#efc1bb;color:#8b322a;} .repo-files{display:grid;gap:12px;} .empty{padding:18px;border:1px dashed var(--line-strong);border-radius:16px;background:#fcfaf4;color:var(--muted);} .toast{position:fixed;right:18px;bottom:18px;padding:12px 14px;border-radius:14px;border:1px solid var(--line-strong);background:#fffdf8;box-shadow:0 14px 24px rgba(72,53,22,.16);display:none;max-width:360px;} .toast.show{display:block;} @media (max-width:900px){main{padding:16px;}header.hero{flex-direction:column;} .hero-meta{justify-content:flex-start;}}' +
|
|
4797
5269
|
'</style>' +
|
|
4798
5270
|
'</head><body>' +
|
|
4799
5271
|
'<main>' +
|
|
@@ -4831,6 +5303,9 @@ function renderDashboardHtml(snapshot) {
|
|
|
4831
5303
|
'<div class="section-grid">' +
|
|
4832
5304
|
'<section class="panel"><h2>Available Agents</h2><div id="agent-section" class="accordion"></div></section>' +
|
|
4833
5305
|
'</div>' +
|
|
5306
|
+
'<div class="section-grid">' +
|
|
5307
|
+
'<section class="panel"><h2>Ticket Workers</h2><div id="worker-section" class="accordion"></div></section>' +
|
|
5308
|
+
'</div>' +
|
|
4834
5309
|
'<div class="section-grid">' +
|
|
4835
5310
|
'<section class="panel"><h2>Support Tickets</h2><div id="support-ticket-section" class="accordion"></div></section>' +
|
|
4836
5311
|
'</div>' +
|
|
@@ -4864,15 +5339,16 @@ function renderDashboardHtml(snapshot) {
|
|
|
4864
5339
|
'function summaryItem(key,value){return \'<div class="summary-item"><span class="k">\'+escapeHtml(key)+\'</span><span class="v">\'+escapeHtml(value)+\'</span></div>\';}' +
|
|
4865
5340
|
'function codeBlock(key,content,extraClass){return \'<pre class="code-block \'+(extraClass||"")+\'" data-scroll-key="\'+escapeHtml(key)+\'">\'+escapeHtml(content||"[missing]")+\'</pre>\';}' +
|
|
4866
5341
|
'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>\';}' +
|
|
4867
|
-
'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
|
|
5342
|
+
'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 ticketParts=ticketState.compartments||[];const ticketTotal=ticketParts.reduce(function(s,p){return s+Number(p.open||0);},0);const agentState=state.agents||{};const agentList=agentState.registered||[];const idleAgents=agentList.filter(function(a){return (a.state||"idle")==="idle";}).length;const cards=[' +
|
|
4868
5343
|
'{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")},' +
|
|
4869
5344
|
'{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")},' +
|
|
4870
|
-
'{title:"Tickets",value:String(ticketTotal),label:"Open
|
|
4871
|
-
'{title:"Agents",value:String(agentList.length),label:"Registered local sessions",tone:agentList.length?"ok":"warn",sub:agentList.length?String(idleAgents)+" idle, "+String(agentList.length-idleAgents)+" working":"No terminal has run: remits-cli agent
|
|
5345
|
+
'{title:"Tickets",value:String(ticketTotal),label:"Open, all platforms and lanes",tone:ticketErrors?"warn":"ok",sub:ticketErrors?String(ticketErrors)+" queue fetch error(s)":(ticketParts.length>1?ticketParts.map(function(p){return String(p.open||0)+" "+(p.dataMode||"prod");}).join(" · "):"One compartment")},' +
|
|
5346
|
+
'{title:"Agents",value:String(agentList.length),label:"Registered local sessions",tone:agentList.length?"ok":"warn",sub:agentList.length?String(idleAgents)+" idle, "+String(agentList.length-idleAgents)+" working":"No terminal has run: remits-cli agent serve"},' +
|
|
5347
|
+
'{title:"Workers",value:String(((state.agents||{}).workers||[]).length),label:"Autonomous ticket runs",tone:((state.agents||{}).workers||[]).length?"ok":"warn",sub:((state.agents||{}).workers||[]).length?"One headless process per ticket":"Nothing running. Start with: remits-cli agent serve"},' +
|
|
4872
5348
|
'{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"},' +
|
|
4873
5349
|
'{title:"Repos",value:String(repoCount),label:"Indexed account repos",tone:repoCount?"ok":"warn",sub:"Last scan: "+formatTime(state.discovery&&state.discovery.scannedAt)}' +
|
|
4874
5350
|
'];document.getElementById("status-grid").innerHTML=cards.map(function(card){return \'<section class="status-card \'+card.tone+\'"><div class="status-head"><span class="status-dot"></span><div><div class="status-label">\'+escapeHtml(card.title)+\'</div><div class="status-value">\'+escapeHtml(card.value)+\'</div></div></div><div class="status-sub"><strong>\'+escapeHtml(card.label)+\':</strong> \'+escapeHtml(card.sub)+\'</div></section>\';}).join("");}' +
|
|
4875
|
-
'function renderSummary(state){const ws=Object.values((state.websocket&&state.websocket.connections)||{});const topics=ws.reduce(function(sum,entry){return sum+Object.keys(entry.topics||{}).length;},0);const agentList=(state.agents||{}).registered||[];const
|
|
5351
|
+
'function renderSummary(state){const ws=Object.values((state.websocket&&state.websocket.connections)||{});const topics=ws.reduce(function(sum,entry){return sum+Object.keys(entry.topics||{}).length;},0);const agentList=(state.agents||{}).registered||[];const ticketParts2=(state.supportTickets||{}).compartments||[];const ticketCounts={routed:ticketParts2.reduce(function(s,p){return s+Number(p.routed||0);},0)};document.getElementById("summary-grid").innerHTML=[' +
|
|
4876
5352
|
'summaryItem("PID",state.service&&state.service.pid||"n/a"),' +
|
|
4877
5353
|
'summaryItem("Dashboard",state.service&&state.service.dashboardUrl||"n/a"),' +
|
|
4878
5354
|
'summaryItem("Repo Matches",String(state.discovery&&state.discovery.matches||0)),' +
|
|
@@ -4884,19 +5360,25 @@ function renderDashboardHtml(snapshot) {
|
|
|
4884
5360
|
'].join("");}' +
|
|
4885
5361
|
'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>\':"");}' +
|
|
4886
5362
|
'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("");}' +
|
|
4887
|
-
'function renderSupportTickets(state){const target=document.getElementById("support-ticket-section");const support=state.supportTickets||{};const
|
|
4888
|
-
'function supportTicketDetails(state,ticket,key){const agents=((state.agents||{}).registered)||[];const ticketAccountId=String(ticket.accountId||"");const eligible=agents.filter(function(a){return (a.accounts||[]).some(function(acc){return String(acc.accountId)===ticketAccountId||acc.routingScope==="self_and_descendants";});});const options=[\'<option value="">- unrouted -</option>\'].concat(eligible.map(function(a){const sel=a.agentId===ticket.routedAgentId?" selected":"";return \'<option value="\'+escapeHtml(a.agentId)+\'"\'+sel+\'>\'+escapeHtml((a.label||a.userName||a.agentId)+" - "+(a.state||"idle")+(a.activity?" ("+a.activity+")":""))+\'</option>\';})).join("");const routeControl=eligible.length?(\'<div class="mini-card"><strong>Route To Agent</strong><div style="display:flex;gap:8px;margin-top:6px;"><select data-route-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'" data-route-account="\'+escapeHtml(ticketAccountId)+\'" style="flex:1;min-width:0;">\'+options+\'</select><button class="action-btn" data-action="route-ticket" data-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'">Route</button></div></div>\'):\'<div class="mini-card"><strong>Route To Agent</strong>No registered agent covers this account.</div>\';const
|
|
5363
|
+
'function renderSupportTickets(state){const target=document.getElementById("support-ticket-section");const support=state.supportTickets||{};const parts=support.compartments||[];const errors=support.errors||[];let html="";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(!parts.length&&!errors.length){target.innerHTML=html+\'<div class="empty">No authenticated support-ticket queue is available yet.</div>\';return;}html+=parts.map(function(part,pi){const lane=(part.dataMode||"prod");const meta=badge(lane+" lane",lane==="test"?"warn":"ok")+\' \'+badge(String(part.open||0)+" open","ok")+\' <span class="muted">\'+escapeHtml(String(part.queueCount||0))+\' account queue(s)\'+(part.overlappingQueuesDropped?", "+part.overlappingQueuesDropped+" overlapping dropped":"")+\'</span>\';const caveat=part.scopeUnknown?\'<div class="mini-card"><strong>Note</strong>A queue did not report which accounts it scanned, so this total may count a ticket more than once.</div>\':"";const cards=\'<div class="mini-grid"><div class="mini-card"><strong>Open</strong>\'+escapeHtml(String(part.open||0))+\'</div><div class="mini-card"><strong>Routed To An Agent</strong>\'+escapeHtml(String(part.routed||0))+\'</div><div class="mini-card"><strong>Unrouted</strong>\'+escapeHtml(String(part.unrouted||0))+\'</div><div class="mini-card"><strong>Awaiting A Reply</strong>\'+escapeHtml(String(part.awaitingReplyOnPage||0))+\' <span class="muted">of \'+escapeHtml(String((part.tickets||[]).length))+\' shown</span></div></div>\'+caveat;const rows=(part.tickets||[]).length?(part.tickets||[]).map(function(ticket,index){const routed=!!ticket.routedAgentId;const waiting=!!ticket.awaitingResponseSince;const meta2=badge(ticket.priority||"priority",ticket.priority==="critical"||ticket.priority==="high"?"bad":"warn")+\' \'+badge(ticket.status||"status",ticket.status==="open"?"ok":"warn")+\' \'+badge(routed?("routed: "+(ticket.routedAgentLabel||ticket.routedAgentId)):"unrouted",routed?"ok":"warn")+(waiting?\' \'+badge("awaiting reply","warn"):"")+\' <span class="muted">\'+escapeHtml(ticket.accountName||ticket.accountId||"unknown account")+\'</span>\';return detailsBlock("support-ticket:"+pi+":"+ticket.ticketId,String(ticket.ticketId||"ticket")+" "+(ticket.subject||""),meta2,supportTicketDetails(state,ticket,"support-ticket:"+pi+":"+index),false);}).join(""):\'<div class="empty">No open support tickets in this compartment.</div>\';return detailsBlock("compartment:"+part.compartment,part.baseUrl||part.compartment,meta,cards+\'<div class="accordion">\'+rows+\'</div>\',pi===0);}).join("");html+=\'<div class="muted" style="margin-top:10px;">Each platform and data lane is counted separately. A localhost test-lane queue is full of suite fixtures and is not comparable to a production queue — summing them is what made this panel report 583 open tickets against a production queue of 24.</div>\';target.innerHTML=html;}' +
|
|
5364
|
+
'function supportTicketDetails(state,ticket,key){const agents=((state.agents||{}).registered)||[];const ticketAccountId=String(ticket.accountId||"");const eligible=agents.filter(function(a){if(ticket.baseUrl&&a.baseUrl&&a.baseUrl!==ticket.baseUrl)return false;if(ticket.dataMode&&a.dataMode&&a.dataMode!==ticket.dataMode)return false;return (a.accounts||[]).some(function(acc){return String(acc.accountId)===ticketAccountId||acc.routingScope==="self_and_descendants";});});const options=[\'<option value="">- unrouted -</option>\'].concat(eligible.map(function(a){const sel=a.agentId===ticket.routedAgentId?" selected":"";return \'<option value="\'+escapeHtml(a.agentId)+\'"\'+sel+\'>\'+escapeHtml((a.label||a.userName||a.agentId)+" - "+(a.state||"idle")+(a.activity?" ("+a.activity+")":""))+\'</option>\';})).join("");const routeControl=eligible.length?(\'<div class="mini-card"><strong>Route To Agent</strong><div style="display:flex;gap:8px;margin-top:6px;"><select data-route-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'" data-route-account="\'+escapeHtml(ticketAccountId)+\'" data-route-base="\'+escapeHtml(ticket.baseUrl||"")+\'" data-route-mode="\'+escapeHtml(ticket.dataMode||"")+\'" style="flex:1;min-width:0;">\'+options+\'</select><button class="action-btn" data-action="route-ticket" data-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'">Route</button></div></div>\'):\'<div class="mini-card"><strong>Route To Agent</strong>No registered agent covers this account.</div>\';const lane=ticket.dataMode||"prod";const planning=[ticket.workstream?("Workstream "+ticket.workstream):"",ticket.plannedIn?("Planned "+ticket.plannedIn):"",ticket.boardStage?("Board "+ticket.boardStage):"",ticket.rank!==undefined&&ticket.rank!==null&&ticket.rank!==""?("Rank "+ticket.rank):"",ticket.size?("Size "+ticket.size):"",ticket.blockedBy?("Blocked by "+ticket.blockedBy):""].filter(Boolean).join(" / ");const command="remits-cli ticket read --ticket "+String(ticket.ticketId||"")+" --data-mode "+lane+"\\nremits-cli ticket accept --ticket "+String(ticket.ticketId||"")+" --data-mode "+lane;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>Owned By</strong>\'+escapeHtml(ticket.assignedTo||"unassigned")+\'</div><div class="mini-card"><strong>Routed To</strong>\'+escapeHtml(ticket.routedAgentLabel||ticket.routedAgentId||"nobody")+\'</div><div class="mini-card"><strong>Implementation</strong>\'+escapeHtml((ticket.implementationAccountName||"n/a")+" ("+(ticket.implementationAccountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Planning</strong>\'+escapeHtml(planning||"No planning fields")+\'</div></div>\'+routeControl+\'<div class="muted">\'+escapeHtml(ticket.affectedComponent||"No affected component recorded.")+\'</div>\'+codeBlock(key+":cmd",command)+\'</div>\';}' +
|
|
4889
5365
|
'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("");}' +
|
|
4890
5366
|
'function agentTone(agent){if(!agent||agent.online===false)return "bad";const s=agent.state||"idle";return s==="paused"?"warn":"ok";}' +
|
|
4891
|
-
'function
|
|
5367
|
+
'function renderWorkers(state){const target=document.getElementById("worker-section");const workers=((state.agents||{}).workers)||[];if(!workers.length){target.innerHTML=\'<div class="empty">No autonomous ticket workers are running. Start one with <strong>remits-cli agent serve</strong> — it launches a fresh headless agent process per routed ticket, so no session accumulates context across tickets.</div>\';return;}target.innerHTML=workers.map(function(w,index){const meta=badge(w.agentType||"agent",w.alive?"ok":"bad")+\' \'+badge(w.alive?"running":"exited",w.alive?"ok":"bad")+\' <span class="muted">attempt \'+escapeHtml(String(w.attempt||1))+\'</span>\';const t=escapeHtml(String(w.ticketId||""));const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(String(w.ticketId||"?"))+\'</div><div class="mini-card"><strong>Doing Now</strong>\'+escapeHtml(w.activity||"nothing reported yet")+\'</div><div class="mini-card"><strong>Started</strong>\'+escapeHtml(formatTime(w.startedAt))+\'</div><div class="mini-card"><strong>Pid</strong>\'+escapeHtml(String(w.pid||"?"))+\'</div><div class="mini-card"><strong>Repo</strong>\'+escapeHtml(w.workingDirectory||"(none resolved)")+\'</div><div class="mini-card"><strong>Supervisor</strong>\'+escapeHtml(w.label||w.agentId||"?")+\'</div></div><div class="wlog-bar"><button class="action-btn" data-follow-ticket="\'+t+\'">Follow live</button><label class="muted"><input type="checkbox" data-follow-tail="\'+t+\'" checked> stick to bottom</label><span class="muted" data-follow-status="\'+t+\'"></span></div><div class="wlog" data-follow-log="\'+t+\'"><div class="muted">Press <strong>Follow live</strong> to stream this run.</div></div>\'+codeBlock("wlog-tail:"+t,"tail -f "+(w.logFile||""))+\'</div>\';return detailsBlock("worker:"+w.ticketId,"Ticket "+w.ticketId+" - "+(w.subject||"(no subject)"),meta,body,index===0);}).join("");restartActiveFollows();}' +
|
|
5368
|
+
'var followState={};' +
|
|
5369
|
+
'function eventHtml(ev){if(!ev)return "";const t=escapeHtml(ev.text||"");if(ev.kind==="command"){const bad=(ev.exitCode!==null&&ev.exitCode!==undefined&&ev.exitCode!==0);const out=ev.detail?\'<span class="out">\'+escapeHtml(String(ev.detail).slice(0,2000))+\'</span>\':"";return \'<div class="ev cmd\'+(bad?" bad":"")+\'">$ \'+t+(bad?\' <span class="muted">(exit \'+escapeHtml(String(ev.exitCode))+\')</span>\':"")+out+\'</div>\';}if(ev.kind==="message")return \'<div class="ev msg">\'+t+\'</div>\';if(ev.kind==="thinking")return \'<div class="ev think">\'+t+\'</div>\';if(ev.kind==="edit")return \'<div class="ev edit">\'+t+\'</div>\';if(ev.kind==="result")return \'<div class="ev res">\'+t+\'</div>\';if(ev.kind==="raw")return \'<div class="ev raw">\'+t+\'</div>\';return \'<div class="ev">\'+t+\'</div>\';}' +
|
|
5370
|
+
'async function pollFollow(ticket){const st=followState[ticket];if(!st)return;const box=document.querySelector(\'[data-follow-log="\'+ticket+\'"]\');const status=document.querySelector(\'[data-follow-status="\'+ticket+\'"]\');if(!box){return;}try{const r=await fetch("/api/worker-log?ticket="+encodeURIComponent(ticket)+"&offset="+st.offset);const d=await r.json();if(d.missing){status&&(status.textContent="no transcript yet");}else{if(st.offset===0)box.innerHTML="";st.offset=d.offset||st.offset;const html=(d.events||[]).map(eventHtml).join("");if(html){box.insertAdjacentHTML("beforeend",html);const stick=document.querySelector(\'[data-follow-tail="\'+ticket+\'"]\');if(!stick||stick.checked)box.scrollTop=box.scrollHeight;}status&&(status.textContent=(d.running?"live":"finished")+" - "+(d.size||0)+" bytes");if(!d.running&&d.offset>=d.size){st.settleCount=(st.settleCount||0)+1;if(st.settleCount>2){clearInterval(st.timer);delete followState[ticket];return;}}else{st.settleCount=0;}}}catch(e){status&&(status.textContent="stream error");}}' +
|
|
5371
|
+
'function startFollow(ticket){if(followState[ticket]){clearInterval(followState[ticket].timer);}followState[ticket]={offset:0,timer:null};pollFollow(ticket);followState[ticket].timer=setInterval(function(){pollFollow(ticket);},2000);}' +
|
|
5372
|
+
'function restartActiveFollows(){Object.keys(followState).forEach(function(t){const box=document.querySelector(\'[data-follow-log="\'+t+\'"]\');if(!box){clearInterval(followState[t].timer);delete followState[t];return;}const st=followState[t];st.offset=0;box.innerHTML="";pollFollow(t);});}' +
|
|
5373
|
+
'function renderAgents(state){const target=document.getElementById("agent-section");const info=state.agents||{};const agents=info.registered||[];const local=info.local||[];const errors=info.errors||[];let html=\'<div class="mini-grid"><div class="mini-card"><strong>Registered</strong>\'+escapeHtml(String(agents.length))+\'</div><div class="mini-card"><strong>On This Machine</strong>\'+escapeHtml(String(local.length))+\'</div><div class="mini-card"><strong>Fetched</strong>\'+escapeHtml(formatTime(info.fetchedAt))+\'</div></div>\';if(errors.length){html+=detailsBlock("agents:errors","Agent lookup errors",badge(String(errors.length)+" error(s)","warn"),codeBlock("agents-errors",JSON.stringify(errors,null,2),"log"),false);}if(!agents.length){target.innerHTML=html+\'<div class="empty">No local agent sessions are registered. In each terminal tab that should work tickets, run <strong>remits-cli agent register</strong>. Each tab becomes its own routable agent and stops receiving work when the tab closes.</div>\';return;}html+=agents.map(function(agent,index){const tone=agentTone(agent);const meta=badge(agent.state||"idle",tone)+\' \'+badge((agent.dataMode||"prod")+" lane",agent.dataMode==="test"?"warn":"ok")+\' <span class="muted">\'+escapeHtml(agent.baseUrl||"?")+\' · seen \'+escapeHtml(agent.secondsSinceSeen==null?"?":agent.secondsSinceSeen+"s ago")+\'</span>\';const accounts=(agent.accounts||[]).map(function(a){return (a.name||a.accountId)+" ("+a.accountId+(a.routingScope==="self_and_descendants"?", +descendants":"")+")";}).join(", ");const history=(agent.history||[]).map(function(h){return \'<li><span class="muted">\'+escapeHtml(formatTime(h.at))+\'</span> \'+escapeHtml((h.state||"")+(h.ticketId?" ticket "+h.ticketId:"")+(h.activity?" - "+h.activity:""))+\'</li>\';}).join("");const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Doing Now</strong>\'+escapeHtml(agent.activity||"nothing reported")+\'</div><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(agent.ticketId||"none")+\'</div><div class="mini-card"><strong>Step</strong>\'+escapeHtml(agent.step||"n/a")+\'</div><div class="mini-card"><strong>User</strong>\'+escapeHtml(agent.userEmail||agent.userName||"n/a")+\'</div><div class="mini-card"><strong>Where</strong>\'+escapeHtml((agent.host||"?")+":"+(agent.cwd||"?"))+\'</div><div class="mini-card"><strong>Tickets Routed</strong>\'+escapeHtml(String(agent.ticketsAssigned||0))+\'</div></div><div class="mini-card"><strong>Covers</strong>\'+escapeHtml(accounts||"none")+\'</div><div><strong style="display:block;margin:12px 0 8px 0;">Recent activity</strong><ul style="margin:0;padding-left:18px;display:grid;gap:6px;">\'+(history||"<li>Nothing recorded yet.</li>")+\'</ul></div></div>\';return detailsBlock("agent:"+agent.agentId,(agent.label||agent.userName||agent.agentId),meta,body,index===0);}).join("");target.innerHTML=html;}' +
|
|
4892
5374
|
'function renderActivity(state){const target=document.getElementById("activity-section");const logFile=(state.globalFiles||[]).find(function(file){return file.label==="activity.log (tail)";});target.innerHTML=detailsBlock("activity:log","Activity tail","service lifecycle, websocket, and agent events",codeBlock("activity-log",logFile&&logFile.content||"[missing]","log"),true);}' +
|
|
4893
5375
|
'function renderGlobalFiles(state){const target=document.getElementById("global-files");const files=(state.globalFiles||[]).filter(function(file){return file.label!=="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("");}' +
|
|
4894
5376
|
'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("");}' +
|
|
4895
|
-
'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);renderAgents(state);renderActivity(state);renderGlobalFiles(state);renderRepos(state);restoreDetailsState(detailState);restoreScrollState(scrollState);}' +
|
|
5377
|
+
'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);renderAgents(state);renderWorkers(state);renderActivity(state);renderGlobalFiles(state);renderRepos(state);restoreDetailsState(detailState);restoreScrollState(scrollState);}' +
|
|
4896
5378
|
'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();}' +
|
|
4897
5379
|
'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);}' +
|
|
4898
5380
|
'async function triggerAction(action,button,extra){if(actionInFlight)return;actionInFlight=true;button.disabled=true;try{let body="action="+encodeURIComponent(action);Object.keys(extra||{}).forEach(function(k){body+="&"+encodeURIComponent(k)+"="+encodeURIComponent(extra[k]==null?"":extra[k]);});const res=await fetch("/action",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},body:body});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;}}' +
|
|
4899
|
-
'document.addEventListener("click",function(event){const button=event.target.closest?event.target.closest("[data-action]"):null;if(!button)return;const action=button.getAttribute("data-action");let extra=null;if(action==="route-ticket"){const ticketId=button.getAttribute("data-ticket");const select=document.querySelector(\'[data-route-ticket="\'+ticketId+\'"]\');if(!select){showToast("No agent selector for that ticket.");return;}extra={ticketId:ticketId,accountId:select.getAttribute("data-route-account"),agentId:select.value};}triggerAction(action,button,extra);});' +
|
|
5381
|
+
'document.addEventListener("click",function(event){const follow=event.target.closest?event.target.closest("[data-follow-ticket]"):null;if(follow){startFollow(follow.getAttribute("data-follow-ticket"));return;}const button=event.target.closest?event.target.closest("[data-action]"):null;if(!button)return;const action=button.getAttribute("data-action");let extra=null;if(action==="route-ticket"){const ticketId=button.getAttribute("data-ticket");const select=document.querySelector(\'[data-route-ticket="\'+ticketId+\'"]\');if(!select){showToast("No agent selector for that ticket.");return;}extra={ticketId:ticketId,accountId:select.getAttribute("data-route-account"),agentId:select.value,baseUrl:select.getAttribute("data-route-base"),dataMode:select.getAttribute("data-route-mode")};}triggerAction(action,button,extra);});' +
|
|
4900
5382
|
'renderAll(dashboardState);' +
|
|
4901
5383
|
'setInterval(function(){fetchState().then(renderAll).catch(function(err){showToast(err.message||String(err));});},refreshMs);' +
|
|
4902
5384
|
'})();' +
|
|
@@ -4929,6 +5411,172 @@ async function reconnectManagedWebsockets() {
|
|
|
4929
5411
|
}
|
|
4930
5412
|
}
|
|
4931
5413
|
|
|
5414
|
+
/**
|
|
5415
|
+
* Turn a worker's raw JSONL transcript into readable events for the dashboard.
|
|
5416
|
+
*
|
|
5417
|
+
* <p>A worker has no terminal to attach to — it is spawned with pipes, deliberately, and `codex exec`
|
|
5418
|
+
* / `claude -p` are non-interactive, so there is nothing to type at even if there were a tty. What a
|
|
5419
|
+
* reader actually wants is to FOLLOW ALONG, and for that the JSONL is strictly better than a terminal
|
|
5420
|
+
* would be: it is already structured, so commands, their exit codes, file changes and the agent's own
|
|
5421
|
+
* messages can be rendered as distinct things instead of scraped back out of ANSI output.</p>
|
|
5422
|
+
*
|
|
5423
|
+
* <p>Reads incrementally from a byte offset so the browser polls for the tail rather than re-fetching
|
|
5424
|
+
* a transcript that grows to megabytes.</p>
|
|
5425
|
+
*/
|
|
5426
|
+
function readWorkerTranscript(logFile, fromOffset = 0) {
|
|
5427
|
+
const empty = { events: [], offset: 0, size: 0, missing: true };
|
|
5428
|
+
if (!logFile || !fs.existsSync(logFile)) {
|
|
5429
|
+
return empty;
|
|
5430
|
+
}
|
|
5431
|
+
// Only ever serve files from the worker log directory. The dashboard binds to 127.0.0.1, but a path
|
|
5432
|
+
// taken from a query string and handed to readFileSync is a file-read primitive regardless.
|
|
5433
|
+
const resolved = path.resolve(logFile);
|
|
5434
|
+
if (path.dirname(resolved) !== path.resolve(WORKER_LOG_DIR)) {
|
|
5435
|
+
return { ...empty, missing: false, forbidden: true };
|
|
5436
|
+
}
|
|
5437
|
+
|
|
5438
|
+
const size = fs.statSync(resolved).size;
|
|
5439
|
+
// A truncated or rotated file (size went backwards) must restart, not read from a stale offset.
|
|
5440
|
+
const start = Math.min(Math.max(0, Number(fromOffset) || 0), size);
|
|
5441
|
+
if (start >= size) {
|
|
5442
|
+
return { events: [], offset: size, size, missing: false };
|
|
5443
|
+
}
|
|
5444
|
+
|
|
5445
|
+
const fd = fs.openSync(resolved, 'r');
|
|
5446
|
+
let text = '';
|
|
5447
|
+
try {
|
|
5448
|
+
const length = size - start;
|
|
5449
|
+
const buffer = Buffer.alloc(length);
|
|
5450
|
+
fs.readSync(fd, buffer, 0, length, start);
|
|
5451
|
+
text = buffer.toString('utf8');
|
|
5452
|
+
} finally {
|
|
5453
|
+
fs.closeSync(fd);
|
|
5454
|
+
}
|
|
5455
|
+
|
|
5456
|
+
// Only whole lines are consumed; a partial trailing line is left for the next poll by rewinding the
|
|
5457
|
+
// reported offset, so a JSON object split across two reads is never dropped as unparseable.
|
|
5458
|
+
const lines = text.split('\n');
|
|
5459
|
+
const trailing = lines.pop();
|
|
5460
|
+
const consumed = size - Buffer.byteLength(trailing, 'utf8');
|
|
5461
|
+
|
|
5462
|
+
const events = [];
|
|
5463
|
+
for (const line of lines) {
|
|
5464
|
+
if (!line.trim()) continue;
|
|
5465
|
+
// describeWorkerEvent returns null for the bookkeeping frames a reader does not want
|
|
5466
|
+
// (thread.started, todo_list, …). Dropping them here keeps the payload a clean list.
|
|
5467
|
+
const described = describeWorkerEvent(line);
|
|
5468
|
+
if (described) {
|
|
5469
|
+
events.push(described);
|
|
5470
|
+
}
|
|
5471
|
+
}
|
|
5472
|
+
return { events, offset: consumed, size, missing: false };
|
|
5473
|
+
}
|
|
5474
|
+
|
|
5475
|
+
/** One transcript line as {kind, text, detail} — never throws, and never drops a line it cannot parse. */
|
|
5476
|
+
function describeWorkerEvent(line) {
|
|
5477
|
+
let event = null;
|
|
5478
|
+
try {
|
|
5479
|
+
event = JSON.parse(line);
|
|
5480
|
+
} catch (_) {
|
|
5481
|
+
// Not JSON: a plain stderr line from the agent or the shell. Worth showing verbatim — the times
|
|
5482
|
+
// this matters most (a crash, a missing binary) are exactly the times it is not JSON.
|
|
5483
|
+
return { kind: 'raw', text: line.slice(0, 4000) };
|
|
5484
|
+
}
|
|
5485
|
+
if (!event || typeof event !== 'object') {
|
|
5486
|
+
return { kind: 'raw', text: line.slice(0, 4000) };
|
|
5487
|
+
}
|
|
5488
|
+
|
|
5489
|
+
if (event.type === 'item.completed' && event.item) {
|
|
5490
|
+
const item = event.item;
|
|
5491
|
+
const kind = item.item_type || item.type;
|
|
5492
|
+
if (kind === 'command_execution') {
|
|
5493
|
+
return {
|
|
5494
|
+
kind: 'command',
|
|
5495
|
+
text: String(item.command || ''),
|
|
5496
|
+
exitCode: item.exit_code == null ? null : Number(item.exit_code),
|
|
5497
|
+
detail: String(item.aggregated_output || '').slice(0, 4000)
|
|
5498
|
+
};
|
|
5499
|
+
}
|
|
5500
|
+
if (kind === 'file_change') {
|
|
5501
|
+
return { kind: 'edit', text: 'edited files', detail: JSON.stringify(item.changes || item, null, 2).slice(0, 4000) };
|
|
5502
|
+
}
|
|
5503
|
+
if (kind === 'reasoning') return { kind: 'thinking', text: String(item.text || '').slice(0, 4000) };
|
|
5504
|
+
if (kind === 'agent_message') return { kind: 'message', text: String(item.text || '').slice(0, 8000) };
|
|
5505
|
+
if (kind === 'todo_list') return null;
|
|
5506
|
+
}
|
|
5507
|
+
if (event.type === 'assistant' && event.message && Array.isArray(event.message.content)) {
|
|
5508
|
+
for (const block of event.message.content) {
|
|
5509
|
+
if (block && block.type === 'tool_use') {
|
|
5510
|
+
const input = block.input || {};
|
|
5511
|
+
return {
|
|
5512
|
+
kind: 'command',
|
|
5513
|
+
text: String(block.name || 'tool') + (input.command || input.file_path || input.pattern
|
|
5514
|
+
? ': ' + (input.command || input.file_path || input.pattern) : ''),
|
|
5515
|
+
detail: JSON.stringify(input, null, 2).slice(0, 4000)
|
|
5516
|
+
};
|
|
5517
|
+
}
|
|
5518
|
+
if (block && block.type === 'text' && block.text) {
|
|
5519
|
+
return { kind: 'message', text: String(block.text).slice(0, 8000) };
|
|
5520
|
+
}
|
|
5521
|
+
}
|
|
5522
|
+
}
|
|
5523
|
+
if (event.type === 'result') {
|
|
5524
|
+
return { kind: 'result', text: 'finished: ' + (event.subtype || 'done'), detail: String(event.result || '').slice(0, 4000) };
|
|
5525
|
+
}
|
|
5526
|
+
if (event.type === 'turn.completed' && event.usage) {
|
|
5527
|
+
return { kind: 'result', text: 'turn complete', detail: JSON.stringify(event.usage) };
|
|
5528
|
+
}
|
|
5529
|
+
// Bookkeeping frames a reader gains nothing from. `item.started` in particular is paired with an
|
|
5530
|
+
// `item.completed` carrying the same command plus its output and exit code, so showing both doubles
|
|
5531
|
+
// the transcript and puts every command on screen twice.
|
|
5532
|
+
if (event.type === 'thread.started' || event.type === 'turn.started' ||
|
|
5533
|
+
event.type === 'item.started' || event.type === 'item.updated' || event.type === 'system') {
|
|
5534
|
+
return null;
|
|
5535
|
+
}
|
|
5536
|
+
return { kind: 'event', text: String(event.type || 'event') };
|
|
5537
|
+
}
|
|
5538
|
+
|
|
5539
|
+
/** The transcript of one running or finished worker, addressed by its ticket. */
|
|
5540
|
+
function workerTranscriptFor(ticketId, fromOffset) {
|
|
5541
|
+
const id = String(ticketId || '').trim();
|
|
5542
|
+
if (!id) {
|
|
5543
|
+
return { error: 'a ticketId is required' };
|
|
5544
|
+
}
|
|
5545
|
+
let worker = null;
|
|
5546
|
+
for (const entry of listLocalAgents()) {
|
|
5547
|
+
const found = listWorkers(entry.agentId).find((w) => String(w.ticketId) === id);
|
|
5548
|
+
if (found) {
|
|
5549
|
+
worker = { ...found, agentId: entry.agentId };
|
|
5550
|
+
break;
|
|
5551
|
+
}
|
|
5552
|
+
}
|
|
5553
|
+
// A worker that has EXITED is gone from the store but its transcript is the most interesting one to
|
|
5554
|
+
// read, so fall back to the newest log file for that ticket.
|
|
5555
|
+
let logFile = worker && worker.logFile;
|
|
5556
|
+
if (!logFile && fs.existsSync(WORKER_LOG_DIR)) {
|
|
5557
|
+
const candidates = fs.readdirSync(WORKER_LOG_DIR)
|
|
5558
|
+
.filter((name) => name.startsWith(id + '-') && name.endsWith('.log'))
|
|
5559
|
+
.sort();
|
|
5560
|
+
if (candidates.length) {
|
|
5561
|
+
logFile = path.join(WORKER_LOG_DIR, candidates[candidates.length - 1]);
|
|
5562
|
+
}
|
|
5563
|
+
}
|
|
5564
|
+
if (!logFile) {
|
|
5565
|
+
return { ticketId: id, missing: true, events: [], offset: 0 };
|
|
5566
|
+
}
|
|
5567
|
+
const transcript = readWorkerTranscript(logFile, fromOffset);
|
|
5568
|
+
return {
|
|
5569
|
+
ticketId: id,
|
|
5570
|
+
logFile,
|
|
5571
|
+
running: !!(worker && worker.pid && processIsAlive(worker.pid)),
|
|
5572
|
+
pid: worker ? worker.pid : null,
|
|
5573
|
+
agentType: worker ? worker.agentType : null,
|
|
5574
|
+
startedAt: worker ? worker.startedAt : null,
|
|
5575
|
+
follow: 'tail -f ' + logFile,
|
|
5576
|
+
...transcript
|
|
5577
|
+
};
|
|
5578
|
+
}
|
|
5579
|
+
|
|
4932
5580
|
function startDashboardServer(preferredPort) {
|
|
4933
5581
|
return new Promise((resolve, reject) => {
|
|
4934
5582
|
const sendDashboardError = (res, err, wantsJson = true) => {
|
|
@@ -4992,7 +5640,9 @@ function startDashboardServer(preferredPort) {
|
|
|
4992
5640
|
actionResult = await routeTicketFromDashboard({
|
|
4993
5641
|
ticketId: params.get('ticketId'),
|
|
4994
5642
|
accountId: params.get('accountId'),
|
|
4995
|
-
agentId: params.get('agentId')
|
|
5643
|
+
agentId: params.get('agentId'),
|
|
5644
|
+
baseUrl: params.get('baseUrl'),
|
|
5645
|
+
dataMode: params.get('dataMode')
|
|
4996
5646
|
});
|
|
4997
5647
|
supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
|
|
4998
5648
|
agentSnapshotCache = { fetchedAtMs: 0, value: null };
|
|
@@ -5018,6 +5668,20 @@ function startDashboardServer(preferredPort) {
|
|
|
5018
5668
|
return;
|
|
5019
5669
|
}
|
|
5020
5670
|
|
|
5671
|
+
if (req.method === 'GET' && requestUrl.pathname === '/api/worker-log') {
|
|
5672
|
+
try {
|
|
5673
|
+
const payload = workerTranscriptFor(
|
|
5674
|
+
requestUrl.searchParams.get('ticket'),
|
|
5675
|
+
Number(requestUrl.searchParams.get('offset')) || 0);
|
|
5676
|
+
res.statusCode = 200;
|
|
5677
|
+
res.setHeader('Content-Type', 'application/json');
|
|
5678
|
+
res.end(JSON.stringify(payload));
|
|
5679
|
+
} catch (err) {
|
|
5680
|
+
sendDashboardError(res, err, true);
|
|
5681
|
+
}
|
|
5682
|
+
return;
|
|
5683
|
+
}
|
|
5684
|
+
|
|
5021
5685
|
if (req.method === 'GET' && requestUrl.pathname === '/') {
|
|
5022
5686
|
try {
|
|
5023
5687
|
const snapshot = await collectDashboardSnapshot();
|
|
@@ -5451,45 +6115,409 @@ const AGENT_WORK_POLL_MS = Number(process.env.REMITS_CLI_AGENT_WORK_POLL_MS || 5
|
|
|
5451
6115
|
const AGENT_WORK_DEFAULT_WAIT_SEC = Number(process.env.REMITS_CLI_AGENT_WORK_WAIT_SEC || 0);
|
|
5452
6116
|
const AGENT_STATES = ['idle', 'working', 'paused'];
|
|
5453
6117
|
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
6118
|
+
// ---------------------------------------------------------------------------------------------
|
|
6119
|
+
// Autonomous ticket workers
|
|
6120
|
+
//
|
|
6121
|
+
// A registered agent can also SERVE: a detached supervisor polls for the tickets routed to this
|
|
6122
|
+
// session and launches ONE fresh headless agent process per ticket.
|
|
6123
|
+
//
|
|
6124
|
+
// One ticket = one process is the whole point, and it is what makes context grooming structural
|
|
6125
|
+
// rather than a discipline. `/clear` and `/new` are TUI commands a human types; a model cannot
|
|
6126
|
+
// invoke them, so a long-lived interactive session working ticket after ticket has no way to reset
|
|
6127
|
+
// itself. A process that exits has nothing to reset.
|
|
6128
|
+
//
|
|
6129
|
+
// The supervisor is deliberately dumb: it starts workers, reports what they are doing, and cleans
|
|
6130
|
+
// up after them. Every decision that must survive it — which session a ticket is routed to, which
|
|
6131
|
+
// worker holds it — is durable on the platform, so killing the supervisor loses nothing but time.
|
|
6132
|
+
// ---------------------------------------------------------------------------------------------
|
|
6133
|
+
|
|
6134
|
+
const WORKER_LOG_DIR = path.join(SESSION_DIR, 'workers');
|
|
6135
|
+
const AGENT_SERVE_POLL_MS = Number(process.env.REMITS_CLI_AGENT_SERVE_POLL_MS || 20000);
|
|
6136
|
+
const AGENT_SERVE_IDLE_POLL_MS = Number(process.env.REMITS_CLI_AGENT_SERVE_IDLE_POLL_MS || 60000);
|
|
6137
|
+
// After this many consecutive empty polls, back off to the idle cadence. An agent waiting all night
|
|
6138
|
+
// should not hold a request-per-20s against production for work that arrives twice a day.
|
|
6139
|
+
const AGENT_SERVE_IDLE_AFTER_POLLS = 10;
|
|
6140
|
+
const AGENT_WORKER_MAX_ATTEMPTS = Number(process.env.REMITS_CLI_AGENT_WORKER_MAX_ATTEMPTS || 2);
|
|
6141
|
+
const AGENT_WORKER_STATUS_THROTTLE_MS = 20000;
|
|
6142
|
+
const AGENT_WORKER_MAX_LOG_BYTES = 8 * 1024 * 1024;
|
|
6143
|
+
|
|
6144
|
+
/**
|
|
6145
|
+
* How each supported agent is invoked headlessly, and how far it is allowed to go.
|
|
6146
|
+
*
|
|
6147
|
+
* Every one of them takes the brief on STDIN rather than in argv: a brief is a page of prose and
|
|
6148
|
+
* argv has a hard length limit, so passing it as an argument fails on exactly the detailed tickets
|
|
6149
|
+
* that most need the detail.
|
|
6150
|
+
*
|
|
6151
|
+
* `mode` is the autonomy bound, not a preference. `investigate` maps to each CLI's genuine
|
|
6152
|
+
* read-only mode so an operator can put a real boundary around a production support agent; `edit`
|
|
6153
|
+
* lets it change files but never commits (the brief says so, and nothing here runs git).
|
|
6154
|
+
*/
|
|
6155
|
+
const AGENT_LAUNCHERS = {
|
|
6156
|
+
codex: {
|
|
6157
|
+
binary: 'codex',
|
|
6158
|
+
// A ticket worker's whole job runs THROUGH the platform: `remits-cli ticket read/accept/progress/
|
|
6159
|
+
// complete`, and every investigation tool. So the sandbox has to let it reach the platform, and by
|
|
6160
|
+
// default it does not — `workspace-write` disables network, and a worker launched without this flag
|
|
6161
|
+
// fails its FIRST command with exit 6 / HTTP 000 while looking, from the outside, like an agent that
|
|
6162
|
+
// simply did not do much. Passed on the command line rather than documented as a config edit so the
|
|
6163
|
+
// supervisor works on a machine nobody has prepared.
|
|
6164
|
+
//
|
|
6165
|
+
// `read-only` is deliberately NOT used for investigate mode, even though it is the tighter sandbox.
|
|
6166
|
+
// It has no network AND no setting that restores it, so a read-only worker cannot read the ticket it
|
|
6167
|
+
// was given, cannot run a diagnostic, and cannot record a finding — the three things investigating
|
|
6168
|
+
// IS. It would then exit 0 having written nothing, which the supervisor reads as a delivered
|
|
6169
|
+
// judgement and un-routes, so the next sweep hands the same ticket to another worker that learns the
|
|
6170
|
+
// same nothing. A loop that reports success is worse than a refusal.
|
|
6171
|
+
//
|
|
6172
|
+
// What enforces the read-only phase instead: the brief's Boundaries section, which names the lease
|
|
6173
|
+
// holder, says which commands not to run, and explains that the shared CHECKOUT is the thing at
|
|
6174
|
+
// risk. Enforcement moved from the sandbox to the instructions deliberately — the sandbox could not
|
|
6175
|
+
// express "may talk to the platform, may not write this directory".
|
|
6176
|
+
build: ({ cwd, mode, lastMessageFile }) => ({
|
|
6177
|
+
args: [
|
|
6178
|
+
'exec',
|
|
6179
|
+
'--cd', cwd,
|
|
6180
|
+
'--skip-git-repo-check',
|
|
6181
|
+
'--sandbox', 'workspace-write',
|
|
6182
|
+
'-c', 'sandbox_workspace_write.network_access=true',
|
|
6183
|
+
'--json',
|
|
6184
|
+
...(lastMessageFile ? ['--output-last-message', lastMessageFile] : []),
|
|
6185
|
+
'-'
|
|
6186
|
+
],
|
|
6187
|
+
// Reported on the worker record so an operator can tell a phase the platform derived from a
|
|
6188
|
+
// sandbox the launcher applied — they are no longer the same thing for codex.
|
|
6189
|
+
sandbox: 'workspace-write' + (mode === 'investigate' ? ' (investigate: boundary is the brief)' : '')
|
|
6190
|
+
})
|
|
6191
|
+
},
|
|
6192
|
+
claude: {
|
|
6193
|
+
binary: 'claude',
|
|
6194
|
+
// Same requirement, different mechanism: in `-p` there is no one to answer a permission prompt, so
|
|
6195
|
+
// an un-allowlisted Bash call is DENIED rather than queued. Without this the worker cannot run
|
|
6196
|
+
// remits-cli at all. `plan` mode is avoided for the same reason `read-only` is avoided above.
|
|
6197
|
+
build: ({ cwd, mode }) => ({
|
|
6198
|
+
args: [
|
|
6199
|
+
'-p',
|
|
6200
|
+
'--add-dir', cwd,
|
|
6201
|
+
'--output-format', 'stream-json',
|
|
6202
|
+
'--verbose',
|
|
6203
|
+
'--permission-mode', 'acceptEdits',
|
|
6204
|
+
'--allowedTools', 'Bash,Read,Grep,Glob,WebFetch' + (mode === 'investigate' ? '' : ',Edit,Write')
|
|
6205
|
+
],
|
|
6206
|
+
sandbox: 'acceptEdits' + (mode === 'investigate' ? ' (investigate: no Edit/Write tool)' : '')
|
|
6207
|
+
})
|
|
6208
|
+
},
|
|
6209
|
+
gemini: {
|
|
6210
|
+
binary: 'gemini',
|
|
6211
|
+
// gemini's -p takes the prompt as a value and APPENDS it to stdin, so the brief still travels on
|
|
6212
|
+
// stdin and -p carries only the instruction to act on it.
|
|
6213
|
+
build: ({ mode }) => ({
|
|
6214
|
+
args: [
|
|
6215
|
+
'-p', 'Work the support ticket described above, following its instructions exactly.',
|
|
6216
|
+
'--approval-mode', mode === 'investigate' ? 'plan' : 'auto_edit',
|
|
6217
|
+
'--output-format', 'stream-json'
|
|
6218
|
+
],
|
|
6219
|
+
sandbox: mode === 'investigate' ? 'plan' : 'auto_edit'
|
|
6220
|
+
})
|
|
5457
6221
|
}
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
6222
|
+
};
|
|
6223
|
+
|
|
6224
|
+
/**
|
|
6225
|
+
* Which supported agent IS this process, judged from the anchor's command line.
|
|
6226
|
+
*
|
|
6227
|
+
* The binary name leads: an agent's command line routinely mentions another agent's name in a path
|
|
6228
|
+
* (`/Users/me/.codex/skills/...` while running claude), so a plain substring scan over the whole line
|
|
6229
|
+
* picks the wrong one. Returns null for an anchor that is not a supported worker — `cursor-agent` and
|
|
6230
|
+
* `aider` can anchor a session but cannot be launched as workers — so the caller falls back rather
|
|
6231
|
+
* than guessing.
|
|
6232
|
+
*/
|
|
6233
|
+
function detectAgentKind(command) {
|
|
6234
|
+
const line = String(command || '').trim();
|
|
6235
|
+
if (!line) {
|
|
6236
|
+
return null;
|
|
6237
|
+
}
|
|
6238
|
+
const binary = path.basename(line.split(/\s+/)[0] || '').toLowerCase();
|
|
6239
|
+
const exact = SUPPORTED_AGENTS.find((agent) => binary === agent || binary.startsWith(agent + '-'));
|
|
6240
|
+
if (exact) {
|
|
6241
|
+
return exact;
|
|
5463
6242
|
}
|
|
6243
|
+
return SUPPORTED_AGENTS.find((agent) => new RegExp('(^|[/\\s])' + agent + '($|[\\s])', 'i').test(line)) || null;
|
|
5464
6244
|
}
|
|
5465
6245
|
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
6246
|
+
/**
|
|
6247
|
+
* Which agent a worker should be launched as.
|
|
6248
|
+
*
|
|
6249
|
+
* Precedence: an explicit flag, then WHAT THIS SESSION ACTUALLY IS, then the configured preference,
|
|
6250
|
+
* then the default. The anchor beats the config because it is specific to this session, and because
|
|
6251
|
+
* the alternative is the surprise this ordering exists to remove: `agent serve` in a Claude tab
|
|
6252
|
+
* labelling itself `claude@repo` while launching codex workers, since the label already detected
|
|
6253
|
+
* from the anchor and only the worker choice read the config. One question, one answer.
|
|
6254
|
+
*/
|
|
6255
|
+
function workerAgentType(value, anchor = null) {
|
|
6256
|
+
return resolveWorkerAgent(value, anchor).agent;
|
|
5469
6257
|
}
|
|
5470
6258
|
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
6259
|
+
/**
|
|
6260
|
+
* The worker choice AND why it was made.
|
|
6261
|
+
*
|
|
6262
|
+
* The reason is reported, not just the value, because the intended host for a supervisor is a plain
|
|
6263
|
+
* terminal tab — where nothing in the process tree is an AI agent, so the choice comes from a config
|
|
6264
|
+
* file the operator may have set months ago and forgotten. "codex" on its own is indistinguishable
|
|
6265
|
+
* from a default; "codex (from your ~/.remits-cli/config.json)" is a fact they can act on.
|
|
6266
|
+
*/
|
|
6267
|
+
function resolveWorkerAgent(value, anchor = null) {
|
|
6268
|
+
const candidate = String(value || '').trim().toLowerCase();
|
|
6269
|
+
if (SUPPORTED_AGENTS.includes(candidate)) {
|
|
6270
|
+
return { agent: candidate, source: 'flag', why: 'you passed --worker-agent' };
|
|
5475
6271
|
}
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
return
|
|
5479
|
-
}
|
|
5480
|
-
|
|
5481
|
-
|
|
6272
|
+
const detected = detectAgentKind(anchor && anchor.command);
|
|
6273
|
+
if (detected) {
|
|
6274
|
+
return { agent: detected, source: 'session', why: 'this session is running ' + detected };
|
|
6275
|
+
}
|
|
6276
|
+
const config = readConfig();
|
|
6277
|
+
if (SUPPORTED_AGENTS.includes(String(config.agent || '').toLowerCase())) {
|
|
6278
|
+
return {
|
|
6279
|
+
agent: String(config.agent).toLowerCase(),
|
|
6280
|
+
source: 'config',
|
|
6281
|
+
why: 'from ' + CONFIG_FILE + ' — override per run with --worker-agent'
|
|
6282
|
+
};
|
|
5482
6283
|
}
|
|
6284
|
+
return {
|
|
6285
|
+
agent: DEFAULT_AGENT,
|
|
6286
|
+
source: 'default',
|
|
6287
|
+
why: 'nothing configured and no agent in this session — pass --worker-agent to choose'
|
|
6288
|
+
};
|
|
5483
6289
|
}
|
|
5484
6290
|
|
|
5485
6291
|
/**
|
|
5486
|
-
*
|
|
6292
|
+
* Is this directory a real checkout, rather than something that merely contains an account-info.json?
|
|
5487
6293
|
*
|
|
5488
|
-
* The
|
|
5489
|
-
*
|
|
6294
|
+
* <p>The index is built by scanning for `account-info.json`, and that file is also in every zip export
|
|
6295
|
+
* of an account. An unpacked export in `~/Downloads` therefore looks exactly like a checkout, and it
|
|
6296
|
+
* exists — which was the whole of the previous validation.</p>
|
|
5490
6297
|
*/
|
|
5491
|
-
function
|
|
5492
|
-
|
|
6298
|
+
function isAccountCheckout(directory) {
|
|
6299
|
+
try {
|
|
6300
|
+
return !!directory && fs.existsSync(path.join(directory, '.git'));
|
|
6301
|
+
} catch (_) {
|
|
6302
|
+
return false;
|
|
6303
|
+
}
|
|
6304
|
+
}
|
|
6305
|
+
|
|
6306
|
+
/**
|
|
6307
|
+
* Where the repository for an account is checked out on THIS machine, or null.
|
|
6308
|
+
*
|
|
6309
|
+
* <p>Null when the entry is not a git checkout, and that is the load-bearing part. The caller's next
|
|
6310
|
+
* step on null is the brief telling the worker "no local checkout was resolved — do not guess", which
|
|
6311
|
+
* is the correct outcome; the previous `fs.existsSync` test let an unpacked zip export in `~/Downloads`
|
|
6312
|
+
* satisfy it instead, so the guard never fired and a worker edited, staged and reported success against
|
|
6313
|
+
* a directory with no remote and no history. Nothing about that run looked wrong. Measured on this
|
|
6314
|
+
* machine: account 11 (11 open tickets) and account 2 both resolved that way.</p>
|
|
6315
|
+
*/
|
|
6316
|
+
function repoDirectoryForAccount(accountId) {
|
|
6317
|
+
const wanted = Number(accountId);
|
|
6318
|
+
if (!Number.isFinite(wanted) || wanted <= 0) {
|
|
6319
|
+
return null;
|
|
6320
|
+
}
|
|
6321
|
+
const entry = listAccountRepoEntries(readAccountRepoIndex())
|
|
6322
|
+
.find((candidate) => Number(candidate.accountId) === wanted);
|
|
6323
|
+
if (!entry || !entry.directory || !isAccountCheckout(entry.directory)) {
|
|
6324
|
+
return null;
|
|
6325
|
+
}
|
|
6326
|
+
return entry.directory;
|
|
6327
|
+
}
|
|
6328
|
+
|
|
6329
|
+
/**
|
|
6330
|
+
* Everything wrong with the local account-repo index, as sentences an operator can act on.
|
|
6331
|
+
*
|
|
6332
|
+
* <p>Reported at `agent serve` rather than discovered one ticket at a time: a supervisor about to work a
|
|
6333
|
+
* production queue should be told up front which accounts it cannot work, because the alternative is
|
|
6334
|
+
* finding out per ticket, hours apart, from a brief that says only "no checkout resolved".</p>
|
|
6335
|
+
*/
|
|
6336
|
+
function accountRepoIndexProblems(ownCwd = null) {
|
|
6337
|
+
const problems = [];
|
|
6338
|
+
// The account this session is standing in resolves from its OWN directory, not from the shared index,
|
|
6339
|
+
// so an ambiguity there is not a problem for this supervisor — it is two worktrees working as intended.
|
|
6340
|
+
// Reporting it anyway trains an operator to ignore the warning, and the warning is load-bearing for
|
|
6341
|
+
// every OTHER account, where a silent wrong winner is unrecoverable.
|
|
6342
|
+
const own = ownCwd ? accountCheckoutIdentity(ownCwd) : null;
|
|
6343
|
+
for (const entry of listAccountRepoEntries(readAccountRepoIndex())) {
|
|
6344
|
+
const label = 'account ' + entry.accountId + ' (' + (entry.name || 'unnamed') + ')';
|
|
6345
|
+
if (!fs.existsSync(entry.directory)) {
|
|
6346
|
+
problems.push({ accountId: Number(entry.accountId), severity: 'unusable',
|
|
6347
|
+
message: label + ': the recorded directory no longer exists — ' + entry.directory });
|
|
6348
|
+
} else if (!isAccountCheckout(entry.directory)) {
|
|
6349
|
+
problems.push({ accountId: Number(entry.accountId), severity: 'unusable',
|
|
6350
|
+
message: label + ': ' + entry.directory + ' is not a git checkout (an unpacked export?). ' +
|
|
6351
|
+
'Tickets for this account will park instead of being worked.' });
|
|
6352
|
+
}
|
|
6353
|
+
if (Array.isArray(entry.ambiguousWith) && entry.ambiguousWith.length &&
|
|
6354
|
+
!(own && own.accountId === Number(entry.accountId))) {
|
|
6355
|
+
problems.push({ accountId: Number(entry.accountId), severity: 'ambiguous',
|
|
6356
|
+
message: label + ': more than one checkout claims this account. Using ' + entry.directory +
|
|
6357
|
+
'; also found ' + entry.ambiguousWith.join(', ') +
|
|
6358
|
+
'. Set the right one with `remits-cli agent repos --account-id ' + entry.accountId +
|
|
6359
|
+
' --directory <path>`.' });
|
|
6360
|
+
}
|
|
6361
|
+
}
|
|
6362
|
+
return problems;
|
|
6363
|
+
}
|
|
6364
|
+
|
|
6365
|
+
/**
|
|
6366
|
+
* The working directory a ticket should be worked in.
|
|
6367
|
+
*
|
|
6368
|
+
* The implementation account leads when the ticket names one — a defect observed on a CLIENT is
|
|
6369
|
+
* almost always fixed in the PLATFORM/PRODUCT repo above it — then the ticket's own account. When
|
|
6370
|
+
* neither is checked out here the answer is null, and the brief says so explicitly instead of
|
|
6371
|
+
* letting the worker start in whatever directory the supervisor happened to inherit.
|
|
6372
|
+
*/
|
|
6373
|
+
function resolveTicketWorkingDirectory(ticket, ownCwd = null) {
|
|
6374
|
+
const facts = (ticket && ticket.briefFacts) || {};
|
|
6375
|
+
const candidates = [
|
|
6376
|
+
facts.repoAccountId,
|
|
6377
|
+
ticket && ticket.implementationAccountId,
|
|
6378
|
+
ticket && ticket.accountId
|
|
6379
|
+
].map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0);
|
|
6380
|
+
|
|
6381
|
+
// THIS supervisor's own checkout wins, when it is a checkout of one of the candidate accounts.
|
|
6382
|
+
//
|
|
6383
|
+
// The account-repo index is a single global map keyed by accountId, and `agent serve` writes its own
|
|
6384
|
+
// cwd into it — so two supervisors serving two git WORKTREES of one account fight over one entry, and
|
|
6385
|
+
// the second to start silently repoints the first's future workers into its own tree. That is the
|
|
6386
|
+
// shape worktrees exist to prevent: a supervisor must launch its workers where IT is, not where the
|
|
6387
|
+
// machine's last-registered session happened to be. The index stays the answer for every account this
|
|
6388
|
+
// session is not standing in, which is the normal case.
|
|
6389
|
+
const own = ownCwd ? accountCheckoutIdentity(ownCwd) : null;
|
|
6390
|
+
if (own && candidates.includes(own.accountId)) {
|
|
6391
|
+
return own.directory;
|
|
6392
|
+
}
|
|
6393
|
+
|
|
6394
|
+
for (const candidate of candidates) {
|
|
6395
|
+
const dir = repoDirectoryForAccount(candidate);
|
|
6396
|
+
if (dir) {
|
|
6397
|
+
return dir;
|
|
6398
|
+
}
|
|
6399
|
+
}
|
|
6400
|
+
return null;
|
|
6401
|
+
}
|
|
6402
|
+
|
|
6403
|
+
/**
|
|
6404
|
+
* Which account this directory is a checkout OF, read from the directory itself.
|
|
6405
|
+
*
|
|
6406
|
+
* Deliberately not a lookup in the shared index: the whole point is to answer for one process without
|
|
6407
|
+
* consulting a map another process can overwrite. Null when the directory is not a real checkout — an
|
|
6408
|
+
* unpacked zip export carries `account-info.json` too, which is exactly the trap `isAccountCheckout`
|
|
6409
|
+
* exists to close.
|
|
6410
|
+
*/
|
|
6411
|
+
function accountCheckoutIdentity(directory) {
|
|
6412
|
+
try {
|
|
6413
|
+
if (!directory || !isAccountCheckout(directory)) {
|
|
6414
|
+
return null;
|
|
6415
|
+
}
|
|
6416
|
+
const info = loadAccountInfo(directory);
|
|
6417
|
+
const accountId = Number(info && accountIdFromAccountInfo(info));
|
|
6418
|
+
if (!Number.isFinite(accountId) || accountId <= 0) {
|
|
6419
|
+
return null;
|
|
6420
|
+
}
|
|
6421
|
+
return { accountId, directory: path.resolve(directory) };
|
|
6422
|
+
} catch (_) {
|
|
6423
|
+
return null;
|
|
6424
|
+
}
|
|
6425
|
+
}
|
|
6426
|
+
|
|
6427
|
+
/**
|
|
6428
|
+
* Turn a worker's streamed output into something an operator can read in one line.
|
|
6429
|
+
*
|
|
6430
|
+
* All three agents emit JSONL, and all three emit a different shape, so this reads defensively and
|
|
6431
|
+
* returns null for anything it does not recognise rather than guessing. A wrong activity line is
|
|
6432
|
+
* worse than none: the dashboard is the only window into a headless run, and it has to be trusted.
|
|
6433
|
+
*/
|
|
6434
|
+
function summarizeWorkerEvent(line) {
|
|
6435
|
+
let event = null;
|
|
6436
|
+
try {
|
|
6437
|
+
event = JSON.parse(line);
|
|
6438
|
+
} catch (_) {
|
|
6439
|
+
return null;
|
|
6440
|
+
}
|
|
6441
|
+
if (!event || typeof event !== 'object') {
|
|
6442
|
+
return null;
|
|
6443
|
+
}
|
|
6444
|
+
|
|
6445
|
+
// codex exec --json. The item's kind is spelled `type` on the versions observed and `item_type` in
|
|
6446
|
+
// some published examples, so read both rather than betting on one — a miss here is silent, and the
|
|
6447
|
+
// whole run then reports "nothing reported yet" while working perfectly.
|
|
6448
|
+
if (event.type === 'item.completed' && event.item) {
|
|
6449
|
+
const item = event.item;
|
|
6450
|
+
const kind = item.item_type || item.type;
|
|
6451
|
+
if (kind === 'command_execution' && item.command) {
|
|
6452
|
+
return 'ran: ' + String(item.command).slice(0, 120);
|
|
6453
|
+
}
|
|
6454
|
+
if (kind === 'file_change') {
|
|
6455
|
+
return 'edited files';
|
|
6456
|
+
}
|
|
6457
|
+
if ((kind === 'reasoning' || kind === 'agent_message') && item.text) {
|
|
6458
|
+
return String(item.text).split('\n')[0].slice(0, 120);
|
|
6459
|
+
}
|
|
6460
|
+
}
|
|
6461
|
+
|
|
6462
|
+
// claude / gemini stream-json
|
|
6463
|
+
if (event.type === 'assistant' && event.message && Array.isArray(event.message.content)) {
|
|
6464
|
+
for (const block of event.message.content) {
|
|
6465
|
+
if (block && block.type === 'tool_use' && block.name) {
|
|
6466
|
+
const input = block.input || {};
|
|
6467
|
+
const detail = input.command || input.file_path || input.pattern || input.path || '';
|
|
6468
|
+
return String(block.name + (detail ? ': ' + detail : '')).slice(0, 120);
|
|
6469
|
+
}
|
|
6470
|
+
if (block && block.type === 'text' && block.text) {
|
|
6471
|
+
return String(block.text).split('\n')[0].slice(0, 120);
|
|
6472
|
+
}
|
|
6473
|
+
}
|
|
6474
|
+
}
|
|
6475
|
+
if (event.type === 'result' && event.subtype) {
|
|
6476
|
+
return 'finishing: ' + event.subtype;
|
|
6477
|
+
}
|
|
6478
|
+
return null;
|
|
6479
|
+
}
|
|
6480
|
+
|
|
6481
|
+
|
|
6482
|
+
function readAgentStore() {
|
|
6483
|
+
if (!fs.existsSync(AGENT_STATE_FILE)) {
|
|
6484
|
+
return { agents: {} };
|
|
6485
|
+
}
|
|
6486
|
+
try {
|
|
6487
|
+
const data = JSON.parse(fs.readFileSync(AGENT_STATE_FILE, 'utf8'));
|
|
6488
|
+
return data && typeof data === 'object' && data.agents ? data : { agents: {} };
|
|
6489
|
+
} catch (_) {
|
|
6490
|
+
return { agents: {} };
|
|
6491
|
+
}
|
|
6492
|
+
}
|
|
6493
|
+
|
|
6494
|
+
function writeAgentStore(store) {
|
|
6495
|
+
ensureSessionDir();
|
|
6496
|
+
atomicWriteFile(AGENT_STATE_FILE, JSON.stringify(store || { agents: {} }, null, 2));
|
|
6497
|
+
}
|
|
6498
|
+
|
|
6499
|
+
function processIsAlive(pid) {
|
|
6500
|
+
const numeric = Number(pid);
|
|
6501
|
+
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
6502
|
+
return false;
|
|
6503
|
+
}
|
|
6504
|
+
try {
|
|
6505
|
+
process.kill(numeric, 0);
|
|
6506
|
+
return true;
|
|
6507
|
+
} catch (err) {
|
|
6508
|
+
// EPERM means the process exists but belongs to another user — still alive.
|
|
6509
|
+
return err && err.code === 'EPERM';
|
|
6510
|
+
}
|
|
6511
|
+
}
|
|
6512
|
+
|
|
6513
|
+
/**
|
|
6514
|
+
* The locally-known agent sessions, dropping any whose anchor process has gone.
|
|
6515
|
+
*
|
|
6516
|
+
* The store is a local convenience so a follow-up command in the same tab resolves the same agent
|
|
6517
|
+
* without being told its id. It is never the source of truth: the platform's registry is.
|
|
6518
|
+
*/
|
|
6519
|
+
function listLocalAgents() {
|
|
6520
|
+
const store = readAgentStore();
|
|
5493
6521
|
const alive = {};
|
|
5494
6522
|
let changed = false;
|
|
5495
6523
|
for (const [agentId, entry] of Object.entries(store.agents || {})) {
|
|
@@ -5582,11 +6610,9 @@ function resolveAgentAnchorPid() {
|
|
|
5582
6610
|
|
|
5583
6611
|
function agentLabelDefault(anchor, cwd) {
|
|
5584
6612
|
const repo = path.basename(cwd || process.cwd());
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
: getPreferredAgent();
|
|
5589
|
-
return kind + '@' + repo;
|
|
6613
|
+
// Same resolution as the WORKER choice, deliberately: a label that says one agent while the
|
|
6614
|
+
// supervisor launches another is worse than no label, because it is the only thing an operator sees.
|
|
6615
|
+
return workerAgentType(null, anchor) + '@' + repo;
|
|
5590
6616
|
}
|
|
5591
6617
|
|
|
5592
6618
|
/**
|
|
@@ -5717,9 +6743,19 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
5717
6743
|
host: os.hostname(),
|
|
5718
6744
|
pid: anchor.pid,
|
|
5719
6745
|
cwd,
|
|
6746
|
+
// WHERE this session works, not just who it is. One account can now have several checkouts at once
|
|
6747
|
+
// (git worktrees) on different branches staging into different workspace lanes, so the account alone
|
|
6748
|
+
// no longer identifies the tree a worker would edit — and the edit lease refusal, the work map and the
|
|
6749
|
+
// ticket claim all report these back.
|
|
6750
|
+
branch: currentBranch(cwd) || undefined,
|
|
6751
|
+
workspace: resolveWorkspace(cwd, flags) || undefined,
|
|
5720
6752
|
dataMode,
|
|
5721
6753
|
accountId: Number(session.accountId) || undefined,
|
|
5722
6754
|
accounts: agentAccountClaims(cwd),
|
|
6755
|
+
// Capacity is how many workers this session can actually RUN, which only this side knows. The
|
|
6756
|
+
// platform stops routing past it, so a supervisor at capacity is not handed work it cannot start.
|
|
6757
|
+
maxConcurrent: options.serve ? options.serve.maxConcurrent : (flags['max-concurrent'] ? parsePositiveInt(flags['max-concurrent'], 1) : undefined),
|
|
6758
|
+
autonomous: options.serve ? true : undefined,
|
|
5723
6759
|
state: flags.state && AGENT_STATES.includes(String(flags.state)) ? String(flags.state) : 'idle',
|
|
5724
6760
|
activity: flags.activity || null,
|
|
5725
6761
|
source: flags.source || 'repo'
|
|
@@ -5748,7 +6784,18 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
5748
6784
|
// A re-register from inside the heartbeat must not fork a second heartbeat: they would both keep
|
|
5749
6785
|
// re-registering, and the daemons multiply for as long as the platform stays unreachable.
|
|
5750
6786
|
if (options.spawnHeartbeat !== false) {
|
|
5751
|
-
|
|
6787
|
+
// Remembered so `agent release` can actually STOP it. Without the pid, deregistering only removes
|
|
6788
|
+
// the platform record — and the daemon's very next poll sees registered:false and re-registers,
|
|
6789
|
+
// so the session comes straight back and, when serving, resumes launching workers.
|
|
6790
|
+
const daemonPid = startAgentHeartbeatDaemon(entry, options.serve || null);
|
|
6791
|
+
entry.daemonPid = daemonPid;
|
|
6792
|
+
entry.serving = !!options.serve;
|
|
6793
|
+
if (options.serve) {
|
|
6794
|
+
entry.workerAgent = options.serve.workerAgent;
|
|
6795
|
+
entry.mode = options.serve.mode;
|
|
6796
|
+
entry.maxConcurrent = options.serve.maxConcurrent;
|
|
6797
|
+
}
|
|
6798
|
+
rememberLocalAgent(entry);
|
|
5752
6799
|
}
|
|
5753
6800
|
|
|
5754
6801
|
if (flagEnabled(flags.json)) {
|
|
@@ -5775,14 +6822,170 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
5775
6822
|
console.log(' --data-mode prod to take real support work.');
|
|
5776
6823
|
}
|
|
5777
6824
|
console.log('');
|
|
5778
|
-
|
|
6825
|
+
if (options.serve) {
|
|
6826
|
+
const serve = options.serve;
|
|
6827
|
+
console.log('AUTONOMOUS. A supervisor is now running in the background for this session.');
|
|
6828
|
+
console.log(' worker ' + serve.workerAgent + ' (' + serve.mode + ' mode)');
|
|
6829
|
+
console.log(' ' + serve.workerAgentWhy);
|
|
6830
|
+
console.log(' capacity ' + serve.maxConcurrent + ' ticket(s) at a time');
|
|
6831
|
+
console.log('');
|
|
6832
|
+
console.log('When a ticket is routed here it launches a fresh ' + serve.workerAgent + ' process for that');
|
|
6833
|
+
console.log('ticket, in that account\'s repo, and that process exits when the ticket is done — so no');
|
|
6834
|
+
console.log('session ever accumulates context across tickets.');
|
|
6835
|
+
console.log('');
|
|
6836
|
+
console.log(' remits-cli agent workers what is running right now');
|
|
6837
|
+
console.log(' remits-cli agent list who else is available');
|
|
6838
|
+
console.log(' remits-cli agent release stop serving and go offline');
|
|
6839
|
+
console.log('');
|
|
6840
|
+
console.log('You do not have to do anything else. This terminal is free.');
|
|
6841
|
+
return;
|
|
6842
|
+
}
|
|
6843
|
+
console.log('You are on duty, but nothing will start on its own. Either:');
|
|
6844
|
+
console.log('');
|
|
6845
|
+
console.log(' remits-cli agent serve # autonomous: a worker process per routed ticket');
|
|
6846
|
+
console.log('');
|
|
6847
|
+
console.log('or work the loop yourself:');
|
|
5779
6848
|
console.log(' 1. remits-cli agent work --wait 600 # ask for work; returns as soon as any arrives');
|
|
5780
6849
|
console.log(' 2. remits-cli agent status --state working --ticket <id> --activity "what you are doing"');
|
|
5781
6850
|
console.log(' 3. work the ticket; keep step 2 current as you change what you are doing');
|
|
5782
|
-
console.log(' 4. close it out via
|
|
6851
|
+
console.log(' 4. close it out via remits-cli ticket (accept -> status -> complete, ask, or release)');
|
|
5783
6852
|
console.log(' 5. remits-cli agent status --state idle # then go back to step 1');
|
|
5784
6853
|
}
|
|
5785
6854
|
|
|
6855
|
+
/**
|
|
6856
|
+
* Register this session AND start working tickets autonomously.
|
|
6857
|
+
*
|
|
6858
|
+
* <p>Returns immediately, on purpose. The caller is very often an AI agent inside an interactive
|
|
6859
|
+
* terminal, and a command that blocked would consume the session it was supposed to free — the
|
|
6860
|
+
* supervisor is detached precisely so the tab that started it can go and do something else, or
|
|
6861
|
+
* nothing at all.</p>
|
|
6862
|
+
*/
|
|
6863
|
+
async function agentServeCommand(flags) {
|
|
6864
|
+
const choice = resolveWorkerAgent(flags['worker-agent'] || flags.agent, resolveAgentAnchorPid());
|
|
6865
|
+
const workerAgent = choice.agent;
|
|
6866
|
+
const launcher = AGENT_LAUNCHERS[workerAgent];
|
|
6867
|
+
if (!commandExists(launcher.binary)) {
|
|
6868
|
+
throw new Error('Cannot serve with "' + workerAgent + '": the `' + launcher.binary +
|
|
6869
|
+
'` command is not on PATH. Install it, or pick another with --worker-agent ' +
|
|
6870
|
+
SUPPORTED_AGENTS.filter((a) => a !== workerAgent).join('|') + '.');
|
|
6871
|
+
}
|
|
6872
|
+
const mode = String(flags.mode || (flagEnabled(flags['investigate-only']) ? 'investigate' : 'edit'))
|
|
6873
|
+
.toLowerCase() === 'investigate' ? 'investigate' : 'edit';
|
|
6874
|
+
|
|
6875
|
+
// Say what this supervisor CANNOT work, before it starts rather than one ticket at a time. A worker
|
|
6876
|
+
// pointed at an account with no usable checkout parks correctly, but the operator only learns that
|
|
6877
|
+
// from a brief they may never read, hours later, on one ticket. `--strict-repos` turns it into a
|
|
6878
|
+
// refusal for anyone who would rather not start a production run with known holes.
|
|
6879
|
+
try {
|
|
6880
|
+
updateAccountRepoIndex(process.cwd());
|
|
6881
|
+
discoverAccountRepos();
|
|
6882
|
+
} catch (_) { /* best-effort; the checks below still report what the index does say */ }
|
|
6883
|
+
const problems = accountRepoIndexProblems(process.cwd());
|
|
6884
|
+
if (problems.length) {
|
|
6885
|
+
console.log('');
|
|
6886
|
+
console.log('Account repository index — ' + problems.length + ' issue(s) to know about before serving:');
|
|
6887
|
+
for (const problem of problems) {
|
|
6888
|
+
console.log(' [' + problem.severity + '] ' + problem.message);
|
|
6889
|
+
}
|
|
6890
|
+
console.log(' Tickets for an "unusable" account are parked with an explanation, never worked in the');
|
|
6891
|
+
console.log(' wrong directory. Fix with: remits-cli agent repos --account-id <id> --directory <path>');
|
|
6892
|
+
const ownHere = accountCheckoutIdentity(process.cwd());
|
|
6893
|
+
if (ownHere) {
|
|
6894
|
+
console.log(' Account ' + ownHere.accountId + ' is exempt: this session serves it from its own directory');
|
|
6895
|
+
console.log(' (' + ownHere.directory + '), so a second worktree elsewhere cannot redirect these workers.');
|
|
6896
|
+
}
|
|
6897
|
+
console.log('');
|
|
6898
|
+
if (flagEnabled(flags['strict-repos'])) {
|
|
6899
|
+
throw new Error('Refusing to serve with ' + problems.length +
|
|
6900
|
+
' account-repository issue(s). Resolve them, or drop --strict-repos to serve anyway.');
|
|
6901
|
+
}
|
|
6902
|
+
}
|
|
6903
|
+
|
|
6904
|
+
return agentRegisterCommand(flags, {
|
|
6905
|
+
serve: {
|
|
6906
|
+
workerAgent,
|
|
6907
|
+
workerAgentWhy: choice.why,
|
|
6908
|
+
mode,
|
|
6909
|
+
maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1))
|
|
6910
|
+
}
|
|
6911
|
+
});
|
|
6912
|
+
}
|
|
6913
|
+
|
|
6914
|
+
/** Is this binary runnable on this machine? Checked before serving, not on the first ticket. */
|
|
6915
|
+
function commandExists(binary) {
|
|
6916
|
+
// `binary` only ever comes from AGENT_LAUNCHERS, so it is a fixed allowlist rather than user input.
|
|
6917
|
+
try {
|
|
6918
|
+
execFileSync('/bin/sh', ['-c', 'command -v ' + binary], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
6919
|
+
return true;
|
|
6920
|
+
} catch (_) {
|
|
6921
|
+
return false;
|
|
6922
|
+
}
|
|
6923
|
+
}
|
|
6924
|
+
|
|
6925
|
+
/**
|
|
6926
|
+
* Find this agent's daemon by looking for the process, when its pid was never recorded.
|
|
6927
|
+
*
|
|
6928
|
+
* `daemonPid` is only in the local store for agents registered by a build that writes it. An agent
|
|
6929
|
+
* registered by an EARLIER build has none — and that daemon is precisely the one that cannot be
|
|
6930
|
+
* stopped any other way: it is orphaned (its parent shell is long gone), it re-registers itself on
|
|
6931
|
+
* the next heartbeat after any deregister, and it predates `serve` so it responds to nothing else.
|
|
6932
|
+
* Its own command line carries the agentId, which is enough to find it.
|
|
6933
|
+
*/
|
|
6934
|
+
function findAgentDaemonPid(agentId) {
|
|
6935
|
+
if (!agentId) {
|
|
6936
|
+
return null;
|
|
6937
|
+
}
|
|
6938
|
+
try {
|
|
6939
|
+
const listing = execFileSync('ps', ['-ax', '-o', 'pid=,command='], {
|
|
6940
|
+
stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 8 * 1024 * 1024
|
|
6941
|
+
}).toString();
|
|
6942
|
+
for (const line of listing.split('\n')) {
|
|
6943
|
+
if (!line.includes('agent') || !line.includes('heartbeat-daemon') || !line.includes(agentId)) {
|
|
6944
|
+
continue;
|
|
6945
|
+
}
|
|
6946
|
+
const pid = Number((line.trim().match(/^(\d+)/) || [])[1]);
|
|
6947
|
+
// Never signal ourselves: `agent release` run from inside a daemon would kill the command.
|
|
6948
|
+
if (Number.isFinite(pid) && pid > 0 && pid !== process.pid && processIsAlive(pid)) {
|
|
6949
|
+
return pid;
|
|
6950
|
+
}
|
|
6951
|
+
}
|
|
6952
|
+
} catch (_) {
|
|
6953
|
+
// ps unavailable or restricted — fall back to the recorded pid only.
|
|
6954
|
+
}
|
|
6955
|
+
return null;
|
|
6956
|
+
}
|
|
6957
|
+
|
|
6958
|
+
/** What this session has running right now, and where to read each run's transcript. */
|
|
6959
|
+
async function agentWorkersCommand(flags) {
|
|
6960
|
+
const agentId = resolveAgentId(flags, { optional: true });
|
|
6961
|
+
if (!agentId) {
|
|
6962
|
+
console.log('No local agent is registered in this session. Run: remits-cli agent serve');
|
|
6963
|
+
return;
|
|
6964
|
+
}
|
|
6965
|
+
const workers = listWorkers(agentId);
|
|
6966
|
+
if (flagEnabled(flags.json)) {
|
|
6967
|
+
console.log(JSON.stringify({ agentId, workers }, null, 2));
|
|
6968
|
+
return;
|
|
6969
|
+
}
|
|
6970
|
+
if (!workers.length) {
|
|
6971
|
+
console.log('No ticket workers are running for ' + agentId + '.');
|
|
6972
|
+
return;
|
|
6973
|
+
}
|
|
6974
|
+
console.log(workers.length + ' worker(s) running:');
|
|
6975
|
+
for (const worker of workers) {
|
|
6976
|
+
const alive = worker.pid && processIsAlive(worker.pid);
|
|
6977
|
+
console.log('');
|
|
6978
|
+
console.log(' ticket ' + worker.ticketId + ' [' + worker.agentType + ', pid ' + worker.pid +
|
|
6979
|
+
(alive ? '' : ', NOT RUNNING') + ']');
|
|
6980
|
+
console.log(' ' + (worker.subject || '(no subject)'));
|
|
6981
|
+
console.log(' since ' + worker.startedAt + ' attempt ' + (worker.attempt || 1));
|
|
6982
|
+
console.log(' in ' + (worker.workingDirectory || '(no repo resolved)') +
|
|
6983
|
+
(worker.workspace ? ' workspace ' + worker.workspace : ''));
|
|
6984
|
+
console.log(' doing ' + (worker.activity || 'nothing reported yet'));
|
|
6985
|
+
console.log(' log ' + worker.logFile);
|
|
6986
|
+
}
|
|
6987
|
+
}
|
|
6988
|
+
|
|
5786
6989
|
function readCliVersion() {
|
|
5787
6990
|
try {
|
|
5788
6991
|
return require(path.join(__dirname, 'package.json')).version || null;
|
|
@@ -5799,24 +7002,413 @@ function readCliVersion() {
|
|
|
5799
7002
|
* appeared online while it happened to be running remits-cli would be invisible for exactly the
|
|
5800
7003
|
* period it was busy working a ticket.
|
|
5801
7004
|
*/
|
|
5802
|
-
function startAgentHeartbeatDaemon(entry) {
|
|
7005
|
+
function startAgentHeartbeatDaemon(entry, serve = null) {
|
|
5803
7006
|
const { spawn } = require('child_process');
|
|
5804
|
-
const
|
|
7007
|
+
const args = [
|
|
5805
7008
|
__filename, 'agent', 'heartbeat-daemon',
|
|
5806
7009
|
'--agent-id', entry.agentId,
|
|
5807
7010
|
'--anchor-pid', String(entry.anchorPid),
|
|
5808
7011
|
'--base-url', entry.baseUrl,
|
|
5809
7012
|
'--cwd', entry.cwd
|
|
5810
|
-
]
|
|
7013
|
+
];
|
|
7014
|
+
if (serve) {
|
|
7015
|
+
args.push(
|
|
7016
|
+
'--serve', 'true',
|
|
7017
|
+
'--worker-agent', serve.workerAgent,
|
|
7018
|
+
'--mode', serve.mode,
|
|
7019
|
+
'--max-concurrent', String(serve.maxConcurrent));
|
|
7020
|
+
}
|
|
7021
|
+
const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', env: { ...process.env } });
|
|
5811
7022
|
child.unref();
|
|
5812
7023
|
appendGlobalActivityLog('agent', 'heartbeat.started', {
|
|
5813
7024
|
agentId: entry.agentId,
|
|
5814
7025
|
anchorPid: entry.anchorPid,
|
|
5815
|
-
heartbeatPid: child.pid
|
|
7026
|
+
heartbeatPid: child.pid,
|
|
7027
|
+
serving: !!serve,
|
|
7028
|
+
workerAgent: serve ? serve.workerAgent : null,
|
|
7029
|
+
mode: serve ? serve.mode : null,
|
|
7030
|
+
maxConcurrent: serve ? serve.maxConcurrent : null
|
|
5816
7031
|
});
|
|
5817
7032
|
return child.pid;
|
|
5818
7033
|
}
|
|
5819
7034
|
|
|
7035
|
+
const WORKER_STATE_FILE = path.join(SESSION_DIR, 'workers.json');
|
|
7036
|
+
|
|
7037
|
+
function readWorkerStore() {
|
|
7038
|
+
if (!fs.existsSync(WORKER_STATE_FILE)) {
|
|
7039
|
+
return { agents: {} };
|
|
7040
|
+
}
|
|
7041
|
+
try {
|
|
7042
|
+
const data = JSON.parse(fs.readFileSync(WORKER_STATE_FILE, 'utf8'));
|
|
7043
|
+
return data && typeof data === 'object' && data.agents ? data : { agents: {} };
|
|
7044
|
+
} catch (_) {
|
|
7045
|
+
return { agents: {} };
|
|
7046
|
+
}
|
|
7047
|
+
}
|
|
7048
|
+
|
|
7049
|
+
function writeWorkerStore(store) {
|
|
7050
|
+
ensureSessionDir();
|
|
7051
|
+
atomicWriteFile(WORKER_STATE_FILE, JSON.stringify(store || { agents: {} }, null, 2));
|
|
7052
|
+
}
|
|
7053
|
+
|
|
7054
|
+
/**
|
|
7055
|
+
* The workers this agent has running, as a file.
|
|
7056
|
+
*
|
|
7057
|
+
* In memory would be enough for the supervisor itself; on disk is what lets `remits-cli agent
|
|
7058
|
+
* workers` answer from another process, and what lets a restarted supervisor find the claims a
|
|
7059
|
+
* killed one left behind and drop them instead of waiting out the TTL.
|
|
7060
|
+
*/
|
|
7061
|
+
function listWorkers(agentId) {
|
|
7062
|
+
const store = readWorkerStore();
|
|
7063
|
+
return Object.values((store.agents || {})[agentId] || {});
|
|
7064
|
+
}
|
|
7065
|
+
|
|
7066
|
+
function recordWorker(agentId, ticketId, patch) {
|
|
7067
|
+
const store = readWorkerStore();
|
|
7068
|
+
store.agents = store.agents || {};
|
|
7069
|
+
store.agents[agentId] = store.agents[agentId] || {};
|
|
7070
|
+
const existing = store.agents[agentId][ticketId] || {};
|
|
7071
|
+
store.agents[agentId][ticketId] = { ...existing, ...patch, ticketId };
|
|
7072
|
+
writeWorkerStore(store);
|
|
7073
|
+
return store.agents[agentId][ticketId];
|
|
7074
|
+
}
|
|
7075
|
+
|
|
7076
|
+
function clearWorker(agentId, ticketId) {
|
|
7077
|
+
const store = readWorkerStore();
|
|
7078
|
+
if (store.agents && store.agents[agentId] && store.agents[agentId][ticketId]) {
|
|
7079
|
+
delete store.agents[agentId][ticketId];
|
|
7080
|
+
writeWorkerStore(store);
|
|
7081
|
+
}
|
|
7082
|
+
}
|
|
7083
|
+
|
|
7084
|
+
/**
|
|
7085
|
+
* Attempts are counted on disk, per ticket, and survive the supervisor.
|
|
7086
|
+
*
|
|
7087
|
+
* Without this a ticket that reliably kills its worker is retried forever: the claim drops on exit,
|
|
7088
|
+
* the next poll sees it routed and unclaimed, and a fresh process starts into the same wall. The
|
|
7089
|
+
* counter is what turns that into a bounded retry ending in an honest release.
|
|
7090
|
+
*/
|
|
7091
|
+
function workerAttemptCount(agentId, ticketId) {
|
|
7092
|
+
const store = readWorkerStore();
|
|
7093
|
+
const attempts = ((store.attempts || {})[agentId] || {})[ticketId];
|
|
7094
|
+
return Number(attempts) || 0;
|
|
7095
|
+
}
|
|
7096
|
+
|
|
7097
|
+
function recordWorkerAttempt(agentId, ticketId) {
|
|
7098
|
+
const store = readWorkerStore();
|
|
7099
|
+
store.attempts = store.attempts || {};
|
|
7100
|
+
store.attempts[agentId] = store.attempts[agentId] || {};
|
|
7101
|
+
const next = (Number(store.attempts[agentId][ticketId]) || 0) + 1;
|
|
7102
|
+
store.attempts[agentId][ticketId] = next;
|
|
7103
|
+
writeWorkerStore(store);
|
|
7104
|
+
return next;
|
|
7105
|
+
}
|
|
7106
|
+
|
|
7107
|
+
function clearWorkerAttempts(agentId, ticketId) {
|
|
7108
|
+
const store = readWorkerStore();
|
|
7109
|
+
if (store.attempts && store.attempts[agentId] && store.attempts[agentId][ticketId] != null) {
|
|
7110
|
+
delete store.attempts[agentId][ticketId];
|
|
7111
|
+
writeWorkerStore(store);
|
|
7112
|
+
}
|
|
7113
|
+
}
|
|
7114
|
+
|
|
7115
|
+
/**
|
|
7116
|
+
* The worker's closing summary, recorded on the ticket by the SUPERVISOR.
|
|
7117
|
+
*
|
|
7118
|
+
* <p>A worker is supposed to record what it established itself, with `remits-cli ticket progress`. Most
|
|
7119
|
+
* do. The ones that do not are exactly the runs worth hearing from: a worker that hit its context limit,
|
|
7120
|
+
* that crashed after investigating, or that could not reach the platform at all. Their conclusion was
|
|
7121
|
+
* captured to disk the whole time (`codex exec --output-last-message`) and thrown away, so a ticket came
|
|
7122
|
+
* back to the queue with nothing on it and the next worker rediscovered the same thing.</p>
|
|
7123
|
+
*
|
|
7124
|
+
* <p>Posted only when the run added no worklog entry of its own, so a worker that reported properly is
|
|
7125
|
+
* not duplicated. Marked `worker_summary` rather than `investigation` so a reader can tell a finding the
|
|
7126
|
+
* worker chose to record from a transcript tail the supervisor salvaged.</p>
|
|
7127
|
+
*/
|
|
7128
|
+
async function postWorkerSummary(context, agentId, ticket, summaryFile, options = {}) {
|
|
7129
|
+
let text = '';
|
|
7130
|
+
try {
|
|
7131
|
+
text = fs.readFileSync(summaryFile, 'utf8').trim();
|
|
7132
|
+
} catch (_) {
|
|
7133
|
+
return false; // codex only writes it on a clean finish, and the other launchers never do.
|
|
7134
|
+
}
|
|
7135
|
+
if (!text) {
|
|
7136
|
+
return false;
|
|
7137
|
+
}
|
|
7138
|
+
// The worklog is read by people and by the next worker's brief; a whole transcript would bury both.
|
|
7139
|
+
const MAX = 4000;
|
|
7140
|
+
const body = text.length > MAX ? (text.slice(0, MAX) + '\n\n…truncated; full run: ' + options.logFile) : text;
|
|
7141
|
+
try {
|
|
7142
|
+
await buildAxios(context.baseUrl, context.session.token, 20000)
|
|
7143
|
+
.post('/cli/ticket', {
|
|
7144
|
+
action: 'progress',
|
|
7145
|
+
ticketId: ticket.ticketId || ticket.id,
|
|
7146
|
+
accountId: Number(ticket.accountId) || undefined,
|
|
7147
|
+
dataMode: context.dataMode,
|
|
7148
|
+
agentId,
|
|
7149
|
+
category: 'worker_summary',
|
|
7150
|
+
summary: 'Autonomous worker exited without recording a finding; this is its closing summary.',
|
|
7151
|
+
details: body,
|
|
7152
|
+
nextStep: options.nextStep || null
|
|
7153
|
+
});
|
|
7154
|
+
return true;
|
|
7155
|
+
} catch (err) {
|
|
7156
|
+
appendGlobalActivityLog('agent', 'worker.summary_post_failed', {
|
|
7157
|
+
agentId, ticketId: ticket.ticketId || ticket.id, error: describeError(err)
|
|
7158
|
+
}, 'error');
|
|
7159
|
+
return false;
|
|
7160
|
+
}
|
|
7161
|
+
}
|
|
7162
|
+
|
|
7163
|
+
/** Drop a claim on the platform. Best-effort: the claim expires on its own if this never lands. */
|
|
7164
|
+
async function releaseTicketClaim(context, agentId, ticket, options = {}) {
|
|
7165
|
+
try {
|
|
7166
|
+
const response = await buildAxios(context.baseUrl, context.session.token, 20000)
|
|
7167
|
+
.post('/cli/ticketClaim', {
|
|
7168
|
+
action: 'unclaim',
|
|
7169
|
+
agentId,
|
|
7170
|
+
ticketId: ticket.ticketId || ticket.id,
|
|
7171
|
+
accountId: Number(ticket.accountId),
|
|
7172
|
+
outcome: options.outcome || null,
|
|
7173
|
+
notes: options.notes || null,
|
|
7174
|
+
release: options.release === true,
|
|
7175
|
+
unroute: options.unroute === true
|
|
7176
|
+
}).then((r) => r.data);
|
|
7177
|
+
return response || null;
|
|
7178
|
+
} catch (err) {
|
|
7179
|
+
appendGlobalActivityLog('agent', 'worker.unclaim_failed', {
|
|
7180
|
+
agentId, ticketId: ticket.ticketId || ticket.id, error: describeError(err)
|
|
7181
|
+
}, 'error');
|
|
7182
|
+
return null;
|
|
7183
|
+
}
|
|
7184
|
+
}
|
|
7185
|
+
|
|
7186
|
+
async function postAgentStatus(context, agentId, update) {
|
|
7187
|
+
try {
|
|
7188
|
+
await buildAxios(context.baseUrl, context.session.token, 10000)
|
|
7189
|
+
.post('/cli/agentHeartbeat', { agentId, ...update });
|
|
7190
|
+
} catch (_) {
|
|
7191
|
+
// Status is commentary. Losing a line of it must never interrupt a run.
|
|
7192
|
+
}
|
|
7193
|
+
}
|
|
7194
|
+
|
|
7195
|
+
/**
|
|
7196
|
+
* Launch one headless agent process for one ticket, and see it through to its exit.
|
|
7197
|
+
*
|
|
7198
|
+
* The brief comes from the platform ({@code SupportTicketBrief}) so all three agent types are told
|
|
7199
|
+
* the same thing; only the working directory is resolved here, because only this machine knows
|
|
7200
|
+
* where the account is checked out.
|
|
7201
|
+
*/
|
|
7202
|
+
function spawnTicketWorker(context, ctx, ticket) {
|
|
7203
|
+
const { spawn } = require('child_process');
|
|
7204
|
+
const agentId = ctx.agentId;
|
|
7205
|
+
const ticketId = String(ticket.id || ticket.ticketId);
|
|
7206
|
+
const agentType = ctx.workerAgent;
|
|
7207
|
+
const launcher = AGENT_LAUNCHERS[agentType];
|
|
7208
|
+
const runId = ctx.runId + '-' + ticketId;
|
|
7209
|
+
|
|
7210
|
+
if (!launcher) {
|
|
7211
|
+
appendGlobalActivityLog('agent', 'worker.unsupported_agent', { agentId, ticketId, agentType }, 'error');
|
|
7212
|
+
return { pid: null, done: Promise.resolve({ ticketId, ok: false, reason: 'unsupported_agent' }) };
|
|
7213
|
+
}
|
|
7214
|
+
|
|
7215
|
+
const workingDirectory = resolveTicketWorkingDirectory(ticket, ctx && ctx.cwd);
|
|
7216
|
+
// The brief is rendered server-side without the local path (the platform cannot know it), so the
|
|
7217
|
+
// resolved directory is appended here. When nothing resolved, the server-rendered brief already
|
|
7218
|
+
// tells the worker to find it rather than guess — do not paper over that with a default.
|
|
7219
|
+
// The staging lane is set BY THIS FUNCTION (REMITS_WORKSPACE below) and the worker has no other way to
|
|
7220
|
+
// learn it: `components status` would show it a lane it never chose, and anything it reports about "the
|
|
7221
|
+
// staged code" is ambiguous until it knows which lane it means. Telling it is one line; making it guess
|
|
7222
|
+
// is the whole class of defect the lane exists to remove.
|
|
7223
|
+
const workerWorkspace = 'ticket-' + ticketId;
|
|
7224
|
+
const brief = String(ticket.brief || '') + (workingDirectory
|
|
7225
|
+
? '\n\n## Resolved working directory\n\n`' + workingDirectory + '`\n'
|
|
7226
|
+
: '') +
|
|
7227
|
+
'\n\n## Your staging lane\n\n' +
|
|
7228
|
+
'Every `remits-cli` command you run stages into the **`' + workerWorkspace + '`** workspace lane, not the\n' +
|
|
7229
|
+
'shared one. This is set for you; you do not need to pass `--workspace`. It means your staged components\n' +
|
|
7230
|
+
'are isolated from anyone else working this repository, and it also means a person looking at the shared\n' +
|
|
7231
|
+
'lane will not see your changes — so say which lane you staged into when you report what you verified.\n';
|
|
7232
|
+
|
|
7233
|
+
fs.mkdirSync(WORKER_LOG_DIR, { recursive: true });
|
|
7234
|
+
const logFile = path.join(WORKER_LOG_DIR, ticketId + '-' + Date.now() + '.log');
|
|
7235
|
+
const lastMessageFile = path.join(WORKER_LOG_DIR, ticketId + '-' + Date.now() + '.result.txt');
|
|
7236
|
+
// The sandbox and the brief MUST agree, and they used not to: `--mode investigate` set the launcher's
|
|
7237
|
+
// sandbox while the server-rendered brief, never told, said "edit and verify freely" — so a read-only
|
|
7238
|
+
// worker spent its run fighting a restriction nobody had mentioned. The platform now returns the phase
|
|
7239
|
+
// it derived from the repository's edit lease, and that is the authority. The session's own --mode can
|
|
7240
|
+
// only narrow it: an operator who asked for a read-only agent gets one even when the lease is free.
|
|
7241
|
+
const effectiveMode = (ctx.mode === 'investigate' || ticket.phase === 'investigating')
|
|
7242
|
+
? 'investigate'
|
|
7243
|
+
: 'edit';
|
|
7244
|
+
const built = launcher.build({
|
|
7245
|
+
cwd: workingDirectory || ctx.cwd,
|
|
7246
|
+
mode: effectiveMode,
|
|
7247
|
+
lastMessageFile: agentType === 'codex' ? lastMessageFile : null
|
|
7248
|
+
});
|
|
7249
|
+
|
|
7250
|
+
const attempt = recordWorkerAttempt(agentId, ticketId);
|
|
7251
|
+
const child = spawn(launcher.binary, built.args, {
|
|
7252
|
+
cwd: workingDirectory || ctx.cwd,
|
|
7253
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
7254
|
+
env: {
|
|
7255
|
+
...process.env,
|
|
7256
|
+
// A worker must never be mistaken for its supervisor. Without this a `remits-cli agent status`
|
|
7257
|
+
// run from inside the worker would resolve ambiguously against the local store.
|
|
7258
|
+
REMITS_AGENT_ID: agentId,
|
|
7259
|
+
REMITS_SUPPORT_TICKET_ID: ticketId,
|
|
7260
|
+
REMITS_WORKER_RUN_ID: runId,
|
|
7261
|
+
// Give the worker its OWN staging lane.
|
|
7262
|
+
//
|
|
7263
|
+
// The edit LEASE already serializes who may edit a repo, and this does not replace it — a lease
|
|
7264
|
+
// protects the working tree, which two processes editing the same files would corrupt regardless of
|
|
7265
|
+
// staging. What the lease does NOT cover is everyone else pointed at the same directory: a developer
|
|
7266
|
+
// iterating in that repo, or a read-only investigating worker running a diagnostic. They all shared
|
|
7267
|
+
// one staging lane with the editing worker, and `components stage` uploads the whole repo and
|
|
7268
|
+
// REPLACES the lane — so a worker staging mid-edit made the human's next test run execute the
|
|
7269
|
+
// worker's half-finished code, and vice versa.
|
|
7270
|
+
//
|
|
7271
|
+
// Env var rather than a flag because the worker is an arbitrary coding agent running arbitrary
|
|
7272
|
+
// remits-cli commands; only the environment reaches all of them. An explicit --workspace on a
|
|
7273
|
+
// command still wins, and `.remits-cli/workspace` in the repo does not override it.
|
|
7274
|
+
REMITS_WORKSPACE: workerWorkspace
|
|
7275
|
+
}
|
|
7276
|
+
});
|
|
7277
|
+
|
|
7278
|
+
recordWorker(agentId, ticketId, {
|
|
7279
|
+
pid: child.pid,
|
|
7280
|
+
runId,
|
|
7281
|
+
// The lane this worker was launched into. An operator reading `agent workers` otherwise has to know
|
|
7282
|
+
// the naming convention to work out where its staged components went.
|
|
7283
|
+
workspace: workerWorkspace,
|
|
7284
|
+
attempt,
|
|
7285
|
+
agentType,
|
|
7286
|
+
// The sandbox the launcher actually applied. Kept beside the phase because for codex they are no
|
|
7287
|
+
// longer the same fact: an investigating worker still runs workspace-write (it needs the network to
|
|
7288
|
+
// report at all), and its boundary is the brief. An operator reading "investigating" must be able to
|
|
7289
|
+
// see that.
|
|
7290
|
+
sandbox: built.sandbox || null,
|
|
7291
|
+
startedAt: new Date().toISOString(),
|
|
7292
|
+
workingDirectory: workingDirectory || null,
|
|
7293
|
+
accountId: ticket.accountId || null,
|
|
7294
|
+
subject: ticket.subject || null,
|
|
7295
|
+
logFile
|
|
7296
|
+
});
|
|
7297
|
+
appendGlobalActivityLog('agent', 'worker.started', {
|
|
7298
|
+
agentId, ticketId, agentType, pid: child.pid, attempt, workingDirectory: workingDirectory || null,
|
|
7299
|
+
// Which phase a run started in is the first thing anyone asks when its output looks thin, so it is
|
|
7300
|
+
// on the record rather than inferable from a sandbox flag nobody logged.
|
|
7301
|
+
phase: effectiveMode === 'investigate' ? 'investigating' : 'editing',
|
|
7302
|
+
editLease: ticket.editLease ? { agentId: ticket.editLease.agentId, ticketId: ticket.editLease.ticketId } : null
|
|
7303
|
+
});
|
|
7304
|
+
if (ticket.phase === 'investigating' && ticket.editLeaseMessage) {
|
|
7305
|
+
appendGlobalActivityLog('agent', 'worker.read_only', {
|
|
7306
|
+
agentId, ticketId, message: ticket.editLeaseMessage
|
|
7307
|
+
});
|
|
7308
|
+
}
|
|
7309
|
+
|
|
7310
|
+
const logStream = fs.createWriteStream(logFile, { flags: 'a' });
|
|
7311
|
+
let logBytes = 0;
|
|
7312
|
+
let lastStatusAtMs = 0;
|
|
7313
|
+
let buffered = '';
|
|
7314
|
+
|
|
7315
|
+
const consume = (chunk) => {
|
|
7316
|
+
const text = chunk.toString();
|
|
7317
|
+
if (logBytes < AGENT_WORKER_MAX_LOG_BYTES) {
|
|
7318
|
+
logBytes += text.length;
|
|
7319
|
+
logStream.write(text);
|
|
7320
|
+
}
|
|
7321
|
+
buffered += text;
|
|
7322
|
+
const lines = buffered.split('\n');
|
|
7323
|
+
buffered = lines.pop();
|
|
7324
|
+
for (const line of lines) {
|
|
7325
|
+
if (!line.trim()) continue;
|
|
7326
|
+
const summary = summarizeWorkerEvent(line);
|
|
7327
|
+
if (!summary) continue;
|
|
7328
|
+
const nowMs = Date.now();
|
|
7329
|
+
if (nowMs - lastStatusAtMs < AGENT_WORKER_STATUS_THROTTLE_MS) continue;
|
|
7330
|
+
lastStatusAtMs = nowMs;
|
|
7331
|
+
recordWorker(agentId, ticketId, { activity: summary, activityAt: new Date().toISOString() });
|
|
7332
|
+
postAgentStatus(context, agentId, {
|
|
7333
|
+
state: 'working', ticketId, activity: summary, step: 'worker:' + agentType
|
|
7334
|
+
});
|
|
7335
|
+
}
|
|
7336
|
+
};
|
|
7337
|
+
|
|
7338
|
+
child.stdout.on('data', consume);
|
|
7339
|
+
child.stderr.on('data', consume);
|
|
7340
|
+
child.stdin.on('error', () => { /* the worker exited before reading the brief */ });
|
|
7341
|
+
child.stdin.write(brief);
|
|
7342
|
+
child.stdin.end();
|
|
7343
|
+
|
|
7344
|
+
const done = new Promise((resolve) => {
|
|
7345
|
+
child.on('error', (err) => {
|
|
7346
|
+
appendGlobalActivityLog('agent', 'worker.spawn_failed',
|
|
7347
|
+
{ agentId, ticketId, agentType, error: describeError(err) }, 'error');
|
|
7348
|
+
resolve({ ticketId, ok: false, reason: 'spawn_failed', message: describeError(err) });
|
|
7349
|
+
});
|
|
7350
|
+
child.on('close', async (code, signal) => {
|
|
7351
|
+
try { logStream.end(); } catch (_) { /* nothing to do */ }
|
|
7352
|
+
const ok = code === 0 && !signal;
|
|
7353
|
+
appendGlobalActivityLog('agent', 'worker.finished',
|
|
7354
|
+
{ agentId, ticketId, agentType, code, signal, attempt, logFile }, ok ? 'info' : 'error');
|
|
7355
|
+
|
|
7356
|
+
// The exit code is a weak signal: a worker can exit non-zero having resolved the ticket, and
|
|
7357
|
+
// zero having done nothing. So the TICKET decides — unclaim always, and only give up (release
|
|
7358
|
+
// it back to the queue) once it has genuinely failed enough times and is still not terminal.
|
|
7359
|
+
const giveUp = !ok && attempt >= AGENT_WORKER_MAX_ATTEMPTS;
|
|
7360
|
+
const result = await releaseTicketClaim(context, agentId, { ticketId, accountId: ticket.accountId }, {
|
|
7361
|
+
outcome: ok ? 'completed' : (signal ? 'killed' : 'failed'),
|
|
7362
|
+
release: giveUp,
|
|
7363
|
+
// A clean exit that left the ticket short of terminal is a DELIVERED judgement, not a failure —
|
|
7364
|
+
// the worker chose pending_review, or documented that it cannot be done. Keeping it routed here
|
|
7365
|
+
// would have the next poll start another worker that reaches the same conclusion, forever.
|
|
7366
|
+
unroute: ok,
|
|
7367
|
+
notes: giveUp
|
|
7368
|
+
? ('Autonomous ' + agentType + ' worker failed ' + attempt + ' time(s); returning to the queue. Log: ' + logFile)
|
|
7369
|
+
: null
|
|
7370
|
+
});
|
|
7371
|
+
|
|
7372
|
+
const row = (result && result.ticket) || {};
|
|
7373
|
+
|
|
7374
|
+
// Did this run leave anything on the record? If not, salvage its closing summary before the ticket
|
|
7375
|
+
// goes back to the queue — otherwise the next worker starts from exactly where this one did.
|
|
7376
|
+
// Compared against the count we were HANDED at spawn, so a worker that reported properly (or that
|
|
7377
|
+
// asked a question, or completed) is never duplicated.
|
|
7378
|
+
const worklogBefore = Number(ticket.worklogCount || 0);
|
|
7379
|
+
const worklogAfter = Number(row.worklogCount != null ? row.worklogCount : worklogBefore);
|
|
7380
|
+
if (worklogAfter <= worklogBefore && !['resolved', 'closed'].includes(String(row.status || ''))) {
|
|
7381
|
+
await postWorkerSummary(context, agentId, { ticketId, accountId: ticket.accountId }, lastMessageFile, {
|
|
7382
|
+
logFile,
|
|
7383
|
+
nextStep: giveUp
|
|
7384
|
+
? 'This worker failed ' + attempt + ' time(s) and the ticket was returned to the queue.'
|
|
7385
|
+
: null
|
|
7386
|
+
});
|
|
7387
|
+
}
|
|
7388
|
+
|
|
7389
|
+
const terminal = ['resolved', 'closed'].includes(String(row.status || ''));
|
|
7390
|
+
if (terminal || giveUp) {
|
|
7391
|
+
clearWorkerAttempts(agentId, ticketId);
|
|
7392
|
+
}
|
|
7393
|
+
clearWorker(agentId, ticketId);
|
|
7394
|
+
resolve({ ticketId, ok, code, signal, terminal, gaveUp: giveUp, status: row.status || null, logFile });
|
|
7395
|
+
});
|
|
7396
|
+
});
|
|
7397
|
+
|
|
7398
|
+
return { pid: child.pid, done };
|
|
7399
|
+
}
|
|
7400
|
+
|
|
7401
|
+
/**
|
|
7402
|
+
* The one detached process behind a registered agent session: presence, and optionally work.
|
|
7403
|
+
*
|
|
7404
|
+
* <p>Presence and work-collection live in the SAME process on purpose. Both need to know how many
|
|
7405
|
+
* workers are running — presence publishes it as capacity, collection needs it to decide how many
|
|
7406
|
+
* tickets to take — and two processes maintaining that number separately would disagree the moment
|
|
7407
|
+
* one of them stalled, with the platform believing whichever wrote last.</p>
|
|
7408
|
+
*
|
|
7409
|
+
* <p>When serving, the work poll IS the heartbeat: `/cli/agentWork` refreshes presence, publishes
|
|
7410
|
+
* the live worker set, renews their claims, and returns newly claimed work, all in one request.</p>
|
|
7411
|
+
*/
|
|
5820
7412
|
async function agentHeartbeatDaemonCommand(flags) {
|
|
5821
7413
|
const agentId = String(flags['agent-id'] || flags.agentId || '').trim();
|
|
5822
7414
|
const anchorPid = Number(flags['anchor-pid'] || flags.anchorPid);
|
|
@@ -5829,11 +7421,36 @@ async function agentHeartbeatDaemonCommand(flags) {
|
|
|
5829
7421
|
// Re-registration must reuse the anchor it was given, never re-resolve one.
|
|
5830
7422
|
process.env.REMITS_AGENT_ANCHOR_PID = String(anchorPid);
|
|
5831
7423
|
|
|
7424
|
+
const serving = flagEnabled(flags.serve);
|
|
7425
|
+
const ctx = {
|
|
7426
|
+
agentId,
|
|
7427
|
+
cwd,
|
|
7428
|
+
// The daemon is detached: its own process tree says nothing about the session it serves, so the
|
|
7429
|
+
// worker kind must come from the flag it was started with, never from re-detection.
|
|
7430
|
+
workerAgent: workerAgentType(flags['worker-agent'] || flags.agent),
|
|
7431
|
+
mode: String(flags.mode || 'edit').toLowerCase() === 'investigate' ? 'investigate' : 'edit',
|
|
7432
|
+
maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1)),
|
|
7433
|
+
runId: 'run-' + Date.now().toString(36)
|
|
7434
|
+
};
|
|
7435
|
+
const inFlight = new Map();
|
|
7436
|
+
|
|
5832
7437
|
const stop = async (reason) => {
|
|
5833
|
-
appendGlobalActivityLog('agent', 'heartbeat.stopped',
|
|
7438
|
+
appendGlobalActivityLog('agent', 'heartbeat.stopped',
|
|
7439
|
+
{ agentId, anchorPid, reason, workers: inFlight.size });
|
|
7440
|
+
// Kill the workers first, then drop their claims. A worker left running after its supervisor is
|
|
7441
|
+
// gone is unsupervised autonomy on production tickets — and its claim would then expire under a
|
|
7442
|
+
// process that is still writing.
|
|
7443
|
+
for (const [ticketId, entry] of inFlight.entries()) {
|
|
7444
|
+
try { process.kill(entry.pid, 'SIGTERM'); } catch (_) { /* already gone */ }
|
|
7445
|
+
clearWorker(agentId, ticketId);
|
|
7446
|
+
}
|
|
5834
7447
|
forgetLocalAgent(agentId);
|
|
5835
7448
|
try {
|
|
5836
7449
|
const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
|
|
7450
|
+
for (const [ticketId, entry] of inFlight.entries()) {
|
|
7451
|
+
await releaseTicketClaim(context, agentId,
|
|
7452
|
+
{ ticketId, accountId: entry.accountId }, { outcome: 'supervisor_stopped' });
|
|
7453
|
+
}
|
|
5837
7454
|
await buildAxios(context.baseUrl, context.session.token, 5000)
|
|
5838
7455
|
.post('/cli/agentDeregister', { agentId });
|
|
5839
7456
|
} catch (_) {
|
|
@@ -5845,21 +7462,108 @@ async function agentHeartbeatDaemonCommand(flags) {
|
|
|
5845
7462
|
process.on('SIGTERM', () => { stop('signal'); });
|
|
5846
7463
|
process.on('SIGINT', () => { stop('signal'); });
|
|
5847
7464
|
|
|
7465
|
+
// A previous supervisor for this agent may have been killed mid-run, leaving claims behind. They
|
|
7466
|
+
// would expire on their own, but that is up to 15 minutes during which this session cannot restart
|
|
7467
|
+
// the work it is still routed. Clear anything whose process is demonstrably gone.
|
|
7468
|
+
if (serving) {
|
|
7469
|
+
for (const stale of listWorkers(agentId)) {
|
|
7470
|
+
if (stale && stale.pid && processIsAlive(stale.pid)) {
|
|
7471
|
+
continue;
|
|
7472
|
+
}
|
|
7473
|
+
clearWorker(agentId, stale.ticketId);
|
|
7474
|
+
try {
|
|
7475
|
+
const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
|
|
7476
|
+
await releaseTicketClaim(context, agentId, stale, { outcome: 'orphaned' });
|
|
7477
|
+
} catch (_) { /* the claim expires regardless */ }
|
|
7478
|
+
}
|
|
7479
|
+
}
|
|
7480
|
+
|
|
7481
|
+
let emptyPolls = 0;
|
|
5848
7482
|
for (;;) {
|
|
5849
7483
|
if (!processIsAlive(anchorPid)) {
|
|
5850
7484
|
await stop('anchor_exited');
|
|
5851
7485
|
return;
|
|
5852
7486
|
}
|
|
7487
|
+
let response = null;
|
|
5853
7488
|
try {
|
|
5854
7489
|
const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
|
|
5855
|
-
const
|
|
5856
|
-
|
|
7490
|
+
const api = buildAxios(context.baseUrl, context.session.token, 30000);
|
|
7491
|
+
|
|
7492
|
+
if (serving) {
|
|
7493
|
+
const activeTickets = [...inFlight.keys()];
|
|
7494
|
+
response = await api.post('/cli/agentWork', {
|
|
7495
|
+
agentId,
|
|
7496
|
+
claim: inFlight.size < ctx.maxConcurrent,
|
|
7497
|
+
runId: ctx.runId,
|
|
7498
|
+
activeTickets,
|
|
7499
|
+
maxConcurrent: ctx.maxConcurrent,
|
|
7500
|
+
// Declared so the phase the platform derives already accounts for an operator who asked for a
|
|
7501
|
+
// read-only agent. Without it the server would hand out an edit lease this session will never
|
|
7502
|
+
// use, blocking a repository nobody is editing.
|
|
7503
|
+
mode: ctx.mode,
|
|
7504
|
+
statuses: 'all'
|
|
7505
|
+
}).then((r) => r.data);
|
|
7506
|
+
|
|
7507
|
+
// The CLI and the platform are released separately, so a new supervisor can meet an older
|
|
7508
|
+
// platform. That platform ignores `claim` and answers with EVERY routed ticket — which this loop
|
|
7509
|
+
// would read as "start all of these", spawning past capacity with no claim protecting any of
|
|
7510
|
+
// them. `supervising` is the platform confirming it understood the request, so its absence means
|
|
7511
|
+
// stand down rather than guess.
|
|
7512
|
+
if (response && response.supervising !== true) {
|
|
7513
|
+
if (emptyPolls === 0) {
|
|
7514
|
+
appendGlobalActivityLog('agent', 'serve.platform_too_old', {
|
|
7515
|
+
agentId,
|
|
7516
|
+
message: 'This platform does not support supervised claiming, so no workers will be started. ' +
|
|
7517
|
+
'Update the platform, or work tickets manually with: remits-cli agent work'
|
|
7518
|
+
}, 'error');
|
|
7519
|
+
}
|
|
7520
|
+
emptyPolls += 1;
|
|
7521
|
+
await new Promise((resolve) => setTimeout(resolve, AGENT_SERVE_IDLE_POLL_MS));
|
|
7522
|
+
continue;
|
|
7523
|
+
}
|
|
7524
|
+
|
|
7525
|
+
// A swept ticket was routed at nobody and this session took it itself. Logged distinctly from a
|
|
7526
|
+
// routed one so an operator reading the activity log can tell "somebody handed me this" from
|
|
7527
|
+
// "nobody had, and I picked it up".
|
|
7528
|
+
const sweptIds = new Set(((response && response.sweptTicketIds) || []).map((id) => String(id)));
|
|
7529
|
+
if (sweptIds.size) {
|
|
7530
|
+
appendGlobalActivityLog('agent', 'serve.swept_unrouted', {
|
|
7531
|
+
agentId,
|
|
7532
|
+
ticketIds: [...sweptIds],
|
|
7533
|
+
message: 'Claimed ' + sweptIds.size + ' unrouted ticket(s) nobody had been given'
|
|
7534
|
+
});
|
|
7535
|
+
}
|
|
7536
|
+
|
|
7537
|
+
for (const ticket of (response && response.tickets) || []) {
|
|
7538
|
+
const ticketId = String(ticket.id || ticket.ticketId || '');
|
|
7539
|
+
if (!ticketId || inFlight.has(ticketId)) {
|
|
7540
|
+
continue;
|
|
7541
|
+
}
|
|
7542
|
+
const started = spawnTicketWorker(context, ctx, ticket);
|
|
7543
|
+
inFlight.set(ticketId, { pid: started.pid, accountId: ticket.accountId });
|
|
7544
|
+
started.done.then(() => {
|
|
7545
|
+
inFlight.delete(ticketId);
|
|
7546
|
+
postAgentStatus(context, agentId, inFlight.size
|
|
7547
|
+
? { state: 'working', ticketId: [...inFlight.keys()][0] }
|
|
7548
|
+
: { state: 'idle', ticketId: null, activity: null, step: null });
|
|
7549
|
+
});
|
|
7550
|
+
}
|
|
7551
|
+
emptyPolls = (response && response.count) ? 0 : emptyPolls + 1;
|
|
7552
|
+
} else {
|
|
7553
|
+
response = await api.post('/cli/agentHeartbeat', { agentId }).then((r) => r.data);
|
|
7554
|
+
}
|
|
7555
|
+
|
|
5857
7556
|
if (response && response.registered === false) {
|
|
5858
7557
|
// The record expired (a long sleep, a platform restart, a Redis flush). Re-register rather
|
|
5859
7558
|
// than heartbeating into a void — the tab is still very much alive.
|
|
5860
7559
|
appendGlobalActivityLog('agent', 'heartbeat.reregistering', { agentId });
|
|
5861
7560
|
await agentRegisterCommand(
|
|
5862
|
-
{
|
|
7561
|
+
{
|
|
7562
|
+
'agent-id': agentId,
|
|
7563
|
+
'base-url': context.baseUrl,
|
|
7564
|
+
'max-concurrent': String(ctx.maxConcurrent),
|
|
7565
|
+
json: 'true'
|
|
7566
|
+
},
|
|
5863
7567
|
{ spawnHeartbeat: false }
|
|
5864
7568
|
).catch((err) => {
|
|
5865
7569
|
appendGlobalActivityLog('agent', 'heartbeat.reregister_failed',
|
|
@@ -5869,7 +7573,11 @@ async function agentHeartbeatDaemonCommand(flags) {
|
|
|
5869
7573
|
} catch (err) {
|
|
5870
7574
|
appendGlobalActivityLog('agent', 'heartbeat.failed', { agentId, error: describeError(err) }, 'error');
|
|
5871
7575
|
}
|
|
5872
|
-
|
|
7576
|
+
|
|
7577
|
+
const interval = !serving
|
|
7578
|
+
? AGENT_HEARTBEAT_INTERVAL_MS
|
|
7579
|
+
: (emptyPolls >= AGENT_SERVE_IDLE_AFTER_POLLS ? AGENT_SERVE_IDLE_POLL_MS : AGENT_SERVE_POLL_MS);
|
|
7580
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
5873
7581
|
}
|
|
5874
7582
|
}
|
|
5875
7583
|
|
|
@@ -5972,8 +7680,8 @@ async function agentWorkCommand(flags) {
|
|
|
5972
7680
|
}
|
|
5973
7681
|
console.log('');
|
|
5974
7682
|
console.log('Next: read it in full, then claim it.');
|
|
5975
|
-
console.log(' remits-cli
|
|
5976
|
-
|
|
7683
|
+
console.log(' remits-cli ticket read --ticket ' + (tickets[0].id || tickets[0].ticketId));
|
|
7684
|
+
console.log(' remits-cli ticket accept --ticket ' + (tickets[0].id || tickets[0].ticketId));
|
|
5977
7685
|
console.log(' remits-cli agent status --state working --ticket ' + (tickets[0].id || tickets[0].ticketId) +
|
|
5978
7686
|
' --activity "reproducing the issue"');
|
|
5979
7687
|
return;
|
|
@@ -5983,6 +7691,647 @@ async function agentWorkCommand(flags) {
|
|
|
5983
7691
|
}
|
|
5984
7692
|
}
|
|
5985
7693
|
|
|
7694
|
+
/**
|
|
7695
|
+
* The generic ticket lifecycle — read a ticket, and move it through its workflow.
|
|
7696
|
+
*
|
|
7697
|
+
* Deliberately NOT a wrapper around any account's ticket tool. `mcp_support_ticket` belongs to one
|
|
7698
|
+
* particular System Account: it is owned by an account a given caller may not reach, it scopes to
|
|
7699
|
+
* its own account, and a partner platform need not have it at all. This talks to the platform's own
|
|
7700
|
+
* record, so the same commands work on every Remits platform — which is what an autonomous worker
|
|
7701
|
+
* needs, since its brief has to be written before anyone knows which platform it will run on.
|
|
7702
|
+
*/
|
|
7703
|
+
async function ticketCommand(flags, subcommand) {
|
|
7704
|
+
const action = String(subcommand || 'read').toLowerCase();
|
|
7705
|
+
|
|
7706
|
+
// `queue` and `create` are the two verbs with no ticket to act on, so they are dispatched before the
|
|
7707
|
+
// id requirement rather than being forced to invent one.
|
|
7708
|
+
if (action === 'queue' || action === 'list' || action === 'ls') {
|
|
7709
|
+
return ticketQueueCommand(flags);
|
|
7710
|
+
}
|
|
7711
|
+
if (action === 'create' || action === 'new' || action === 'raise') {
|
|
7712
|
+
return ticketCreateCommand(flags);
|
|
7713
|
+
}
|
|
7714
|
+
// Bulk reclaim scans a queue rather than acting on one id, so it is dispatched here too. With
|
|
7715
|
+
// --ticket it falls through to the single-ticket path below like any other verb.
|
|
7716
|
+
if (action === 'reclaim' && !flags.ticket && !flags['ticket-id']) {
|
|
7717
|
+
return ticketReclaimSweepCommand(flags);
|
|
7718
|
+
}
|
|
7719
|
+
|
|
7720
|
+
const ticketId = String(flags.ticket || flags['ticket-id'] || flags._?.[2] || process.env.REMITS_SUPPORT_TICKET_ID || '').trim();
|
|
7721
|
+
if (!ticketId) {
|
|
7722
|
+
printTicketHelp();
|
|
7723
|
+
throw new Error('A --ticket ID is required (or set REMITS_SUPPORT_TICKET_ID).');
|
|
7724
|
+
}
|
|
7725
|
+
|
|
7726
|
+
const local = listLocalAgents()[0] || null;
|
|
7727
|
+
const context = agentSessionFor(flags, local);
|
|
7728
|
+
|
|
7729
|
+
// Routing lives on its own endpoint (the operator surface the dashboard and any Embeddable share),
|
|
7730
|
+
// so it is dispatched here rather than folded into the lifecycle switch.
|
|
7731
|
+
if (action === 'route' || action === 'unroute') {
|
|
7732
|
+
const agentId = action === 'unroute' ? null : String(flags.agent || flags['agent-id'] || '').trim();
|
|
7733
|
+
if (action === 'route' && !agentId) {
|
|
7734
|
+
throw new Error('route needs --agent <agentId>. See: remits-cli agent list');
|
|
7735
|
+
}
|
|
7736
|
+
const accountId = Number(flags['account-id']) || (await resolveTicketAccountId(context, ticketId));
|
|
7737
|
+
const result = await buildAxios(context.baseUrl, context.session.token, 20000)
|
|
7738
|
+
.post('/cli/ticketRoute', { ticketId, accountId, agentId, dataMode: context.dataMode })
|
|
7739
|
+
.then((r) => r.data)
|
|
7740
|
+
.catch((err) => (err && err.response && err.response.data) || null);
|
|
7741
|
+
if (!result || result.success === false) {
|
|
7742
|
+
console.error('Refused: ' + ((result && result.message) || 'routing failed'));
|
|
7743
|
+
process.exitCode = 1;
|
|
7744
|
+
return;
|
|
7745
|
+
}
|
|
7746
|
+
console.log(result.routed
|
|
7747
|
+
? 'routed ticket ' + ticketId + ' to ' + (result.agentLabel || result.agentId)
|
|
7748
|
+
: 'cleared routing on ticket ' + ticketId);
|
|
7749
|
+
return;
|
|
7750
|
+
}
|
|
7751
|
+
|
|
7752
|
+
const payload = {
|
|
7753
|
+
action,
|
|
7754
|
+
ticketId,
|
|
7755
|
+
accountId: Number(flags['account-id']) || undefined,
|
|
7756
|
+
dataMode: context.dataMode,
|
|
7757
|
+
status: flags.status,
|
|
7758
|
+
resolution: flags.resolution,
|
|
7759
|
+
assignee: flags.assignee,
|
|
7760
|
+
notes: flags.notes,
|
|
7761
|
+
summary: flags.summary || flags.note,
|
|
7762
|
+
question: flags.question,
|
|
7763
|
+
context: flags.context,
|
|
7764
|
+
// Recipients are a list on the platform side (message.to / deliver.to). A single value still works,
|
|
7765
|
+
// so `--to a@x.test` and `--to a@x.test,b@x.test` both do what they look like they do.
|
|
7766
|
+
to: flags.to ? String(flags.to).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
|
|
7767
|
+
message: flags.message || flags.answer,
|
|
7768
|
+
route: flags.route === 'false' ? false : undefined,
|
|
7769
|
+
details: flags.details,
|
|
7770
|
+
nextStep: flags['next-step'],
|
|
7771
|
+
category: flags.category,
|
|
7772
|
+
// Clearing a planning slot has to be expressible, and `||` could not express it: an empty value is
|
|
7773
|
+
// falsy, so it collapsed to undefined, JSON.stringify dropped the key, and the server was never told.
|
|
7774
|
+
// The platform side has always supported clearing (an explicit blank nulls the field) — only the CLI
|
|
7775
|
+
// could not ask for it. `--planned-in` with no value now means "clear it".
|
|
7776
|
+
plannedIn: planningFlagValue(firstDefined(flags['planned-in'], flags.plannedIn)),
|
|
7777
|
+
boardStage: planningFlagValue(firstDefined(flags['board-stage'], flags.boardStage)),
|
|
7778
|
+
rank: planningFlagValue(flags.rank),
|
|
7779
|
+
size: planningFlagValue(flags.size),
|
|
7780
|
+
blockedBy: planningFlagValue(firstDefined(flags['blocked-by'], flags.blockedBy)),
|
|
7781
|
+
workstream: planningFlagValue(flags.workstream),
|
|
7782
|
+
tags: flags.tags ? String(flags.tags).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
|
|
7783
|
+
label: flags.label,
|
|
7784
|
+
url: flags.url,
|
|
7785
|
+
body: flags.body,
|
|
7786
|
+
role: flags.role,
|
|
7787
|
+
email: flags.email,
|
|
7788
|
+
key: flags.key,
|
|
7789
|
+
value: flags.value,
|
|
7790
|
+
// Conversation and outbox. `--message` is shared with `answer`/`note`, which is deliberate: it is
|
|
7791
|
+
// always "the text", and the ACTION decides who ends up reading it.
|
|
7792
|
+
channel: flags.channel,
|
|
7793
|
+
subject: flags.subject,
|
|
7794
|
+
visibility: flags.visibility,
|
|
7795
|
+
direction: flags.direction,
|
|
7796
|
+
template: flags.template,
|
|
7797
|
+
cc: flags.cc ? String(flags.cc).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
|
|
7798
|
+
deliveryId: flags['delivery-id'] || flags.deliveryId || undefined,
|
|
7799
|
+
idempotencyKey: flags['idempotency-key'] || flags.idempotencyKey || undefined,
|
|
7800
|
+
providerMessageId: flags['provider-message-id'] || undefined,
|
|
7801
|
+
reason: flags.reason,
|
|
7802
|
+
// Lease operations act as the AGENT, not the user: the lease is held by a worker session, and a
|
|
7803
|
+
// human's email is not a thing that can hold one. Inside a worker these come from the environment
|
|
7804
|
+
// the supervisor set, so the commands need no flags.
|
|
7805
|
+
agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined,
|
|
7806
|
+
runId: flags['run-id'] || process.env.REMITS_WORKER_RUN_ID || undefined,
|
|
7807
|
+
agentLabel: flags['agent-label'] || undefined,
|
|
7808
|
+
// WHERE this run is working, sent with every lifecycle call so the lease and the claim record THIS
|
|
7809
|
+
// process's checkout rather than the supervisor's. A ticket worker runs in its own workspace lane
|
|
7810
|
+
// (REMITS_WORKSPACE=ticket-<id>) and may run in its own worktree, so the two genuinely differ.
|
|
7811
|
+
...runContextPayload(flags)
|
|
7812
|
+
};
|
|
7813
|
+
|
|
7814
|
+
let response;
|
|
7815
|
+
try {
|
|
7816
|
+
response = await buildAxios(context.baseUrl, context.session.token, 30000)
|
|
7817
|
+
.post('/cli/ticket', payload).then((r) => r.data);
|
|
7818
|
+
} catch (err) {
|
|
7819
|
+
// A lifecycle guard answers with 409 and a sentence saying what is wrong ("accept it first",
|
|
7820
|
+
// "already assigned to someone else"). That sentence IS the correction a caller needs, so it must
|
|
7821
|
+
// reach them intact rather than as an HTTP status.
|
|
7822
|
+
const body = err && err.response && err.response.data;
|
|
7823
|
+
if (body && body.message) {
|
|
7824
|
+
console.error('Refused: ' + body.message);
|
|
7825
|
+
process.exitCode = 1;
|
|
7826
|
+
return;
|
|
7827
|
+
}
|
|
7828
|
+
throw err;
|
|
7829
|
+
}
|
|
7830
|
+
|
|
7831
|
+
if (action === 'where' || action === 'context') {
|
|
7832
|
+
// The one run-context block, rendered by the platform so `ticket where`, `agent map`, the MCP tool and
|
|
7833
|
+
// the operator UI cannot paraphrase the same repository four different ways.
|
|
7834
|
+
const lines = (response && response.runContext && response.runContext.lines) || [];
|
|
7835
|
+
if (!lines.length) console.log('No run context for ticket ' + ticketId + '.');
|
|
7836
|
+
else lines.forEach((line) => console.log(' ' + line));
|
|
7837
|
+
return;
|
|
7838
|
+
}
|
|
7839
|
+
|
|
7840
|
+
if (flagEnabled(flags.json) || action === 'read' || action === 'deliveries') {
|
|
7841
|
+
console.log(JSON.stringify(response, null, 2));
|
|
7842
|
+
return;
|
|
7843
|
+
}
|
|
7844
|
+
|
|
7845
|
+
if (action === 'force-unlease' || action === 'force_unlease') {
|
|
7846
|
+
const previous = (response && response.previous) || null;
|
|
7847
|
+
console.log(response.released
|
|
7848
|
+
? 'Edit lease on repository account ' + (response.repoAccountId || '?') + ' force-released' +
|
|
7849
|
+
(previous ? ' (was held by ' + (previous.agentLabel || previous.agentId) + ')' : '') + '.'
|
|
7850
|
+
: 'Nothing was holding the edit lease on this repository.');
|
|
7851
|
+
console.log(' A handoff worklog entry was recorded on ticket ' + ticketId + '.');
|
|
7852
|
+
return;
|
|
7853
|
+
}
|
|
7854
|
+
|
|
7855
|
+
if (action === 'lease' || action === 'unlease') {
|
|
7856
|
+
const lease = (response && response.editLease) || null;
|
|
7857
|
+
if (action === 'unlease') {
|
|
7858
|
+
console.log(response.released
|
|
7859
|
+
? 'Edit lease released. Another agent can now edit this repository.'
|
|
7860
|
+
: 'You did not hold the edit lease on this repository; nothing to release.');
|
|
7861
|
+
return;
|
|
7862
|
+
}
|
|
7863
|
+
if (response.success) {
|
|
7864
|
+
console.log('Edit lease acquired — you may edit, stage and verify this repository.');
|
|
7865
|
+
console.log('Release it as soon as you are done: remits-cli ticket unlease');
|
|
7866
|
+
return;
|
|
7867
|
+
}
|
|
7868
|
+
// Not an error. The run continues read-only, and the message says who has it, WHERE they are working,
|
|
7869
|
+
// and what to do instead of waiting.
|
|
7870
|
+
console.log('Edit lease NOT acquired — this run stays read-only.');
|
|
7871
|
+
if (response.message) console.log(response.message);
|
|
7872
|
+
else if (lease) console.log('Held by ' + (lease.agentLabel || lease.agentId) + '.');
|
|
7873
|
+
const holderAt = describeRunLocation(lease);
|
|
7874
|
+
if (holderAt) console.log('Holder is working ' + holderAt);
|
|
7875
|
+
process.exitCode = 0;
|
|
7876
|
+
return;
|
|
7877
|
+
}
|
|
7878
|
+
|
|
7879
|
+
const ticket = (response && response.ticket) || {};
|
|
7880
|
+
console.log(action + ' ok — ticket ' + ticketId + ' is now [' + (ticket.status || '?') + ']' +
|
|
7881
|
+
(ticket.assignedTo ? ', owned by ' + ticket.assignedTo : ', unassigned'));
|
|
7882
|
+
// Say what was actually written. Without this, `message`, `note`, `deliver` and `participant` all
|
|
7883
|
+
// printed the same status line, so the only feedback distinguishing "told the customer" from "left an
|
|
7884
|
+
// internal note" was the word the caller had just typed.
|
|
7885
|
+
if (action === 'message') {
|
|
7886
|
+
console.log(' public message added (' + (ticket.messageCount || '?') + ' on the ticket) — visible to the requester.');
|
|
7887
|
+
console.log(' This did NOT send anything. To have it delivered: remits-cli ticket deliver --channel email --to <who>');
|
|
7888
|
+
}
|
|
7889
|
+
if (action === 'note') console.log(' internal note added — the requester does not see it.');
|
|
7890
|
+
if (action === 'participant') console.log(' participants: ' + (ticket.participantCount || '?'));
|
|
7891
|
+
if (action === 'deliver') {
|
|
7892
|
+
console.log(' delivery queued (' + (ticket.deliveryCount || '?') + ' on the ticket). An account-owned rule or');
|
|
7893
|
+
console.log(' scheduled Action sends it. Check with: remits-cli ticket deliveries --ticket ' + ticketId);
|
|
7894
|
+
}
|
|
7895
|
+
if (action === 'planning' || action === 'plan') {
|
|
7896
|
+
const planning = [
|
|
7897
|
+
ticket.workstream ? 'workstream=' + ticket.workstream : '',
|
|
7898
|
+
ticket.plannedIn ? 'plannedIn=' + ticket.plannedIn : '',
|
|
7899
|
+
ticket.boardStage ? 'boardStage=' + ticket.boardStage : '',
|
|
7900
|
+
ticket.rank !== undefined && ticket.rank !== null && ticket.rank !== '' ? 'rank=' + ticket.rank : '',
|
|
7901
|
+
ticket.size ? 'size=' + ticket.size : '',
|
|
7902
|
+
ticket.blockedBy ? 'blockedBy=' + ticket.blockedBy : ''
|
|
7903
|
+
].filter(Boolean).join(', ');
|
|
7904
|
+
console.log(' planning: ' + (planning || 'cleared'));
|
|
7905
|
+
}
|
|
7906
|
+
if (ticket.awaitingResponseSince) {
|
|
7907
|
+
console.log(' waiting on an answer since ' + ticket.awaitingResponseSince);
|
|
7908
|
+
if (ticket.awaitingResponseQuestion) {
|
|
7909
|
+
console.log(' question: ' + String(ticket.awaitingResponseQuestion).split('\n')[0].slice(0, 160));
|
|
7910
|
+
}
|
|
7911
|
+
console.log(' answer it with: remits-cli ticket reply --ticket ' + ticketId + ' --message "..."');
|
|
7912
|
+
}
|
|
7913
|
+
}
|
|
7914
|
+
|
|
7915
|
+
/**
|
|
7916
|
+
* The ticket queue — what else is open, and what it looks like as a whole.
|
|
7917
|
+
*
|
|
7918
|
+
* <p><b>Why this had to exist.</b> The worker brief is emphatic that `remits-cli ticket` is the surface to
|
|
7919
|
+
* use, and that an account's own ticket tooling must not be reached for: it belongs to one account, may not
|
|
7920
|
+
* exist on this platform, and refuses tickets other accounts own. That advice was right and the surface was
|
|
7921
|
+
* incomplete — it could act on ONE ticket whose id it was handed, and could not see anything else. So a
|
|
7922
|
+
* worker could not tell that the ticket it was given is one of eleven instances of the same fix, could not
|
|
7923
|
+
* check whether the defect it just found is already filed, and could not triage at all. The server side
|
|
7924
|
+
* already existed in full (`/cli/tickets`, the same `queue(...)` the operator UI reads); its only consumer
|
|
7925
|
+
* was the local dashboard.</p>
|
|
7926
|
+
*/
|
|
7927
|
+
async function ticketQueueCommand(flags) {
|
|
7928
|
+
const local = listLocalAgents()[0] || null;
|
|
7929
|
+
const context = agentSessionFor(flags, local);
|
|
7930
|
+
const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
|
|
7931
|
+
if (!Number.isFinite(accountId) || accountId <= 0) {
|
|
7932
|
+
throw new Error('ticket queue needs --account-id (or run it from an account repo).');
|
|
7933
|
+
}
|
|
7934
|
+
|
|
7935
|
+
const csv = (value) => (value ? String(value).split(',').map((v) => v.trim()).filter(Boolean) : undefined);
|
|
7936
|
+
const response = await buildAxios(context.baseUrl, context.session.token, 30000)
|
|
7937
|
+
.post('/cli/tickets', {
|
|
7938
|
+
accountId,
|
|
7939
|
+
dataMode: context.dataMode,
|
|
7940
|
+
statuses: csv(flags.status || flags.statuses),
|
|
7941
|
+
priorities: csv(flags.priority || flags.priorities),
|
|
7942
|
+
types: csv(flags.type || flags.types),
|
|
7943
|
+
sources: csv(flags.source || flags.sources),
|
|
7944
|
+
workstream: flags.workstream,
|
|
7945
|
+
assignedTo: flags['assigned-to'] || flags.assignedTo,
|
|
7946
|
+
routedAgentId: flags['routed-to'] || flags.routedAgentId,
|
|
7947
|
+
unrouted: flagEnabled(flags.unrouted) || undefined,
|
|
7948
|
+
unassigned: flagEnabled(flags.unassigned) || undefined,
|
|
7949
|
+
// "Parked on a human" cannot be asked for with a status filter: `ask` parks a ticket in
|
|
7950
|
+
// pending_review, which also means "done, review me".
|
|
7951
|
+
awaitingResponse: flagEnabled(flags['awaiting-response'] || flags.awaitingResponse) || undefined,
|
|
7952
|
+
plannedIn: flags['planned-in'] || flags.plannedIn,
|
|
7953
|
+
boardStage: flags['board-stage'] || flags.boardStage,
|
|
7954
|
+
blockedBy: flags['blocked-by'] || flags.blockedBy,
|
|
7955
|
+
search: flags.search || flags.q,
|
|
7956
|
+
sortBy: flags['sort-by'] || flags.sortBy,
|
|
7957
|
+
sortDirection: flags['sort-direction'] || flags.sortDirection,
|
|
7958
|
+
includeChildren: flags['include-children'] === 'false' ? false : undefined,
|
|
7959
|
+
limit: parsePositiveInt(flags.limit, 25)
|
|
7960
|
+
}).then((r) => r.data);
|
|
7961
|
+
|
|
7962
|
+
if (flagEnabled(flags.json)) {
|
|
7963
|
+
console.log(JSON.stringify(response, null, 2));
|
|
7964
|
+
return;
|
|
7965
|
+
}
|
|
7966
|
+
|
|
7967
|
+
const tickets = (response && response.tickets) || [];
|
|
7968
|
+
const counts = (response && response.counts) || {};
|
|
7969
|
+
if (!tickets.length) {
|
|
7970
|
+
console.log('No tickets match. (' + (response && response.dataMode) + ' lane, account ' + accountId + ')');
|
|
7971
|
+
return;
|
|
7972
|
+
}
|
|
7973
|
+
console.log(String(response.totalCount != null ? response.totalCount : tickets.length) +
|
|
7974
|
+
' ticket(s) — ' + (counts.unrouted || 0) + ' unrouted, ' + (counts.unassigned || 0) + ' unassigned' +
|
|
7975
|
+
', ' + (counts.awaitingResponse || 0) + ' waiting on a human' +
|
|
7976
|
+
' [' + (response.dataMode || '?') + ' lane]');
|
|
7977
|
+
console.log('');
|
|
7978
|
+
// Board position is printed only when some ticket in this queue actually has one. An always-on column
|
|
7979
|
+
// would be blank for every account that does not run a board, and a blank column reads as missing data.
|
|
7980
|
+
const showsBoard = tickets.some((t) => t.boardStage || t.plannedIn || t.blockedBy);
|
|
7981
|
+
for (const t of tickets) {
|
|
7982
|
+
// The repo account, not just the owning account: it is what the edit lease excludes on, so it is
|
|
7983
|
+
// what tells a reader which of these can be worked at the same time.
|
|
7984
|
+
const repo = t.implementationAccountId || t.accountId;
|
|
7985
|
+
const who = t.routedAgentLabel || t.routedAgentId || t.assignedTo || '—';
|
|
7986
|
+
console.log(' ' + String(t.ticketId || t.id).padStart(6) + ' ' +
|
|
7987
|
+
String(t.status || '').padEnd(14) + String(t.priority || '').padEnd(9) +
|
|
7988
|
+
'repo ' + String(repo || '?').padEnd(6) +
|
|
7989
|
+
String(who).slice(0, 18).padEnd(20) + String(t.subject || '').slice(0, 58));
|
|
7990
|
+
// A second line rather than more columns: these are the account's own labels, of unbounded length,
|
|
7991
|
+
// and truncating `awaiting_customer_confirmation` to fit a column makes it unrecognisable.
|
|
7992
|
+
const marks = [
|
|
7993
|
+
t.awaitingResponseSince ? 'WAITING ON A HUMAN' : '',
|
|
7994
|
+
showsBoard && t.boardStage ? 'stage ' + t.boardStage : '',
|
|
7995
|
+
showsBoard && t.plannedIn ? 'in ' + t.plannedIn : '',
|
|
7996
|
+
showsBoard && t.blockedBy ? 'BLOCKED BY ' + t.blockedBy : ''
|
|
7997
|
+
].filter(Boolean);
|
|
7998
|
+
if (marks.length) console.log(' '.repeat(10) + marks.join(' '));
|
|
7999
|
+
}
|
|
8000
|
+
if (response.totalCount > tickets.length) {
|
|
8001
|
+
console.log('');
|
|
8002
|
+
console.log(' showing ' + tickets.length + ' of ' + response.totalCount + ' — raise --limit to see more');
|
|
8003
|
+
}
|
|
8004
|
+
}
|
|
8005
|
+
|
|
8006
|
+
/**
|
|
8007
|
+
* Find tickets an agent took and abandoned, and hand them back to the queue.
|
|
8008
|
+
*
|
|
8009
|
+
* <p><b>Dry by default.</b> This takes work away from a holder on a judgement about staleness, across a
|
|
8010
|
+
* whole account tree, in one command — so the default is to show what it WOULD do. `--apply` is the
|
|
8011
|
+
* operator saying yes. The platform re-checks every row against the document before acting, so a stale
|
|
8012
|
+
* listing cannot cause a wrong reclaim even if this list is minutes old.</p>
|
|
8013
|
+
*/
|
|
8014
|
+
async function ticketReclaimSweepCommand(flags) {
|
|
8015
|
+
const local = listLocalAgents()[0] || null;
|
|
8016
|
+
const context = agentSessionFor(flags, local);
|
|
8017
|
+
const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
|
|
8018
|
+
if (!Number.isFinite(accountId) || accountId <= 0) {
|
|
8019
|
+
throw new Error('ticket reclaim needs --account-id (or --ticket ID for a single ticket).');
|
|
8020
|
+
}
|
|
8021
|
+
const staleHours = parsePositiveInt(flags['stale-hours'] || flags.staleHours, 24);
|
|
8022
|
+
const apply = flagEnabled(flags.apply);
|
|
8023
|
+
|
|
8024
|
+
const queue = await buildAxios(context.baseUrl, context.session.token, 30000)
|
|
8025
|
+
.post('/cli/tickets', {
|
|
8026
|
+
accountId, dataMode: context.dataMode,
|
|
8027
|
+
statuses: ['accepted', 'in_progress'], limit: parsePositiveInt(flags.limit, 200)
|
|
8028
|
+
}).then((r) => r.data);
|
|
8029
|
+
|
|
8030
|
+
const cutoff = Date.now() - staleHours * 3600000;
|
|
8031
|
+
// A local pre-filter only, to keep the output short and avoid asking about obviously-live rows. The
|
|
8032
|
+
// platform owns the actual decision and will refuse anything that does not qualify.
|
|
8033
|
+
const candidates = ((queue && queue.tickets) || []).filter((t) => {
|
|
8034
|
+
const holder = t.assignedTo || t.routedAgentId;
|
|
8035
|
+
if (!holder || String(holder).includes('@')) return false;
|
|
8036
|
+
if (t.awaitingResponseSince) return false;
|
|
8037
|
+
const touched = Date.parse(t.updatedAt || t.claimedAt || t.assignedAt || '');
|
|
8038
|
+
return Number.isFinite(touched) && touched < cutoff;
|
|
8039
|
+
});
|
|
8040
|
+
|
|
8041
|
+
if (!candidates.length) {
|
|
8042
|
+
console.log('No abandoned tickets under account ' + accountId + ' (agent-held, untouched for ' +
|
|
8043
|
+
staleHours + 'h). ' + (((queue && queue.tickets) || []).length) + ' accepted/in_progress ticket(s) checked.');
|
|
8044
|
+
return;
|
|
8045
|
+
}
|
|
8046
|
+
|
|
8047
|
+
console.log((apply ? 'Reclaiming ' : 'Would reclaim ') + candidates.length + ' abandoned ticket(s) — ' +
|
|
8048
|
+
'agent-held, no live session, untouched for ' + staleHours + 'h:');
|
|
8049
|
+
console.log('');
|
|
8050
|
+
let done = 0;
|
|
8051
|
+
for (const t of candidates) {
|
|
8052
|
+
const id = String(t.ticketId || t.id);
|
|
8053
|
+
const line = ' ' + id.padStart(6) + ' ' + String(t.status || '').padEnd(13) +
|
|
8054
|
+
String(t.assignedTo || t.routedAgentId || '').slice(0, 16).padEnd(18) +
|
|
8055
|
+
String(t.subject || '').slice(0, 52);
|
|
8056
|
+
if (!apply) {
|
|
8057
|
+
console.log(line);
|
|
8058
|
+
continue;
|
|
8059
|
+
}
|
|
8060
|
+
try {
|
|
8061
|
+
await buildAxios(context.baseUrl, context.session.token, 30000)
|
|
8062
|
+
.post('/cli/ticket', {
|
|
8063
|
+
action: 'reclaim', ticketId: id, accountId: Number(t.accountId) || undefined,
|
|
8064
|
+
dataMode: context.dataMode, staleHours, reason: flags.reason || undefined
|
|
8065
|
+
});
|
|
8066
|
+
done += 1;
|
|
8067
|
+
console.log(line + ' -> open');
|
|
8068
|
+
} catch (err) {
|
|
8069
|
+
const body = err && err.response && err.response.data;
|
|
8070
|
+
// A refusal is the platform re-checking against the document and disagreeing with this listing.
|
|
8071
|
+
// That is the guard working, so it is reported per ticket rather than failing the run.
|
|
8072
|
+
console.log(line + ' -> refused: ' + ((body && body.message) || describeError(err)).slice(0, 90));
|
|
8073
|
+
}
|
|
8074
|
+
}
|
|
8075
|
+
console.log('');
|
|
8076
|
+
console.log(apply
|
|
8077
|
+
? (done + ' reclaimed and back in the queue; an idle supervisor will sweep them.')
|
|
8078
|
+
: 'Dry run. Re-run with --apply to reclaim these.');
|
|
8079
|
+
}
|
|
8080
|
+
|
|
8081
|
+
/**
|
|
8082
|
+
* Raise a ticket.
|
|
8083
|
+
*
|
|
8084
|
+
* <p>The verb a worker needs when it finds a SECOND problem. Without it the only options were to widen the
|
|
8085
|
+
* ticket it was given until the record no longer described one piece of work, or to drop the finding.</p>
|
|
8086
|
+
*/
|
|
8087
|
+
async function ticketCreateCommand(flags) {
|
|
8088
|
+
const local = listLocalAgents()[0] || null;
|
|
8089
|
+
const context = agentSessionFor(flags, local);
|
|
8090
|
+
const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
|
|
8091
|
+
if (!Number.isFinite(accountId) || accountId <= 0) {
|
|
8092
|
+
throw new Error('ticket create needs --account-id (or run it from an account repo).');
|
|
8093
|
+
}
|
|
8094
|
+
if (!flags.subject) {
|
|
8095
|
+
throw new Error('ticket create needs --subject "..." and --type defect|question|task|incident|enhancement.');
|
|
8096
|
+
}
|
|
8097
|
+
|
|
8098
|
+
let response;
|
|
8099
|
+
try {
|
|
8100
|
+
response = await buildAxios(context.baseUrl, context.session.token, 30000)
|
|
8101
|
+
.post('/cli/ticketCreate', {
|
|
8102
|
+
accountId,
|
|
8103
|
+
dataMode: context.dataMode,
|
|
8104
|
+
subject: flags.subject,
|
|
8105
|
+
description: flags.description || flags.body,
|
|
8106
|
+
type: flags.type || 'defect',
|
|
8107
|
+
priority: flags.priority,
|
|
8108
|
+
source: flags.source,
|
|
8109
|
+
workstream: flags.workstream,
|
|
8110
|
+
affectedComponent: flags['affected-component'] || flags.affectedComponent,
|
|
8111
|
+
implementationAccountId: flags['implementation-account-id'] || flags.implementationAccountId,
|
|
8112
|
+
// A deterministic key makes this get-or-create, which is what stops a swept fleet raising the
|
|
8113
|
+
// same follow-up once per worker.
|
|
8114
|
+
referenceId: flags['reference-id'] || flags.referenceId,
|
|
8115
|
+
tags: flags.tags ? String(flags.tags).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
|
|
8116
|
+
agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined
|
|
8117
|
+
}).then((r) => r.data);
|
|
8118
|
+
} catch (err) {
|
|
8119
|
+
const body = err && err.response && err.response.data;
|
|
8120
|
+
if (body && body.message) {
|
|
8121
|
+
console.error('Refused: ' + body.message);
|
|
8122
|
+
process.exitCode = 1;
|
|
8123
|
+
return;
|
|
8124
|
+
}
|
|
8125
|
+
throw err;
|
|
8126
|
+
}
|
|
8127
|
+
|
|
8128
|
+
if (flagEnabled(flags.json)) {
|
|
8129
|
+
console.log(JSON.stringify(response, null, 2));
|
|
8130
|
+
return;
|
|
8131
|
+
}
|
|
8132
|
+
const t = (response && response.ticket) || {};
|
|
8133
|
+
console.log('created ticket ' + response.ticketId + ' on account ' + response.accountId +
|
|
8134
|
+
' [' + (t.status || 'open') + ']');
|
|
8135
|
+
console.log(' ' + (t.subject || flags.subject));
|
|
8136
|
+
}
|
|
8137
|
+
|
|
8138
|
+
/** The account a ticket belongs to, so routing does not make the caller look it up by hand. */
|
|
8139
|
+
async function resolveTicketAccountId(context, ticketId) {
|
|
8140
|
+
const read = await buildAxios(context.baseUrl, context.session.token, 20000)
|
|
8141
|
+
.post('/cli/ticket', { action: 'read', ticketId, dataMode: context.dataMode })
|
|
8142
|
+
.then((r) => r.data)
|
|
8143
|
+
.catch(() => null);
|
|
8144
|
+
const accountId = Number(read && read.ticket && read.ticket.accountId);
|
|
8145
|
+
if (!Number.isFinite(accountId) || accountId <= 0) {
|
|
8146
|
+
throw new Error('Could not resolve the owning account for ticket ' + ticketId + '. Pass --account-id.');
|
|
8147
|
+
}
|
|
8148
|
+
return accountId;
|
|
8149
|
+
}
|
|
8150
|
+
|
|
8151
|
+
function printTicketHelp() {
|
|
8152
|
+
console.log('Usage: remits-cli ticket <action> --ticket ID');
|
|
8153
|
+
console.log('');
|
|
8154
|
+
console.log(' queue [--account-id ID] [--status open,accepted] [--unrouted] [--search "..."]');
|
|
8155
|
+
console.log(' What else is open. Read it BEFORE deciding your ticket is unique — the same fix often');
|
|
8156
|
+
console.log(' appears a dozen times, and the "repo" column is the account the edit lease excludes');
|
|
8157
|
+
console.log(' on, so it also tells you which of these can be worked at the same time.');
|
|
8158
|
+
console.log(' Also: --priority --type --workstream --assigned-to --unassigned --board-stage');
|
|
8159
|
+
console.log(' --planned-in --blocked-by --sort-by --sort-direction --limit --json');
|
|
8160
|
+
console.log(' --awaiting-response lists only tickets parked on a human. A status filter cannot');
|
|
8161
|
+
console.log(' ask that: `ask` parks a ticket in pending_review, which also means "done, review me".');
|
|
8162
|
+
console.log(' create --subject "..." --type defect|question|task|incident|enhancement');
|
|
8163
|
+
console.log(' Raise a ticket. Use it when you find a SECOND problem while working one — file it');
|
|
8164
|
+
console.log(' rather than widening the ticket you were given. [--priority --description --tags');
|
|
8165
|
+
console.log(' --workstream --affected-component --implementation-account-id --reference-id]');
|
|
8166
|
+
console.log(' --reference-id makes it get-or-create, so a re-run does not duplicate it.');
|
|
8167
|
+
console.log('');
|
|
8168
|
+
console.log(' read The full ticket record, as JSON.');
|
|
8169
|
+
console.log(' where WHERE this work is happening: repository account,');
|
|
8170
|
+
console.log(' checkout, branch, staging workspace lane, the live edit lease and who holds it, the');
|
|
8171
|
+
console.log(' live claim, and your phase. Read it BEFORE you edit anything — with git worktrees and');
|
|
8172
|
+
console.log(' workspace lanes, two agents on one account can be in completely different trees.');
|
|
8173
|
+
console.log(' accept Claim it as yours before you start work.');
|
|
8174
|
+
console.log(' status --status in_progress|pending_review');
|
|
8175
|
+
console.log(' progress --summary "..." [--category investigation|fix|verification|...] [--next-step "..."]');
|
|
8176
|
+
console.log(' complete --resolution "what you found and did"');
|
|
8177
|
+
console.log(' ask --question "..." [--context "..."] [--to WHO]');
|
|
8178
|
+
console.log(' Put a decision to a human and park. Use it when the CHOICE is not yours.');
|
|
8179
|
+
console.log(' answer --message "..." [--route false] (alias: reply)');
|
|
8180
|
+
console.log(' Answer the open question a previous run asked. By default this re-routes the ticket,');
|
|
8181
|
+
console.log(' so a fresh worker resumes with your answer already in its brief. This is NOT how you');
|
|
8182
|
+
console.log(' talk to the requester — that is `message`. (`reply` means the conversational one in');
|
|
8183
|
+
console.log(' some account tools, so this surface teaches `answer` and keeps `reply` as an alias.)');
|
|
8184
|
+
console.log(' message --message "..." [--to a@x,b@y] [--subject "..."] [--channel email]');
|
|
8185
|
+
console.log(' A PUBLIC conversation entry the requester reads. Writing it does not send it:');
|
|
8186
|
+
console.log(' the record is vendor-agnostic and the account\'s own rule chooses the transport.');
|
|
8187
|
+
console.log(' deliver --channel email --to a@x [--template T] [--subject "..."] [--idempotency-key K]');
|
|
8188
|
+
console.log(' Ask for that entry to actually be SENT. It records an outbox request; an account-owned');
|
|
8189
|
+
console.log(' rule or scheduled Action delivers it and marks the result. Use it when a ticket is');
|
|
8190
|
+
console.log(' waiting on somebody who is not watching the queue.');
|
|
8191
|
+
console.log(' deliveries [--channel email] What is queued to go out, and what already went.');
|
|
8192
|
+
console.log(' delivered --delivery-id ID | delivery-failed --delivery-id ID --reason "..."');
|
|
8193
|
+
console.log(' release [--notes "..."] Hand it back to the queue.');
|
|
8194
|
+
console.log(' reclaim [--ticket ID | --account-id ID] [--stale-hours 24] [--apply]');
|
|
8195
|
+
console.log(' OPERATOR: take a ticket back from an agent that accepted it and vanished. Such a');
|
|
8196
|
+
console.log(' ticket is invisible to the sweep (it is assigned, so nothing offers it to anyone).');
|
|
8197
|
+
console.log(' Never touches a ticket owned by a person, holding a live claim, or awaiting a reply.');
|
|
8198
|
+
console.log(' Without --ticket it scans the account tree and is a DRY RUN until --apply.');
|
|
8199
|
+
console.log(' reopen | assign --assignee EMAIL');
|
|
8200
|
+
console.log(' route --agent AGENT_ID Hand it to a registered agent session.');
|
|
8201
|
+
console.log(' unroute Clear its routing, e.g. when that session is gone.');
|
|
8202
|
+
console.log(' tag --tags a,b Mark it. The cheapest way to make a cluster of');
|
|
8203
|
+
console.log(' near-identical tickets visible as one cluster to whoever looks next.');
|
|
8204
|
+
console.log(' artifact --type TYPE --label "..." [--url U | --content "..."]');
|
|
8205
|
+
console.log(' Attach evidence — a log, a query result, a repro. Better than pasting it into a');
|
|
8206
|
+
console.log(' worklog summary, where it is neither typed nor retrievable.');
|
|
8207
|
+
console.log(' note --message "..." An INTERNAL note. The next worker and a reviewer');
|
|
8208
|
+
console.log(' see it; the requester does not. Use `message` for anything they should read.');
|
|
8209
|
+
console.log(' participant --email E [--role watcher|requester|agent]');
|
|
8210
|
+
console.log(' field --key K --value V Write the ORGANIZATION\'s own field on the ticket,');
|
|
8211
|
+
console.log(' outside the platform\'s bounded planning slots. Whatever your account models.');
|
|
8212
|
+
console.log(' planning [--workstream V] [--planned-in V] [--board-stage V] [--rank N] [--size V] [--blocked-by V]');
|
|
8213
|
+
console.log(' Update organization-owned planning fields without changing platform lifecycle status.');
|
|
8214
|
+
console.log(' Pass a flag with no value to CLEAR that slot, e.g. --blocked-by');
|
|
8215
|
+
console.log(' force-unlease [--reason "..."] Break a stale repository lease somebody else holds.');
|
|
8216
|
+
console.log(' For an operator only: unlease returns YOUR lease, this one takes away theirs, and it');
|
|
8217
|
+
console.log(' records a handoff worklog entry on the ticket so the intervention is visible.');
|
|
8218
|
+
console.log(' lease | unlease Take or give back the right to EDIT this ticket\'s');
|
|
8219
|
+
console.log(' repository. One agent at a time per account; everyone else investigates read-only.');
|
|
8220
|
+
console.log(' Your run already holds it if the brief said so. Take it when you started read-only');
|
|
8221
|
+
console.log(' and have reached an actual edit. A refusal is not an error — it names who has it,');
|
|
8222
|
+
console.log(' and investigating is still useful work. complete/ask/release free it for you.');
|
|
8223
|
+
console.log('');
|
|
8224
|
+
console.log(' Works on every Remits platform: it reads and writes the platform ticket record, not');
|
|
8225
|
+
console.log(' any one account\'s ticket tooling. Inside a worker, --ticket defaults to the ticket');
|
|
8226
|
+
console.log(' that worker was launched for.');
|
|
8227
|
+
}
|
|
8228
|
+
|
|
8229
|
+
/**
|
|
8230
|
+
* The run-context fields every call that RECORDS a location sends: checkout, branch, staging workspace.
|
|
8231
|
+
*
|
|
8232
|
+
* One helper so the lease, the claim and the registration cannot describe this process three ways —
|
|
8233
|
+
* the same reason the platform builds the tuple in exactly one class (AgentRunContext).
|
|
8234
|
+
*/
|
|
8235
|
+
function runContextPayload(flags = {}) {
|
|
8236
|
+
const cwd = process.cwd();
|
|
8237
|
+
return {
|
|
8238
|
+
workingDirectory: cwd,
|
|
8239
|
+
branch: currentBranch(cwd) || undefined,
|
|
8240
|
+
workspace: resolveWorkspace(cwd, flags) || undefined
|
|
8241
|
+
};
|
|
8242
|
+
}
|
|
8243
|
+
|
|
8244
|
+
/**
|
|
8245
|
+
* WHERE a worker is working — checkout, branch, staging workspace lane — on one line.
|
|
8246
|
+
*
|
|
8247
|
+
* The platform builds the same string server-side (AgentRunContext.describeLocation) for the surfaces it
|
|
8248
|
+
* renders; this is the terminal's copy for rows it prints itself. The workspace is always named, `shared`
|
|
8249
|
+
* when it is the default lane, because "no workspace shown" and "the shared lane" were the same output
|
|
8250
|
+
* and they are opposite facts: the shared lane is exactly the one where concurrent agents overwrite
|
|
8251
|
+
* each other's staged components.
|
|
8252
|
+
*/
|
|
8253
|
+
function describeRunLocation(record) {
|
|
8254
|
+
if (!record) return '';
|
|
8255
|
+
const dir = record.workingDirectory || record.cwd || record.claimedWorkingDirectory;
|
|
8256
|
+
const branch = record.branch || record.claimedBranch;
|
|
8257
|
+
const workspace = record.workspace || record.claimedWorkspace;
|
|
8258
|
+
if (!dir && !branch && !workspace) return '';
|
|
8259
|
+
const parts = [];
|
|
8260
|
+
if (dir) parts.push(dir);
|
|
8261
|
+
if (branch) parts.push('branch ' + branch);
|
|
8262
|
+
parts.push('workspace ' + (workspace || 'shared'));
|
|
8263
|
+
return parts.join(' ');
|
|
8264
|
+
}
|
|
8265
|
+
|
|
8266
|
+
/**
|
|
8267
|
+
* The live work map: which repositories are being edited, by whom, and who else is around.
|
|
8268
|
+
*
|
|
8269
|
+
* The question every agent has to answer before it starts — "can I safely edit account 33 right now" —
|
|
8270
|
+
* and the one an operator asks when a queue is not moving. Agents, leases and tickets were each readable
|
|
8271
|
+
* on their own and never together, so every caller invented its own join and they disagreed.
|
|
8272
|
+
*/
|
|
8273
|
+
async function agentWorkMapCommand(flags) {
|
|
8274
|
+
const agentId = resolveAgentId(flags, { optional: true });
|
|
8275
|
+
const local = agentId ? (listLocalAgents().find((entry) => entry.agentId === agentId) || {}) : {};
|
|
8276
|
+
const context = agentSessionFor(flags, local);
|
|
8277
|
+
const accountId = flags['account-id'] ? Number(flags['account-id']) : Number(context.accountId);
|
|
8278
|
+
|
|
8279
|
+
const response = await buildAxios(context.baseUrl, context.session.token, 30000)
|
|
8280
|
+
.post('/cli/workMap', {
|
|
8281
|
+
agentId,
|
|
8282
|
+
accountId,
|
|
8283
|
+
accountIds: flags['account-ids'] ? String(flags['account-ids']).split(',').map((s) => Number(s.trim())) : undefined,
|
|
8284
|
+
dataMode: context.dataMode,
|
|
8285
|
+
all: flagEnabled(flags.all)
|
|
8286
|
+
}).then((r) => r.data);
|
|
8287
|
+
|
|
8288
|
+
if (flagEnabled(flags.json)) {
|
|
8289
|
+
console.log(JSON.stringify(response, null, 2));
|
|
8290
|
+
return;
|
|
8291
|
+
}
|
|
8292
|
+
|
|
8293
|
+
const repositories = (response && response.repositories) || [];
|
|
8294
|
+
if (!repositories.length) {
|
|
8295
|
+
console.log('No repositories in scope for ' + context.baseUrl + ' (' + context.dataMode + ' lane).');
|
|
8296
|
+
return;
|
|
8297
|
+
}
|
|
8298
|
+
console.log('Work map — ' + context.baseUrl + ' · ' + response.dataMode + ' lane');
|
|
8299
|
+
console.log('');
|
|
8300
|
+
for (const repo of repositories) {
|
|
8301
|
+
const lease = repo.editLease;
|
|
8302
|
+
console.log(' ' + repo.accountName + ' (' + repo.accountId + ')' +
|
|
8303
|
+
(repo.accountType ? ' ' + repo.accountType : ''));
|
|
8304
|
+
if (lease) {
|
|
8305
|
+
const who = lease.agentLabel || lease.agentId;
|
|
8306
|
+
console.log(' EDIT LEASE ' + who +
|
|
8307
|
+
(lease.ticketId ? ' ticket ' + lease.ticketId : '') +
|
|
8308
|
+
(lease.heldForSeconds != null ? ' held ' + lease.heldForSeconds + 's' : '') +
|
|
8309
|
+
(lease.expiresInSeconds != null ? ' expires in ' + lease.expiresInSeconds + 's' : '') +
|
|
8310
|
+
(repo.editable ? ' (yours)' : ''));
|
|
8311
|
+
// The holder's checkout, branch and staging lane. Without them a refused agent cannot tell a real
|
|
8312
|
+
// file conflict from a holder working in an entirely different worktree.
|
|
8313
|
+
const holderAt = describeRunLocation(lease);
|
|
8314
|
+
if (holderAt) console.log(' ' + holderAt);
|
|
8315
|
+
} else {
|
|
8316
|
+
console.log(' EDIT LEASE free');
|
|
8317
|
+
}
|
|
8318
|
+
for (const agent of (repo.agents || [])) {
|
|
8319
|
+
// No phase means the session is present but holding nothing. Saying "investigating" there would
|
|
8320
|
+
// report work that is not happening.
|
|
8321
|
+
const phase = agent.phase ? agent.phase.padEnd(13) : 'available ';
|
|
8322
|
+
console.log(' ' + phase + ' ' +
|
|
8323
|
+
(agent.label || agent.agentId) + ' ' + (agent.state || '?') +
|
|
8324
|
+
(agent.activeTickets && agent.activeTickets.length ? ' tickets ' + agent.activeTickets.join(',') : '') +
|
|
8325
|
+
(agent.secondsSinceSeen != null ? ' seen ' + agent.secondsSinceSeen + 's ago' : ''));
|
|
8326
|
+
const at = describeRunLocation(agent);
|
|
8327
|
+
if (at) console.log(' ' + at);
|
|
8328
|
+
if (agent.activity) console.log(' ' + String(agent.activity).slice(0, 100));
|
|
8329
|
+
}
|
|
8330
|
+
console.log('');
|
|
8331
|
+
}
|
|
8332
|
+
console.log('Only the lease holder may edit or stage a repository. Everyone else investigates read-only.');
|
|
8333
|
+
}
|
|
8334
|
+
|
|
5986
8335
|
async function agentListCommand(flags) {
|
|
5987
8336
|
const cwd = process.cwd();
|
|
5988
8337
|
const mine = listLocalAgents()[0] || null;
|
|
@@ -6018,7 +8367,8 @@ async function agentListCommand(flags) {
|
|
|
6018
8367
|
console.log('No agents are registered for ' + scopeLabel + ' in the ' + context.dataMode + ' lane.');
|
|
6019
8368
|
return;
|
|
6020
8369
|
}
|
|
6021
|
-
console.log(agents.length + ' agent(s) available across ' + scopeLabel +
|
|
8370
|
+
console.log(agents.length + ' agent(s) available across ' + scopeLabel +
|
|
8371
|
+
' in the ' + context.dataMode + ' lane:');
|
|
6022
8372
|
for (const agent of agents) {
|
|
6023
8373
|
console.log('');
|
|
6024
8374
|
console.log(' ' + agent.agentId);
|
|
@@ -6026,9 +8376,117 @@ async function agentListCommand(flags) {
|
|
|
6026
8376
|
console.log(' state ' + (agent.state || 'idle') +
|
|
6027
8377
|
(agent.ticketId ? ' ticket ' + agent.ticketId : '') +
|
|
6028
8378
|
' last seen ' + (agent.secondsSinceSeen == null ? '?' : agent.secondsSinceSeen + 's ago'));
|
|
8379
|
+
// The agent's OWN lane, not the query's. --all skips the lane filter for diagnostics, and without
|
|
8380
|
+
// this a test-lane session and a production one render identically in that listing. An agent at
|
|
8381
|
+
// capacity is likewise not routable, which is the other reason an idle-looking agent gets nothing.
|
|
8382
|
+
console.log(' lane ' + (agent.dataMode || 'prod') +
|
|
8383
|
+
' capacity ' + (agent.activeTicketCount || 0) + '/' + (agent.maxConcurrent || 1) +
|
|
8384
|
+
(agent.hasCapacity === false ? ' (at capacity — not routable)' : ''));
|
|
6029
8385
|
if (agent.activity) console.log(' doing ' + agent.activity);
|
|
6030
|
-
|
|
8386
|
+
// Through describeRunLocation, which reads BOTH the canonical `workingDirectory` and the historical
|
|
8387
|
+
// `cwd`: a record written by an older platform, or still in Redis from before this field was
|
|
8388
|
+
// canonicalized, must not render as an unknown location.
|
|
8389
|
+
console.log(' where ' + (agent.host || '?') + ':' +
|
|
8390
|
+
(describeRunLocation(agent) || agent.workingDirectory || agent.cwd || '?'));
|
|
8391
|
+
}
|
|
8392
|
+
}
|
|
8393
|
+
|
|
8394
|
+
/**
|
|
8395
|
+
* Inspect and correct the local account-repository index — which checkout a ticket for an account is
|
|
8396
|
+
* worked in.
|
|
8397
|
+
*
|
|
8398
|
+
* <p>This index is the one fact the platform cannot supply: it knows the ticket and the account, and
|
|
8399
|
+
* only this machine knows where that account is checked out. It is built by scanning for
|
|
8400
|
+
* `account-info.json`, which is also present in every unpacked zip export, so it can and does acquire
|
|
8401
|
+
* entries that are not checkouts at all. There was no way to see it, and no way to correct it.</p>
|
|
8402
|
+
*/
|
|
8403
|
+
async function agentReposCommand(flags) {
|
|
8404
|
+
const accountId = Number(flags['account-id'] || flags.accountId);
|
|
8405
|
+
const directory = flags.directory || flags.dir;
|
|
8406
|
+
|
|
8407
|
+
if (Number.isFinite(accountId) && accountId > 0 && directory) {
|
|
8408
|
+
const resolved = path.resolve(String(directory));
|
|
8409
|
+
if (!fs.existsSync(resolved)) {
|
|
8410
|
+
throw new Error('No such directory: ' + resolved);
|
|
8411
|
+
}
|
|
8412
|
+
if (!isAccountCheckout(resolved)) {
|
|
8413
|
+
throw new Error(resolved + ' is not a git checkout. A ticket worker edits, stages and reports ' +
|
|
8414
|
+
'against this directory, so an unpacked export would silently lose the work.');
|
|
8415
|
+
}
|
|
8416
|
+
const info = loadAccountInfo(resolved);
|
|
8417
|
+
const declared = info ? Number(accountIdFromAccountInfo(info)) : null;
|
|
8418
|
+
if (declared && declared !== accountId) {
|
|
8419
|
+
throw new Error(resolved + ' declares account ' + declared + ', not ' + accountId +
|
|
8420
|
+
'. Point account ' + declared + ' at it instead, or pass the directory that belongs to ' + accountId + '.');
|
|
8421
|
+
}
|
|
8422
|
+
const index = readAccountRepoIndex();
|
|
8423
|
+
const previous = index[String(accountId)];
|
|
8424
|
+
index[String(accountId)] = {
|
|
8425
|
+
...(previous || {}),
|
|
8426
|
+
...(info ? buildAccountRepoEntryFromInfo(info, resolved, 'manual') : {}),
|
|
8427
|
+
accountId,
|
|
8428
|
+
directory: resolved,
|
|
8429
|
+
source: 'manual',
|
|
8430
|
+
// The operator has just answered the question, so the rival is no longer ambiguity.
|
|
8431
|
+
ambiguousWith: [],
|
|
8432
|
+
updatedAt: new Date().toISOString()
|
|
8433
|
+
};
|
|
8434
|
+
writeAccountRepoIndex(index);
|
|
8435
|
+
console.log('account ' + accountId + ' -> ' + resolved);
|
|
8436
|
+
if (previous && previous.directory && previous.directory !== resolved) {
|
|
8437
|
+
console.log(' (was ' + previous.directory + ')');
|
|
8438
|
+
}
|
|
8439
|
+
return;
|
|
8440
|
+
}
|
|
8441
|
+
|
|
8442
|
+
if (Number.isFinite(accountId) && accountId > 0 && flagEnabled(flags.forget)) {
|
|
8443
|
+
const index = readAccountRepoIndex();
|
|
8444
|
+
const previous = index[String(accountId)];
|
|
8445
|
+
delete index[String(accountId)];
|
|
8446
|
+
writeAccountRepoIndex(index);
|
|
8447
|
+
console.log(previous
|
|
8448
|
+
? 'account ' + accountId + ' removed (was ' + previous.directory + '). Its tickets will now park ' +
|
|
8449
|
+
'with an explanation rather than being worked in that directory.'
|
|
8450
|
+
: 'account ' + accountId + ' was not in the index.');
|
|
8451
|
+
return;
|
|
8452
|
+
}
|
|
8453
|
+
|
|
8454
|
+
if (flagEnabled(flags.rescan)) {
|
|
8455
|
+
try { discoverAccountRepos(); } catch (err) { console.error('scan failed: ' + describeError(err)); }
|
|
8456
|
+
}
|
|
8457
|
+
|
|
8458
|
+
const entries = listAccountRepoEntries(readAccountRepoIndex())
|
|
8459
|
+
.sort((a, b) => Number(a.accountId) - Number(b.accountId));
|
|
8460
|
+
if (flagEnabled(flags.json)) {
|
|
8461
|
+
console.log(JSON.stringify({ repos: entries, problems: accountRepoIndexProblems() }, null, 2));
|
|
8462
|
+
return;
|
|
8463
|
+
}
|
|
8464
|
+
|
|
8465
|
+
console.log('Account repositories on this machine (' + entries.length + '):');
|
|
8466
|
+
console.log('');
|
|
8467
|
+
for (const entry of entries) {
|
|
8468
|
+
const ok = isAccountCheckout(entry.directory);
|
|
8469
|
+
const missing = !fs.existsSync(entry.directory);
|
|
8470
|
+
const state = missing ? 'MISSING ' : (ok ? 'ok ' : 'NOT-GIT ');
|
|
8471
|
+
console.log(' ' + state + String(entry.accountId).padStart(5) + ' ' +
|
|
8472
|
+
String(entry.name || '').slice(0, 26).padEnd(26) + ' ' + entry.directory);
|
|
8473
|
+
for (const rival of entry.ambiguousWith || []) {
|
|
8474
|
+
console.log(' also claims this account: ' + rival);
|
|
8475
|
+
}
|
|
8476
|
+
}
|
|
8477
|
+
|
|
8478
|
+
const problems = accountRepoIndexProblems();
|
|
8479
|
+
if (problems.length) {
|
|
8480
|
+
console.log('');
|
|
8481
|
+
console.log(problems.length + ' issue(s):');
|
|
8482
|
+
for (const problem of problems) {
|
|
8483
|
+
console.log(' [' + problem.severity + '] ' + problem.message);
|
|
8484
|
+
}
|
|
6031
8485
|
}
|
|
8486
|
+
console.log('');
|
|
8487
|
+
console.log(' Set one: remits-cli agent repos --account-id <id> --directory <path>');
|
|
8488
|
+
console.log(' Remove one: remits-cli agent repos --account-id <id> --forget');
|
|
8489
|
+
console.log(' Rescan: remits-cli agent repos --rescan');
|
|
6032
8490
|
}
|
|
6033
8491
|
|
|
6034
8492
|
async function agentReleaseCommand(flags) {
|
|
@@ -6038,19 +8496,40 @@ async function agentReleaseCommand(flags) {
|
|
|
6038
8496
|
return;
|
|
6039
8497
|
}
|
|
6040
8498
|
const local = listLocalAgents().find((entry) => entry.agentId === agentId) || {};
|
|
8499
|
+
|
|
8500
|
+
// Stop the supervisor BEFORE deregistering. The other order is a race the supervisor wins: it sees
|
|
8501
|
+
// registered:false, concludes its record expired, and re-registers the session we just retired.
|
|
8502
|
+
// Its own SIGTERM handler kills any workers and drops their claims, so this is a clean stop.
|
|
8503
|
+
let workersStopped = 0;
|
|
8504
|
+
const daemonPid = local.daemonPid && processIsAlive(local.daemonPid)
|
|
8505
|
+
? local.daemonPid
|
|
8506
|
+
: findAgentDaemonPid(agentId);
|
|
8507
|
+
if (daemonPid) {
|
|
8508
|
+
workersStopped = listWorkers(agentId).length;
|
|
8509
|
+
try { process.kill(daemonPid, 'SIGTERM'); } catch (_) { /* already gone */ }
|
|
8510
|
+
}
|
|
8511
|
+
|
|
6041
8512
|
const context = agentSessionFor(flags, local);
|
|
6042
8513
|
await buildAxios(context.baseUrl, context.session.token, 10000)
|
|
6043
8514
|
.post('/cli/agentDeregister', { agentId }).then((r) => r.data).catch(() => null);
|
|
6044
8515
|
forgetLocalAgent(agentId);
|
|
6045
8516
|
console.log('Deregistered agent ' + agentId + '. This session will no longer receive tickets.');
|
|
8517
|
+
if (workersStopped) {
|
|
8518
|
+
console.log('Stopped ' + workersStopped + ' running ticket worker(s); their claims were released so');
|
|
8519
|
+
console.log('another agent can pick the work up.');
|
|
8520
|
+
}
|
|
6046
8521
|
}
|
|
6047
8522
|
|
|
6048
8523
|
async function agentCommand(flags, subcommand) {
|
|
6049
8524
|
switch (subcommand) {
|
|
6050
8525
|
case 'register': return agentRegisterCommand(flags);
|
|
8526
|
+
case 'serve': case 'start': return agentServeCommand(flags);
|
|
8527
|
+
case 'workers': return agentWorkersCommand(flags);
|
|
6051
8528
|
case 'status': return agentStatusCommand(flags);
|
|
6052
8529
|
case 'work': return agentWorkCommand(flags);
|
|
6053
8530
|
case 'list': case 'ls': return agentListCommand(flags);
|
|
8531
|
+
case 'map': case 'work-map': case 'workmap': return agentWorkMapCommand(flags);
|
|
8532
|
+
case 'repos': case 'repo': return agentReposCommand(flags);
|
|
6054
8533
|
case 'release': case 'deregister': case 'stop': return agentReleaseCommand(flags);
|
|
6055
8534
|
case 'heartbeat-daemon': return agentHeartbeatDaemonCommand(flags);
|
|
6056
8535
|
default:
|
|
@@ -6062,8 +8541,23 @@ async function agentCommand(flags, subcommand) {
|
|
|
6062
8541
|
function printAgentHelp() {
|
|
6063
8542
|
console.log('Usage: remits-cli agent <subcommand>');
|
|
6064
8543
|
console.log('');
|
|
8544
|
+
console.log(' serve [--worker-agent claude|codex|gemini] [--max-concurrent N] [--mode edit|investigate]');
|
|
8545
|
+
console.log(' Run this in a PLAIN TERMINAL TAB — that tab becomes the agent host. It returns');
|
|
8546
|
+
console.log(' immediately; a background supervisor launches ONE fresh headless agent process per');
|
|
8547
|
+
console.log(' routed ticket, in that account\'s repo, and that process exits when the ticket is');
|
|
8548
|
+
console.log(' done. Nothing accumulates context across tickets, so no session needs clearing.');
|
|
8549
|
+
console.log(' Closing the tab stops the agent.');
|
|
8550
|
+
console.log('');
|
|
8551
|
+
console.log(' --worker-agent picks WHICH AI does the work. Without it: whatever agent this');
|
|
8552
|
+
console.log(' session is running (when you start it from inside one), else your');
|
|
8553
|
+
console.log(' `remits-cli config set --agent NAME` default. It prints which it chose and why.');
|
|
8554
|
+
console.log(' --mode investigate runs workers read-only (no file edits).');
|
|
8555
|
+
console.log('');
|
|
8556
|
+
console.log(' workers [--json] What this session is running right now.');
|
|
8557
|
+
console.log('');
|
|
6065
8558
|
console.log(' register [--label NAME] [--data-mode test|prod] [--base-url URL]');
|
|
6066
|
-
console.log('
|
|
8559
|
+
console.log(' Presence only — make THIS session routable without starting anything. Use it when a');
|
|
8560
|
+
console.log(' human, or the agent in this very tab, will work the tickets by hand. Runs from ANY');
|
|
6067
8561
|
console.log(' directory — it registers for every account repo indexed on this machine that your');
|
|
6068
8562
|
console.log(' user can reach, so you do not have to be inside a particular repo. Defaults to the');
|
|
6069
8563
|
console.log(' production platform and the prod lane, because a support agent works real tickets.');
|
|
@@ -6078,6 +8572,15 @@ function printAgentHelp() {
|
|
|
6078
8572
|
console.log(' Report what this session is doing, so operators can see it live.');
|
|
6079
8573
|
console.log('');
|
|
6080
8574
|
console.log(' list [--account-id ID] [--json] Who else is available for an account.');
|
|
8575
|
+
console.log(' map [--account-ids 1,4] [--json] Who is EDITING which repository right now.');
|
|
8576
|
+
console.log(' One agent at a time may edit an account\'s repo (the edit lease); everyone else');
|
|
8577
|
+
console.log(' investigates read-only. Check this before starting work that changes files.');
|
|
8578
|
+
console.log(' repos [--rescan] [--json] WHERE each account is checked out on this machine —');
|
|
8579
|
+
console.log(' the one fact the platform cannot supply, and what decides the directory a ticket');
|
|
8580
|
+
console.log(' worker is launched into. Flags anything that is not a git checkout (an unpacked');
|
|
8581
|
+
console.log(' export looks identical to the scanner) and any account claimed by two checkouts.');
|
|
8582
|
+
console.log(' Correct it with --account-id <id> --directory <path>, or --account-id <id> --forget');
|
|
8583
|
+
console.log(' to make that account\'s tickets park instead of being worked somewhere wrong.');
|
|
6081
8584
|
console.log(' release Deregister this session immediately.');
|
|
6082
8585
|
console.log('');
|
|
6083
8586
|
console.log(' After `register`, the other subcommands need no flags: they reuse the platform, lane');
|
|
@@ -6502,24 +9005,31 @@ function printComponentsHelp(subcommand) {
|
|
|
6502
9005
|
return;
|
|
6503
9006
|
}
|
|
6504
9007
|
if (subcommand === 'stage' || subcommand === 'push') {
|
|
6505
|
-
console.log('Usage: remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
|
|
9008
|
+
console.log('Usage: remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--changed-only] [--data-mode test|prod] [--json|--verbose]');
|
|
6506
9009
|
console.log('');
|
|
6507
9010
|
console.log('Stages local component files into the Redis staging cache. It never writes the database or git.');
|
|
6508
9011
|
console.log('Terminal output separates local working-tree component deltas from the full materialized staging cache count.');
|
|
9012
|
+
console.log('');
|
|
9013
|
+
console.log(' --workspace NAME stage into an isolated lane on this branch (see `remits-cli workspace`).');
|
|
9014
|
+
console.log(' --changed-only stage only the components this working tree edited, and do NOT reconcile');
|
|
9015
|
+
console.log(' (entries for untouched components are left alone rather than deleted).');
|
|
6509
9016
|
return;
|
|
6510
9017
|
}
|
|
6511
9018
|
if (subcommand === 'status') {
|
|
6512
|
-
console.log('Usage: remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
|
|
9019
|
+
console.log('Usage: remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--component-type TYPE --component-id ID] [--json|--verbose]');
|
|
6513
9020
|
console.log('');
|
|
6514
9021
|
console.log('Shows staged entries and whether this checkout resolves/writes trunk or a variant branch.');
|
|
9022
|
+
console.log('Also lists every OTHER staging lane on this branch for this account/user, so you can see');
|
|
9023
|
+
console.log('whether another agent is staging alongside you.');
|
|
6515
9024
|
return;
|
|
6516
9025
|
}
|
|
6517
9026
|
if (subcommand === 'clear') {
|
|
6518
|
-
console.log('Usage: remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
|
|
9027
|
+
console.log('Usage: remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
|
|
6519
9028
|
console.log('');
|
|
6520
9029
|
console.log('Clears staged Redis entries without touching the database or git.');
|
|
6521
9030
|
console.log('Use --component-type with --component-id when the id could exist in multiple component families.');
|
|
6522
9031
|
console.log('Use --all for the temporary-experiment cleanup flow.');
|
|
9032
|
+
console.log('--all is scoped to YOUR workspace lane; it never clears another lane on the same branch.');
|
|
6523
9033
|
return;
|
|
6524
9034
|
}
|
|
6525
9035
|
if (subcommand === 'commit') {
|
|
@@ -6530,6 +9040,9 @@ function printComponentsHelp(subcommand) {
|
|
|
6530
9040
|
return;
|
|
6531
9041
|
}
|
|
6532
9042
|
console.log('Usage: remits-cli components <stage|status|clear|sync|commit|promotion|branches|branch>');
|
|
9043
|
+
console.log('');
|
|
9044
|
+
console.log('All staging subcommands accept --workspace NAME to isolate a lane on the same branch,');
|
|
9045
|
+
console.log('so several agents can iterate at once. See: remits-cli workspace --help');
|
|
6533
9046
|
console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
|
|
6534
9047
|
console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
|
|
6535
9048
|
console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
|
|
@@ -6576,8 +9089,16 @@ function printToolHelp() {
|
|
|
6576
9089
|
console.log('Usage: remits-cli tool --name <toolName> [--base-url URL] [--account-id ID] [--branch BRANCH] [--input "{...}"|--input-file file.json] [--data-mode test|prod] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
|
|
6577
9090
|
console.log(' remits-cli tool status --call-id <callId> [--base-url URL] [--account-id ID] [--data-mode test|prod]');
|
|
6578
9091
|
console.log('');
|
|
9092
|
+
console.log('Hierarchy scope (read/discovery tools) — you do NOT need the owning child account id:');
|
|
9093
|
+
console.log(' --scope self|children|hierarchy expand from the repo account when searching');
|
|
9094
|
+
console.log(' --target-account-id ID the exact account to act on (required by writes)');
|
|
9095
|
+
console.log(' --account-ids 1,2,3 an explicit bounded list of owner accounts');
|
|
9096
|
+
console.log(' --anchor-account-id ID disambiguate the path for a multi-parent account');
|
|
9097
|
+
console.log(' These are merged into --input; a value already in --input wins.');
|
|
9098
|
+
console.log('');
|
|
6579
9099
|
console.log('Examples:');
|
|
6580
9100
|
console.log(' remits-cli tool --name mcp_firestore_search --input-file query.json --data-mode prod');
|
|
9101
|
+
console.log(' remits-cli tool --name mcp_firestore_search --input \'{"collection":"statements","documentId":"1234"}\' --scope children');
|
|
6581
9102
|
console.log(' remits-cli tool --name mcp_run_action --input \'{"accountId":49,"actionId":200,"executionMode":"async"}\' --data-mode prod');
|
|
6582
9103
|
console.log('');
|
|
6583
9104
|
console.log('For mcp_run_action and mcp_run_agent, prefer the tool\'s own executionMode:"async" and poll with that tool\'s controlAction:"status".');
|
|
@@ -6623,7 +9144,7 @@ async function main() {
|
|
|
6623
9144
|
try { updateAccountRepoIndex(process.cwd()); } catch (_) { /* not in account repo */ }
|
|
6624
9145
|
// Auto-start the background service if not already running.
|
|
6625
9146
|
// Skip for lifecycle subcommands and help.
|
|
6626
|
-
const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami' || command === 'agent';
|
|
9147
|
+
const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami' || command === 'agent' || command === 'ticket';
|
|
6627
9148
|
const isHelpCmd = !command || command === 'help' || command === '--help' || wantsHelp;
|
|
6628
9149
|
if (!wantsJson && !isHelpCmd && !isLifecycleCmd && !isListenerRunning()) {
|
|
6629
9150
|
try {
|
|
@@ -6654,6 +9175,7 @@ async function main() {
|
|
|
6654
9175
|
console.log(' remits-cli agent work [--wait SECONDS] # ask for tickets routed to this session');
|
|
6655
9176
|
console.log(' remits-cli agent status [--state idle|working|paused] [--ticket ID] [--activity "..."]');
|
|
6656
9177
|
console.log(' remits-cli agent list [--account-id ID] # who else is available');
|
|
9178
|
+
console.log(' remits-cli agent map [--account-ids 1,4] [--json] # who is EDITING what, where, right now');
|
|
6657
9179
|
console.log(' remits-cli agent release # stop receiving tickets now');
|
|
6658
9180
|
console.log(' remits-cli start [--foreground true] [--port 8787] # human dashboard in the browser');
|
|
6659
9181
|
console.log(' remits-cli stop');
|
|
@@ -6663,11 +9185,12 @@ async function main() {
|
|
|
6663
9185
|
console.log(' remits-cli data-mode [set test|prod]');
|
|
6664
9186
|
console.log(' remits-cli install --skills [--target codex|claude|gemini|all] [--overwrite true]');
|
|
6665
9187
|
console.log(' remits-cli tools [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--variant-branch NAME|none]');
|
|
6666
|
-
console.log(' remits-cli tool --name <toolName> [--base-url URL] [--branch BRANCH] [--input \"{...}\"|--input-file file.json] [--data-mode test|prod] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
|
|
9188
|
+
console.log(' remits-cli tool --name <toolName> [--base-url URL] [--branch BRANCH] [--input \"{...}\"|--input-file file.json] [--data-mode test|prod] [--scope self|children|hierarchy] [--target-account-id ID] [--account-ids 1,2,3] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
|
|
6667
9189
|
console.log(' remits-cli tool status --call-id <callId> [--base-url URL] [--account-id ID] [--data-mode test|prod]');
|
|
6668
|
-
console.log(' remits-cli
|
|
6669
|
-
console.log(' remits-cli components
|
|
6670
|
-
console.log(' remits-cli components
|
|
9190
|
+
console.log(' remits-cli workspace [show|use <name>|use --auto|clear] # isolate staging when several agents share a repo');
|
|
9191
|
+
console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--changed-only] [--data-mode test|prod] [--json|--verbose]');
|
|
9192
|
+
console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--component-type TYPE --component-id ID] [--json|--verbose]');
|
|
9193
|
+
console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
|
|
6671
9194
|
console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
|
|
6672
9195
|
console.log(' remits-cli components commit [--message \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
|
|
6673
9196
|
console.log(' remits-cli components promotion [<branch>] [--json] [--no-fail] # promotion readiness + ordered next steps');
|
|
@@ -6688,6 +9211,34 @@ async function main() {
|
|
|
6688
9211
|
process.exit(0);
|
|
6689
9212
|
}
|
|
6690
9213
|
|
|
9214
|
+
if (wantsHelp && (command === 'workspace' || command === 'ws')) {
|
|
9215
|
+
console.log('Usage: remits-cli workspace [show | use <name> | use --auto | clear]');
|
|
9216
|
+
console.log('');
|
|
9217
|
+
console.log('A WORKSPACE is an optional second isolation axis for staging, alongside the git branch.');
|
|
9218
|
+
console.log('Staging is keyed by (account, cli user, branch, workspace) — and account and user are fixed');
|
|
9219
|
+
console.log('for a given repo, so without a workspace the branch is the ONLY axis. That is why two coding');
|
|
9220
|
+
console.log('agents in two worktrees of the same branch overwrite each other: `components stage` uploads');
|
|
9221
|
+
console.log('the whole repo and replaces the lane wholesale.');
|
|
9222
|
+
console.log('');
|
|
9223
|
+
console.log('A workspace isolates staging WITHOUT changing anything else:');
|
|
9224
|
+
console.log(' - a commit still targets the same branch and the same owner account');
|
|
9225
|
+
console.log(' - the run still resolves whatever committed ComponentVariant branch the account subscribes to');
|
|
9226
|
+
console.log(' - only which staged entries shadow them changes');
|
|
9227
|
+
console.log('');
|
|
9228
|
+
console.log('Typical setup, one worktree per agent, all on the same branch:');
|
|
9229
|
+
console.log(' git worktree add ../ms-agent-a forked');
|
|
9230
|
+
console.log(' cd ../ms-agent-a && remits-cli workspace use --auto');
|
|
9231
|
+
console.log(' remits-cli components stage # isolated lane; nobody else sees it');
|
|
9232
|
+
console.log(' remits-cli test run --test 42 # resolves this lane over the forked variants');
|
|
9233
|
+
console.log(' remits-cli token --path /page/x # its own browser token, in this lane');
|
|
9234
|
+
console.log('');
|
|
9235
|
+
console.log('Precedence: --workspace > REMITS_WORKSPACE > .remits-cli/workspace > shared default lane');
|
|
9236
|
+
console.log('Use --no-workspace on a single command to target the shared lane without clearing the file.');
|
|
9237
|
+
console.log('');
|
|
9238
|
+
console.log('`.remits-cli/workspace` is per-checkout and gitignored, so a worktree keeps its own lane.');
|
|
9239
|
+
process.exit(0);
|
|
9240
|
+
}
|
|
9241
|
+
|
|
6691
9242
|
if (wantsHelp && command === 'test') {
|
|
6692
9243
|
printTestHelp();
|
|
6693
9244
|
process.exit(0);
|
|
@@ -6813,11 +9364,25 @@ async function main() {
|
|
|
6813
9364
|
return;
|
|
6814
9365
|
}
|
|
6815
9366
|
|
|
9367
|
+
if (command === 'ticket') {
|
|
9368
|
+
if (wantsHelp || !subcommand) {
|
|
9369
|
+
printTicketHelp();
|
|
9370
|
+
process.exit(0);
|
|
9371
|
+
}
|
|
9372
|
+
await ticketCommand(args, subcommand);
|
|
9373
|
+
return;
|
|
9374
|
+
}
|
|
9375
|
+
|
|
6816
9376
|
if (command === 'install') {
|
|
6817
9377
|
await installSkillsCommand(args);
|
|
6818
9378
|
return;
|
|
6819
9379
|
}
|
|
6820
9380
|
|
|
9381
|
+
if (command === 'workspace' || command === 'ws') {
|
|
9382
|
+
await workspaceCommand(args);
|
|
9383
|
+
return;
|
|
9384
|
+
}
|
|
9385
|
+
|
|
6821
9386
|
if (command === 'test' && subcommand === 'run') {
|
|
6822
9387
|
await testCommand(args);
|
|
6823
9388
|
return;
|