@yeaft/webchat-agent 1.0.331 → 1.0.333

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/cli.js CHANGED
@@ -6,13 +6,13 @@
6
6
  import { assertNodeVersion } from './check-node-version.js';
7
7
  assertNodeVersion({ component: '@yeaft/webchat-agent' });
8
8
 
9
- import { execSync, spawn } from 'child_process';
9
+ import { execFileSync, execSync, spawn } from 'child_process';
10
10
  import { createInterface } from 'readline/promises';
11
11
  import { stdin as input, stdout as output } from 'process';
12
- import { readFileSync, writeFileSync, mkdirSync } from 'fs';
12
+ import { readFileSync, mkdirSync } from 'fs';
13
13
  import { dirname, join } from 'path';
14
14
  import { fileURLToPath } from 'url';
15
- import { platform, homedir } from 'os';
15
+ import { platform } from 'os';
16
16
  import {
17
17
  addOrUpdateProvider,
18
18
  formatLlmConfig,
@@ -29,12 +29,22 @@ import {
29
29
  discoverOpenAICompatibleModels,
30
30
  GITHUB_COPILOT_PROVIDER,
31
31
  } from './llm-model-discovery.js';
32
- import { applyAgentIdentityToEnv, warnDeprecatedInstanceArg } from './service/config.js';
32
+ import {
33
+ DEFAULT_INSTANCE_ID,
34
+ applyAgentIdentityToEnv,
35
+ getConfigDir,
36
+ getPm2AppName,
37
+ resolveServiceInstanceId,
38
+ warnDeprecatedInstanceArg,
39
+ } from './service/config.js';
33
40
  import {
34
41
  buildUpgradeInstallCommand,
35
42
  buildUpgradeMetadataUrl,
36
43
  buildUpgradeVersionCommand,
37
44
  launchWindowsUpgradeScript,
45
+ prepareWindowsUpgradeRunner,
46
+ resolveWindowsNpmCliPath,
47
+ resolveWindowsPm2CliPath,
38
48
  } from './upgrade-command.js';
39
49
 
40
50
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -60,7 +70,7 @@ if (command === 'doctor') {
60
70
  process.exit(1);
61
71
  }
62
72
  } else if (command === 'upgrade') {
63
- await upgrade();
73
+ await upgrade(subArgs);
64
74
  } else if (command === '--version' || command === '-v') {
65
75
  console.log(pkg.version);
66
76
  } else if (command === '--help' || command === '-h') {
@@ -88,7 +98,7 @@ function printHelp() {
88
98
  yeaft-agent logs [options] View service logs (follow mode)
89
99
  yeaft-agent doctor Diagnose service configuration
90
100
  yeaft-agent llm <command> Configure local Yeaft LLM providers/models
91
- yeaft-agent upgrade Upgrade to latest version
101
+ yeaft-agent upgrade [--name <id>] Upgrade and restart the selected service
92
102
  yeaft-agent --version Show version
93
103
 
94
104
  Options:
@@ -486,7 +496,9 @@ async function checkForUpdates() {
486
496
  }
487
497
  }
488
498
 
489
- async function upgrade() {
499
+ async function upgrade(args = []) {
500
+ warnDeprecatedInstanceArg(args);
501
+ const instanceId = resolveServiceInstanceId(args, process.env, { management: true });
490
502
  console.log(`Current version: ${pkg.version}`);
491
503
  console.log('Checking for updates...');
492
504
 
@@ -500,9 +512,9 @@ async function upgrade() {
500
512
 
501
513
  if (platform() === 'win32') {
502
514
  // On Windows, the current process locks its own files. npm cannot overwrite
503
- // them while this process is running. Spawn a detached bat script that waits
504
- // for us to exit, then runs npm install, then optionally restarts the service.
505
- await upgradeWindows(latest);
515
+ // them while this process is running. Spawn a detached Node runner that waits
516
+ // for us to exit, then runs npm install and optionally restarts the service.
517
+ await upgradeWindows(latest, instanceId);
506
518
  } else {
507
519
  execSync(buildUpgradeInstallCommand(`${pkg.name}@${latest}`), { stdio: 'inherit' });
508
520
  console.log(`Successfully upgraded to ${latest}`);
@@ -526,114 +538,72 @@ async function upgrade() {
526
538
  }
527
539
  }
528
540
 
529
- async function upgradeWindows(latestVersion) {
530
- const configDir = join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'yeaft-agent');
531
- mkdirSync(configDir, { recursive: true });
541
+ async function upgradeWindows(latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
542
+ const configDir = getConfigDir(instanceId);
543
+ const upgradeDir = join(configDir, 'upgrade-runtime');
532
544
  const logDir = join(configDir, 'logs');
533
545
  mkdirSync(logDir, { recursive: true });
534
- const batPath = join(configDir, 'upgrade-cli.bat');
535
- const handoffPath = join(configDir, 'upgrade-cli.started');
546
+
547
+ const runnerPath = join(upgradeDir, 'windows-upgrade-runner.js');
548
+ const commandPath = join(upgradeDir, 'upgrade-command.js');
549
+ const payloadPath = join(upgradeDir, 'payload.json');
550
+ const handoffPath = join(upgradeDir, 'started');
536
551
  const logPath = join(logDir, 'upgrade.log');
537
- const pid = process.pid;
538
- const pkgSpec = `${pkg.name}@${latestVersion}`;
552
+ const ecosystemPath = join(configDir, 'ecosystem.config.cjs');
553
+ const pm2AppName = getPm2AppName(instanceId);
554
+ const npmCliPath = resolveWindowsNpmCliPath(process.execPath);
555
+ if (!npmCliPath) throw new Error('npm JavaScript CLI entry point could not be resolved');
556
+ const pm2CliPath = resolveWindowsPm2CliPath(process.execPath);
539
557
 
540
- // Detect PM2 now, but do not delete it until the updater confirms handoff.
541
558
  let isPm2 = false;
542
- const ecoPath = join(configDir, 'ecosystem.config.cjs');
543
- try {
544
- const pm2List = execSync('pm2 jlist', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
545
- const apps = JSON.parse(pm2List);
546
- isPm2 = Array.isArray(apps) && apps.some(app => app.name === 'yeaft-agent');
547
- } catch {
548
- // PM2 not installed or not managing yeaft-agent — continue
549
- }
550
-
551
- const batLines = [
552
- '@echo off',
553
- 'setlocal',
554
- `set PID=${pid}`,
555
- `set PKG=${pkgSpec}`,
556
- `set LOGFILE=${logPath}`,
557
- `set HANDOFF=${handoffPath}`,
558
- `set MAX_WAIT=30`,
559
- `set COUNT=0`,
560
- '',
561
- ':: Change to temp dir to avoid EBUSY on cwd',
562
- 'cd /d "%TEMP%"',
563
- '',
564
- 'echo started>"%HANDOFF%"',
565
- 'echo [Upgrade] Started at %date% %time% > "%LOGFILE%"',
566
- `echo [Upgrade] Version: ${pkg.version} -> ${latestVersion} >> "%LOGFILE%"`,
567
- `echo [Upgrade] PM2 managed: ${isPm2 ? 'yes (deleted pre-exit)' : 'no'} >> "%LOGFILE%"`,
568
- 'echo [Upgrade] Waiting for CLI process (PID %PID%) to exit... >> "%LOGFILE%"',
569
- '',
570
- ':WAIT_LOOP',
571
- 'tasklist /FI "PID eq %PID%" /NH 2>NUL | findstr /C:"%PID%" >NUL',
572
- 'if errorlevel 1 goto PID_EXITED',
573
- 'set /A COUNT+=1',
574
- 'if %COUNT% GEQ %MAX_WAIT% (',
575
- ' echo [Upgrade] Timeout waiting for PID %PID% to exit after %MAX_WAIT%s >> "%LOGFILE%"',
576
- ' goto PID_EXITED',
577
- ')',
578
- 'ping -n 3 127.0.0.1 >NUL',
579
- 'goto WAIT_LOOP',
580
- ':PID_EXITED',
581
- '',
582
- ':: Extra wait for file locks to release',
583
- 'echo [Upgrade] Process exited at %time%, waiting for file locks... >> "%LOGFILE%"',
584
- 'ping -n 5 127.0.0.1 >NUL',
585
- '',
586
- 'echo [Upgrade] Running npm install -g %PKG%... >> "%LOGFILE%"',
587
- `call ${buildUpgradeInstallCommand('%PKG%')} >> "%LOGFILE%" 2>&1`,
588
- 'if not "%errorlevel%"=="0" (',
589
- ' echo [Upgrade] npm install failed with exit code %errorlevel% at %time% >> "%LOGFILE%"',
590
- ' goto PM2_RESTART',
591
- ')',
592
- 'echo [Upgrade] npm install succeeded at %time% >> "%LOGFILE%"',
593
- ];
594
-
595
- // PM2 re-registration after successful upgrade
596
- batLines.push(
597
- '',
598
- ':PM2_RESTART',
599
- );
600
- if (isPm2) {
601
- batLines.push(
602
- 'echo [Upgrade] Re-registering agent via PM2... >> "%LOGFILE%"',
603
- `if exist "${ecoPath}" (`,
604
- ` call pm2 start "${ecoPath}" >> "%LOGFILE%" 2>&1`,
605
- ' call pm2 save >> "%LOGFILE%" 2>&1',
606
- ' echo [Upgrade] PM2 app re-registered at %time% >> "%LOGFILE%"',
607
- ') else (',
608
- ' echo [Upgrade] WARNING: ecosystem.config.cjs not found, PM2 not restarted >> "%LOGFILE%"',
609
- ')',
610
- );
559
+ if (pm2CliPath) {
560
+ try {
561
+ const apps = JSON.parse(execFileSync(process.execPath, [pm2CliPath, 'jlist'], { encoding: 'utf8' }));
562
+ isPm2 = Array.isArray(apps) && apps.some(app => app.name === pm2AppName);
563
+ } catch {}
611
564
  }
612
565
 
613
- batLines.push(
614
- '',
615
- 'echo [Upgrade] Finished at %time% >> "%LOGFILE%"',
616
- ':CLEANUP',
617
- 'del /F /Q "%HANDOFF%" 2>NUL',
618
- `del /F /Q "${batPath}" 2>NUL`,
619
- );
620
-
621
- writeFileSync(batPath, batLines.join('\r\n'));
566
+ const payload = {
567
+ parentPid: process.pid,
568
+ packageSpec: `${pkg.name}@${latestVersion}`,
569
+ globalInstall: true,
570
+ installDir: dirname(dirname(__dirname)),
571
+ logPath,
572
+ handoffPath,
573
+ runnerPath,
574
+ commandPath,
575
+ payloadPath,
576
+ nodePath: process.execPath,
577
+ npmCliPath,
578
+ pm2CliPath: isPm2 ? pm2CliPath : null,
579
+ ecosystemPath: isPm2 ? ecosystemPath : null,
580
+ };
581
+ prepareWindowsUpgradeRunner({
582
+ sourceRunnerPath: join(__dirname, 'windows-upgrade-runner.js'),
583
+ sourceCommandPath: join(__dirname, 'upgrade-command.js'),
584
+ runnerPath,
585
+ commandPath,
586
+ payloadPath,
587
+ payload,
588
+ });
622
589
 
623
590
  const launcher = await launchWindowsUpgradeScript({
624
- batPath,
591
+ nodePath: process.execPath,
592
+ runnerPath,
593
+ payloadPath,
594
+ logPath,
625
595
  handoffPath,
626
596
  spawnProcess: spawn,
627
597
  onHandoff: isPm2
628
598
  ? () => {
629
- execSync('pm2 delete yeaft-agent', { stdio: 'pipe' });
630
- console.log('PM2 app deleted after upgrade handoff.');
599
+ execFileSync(process.execPath, [pm2CliPath, 'delete', pm2AppName], { stdio: 'pipe' });
600
+ console.log(`PM2 app ${pm2AppName} deleted after upgrade handoff.`);
631
601
  }
632
602
  : undefined,
633
603
  });
634
604
 
635
- console.log(`Upgrade script spawned via ${launcher}.`);
636
- console.log(`This process will exit now. The upgrade will proceed after exit.`);
605
+ console.log(`Upgrade runner spawned via ${launcher}.`);
606
+ console.log('This process will exit now. The upgrade will proceed after exit.');
637
607
  console.log(`Check upgrade log: ${logPath}`);
638
608
  process.exit(0);
639
609
  }
@@ -1,5 +1,6 @@
1
1
  import { execFile, execFileSync, spawn } from 'child_process';
2
2
  import { writeFileSync, mkdirSync, existsSync } from 'fs';
3
+ import { fileURLToPath } from 'url';
3
4
  import { join, dirname } from 'path';
4
5
  import { platform, homedir } from 'os';
5
6
  import ctx from '../context.js';
@@ -7,8 +8,10 @@ import { getConfigDir, getServiceName, getPm2AppName, getLaunchdPlistPath, DEFAU
7
8
  import {
8
9
  buildUpgradeInstallCommand,
9
10
  buildUpgradeMetadataArgs,
10
- buildUpgradeUpdateCommand,
11
11
  launchWindowsUpgradeScript,
12
+ prepareWindowsUpgradeRunner,
13
+ resolveWindowsNpmCliPath,
14
+ resolveWindowsPm2CliPath,
12
15
  } from '../upgrade-command.js';
13
16
  import { sendToServer } from './buffer.js';
14
17
  import { stopAgentHeartbeat } from './heartbeat.js';
@@ -53,11 +56,6 @@ export function isUpgradeAvailable(currentVersion, latestVersion) {
53
56
  return cmpTuple(latest, current) > 0;
54
57
  }
55
58
 
56
- /** Build the detached Windows npm invocation used after the Agent exits. */
57
- export function buildWindowsUpgradeCommand() {
58
- return `call ${buildUpgradeUpdateCommand('%PKG%')}`;
59
- }
60
-
61
59
  // Shared cleanup logic for restart/upgrade
62
60
  function cleanupAndExit(exitCode) {
63
61
  setTimeout(async () => {
@@ -268,115 +266,65 @@ export async function handleUpgradeAgent() {
268
266
  }
269
267
 
270
268
  async function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
271
- const pid = process.pid;
272
269
  const configDir = getConfigDir(instanceId);
273
- mkdirSync(configDir, { recursive: true });
270
+ const upgradeDir = join(configDir, 'upgrade-runtime');
274
271
  const logDir = join(configDir, 'logs');
275
272
  mkdirSync(logDir, { recursive: true });
276
- const batPath = join(configDir, 'upgrade.bat');
277
- const handoffPath = join(configDir, 'upgrade.started');
273
+
274
+ const runnerPath = join(upgradeDir, 'windows-upgrade-runner.js');
275
+ const commandPath = join(upgradeDir, 'upgrade-command.js');
276
+ const payloadPath = join(upgradeDir, 'payload.json');
277
+ const handoffPath = join(upgradeDir, 'started');
278
278
  const logPath = join(logDir, 'upgrade.log');
279
+ const ecosystemPath = join(configDir, 'ecosystem.config.cjs');
280
+ const npmCliPath = resolveWindowsNpmCliPath(process.execPath);
281
+ if (!npmCliPath) throw new Error('npm JavaScript CLI entry point could not be resolved');
279
282
  const isPm2 = !!process.env.pm_id;
280
- const installDirWin = installDir.replace(/\//g, '\\');
281
- const ecoPath = join(configDir, 'ecosystem.config.cjs').replace(/\//g, '\\');
282
-
283
- const pm2Win = pm2Path.replace(/\//g, '\\');
284
-
285
- const batLines = [
286
- '@echo off',
287
- 'setlocal',
288
- `set PID=${pid}`,
289
- `set PKG=${pkgName}`,
290
- `set INSTALL_DIR=${installDirWin}`,
291
- `set LOGFILE=${logPath}`,
292
- `set HANDOFF=${handoffPath}`,
293
- `set MAX_WAIT=30`,
294
- `set COUNT=0`,
295
- '',
296
- ':: Change to temp dir to avoid EBUSY on cwd',
297
- 'cd /d "%TEMP%"',
298
- '',
299
- 'echo started>"%HANDOFF%"',
300
- 'echo [Upgrade] Started at %date% %time% > "%LOGFILE%"',
301
- `echo [Upgrade] Version: ${ctx.agentVersion} -> ${latestVersion} >> "%LOGFILE%"`,
302
- `echo [Upgrade] PM2 managed: ${isPm2 ? 'yes (deleted pre-exit)' : 'no'} >> "%LOGFILE%"`,
303
- `echo [Upgrade] Install dir: ${installDirWin} >> "%LOGFILE%"`,
304
- ];
305
-
306
- // Wait for old process to exit (PM2 already deleted before exit, so no auto-restart race)
307
- batLines.push(
308
- 'echo [Upgrade] Waiting for PID %PID% to exit... >> "%LOGFILE%"',
309
- ':WAIT_LOOP',
310
- 'tasklist /FI "PID eq %PID%" /NH 2>NUL | findstr /C:"%PID%" >NUL',
311
- 'if errorlevel 1 goto PID_EXITED',
312
- 'set /A COUNT+=1',
313
- 'if %COUNT% GEQ %MAX_WAIT% (',
314
- ' echo [Upgrade] Timeout waiting for PID %PID% to exit after %MAX_WAIT%s >> "%LOGFILE%"',
315
- ' goto PID_EXITED',
316
- ')',
317
- 'ping -n 3 127.0.0.1 >NUL',
318
- 'goto WAIT_LOOP',
319
- ':PID_EXITED',
320
- );
321
-
322
- // No need to pm2 stop — PM2 app was already deleted before process exit.
323
- // Wait extra time for file locks to fully release.
324
- batLines.push(
325
- 'echo [Upgrade] Process exited at %time%, waiting for file locks... >> "%LOGFILE%"',
326
- 'ping -n 5 127.0.0.1 >NUL',
327
- );
328
-
329
- // Run npm only after the Agent has released its package files. The old
330
- // package-copy worker performed a second dependency install, hid failures,
331
- // and bypassed npm's supported global-update transaction.
332
- batLines.push(
333
- 'echo [Upgrade] Running npm update at %time%... >> "%LOGFILE%"',
334
- `${buildWindowsUpgradeCommand()} >> "%LOGFILE%" 2>&1`,
335
- 'if not "%errorlevel%"=="0" (',
336
- ' echo [Upgrade] npm update failed with exit code %errorlevel% at %time% >> "%LOGFILE%"',
337
- ' goto RESTART_SERVICE',
338
- ')',
339
- 'echo [Upgrade] npm update succeeded at %time% >> "%LOGFILE%"',
340
- );
341
-
342
- batLines.push(':RESTART_SERVICE');
343
-
344
- if (isPm2) {
345
- // Re-register and start via ecosystem config (PM2 app was deleted pre-exit)
346
- batLines.push(
347
- 'echo [Upgrade] Re-registering agent via PM2... >> "%LOGFILE%"',
348
- `if exist "${ecoPath}" (`,
349
- ` call "${pm2Win}" start "${ecoPath}" >> "%LOGFILE%" 2>&1`,
350
- ` call "${pm2Win}" save >> "%LOGFILE%" 2>&1`,
351
- ' echo [Upgrade] PM2 app re-registered at %time% >> "%LOGFILE%"',
352
- ') else (',
353
- ' echo [Upgrade] WARNING: ecosystem.config.cjs not found, PM2 not restarted >> "%LOGFILE%"',
354
- ')',
355
- );
283
+ const pm2CliPath = isPm2 ? resolveWindowsPm2CliPath(process.execPath) : null;
284
+ if (isPm2 && !pm2CliPath) {
285
+ throw new Error('PM2 manages this Agent, but its JavaScript CLI entry point could not be resolved');
356
286
  }
357
287
 
358
- batLines.push(
359
- '',
360
- 'echo [Upgrade] Finished at %time% >> "%LOGFILE%"',
361
- 'del /F /Q "%HANDOFF%" 2>NUL',
362
- `del /F /Q "${batPath}"`,
363
- );
364
-
365
- writeFileSync(batPath, batLines.join('\r\n'));
288
+ const payload = {
289
+ parentPid: process.pid,
290
+ packageSpec: `${pkgName}@${latestVersion}`,
291
+ globalInstall: isGlobalInstall,
292
+ installDir,
293
+ logPath,
294
+ handoffPath,
295
+ runnerPath,
296
+ commandPath,
297
+ payloadPath,
298
+ nodePath: process.execPath,
299
+ npmCliPath,
300
+ pm2CliPath,
301
+ ecosystemPath: isPm2 ? ecosystemPath : null,
302
+ };
303
+ prepareWindowsUpgradeRunner({
304
+ sourceRunnerPath: fileURLToPath(new URL('../windows-upgrade-runner.js', import.meta.url)),
305
+ sourceCommandPath: fileURLToPath(new URL('../upgrade-command.js', import.meta.url)),
306
+ runnerPath,
307
+ commandPath,
308
+ payloadPath,
309
+ payload,
310
+ });
366
311
 
367
312
  const launcher = await launchWindowsUpgradeScript({
368
- batPath,
313
+ nodePath: process.execPath,
314
+ runnerPath,
315
+ payloadPath,
316
+ logPath,
369
317
  handoffPath,
370
318
  spawnProcess: spawn,
371
319
  onHandoff: isPm2
372
320
  ? () => {
373
- execFileSync(pm2Path, ['delete', getPm2AppName(instanceId)], { stdio: 'pipe', env: safeEnv, ...shellOpt });
321
+ execFileSync(process.execPath, [pm2CliPath, 'delete', getPm2AppName(instanceId)], { stdio: 'pipe', env: safeEnv });
374
322
  console.log('[Agent] PM2 app deleted after upgrade handoff');
375
323
  }
376
324
  : undefined,
377
325
  });
378
326
 
379
- console.log(`[Agent] Spawned upgrade via ${launcher} (PID wait for ${pid}, pm2=${isPm2}, dir=${installDir}): ${batPath}`);
327
+ console.log(`[Agent] Spawned Windows upgrade runner via ${launcher} (pm2=${isPm2}, dir=${installDir})`);
380
328
  await sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
381
329
  }
382
330
 
@@ -1,5 +1,41 @@
1
1
  import { stmts, transaction } from './connection.js';
2
2
 
3
+ export function applySessionUiMetadataUpdates(userId, updates, now = Date.now()) {
4
+ if (!userId || !Array.isArray(updates) || updates.length === 0) return false;
5
+ for (const update of updates) {
6
+ if (!update?.catalogKey) throw new Error('Catalog metadata update requires catalogKey');
7
+ stmts.upsertSessionUiMetadata.run(
8
+ userId,
9
+ update.catalogKey,
10
+ update.pinned === true ? 1 : 0,
11
+ Number.isFinite(update.sortRank) ? update.sortRank : null,
12
+ now,
13
+ );
14
+ if (update.runtimeProvider === 'yeaft') {
15
+ const result = stmts.setYeaftSessionPinnedForAgent.run(
16
+ update.pinned === true ? 1 : 0,
17
+ now,
18
+ update.sessionId,
19
+ userId,
20
+ update.agentId,
21
+ );
22
+ if (result.changes !== 1) throw new Error('Yeaft Session identity changed during metadata update');
23
+ } else if (update.runtimeProvider === 'claude-code' || update.runtimeProvider === 'copilot') {
24
+ const result = stmts.updateSessionPinnedForRoute.run(
25
+ update.pinned === true ? 1 : 0,
26
+ now,
27
+ update.sessionId,
28
+ update.agentId,
29
+ userId,
30
+ );
31
+ if (result.changes !== 1) throw new Error('Chat Session identity changed during metadata update');
32
+ } else {
33
+ throw new Error('Unknown Session runtime provider during metadata update');
34
+ }
35
+ }
36
+ return true;
37
+ }
38
+
3
39
  function mapRow(row) {
4
40
  if (!row) return null;
5
41
  return {
@@ -36,39 +72,7 @@ export const sessionUiMetadataDb = {
36
72
 
37
73
  applyBatch(userId, updates) {
38
74
  if (!userId || !Array.isArray(updates) || updates.length === 0) return false;
39
- return transaction(() => {
40
- const now = Date.now();
41
- for (const update of updates) {
42
- if (!update?.catalogKey) throw new Error('Catalog metadata update requires catalogKey');
43
- stmts.upsertSessionUiMetadata.run(
44
- userId,
45
- update.catalogKey,
46
- update.pinned === true ? 1 : 0,
47
- Number.isFinite(update.sortRank) ? update.sortRank : null,
48
- now,
49
- );
50
- if (update.runtimeProvider === 'yeaft') {
51
- const result = stmts.setYeaftSessionPinnedForAgent.run(
52
- update.pinned === true ? 1 : 0,
53
- now,
54
- update.sessionId,
55
- userId,
56
- update.agentId,
57
- );
58
- if (result.changes !== 1) throw new Error('Yeaft Session identity changed during metadata update');
59
- } else if (update.runtimeProvider === 'claude-code' || update.runtimeProvider === 'copilot') {
60
- const result = stmts.updateSessionPinnedForRoute.run(
61
- update.pinned === true ? 1 : 0,
62
- now,
63
- update.sessionId,
64
- update.agentId,
65
- userId,
66
- );
67
- if (result.changes !== 1) throw new Error('Chat Session identity changed during metadata update');
68
- }
69
- }
70
- return true;
71
- })();
75
+ return transaction(() => applySessionUiMetadataUpdates(userId, updates))();
72
76
  },
73
77
 
74
78
  delete(userId, catalogKey) {
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { stmts, transaction } from './connection.js';
3
+ import { applySessionUiMetadataUpdates } from './session-ui-metadata-db.js';
3
4
 
4
5
  export class YeaftProjectDbError extends Error {
5
6
  constructor(code, message) {
@@ -137,7 +138,12 @@ export const yeaftProjectDb = {
137
138
  return { projectId: id };
138
139
  },
139
140
 
140
- moveSession(userId, { agentId, sessionId, projectId = null } = {}) {
141
+ moveSession(userId, {
142
+ agentId,
143
+ sessionId,
144
+ projectId = null,
145
+ catalogUpdates = null,
146
+ } = {}) {
141
147
  const ownerId = requireUserId(userId);
142
148
  const targetAgentId = requireId(agentId, 'invalid_agent_id', 'Agent id');
143
149
  const targetSessionId = requireId(sessionId, 'invalid_session_id', 'Session id');
@@ -145,8 +151,12 @@ export const yeaftProjectDb = {
145
151
  ? null
146
152
  : requireId(projectId, 'invalid_project_id', 'Project id');
147
153
  if (targetProjectId) requireProject(ownerId, targetProjectId);
154
+ if (catalogUpdates !== null && (!Array.isArray(catalogUpdates) || catalogUpdates.length === 0)) {
155
+ throw new YeaftProjectDbError('invalid_catalog_order', 'Complete catalog order is required');
156
+ }
148
157
 
149
- transaction(() => {
158
+ return transaction(() => {
159
+ const now = Date.now();
150
160
  stmts.deleteYeaftProjectSessionMembership.run(ownerId, targetAgentId, targetSessionId);
151
161
  if (targetProjectId) {
152
162
  stmts.insertYeaftProjectSessionMembership.run(
@@ -154,11 +164,12 @@ export const yeaftProjectDb = {
154
164
  targetProjectId,
155
165
  targetAgentId,
156
166
  targetSessionId,
157
- Date.now(),
167
+ now,
158
168
  );
159
169
  }
170
+ if (catalogUpdates) applySessionUiMetadataUpdates(ownerId, catalogUpdates, now);
171
+ return { agentId: targetAgentId, sessionId: targetSessionId, projectId: targetProjectId };
160
172
  })();
161
- return { agentId: targetAgentId, sessionId: targetSessionId, projectId: targetProjectId };
162
173
  },
163
174
 
164
175
  reconcileAgentSessions(userId, agentId, sessionIds) {
@@ -11,7 +11,7 @@ import {
11
11
  import { agents, pendingFiles, trackUserTurn, webClients } from '../context.js';
12
12
  import {
13
13
  sendToWebClient, forwardToAgent,
14
- broadcastAgentList, broadcastSessionCatalog,
14
+ broadcastAgentList, broadcastSessionCatalog, buildSessionCatalog,
15
15
  verifyConversationOwnership, verifyAgentOwnership
16
16
  } from '../ws-utils.js';
17
17
  import { routeSessionPin } from './session-pin-router.js';
@@ -100,6 +100,35 @@ function persistSessionPin(userId, routeRef, pinned) {
100
100
  }]);
101
101
  }
102
102
 
103
+ function catalogOrderUpdates(client, items) {
104
+ if (!client?.userId || !Array.isArray(items) || items.length === 0) return null;
105
+ const canonical = buildSessionCatalog(client.userId, client.role);
106
+ if (canonical.length !== items.length) return null;
107
+ const canonicalByKey = new Map(canonical.map(row => [row.catalogKey, row]));
108
+ if (canonicalByKey.size !== canonical.length) return null;
109
+
110
+ const seen = new Set();
111
+ const updates = [];
112
+ for (const [sortRank, item] of items.entries()) {
113
+ const row = canonicalByKey.get(item?.catalogKey);
114
+ const routeRef = item?.routeRef;
115
+ if (!row || seen.has(item.catalogKey)
116
+ || routeRef?.runtimeProvider !== row.routeRef?.runtimeProvider
117
+ || routeRef?.agentId !== row.routeRef?.agentId
118
+ || routeRef?.sessionId !== row.routeRef?.sessionId) return null;
119
+ seen.add(item.catalogKey);
120
+ updates.push({
121
+ catalogKey: row.catalogKey,
122
+ runtimeProvider: row.routeRef.runtimeProvider,
123
+ agentId: row.routeRef.agentId,
124
+ sessionId: row.routeRef.sessionId,
125
+ pinned: row.pinned === true,
126
+ sortRank,
127
+ });
128
+ }
129
+ return seen.size === canonical.length ? updates : null;
130
+ }
131
+
103
132
  export function groupOnlineYeaftSessions(rows, agentRegistry = agents) {
104
133
  const byAgent = {};
105
134
  for (const row of Array.isArray(rows) ? rows : []) {
@@ -522,43 +551,9 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
522
551
 
523
552
  case 'reorder_session_catalog': {
524
553
  if (!client.userId || !Array.isArray(msg.sessions)) break;
525
- const updates = [];
526
- for (const [index, item] of msg.sessions.entries()) {
527
- const routeRef = item?.routeRef;
528
- if (!item?.catalogKey || !routeRef?.runtimeProvider) { updates.length = 0; break; }
529
- const { runtimeProvider, agentId, sessionId } = routeRef;
530
- let expectedCatalogKey = null;
531
- if (runtimeProvider === 'yeaft') {
532
- if (!agentId || !sessionId || !yeaftSessionDb.getForAgent(client.userId, agentId, sessionId)) {
533
- updates.length = 0;
534
- break;
535
- }
536
- expectedCatalogKey = yeaftCatalogKey(agentId, sessionId);
537
- } else if (runtimeProvider === 'claude-code' || runtimeProvider === 'copilot') {
538
- if (!agentId || !sessionId
539
- || (!CONFIG.skipAuth && !verifyConversationOwnership(sessionId, client.userId, client.role))) {
540
- updates.length = 0;
541
- break;
542
- }
543
- const row = sessionDb.get(sessionId);
544
- if (!row || row.agent_id !== agentId || (row.provider || 'claude-code') !== runtimeProvider) {
545
- updates.length = 0;
546
- break;
547
- }
548
- expectedCatalogKey = chatCatalogKey(sessionId);
549
- }
550
- if (expectedCatalogKey !== item.catalogKey) { updates.length = 0; break; }
551
- updates.push({
552
- catalogKey: expectedCatalogKey,
553
- runtimeProvider,
554
- agentId,
555
- sessionId,
556
- pinned: item.pinned === true,
557
- sortRank: index,
558
- });
559
- }
554
+ const updates = catalogOrderUpdates(client, msg.sessions);
560
555
  let persisted = false;
561
- if (updates.length === msg.sessions.length && updates.length > 0) {
556
+ if (updates) {
562
557
  try {
563
558
  persisted = sessionUiMetadataDb.applyBatch(client.userId, updates);
564
559
  if (persisted) await broadcastSessionCatalog(client.userId);
@@ -1340,10 +1335,19 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
1340
1335
  error.code = 'session_archived';
1341
1336
  throw error;
1342
1337
  }
1338
+ const catalogUpdates = msg.catalogOrder === undefined
1339
+ ? null
1340
+ : catalogOrderUpdates(client, msg.catalogOrder);
1341
+ if (msg.catalogOrder !== undefined && !catalogUpdates) {
1342
+ const error = new Error('Complete canonical catalog order is required');
1343
+ error.code = 'invalid_catalog_order';
1344
+ throw error;
1345
+ }
1343
1346
  result = yeaftProjectDb.moveSession(client.userId, {
1344
1347
  agentId,
1345
1348
  sessionId,
1346
1349
  projectId: msg.projectId || null,
1350
+ catalogUpdates,
1347
1351
  });
1348
1352
  } else {
1349
1353
  throw new Error('Unknown Project operation');