@remits/remits-cli 0.1.109 → 0.1.112
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 +13 -16
- package/index.js +2028 -133
- package/package.json +1 -1
- package/skills/remits-cli/SKILL.md +314 -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);
|
|
@@ -1615,6 +1716,25 @@ function parsePositiveInt(value, fallback) {
|
|
|
1615
1716
|
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
1616
1717
|
}
|
|
1617
1718
|
|
|
1719
|
+
/**
|
|
1720
|
+
* How long to give a full stage, from how much it is actually uploading.
|
|
1721
|
+
*
|
|
1722
|
+
* A fixed 60s was fine for a small repo and wrong for a real one: the request completes server-side and
|
|
1723
|
+
* the client abandons it, which is indistinguishable at the terminal from a stage that never landed.
|
|
1724
|
+
* Roughly 60s of headroom per megabyte on top of a 60s floor, capped so a pathological payload cannot
|
|
1725
|
+
* hang a session forever. `--stage-timeout-ms` overrides it.
|
|
1726
|
+
*/
|
|
1727
|
+
function stageTimeoutMs(components, flags = {}) {
|
|
1728
|
+
const override = parsePositiveInt(flags['stage-timeout-ms'] || flags.stageTimeoutMs, 0);
|
|
1729
|
+
if (override) return override;
|
|
1730
|
+
let bytes = 0;
|
|
1731
|
+
for (const component of components || []) {
|
|
1732
|
+
try { bytes += Buffer.byteLength(JSON.stringify(component)); } catch (_) { /* unmeasurable is fine */ }
|
|
1733
|
+
}
|
|
1734
|
+
const megabytes = bytes / (1024 * 1024);
|
|
1735
|
+
return Math.min(600000, Math.max(60000, Math.round(60000 + (megabytes * 60000))));
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1618
1738
|
function buildAxios(baseUrl, token, timeoutMs = 60000) {
|
|
1619
1739
|
const headers = token ? { Authorization: 'Bearer ' + token } : {};
|
|
1620
1740
|
return axios.create({ baseURL: baseUrl, timeout: parsePositiveInt(timeoutMs, 60000), headers });
|
|
@@ -2059,24 +2179,54 @@ async function pushComponentsCommand(flags) {
|
|
|
2059
2179
|
const { session, accountId } = sessionContext;
|
|
2060
2180
|
const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
|
|
2061
2181
|
const branchName = flags.branch || currentBranch(cwd);
|
|
2182
|
+
const workspace = resolveWorkspace(cwd, flags);
|
|
2062
2183
|
const dataMode = resolveDataMode(flags, session);
|
|
2063
2184
|
const requestedMode = String(flags.mode || 'stage').toLowerCase();
|
|
2064
2185
|
const mode = requestedMode === 'push' ? 'stage' : requestedMode;
|
|
2065
|
-
const components = collectComponents(cwd);
|
|
2066
2186
|
const changedFromWorkingTree = changedComponentsFromWorkingTree(cwd);
|
|
2067
2187
|
|
|
2188
|
+
// --changed-only stages just the components this working tree edited, instead of re-uploading all of
|
|
2189
|
+
// them. Two reasons it matters here: it is what makes an iteration loop cheap on a large repo, and it
|
|
2190
|
+
// deliberately does NOT send `replace`, so the server's reconcile pass cannot delete the entries for
|
|
2191
|
+
// components this partial stage did not mention.
|
|
2192
|
+
const changedOnly = flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly);
|
|
2193
|
+
let components = collectComponents(cwd);
|
|
2194
|
+
if (changedOnly) {
|
|
2195
|
+
if (changedFromWorkingTree === null) {
|
|
2196
|
+
throw new Error('--changed-only needs a git working tree to establish the changed set, and this directory is not one.');
|
|
2197
|
+
}
|
|
2198
|
+
const wanted = new Set(changedFromWorkingTree.map((entry) =>
|
|
2199
|
+
entry.type + ':' + (entry.id ? 'id:' + entry.id : 'name:' + String(entry.name || '').toLowerCase())));
|
|
2200
|
+
components = components.filter((component) => wanted.has(
|
|
2201
|
+
component.type + ':' + (component.id ? 'id:' + component.id : 'name:' + String(component.name || '').toLowerCase())));
|
|
2202
|
+
if (!components.length) {
|
|
2203
|
+
console.log('--changed-only: this working tree has no uncommitted component edits; nothing to stage.');
|
|
2204
|
+
printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
|
|
2205
|
+
return { success: true, mode, updated: 0, unchanged: 0, skipped: [], changedOnly: true };
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2068
2209
|
if (requestedMode === 'push') {
|
|
2069
2210
|
console.log('Warning: `components push` is deprecated and currently behaves the same as `components stage`.');
|
|
2070
2211
|
}
|
|
2071
2212
|
|
|
2072
|
-
|
|
2213
|
+
// A full stage uploads the WHOLE repo, and a large one is megabytes: Merchant Statements is 9.6 MB
|
|
2214
|
+
// across 106 components. Against a remote platform that routinely runs past 60s — and the failure was
|
|
2215
|
+
// the worst possible shape, because the server had already stored everything: the CLI reported
|
|
2216
|
+
// "timeout ... ECONNABORTED" over a stage that had SUCCEEDED, so the caller would either re-upload it
|
|
2217
|
+
// or, worse, conclude staging was broken and go on reading trunk. Scaled by payload size, floored at
|
|
2218
|
+
// the old default, and overridable.
|
|
2219
|
+
const api = buildAxios(baseUrl, session.token, stageTimeoutMs(components, flags));
|
|
2073
2220
|
const response = await loggedPost(api, cwd, '/cli/components', {
|
|
2074
2221
|
token: session.token,
|
|
2075
2222
|
accountId,
|
|
2076
2223
|
branchName,
|
|
2224
|
+
workspace,
|
|
2077
2225
|
dataMode,
|
|
2078
2226
|
mode,
|
|
2079
|
-
|
|
2227
|
+
// A partial (--changed-only) stage must not trigger the server's reconcile pass: reconcile deletes
|
|
2228
|
+
// every staged entry absent from the manifest, which for a partial manifest is nearly all of them.
|
|
2229
|
+
replace: !changedOnly,
|
|
2080
2230
|
components
|
|
2081
2231
|
}).then((r) => r.data);
|
|
2082
2232
|
response.changedFromWorkingTree = changedFromWorkingTree || [];
|
|
@@ -2089,8 +2239,9 @@ async function pushComponentsCommand(flags) {
|
|
|
2089
2239
|
|
|
2090
2240
|
printSessionResolutionWarning(sessionContext);
|
|
2091
2241
|
printResolvedBaseUrl(baseUrl);
|
|
2242
|
+
printStagingLane(response.branchName || branchName, response.workspace || workspace, workspaceSource(cwd, flags));
|
|
2092
2243
|
console.log('Data mode:', response.dataMode || dataMode);
|
|
2093
|
-
console.log('Mode:', response.mode || mode);
|
|
2244
|
+
console.log('Mode:', response.mode || mode + (changedOnly ? ' (--changed-only)' : ''));
|
|
2094
2245
|
printStageSummary(response, flags);
|
|
2095
2246
|
}
|
|
2096
2247
|
|
|
@@ -2166,8 +2317,26 @@ function printStageSummary(response, flags) {
|
|
|
2166
2317
|
|
|
2167
2318
|
function printStatusSummary(response, flags) {
|
|
2168
2319
|
printBranchContext(response);
|
|
2320
|
+
printStagingLane(response.branchName, response.workspace, null);
|
|
2169
2321
|
console.log('Staged count:', response.stagedCount || 0);
|
|
2170
2322
|
printComponentTypeCounts(response.entries || []);
|
|
2323
|
+
|
|
2324
|
+
// Every lane staged on this branch, not just this one. An agent that believes it is working alone can
|
|
2325
|
+
// see here that it is not — which was impossible before workspaces existed, and is a large part of why
|
|
2326
|
+
// concurrent agents overwrote each other without anyone being able to observe it.
|
|
2327
|
+
const lanes = Array.isArray(response.workspaces) ? response.workspaces : [];
|
|
2328
|
+
if (lanes.length > 1 || (lanes.length === 1 && lanes[0].workspace !== (response.workspace || null))) {
|
|
2329
|
+
console.log('');
|
|
2330
|
+
console.log('Other staging lanes on branch "' + response.branchName + '" for this account/user:');
|
|
2331
|
+
lanes.forEach((lane) => {
|
|
2332
|
+
const isCurrent = (lane.workspace || null) === (response.workspace || null);
|
|
2333
|
+
const label = lane.workspace ? 'workspace ' + lane.workspace : 'shared default lane';
|
|
2334
|
+
const ttl = lane.ttlSeconds != null ? ' expires in ~' + Math.round(lane.ttlSeconds / 60) + 'm' : '';
|
|
2335
|
+
console.log(' ' + (isCurrent ? '* ' : ' ') + label + ' — ' + (lane.keyCount || 0) + ' staged key(s)' + ttl);
|
|
2336
|
+
});
|
|
2337
|
+
console.log(' (* = this command\'s lane)');
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2171
2340
|
printComponentCommandResponse('Components staging', response, flags);
|
|
2172
2341
|
}
|
|
2173
2342
|
|
|
@@ -2210,6 +2379,64 @@ function printClearSummary(response, flags) {
|
|
|
2210
2379
|
printComponentCommandResponse('Components clear', response, flags);
|
|
2211
2380
|
}
|
|
2212
2381
|
|
|
2382
|
+
/**
|
|
2383
|
+
* `remits-cli workspace [show|use <name>|clear]`
|
|
2384
|
+
*
|
|
2385
|
+
* The ergonomic entry point for the staging workspace lane. `use --auto` names the lane after the
|
|
2386
|
+
* checkout directory, which is what makes `git worktree add ../ms-agent-a` + one command enough to give
|
|
2387
|
+
* an agent its own isolated staging lane on the SAME branch.
|
|
2388
|
+
*/
|
|
2389
|
+
async function workspaceCommand(flags) {
|
|
2390
|
+
const cwd = process.cwd();
|
|
2391
|
+
ensureLocalState(cwd);
|
|
2392
|
+
const sub = String((flags._ && flags._[1]) || 'show').toLowerCase();
|
|
2393
|
+
|
|
2394
|
+
if (sub === 'clear' || sub === 'unset' || sub === 'none') {
|
|
2395
|
+
writeWorkspaceFile(cwd, null);
|
|
2396
|
+
console.log('Workspace cleared. This checkout now stages into the shared default lane for its branch.');
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
if (sub === 'use' || sub === 'set') {
|
|
2401
|
+
const auto = flagEnabled(flags.auto);
|
|
2402
|
+
const requested = auto ? path.basename(cwd) : (flags._ && flags._[2]) || flags.name || flags.workspace;
|
|
2403
|
+
if (!requested) {
|
|
2404
|
+
throw new Error('Usage: remits-cli workspace use <name> (or --auto to name it after this directory)');
|
|
2405
|
+
}
|
|
2406
|
+
const normalized = normalizeWorkspace(requested);
|
|
2407
|
+
if (!normalized) {
|
|
2408
|
+
throw new Error('Workspace name "' + requested + '" normalizes to nothing. Use letters, digits, dot, dash or underscore.');
|
|
2409
|
+
}
|
|
2410
|
+
writeWorkspaceFile(cwd, normalized);
|
|
2411
|
+
console.log('Workspace set to:', normalized);
|
|
2412
|
+
if (normalized !== String(requested).trim()) {
|
|
2413
|
+
// Say so rather than silently accepting a name that is not the lane. An agent that thinks it is on
|
|
2414
|
+
// "Agent A" and is actually on "agent-a" cannot reconcile its own output with a status listing.
|
|
2415
|
+
console.log(' (normalized from "' + String(requested).trim() + '")');
|
|
2416
|
+
}
|
|
2417
|
+
console.log('Stored in:', localStatePaths(cwd).workspaceFile, '(gitignored, per-checkout)');
|
|
2418
|
+
console.log('');
|
|
2419
|
+
console.log('Every stage / test run / token from this directory now uses this lane.');
|
|
2420
|
+
console.log('It does NOT change which branch a commit targets, or which committed variants run.');
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
if (sub !== 'show' && sub !== 'status') {
|
|
2425
|
+
throw new Error('Unknown workspace subcommand "' + sub + '". Expected show, use, or clear.');
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
const branchName = flags.branch || currentBranch(cwd);
|
|
2429
|
+
const workspace = resolveWorkspace(cwd, flags);
|
|
2430
|
+
printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
|
|
2431
|
+
console.log('');
|
|
2432
|
+
console.log('Precedence: --workspace > REMITS_WORKSPACE > .remits-cli/workspace > shared default lane');
|
|
2433
|
+
console.log('');
|
|
2434
|
+
console.log(' remits-cli workspace use <name> set this checkout\'s lane');
|
|
2435
|
+
console.log(' remits-cli workspace use --auto name it after this directory (' + normalizeWorkspace(path.basename(cwd)) + ')');
|
|
2436
|
+
console.log(' remits-cli workspace clear go back to the shared default lane');
|
|
2437
|
+
console.log(' remits-cli components status see every lane staged on this branch');
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2213
2440
|
async function statusComponentsCommand(flags) {
|
|
2214
2441
|
const cwd = process.cwd();
|
|
2215
2442
|
ensureLocalState(cwd);
|
|
@@ -2217,6 +2444,7 @@ async function statusComponentsCommand(flags) {
|
|
|
2217
2444
|
const { session, accountId } = sessionContext;
|
|
2218
2445
|
const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
|
|
2219
2446
|
const branchName = flags.branch || currentBranch(cwd);
|
|
2447
|
+
const workspace = resolveWorkspace(cwd, flags);
|
|
2220
2448
|
const dataMode = resolveDataMode(flags, session);
|
|
2221
2449
|
const api = buildAxios(baseUrl, session.token);
|
|
2222
2450
|
|
|
@@ -2224,6 +2452,7 @@ async function statusComponentsCommand(flags) {
|
|
|
2224
2452
|
token: session.token,
|
|
2225
2453
|
accountId,
|
|
2226
2454
|
branchName,
|
|
2455
|
+
workspace,
|
|
2227
2456
|
dataMode,
|
|
2228
2457
|
mode: 'status',
|
|
2229
2458
|
componentType: flags['component-type'] || flags.type,
|
|
@@ -2272,6 +2501,9 @@ async function clearComponentsCommand(flags) {
|
|
|
2272
2501
|
const response = await loggedPost(api, cwd, '/cli/components', {
|
|
2273
2502
|
token: session.token,
|
|
2274
2503
|
accountId,
|
|
2504
|
+
// A clear NEVER reaches beyond this lane, so `--all` means "all of MY staged entries", not
|
|
2505
|
+
// "everything staged on this branch by anyone".
|
|
2506
|
+
workspace: resolveWorkspace(cwd, flags),
|
|
2275
2507
|
branchName,
|
|
2276
2508
|
dataMode,
|
|
2277
2509
|
mode: 'clear',
|
|
@@ -2291,6 +2523,7 @@ async function clearComponentsCommand(flags) {
|
|
|
2291
2523
|
|
|
2292
2524
|
printSessionResolutionWarning(sessionContext);
|
|
2293
2525
|
printResolvedBaseUrl(baseUrl);
|
|
2526
|
+
printStagingLane(response.branchName || branchName, response.workspace, workspaceSource(cwd, flags));
|
|
2294
2527
|
console.log('Data mode:', response.dataMode || dataMode);
|
|
2295
2528
|
console.log('Mode:', response.mode || 'clear');
|
|
2296
2529
|
printClearSummary(response, flags);
|
|
@@ -2304,6 +2537,7 @@ async function syncComponentsCommand(flags) {
|
|
|
2304
2537
|
const { session, accountId } = sessionContext;
|
|
2305
2538
|
const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
|
|
2306
2539
|
const branchName = flags.branch || currentBranch(cwd);
|
|
2540
|
+
const workspace = resolveWorkspace(cwd, flags);
|
|
2307
2541
|
const dataMode = resolveDataMode(flags, session);
|
|
2308
2542
|
const forceTombstones = flagEnabled(flags['force-tombstones']) || flagEnabled(flags.forceTombstones);
|
|
2309
2543
|
const dryRun = flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun);
|
|
@@ -2325,6 +2559,11 @@ async function syncComponentsCommand(flags) {
|
|
|
2325
2559
|
token: session.token,
|
|
2326
2560
|
accountId,
|
|
2327
2561
|
branchName,
|
|
2562
|
+
// A successful sync clears the staging scope so stale staged aliases cannot shadow the freshly synced
|
|
2563
|
+
// DB rows. Sending the workspace keeps that clear scoped to THIS lane: without it, one agent's sync
|
|
2564
|
+
// would wipe every sibling agent's staged work on the same branch, which is the exact failure the
|
|
2565
|
+
// workspace lane exists to remove.
|
|
2566
|
+
workspace,
|
|
2328
2567
|
dataMode,
|
|
2329
2568
|
forceTombstones,
|
|
2330
2569
|
dryRun,
|
|
@@ -2338,6 +2577,7 @@ async function syncComponentsCommand(flags) {
|
|
|
2338
2577
|
token: session.token,
|
|
2339
2578
|
accountId,
|
|
2340
2579
|
branchName,
|
|
2580
|
+
workspace,
|
|
2341
2581
|
dataMode,
|
|
2342
2582
|
mode: 'status'
|
|
2343
2583
|
}).then((r) => r.data);
|
|
@@ -3230,6 +3470,7 @@ async function testCommand(flags) {
|
|
|
3230
3470
|
const { session, accountId } = sessionContext;
|
|
3231
3471
|
const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
|
|
3232
3472
|
const branchName = flags.branch || currentBranch(cwd);
|
|
3473
|
+
const workspace = resolveWorkspace(cwd, flags);
|
|
3233
3474
|
// Test runs must default to test dataMode even when the most-recent authenticated
|
|
3234
3475
|
// session for the account is prod. Production test runs therefore require an explicit
|
|
3235
3476
|
// --data-mode prod on the command line.
|
|
@@ -3285,6 +3526,7 @@ async function testCommand(flags) {
|
|
|
3285
3526
|
asAccountId,
|
|
3286
3527
|
variantBranch,
|
|
3287
3528
|
branchName,
|
|
3529
|
+
workspace,
|
|
3288
3530
|
dataMode,
|
|
3289
3531
|
testId: /^\d+$/.test(String(testRef)) ? Number(testRef) : undefined,
|
|
3290
3532
|
testName: /^\d+$/.test(String(testRef)) ? undefined : String(testRef),
|
|
@@ -3297,6 +3539,7 @@ async function testCommand(flags) {
|
|
|
3297
3539
|
|
|
3298
3540
|
printSessionResolutionWarning(sessionContext);
|
|
3299
3541
|
printResolvedBaseUrl(baseUrl);
|
|
3542
|
+
printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
|
|
3300
3543
|
console.log('Test run started:', start.taskId);
|
|
3301
3544
|
runtimeState.currentTestTaskId = start.taskId;
|
|
3302
3545
|
|
|
@@ -3354,6 +3597,7 @@ async function tokenCommand(flags) {
|
|
|
3354
3597
|
const { session, accountId } = sessionContext;
|
|
3355
3598
|
const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
|
|
3356
3599
|
const branchName = flags.branch || currentBranch(cwd);
|
|
3600
|
+
const workspace = resolveWorkspace(cwd, flags);
|
|
3357
3601
|
const dataMode = resolveDataMode(flags, session);
|
|
3358
3602
|
|
|
3359
3603
|
const api = buildAxios(baseUrl, session.token);
|
|
@@ -3371,6 +3615,7 @@ async function tokenCommand(flags) {
|
|
|
3371
3615
|
asAccountId: flags['as-account'] || flags['as-account-id'],
|
|
3372
3616
|
variantBranch,
|
|
3373
3617
|
branchName,
|
|
3618
|
+
workspace,
|
|
3374
3619
|
dataMode,
|
|
3375
3620
|
path: requestedPath
|
|
3376
3621
|
}).then((r) => r.data);
|
|
@@ -3396,6 +3641,8 @@ async function tokenCommand(flags) {
|
|
|
3396
3641
|
accountId: data.accountId,
|
|
3397
3642
|
branchName: data.branchName,
|
|
3398
3643
|
variantBranch: data.variantBranch,
|
|
3644
|
+
workspace: data.workspace !== undefined ? data.workspace : workspace,
|
|
3645
|
+
stagingLane: data.stagingLane,
|
|
3399
3646
|
dataMode: data.dataMode || dataMode,
|
|
3400
3647
|
shortBasePath: data.shortBasePath,
|
|
3401
3648
|
embeddableUrl
|
|
@@ -3461,6 +3708,7 @@ async function toolsCommand(flags) {
|
|
|
3461
3708
|
const { session, accountId } = sessionContext;
|
|
3462
3709
|
const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
|
|
3463
3710
|
const branchName = flags.branch || currentBranch(cwd);
|
|
3711
|
+
const workspace = resolveWorkspace(cwd, flags);
|
|
3464
3712
|
const dataMode = resolveDataMode(flags, session);
|
|
3465
3713
|
const variantBranch = flags['variant-branch'];
|
|
3466
3714
|
const api = buildAxios(baseUrl, session.token);
|
|
@@ -3470,6 +3718,7 @@ async function toolsCommand(flags) {
|
|
|
3470
3718
|
accountId,
|
|
3471
3719
|
variantBranch,
|
|
3472
3720
|
branchName,
|
|
3721
|
+
workspace,
|
|
3473
3722
|
dataMode
|
|
3474
3723
|
}).then((r) => r.data);
|
|
3475
3724
|
|
|
@@ -3592,6 +3841,7 @@ async function toolCommand(flags) {
|
|
|
3592
3841
|
accountId,
|
|
3593
3842
|
accountIdExplicit,
|
|
3594
3843
|
branchName,
|
|
3844
|
+
workspace: resolveWorkspace(cwd, flags),
|
|
3595
3845
|
dataMode,
|
|
3596
3846
|
variantBranch,
|
|
3597
3847
|
name: String(toolName),
|
|
@@ -4415,6 +4665,7 @@ function exportIssueBundle(snapshot) {
|
|
|
4415
4665
|
agents: {
|
|
4416
4666
|
localCount: snapshot.agents && snapshot.agents.local ? snapshot.agents.local.length : 0,
|
|
4417
4667
|
registeredCount: snapshot.agents && snapshot.agents.registered ? snapshot.agents.registered.length : 0,
|
|
4668
|
+
workerCount: snapshot.agents && snapshot.agents.workers ? snapshot.agents.workers.length : 0,
|
|
4418
4669
|
errors: (snapshot.agents && snapshot.agents.errors) || []
|
|
4419
4670
|
},
|
|
4420
4671
|
repos: (snapshot.repos || []).map((repo) => ({
|
|
@@ -4528,6 +4779,31 @@ function buildRepoSnapshot(entry) {
|
|
|
4528
4779
|
};
|
|
4529
4780
|
}
|
|
4530
4781
|
|
|
4782
|
+
/**
|
|
4783
|
+
* The first argument that was actually SUPPLIED, empty string included.
|
|
4784
|
+
*
|
|
4785
|
+
* `a || b` cannot express this: an empty string is a real value on any field that can be cleared, and it
|
|
4786
|
+
* is falsy, so `||` silently turns "clear this" into "say nothing about this".
|
|
4787
|
+
*/
|
|
4788
|
+
function firstDefined(...values) {
|
|
4789
|
+
for (const value of values) {
|
|
4790
|
+
if (value !== undefined && value !== null) return value;
|
|
4791
|
+
}
|
|
4792
|
+
return undefined;
|
|
4793
|
+
}
|
|
4794
|
+
|
|
4795
|
+
/**
|
|
4796
|
+
* A planning flag's value, where a valueless flag means CLEAR.
|
|
4797
|
+
*
|
|
4798
|
+
* `parseArgs` reports both `--planned-in` and `--planned-in ""` as `true`, because an empty next argument
|
|
4799
|
+
* is indistinguishable from an absent one. A planning slot is a field an org must be able to blank, so the
|
|
4800
|
+
* valueless form is given that meaning rather than being sent to the server as the literal string "true".
|
|
4801
|
+
*/
|
|
4802
|
+
function planningFlagValue(value) {
|
|
4803
|
+
if (value === undefined || value === null) return undefined;
|
|
4804
|
+
return value === true ? '' : value;
|
|
4805
|
+
}
|
|
4806
|
+
|
|
4531
4807
|
function compactSupportTicketRow(row) {
|
|
4532
4808
|
if (!row || typeof row !== 'object') {
|
|
4533
4809
|
return null;
|
|
@@ -4545,9 +4821,18 @@ function compactSupportTicketRow(row) {
|
|
|
4545
4821
|
routedAgentId: row.routedAgentId || null,
|
|
4546
4822
|
routedAgentLabel: row.routedAgentLabel || null,
|
|
4547
4823
|
routedAt: row.routedAt || null,
|
|
4824
|
+
claimedAgentId: row.claimedAgentId || null,
|
|
4825
|
+
awaitingResponseSince: row.awaitingResponseSince || null,
|
|
4826
|
+
awaitingResponseQuestion: row.awaitingResponseQuestion || null,
|
|
4548
4827
|
implementationAccountId: row.implementationAccountId || null,
|
|
4549
4828
|
implementationAccountName: row.implementationAccountName || null,
|
|
4550
4829
|
affectedComponent: row.affectedComponent || null,
|
|
4830
|
+
workstream: row.workstream || null,
|
|
4831
|
+
plannedIn: row.plannedIn || null,
|
|
4832
|
+
boardStage: row.boardStage || null,
|
|
4833
|
+
rank: row.rank === undefined ? null : row.rank,
|
|
4834
|
+
size: row.size || null,
|
|
4835
|
+
blockedBy: row.blockedBy || null,
|
|
4551
4836
|
updatedAt: row.updatedAt || row.updated || row.lastActivityAt || null,
|
|
4552
4837
|
activityAt: row.activityAt || row.lastActivityAt || null,
|
|
4553
4838
|
activityLabel: row.activityLabel || null
|
|
@@ -4565,8 +4850,13 @@ function compactSupportTicketRow(row) {
|
|
|
4565
4850
|
async function fetchSupportTicketQueueForSession(session) {
|
|
4566
4851
|
const baseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
|
|
4567
4852
|
const api = buildAxios(baseUrl, session.token, 15000);
|
|
4853
|
+
// The lane is PINNED, not left to the server's default. Without it the queue this row claims to
|
|
4854
|
+
// describe is whichever lane the platform happened to choose, so a row labelled with a session's
|
|
4855
|
+
// lane could be showing the other one.
|
|
4856
|
+
const dataMode = normalizeDataMode(session.dataMode || 'prod');
|
|
4568
4857
|
const result = await api.post('/cli/tickets', {
|
|
4569
4858
|
accountId: Number(session.accountId),
|
|
4859
|
+
dataMode,
|
|
4570
4860
|
includeChildren: true,
|
|
4571
4861
|
sortBy: 'triage',
|
|
4572
4862
|
limit: Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100)
|
|
@@ -4580,7 +4870,8 @@ async function fetchSupportTicketQueueForSession(session) {
|
|
|
4580
4870
|
baseUrl,
|
|
4581
4871
|
accountId: Number(session.accountId),
|
|
4582
4872
|
userId: session.user && session.user.id ? session.user.id : null,
|
|
4583
|
-
dataMode: (result && result.dataMode) ||
|
|
4873
|
+
dataMode: (result && result.dataMode) || dataMode,
|
|
4874
|
+
compartment: baseUrl + ' · ' + ((result && result.dataMode) || dataMode) + ' lane',
|
|
4584
4875
|
totalCount: (result && result.totalCount) || tickets.length,
|
|
4585
4876
|
count: (result && result.count) || tickets.length,
|
|
4586
4877
|
counts: (result && result.counts) || {},
|
|
@@ -4604,27 +4895,46 @@ async function fetchSupportTicketQueueForSession(session) {
|
|
|
4604
4895
|
* Goes through the same platform endpoint an operator Embeddable would use, so "route this ticket"
|
|
4605
4896
|
* has ONE implementation and one access check whichever surface asked for it.
|
|
4606
4897
|
*/
|
|
4607
|
-
|
|
4898
|
+
/**
|
|
4899
|
+
* Route a ticket from the dashboard, on the platform and lane that ticket actually lives on.
|
|
4900
|
+
*
|
|
4901
|
+
* <p>Selecting the session by {@code accountId} alone — and falling back to any authenticated session
|
|
4902
|
+
* at all — was a cross-platform write triggered from a button. Account 1 exists on production and on
|
|
4903
|
+
* a localhost instance both, so routing a localhost test fixture could reach production, or the
|
|
4904
|
+
* reverse. The ticket carries the compartment it came from; that is what decides where the write goes,
|
|
4905
|
+
* and if no session matches it we refuse rather than write somewhere plausible.</p>
|
|
4906
|
+
*/
|
|
4907
|
+
async function routeTicketFromDashboard({ ticketId, accountId, agentId, baseUrl, dataMode }) {
|
|
4608
4908
|
const numericAccountId = Number(accountId);
|
|
4609
4909
|
if (!ticketId || !Number.isFinite(numericAccountId)) {
|
|
4610
4910
|
throw new Error('Routing needs a ticketId and an accountId.');
|
|
4611
4911
|
}
|
|
4612
|
-
const
|
|
4613
|
-
|
|
4614
|
-
|
|
4912
|
+
const wantedBase = baseUrl ? normalizeBaseUrl(baseUrl) : null;
|
|
4913
|
+
const wantedMode = dataMode ? normalizeDataMode(dataMode) : null;
|
|
4914
|
+
const candidates = listSessionsFromStore(readAllSessions()).filter((entry) => {
|
|
4915
|
+
if (!entry || !entry.token) return false;
|
|
4916
|
+
if (wantedBase && normalizeBaseUrl(entry.baseUrl || DEFAULT_BASE_URL) !== wantedBase) return false;
|
|
4917
|
+
if (wantedMode && normalizeDataMode(entry.dataMode || 'prod') !== wantedMode) return false;
|
|
4918
|
+
return true;
|
|
4919
|
+
});
|
|
4920
|
+
const session = candidates.find((entry) => Number(entry.accountId) === numericAccountId) || candidates[0];
|
|
4615
4921
|
if (!session) {
|
|
4616
|
-
throw new Error('No authenticated session
|
|
4922
|
+
throw new Error('No authenticated session for ' + (wantedBase || 'that platform') +
|
|
4923
|
+
(wantedMode ? ' in the ' + wantedMode + ' lane' : '') + '. Run: remits-cli auth --base-url ' +
|
|
4924
|
+
(wantedBase || DEFAULT_BASE_URL));
|
|
4617
4925
|
}
|
|
4618
|
-
const
|
|
4619
|
-
const result = await buildAxios(
|
|
4926
|
+
const targetBaseUrl = normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL);
|
|
4927
|
+
const result = await buildAxios(targetBaseUrl, session.token, 15000)
|
|
4620
4928
|
.post('/cli/ticketRoute', {
|
|
4621
4929
|
ticketId: String(ticketId),
|
|
4622
4930
|
accountId: numericAccountId,
|
|
4623
4931
|
agentId: agentId || null,
|
|
4624
|
-
dataMode: session.dataMode
|
|
4932
|
+
dataMode: wantedMode || session.dataMode
|
|
4625
4933
|
}).then((r) => r.data);
|
|
4626
4934
|
appendGlobalActivityLog('ticket', 'routed', {
|
|
4627
|
-
ticketId, accountId: numericAccountId, agentId: agentId || null,
|
|
4935
|
+
ticketId, accountId: numericAccountId, agentId: agentId || null,
|
|
4936
|
+
baseUrl: targetBaseUrl, dataMode: wantedMode || session.dataMode,
|
|
4937
|
+
success: result && result.success
|
|
4628
4938
|
});
|
|
4629
4939
|
if (result && result.success === false) {
|
|
4630
4940
|
throw new Error(result.message || 'Routing was refused.');
|
|
@@ -4657,7 +4967,12 @@ async function collectAgentSnapshot() {
|
|
|
4657
4967
|
.post('/cli/agents', { accountId: Number(session.accountId), dataMode: session.dataMode })
|
|
4658
4968
|
.then((r) => r.data);
|
|
4659
4969
|
for (const agent of (result && result.agents) || []) {
|
|
4660
|
-
|
|
4970
|
+
// Stamp the platform and lane it was found on. Agent ids are UUIDs so a collision is unlikely,
|
|
4971
|
+
// but a merged list with no provenance cannot tell a localhost fixture agent from a production
|
|
4972
|
+
// one — the same blending that made the ticket panel report 583.
|
|
4973
|
+
if (agent && agent.agentId) {
|
|
4974
|
+
byAgentId[baseUrl + '|' + agent.agentId] = { ...agent, baseUrl, lane: agent.dataMode || session.dataMode };
|
|
4975
|
+
}
|
|
4661
4976
|
}
|
|
4662
4977
|
} catch (err) {
|
|
4663
4978
|
errors.push({ baseUrl, accountId: Number(session.accountId), message: describeError(err) });
|
|
@@ -4670,12 +4985,110 @@ async function collectAgentSnapshot() {
|
|
|
4670
4985
|
registered: Object.values(byAgentId)
|
|
4671
4986
|
.sort((a, b) => Number(b.lastSeenAtMs || 0) - Number(a.lastSeenAtMs || 0)),
|
|
4672
4987
|
local: listLocalAgents(),
|
|
4988
|
+
// Workers are a purely LOCAL fact — the platform knows a ticket is claimed, not that a process on
|
|
4989
|
+
// this laptop is running it — so they are read from disk here rather than from any endpoint.
|
|
4990
|
+
workers: listLocalAgents().reduce((all, entry) => all.concat(
|
|
4991
|
+
listWorkers(entry.agentId).map((worker) => ({
|
|
4992
|
+
...worker,
|
|
4993
|
+
agentId: entry.agentId,
|
|
4994
|
+
label: entry.label || entry.agentId,
|
|
4995
|
+
alive: !!(worker.pid && processIsAlive(worker.pid))
|
|
4996
|
+
}))), []),
|
|
4673
4997
|
errors
|
|
4674
4998
|
};
|
|
4675
4999
|
agentSnapshotCache = { fetchedAtMs: nowMs, value };
|
|
4676
5000
|
return value;
|
|
4677
5001
|
}
|
|
4678
5002
|
|
|
5003
|
+
/**
|
|
5004
|
+
* Split the fetched queues into the compartments a number can honestly be reported for.
|
|
5005
|
+
*
|
|
5006
|
+
* <p>A compartment is one <b>platform and one data lane</b>. Blending them was the headline defect:
|
|
5007
|
+
* the panel summed a production platform's queue with a localhost test-lane queue full of suite
|
|
5008
|
+
* fixtures and presented the result as "OPEN". Prod showed 24; the panel showed 583.</p>
|
|
5009
|
+
*
|
|
5010
|
+
* <p>Three narrower defects rode along with it, and each would still be wrong after separating the
|
|
5011
|
+
* regions:</p>
|
|
5012
|
+
* <ul>
|
|
5013
|
+
* <li><b>The sum double-counted.</b> Two sessions whose account hierarchies overlap both return the
|
|
5014
|
+
* same ticket, and the totals were summed per queue while only the DISPLAYED list was deduped.
|
|
5015
|
+
* Overlap is detectable: {@code /cli/tickets} reports the accounts it actually scanned, so a
|
|
5016
|
+
* queue whose scanned set is contained in another's is dropped before anything is added up.</li>
|
|
5017
|
+
* <li><b>Ticket ids were deduped globally.</b> An id is unique within a platform, not across them —
|
|
5018
|
+
* so a localhost fixture with the same id as a real production ticket silently hid it. Keys are
|
|
5019
|
+
* per compartment now.</li>
|
|
5020
|
+
* <li><b>"Waiting" was total minus routed</b>, which is <i>unrouted</i>, not waiting. With a real
|
|
5021
|
+
* awaiting-a-reply concept on the record, that label was pointing at the wrong thing entirely.</li>
|
|
5022
|
+
* </ul>
|
|
5023
|
+
*/
|
|
5024
|
+
function buildTicketCompartments(queues) {
|
|
5025
|
+
const byCompartment = {};
|
|
5026
|
+
for (const queue of queues || []) {
|
|
5027
|
+
const key = queue.compartment || (queue.baseUrl + ' · ' + (queue.dataMode || 'prod') + ' lane');
|
|
5028
|
+
if (!byCompartment[key]) {
|
|
5029
|
+
byCompartment[key] = { compartment: key, baseUrl: queue.baseUrl, dataMode: queue.dataMode, queues: [] };
|
|
5030
|
+
}
|
|
5031
|
+
byCompartment[key].queues.push(queue);
|
|
5032
|
+
}
|
|
5033
|
+
|
|
5034
|
+
return Object.values(byCompartment).map((group) => {
|
|
5035
|
+
// Drop any queue whose scanned accounts are wholly contained in another's: its tickets are already
|
|
5036
|
+
// in that one, so keeping it would count them twice. Equal sets keep the first seen.
|
|
5037
|
+
const scanned = group.queues.map((queue) => new Set(
|
|
5038
|
+
(queue.searchedAccounts || []).map((entry) => String(entry.accountId || entry.accountScope || ''))));
|
|
5039
|
+
const independent = group.queues.filter((queue, i) => {
|
|
5040
|
+
if (!scanned[i].size) {
|
|
5041
|
+
return true; // nothing reported: cannot prove overlap, so keep it and report honestly below
|
|
5042
|
+
}
|
|
5043
|
+
return !group.queues.some((other, j) => {
|
|
5044
|
+
if (i === j || !scanned[j].size) return false;
|
|
5045
|
+
const contained = [...scanned[i]].every((id) => scanned[j].has(id));
|
|
5046
|
+
if (!contained) return false;
|
|
5047
|
+
// Identical scopes: keep the earlier one, drop the later, or both would drop each other.
|
|
5048
|
+
const identical = scanned[i].size === scanned[j].size;
|
|
5049
|
+
return identical ? j < i : true;
|
|
5050
|
+
});
|
|
5051
|
+
});
|
|
5052
|
+
|
|
5053
|
+
const ticketsById = {};
|
|
5054
|
+
for (const queue of independent) {
|
|
5055
|
+
for (const ticket of queue.tickets || []) {
|
|
5056
|
+
const id = String(ticket.ticketId || '');
|
|
5057
|
+
if (!id || ticketsById[id]) continue;
|
|
5058
|
+
ticketsById[id] = { ...ticket, queueAccountId: queue.accountId, baseUrl: queue.baseUrl,
|
|
5059
|
+
dataMode: queue.dataMode, compartment: group.compartment };
|
|
5060
|
+
}
|
|
5061
|
+
}
|
|
5062
|
+
const tickets = Object.values(ticketsById);
|
|
5063
|
+
|
|
5064
|
+
const open = independent.reduce((sum, queue) => sum + Number(queue.totalCount || 0), 0);
|
|
5065
|
+
const routed = independent.reduce((sum, queue) => sum + Number((queue.counts || {}).routed || 0), 0);
|
|
5066
|
+
const assigned = independent.reduce((sum, queue) => sum + Number((queue.counts || {}).assigned || 0), 0);
|
|
5067
|
+
|
|
5068
|
+
return {
|
|
5069
|
+
compartment: group.compartment,
|
|
5070
|
+
baseUrl: group.baseUrl,
|
|
5071
|
+
dataMode: group.dataMode,
|
|
5072
|
+
queueCount: independent.length,
|
|
5073
|
+
overlappingQueuesDropped: group.queues.length - independent.length,
|
|
5074
|
+
// Counted from queues proven to scan disjoint account scopes, so the same ticket is never added
|
|
5075
|
+
// twice. `scopeUnknown` says so out loud when a queue could not report what it scanned — a count
|
|
5076
|
+
// that MIGHT overlap must never read as one that cannot.
|
|
5077
|
+
scopeUnknown: independent.some((queue) => !(queue.searchedAccounts || []).length),
|
|
5078
|
+
open,
|
|
5079
|
+
routed,
|
|
5080
|
+
unrouted: Math.max(0, open - routed),
|
|
5081
|
+
assigned,
|
|
5082
|
+
// Only ever counted over the page actually retrieved, and labelled as such in the UI.
|
|
5083
|
+
awaitingReplyOnPage: tickets.filter((t) => t.awaitingResponseSince).length,
|
|
5084
|
+
accounts: independent.map((queue) => ({
|
|
5085
|
+
accountId: queue.accountId, open: Number(queue.totalCount || 0), scanned: (queue.searchedAccounts || []).length
|
|
5086
|
+
})),
|
|
5087
|
+
tickets: tickets.slice(0, Math.min(Math.max(parsePositiveInt(SUPPORT_TICKET_DASHBOARD_LIMIT, 20), 1), 100))
|
|
5088
|
+
};
|
|
5089
|
+
}).sort((a, b) => b.open - a.open);
|
|
5090
|
+
}
|
|
5091
|
+
|
|
4679
5092
|
async function collectSupportTicketSnapshot() {
|
|
4680
5093
|
const nowMs = Date.now();
|
|
4681
5094
|
if (supportTicketSnapshotCache.value &&
|
|
@@ -4711,34 +5124,11 @@ async function collectSupportTicketSnapshot() {
|
|
|
4711
5124
|
}
|
|
4712
5125
|
}));
|
|
4713
5126
|
|
|
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
5127
|
const value = {
|
|
4730
5128
|
fetchedAt: new Date(nowMs).toISOString(),
|
|
4731
5129
|
refreshMs: SUPPORT_TICKET_DASHBOARD_REFRESH_MS,
|
|
4732
5130
|
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)),
|
|
5131
|
+
compartments: buildTicketCompartments(queues),
|
|
4742
5132
|
queues,
|
|
4743
5133
|
errors
|
|
4744
5134
|
};
|
|
@@ -4793,7 +5183,8 @@ function renderDashboardHtml(snapshot) {
|
|
|
4793
5183
|
':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
5184
|
'*{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
5185
|
'.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
|
-
'.
|
|
5186
|
+
'.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;} '+
|
|
5187
|
+
'.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
5188
|
'</style>' +
|
|
4798
5189
|
'</head><body>' +
|
|
4799
5190
|
'<main>' +
|
|
@@ -4831,6 +5222,9 @@ function renderDashboardHtml(snapshot) {
|
|
|
4831
5222
|
'<div class="section-grid">' +
|
|
4832
5223
|
'<section class="panel"><h2>Available Agents</h2><div id="agent-section" class="accordion"></div></section>' +
|
|
4833
5224
|
'</div>' +
|
|
5225
|
+
'<div class="section-grid">' +
|
|
5226
|
+
'<section class="panel"><h2>Ticket Workers</h2><div id="worker-section" class="accordion"></div></section>' +
|
|
5227
|
+
'</div>' +
|
|
4834
5228
|
'<div class="section-grid">' +
|
|
4835
5229
|
'<section class="panel"><h2>Support Tickets</h2><div id="support-ticket-section" class="accordion"></div></section>' +
|
|
4836
5230
|
'</div>' +
|
|
@@ -4864,15 +5258,16 @@ function renderDashboardHtml(snapshot) {
|
|
|
4864
5258
|
'function summaryItem(key,value){return \'<div class="summary-item"><span class="k">\'+escapeHtml(key)+\'</span><span class="v">\'+escapeHtml(value)+\'</span></div>\';}' +
|
|
4865
5259
|
'function codeBlock(key,content,extraClass){return \'<pre class="code-block \'+(extraClass||"")+\'" data-scroll-key="\'+escapeHtml(key)+\'">\'+escapeHtml(content||"[missing]")+\'</pre>\';}' +
|
|
4866
5260
|
'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
|
|
5261
|
+
'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
5262
|
'{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
5263
|
'{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
|
|
5264
|
+
'{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")},' +
|
|
5265
|
+
'{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"},' +
|
|
5266
|
+
'{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
5267
|
'{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
5268
|
'{title:"Repos",value:String(repoCount),label:"Indexed account repos",tone:repoCount?"ok":"warn",sub:"Last scan: "+formatTime(state.discovery&&state.discovery.scannedAt)}' +
|
|
4874
5269
|
'];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
|
|
5270
|
+
'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
5271
|
'summaryItem("PID",state.service&&state.service.pid||"n/a"),' +
|
|
4877
5272
|
'summaryItem("Dashboard",state.service&&state.service.dashboardUrl||"n/a"),' +
|
|
4878
5273
|
'summaryItem("Repo Matches",String(state.discovery&&state.discovery.matches||0)),' +
|
|
@@ -4884,19 +5279,25 @@ function renderDashboardHtml(snapshot) {
|
|
|
4884
5279
|
'].join("");}' +
|
|
4885
5280
|
'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
5281
|
'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
|
|
5282
|
+
'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;}' +
|
|
5283
|
+
'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
5284
|
'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
5285
|
'function agentTone(agent){if(!agent||agent.online===false)return "bad";const s=agent.state||"idle";return s==="paused"?"warn":"ok";}' +
|
|
4891
|
-
'function
|
|
5286
|
+
'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();}' +
|
|
5287
|
+
'var followState={};' +
|
|
5288
|
+
'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>\';}' +
|
|
5289
|
+
'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");}}' +
|
|
5290
|
+
'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);}' +
|
|
5291
|
+
'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);});}' +
|
|
5292
|
+
'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
5293
|
'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
5294
|
'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
5295
|
'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);}' +
|
|
5296
|
+
'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
5297
|
'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
5298
|
'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
5299
|
'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);});' +
|
|
5300
|
+
'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
5301
|
'renderAll(dashboardState);' +
|
|
4901
5302
|
'setInterval(function(){fetchState().then(renderAll).catch(function(err){showToast(err.message||String(err));});},refreshMs);' +
|
|
4902
5303
|
'})();' +
|
|
@@ -4929,6 +5330,172 @@ async function reconnectManagedWebsockets() {
|
|
|
4929
5330
|
}
|
|
4930
5331
|
}
|
|
4931
5332
|
|
|
5333
|
+
/**
|
|
5334
|
+
* Turn a worker's raw JSONL transcript into readable events for the dashboard.
|
|
5335
|
+
*
|
|
5336
|
+
* <p>A worker has no terminal to attach to — it is spawned with pipes, deliberately, and `codex exec`
|
|
5337
|
+
* / `claude -p` are non-interactive, so there is nothing to type at even if there were a tty. What a
|
|
5338
|
+
* reader actually wants is to FOLLOW ALONG, and for that the JSONL is strictly better than a terminal
|
|
5339
|
+
* would be: it is already structured, so commands, their exit codes, file changes and the agent's own
|
|
5340
|
+
* messages can be rendered as distinct things instead of scraped back out of ANSI output.</p>
|
|
5341
|
+
*
|
|
5342
|
+
* <p>Reads incrementally from a byte offset so the browser polls for the tail rather than re-fetching
|
|
5343
|
+
* a transcript that grows to megabytes.</p>
|
|
5344
|
+
*/
|
|
5345
|
+
function readWorkerTranscript(logFile, fromOffset = 0) {
|
|
5346
|
+
const empty = { events: [], offset: 0, size: 0, missing: true };
|
|
5347
|
+
if (!logFile || !fs.existsSync(logFile)) {
|
|
5348
|
+
return empty;
|
|
5349
|
+
}
|
|
5350
|
+
// Only ever serve files from the worker log directory. The dashboard binds to 127.0.0.1, but a path
|
|
5351
|
+
// taken from a query string and handed to readFileSync is a file-read primitive regardless.
|
|
5352
|
+
const resolved = path.resolve(logFile);
|
|
5353
|
+
if (path.dirname(resolved) !== path.resolve(WORKER_LOG_DIR)) {
|
|
5354
|
+
return { ...empty, missing: false, forbidden: true };
|
|
5355
|
+
}
|
|
5356
|
+
|
|
5357
|
+
const size = fs.statSync(resolved).size;
|
|
5358
|
+
// A truncated or rotated file (size went backwards) must restart, not read from a stale offset.
|
|
5359
|
+
const start = Math.min(Math.max(0, Number(fromOffset) || 0), size);
|
|
5360
|
+
if (start >= size) {
|
|
5361
|
+
return { events: [], offset: size, size, missing: false };
|
|
5362
|
+
}
|
|
5363
|
+
|
|
5364
|
+
const fd = fs.openSync(resolved, 'r');
|
|
5365
|
+
let text = '';
|
|
5366
|
+
try {
|
|
5367
|
+
const length = size - start;
|
|
5368
|
+
const buffer = Buffer.alloc(length);
|
|
5369
|
+
fs.readSync(fd, buffer, 0, length, start);
|
|
5370
|
+
text = buffer.toString('utf8');
|
|
5371
|
+
} finally {
|
|
5372
|
+
fs.closeSync(fd);
|
|
5373
|
+
}
|
|
5374
|
+
|
|
5375
|
+
// Only whole lines are consumed; a partial trailing line is left for the next poll by rewinding the
|
|
5376
|
+
// reported offset, so a JSON object split across two reads is never dropped as unparseable.
|
|
5377
|
+
const lines = text.split('\n');
|
|
5378
|
+
const trailing = lines.pop();
|
|
5379
|
+
const consumed = size - Buffer.byteLength(trailing, 'utf8');
|
|
5380
|
+
|
|
5381
|
+
const events = [];
|
|
5382
|
+
for (const line of lines) {
|
|
5383
|
+
if (!line.trim()) continue;
|
|
5384
|
+
// describeWorkerEvent returns null for the bookkeeping frames a reader does not want
|
|
5385
|
+
// (thread.started, todo_list, …). Dropping them here keeps the payload a clean list.
|
|
5386
|
+
const described = describeWorkerEvent(line);
|
|
5387
|
+
if (described) {
|
|
5388
|
+
events.push(described);
|
|
5389
|
+
}
|
|
5390
|
+
}
|
|
5391
|
+
return { events, offset: consumed, size, missing: false };
|
|
5392
|
+
}
|
|
5393
|
+
|
|
5394
|
+
/** One transcript line as {kind, text, detail} — never throws, and never drops a line it cannot parse. */
|
|
5395
|
+
function describeWorkerEvent(line) {
|
|
5396
|
+
let event = null;
|
|
5397
|
+
try {
|
|
5398
|
+
event = JSON.parse(line);
|
|
5399
|
+
} catch (_) {
|
|
5400
|
+
// Not JSON: a plain stderr line from the agent or the shell. Worth showing verbatim — the times
|
|
5401
|
+
// this matters most (a crash, a missing binary) are exactly the times it is not JSON.
|
|
5402
|
+
return { kind: 'raw', text: line.slice(0, 4000) };
|
|
5403
|
+
}
|
|
5404
|
+
if (!event || typeof event !== 'object') {
|
|
5405
|
+
return { kind: 'raw', text: line.slice(0, 4000) };
|
|
5406
|
+
}
|
|
5407
|
+
|
|
5408
|
+
if (event.type === 'item.completed' && event.item) {
|
|
5409
|
+
const item = event.item;
|
|
5410
|
+
const kind = item.item_type || item.type;
|
|
5411
|
+
if (kind === 'command_execution') {
|
|
5412
|
+
return {
|
|
5413
|
+
kind: 'command',
|
|
5414
|
+
text: String(item.command || ''),
|
|
5415
|
+
exitCode: item.exit_code == null ? null : Number(item.exit_code),
|
|
5416
|
+
detail: String(item.aggregated_output || '').slice(0, 4000)
|
|
5417
|
+
};
|
|
5418
|
+
}
|
|
5419
|
+
if (kind === 'file_change') {
|
|
5420
|
+
return { kind: 'edit', text: 'edited files', detail: JSON.stringify(item.changes || item, null, 2).slice(0, 4000) };
|
|
5421
|
+
}
|
|
5422
|
+
if (kind === 'reasoning') return { kind: 'thinking', text: String(item.text || '').slice(0, 4000) };
|
|
5423
|
+
if (kind === 'agent_message') return { kind: 'message', text: String(item.text || '').slice(0, 8000) };
|
|
5424
|
+
if (kind === 'todo_list') return null;
|
|
5425
|
+
}
|
|
5426
|
+
if (event.type === 'assistant' && event.message && Array.isArray(event.message.content)) {
|
|
5427
|
+
for (const block of event.message.content) {
|
|
5428
|
+
if (block && block.type === 'tool_use') {
|
|
5429
|
+
const input = block.input || {};
|
|
5430
|
+
return {
|
|
5431
|
+
kind: 'command',
|
|
5432
|
+
text: String(block.name || 'tool') + (input.command || input.file_path || input.pattern
|
|
5433
|
+
? ': ' + (input.command || input.file_path || input.pattern) : ''),
|
|
5434
|
+
detail: JSON.stringify(input, null, 2).slice(0, 4000)
|
|
5435
|
+
};
|
|
5436
|
+
}
|
|
5437
|
+
if (block && block.type === 'text' && block.text) {
|
|
5438
|
+
return { kind: 'message', text: String(block.text).slice(0, 8000) };
|
|
5439
|
+
}
|
|
5440
|
+
}
|
|
5441
|
+
}
|
|
5442
|
+
if (event.type === 'result') {
|
|
5443
|
+
return { kind: 'result', text: 'finished: ' + (event.subtype || 'done'), detail: String(event.result || '').slice(0, 4000) };
|
|
5444
|
+
}
|
|
5445
|
+
if (event.type === 'turn.completed' && event.usage) {
|
|
5446
|
+
return { kind: 'result', text: 'turn complete', detail: JSON.stringify(event.usage) };
|
|
5447
|
+
}
|
|
5448
|
+
// Bookkeeping frames a reader gains nothing from. `item.started` in particular is paired with an
|
|
5449
|
+
// `item.completed` carrying the same command plus its output and exit code, so showing both doubles
|
|
5450
|
+
// the transcript and puts every command on screen twice.
|
|
5451
|
+
if (event.type === 'thread.started' || event.type === 'turn.started' ||
|
|
5452
|
+
event.type === 'item.started' || event.type === 'item.updated' || event.type === 'system') {
|
|
5453
|
+
return null;
|
|
5454
|
+
}
|
|
5455
|
+
return { kind: 'event', text: String(event.type || 'event') };
|
|
5456
|
+
}
|
|
5457
|
+
|
|
5458
|
+
/** The transcript of one running or finished worker, addressed by its ticket. */
|
|
5459
|
+
function workerTranscriptFor(ticketId, fromOffset) {
|
|
5460
|
+
const id = String(ticketId || '').trim();
|
|
5461
|
+
if (!id) {
|
|
5462
|
+
return { error: 'a ticketId is required' };
|
|
5463
|
+
}
|
|
5464
|
+
let worker = null;
|
|
5465
|
+
for (const entry of listLocalAgents()) {
|
|
5466
|
+
const found = listWorkers(entry.agentId).find((w) => String(w.ticketId) === id);
|
|
5467
|
+
if (found) {
|
|
5468
|
+
worker = { ...found, agentId: entry.agentId };
|
|
5469
|
+
break;
|
|
5470
|
+
}
|
|
5471
|
+
}
|
|
5472
|
+
// A worker that has EXITED is gone from the store but its transcript is the most interesting one to
|
|
5473
|
+
// read, so fall back to the newest log file for that ticket.
|
|
5474
|
+
let logFile = worker && worker.logFile;
|
|
5475
|
+
if (!logFile && fs.existsSync(WORKER_LOG_DIR)) {
|
|
5476
|
+
const candidates = fs.readdirSync(WORKER_LOG_DIR)
|
|
5477
|
+
.filter((name) => name.startsWith(id + '-') && name.endsWith('.log'))
|
|
5478
|
+
.sort();
|
|
5479
|
+
if (candidates.length) {
|
|
5480
|
+
logFile = path.join(WORKER_LOG_DIR, candidates[candidates.length - 1]);
|
|
5481
|
+
}
|
|
5482
|
+
}
|
|
5483
|
+
if (!logFile) {
|
|
5484
|
+
return { ticketId: id, missing: true, events: [], offset: 0 };
|
|
5485
|
+
}
|
|
5486
|
+
const transcript = readWorkerTranscript(logFile, fromOffset);
|
|
5487
|
+
return {
|
|
5488
|
+
ticketId: id,
|
|
5489
|
+
logFile,
|
|
5490
|
+
running: !!(worker && worker.pid && processIsAlive(worker.pid)),
|
|
5491
|
+
pid: worker ? worker.pid : null,
|
|
5492
|
+
agentType: worker ? worker.agentType : null,
|
|
5493
|
+
startedAt: worker ? worker.startedAt : null,
|
|
5494
|
+
follow: 'tail -f ' + logFile,
|
|
5495
|
+
...transcript
|
|
5496
|
+
};
|
|
5497
|
+
}
|
|
5498
|
+
|
|
4932
5499
|
function startDashboardServer(preferredPort) {
|
|
4933
5500
|
return new Promise((resolve, reject) => {
|
|
4934
5501
|
const sendDashboardError = (res, err, wantsJson = true) => {
|
|
@@ -4992,7 +5559,9 @@ function startDashboardServer(preferredPort) {
|
|
|
4992
5559
|
actionResult = await routeTicketFromDashboard({
|
|
4993
5560
|
ticketId: params.get('ticketId'),
|
|
4994
5561
|
accountId: params.get('accountId'),
|
|
4995
|
-
agentId: params.get('agentId')
|
|
5562
|
+
agentId: params.get('agentId'),
|
|
5563
|
+
baseUrl: params.get('baseUrl'),
|
|
5564
|
+
dataMode: params.get('dataMode')
|
|
4996
5565
|
});
|
|
4997
5566
|
supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
|
|
4998
5567
|
agentSnapshotCache = { fetchedAtMs: 0, value: null };
|
|
@@ -5018,6 +5587,20 @@ function startDashboardServer(preferredPort) {
|
|
|
5018
5587
|
return;
|
|
5019
5588
|
}
|
|
5020
5589
|
|
|
5590
|
+
if (req.method === 'GET' && requestUrl.pathname === '/api/worker-log') {
|
|
5591
|
+
try {
|
|
5592
|
+
const payload = workerTranscriptFor(
|
|
5593
|
+
requestUrl.searchParams.get('ticket'),
|
|
5594
|
+
Number(requestUrl.searchParams.get('offset')) || 0);
|
|
5595
|
+
res.statusCode = 200;
|
|
5596
|
+
res.setHeader('Content-Type', 'application/json');
|
|
5597
|
+
res.end(JSON.stringify(payload));
|
|
5598
|
+
} catch (err) {
|
|
5599
|
+
sendDashboardError(res, err, true);
|
|
5600
|
+
}
|
|
5601
|
+
return;
|
|
5602
|
+
}
|
|
5603
|
+
|
|
5021
5604
|
if (req.method === 'GET' && requestUrl.pathname === '/') {
|
|
5022
5605
|
try {
|
|
5023
5606
|
const snapshot = await collectDashboardSnapshot();
|
|
@@ -5451,58 +6034,296 @@ const AGENT_WORK_POLL_MS = Number(process.env.REMITS_CLI_AGENT_WORK_POLL_MS || 5
|
|
|
5451
6034
|
const AGENT_WORK_DEFAULT_WAIT_SEC = Number(process.env.REMITS_CLI_AGENT_WORK_WAIT_SEC || 0);
|
|
5452
6035
|
const AGENT_STATES = ['idle', 'working', 'paused'];
|
|
5453
6036
|
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
6037
|
+
// ---------------------------------------------------------------------------------------------
|
|
6038
|
+
// Autonomous ticket workers
|
|
6039
|
+
//
|
|
6040
|
+
// A registered agent can also SERVE: a detached supervisor polls for the tickets routed to this
|
|
6041
|
+
// session and launches ONE fresh headless agent process per ticket.
|
|
6042
|
+
//
|
|
6043
|
+
// One ticket = one process is the whole point, and it is what makes context grooming structural
|
|
6044
|
+
// rather than a discipline. `/clear` and `/new` are TUI commands a human types; a model cannot
|
|
6045
|
+
// invoke them, so a long-lived interactive session working ticket after ticket has no way to reset
|
|
6046
|
+
// itself. A process that exits has nothing to reset.
|
|
6047
|
+
//
|
|
6048
|
+
// The supervisor is deliberately dumb: it starts workers, reports what they are doing, and cleans
|
|
6049
|
+
// up after them. Every decision that must survive it — which session a ticket is routed to, which
|
|
6050
|
+
// worker holds it — is durable on the platform, so killing the supervisor loses nothing but time.
|
|
6051
|
+
// ---------------------------------------------------------------------------------------------
|
|
6052
|
+
|
|
6053
|
+
const WORKER_LOG_DIR = path.join(SESSION_DIR, 'workers');
|
|
6054
|
+
const AGENT_SERVE_POLL_MS = Number(process.env.REMITS_CLI_AGENT_SERVE_POLL_MS || 20000);
|
|
6055
|
+
const AGENT_SERVE_IDLE_POLL_MS = Number(process.env.REMITS_CLI_AGENT_SERVE_IDLE_POLL_MS || 60000);
|
|
6056
|
+
// After this many consecutive empty polls, back off to the idle cadence. An agent waiting all night
|
|
6057
|
+
// should not hold a request-per-20s against production for work that arrives twice a day.
|
|
6058
|
+
const AGENT_SERVE_IDLE_AFTER_POLLS = 10;
|
|
6059
|
+
const AGENT_WORKER_MAX_ATTEMPTS = Number(process.env.REMITS_CLI_AGENT_WORKER_MAX_ATTEMPTS || 2);
|
|
6060
|
+
const AGENT_WORKER_STATUS_THROTTLE_MS = 20000;
|
|
6061
|
+
const AGENT_WORKER_MAX_LOG_BYTES = 8 * 1024 * 1024;
|
|
6062
|
+
|
|
6063
|
+
/**
|
|
6064
|
+
* How each supported agent is invoked headlessly, and how far it is allowed to go.
|
|
6065
|
+
*
|
|
6066
|
+
* Every one of them takes the brief on STDIN rather than in argv: a brief is a page of prose and
|
|
6067
|
+
* argv has a hard length limit, so passing it as an argument fails on exactly the detailed tickets
|
|
6068
|
+
* that most need the detail.
|
|
6069
|
+
*
|
|
6070
|
+
* `mode` is the autonomy bound, not a preference. `investigate` maps to each CLI's genuine
|
|
6071
|
+
* read-only mode so an operator can put a real boundary around a production support agent; `edit`
|
|
6072
|
+
* lets it change files but never commits (the brief says so, and nothing here runs git).
|
|
6073
|
+
*/
|
|
6074
|
+
const AGENT_LAUNCHERS = {
|
|
6075
|
+
codex: {
|
|
6076
|
+
binary: 'codex',
|
|
6077
|
+
build: ({ cwd, mode, lastMessageFile }) => ({
|
|
6078
|
+
args: [
|
|
6079
|
+
'exec',
|
|
6080
|
+
'--cd', cwd,
|
|
6081
|
+
'--skip-git-repo-check',
|
|
6082
|
+
'--sandbox', mode === 'investigate' ? 'read-only' : 'workspace-write',
|
|
6083
|
+
'--json',
|
|
6084
|
+
...(lastMessageFile ? ['--output-last-message', lastMessageFile] : []),
|
|
6085
|
+
'-'
|
|
6086
|
+
]
|
|
6087
|
+
})
|
|
6088
|
+
},
|
|
6089
|
+
claude: {
|
|
6090
|
+
binary: 'claude',
|
|
6091
|
+
build: ({ cwd, mode }) => ({
|
|
6092
|
+
args: [
|
|
6093
|
+
'-p',
|
|
6094
|
+
'--add-dir', cwd,
|
|
6095
|
+
'--output-format', 'stream-json',
|
|
6096
|
+
'--verbose',
|
|
6097
|
+
'--permission-mode', mode === 'investigate' ? 'plan' : 'acceptEdits'
|
|
6098
|
+
]
|
|
6099
|
+
})
|
|
6100
|
+
},
|
|
6101
|
+
gemini: {
|
|
6102
|
+
binary: 'gemini',
|
|
6103
|
+
// gemini's -p takes the prompt as a value and APPENDS it to stdin, so the brief still travels on
|
|
6104
|
+
// stdin and -p carries only the instruction to act on it.
|
|
6105
|
+
build: ({ mode }) => ({
|
|
6106
|
+
args: [
|
|
6107
|
+
'-p', 'Work the support ticket described above, following its instructions exactly.',
|
|
6108
|
+
'--approval-mode', mode === 'investigate' ? 'plan' : 'auto_edit',
|
|
6109
|
+
'--output-format', 'stream-json'
|
|
6110
|
+
]
|
|
6111
|
+
})
|
|
5457
6112
|
}
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
6113
|
+
};
|
|
6114
|
+
|
|
6115
|
+
/**
|
|
6116
|
+
* Which supported agent IS this process, judged from the anchor's command line.
|
|
6117
|
+
*
|
|
6118
|
+
* The binary name leads: an agent's command line routinely mentions another agent's name in a path
|
|
6119
|
+
* (`/Users/me/.codex/skills/...` while running claude), so a plain substring scan over the whole line
|
|
6120
|
+
* picks the wrong one. Returns null for an anchor that is not a supported worker — `cursor-agent` and
|
|
6121
|
+
* `aider` can anchor a session but cannot be launched as workers — so the caller falls back rather
|
|
6122
|
+
* than guessing.
|
|
6123
|
+
*/
|
|
6124
|
+
function detectAgentKind(command) {
|
|
6125
|
+
const line = String(command || '').trim();
|
|
6126
|
+
if (!line) {
|
|
6127
|
+
return null;
|
|
5463
6128
|
}
|
|
6129
|
+
const binary = path.basename(line.split(/\s+/)[0] || '').toLowerCase();
|
|
6130
|
+
const exact = SUPPORTED_AGENTS.find((agent) => binary === agent || binary.startsWith(agent + '-'));
|
|
6131
|
+
if (exact) {
|
|
6132
|
+
return exact;
|
|
6133
|
+
}
|
|
6134
|
+
return SUPPORTED_AGENTS.find((agent) => new RegExp('(^|[/\\s])' + agent + '($|[\\s])', 'i').test(line)) || null;
|
|
5464
6135
|
}
|
|
5465
6136
|
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
6137
|
+
/**
|
|
6138
|
+
* Which agent a worker should be launched as.
|
|
6139
|
+
*
|
|
6140
|
+
* Precedence: an explicit flag, then WHAT THIS SESSION ACTUALLY IS, then the configured preference,
|
|
6141
|
+
* then the default. The anchor beats the config because it is specific to this session, and because
|
|
6142
|
+
* the alternative is the surprise this ordering exists to remove: `agent serve` in a Claude tab
|
|
6143
|
+
* labelling itself `claude@repo` while launching codex workers, since the label already detected
|
|
6144
|
+
* from the anchor and only the worker choice read the config. One question, one answer.
|
|
6145
|
+
*/
|
|
6146
|
+
function workerAgentType(value, anchor = null) {
|
|
6147
|
+
return resolveWorkerAgent(value, anchor).agent;
|
|
5469
6148
|
}
|
|
5470
6149
|
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
6150
|
+
/**
|
|
6151
|
+
* The worker choice AND why it was made.
|
|
6152
|
+
*
|
|
6153
|
+
* The reason is reported, not just the value, because the intended host for a supervisor is a plain
|
|
6154
|
+
* terminal tab — where nothing in the process tree is an AI agent, so the choice comes from a config
|
|
6155
|
+
* file the operator may have set months ago and forgotten. "codex" on its own is indistinguishable
|
|
6156
|
+
* from a default; "codex (from your ~/.remits-cli/config.json)" is a fact they can act on.
|
|
6157
|
+
*/
|
|
6158
|
+
function resolveWorkerAgent(value, anchor = null) {
|
|
6159
|
+
const candidate = String(value || '').trim().toLowerCase();
|
|
6160
|
+
if (SUPPORTED_AGENTS.includes(candidate)) {
|
|
6161
|
+
return { agent: candidate, source: 'flag', why: 'you passed --worker-agent' };
|
|
5475
6162
|
}
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
return
|
|
5479
|
-
} catch (err) {
|
|
5480
|
-
// EPERM means the process exists but belongs to another user — still alive.
|
|
5481
|
-
return err && err.code === 'EPERM';
|
|
6163
|
+
const detected = detectAgentKind(anchor && anchor.command);
|
|
6164
|
+
if (detected) {
|
|
6165
|
+
return { agent: detected, source: 'session', why: 'this session is running ' + detected };
|
|
5482
6166
|
}
|
|
6167
|
+
const config = readConfig();
|
|
6168
|
+
if (SUPPORTED_AGENTS.includes(String(config.agent || '').toLowerCase())) {
|
|
6169
|
+
return {
|
|
6170
|
+
agent: String(config.agent).toLowerCase(),
|
|
6171
|
+
source: 'config',
|
|
6172
|
+
why: 'from ' + CONFIG_FILE + ' — override per run with --worker-agent'
|
|
6173
|
+
};
|
|
6174
|
+
}
|
|
6175
|
+
return {
|
|
6176
|
+
agent: DEFAULT_AGENT,
|
|
6177
|
+
source: 'default',
|
|
6178
|
+
why: 'nothing configured and no agent in this session — pass --worker-agent to choose'
|
|
6179
|
+
};
|
|
6180
|
+
}
|
|
6181
|
+
|
|
6182
|
+
/** Where the repository for an account is checked out on THIS machine, or null. */
|
|
6183
|
+
function repoDirectoryForAccount(accountId) {
|
|
6184
|
+
const wanted = Number(accountId);
|
|
6185
|
+
if (!Number.isFinite(wanted) || wanted <= 0) {
|
|
6186
|
+
return null;
|
|
6187
|
+
}
|
|
6188
|
+
const entry = listAccountRepoEntries(readAccountRepoIndex())
|
|
6189
|
+
.find((candidate) => Number(candidate.accountId) === wanted);
|
|
6190
|
+
if (!entry || !entry.directory || !fs.existsSync(entry.directory)) {
|
|
6191
|
+
return null;
|
|
6192
|
+
}
|
|
6193
|
+
return entry.directory;
|
|
5483
6194
|
}
|
|
5484
6195
|
|
|
5485
6196
|
/**
|
|
5486
|
-
* The
|
|
6197
|
+
* The working directory a ticket should be worked in.
|
|
5487
6198
|
*
|
|
5488
|
-
* The
|
|
5489
|
-
*
|
|
6199
|
+
* The implementation account leads when the ticket names one — a defect observed on a CLIENT is
|
|
6200
|
+
* almost always fixed in the PLATFORM/PRODUCT repo above it — then the ticket's own account. When
|
|
6201
|
+
* neither is checked out here the answer is null, and the brief says so explicitly instead of
|
|
6202
|
+
* letting the worker start in whatever directory the supervisor happened to inherit.
|
|
5490
6203
|
*/
|
|
5491
|
-
function
|
|
5492
|
-
const
|
|
5493
|
-
const
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
6204
|
+
function resolveTicketWorkingDirectory(ticket) {
|
|
6205
|
+
const facts = (ticket && ticket.briefFacts) || {};
|
|
6206
|
+
const candidates = [
|
|
6207
|
+
facts.repoAccountId,
|
|
6208
|
+
ticket && ticket.implementationAccountId,
|
|
6209
|
+
ticket && ticket.accountId
|
|
6210
|
+
];
|
|
6211
|
+
for (const candidate of candidates) {
|
|
6212
|
+
const dir = repoDirectoryForAccount(candidate);
|
|
6213
|
+
if (dir) {
|
|
6214
|
+
return dir;
|
|
5500
6215
|
}
|
|
5501
6216
|
}
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
5505
|
-
|
|
6217
|
+
return null;
|
|
6218
|
+
}
|
|
6219
|
+
|
|
6220
|
+
/**
|
|
6221
|
+
* Turn a worker's streamed output into something an operator can read in one line.
|
|
6222
|
+
*
|
|
6223
|
+
* All three agents emit JSONL, and all three emit a different shape, so this reads defensively and
|
|
6224
|
+
* returns null for anything it does not recognise rather than guessing. A wrong activity line is
|
|
6225
|
+
* worse than none: the dashboard is the only window into a headless run, and it has to be trusted.
|
|
6226
|
+
*/
|
|
6227
|
+
function summarizeWorkerEvent(line) {
|
|
6228
|
+
let event = null;
|
|
6229
|
+
try {
|
|
6230
|
+
event = JSON.parse(line);
|
|
6231
|
+
} catch (_) {
|
|
6232
|
+
return null;
|
|
6233
|
+
}
|
|
6234
|
+
if (!event || typeof event !== 'object') {
|
|
6235
|
+
return null;
|
|
6236
|
+
}
|
|
6237
|
+
|
|
6238
|
+
// codex exec --json. The item's kind is spelled `type` on the versions observed and `item_type` in
|
|
6239
|
+
// some published examples, so read both rather than betting on one — a miss here is silent, and the
|
|
6240
|
+
// whole run then reports "nothing reported yet" while working perfectly.
|
|
6241
|
+
if (event.type === 'item.completed' && event.item) {
|
|
6242
|
+
const item = event.item;
|
|
6243
|
+
const kind = item.item_type || item.type;
|
|
6244
|
+
if (kind === 'command_execution' && item.command) {
|
|
6245
|
+
return 'ran: ' + String(item.command).slice(0, 120);
|
|
6246
|
+
}
|
|
6247
|
+
if (kind === 'file_change') {
|
|
6248
|
+
return 'edited files';
|
|
6249
|
+
}
|
|
6250
|
+
if ((kind === 'reasoning' || kind === 'agent_message') && item.text) {
|
|
6251
|
+
return String(item.text).split('\n')[0].slice(0, 120);
|
|
6252
|
+
}
|
|
6253
|
+
}
|
|
6254
|
+
|
|
6255
|
+
// claude / gemini stream-json
|
|
6256
|
+
if (event.type === 'assistant' && event.message && Array.isArray(event.message.content)) {
|
|
6257
|
+
for (const block of event.message.content) {
|
|
6258
|
+
if (block && block.type === 'tool_use' && block.name) {
|
|
6259
|
+
const input = block.input || {};
|
|
6260
|
+
const detail = input.command || input.file_path || input.pattern || input.path || '';
|
|
6261
|
+
return String(block.name + (detail ? ': ' + detail : '')).slice(0, 120);
|
|
6262
|
+
}
|
|
6263
|
+
if (block && block.type === 'text' && block.text) {
|
|
6264
|
+
return String(block.text).split('\n')[0].slice(0, 120);
|
|
6265
|
+
}
|
|
6266
|
+
}
|
|
6267
|
+
}
|
|
6268
|
+
if (event.type === 'result' && event.subtype) {
|
|
6269
|
+
return 'finishing: ' + event.subtype;
|
|
6270
|
+
}
|
|
6271
|
+
return null;
|
|
6272
|
+
}
|
|
6273
|
+
|
|
6274
|
+
|
|
6275
|
+
function readAgentStore() {
|
|
6276
|
+
if (!fs.existsSync(AGENT_STATE_FILE)) {
|
|
6277
|
+
return { agents: {} };
|
|
6278
|
+
}
|
|
6279
|
+
try {
|
|
6280
|
+
const data = JSON.parse(fs.readFileSync(AGENT_STATE_FILE, 'utf8'));
|
|
6281
|
+
return data && typeof data === 'object' && data.agents ? data : { agents: {} };
|
|
6282
|
+
} catch (_) {
|
|
6283
|
+
return { agents: {} };
|
|
6284
|
+
}
|
|
6285
|
+
}
|
|
6286
|
+
|
|
6287
|
+
function writeAgentStore(store) {
|
|
6288
|
+
ensureSessionDir();
|
|
6289
|
+
atomicWriteFile(AGENT_STATE_FILE, JSON.stringify(store || { agents: {} }, null, 2));
|
|
6290
|
+
}
|
|
6291
|
+
|
|
6292
|
+
function processIsAlive(pid) {
|
|
6293
|
+
const numeric = Number(pid);
|
|
6294
|
+
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
6295
|
+
return false;
|
|
6296
|
+
}
|
|
6297
|
+
try {
|
|
6298
|
+
process.kill(numeric, 0);
|
|
6299
|
+
return true;
|
|
6300
|
+
} catch (err) {
|
|
6301
|
+
// EPERM means the process exists but belongs to another user — still alive.
|
|
6302
|
+
return err && err.code === 'EPERM';
|
|
6303
|
+
}
|
|
6304
|
+
}
|
|
6305
|
+
|
|
6306
|
+
/**
|
|
6307
|
+
* The locally-known agent sessions, dropping any whose anchor process has gone.
|
|
6308
|
+
*
|
|
6309
|
+
* The store is a local convenience so a follow-up command in the same tab resolves the same agent
|
|
6310
|
+
* without being told its id. It is never the source of truth: the platform's registry is.
|
|
6311
|
+
*/
|
|
6312
|
+
function listLocalAgents() {
|
|
6313
|
+
const store = readAgentStore();
|
|
6314
|
+
const alive = {};
|
|
6315
|
+
let changed = false;
|
|
6316
|
+
for (const [agentId, entry] of Object.entries(store.agents || {})) {
|
|
6317
|
+
if (entry && processIsAlive(entry.anchorPid)) {
|
|
6318
|
+
alive[agentId] = entry;
|
|
6319
|
+
} else {
|
|
6320
|
+
changed = true;
|
|
6321
|
+
}
|
|
6322
|
+
}
|
|
6323
|
+
if (changed) {
|
|
6324
|
+
writeAgentStore({ agents: alive });
|
|
6325
|
+
}
|
|
6326
|
+
return Object.values(alive).sort((a, b) => Number(b.registeredAtMs || 0) - Number(a.registeredAtMs || 0));
|
|
5506
6327
|
}
|
|
5507
6328
|
|
|
5508
6329
|
function rememberLocalAgent(entry) {
|
|
@@ -5582,11 +6403,9 @@ function resolveAgentAnchorPid() {
|
|
|
5582
6403
|
|
|
5583
6404
|
function agentLabelDefault(anchor, cwd) {
|
|
5584
6405
|
const repo = path.basename(cwd || process.cwd());
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
: getPreferredAgent();
|
|
5589
|
-
return kind + '@' + repo;
|
|
6406
|
+
// Same resolution as the WORKER choice, deliberately: a label that says one agent while the
|
|
6407
|
+
// supervisor launches another is worse than no label, because it is the only thing an operator sees.
|
|
6408
|
+
return workerAgentType(null, anchor) + '@' + repo;
|
|
5590
6409
|
}
|
|
5591
6410
|
|
|
5592
6411
|
/**
|
|
@@ -5617,13 +6436,37 @@ function resolveAgentId(flags, options = {}) {
|
|
|
5617
6436
|
);
|
|
5618
6437
|
}
|
|
5619
6438
|
|
|
6439
|
+
/**
|
|
6440
|
+
* The host and lane a support agent registers against when it is told nothing.
|
|
6441
|
+
*
|
|
6442
|
+
* An agent works REAL support tickets, and it must be startable from any directory on the machine —
|
|
6443
|
+
* not only from inside an account repo. So it defaults to the platform host the CLI ships with
|
|
6444
|
+
* (`REMITS_BASE_URL` when set) and to the `prod` lane.
|
|
6445
|
+
*
|
|
6446
|
+
* That is a deliberate exception to the CLI's test-first default, which exists to stop a developer
|
|
6447
|
+
* ITERATING from touching live data. Registering presence mutates no business data, and a test-lane
|
|
6448
|
+
* agent is silently useless for support: it registers, appears online, and can never be routed a
|
|
6449
|
+
* production ticket. Ask for `--data-mode test` when you want a fixture agent.
|
|
6450
|
+
*/
|
|
6451
|
+
function agentDefaults(flags, entry) {
|
|
6452
|
+
return {
|
|
6453
|
+
baseUrl: normalizeBaseUrl(
|
|
6454
|
+
(flags && flags['base-url']) || (entry && entry.baseUrl) || DEFAULT_BASE_URL),
|
|
6455
|
+
dataMode: normalizeDataMode(
|
|
6456
|
+
(flags && (flags['data-mode'] || flags.dataMode)) || (entry && entry.dataMode) || 'prod')
|
|
6457
|
+
};
|
|
6458
|
+
}
|
|
6459
|
+
|
|
5620
6460
|
function agentSessionFor(flags, entry) {
|
|
5621
6461
|
const cwd = (entry && entry.cwd) || process.cwd();
|
|
6462
|
+
const defaults = agentDefaults(flags, entry);
|
|
5622
6463
|
const context = resolveSessionContext(cwd, Object.assign({}, flags, {
|
|
5623
|
-
'base-url':
|
|
6464
|
+
'base-url': defaults.baseUrl,
|
|
6465
|
+
'data-mode': defaults.dataMode
|
|
5624
6466
|
}));
|
|
5625
6467
|
if (!context.session || !context.session.token) {
|
|
5626
|
-
throw new Error('No authenticated session
|
|
6468
|
+
throw new Error('No authenticated session for ' + defaults.baseUrl +
|
|
6469
|
+
'. Run: remits-cli auth --base-url ' + defaults.baseUrl);
|
|
5627
6470
|
}
|
|
5628
6471
|
// resolveSessionContext names it `resolvedBaseUrl` and resolves no data lane, so normalize both here
|
|
5629
6472
|
// rather than letting each caller re-derive them. Destructuring `{ baseUrl, dataMode }` off it yields
|
|
@@ -5631,8 +6474,8 @@ function agentSessionFor(flags, entry) {
|
|
|
5631
6474
|
return {
|
|
5632
6475
|
session: context.session,
|
|
5633
6476
|
accountId: context.accountId,
|
|
5634
|
-
baseUrl:
|
|
5635
|
-
dataMode:
|
|
6477
|
+
baseUrl: defaults.baseUrl,
|
|
6478
|
+
dataMode: defaults.dataMode,
|
|
5636
6479
|
sessionResolutionWarning: context.sessionResolutionWarning
|
|
5637
6480
|
};
|
|
5638
6481
|
}
|
|
@@ -5693,9 +6536,19 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
5693
6536
|
host: os.hostname(),
|
|
5694
6537
|
pid: anchor.pid,
|
|
5695
6538
|
cwd,
|
|
6539
|
+
// WHERE this session works, not just who it is. One account can now have several checkouts at once
|
|
6540
|
+
// (git worktrees) on different branches staging into different workspace lanes, so the account alone
|
|
6541
|
+
// no longer identifies the tree a worker would edit — and the edit lease refusal, the work map and the
|
|
6542
|
+
// ticket claim all report these back.
|
|
6543
|
+
branch: currentBranch(cwd) || undefined,
|
|
6544
|
+
workspace: resolveWorkspace(cwd, flags) || undefined,
|
|
5696
6545
|
dataMode,
|
|
5697
6546
|
accountId: Number(session.accountId) || undefined,
|
|
5698
6547
|
accounts: agentAccountClaims(cwd),
|
|
6548
|
+
// Capacity is how many workers this session can actually RUN, which only this side knows. The
|
|
6549
|
+
// platform stops routing past it, so a supervisor at capacity is not handed work it cannot start.
|
|
6550
|
+
maxConcurrent: options.serve ? options.serve.maxConcurrent : (flags['max-concurrent'] ? parsePositiveInt(flags['max-concurrent'], 1) : undefined),
|
|
6551
|
+
autonomous: options.serve ? true : undefined,
|
|
5699
6552
|
state: flags.state && AGENT_STATES.includes(String(flags.state)) ? String(flags.state) : 'idle',
|
|
5700
6553
|
activity: flags.activity || null,
|
|
5701
6554
|
source: flags.source || 'repo'
|
|
@@ -5712,6 +6565,9 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
5712
6565
|
cwd,
|
|
5713
6566
|
baseUrl,
|
|
5714
6567
|
dataMode,
|
|
6568
|
+
// The accounts the platform ACCEPTED, so `agent list` can scope itself to the work this session
|
|
6569
|
+
// can actually be routed rather than to whichever session happened to be most recent.
|
|
6570
|
+
accountIds: response.accountIds || [],
|
|
5715
6571
|
accountId: Number(session.accountId) || null,
|
|
5716
6572
|
anchorPid: anchor.pid,
|
|
5717
6573
|
anchorSource: anchor.source,
|
|
@@ -5721,7 +6577,18 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
5721
6577
|
// A re-register from inside the heartbeat must not fork a second heartbeat: they would both keep
|
|
5722
6578
|
// re-registering, and the daemons multiply for as long as the platform stays unreachable.
|
|
5723
6579
|
if (options.spawnHeartbeat !== false) {
|
|
5724
|
-
|
|
6580
|
+
// Remembered so `agent release` can actually STOP it. Without the pid, deregistering only removes
|
|
6581
|
+
// the platform record — and the daemon's very next poll sees registered:false and re-registers,
|
|
6582
|
+
// so the session comes straight back and, when serving, resumes launching workers.
|
|
6583
|
+
const daemonPid = startAgentHeartbeatDaemon(entry, options.serve || null);
|
|
6584
|
+
entry.daemonPid = daemonPid;
|
|
6585
|
+
entry.serving = !!options.serve;
|
|
6586
|
+
if (options.serve) {
|
|
6587
|
+
entry.workerAgent = options.serve.workerAgent;
|
|
6588
|
+
entry.mode = options.serve.mode;
|
|
6589
|
+
entry.maxConcurrent = options.serve.maxConcurrent;
|
|
6590
|
+
}
|
|
6591
|
+
rememberLocalAgent(entry);
|
|
5725
6592
|
}
|
|
5726
6593
|
|
|
5727
6594
|
if (flagEnabled(flags.json)) {
|
|
@@ -5729,19 +6596,158 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
5729
6596
|
return;
|
|
5730
6597
|
}
|
|
5731
6598
|
|
|
5732
|
-
|
|
6599
|
+
const accepted = response.accountIds || [];
|
|
6600
|
+
console.log('Registered as a support agent: ' + response.agentId);
|
|
5733
6601
|
console.log(' label ' + payload.label);
|
|
5734
|
-
console.log('
|
|
5735
|
-
console.log('
|
|
6602
|
+
console.log(' platform ' + baseUrl);
|
|
6603
|
+
console.log(' data lane ' + dataMode + (dataMode === 'prod' ? ' (live support tickets)' : ' (fixtures only)'));
|
|
6604
|
+
console.log(' accounts ' + accepted.length + ' account(s) this session can be routed work for');
|
|
6605
|
+
console.log(' ' + accepted.join(', '));
|
|
5736
6606
|
console.log(' heartbeat every ' + Math.round(AGENT_HEARTBEAT_INTERVAL_MS / 1000) + 's, anchored to pid ' +
|
|
5737
|
-
anchor.pid + ' (' + anchor.source + ')');
|
|
6607
|
+
anchor.pid + ' (' + anchor.source + '), ends when this session ends');
|
|
5738
6608
|
if ((response.skippedAccounts || []).length) {
|
|
5739
6609
|
console.log(' refused ' + response.skippedAccounts
|
|
5740
6610
|
.map((entry) => entry.accountId + ' (' + entry.reason + ')').join(', '));
|
|
5741
6611
|
}
|
|
6612
|
+
if (dataMode !== 'prod') {
|
|
6613
|
+
console.log('');
|
|
6614
|
+
console.log('NOTE: a ' + dataMode + '-lane agent never receives production tickets. Re-register with');
|
|
6615
|
+
console.log(' --data-mode prod to take real support work.');
|
|
6616
|
+
}
|
|
5742
6617
|
console.log('');
|
|
5743
|
-
|
|
5744
|
-
|
|
6618
|
+
if (options.serve) {
|
|
6619
|
+
const serve = options.serve;
|
|
6620
|
+
console.log('AUTONOMOUS. A supervisor is now running in the background for this session.');
|
|
6621
|
+
console.log(' worker ' + serve.workerAgent + ' (' + serve.mode + ' mode)');
|
|
6622
|
+
console.log(' ' + serve.workerAgentWhy);
|
|
6623
|
+
console.log(' capacity ' + serve.maxConcurrent + ' ticket(s) at a time');
|
|
6624
|
+
console.log('');
|
|
6625
|
+
console.log('When a ticket is routed here it launches a fresh ' + serve.workerAgent + ' process for that');
|
|
6626
|
+
console.log('ticket, in that account\'s repo, and that process exits when the ticket is done — so no');
|
|
6627
|
+
console.log('session ever accumulates context across tickets.');
|
|
6628
|
+
console.log('');
|
|
6629
|
+
console.log(' remits-cli agent workers what is running right now');
|
|
6630
|
+
console.log(' remits-cli agent list who else is available');
|
|
6631
|
+
console.log(' remits-cli agent release stop serving and go offline');
|
|
6632
|
+
console.log('');
|
|
6633
|
+
console.log('You do not have to do anything else. This terminal is free.');
|
|
6634
|
+
return;
|
|
6635
|
+
}
|
|
6636
|
+
console.log('You are on duty, but nothing will start on its own. Either:');
|
|
6637
|
+
console.log('');
|
|
6638
|
+
console.log(' remits-cli agent serve # autonomous: a worker process per routed ticket');
|
|
6639
|
+
console.log('');
|
|
6640
|
+
console.log('or work the loop yourself:');
|
|
6641
|
+
console.log(' 1. remits-cli agent work --wait 600 # ask for work; returns as soon as any arrives');
|
|
6642
|
+
console.log(' 2. remits-cli agent status --state working --ticket <id> --activity "what you are doing"');
|
|
6643
|
+
console.log(' 3. work the ticket; keep step 2 current as you change what you are doing');
|
|
6644
|
+
console.log(' 4. close it out via remits-cli ticket (accept -> status -> complete, ask, or release)');
|
|
6645
|
+
console.log(' 5. remits-cli agent status --state idle # then go back to step 1');
|
|
6646
|
+
}
|
|
6647
|
+
|
|
6648
|
+
/**
|
|
6649
|
+
* Register this session AND start working tickets autonomously.
|
|
6650
|
+
*
|
|
6651
|
+
* <p>Returns immediately, on purpose. The caller is very often an AI agent inside an interactive
|
|
6652
|
+
* terminal, and a command that blocked would consume the session it was supposed to free — the
|
|
6653
|
+
* supervisor is detached precisely so the tab that started it can go and do something else, or
|
|
6654
|
+
* nothing at all.</p>
|
|
6655
|
+
*/
|
|
6656
|
+
async function agentServeCommand(flags) {
|
|
6657
|
+
const choice = resolveWorkerAgent(flags['worker-agent'] || flags.agent, resolveAgentAnchorPid());
|
|
6658
|
+
const workerAgent = choice.agent;
|
|
6659
|
+
const launcher = AGENT_LAUNCHERS[workerAgent];
|
|
6660
|
+
if (!commandExists(launcher.binary)) {
|
|
6661
|
+
throw new Error('Cannot serve with "' + workerAgent + '": the `' + launcher.binary +
|
|
6662
|
+
'` command is not on PATH. Install it, or pick another with --worker-agent ' +
|
|
6663
|
+
SUPPORTED_AGENTS.filter((a) => a !== workerAgent).join('|') + '.');
|
|
6664
|
+
}
|
|
6665
|
+
const mode = String(flags.mode || (flagEnabled(flags['investigate-only']) ? 'investigate' : 'edit'))
|
|
6666
|
+
.toLowerCase() === 'investigate' ? 'investigate' : 'edit';
|
|
6667
|
+
|
|
6668
|
+
return agentRegisterCommand(flags, {
|
|
6669
|
+
serve: {
|
|
6670
|
+
workerAgent,
|
|
6671
|
+
workerAgentWhy: choice.why,
|
|
6672
|
+
mode,
|
|
6673
|
+
maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1))
|
|
6674
|
+
}
|
|
6675
|
+
});
|
|
6676
|
+
}
|
|
6677
|
+
|
|
6678
|
+
/** Is this binary runnable on this machine? Checked before serving, not on the first ticket. */
|
|
6679
|
+
function commandExists(binary) {
|
|
6680
|
+
// `binary` only ever comes from AGENT_LAUNCHERS, so it is a fixed allowlist rather than user input.
|
|
6681
|
+
try {
|
|
6682
|
+
execFileSync('/bin/sh', ['-c', 'command -v ' + binary], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
6683
|
+
return true;
|
|
6684
|
+
} catch (_) {
|
|
6685
|
+
return false;
|
|
6686
|
+
}
|
|
6687
|
+
}
|
|
6688
|
+
|
|
6689
|
+
/**
|
|
6690
|
+
* Find this agent's daemon by looking for the process, when its pid was never recorded.
|
|
6691
|
+
*
|
|
6692
|
+
* `daemonPid` is only in the local store for agents registered by a build that writes it. An agent
|
|
6693
|
+
* registered by an EARLIER build has none — and that daemon is precisely the one that cannot be
|
|
6694
|
+
* stopped any other way: it is orphaned (its parent shell is long gone), it re-registers itself on
|
|
6695
|
+
* the next heartbeat after any deregister, and it predates `serve` so it responds to nothing else.
|
|
6696
|
+
* Its own command line carries the agentId, which is enough to find it.
|
|
6697
|
+
*/
|
|
6698
|
+
function findAgentDaemonPid(agentId) {
|
|
6699
|
+
if (!agentId) {
|
|
6700
|
+
return null;
|
|
6701
|
+
}
|
|
6702
|
+
try {
|
|
6703
|
+
const listing = execFileSync('ps', ['-ax', '-o', 'pid=,command='], {
|
|
6704
|
+
stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 8 * 1024 * 1024
|
|
6705
|
+
}).toString();
|
|
6706
|
+
for (const line of listing.split('\n')) {
|
|
6707
|
+
if (!line.includes('agent') || !line.includes('heartbeat-daemon') || !line.includes(agentId)) {
|
|
6708
|
+
continue;
|
|
6709
|
+
}
|
|
6710
|
+
const pid = Number((line.trim().match(/^(\d+)/) || [])[1]);
|
|
6711
|
+
// Never signal ourselves: `agent release` run from inside a daemon would kill the command.
|
|
6712
|
+
if (Number.isFinite(pid) && pid > 0 && pid !== process.pid && processIsAlive(pid)) {
|
|
6713
|
+
return pid;
|
|
6714
|
+
}
|
|
6715
|
+
}
|
|
6716
|
+
} catch (_) {
|
|
6717
|
+
// ps unavailable or restricted — fall back to the recorded pid only.
|
|
6718
|
+
}
|
|
6719
|
+
return null;
|
|
6720
|
+
}
|
|
6721
|
+
|
|
6722
|
+
/** What this session has running right now, and where to read each run's transcript. */
|
|
6723
|
+
async function agentWorkersCommand(flags) {
|
|
6724
|
+
const agentId = resolveAgentId(flags, { optional: true });
|
|
6725
|
+
if (!agentId) {
|
|
6726
|
+
console.log('No local agent is registered in this session. Run: remits-cli agent serve');
|
|
6727
|
+
return;
|
|
6728
|
+
}
|
|
6729
|
+
const workers = listWorkers(agentId);
|
|
6730
|
+
if (flagEnabled(flags.json)) {
|
|
6731
|
+
console.log(JSON.stringify({ agentId, workers }, null, 2));
|
|
6732
|
+
return;
|
|
6733
|
+
}
|
|
6734
|
+
if (!workers.length) {
|
|
6735
|
+
console.log('No ticket workers are running for ' + agentId + '.');
|
|
6736
|
+
return;
|
|
6737
|
+
}
|
|
6738
|
+
console.log(workers.length + ' worker(s) running:');
|
|
6739
|
+
for (const worker of workers) {
|
|
6740
|
+
const alive = worker.pid && processIsAlive(worker.pid);
|
|
6741
|
+
console.log('');
|
|
6742
|
+
console.log(' ticket ' + worker.ticketId + ' [' + worker.agentType + ', pid ' + worker.pid +
|
|
6743
|
+
(alive ? '' : ', NOT RUNNING') + ']');
|
|
6744
|
+
console.log(' ' + (worker.subject || '(no subject)'));
|
|
6745
|
+
console.log(' since ' + worker.startedAt + ' attempt ' + (worker.attempt || 1));
|
|
6746
|
+
console.log(' in ' + (worker.workingDirectory || '(no repo resolved)') +
|
|
6747
|
+
(worker.workspace ? ' workspace ' + worker.workspace : ''));
|
|
6748
|
+
console.log(' doing ' + (worker.activity || 'nothing reported yet'));
|
|
6749
|
+
console.log(' log ' + worker.logFile);
|
|
6750
|
+
}
|
|
5745
6751
|
}
|
|
5746
6752
|
|
|
5747
6753
|
function readCliVersion() {
|
|
@@ -5760,24 +6766,344 @@ function readCliVersion() {
|
|
|
5760
6766
|
* appeared online while it happened to be running remits-cli would be invisible for exactly the
|
|
5761
6767
|
* period it was busy working a ticket.
|
|
5762
6768
|
*/
|
|
5763
|
-
function startAgentHeartbeatDaemon(entry) {
|
|
6769
|
+
function startAgentHeartbeatDaemon(entry, serve = null) {
|
|
5764
6770
|
const { spawn } = require('child_process');
|
|
5765
|
-
const
|
|
6771
|
+
const args = [
|
|
5766
6772
|
__filename, 'agent', 'heartbeat-daemon',
|
|
5767
6773
|
'--agent-id', entry.agentId,
|
|
5768
6774
|
'--anchor-pid', String(entry.anchorPid),
|
|
5769
6775
|
'--base-url', entry.baseUrl,
|
|
5770
6776
|
'--cwd', entry.cwd
|
|
5771
|
-
]
|
|
6777
|
+
];
|
|
6778
|
+
if (serve) {
|
|
6779
|
+
args.push(
|
|
6780
|
+
'--serve', 'true',
|
|
6781
|
+
'--worker-agent', serve.workerAgent,
|
|
6782
|
+
'--mode', serve.mode,
|
|
6783
|
+
'--max-concurrent', String(serve.maxConcurrent));
|
|
6784
|
+
}
|
|
6785
|
+
const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', env: { ...process.env } });
|
|
5772
6786
|
child.unref();
|
|
5773
6787
|
appendGlobalActivityLog('agent', 'heartbeat.started', {
|
|
5774
6788
|
agentId: entry.agentId,
|
|
5775
6789
|
anchorPid: entry.anchorPid,
|
|
5776
|
-
heartbeatPid: child.pid
|
|
6790
|
+
heartbeatPid: child.pid,
|
|
6791
|
+
serving: !!serve,
|
|
6792
|
+
workerAgent: serve ? serve.workerAgent : null,
|
|
6793
|
+
mode: serve ? serve.mode : null,
|
|
6794
|
+
maxConcurrent: serve ? serve.maxConcurrent : null
|
|
5777
6795
|
});
|
|
5778
6796
|
return child.pid;
|
|
5779
6797
|
}
|
|
5780
6798
|
|
|
6799
|
+
const WORKER_STATE_FILE = path.join(SESSION_DIR, 'workers.json');
|
|
6800
|
+
|
|
6801
|
+
function readWorkerStore() {
|
|
6802
|
+
if (!fs.existsSync(WORKER_STATE_FILE)) {
|
|
6803
|
+
return { agents: {} };
|
|
6804
|
+
}
|
|
6805
|
+
try {
|
|
6806
|
+
const data = JSON.parse(fs.readFileSync(WORKER_STATE_FILE, 'utf8'));
|
|
6807
|
+
return data && typeof data === 'object' && data.agents ? data : { agents: {} };
|
|
6808
|
+
} catch (_) {
|
|
6809
|
+
return { agents: {} };
|
|
6810
|
+
}
|
|
6811
|
+
}
|
|
6812
|
+
|
|
6813
|
+
function writeWorkerStore(store) {
|
|
6814
|
+
ensureSessionDir();
|
|
6815
|
+
atomicWriteFile(WORKER_STATE_FILE, JSON.stringify(store || { agents: {} }, null, 2));
|
|
6816
|
+
}
|
|
6817
|
+
|
|
6818
|
+
/**
|
|
6819
|
+
* The workers this agent has running, as a file.
|
|
6820
|
+
*
|
|
6821
|
+
* In memory would be enough for the supervisor itself; on disk is what lets `remits-cli agent
|
|
6822
|
+
* workers` answer from another process, and what lets a restarted supervisor find the claims a
|
|
6823
|
+
* killed one left behind and drop them instead of waiting out the TTL.
|
|
6824
|
+
*/
|
|
6825
|
+
function listWorkers(agentId) {
|
|
6826
|
+
const store = readWorkerStore();
|
|
6827
|
+
return Object.values((store.agents || {})[agentId] || {});
|
|
6828
|
+
}
|
|
6829
|
+
|
|
6830
|
+
function recordWorker(agentId, ticketId, patch) {
|
|
6831
|
+
const store = readWorkerStore();
|
|
6832
|
+
store.agents = store.agents || {};
|
|
6833
|
+
store.agents[agentId] = store.agents[agentId] || {};
|
|
6834
|
+
const existing = store.agents[agentId][ticketId] || {};
|
|
6835
|
+
store.agents[agentId][ticketId] = { ...existing, ...patch, ticketId };
|
|
6836
|
+
writeWorkerStore(store);
|
|
6837
|
+
return store.agents[agentId][ticketId];
|
|
6838
|
+
}
|
|
6839
|
+
|
|
6840
|
+
function clearWorker(agentId, ticketId) {
|
|
6841
|
+
const store = readWorkerStore();
|
|
6842
|
+
if (store.agents && store.agents[agentId] && store.agents[agentId][ticketId]) {
|
|
6843
|
+
delete store.agents[agentId][ticketId];
|
|
6844
|
+
writeWorkerStore(store);
|
|
6845
|
+
}
|
|
6846
|
+
}
|
|
6847
|
+
|
|
6848
|
+
/**
|
|
6849
|
+
* Attempts are counted on disk, per ticket, and survive the supervisor.
|
|
6850
|
+
*
|
|
6851
|
+
* Without this a ticket that reliably kills its worker is retried forever: the claim drops on exit,
|
|
6852
|
+
* the next poll sees it routed and unclaimed, and a fresh process starts into the same wall. The
|
|
6853
|
+
* counter is what turns that into a bounded retry ending in an honest release.
|
|
6854
|
+
*/
|
|
6855
|
+
function workerAttemptCount(agentId, ticketId) {
|
|
6856
|
+
const store = readWorkerStore();
|
|
6857
|
+
const attempts = ((store.attempts || {})[agentId] || {})[ticketId];
|
|
6858
|
+
return Number(attempts) || 0;
|
|
6859
|
+
}
|
|
6860
|
+
|
|
6861
|
+
function recordWorkerAttempt(agentId, ticketId) {
|
|
6862
|
+
const store = readWorkerStore();
|
|
6863
|
+
store.attempts = store.attempts || {};
|
|
6864
|
+
store.attempts[agentId] = store.attempts[agentId] || {};
|
|
6865
|
+
const next = (Number(store.attempts[agentId][ticketId]) || 0) + 1;
|
|
6866
|
+
store.attempts[agentId][ticketId] = next;
|
|
6867
|
+
writeWorkerStore(store);
|
|
6868
|
+
return next;
|
|
6869
|
+
}
|
|
6870
|
+
|
|
6871
|
+
function clearWorkerAttempts(agentId, ticketId) {
|
|
6872
|
+
const store = readWorkerStore();
|
|
6873
|
+
if (store.attempts && store.attempts[agentId] && store.attempts[agentId][ticketId] != null) {
|
|
6874
|
+
delete store.attempts[agentId][ticketId];
|
|
6875
|
+
writeWorkerStore(store);
|
|
6876
|
+
}
|
|
6877
|
+
}
|
|
6878
|
+
|
|
6879
|
+
/** Drop a claim on the platform. Best-effort: the claim expires on its own if this never lands. */
|
|
6880
|
+
async function releaseTicketClaim(context, agentId, ticket, options = {}) {
|
|
6881
|
+
try {
|
|
6882
|
+
const response = await buildAxios(context.baseUrl, context.session.token, 20000)
|
|
6883
|
+
.post('/cli/ticketClaim', {
|
|
6884
|
+
action: 'unclaim',
|
|
6885
|
+
agentId,
|
|
6886
|
+
ticketId: ticket.ticketId || ticket.id,
|
|
6887
|
+
accountId: Number(ticket.accountId),
|
|
6888
|
+
outcome: options.outcome || null,
|
|
6889
|
+
notes: options.notes || null,
|
|
6890
|
+
release: options.release === true,
|
|
6891
|
+
unroute: options.unroute === true
|
|
6892
|
+
}).then((r) => r.data);
|
|
6893
|
+
return response || null;
|
|
6894
|
+
} catch (err) {
|
|
6895
|
+
appendGlobalActivityLog('agent', 'worker.unclaim_failed', {
|
|
6896
|
+
agentId, ticketId: ticket.ticketId || ticket.id, error: describeError(err)
|
|
6897
|
+
}, 'error');
|
|
6898
|
+
return null;
|
|
6899
|
+
}
|
|
6900
|
+
}
|
|
6901
|
+
|
|
6902
|
+
async function postAgentStatus(context, agentId, update) {
|
|
6903
|
+
try {
|
|
6904
|
+
await buildAxios(context.baseUrl, context.session.token, 10000)
|
|
6905
|
+
.post('/cli/agentHeartbeat', { agentId, ...update });
|
|
6906
|
+
} catch (_) {
|
|
6907
|
+
// Status is commentary. Losing a line of it must never interrupt a run.
|
|
6908
|
+
}
|
|
6909
|
+
}
|
|
6910
|
+
|
|
6911
|
+
/**
|
|
6912
|
+
* Launch one headless agent process for one ticket, and see it through to its exit.
|
|
6913
|
+
*
|
|
6914
|
+
* The brief comes from the platform ({@code SupportTicketBrief}) so all three agent types are told
|
|
6915
|
+
* the same thing; only the working directory is resolved here, because only this machine knows
|
|
6916
|
+
* where the account is checked out.
|
|
6917
|
+
*/
|
|
6918
|
+
function spawnTicketWorker(context, ctx, ticket) {
|
|
6919
|
+
const { spawn } = require('child_process');
|
|
6920
|
+
const agentId = ctx.agentId;
|
|
6921
|
+
const ticketId = String(ticket.id || ticket.ticketId);
|
|
6922
|
+
const agentType = ctx.workerAgent;
|
|
6923
|
+
const launcher = AGENT_LAUNCHERS[agentType];
|
|
6924
|
+
const runId = ctx.runId + '-' + ticketId;
|
|
6925
|
+
|
|
6926
|
+
if (!launcher) {
|
|
6927
|
+
appendGlobalActivityLog('agent', 'worker.unsupported_agent', { agentId, ticketId, agentType }, 'error');
|
|
6928
|
+
return { pid: null, done: Promise.resolve({ ticketId, ok: false, reason: 'unsupported_agent' }) };
|
|
6929
|
+
}
|
|
6930
|
+
|
|
6931
|
+
const workingDirectory = resolveTicketWorkingDirectory(ticket);
|
|
6932
|
+
// The brief is rendered server-side without the local path (the platform cannot know it), so the
|
|
6933
|
+
// resolved directory is appended here. When nothing resolved, the server-rendered brief already
|
|
6934
|
+
// tells the worker to find it rather than guess — do not paper over that with a default.
|
|
6935
|
+
// The staging lane is set BY THIS FUNCTION (REMITS_WORKSPACE below) and the worker has no other way to
|
|
6936
|
+
// learn it: `components status` would show it a lane it never chose, and anything it reports about "the
|
|
6937
|
+
// staged code" is ambiguous until it knows which lane it means. Telling it is one line; making it guess
|
|
6938
|
+
// is the whole class of defect the lane exists to remove.
|
|
6939
|
+
const workerWorkspace = 'ticket-' + ticketId;
|
|
6940
|
+
const brief = String(ticket.brief || '') + (workingDirectory
|
|
6941
|
+
? '\n\n## Resolved working directory\n\n`' + workingDirectory + '`\n'
|
|
6942
|
+
: '') +
|
|
6943
|
+
'\n\n## Your staging lane\n\n' +
|
|
6944
|
+
'Every `remits-cli` command you run stages into the **`' + workerWorkspace + '`** workspace lane, not the\n' +
|
|
6945
|
+
'shared one. This is set for you; you do not need to pass `--workspace`. It means your staged components\n' +
|
|
6946
|
+
'are isolated from anyone else working this repository, and it also means a person looking at the shared\n' +
|
|
6947
|
+
'lane will not see your changes — so say which lane you staged into when you report what you verified.\n';
|
|
6948
|
+
|
|
6949
|
+
fs.mkdirSync(WORKER_LOG_DIR, { recursive: true });
|
|
6950
|
+
const logFile = path.join(WORKER_LOG_DIR, ticketId + '-' + Date.now() + '.log');
|
|
6951
|
+
const lastMessageFile = path.join(WORKER_LOG_DIR, ticketId + '-' + Date.now() + '.result.txt');
|
|
6952
|
+
// The sandbox and the brief MUST agree, and they used not to: `--mode investigate` set the launcher's
|
|
6953
|
+
// sandbox while the server-rendered brief, never told, said "edit and verify freely" — so a read-only
|
|
6954
|
+
// worker spent its run fighting a restriction nobody had mentioned. The platform now returns the phase
|
|
6955
|
+
// it derived from the repository's edit lease, and that is the authority. The session's own --mode can
|
|
6956
|
+
// only narrow it: an operator who asked for a read-only agent gets one even when the lease is free.
|
|
6957
|
+
const effectiveMode = (ctx.mode === 'investigate' || ticket.phase === 'investigating')
|
|
6958
|
+
? 'investigate'
|
|
6959
|
+
: 'edit';
|
|
6960
|
+
const built = launcher.build({
|
|
6961
|
+
cwd: workingDirectory || ctx.cwd,
|
|
6962
|
+
mode: effectiveMode,
|
|
6963
|
+
lastMessageFile: agentType === 'codex' ? lastMessageFile : null
|
|
6964
|
+
});
|
|
6965
|
+
|
|
6966
|
+
const attempt = recordWorkerAttempt(agentId, ticketId);
|
|
6967
|
+
const child = spawn(launcher.binary, built.args, {
|
|
6968
|
+
cwd: workingDirectory || ctx.cwd,
|
|
6969
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
6970
|
+
env: {
|
|
6971
|
+
...process.env,
|
|
6972
|
+
// A worker must never be mistaken for its supervisor. Without this a `remits-cli agent status`
|
|
6973
|
+
// run from inside the worker would resolve ambiguously against the local store.
|
|
6974
|
+
REMITS_AGENT_ID: agentId,
|
|
6975
|
+
REMITS_SUPPORT_TICKET_ID: ticketId,
|
|
6976
|
+
REMITS_WORKER_RUN_ID: runId,
|
|
6977
|
+
// Give the worker its OWN staging lane.
|
|
6978
|
+
//
|
|
6979
|
+
// The edit LEASE already serializes who may edit a repo, and this does not replace it — a lease
|
|
6980
|
+
// protects the working tree, which two processes editing the same files would corrupt regardless of
|
|
6981
|
+
// staging. What the lease does NOT cover is everyone else pointed at the same directory: a developer
|
|
6982
|
+
// iterating in that repo, or a read-only investigating worker running a diagnostic. They all shared
|
|
6983
|
+
// one staging lane with the editing worker, and `components stage` uploads the whole repo and
|
|
6984
|
+
// REPLACES the lane — so a worker staging mid-edit made the human's next test run execute the
|
|
6985
|
+
// worker's half-finished code, and vice versa.
|
|
6986
|
+
//
|
|
6987
|
+
// Env var rather than a flag because the worker is an arbitrary coding agent running arbitrary
|
|
6988
|
+
// remits-cli commands; only the environment reaches all of them. An explicit --workspace on a
|
|
6989
|
+
// command still wins, and `.remits-cli/workspace` in the repo does not override it.
|
|
6990
|
+
REMITS_WORKSPACE: workerWorkspace
|
|
6991
|
+
}
|
|
6992
|
+
});
|
|
6993
|
+
|
|
6994
|
+
recordWorker(agentId, ticketId, {
|
|
6995
|
+
pid: child.pid,
|
|
6996
|
+
runId,
|
|
6997
|
+
// The lane this worker was launched into. An operator reading `agent workers` otherwise has to know
|
|
6998
|
+
// the naming convention to work out where its staged components went.
|
|
6999
|
+
workspace: workerWorkspace,
|
|
7000
|
+
attempt,
|
|
7001
|
+
agentType,
|
|
7002
|
+
startedAt: new Date().toISOString(),
|
|
7003
|
+
workingDirectory: workingDirectory || null,
|
|
7004
|
+
accountId: ticket.accountId || null,
|
|
7005
|
+
subject: ticket.subject || null,
|
|
7006
|
+
logFile
|
|
7007
|
+
});
|
|
7008
|
+
appendGlobalActivityLog('agent', 'worker.started', {
|
|
7009
|
+
agentId, ticketId, agentType, pid: child.pid, attempt, workingDirectory: workingDirectory || null,
|
|
7010
|
+
// Which phase a run started in is the first thing anyone asks when its output looks thin, so it is
|
|
7011
|
+
// on the record rather than inferable from a sandbox flag nobody logged.
|
|
7012
|
+
phase: effectiveMode === 'investigate' ? 'investigating' : 'editing',
|
|
7013
|
+
editLease: ticket.editLease ? { agentId: ticket.editLease.agentId, ticketId: ticket.editLease.ticketId } : null
|
|
7014
|
+
});
|
|
7015
|
+
if (ticket.phase === 'investigating' && ticket.editLeaseMessage) {
|
|
7016
|
+
appendGlobalActivityLog('agent', 'worker.read_only', {
|
|
7017
|
+
agentId, ticketId, message: ticket.editLeaseMessage
|
|
7018
|
+
});
|
|
7019
|
+
}
|
|
7020
|
+
|
|
7021
|
+
const logStream = fs.createWriteStream(logFile, { flags: 'a' });
|
|
7022
|
+
let logBytes = 0;
|
|
7023
|
+
let lastStatusAtMs = 0;
|
|
7024
|
+
let buffered = '';
|
|
7025
|
+
|
|
7026
|
+
const consume = (chunk) => {
|
|
7027
|
+
const text = chunk.toString();
|
|
7028
|
+
if (logBytes < AGENT_WORKER_MAX_LOG_BYTES) {
|
|
7029
|
+
logBytes += text.length;
|
|
7030
|
+
logStream.write(text);
|
|
7031
|
+
}
|
|
7032
|
+
buffered += text;
|
|
7033
|
+
const lines = buffered.split('\n');
|
|
7034
|
+
buffered = lines.pop();
|
|
7035
|
+
for (const line of lines) {
|
|
7036
|
+
if (!line.trim()) continue;
|
|
7037
|
+
const summary = summarizeWorkerEvent(line);
|
|
7038
|
+
if (!summary) continue;
|
|
7039
|
+
const nowMs = Date.now();
|
|
7040
|
+
if (nowMs - lastStatusAtMs < AGENT_WORKER_STATUS_THROTTLE_MS) continue;
|
|
7041
|
+
lastStatusAtMs = nowMs;
|
|
7042
|
+
recordWorker(agentId, ticketId, { activity: summary, activityAt: new Date().toISOString() });
|
|
7043
|
+
postAgentStatus(context, agentId, {
|
|
7044
|
+
state: 'working', ticketId, activity: summary, step: 'worker:' + agentType
|
|
7045
|
+
});
|
|
7046
|
+
}
|
|
7047
|
+
};
|
|
7048
|
+
|
|
7049
|
+
child.stdout.on('data', consume);
|
|
7050
|
+
child.stderr.on('data', consume);
|
|
7051
|
+
child.stdin.on('error', () => { /* the worker exited before reading the brief */ });
|
|
7052
|
+
child.stdin.write(brief);
|
|
7053
|
+
child.stdin.end();
|
|
7054
|
+
|
|
7055
|
+
const done = new Promise((resolve) => {
|
|
7056
|
+
child.on('error', (err) => {
|
|
7057
|
+
appendGlobalActivityLog('agent', 'worker.spawn_failed',
|
|
7058
|
+
{ agentId, ticketId, agentType, error: describeError(err) }, 'error');
|
|
7059
|
+
resolve({ ticketId, ok: false, reason: 'spawn_failed', message: describeError(err) });
|
|
7060
|
+
});
|
|
7061
|
+
child.on('close', async (code, signal) => {
|
|
7062
|
+
try { logStream.end(); } catch (_) { /* nothing to do */ }
|
|
7063
|
+
const ok = code === 0 && !signal;
|
|
7064
|
+
appendGlobalActivityLog('agent', 'worker.finished',
|
|
7065
|
+
{ agentId, ticketId, agentType, code, signal, attempt, logFile }, ok ? 'info' : 'error');
|
|
7066
|
+
|
|
7067
|
+
// The exit code is a weak signal: a worker can exit non-zero having resolved the ticket, and
|
|
7068
|
+
// zero having done nothing. So the TICKET decides — unclaim always, and only give up (release
|
|
7069
|
+
// it back to the queue) once it has genuinely failed enough times and is still not terminal.
|
|
7070
|
+
const giveUp = !ok && attempt >= AGENT_WORKER_MAX_ATTEMPTS;
|
|
7071
|
+
const result = await releaseTicketClaim(context, agentId, { ticketId, accountId: ticket.accountId }, {
|
|
7072
|
+
outcome: ok ? 'completed' : (signal ? 'killed' : 'failed'),
|
|
7073
|
+
release: giveUp,
|
|
7074
|
+
// A clean exit that left the ticket short of terminal is a DELIVERED judgement, not a failure —
|
|
7075
|
+
// the worker chose pending_review, or documented that it cannot be done. Keeping it routed here
|
|
7076
|
+
// would have the next poll start another worker that reaches the same conclusion, forever.
|
|
7077
|
+
unroute: ok,
|
|
7078
|
+
notes: giveUp
|
|
7079
|
+
? ('Autonomous ' + agentType + ' worker failed ' + attempt + ' time(s); returning to the queue. Log: ' + logFile)
|
|
7080
|
+
: null
|
|
7081
|
+
});
|
|
7082
|
+
|
|
7083
|
+
const row = (result && result.ticket) || {};
|
|
7084
|
+
const terminal = ['resolved', 'closed'].includes(String(row.status || ''));
|
|
7085
|
+
if (terminal || giveUp) {
|
|
7086
|
+
clearWorkerAttempts(agentId, ticketId);
|
|
7087
|
+
}
|
|
7088
|
+
clearWorker(agentId, ticketId);
|
|
7089
|
+
resolve({ ticketId, ok, code, signal, terminal, gaveUp: giveUp, status: row.status || null, logFile });
|
|
7090
|
+
});
|
|
7091
|
+
});
|
|
7092
|
+
|
|
7093
|
+
return { pid: child.pid, done };
|
|
7094
|
+
}
|
|
7095
|
+
|
|
7096
|
+
/**
|
|
7097
|
+
* The one detached process behind a registered agent session: presence, and optionally work.
|
|
7098
|
+
*
|
|
7099
|
+
* <p>Presence and work-collection live in the SAME process on purpose. Both need to know how many
|
|
7100
|
+
* workers are running — presence publishes it as capacity, collection needs it to decide how many
|
|
7101
|
+
* tickets to take — and two processes maintaining that number separately would disagree the moment
|
|
7102
|
+
* one of them stalled, with the platform believing whichever wrote last.</p>
|
|
7103
|
+
*
|
|
7104
|
+
* <p>When serving, the work poll IS the heartbeat: `/cli/agentWork` refreshes presence, publishes
|
|
7105
|
+
* the live worker set, renews their claims, and returns newly claimed work, all in one request.</p>
|
|
7106
|
+
*/
|
|
5781
7107
|
async function agentHeartbeatDaemonCommand(flags) {
|
|
5782
7108
|
const agentId = String(flags['agent-id'] || flags.agentId || '').trim();
|
|
5783
7109
|
const anchorPid = Number(flags['anchor-pid'] || flags.anchorPid);
|
|
@@ -5790,11 +7116,36 @@ async function agentHeartbeatDaemonCommand(flags) {
|
|
|
5790
7116
|
// Re-registration must reuse the anchor it was given, never re-resolve one.
|
|
5791
7117
|
process.env.REMITS_AGENT_ANCHOR_PID = String(anchorPid);
|
|
5792
7118
|
|
|
7119
|
+
const serving = flagEnabled(flags.serve);
|
|
7120
|
+
const ctx = {
|
|
7121
|
+
agentId,
|
|
7122
|
+
cwd,
|
|
7123
|
+
// The daemon is detached: its own process tree says nothing about the session it serves, so the
|
|
7124
|
+
// worker kind must come from the flag it was started with, never from re-detection.
|
|
7125
|
+
workerAgent: workerAgentType(flags['worker-agent'] || flags.agent),
|
|
7126
|
+
mode: String(flags.mode || 'edit').toLowerCase() === 'investigate' ? 'investigate' : 'edit',
|
|
7127
|
+
maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1)),
|
|
7128
|
+
runId: 'run-' + Date.now().toString(36)
|
|
7129
|
+
};
|
|
7130
|
+
const inFlight = new Map();
|
|
7131
|
+
|
|
5793
7132
|
const stop = async (reason) => {
|
|
5794
|
-
appendGlobalActivityLog('agent', 'heartbeat.stopped',
|
|
7133
|
+
appendGlobalActivityLog('agent', 'heartbeat.stopped',
|
|
7134
|
+
{ agentId, anchorPid, reason, workers: inFlight.size });
|
|
7135
|
+
// Kill the workers first, then drop their claims. A worker left running after its supervisor is
|
|
7136
|
+
// gone is unsupervised autonomy on production tickets — and its claim would then expire under a
|
|
7137
|
+
// process that is still writing.
|
|
7138
|
+
for (const [ticketId, entry] of inFlight.entries()) {
|
|
7139
|
+
try { process.kill(entry.pid, 'SIGTERM'); } catch (_) { /* already gone */ }
|
|
7140
|
+
clearWorker(agentId, ticketId);
|
|
7141
|
+
}
|
|
5795
7142
|
forgetLocalAgent(agentId);
|
|
5796
7143
|
try {
|
|
5797
7144
|
const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
|
|
7145
|
+
for (const [ticketId, entry] of inFlight.entries()) {
|
|
7146
|
+
await releaseTicketClaim(context, agentId,
|
|
7147
|
+
{ ticketId, accountId: entry.accountId }, { outcome: 'supervisor_stopped' });
|
|
7148
|
+
}
|
|
5798
7149
|
await buildAxios(context.baseUrl, context.session.token, 5000)
|
|
5799
7150
|
.post('/cli/agentDeregister', { agentId });
|
|
5800
7151
|
} catch (_) {
|
|
@@ -5806,21 +7157,108 @@ async function agentHeartbeatDaemonCommand(flags) {
|
|
|
5806
7157
|
process.on('SIGTERM', () => { stop('signal'); });
|
|
5807
7158
|
process.on('SIGINT', () => { stop('signal'); });
|
|
5808
7159
|
|
|
7160
|
+
// A previous supervisor for this agent may have been killed mid-run, leaving claims behind. They
|
|
7161
|
+
// would expire on their own, but that is up to 15 minutes during which this session cannot restart
|
|
7162
|
+
// the work it is still routed. Clear anything whose process is demonstrably gone.
|
|
7163
|
+
if (serving) {
|
|
7164
|
+
for (const stale of listWorkers(agentId)) {
|
|
7165
|
+
if (stale && stale.pid && processIsAlive(stale.pid)) {
|
|
7166
|
+
continue;
|
|
7167
|
+
}
|
|
7168
|
+
clearWorker(agentId, stale.ticketId);
|
|
7169
|
+
try {
|
|
7170
|
+
const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
|
|
7171
|
+
await releaseTicketClaim(context, agentId, stale, { outcome: 'orphaned' });
|
|
7172
|
+
} catch (_) { /* the claim expires regardless */ }
|
|
7173
|
+
}
|
|
7174
|
+
}
|
|
7175
|
+
|
|
7176
|
+
let emptyPolls = 0;
|
|
5809
7177
|
for (;;) {
|
|
5810
7178
|
if (!processIsAlive(anchorPid)) {
|
|
5811
7179
|
await stop('anchor_exited');
|
|
5812
7180
|
return;
|
|
5813
7181
|
}
|
|
7182
|
+
let response = null;
|
|
5814
7183
|
try {
|
|
5815
7184
|
const context = agentSessionFor(flags, { cwd, baseUrl: flags['base-url'] });
|
|
5816
|
-
const
|
|
5817
|
-
|
|
7185
|
+
const api = buildAxios(context.baseUrl, context.session.token, 30000);
|
|
7186
|
+
|
|
7187
|
+
if (serving) {
|
|
7188
|
+
const activeTickets = [...inFlight.keys()];
|
|
7189
|
+
response = await api.post('/cli/agentWork', {
|
|
7190
|
+
agentId,
|
|
7191
|
+
claim: inFlight.size < ctx.maxConcurrent,
|
|
7192
|
+
runId: ctx.runId,
|
|
7193
|
+
activeTickets,
|
|
7194
|
+
maxConcurrent: ctx.maxConcurrent,
|
|
7195
|
+
// Declared so the phase the platform derives already accounts for an operator who asked for a
|
|
7196
|
+
// read-only agent. Without it the server would hand out an edit lease this session will never
|
|
7197
|
+
// use, blocking a repository nobody is editing.
|
|
7198
|
+
mode: ctx.mode,
|
|
7199
|
+
statuses: 'all'
|
|
7200
|
+
}).then((r) => r.data);
|
|
7201
|
+
|
|
7202
|
+
// The CLI and the platform are released separately, so a new supervisor can meet an older
|
|
7203
|
+
// platform. That platform ignores `claim` and answers with EVERY routed ticket — which this loop
|
|
7204
|
+
// would read as "start all of these", spawning past capacity with no claim protecting any of
|
|
7205
|
+
// them. `supervising` is the platform confirming it understood the request, so its absence means
|
|
7206
|
+
// stand down rather than guess.
|
|
7207
|
+
if (response && response.supervising !== true) {
|
|
7208
|
+
if (emptyPolls === 0) {
|
|
7209
|
+
appendGlobalActivityLog('agent', 'serve.platform_too_old', {
|
|
7210
|
+
agentId,
|
|
7211
|
+
message: 'This platform does not support supervised claiming, so no workers will be started. ' +
|
|
7212
|
+
'Update the platform, or work tickets manually with: remits-cli agent work'
|
|
7213
|
+
}, 'error');
|
|
7214
|
+
}
|
|
7215
|
+
emptyPolls += 1;
|
|
7216
|
+
await new Promise((resolve) => setTimeout(resolve, AGENT_SERVE_IDLE_POLL_MS));
|
|
7217
|
+
continue;
|
|
7218
|
+
}
|
|
7219
|
+
|
|
7220
|
+
// A swept ticket was routed at nobody and this session took it itself. Logged distinctly from a
|
|
7221
|
+
// routed one so an operator reading the activity log can tell "somebody handed me this" from
|
|
7222
|
+
// "nobody had, and I picked it up".
|
|
7223
|
+
const sweptIds = new Set(((response && response.sweptTicketIds) || []).map((id) => String(id)));
|
|
7224
|
+
if (sweptIds.size) {
|
|
7225
|
+
appendGlobalActivityLog('agent', 'serve.swept_unrouted', {
|
|
7226
|
+
agentId,
|
|
7227
|
+
ticketIds: [...sweptIds],
|
|
7228
|
+
message: 'Claimed ' + sweptIds.size + ' unrouted ticket(s) nobody had been given'
|
|
7229
|
+
});
|
|
7230
|
+
}
|
|
7231
|
+
|
|
7232
|
+
for (const ticket of (response && response.tickets) || []) {
|
|
7233
|
+
const ticketId = String(ticket.id || ticket.ticketId || '');
|
|
7234
|
+
if (!ticketId || inFlight.has(ticketId)) {
|
|
7235
|
+
continue;
|
|
7236
|
+
}
|
|
7237
|
+
const started = spawnTicketWorker(context, ctx, ticket);
|
|
7238
|
+
inFlight.set(ticketId, { pid: started.pid, accountId: ticket.accountId });
|
|
7239
|
+
started.done.then(() => {
|
|
7240
|
+
inFlight.delete(ticketId);
|
|
7241
|
+
postAgentStatus(context, agentId, inFlight.size
|
|
7242
|
+
? { state: 'working', ticketId: [...inFlight.keys()][0] }
|
|
7243
|
+
: { state: 'idle', ticketId: null, activity: null, step: null });
|
|
7244
|
+
});
|
|
7245
|
+
}
|
|
7246
|
+
emptyPolls = (response && response.count) ? 0 : emptyPolls + 1;
|
|
7247
|
+
} else {
|
|
7248
|
+
response = await api.post('/cli/agentHeartbeat', { agentId }).then((r) => r.data);
|
|
7249
|
+
}
|
|
7250
|
+
|
|
5818
7251
|
if (response && response.registered === false) {
|
|
5819
7252
|
// The record expired (a long sleep, a platform restart, a Redis flush). Re-register rather
|
|
5820
7253
|
// than heartbeating into a void — the tab is still very much alive.
|
|
5821
7254
|
appendGlobalActivityLog('agent', 'heartbeat.reregistering', { agentId });
|
|
5822
7255
|
await agentRegisterCommand(
|
|
5823
|
-
{
|
|
7256
|
+
{
|
|
7257
|
+
'agent-id': agentId,
|
|
7258
|
+
'base-url': context.baseUrl,
|
|
7259
|
+
'max-concurrent': String(ctx.maxConcurrent),
|
|
7260
|
+
json: 'true'
|
|
7261
|
+
},
|
|
5824
7262
|
{ spawnHeartbeat: false }
|
|
5825
7263
|
).catch((err) => {
|
|
5826
7264
|
appendGlobalActivityLog('agent', 'heartbeat.reregister_failed',
|
|
@@ -5830,7 +7268,11 @@ async function agentHeartbeatDaemonCommand(flags) {
|
|
|
5830
7268
|
} catch (err) {
|
|
5831
7269
|
appendGlobalActivityLog('agent', 'heartbeat.failed', { agentId, error: describeError(err) }, 'error');
|
|
5832
7270
|
}
|
|
5833
|
-
|
|
7271
|
+
|
|
7272
|
+
const interval = !serving
|
|
7273
|
+
? AGENT_HEARTBEAT_INTERVAL_MS
|
|
7274
|
+
: (emptyPolls >= AGENT_SERVE_IDLE_AFTER_POLLS ? AGENT_SERVE_IDLE_POLL_MS : AGENT_SERVE_POLL_MS);
|
|
7275
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
5834
7276
|
}
|
|
5835
7277
|
}
|
|
5836
7278
|
|
|
@@ -5933,8 +7375,8 @@ async function agentWorkCommand(flags) {
|
|
|
5933
7375
|
}
|
|
5934
7376
|
console.log('');
|
|
5935
7377
|
console.log('Next: read it in full, then claim it.');
|
|
5936
|
-
console.log(' remits-cli
|
|
5937
|
-
|
|
7378
|
+
console.log(' remits-cli ticket read --ticket ' + (tickets[0].id || tickets[0].ticketId));
|
|
7379
|
+
console.log(' remits-cli ticket accept --ticket ' + (tickets[0].id || tickets[0].ticketId));
|
|
5938
7380
|
console.log(' remits-cli agent status --state working --ticket ' + (tickets[0].id || tickets[0].ticketId) +
|
|
5939
7381
|
' --activity "reproducing the issue"');
|
|
5940
7382
|
return;
|
|
@@ -5944,13 +7386,354 @@ async function agentWorkCommand(flags) {
|
|
|
5944
7386
|
}
|
|
5945
7387
|
}
|
|
5946
7388
|
|
|
7389
|
+
/**
|
|
7390
|
+
* The generic ticket lifecycle — read a ticket, and move it through its workflow.
|
|
7391
|
+
*
|
|
7392
|
+
* Deliberately NOT a wrapper around any account's ticket tool. `mcp_support_ticket` belongs to one
|
|
7393
|
+
* particular System Account: it is owned by an account a given caller may not reach, it scopes to
|
|
7394
|
+
* its own account, and a partner platform need not have it at all. This talks to the platform's own
|
|
7395
|
+
* record, so the same commands work on every Remits platform — which is what an autonomous worker
|
|
7396
|
+
* needs, since its brief has to be written before anyone knows which platform it will run on.
|
|
7397
|
+
*/
|
|
7398
|
+
async function ticketCommand(flags, subcommand) {
|
|
7399
|
+
const action = String(subcommand || 'read').toLowerCase();
|
|
7400
|
+
const ticketId = String(flags.ticket || flags['ticket-id'] || flags._?.[2] || process.env.REMITS_SUPPORT_TICKET_ID || '').trim();
|
|
7401
|
+
if (!ticketId) {
|
|
7402
|
+
printTicketHelp();
|
|
7403
|
+
throw new Error('A --ticket ID is required (or set REMITS_SUPPORT_TICKET_ID).');
|
|
7404
|
+
}
|
|
7405
|
+
|
|
7406
|
+
const local = listLocalAgents()[0] || null;
|
|
7407
|
+
const context = agentSessionFor(flags, local);
|
|
7408
|
+
|
|
7409
|
+
// Routing lives on its own endpoint (the operator surface the dashboard and any Embeddable share),
|
|
7410
|
+
// so it is dispatched here rather than folded into the lifecycle switch.
|
|
7411
|
+
if (action === 'route' || action === 'unroute') {
|
|
7412
|
+
const agentId = action === 'unroute' ? null : String(flags.agent || flags['agent-id'] || '').trim();
|
|
7413
|
+
if (action === 'route' && !agentId) {
|
|
7414
|
+
throw new Error('route needs --agent <agentId>. See: remits-cli agent list');
|
|
7415
|
+
}
|
|
7416
|
+
const accountId = Number(flags['account-id']) || (await resolveTicketAccountId(context, ticketId));
|
|
7417
|
+
const result = await buildAxios(context.baseUrl, context.session.token, 20000)
|
|
7418
|
+
.post('/cli/ticketRoute', { ticketId, accountId, agentId, dataMode: context.dataMode })
|
|
7419
|
+
.then((r) => r.data)
|
|
7420
|
+
.catch((err) => (err && err.response && err.response.data) || null);
|
|
7421
|
+
if (!result || result.success === false) {
|
|
7422
|
+
console.error('Refused: ' + ((result && result.message) || 'routing failed'));
|
|
7423
|
+
process.exitCode = 1;
|
|
7424
|
+
return;
|
|
7425
|
+
}
|
|
7426
|
+
console.log(result.routed
|
|
7427
|
+
? 'routed ticket ' + ticketId + ' to ' + (result.agentLabel || result.agentId)
|
|
7428
|
+
: 'cleared routing on ticket ' + ticketId);
|
|
7429
|
+
return;
|
|
7430
|
+
}
|
|
7431
|
+
|
|
7432
|
+
const payload = {
|
|
7433
|
+
action,
|
|
7434
|
+
ticketId,
|
|
7435
|
+
accountId: Number(flags['account-id']) || undefined,
|
|
7436
|
+
dataMode: context.dataMode,
|
|
7437
|
+
status: flags.status,
|
|
7438
|
+
resolution: flags.resolution,
|
|
7439
|
+
assignee: flags.assignee,
|
|
7440
|
+
notes: flags.notes,
|
|
7441
|
+
summary: flags.summary || flags.note,
|
|
7442
|
+
question: flags.question,
|
|
7443
|
+
context: flags.context,
|
|
7444
|
+
to: flags.to,
|
|
7445
|
+
message: flags.message || flags.answer,
|
|
7446
|
+
route: flags.route === 'false' ? false : undefined,
|
|
7447
|
+
details: flags.details,
|
|
7448
|
+
nextStep: flags['next-step'],
|
|
7449
|
+
category: flags.category,
|
|
7450
|
+
// Clearing a planning slot has to be expressible, and `||` could not express it: an empty value is
|
|
7451
|
+
// falsy, so it collapsed to undefined, JSON.stringify dropped the key, and the server was never told.
|
|
7452
|
+
// The platform side has always supported clearing (an explicit blank nulls the field) — only the CLI
|
|
7453
|
+
// could not ask for it. `--planned-in` with no value now means "clear it".
|
|
7454
|
+
plannedIn: planningFlagValue(firstDefined(flags['planned-in'], flags.plannedIn)),
|
|
7455
|
+
boardStage: planningFlagValue(firstDefined(flags['board-stage'], flags.boardStage)),
|
|
7456
|
+
rank: planningFlagValue(flags.rank),
|
|
7457
|
+
size: planningFlagValue(flags.size),
|
|
7458
|
+
blockedBy: planningFlagValue(firstDefined(flags['blocked-by'], flags.blockedBy)),
|
|
7459
|
+
workstream: planningFlagValue(flags.workstream),
|
|
7460
|
+
// Lease operations act as the AGENT, not the user: the lease is held by a worker session, and a
|
|
7461
|
+
// human's email is not a thing that can hold one. Inside a worker these come from the environment
|
|
7462
|
+
// the supervisor set, so the commands need no flags.
|
|
7463
|
+
agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined,
|
|
7464
|
+
runId: flags['run-id'] || process.env.REMITS_WORKER_RUN_ID || undefined,
|
|
7465
|
+
agentLabel: flags['agent-label'] || undefined,
|
|
7466
|
+
// WHERE this run is working, sent with every lifecycle call so the lease and the claim record THIS
|
|
7467
|
+
// process's checkout rather than the supervisor's. A ticket worker runs in its own workspace lane
|
|
7468
|
+
// (REMITS_WORKSPACE=ticket-<id>) and may run in its own worktree, so the two genuinely differ.
|
|
7469
|
+
...runContextPayload(flags)
|
|
7470
|
+
};
|
|
7471
|
+
|
|
7472
|
+
let response;
|
|
7473
|
+
try {
|
|
7474
|
+
response = await buildAxios(context.baseUrl, context.session.token, 30000)
|
|
7475
|
+
.post('/cli/ticket', payload).then((r) => r.data);
|
|
7476
|
+
} catch (err) {
|
|
7477
|
+
// A lifecycle guard answers with 409 and a sentence saying what is wrong ("accept it first",
|
|
7478
|
+
// "already assigned to someone else"). That sentence IS the correction a caller needs, so it must
|
|
7479
|
+
// reach them intact rather than as an HTTP status.
|
|
7480
|
+
const body = err && err.response && err.response.data;
|
|
7481
|
+
if (body && body.message) {
|
|
7482
|
+
console.error('Refused: ' + body.message);
|
|
7483
|
+
process.exitCode = 1;
|
|
7484
|
+
return;
|
|
7485
|
+
}
|
|
7486
|
+
throw err;
|
|
7487
|
+
}
|
|
7488
|
+
|
|
7489
|
+
if (action === 'where' || action === 'context') {
|
|
7490
|
+
// The one run-context block, rendered by the platform so `ticket where`, `agent map`, the MCP tool and
|
|
7491
|
+
// the operator UI cannot paraphrase the same repository four different ways.
|
|
7492
|
+
const lines = (response && response.runContext && response.runContext.lines) || [];
|
|
7493
|
+
if (!lines.length) console.log('No run context for ticket ' + ticketId + '.');
|
|
7494
|
+
else lines.forEach((line) => console.log(' ' + line));
|
|
7495
|
+
return;
|
|
7496
|
+
}
|
|
7497
|
+
|
|
7498
|
+
if (flagEnabled(flags.json) || action === 'read') {
|
|
7499
|
+
console.log(JSON.stringify(response, null, 2));
|
|
7500
|
+
return;
|
|
7501
|
+
}
|
|
7502
|
+
|
|
7503
|
+
if (action === 'force-unlease' || action === 'force_unlease') {
|
|
7504
|
+
const previous = (response && response.previous) || null;
|
|
7505
|
+
console.log(response.released
|
|
7506
|
+
? 'Edit lease on repository account ' + (response.repoAccountId || '?') + ' force-released' +
|
|
7507
|
+
(previous ? ' (was held by ' + (previous.agentLabel || previous.agentId) + ')' : '') + '.'
|
|
7508
|
+
: 'Nothing was holding the edit lease on this repository.');
|
|
7509
|
+
console.log(' A handoff worklog entry was recorded on ticket ' + ticketId + '.');
|
|
7510
|
+
return;
|
|
7511
|
+
}
|
|
7512
|
+
|
|
7513
|
+
if (action === 'lease' || action === 'unlease') {
|
|
7514
|
+
const lease = (response && response.editLease) || null;
|
|
7515
|
+
if (action === 'unlease') {
|
|
7516
|
+
console.log(response.released
|
|
7517
|
+
? 'Edit lease released. Another agent can now edit this repository.'
|
|
7518
|
+
: 'You did not hold the edit lease on this repository; nothing to release.');
|
|
7519
|
+
return;
|
|
7520
|
+
}
|
|
7521
|
+
if (response.success) {
|
|
7522
|
+
console.log('Edit lease acquired — you may edit, stage and verify this repository.');
|
|
7523
|
+
console.log('Release it as soon as you are done: remits-cli ticket unlease');
|
|
7524
|
+
return;
|
|
7525
|
+
}
|
|
7526
|
+
// Not an error. The run continues read-only, and the message says who has it, WHERE they are working,
|
|
7527
|
+
// and what to do instead of waiting.
|
|
7528
|
+
console.log('Edit lease NOT acquired — this run stays read-only.');
|
|
7529
|
+
if (response.message) console.log(response.message);
|
|
7530
|
+
else if (lease) console.log('Held by ' + (lease.agentLabel || lease.agentId) + '.');
|
|
7531
|
+
const holderAt = describeRunLocation(lease);
|
|
7532
|
+
if (holderAt) console.log('Holder is working ' + holderAt);
|
|
7533
|
+
process.exitCode = 0;
|
|
7534
|
+
return;
|
|
7535
|
+
}
|
|
7536
|
+
|
|
7537
|
+
const ticket = (response && response.ticket) || {};
|
|
7538
|
+
console.log(action + ' ok — ticket ' + ticketId + ' is now [' + (ticket.status || '?') + ']' +
|
|
7539
|
+
(ticket.assignedTo ? ', owned by ' + ticket.assignedTo : ', unassigned'));
|
|
7540
|
+
if (action === 'planning' || action === 'plan') {
|
|
7541
|
+
const planning = [
|
|
7542
|
+
ticket.workstream ? 'workstream=' + ticket.workstream : '',
|
|
7543
|
+
ticket.plannedIn ? 'plannedIn=' + ticket.plannedIn : '',
|
|
7544
|
+
ticket.boardStage ? 'boardStage=' + ticket.boardStage : '',
|
|
7545
|
+
ticket.rank !== undefined && ticket.rank !== null && ticket.rank !== '' ? 'rank=' + ticket.rank : '',
|
|
7546
|
+
ticket.size ? 'size=' + ticket.size : '',
|
|
7547
|
+
ticket.blockedBy ? 'blockedBy=' + ticket.blockedBy : ''
|
|
7548
|
+
].filter(Boolean).join(', ');
|
|
7549
|
+
console.log(' planning: ' + (planning || 'cleared'));
|
|
7550
|
+
}
|
|
7551
|
+
if (ticket.awaitingResponseSince) {
|
|
7552
|
+
console.log(' waiting on an answer since ' + ticket.awaitingResponseSince);
|
|
7553
|
+
if (ticket.awaitingResponseQuestion) {
|
|
7554
|
+
console.log(' question: ' + String(ticket.awaitingResponseQuestion).split('\n')[0].slice(0, 160));
|
|
7555
|
+
}
|
|
7556
|
+
console.log(' answer it with: remits-cli ticket reply --ticket ' + ticketId + ' --message "..."');
|
|
7557
|
+
}
|
|
7558
|
+
}
|
|
7559
|
+
|
|
7560
|
+
/** The account a ticket belongs to, so routing does not make the caller look it up by hand. */
|
|
7561
|
+
async function resolveTicketAccountId(context, ticketId) {
|
|
7562
|
+
const read = await buildAxios(context.baseUrl, context.session.token, 20000)
|
|
7563
|
+
.post('/cli/ticket', { action: 'read', ticketId, dataMode: context.dataMode })
|
|
7564
|
+
.then((r) => r.data)
|
|
7565
|
+
.catch(() => null);
|
|
7566
|
+
const accountId = Number(read && read.ticket && read.ticket.accountId);
|
|
7567
|
+
if (!Number.isFinite(accountId) || accountId <= 0) {
|
|
7568
|
+
throw new Error('Could not resolve the owning account for ticket ' + ticketId + '. Pass --account-id.');
|
|
7569
|
+
}
|
|
7570
|
+
return accountId;
|
|
7571
|
+
}
|
|
7572
|
+
|
|
7573
|
+
function printTicketHelp() {
|
|
7574
|
+
console.log('Usage: remits-cli ticket <action> --ticket ID');
|
|
7575
|
+
console.log('');
|
|
7576
|
+
console.log(' read The full ticket record, as JSON.');
|
|
7577
|
+
console.log(' where WHERE this work is happening: repository account,');
|
|
7578
|
+
console.log(' checkout, branch, staging workspace lane, the live edit lease and who holds it, the');
|
|
7579
|
+
console.log(' live claim, and your phase. Read it BEFORE you edit anything — with git worktrees and');
|
|
7580
|
+
console.log(' workspace lanes, two agents on one account can be in completely different trees.');
|
|
7581
|
+
console.log(' accept Claim it as yours before you start work.');
|
|
7582
|
+
console.log(' status --status in_progress|pending_review');
|
|
7583
|
+
console.log(' progress --summary "..." [--category investigation|fix|verification|...] [--next-step "..."]');
|
|
7584
|
+
console.log(' complete --resolution "what you found and did"');
|
|
7585
|
+
console.log(' ask --question "..." [--context "..."] [--to WHO]');
|
|
7586
|
+
console.log(' Put a decision to a human and park. Use it when the CHOICE is not yours.');
|
|
7587
|
+
console.log(' reply --message "..." [--route false]');
|
|
7588
|
+
console.log(' Answer the open question. By default this re-routes the ticket, so a fresh worker');
|
|
7589
|
+
console.log(' resumes with your answer already in its brief.');
|
|
7590
|
+
console.log(' release [--notes "..."] Hand it back to the queue.');
|
|
7591
|
+
console.log(' reopen | assign --assignee EMAIL');
|
|
7592
|
+
console.log(' route --agent AGENT_ID Hand it to a registered agent session.');
|
|
7593
|
+
console.log(' unroute Clear its routing, e.g. when that session is gone.');
|
|
7594
|
+
console.log(' planning [--workstream V] [--planned-in V] [--board-stage V] [--rank N] [--size V] [--blocked-by V]');
|
|
7595
|
+
console.log(' Update organization-owned planning fields without changing platform lifecycle status.');
|
|
7596
|
+
console.log(' Pass a flag with no value to CLEAR that slot, e.g. --blocked-by');
|
|
7597
|
+
console.log(' force-unlease [--reason "..."] Break a stale repository lease somebody else holds.');
|
|
7598
|
+
console.log(' For an operator only: unlease returns YOUR lease, this one takes away theirs, and it');
|
|
7599
|
+
console.log(' records a handoff worklog entry on the ticket so the intervention is visible.');
|
|
7600
|
+
console.log(' lease | unlease Take or give back the right to EDIT this ticket\'s');
|
|
7601
|
+
console.log(' repository. One agent at a time per account; everyone else investigates read-only.');
|
|
7602
|
+
console.log(' Your run already holds it if the brief said so. Take it when you started read-only');
|
|
7603
|
+
console.log(' and have reached an actual edit. A refusal is not an error — it names who has it,');
|
|
7604
|
+
console.log(' and investigating is still useful work. complete/ask/release free it for you.');
|
|
7605
|
+
console.log('');
|
|
7606
|
+
console.log(' Works on every Remits platform: it reads and writes the platform ticket record, not');
|
|
7607
|
+
console.log(' any one account\'s ticket tooling. Inside a worker, --ticket defaults to the ticket');
|
|
7608
|
+
console.log(' that worker was launched for.');
|
|
7609
|
+
}
|
|
7610
|
+
|
|
7611
|
+
/**
|
|
7612
|
+
* The run-context fields every call that RECORDS a location sends: checkout, branch, staging workspace.
|
|
7613
|
+
*
|
|
7614
|
+
* One helper so the lease, the claim and the registration cannot describe this process three ways —
|
|
7615
|
+
* the same reason the platform builds the tuple in exactly one class (AgentRunContext).
|
|
7616
|
+
*/
|
|
7617
|
+
function runContextPayload(flags = {}) {
|
|
7618
|
+
const cwd = process.cwd();
|
|
7619
|
+
return {
|
|
7620
|
+
workingDirectory: cwd,
|
|
7621
|
+
branch: currentBranch(cwd) || undefined,
|
|
7622
|
+
workspace: resolveWorkspace(cwd, flags) || undefined
|
|
7623
|
+
};
|
|
7624
|
+
}
|
|
7625
|
+
|
|
7626
|
+
/**
|
|
7627
|
+
* WHERE a worker is working — checkout, branch, staging workspace lane — on one line.
|
|
7628
|
+
*
|
|
7629
|
+
* The platform builds the same string server-side (AgentRunContext.describeLocation) for the surfaces it
|
|
7630
|
+
* renders; this is the terminal's copy for rows it prints itself. The workspace is always named, `shared`
|
|
7631
|
+
* when it is the default lane, because "no workspace shown" and "the shared lane" were the same output
|
|
7632
|
+
* and they are opposite facts: the shared lane is exactly the one where concurrent agents overwrite
|
|
7633
|
+
* each other's staged components.
|
|
7634
|
+
*/
|
|
7635
|
+
function describeRunLocation(record) {
|
|
7636
|
+
if (!record) return '';
|
|
7637
|
+
const dir = record.workingDirectory || record.cwd || record.claimedWorkingDirectory;
|
|
7638
|
+
const branch = record.branch || record.claimedBranch;
|
|
7639
|
+
const workspace = record.workspace || record.claimedWorkspace;
|
|
7640
|
+
if (!dir && !branch && !workspace) return '';
|
|
7641
|
+
const parts = [];
|
|
7642
|
+
if (dir) parts.push(dir);
|
|
7643
|
+
if (branch) parts.push('branch ' + branch);
|
|
7644
|
+
parts.push('workspace ' + (workspace || 'shared'));
|
|
7645
|
+
return parts.join(' ');
|
|
7646
|
+
}
|
|
7647
|
+
|
|
7648
|
+
/**
|
|
7649
|
+
* The live work map: which repositories are being edited, by whom, and who else is around.
|
|
7650
|
+
*
|
|
7651
|
+
* The question every agent has to answer before it starts — "can I safely edit account 33 right now" —
|
|
7652
|
+
* and the one an operator asks when a queue is not moving. Agents, leases and tickets were each readable
|
|
7653
|
+
* on their own and never together, so every caller invented its own join and they disagreed.
|
|
7654
|
+
*/
|
|
7655
|
+
async function agentWorkMapCommand(flags) {
|
|
7656
|
+
const agentId = resolveAgentId(flags, { optional: true });
|
|
7657
|
+
const local = agentId ? (listLocalAgents().find((entry) => entry.agentId === agentId) || {}) : {};
|
|
7658
|
+
const context = agentSessionFor(flags, local);
|
|
7659
|
+
const accountId = flags['account-id'] ? Number(flags['account-id']) : Number(context.accountId);
|
|
7660
|
+
|
|
7661
|
+
const response = await buildAxios(context.baseUrl, context.session.token, 30000)
|
|
7662
|
+
.post('/cli/workMap', {
|
|
7663
|
+
agentId,
|
|
7664
|
+
accountId,
|
|
7665
|
+
accountIds: flags['account-ids'] ? String(flags['account-ids']).split(',').map((s) => Number(s.trim())) : undefined,
|
|
7666
|
+
dataMode: context.dataMode,
|
|
7667
|
+
all: flagEnabled(flags.all)
|
|
7668
|
+
}).then((r) => r.data);
|
|
7669
|
+
|
|
7670
|
+
if (flagEnabled(flags.json)) {
|
|
7671
|
+
console.log(JSON.stringify(response, null, 2));
|
|
7672
|
+
return;
|
|
7673
|
+
}
|
|
7674
|
+
|
|
7675
|
+
const repositories = (response && response.repositories) || [];
|
|
7676
|
+
if (!repositories.length) {
|
|
7677
|
+
console.log('No repositories in scope for ' + context.baseUrl + ' (' + context.dataMode + ' lane).');
|
|
7678
|
+
return;
|
|
7679
|
+
}
|
|
7680
|
+
console.log('Work map — ' + context.baseUrl + ' · ' + response.dataMode + ' lane');
|
|
7681
|
+
console.log('');
|
|
7682
|
+
for (const repo of repositories) {
|
|
7683
|
+
const lease = repo.editLease;
|
|
7684
|
+
console.log(' ' + repo.accountName + ' (' + repo.accountId + ')' +
|
|
7685
|
+
(repo.accountType ? ' ' + repo.accountType : ''));
|
|
7686
|
+
if (lease) {
|
|
7687
|
+
const who = lease.agentLabel || lease.agentId;
|
|
7688
|
+
console.log(' EDIT LEASE ' + who +
|
|
7689
|
+
(lease.ticketId ? ' ticket ' + lease.ticketId : '') +
|
|
7690
|
+
(lease.heldForSeconds != null ? ' held ' + lease.heldForSeconds + 's' : '') +
|
|
7691
|
+
(lease.expiresInSeconds != null ? ' expires in ' + lease.expiresInSeconds + 's' : '') +
|
|
7692
|
+
(repo.editable ? ' (yours)' : ''));
|
|
7693
|
+
// The holder's checkout, branch and staging lane. Without them a refused agent cannot tell a real
|
|
7694
|
+
// file conflict from a holder working in an entirely different worktree.
|
|
7695
|
+
const holderAt = describeRunLocation(lease);
|
|
7696
|
+
if (holderAt) console.log(' ' + holderAt);
|
|
7697
|
+
} else {
|
|
7698
|
+
console.log(' EDIT LEASE free');
|
|
7699
|
+
}
|
|
7700
|
+
for (const agent of (repo.agents || [])) {
|
|
7701
|
+
// No phase means the session is present but holding nothing. Saying "investigating" there would
|
|
7702
|
+
// report work that is not happening.
|
|
7703
|
+
const phase = agent.phase ? agent.phase.padEnd(13) : 'available ';
|
|
7704
|
+
console.log(' ' + phase + ' ' +
|
|
7705
|
+
(agent.label || agent.agentId) + ' ' + (agent.state || '?') +
|
|
7706
|
+
(agent.activeTickets && agent.activeTickets.length ? ' tickets ' + agent.activeTickets.join(',') : '') +
|
|
7707
|
+
(agent.secondsSinceSeen != null ? ' seen ' + agent.secondsSinceSeen + 's ago' : ''));
|
|
7708
|
+
const at = describeRunLocation(agent);
|
|
7709
|
+
if (at) console.log(' ' + at);
|
|
7710
|
+
if (agent.activity) console.log(' ' + String(agent.activity).slice(0, 100));
|
|
7711
|
+
}
|
|
7712
|
+
console.log('');
|
|
7713
|
+
}
|
|
7714
|
+
console.log('Only the lease holder may edit or stage a repository. Everyone else investigates read-only.');
|
|
7715
|
+
}
|
|
7716
|
+
|
|
5947
7717
|
async function agentListCommand(flags) {
|
|
5948
7718
|
const cwd = process.cwd();
|
|
5949
|
-
const
|
|
5950
|
-
const
|
|
5951
|
-
|
|
7719
|
+
const mine = listLocalAgents()[0] || null;
|
|
7720
|
+
const context = agentSessionFor(flags, mine);
|
|
7721
|
+
|
|
7722
|
+
// An explicit --account-id asks about one account. Otherwise, if this machine has a registered
|
|
7723
|
+
// agent, scope to the accounts it was accepted for — the peers it could actually share work with.
|
|
7724
|
+
// Falling back to "whichever session was most recent" answers a question nobody asked.
|
|
7725
|
+
const explicitAccountId = Number(flags && flags['account-id']);
|
|
7726
|
+
const scopedAccountIds = (!Number.isFinite(explicitAccountId) && mine && Array.isArray(mine.accountIds))
|
|
7727
|
+
? mine.accountIds
|
|
7728
|
+
: null;
|
|
7729
|
+
const accountId = Number.isFinite(explicitAccountId) && explicitAccountId > 0
|
|
7730
|
+
? explicitAccountId
|
|
7731
|
+
: (scopedAccountIds ? null : resolvePreferredAccountId(cwd, flags, context.session));
|
|
7732
|
+
|
|
7733
|
+
const response = await buildAxios(context.baseUrl, context.session.token, 20000)
|
|
5952
7734
|
.post('/cli/agents', {
|
|
5953
7735
|
accountId,
|
|
7736
|
+
accountIds: scopedAccountIds,
|
|
5954
7737
|
dataMode: context.dataMode,
|
|
5955
7738
|
all: flagEnabled(flags.all)
|
|
5956
7739
|
}).then((r) => r.data);
|
|
@@ -5959,12 +7742,15 @@ async function agentListCommand(flags) {
|
|
|
5959
7742
|
console.log(JSON.stringify(response, null, 2));
|
|
5960
7743
|
return;
|
|
5961
7744
|
}
|
|
7745
|
+
const scopeLabel = accountId ? 'account ' + accountId
|
|
7746
|
+
: (scopedAccountIds ? scopedAccountIds.length + ' account(s) this session covers' : 'your accounts');
|
|
5962
7747
|
const agents = (response && response.agents) || [];
|
|
5963
7748
|
if (!agents.length) {
|
|
5964
|
-
console.log('No agents are registered for
|
|
7749
|
+
console.log('No agents are registered for ' + scopeLabel + ' in the ' + context.dataMode + ' lane.');
|
|
5965
7750
|
return;
|
|
5966
7751
|
}
|
|
5967
|
-
console.log(agents.length + ' agent(s) available
|
|
7752
|
+
console.log(agents.length + ' agent(s) available across ' + scopeLabel +
|
|
7753
|
+
' in the ' + context.dataMode + ' lane:');
|
|
5968
7754
|
for (const agent of agents) {
|
|
5969
7755
|
console.log('');
|
|
5970
7756
|
console.log(' ' + agent.agentId);
|
|
@@ -5972,8 +7758,18 @@ async function agentListCommand(flags) {
|
|
|
5972
7758
|
console.log(' state ' + (agent.state || 'idle') +
|
|
5973
7759
|
(agent.ticketId ? ' ticket ' + agent.ticketId : '') +
|
|
5974
7760
|
' last seen ' + (agent.secondsSinceSeen == null ? '?' : agent.secondsSinceSeen + 's ago'));
|
|
7761
|
+
// The agent's OWN lane, not the query's. --all skips the lane filter for diagnostics, and without
|
|
7762
|
+
// this a test-lane session and a production one render identically in that listing. An agent at
|
|
7763
|
+
// capacity is likewise not routable, which is the other reason an idle-looking agent gets nothing.
|
|
7764
|
+
console.log(' lane ' + (agent.dataMode || 'prod') +
|
|
7765
|
+
' capacity ' + (agent.activeTicketCount || 0) + '/' + (agent.maxConcurrent || 1) +
|
|
7766
|
+
(agent.hasCapacity === false ? ' (at capacity — not routable)' : ''));
|
|
5975
7767
|
if (agent.activity) console.log(' doing ' + agent.activity);
|
|
5976
|
-
|
|
7768
|
+
// Through describeRunLocation, which reads BOTH the canonical `workingDirectory` and the historical
|
|
7769
|
+
// `cwd`: a record written by an older platform, or still in Redis from before this field was
|
|
7770
|
+
// canonicalized, must not render as an unknown location.
|
|
7771
|
+
console.log(' where ' + (agent.host || '?') + ':' +
|
|
7772
|
+
(describeRunLocation(agent) || agent.workingDirectory || agent.cwd || '?'));
|
|
5977
7773
|
}
|
|
5978
7774
|
}
|
|
5979
7775
|
|
|
@@ -5984,19 +7780,39 @@ async function agentReleaseCommand(flags) {
|
|
|
5984
7780
|
return;
|
|
5985
7781
|
}
|
|
5986
7782
|
const local = listLocalAgents().find((entry) => entry.agentId === agentId) || {};
|
|
7783
|
+
|
|
7784
|
+
// Stop the supervisor BEFORE deregistering. The other order is a race the supervisor wins: it sees
|
|
7785
|
+
// registered:false, concludes its record expired, and re-registers the session we just retired.
|
|
7786
|
+
// Its own SIGTERM handler kills any workers and drops their claims, so this is a clean stop.
|
|
7787
|
+
let workersStopped = 0;
|
|
7788
|
+
const daemonPid = local.daemonPid && processIsAlive(local.daemonPid)
|
|
7789
|
+
? local.daemonPid
|
|
7790
|
+
: findAgentDaemonPid(agentId);
|
|
7791
|
+
if (daemonPid) {
|
|
7792
|
+
workersStopped = listWorkers(agentId).length;
|
|
7793
|
+
try { process.kill(daemonPid, 'SIGTERM'); } catch (_) { /* already gone */ }
|
|
7794
|
+
}
|
|
7795
|
+
|
|
5987
7796
|
const context = agentSessionFor(flags, local);
|
|
5988
7797
|
await buildAxios(context.baseUrl, context.session.token, 10000)
|
|
5989
7798
|
.post('/cli/agentDeregister', { agentId }).then((r) => r.data).catch(() => null);
|
|
5990
7799
|
forgetLocalAgent(agentId);
|
|
5991
7800
|
console.log('Deregistered agent ' + agentId + '. This session will no longer receive tickets.');
|
|
7801
|
+
if (workersStopped) {
|
|
7802
|
+
console.log('Stopped ' + workersStopped + ' running ticket worker(s); their claims were released so');
|
|
7803
|
+
console.log('another agent can pick the work up.');
|
|
7804
|
+
}
|
|
5992
7805
|
}
|
|
5993
7806
|
|
|
5994
7807
|
async function agentCommand(flags, subcommand) {
|
|
5995
7808
|
switch (subcommand) {
|
|
5996
7809
|
case 'register': return agentRegisterCommand(flags);
|
|
7810
|
+
case 'serve': case 'start': return agentServeCommand(flags);
|
|
7811
|
+
case 'workers': return agentWorkersCommand(flags);
|
|
5997
7812
|
case 'status': return agentStatusCommand(flags);
|
|
5998
7813
|
case 'work': return agentWorkCommand(flags);
|
|
5999
7814
|
case 'list': case 'ls': return agentListCommand(flags);
|
|
7815
|
+
case 'map': case 'work-map': case 'workmap': return agentWorkMapCommand(flags);
|
|
6000
7816
|
case 'release': case 'deregister': case 'stop': return agentReleaseCommand(flags);
|
|
6001
7817
|
case 'heartbeat-daemon': return agentHeartbeatDaemonCommand(flags);
|
|
6002
7818
|
default:
|
|
@@ -6008,9 +7824,28 @@ async function agentCommand(flags, subcommand) {
|
|
|
6008
7824
|
function printAgentHelp() {
|
|
6009
7825
|
console.log('Usage: remits-cli agent <subcommand>');
|
|
6010
7826
|
console.log('');
|
|
6011
|
-
console.log('
|
|
6012
|
-
console.log('
|
|
6013
|
-
console.log('
|
|
7827
|
+
console.log(' serve [--worker-agent claude|codex|gemini] [--max-concurrent N] [--mode edit|investigate]');
|
|
7828
|
+
console.log(' Run this in a PLAIN TERMINAL TAB — that tab becomes the agent host. It returns');
|
|
7829
|
+
console.log(' immediately; a background supervisor launches ONE fresh headless agent process per');
|
|
7830
|
+
console.log(' routed ticket, in that account\'s repo, and that process exits when the ticket is');
|
|
7831
|
+
console.log(' done. Nothing accumulates context across tickets, so no session needs clearing.');
|
|
7832
|
+
console.log(' Closing the tab stops the agent.');
|
|
7833
|
+
console.log('');
|
|
7834
|
+
console.log(' --worker-agent picks WHICH AI does the work. Without it: whatever agent this');
|
|
7835
|
+
console.log(' session is running (when you start it from inside one), else your');
|
|
7836
|
+
console.log(' `remits-cli config set --agent NAME` default. It prints which it chose and why.');
|
|
7837
|
+
console.log(' --mode investigate runs workers read-only (no file edits).');
|
|
7838
|
+
console.log('');
|
|
7839
|
+
console.log(' workers [--json] What this session is running right now.');
|
|
7840
|
+
console.log('');
|
|
7841
|
+
console.log(' register [--label NAME] [--data-mode test|prod] [--base-url URL]');
|
|
7842
|
+
console.log(' Presence only — make THIS session routable without starting anything. Use it when a');
|
|
7843
|
+
console.log(' human, or the agent in this very tab, will work the tickets by hand. Runs from ANY');
|
|
7844
|
+
console.log(' directory — it registers for every account repo indexed on this machine that your');
|
|
7845
|
+
console.log(' user can reach, so you do not have to be inside a particular repo. Defaults to the');
|
|
7846
|
+
console.log(' production platform and the prod lane, because a support agent works real tickets.');
|
|
7847
|
+
console.log(' Starts a heartbeat that ends when this session ends, so a closed tab stops');
|
|
7848
|
+
console.log(' receiving work on its own.');
|
|
6014
7849
|
console.log('');
|
|
6015
7850
|
console.log(' work [--wait SECONDS] [--json]');
|
|
6016
7851
|
console.log(' Ask for the tickets routed to this session. With --wait it keeps asking until');
|
|
@@ -6020,7 +7855,13 @@ function printAgentHelp() {
|
|
|
6020
7855
|
console.log(' Report what this session is doing, so operators can see it live.');
|
|
6021
7856
|
console.log('');
|
|
6022
7857
|
console.log(' list [--account-id ID] [--json] Who else is available for an account.');
|
|
7858
|
+
console.log(' map [--account-ids 1,4] [--json] Who is EDITING which repository right now.');
|
|
7859
|
+
console.log(' One agent at a time may edit an account\'s repo (the edit lease); everyone else');
|
|
7860
|
+
console.log(' investigates read-only. Check this before starting work that changes files.');
|
|
6023
7861
|
console.log(' release Deregister this session immediately.');
|
|
7862
|
+
console.log('');
|
|
7863
|
+
console.log(' After `register`, the other subcommands need no flags: they reuse the platform, lane');
|
|
7864
|
+
console.log(' and identity this session registered with.');
|
|
6024
7865
|
}
|
|
6025
7866
|
|
|
6026
7867
|
async function listenCommand(flags) {
|
|
@@ -6441,24 +8282,31 @@ function printComponentsHelp(subcommand) {
|
|
|
6441
8282
|
return;
|
|
6442
8283
|
}
|
|
6443
8284
|
if (subcommand === 'stage' || subcommand === 'push') {
|
|
6444
|
-
console.log('Usage: remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
|
|
8285
|
+
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]');
|
|
6445
8286
|
console.log('');
|
|
6446
8287
|
console.log('Stages local component files into the Redis staging cache. It never writes the database or git.');
|
|
6447
8288
|
console.log('Terminal output separates local working-tree component deltas from the full materialized staging cache count.');
|
|
8289
|
+
console.log('');
|
|
8290
|
+
console.log(' --workspace NAME stage into an isolated lane on this branch (see `remits-cli workspace`).');
|
|
8291
|
+
console.log(' --changed-only stage only the components this working tree edited, and do NOT reconcile');
|
|
8292
|
+
console.log(' (entries for untouched components are left alone rather than deleted).');
|
|
6448
8293
|
return;
|
|
6449
8294
|
}
|
|
6450
8295
|
if (subcommand === 'status') {
|
|
6451
|
-
console.log('Usage: remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
|
|
8296
|
+
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]');
|
|
6452
8297
|
console.log('');
|
|
6453
8298
|
console.log('Shows staged entries and whether this checkout resolves/writes trunk or a variant branch.');
|
|
8299
|
+
console.log('Also lists every OTHER staging lane on this branch for this account/user, so you can see');
|
|
8300
|
+
console.log('whether another agent is staging alongside you.');
|
|
6454
8301
|
return;
|
|
6455
8302
|
}
|
|
6456
8303
|
if (subcommand === 'clear') {
|
|
6457
|
-
console.log('Usage: remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
|
|
8304
|
+
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]');
|
|
6458
8305
|
console.log('');
|
|
6459
8306
|
console.log('Clears staged Redis entries without touching the database or git.');
|
|
6460
8307
|
console.log('Use --component-type with --component-id when the id could exist in multiple component families.');
|
|
6461
8308
|
console.log('Use --all for the temporary-experiment cleanup flow.');
|
|
8309
|
+
console.log('--all is scoped to YOUR workspace lane; it never clears another lane on the same branch.');
|
|
6462
8310
|
return;
|
|
6463
8311
|
}
|
|
6464
8312
|
if (subcommand === 'commit') {
|
|
@@ -6469,6 +8317,9 @@ function printComponentsHelp(subcommand) {
|
|
|
6469
8317
|
return;
|
|
6470
8318
|
}
|
|
6471
8319
|
console.log('Usage: remits-cli components <stage|status|clear|sync|commit|promotion|branches|branch>');
|
|
8320
|
+
console.log('');
|
|
8321
|
+
console.log('All staging subcommands accept --workspace NAME to isolate a lane on the same branch,');
|
|
8322
|
+
console.log('so several agents can iterate at once. See: remits-cli workspace --help');
|
|
6472
8323
|
console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
|
|
6473
8324
|
console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
|
|
6474
8325
|
console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
|
|
@@ -6562,7 +8413,7 @@ async function main() {
|
|
|
6562
8413
|
try { updateAccountRepoIndex(process.cwd()); } catch (_) { /* not in account repo */ }
|
|
6563
8414
|
// Auto-start the background service if not already running.
|
|
6564
8415
|
// Skip for lifecycle subcommands and help.
|
|
6565
|
-
const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami' || command === 'agent';
|
|
8416
|
+
const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami' || command === 'agent' || command === 'ticket';
|
|
6566
8417
|
const isHelpCmd = !command || command === 'help' || command === '--help' || wantsHelp;
|
|
6567
8418
|
if (!wantsJson && !isHelpCmd && !isLifecycleCmd && !isListenerRunning()) {
|
|
6568
8419
|
try {
|
|
@@ -6589,10 +8440,11 @@ async function main() {
|
|
|
6589
8440
|
console.log(' remits-cli auth [--base-url URL] [--account-id ID] [--port 8765] [--data-mode test|prod]');
|
|
6590
8441
|
console.log(' remits-cli sessions [list|remove] [--account-id ID] [--base-url URL] [--data-mode test|prod]');
|
|
6591
8442
|
console.log(' remits-cli config [set] [--agent claude|codex|gemini]');
|
|
6592
|
-
console.log(' remits-cli agent register
|
|
6593
|
-
console.log(' remits-cli agent work [--wait SECONDS]
|
|
8443
|
+
console.log(' remits-cli agent register # become a support agent (any directory, prod by default)');
|
|
8444
|
+
console.log(' remits-cli agent work [--wait SECONDS] # ask for tickets routed to this session');
|
|
6594
8445
|
console.log(' remits-cli agent status [--state idle|working|paused] [--ticket ID] [--activity "..."]');
|
|
6595
8446
|
console.log(' remits-cli agent list [--account-id ID] # who else is available');
|
|
8447
|
+
console.log(' remits-cli agent map [--account-ids 1,4] [--json] # who is EDITING what, where, right now');
|
|
6596
8448
|
console.log(' remits-cli agent release # stop receiving tickets now');
|
|
6597
8449
|
console.log(' remits-cli start [--foreground true] [--port 8787] # human dashboard in the browser');
|
|
6598
8450
|
console.log(' remits-cli stop');
|
|
@@ -6604,9 +8456,10 @@ async function main() {
|
|
|
6604
8456
|
console.log(' remits-cli tools [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--variant-branch NAME|none]');
|
|
6605
8457
|
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]');
|
|
6606
8458
|
console.log(' remits-cli tool status --call-id <callId> [--base-url URL] [--account-id ID] [--data-mode test|prod]');
|
|
6607
|
-
console.log(' remits-cli
|
|
6608
|
-
console.log(' remits-cli components
|
|
6609
|
-
console.log(' remits-cli components
|
|
8459
|
+
console.log(' remits-cli workspace [show|use <name>|use --auto|clear] # isolate staging when several agents share a repo');
|
|
8460
|
+
console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--changed-only] [--data-mode test|prod] [--json|--verbose]');
|
|
8461
|
+
console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--component-type TYPE --component-id ID] [--json|--verbose]');
|
|
8462
|
+
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]');
|
|
6610
8463
|
console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
|
|
6611
8464
|
console.log(' remits-cli components commit [--message \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
|
|
6612
8465
|
console.log(' remits-cli components promotion [<branch>] [--json] [--no-fail] # promotion readiness + ordered next steps');
|
|
@@ -6627,6 +8480,34 @@ async function main() {
|
|
|
6627
8480
|
process.exit(0);
|
|
6628
8481
|
}
|
|
6629
8482
|
|
|
8483
|
+
if (wantsHelp && (command === 'workspace' || command === 'ws')) {
|
|
8484
|
+
console.log('Usage: remits-cli workspace [show | use <name> | use --auto | clear]');
|
|
8485
|
+
console.log('');
|
|
8486
|
+
console.log('A WORKSPACE is an optional second isolation axis for staging, alongside the git branch.');
|
|
8487
|
+
console.log('Staging is keyed by (account, cli user, branch, workspace) — and account and user are fixed');
|
|
8488
|
+
console.log('for a given repo, so without a workspace the branch is the ONLY axis. That is why two coding');
|
|
8489
|
+
console.log('agents in two worktrees of the same branch overwrite each other: `components stage` uploads');
|
|
8490
|
+
console.log('the whole repo and replaces the lane wholesale.');
|
|
8491
|
+
console.log('');
|
|
8492
|
+
console.log('A workspace isolates staging WITHOUT changing anything else:');
|
|
8493
|
+
console.log(' - a commit still targets the same branch and the same owner account');
|
|
8494
|
+
console.log(' - the run still resolves whatever committed ComponentVariant branch the account subscribes to');
|
|
8495
|
+
console.log(' - only which staged entries shadow them changes');
|
|
8496
|
+
console.log('');
|
|
8497
|
+
console.log('Typical setup, one worktree per agent, all on the same branch:');
|
|
8498
|
+
console.log(' git worktree add ../ms-agent-a forked');
|
|
8499
|
+
console.log(' cd ../ms-agent-a && remits-cli workspace use --auto');
|
|
8500
|
+
console.log(' remits-cli components stage # isolated lane; nobody else sees it');
|
|
8501
|
+
console.log(' remits-cli test run --test 42 # resolves this lane over the forked variants');
|
|
8502
|
+
console.log(' remits-cli token --path /page/x # its own browser token, in this lane');
|
|
8503
|
+
console.log('');
|
|
8504
|
+
console.log('Precedence: --workspace > REMITS_WORKSPACE > .remits-cli/workspace > shared default lane');
|
|
8505
|
+
console.log('Use --no-workspace on a single command to target the shared lane without clearing the file.');
|
|
8506
|
+
console.log('');
|
|
8507
|
+
console.log('`.remits-cli/workspace` is per-checkout and gitignored, so a worktree keeps its own lane.');
|
|
8508
|
+
process.exit(0);
|
|
8509
|
+
}
|
|
8510
|
+
|
|
6630
8511
|
if (wantsHelp && command === 'test') {
|
|
6631
8512
|
printTestHelp();
|
|
6632
8513
|
process.exit(0);
|
|
@@ -6752,11 +8633,25 @@ async function main() {
|
|
|
6752
8633
|
return;
|
|
6753
8634
|
}
|
|
6754
8635
|
|
|
8636
|
+
if (command === 'ticket') {
|
|
8637
|
+
if (wantsHelp || !subcommand) {
|
|
8638
|
+
printTicketHelp();
|
|
8639
|
+
process.exit(0);
|
|
8640
|
+
}
|
|
8641
|
+
await ticketCommand(args, subcommand);
|
|
8642
|
+
return;
|
|
8643
|
+
}
|
|
8644
|
+
|
|
6755
8645
|
if (command === 'install') {
|
|
6756
8646
|
await installSkillsCommand(args);
|
|
6757
8647
|
return;
|
|
6758
8648
|
}
|
|
6759
8649
|
|
|
8650
|
+
if (command === 'workspace' || command === 'ws') {
|
|
8651
|
+
await workspaceCommand(args);
|
|
8652
|
+
return;
|
|
8653
|
+
}
|
|
8654
|
+
|
|
6760
8655
|
if (command === 'test' && subcommand === 'run') {
|
|
6761
8656
|
await testCommand(args);
|
|
6762
8657
|
return;
|