From b05b2d36e697d09c9736abd913881e3aa4e72a4f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:32:46 -0700 Subject: [PATCH 01/17] Add per-installation wake and pod management controls Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- .../service/agent-manage-settings.test.js | 125 +++++++++++++++++ backend/routes/registry.ts | 2 + backend/routes/registry/manage-settings.ts | 87 ++++++++++++ backend/routes/registry/pod-agents.ts | 4 +- frontend/src/i18n/locales/en.json | 15 +++ frontend/src/i18n/locales/zh-CN.json | 15 +++ .../src/v2/__tests__/V2AgentWakePods.test.tsx | 51 +++++++ frontend/src/v2/agents/V2AgentWakePods.tsx | 126 ++++++++++++++++++ frontend/src/v2/agents/v2-agent-wake-pods.css | 14 ++ 9 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 backend/__tests__/service/agent-manage-settings.test.js create mode 100644 backend/routes/registry/manage-settings.ts create mode 100644 frontend/src/v2/__tests__/V2AgentWakePods.test.tsx create mode 100644 frontend/src/v2/agents/V2AgentWakePods.tsx create mode 100644 frontend/src/v2/agents/v2-agent-wake-pods.css diff --git a/backend/__tests__/service/agent-manage-settings.test.js b/backend/__tests__/service/agent-manage-settings.test.js new file mode 100644 index 0000000000..9cff160cbf --- /dev/null +++ b/backend/__tests__/service/agent-manage-settings.test.js @@ -0,0 +1,125 @@ +const express = require('express'); +const request = require('supertest'); +const jwt = require('jsonwebtoken'); +const { setupMongoDb, closeMongoDb } = require('../utils/testUtils'); +const User = require('../../models/User'); +const Pod = require('../../models/Pod'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const AgentMemory = require('../../models/AgentMemory'); +const manageSettings = require('../../routes/registry/manage-settings'); +const podAgents = require('../../routes/registry/pod-agents'); + +const SECRET = 'agent-manage-settings-test'; +const token = (user) => jwt.sign({ id: String(user._id) }, SECRET); +const config = (installation) => Object.fromEntries(installation.config || []); + +describe('per-installation agent settings', () => { + let app; let owner; let other; let admin; let podA; let podB; + + beforeAll(async () => { + process.env.JWT_SECRET = SECRET; + await setupMongoDb(); + app = express(); + app.use(express.json()); + app.use('/api/registry', manageSettings); + app.use('/api/registry', podAgents); + owner = await User.create({ username: 'settings-owner', email: 'settings-owner@test.com', password: 'password123' }); + other = await User.create({ username: 'settings-other', email: 'settings-other@test.com', password: 'password123' }); + admin = await User.create({ username: 'settings-admin', email: 'settings-admin@test.com', password: 'password123', role: 'admin' }); + podA = await Pod.create({ name: 'Settings A', type: 'chat', createdBy: owner._id, members: [owner._id, other._id] }); + podB = await Pod.create({ name: 'Settings B', type: 'chat', createdBy: other._id, members: [owner._id, other._id] }); + }); + + afterAll(async () => closeMongoDb()); + + beforeEach(async () => { + await AgentInstallation.deleteMany({ agentName: 'settings-agent' }); + await AgentInstallation.create([ + { agentName: 'settings-agent', instanceId: 'same', podId: podA._id, version: '1', installedBy: owner._id, + config: new Map([['wakeOnMessage', { enabled: true, humanOnly: true }], ['boardWake', { enabled: false }], ['runtime', { runtimeType: 'wrapper' }]]) }, + { agentName: 'settings-agent', instanceId: 'same', podId: podB._id, version: '1', installedBy: other._id, + config: new Map([['wakeOnMessage', { enabled: false }], ['runtime', { runtimeType: 'wrapper' }]]) }, + ]); + }); + + it('lists only installations the caller may manage', async () => { + const response = await request(app).get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') + .set('Authorization', `Bearer ${token(owner)}`); + expect(response.status).toBe(200); + expect(response.body.installations).toEqual([expect.objectContaining({ + podId: String(podA._id), wakeOnMessage: true, boardWake: false, heartbeatMinutes: 60, + })]); + }); + + it('writes one installation and preserves its human-only default and runtime', async () => { + const response = await request(app) + .patch(`/api/registry/agents/settings-agent/manage-settings/pods/${podA._id}?instanceId=same`) + .set('Authorization', `Bearer ${token(owner)}`) + .send({ wakeOnMessage: false, boardWake: true, heartbeatMinutes: 90 }); + expect(response.status).toBe(200); + const [a, b] = await Promise.all([ + AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' }), + AgentInstallation.findOne({ podId: podB._id, agentName: 'settings-agent' }), + ]); + expect(config(a).wakeOnMessage).toEqual({ enabled: false, humanOnly: true }); + expect(config(a).boardWake).toEqual({ enabled: true }); + expect(config(a).heartbeat).toEqual({ everyMinutes: 90 }); + expect(config(a).runtime).toEqual({ runtimeType: 'wrapper' }); + expect(config(b).wakeOnMessage).toEqual({ enabled: false }); + expect(config(b).heartbeat).toBeUndefined(); + }); + + it('refuses another pod member and invalid intervals before writing', async () => { + const url = `/api/registry/agents/settings-agent/manage-settings/pods/${podA._id}?instanceId=same`; + const body = { wakeOnMessage: false, boardWake: true, heartbeatMinutes: 30 }; + const denied = await request(app).patch(url).set('Authorization', `Bearer ${token(other)}`).send(body); + const malformed = await request(app).patch(url).set('Authorization', `Bearer ${token(owner)}`) + .send({ ...body, heartbeatMinutes: 0 }); + expect(denied.status).toBe(403); + expect(malformed.status).toBe(400); + const unchanged = await AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' }); + expect(config(unchanged).wakeOnMessage).toEqual({ enabled: true, humanOnly: true }); + }); + + it('lets an instance admin manage an installation they did not create', async () => { + const listed = await request(app).get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') + .set('Authorization', `Bearer ${token(admin)}`); + expect(listed.status).toBe(200); + expect(listed.body.installations).toHaveLength(2); + const saved = await request(app) + .patch(`/api/registry/agents/settings-agent/manage-settings/pods/${podB._id}?instanceId=same`) + .set('Authorization', `Bearer ${token(admin)}`) + .send({ wakeOnMessage: true, boardWake: false, heartbeatMinutes: 120 }); + expect(saved.status).toBe(200); + expect(config(await AgentInstallation.findOne({ podId: podB._id, agentName: 'settings-agent' })).heartbeat) + .toEqual({ everyMinutes: 120 }); + }); + + it('removes one pod installation while keeping the agent identity and memory', async () => { + const bot = await User.create({ + username: 'settings-agent-same', email: 'settings-agent@test.com', password: 'password123', + isBot: true, botMetadata: { agentName: 'settings-agent', instanceId: 'same' }, + }); + const memory = await AgentMemory.create({ + agentName: 'settings-agent', instanceId: 'same', + sections: { long_term: { content: 'Remember this', visibility: 'private', byteSize: 13 } }, + }); + const response = await request(app) + .delete(`/api/registry/agents/settings-agent/pods/${podA._id}?instanceId=same`) + .set('Authorization', `Bearer ${token(owner)}`); + expect(response.status).toBe(200); + expect((await AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' })).status).toBe('uninstalled'); + expect((await AgentInstallation.findOne({ podId: podB._id, agentName: 'settings-agent' })).status).toBe('active'); + expect(await User.exists({ _id: bot._id })).toBeTruthy(); + expect(await AgentMemory.exists({ _id: memory._id })).toBeTruthy(); + }); + + it('does not let a pod creator remove someone else’s installation', async () => { + await AgentInstallation.updateOne({ podId: podA._id, agentName: 'settings-agent' }, { installedBy: other._id }); + const response = await request(app) + .delete(`/api/registry/agents/settings-agent/pods/${podA._id}?instanceId=same`) + .set('Authorization', `Bearer ${token(owner)}`); + expect(response.status).toBe(403); + expect((await AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' })).status).toBe('active'); + }); +}); diff --git a/backend/routes/registry.ts b/backend/routes/registry.ts index 669667b759..df9bb3b352 100644 --- a/backend/routes/registry.ts +++ b/backend/routes/registry.ts @@ -35,6 +35,8 @@ router.use(require('./registry/provision')); // eslint-disable-next-line global-require router.use(require('./registry/agent-config')); // eslint-disable-next-line global-require +router.use(require('./registry/manage-settings')); +// eslint-disable-next-line global-require router.use(require('./registry/publish')); module.exports = router; diff --git a/backend/routes/registry/manage-settings.ts b/backend/routes/registry/manage-settings.ts new file mode 100644 index 0000000000..f8e115506f --- /dev/null +++ b/backend/routes/registry/manage-settings.ts @@ -0,0 +1,87 @@ +// Owner/admin controls for one agent installation at a time. +const express = require('express'); +const auth = require('../../middleware/auth'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const Pod = require('../../models/Pod'); +const User = require('../../models/User'); +const { getUserId, normalizeConfigMap, normalizeInstanceId } = require('./helpers'); + +const router = express.Router(); + +const canManage = (installation: any, userId: string, isAdmin: boolean) => ( + isAdmin || String(installation.installedBy) === String(userId) +); + +const settings = (installation: any, podName: string) => { + const config = normalizeConfigMap(installation.config) || {}; + const wakeOnMessage = config.wakeOnMessage?.enabled === true; + return { + podId: String(installation.podId), + podName, + wakeOnMessage, + boardWake: typeof config.boardWake?.enabled === 'boolean' + ? config.boardWake.enabled : wakeOnMessage, + heartbeatMinutes: Number.isFinite(Number(config.heartbeat?.everyMinutes)) + && Number(config.heartbeat?.everyMinutes) > 0 + ? Math.max(1, Math.min(1440, Math.trunc(Number(config.heartbeat.everyMinutes)))) : 60, + }; +}; + +router.get('/agents/:name/manage-settings', auth, async (req: any, res: any) => { + try { + const userId = getUserId(req); + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const isAdmin = (await User.findById(userId).select('role').lean())?.role === 'admin'; + const installations = await AgentInstallation.find({ + agentName: String(req.params.name).toLowerCase(), + instanceId: normalizeInstanceId(req.query.instanceId), + status: { $ne: 'uninstalled' }, + }).lean(); + const manageable = installations.filter((entry: any) => canManage(entry, userId, isAdmin)); + const pods = await Pod.find({ _id: { $in: manageable.map((entry: any) => entry.podId) } }) + .select('name').lean(); + const names = new Map(pods.map((pod: any) => [String(pod._id), String(pod.name)])); + return res.json({ installations: manageable + .filter((entry: any) => names.has(String(entry.podId))) + .map((entry: any) => settings(entry, names.get(String(entry.podId)) || 'Pod')) }); + } catch (error) { + console.error('Error loading agent settings:', error); + return res.status(500).json({ error: 'Failed to load agent settings' }); + } +}); + +router.patch('/agents/:name/manage-settings/pods/:podId', auth, async (req: any, res: any) => { + try { + const userId = getUserId(req); + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const { wakeOnMessage, boardWake, heartbeatMinutes } = req.body || {}; + if (typeof wakeOnMessage !== 'boolean' || typeof boardWake !== 'boolean' + || !Number.isInteger(heartbeatMinutes) || heartbeatMinutes < 1 || heartbeatMinutes > 1440) { + return res.status(400).json({ error: 'Invalid wake settings' }); + } + const installation = await AgentInstallation.findOne({ + agentName: String(req.params.name).toLowerCase(), + instanceId: normalizeInstanceId(req.query.instanceId), + podId: req.params.podId, + status: { $ne: 'uninstalled' }, + }); + if (!installation) return res.status(404).json({ error: 'Agent not installed in this pod' }); + const isAdmin = (await User.findById(userId).select('role').lean())?.role === 'admin'; + if (!canManage(installation, userId, isAdmin)) return res.status(403).json({ error: 'Installer or admin required' }); + const config = normalizeConfigMap(installation.config) || {}; + installation.config = new Map(Object.entries({ + ...config, + wakeOnMessage: { ...config.wakeOnMessage, enabled: wakeOnMessage }, + boardWake: { ...config.boardWake, enabled: boardWake }, + heartbeat: { ...config.heartbeat, everyMinutes: heartbeatMinutes }, + })); + await installation.save(); + return res.json({ settings: settings(installation, '') }); + } catch (error) { + console.error('Error saving agent settings:', error); + return res.status(500).json({ error: 'Failed to save agent settings' }); + } +}); + +module.exports = router; +export {}; diff --git a/backend/routes/registry/pod-agents.ts b/backend/routes/registry/pod-agents.ts index d0735dbdd3..15ddb5a485 100644 --- a/backend/routes/registry/pod-agents.ts +++ b/backend/routes/registry/pod-agents.ts @@ -111,8 +111,8 @@ podAgentsRouter.delete('/agents/:name/pods/:podId', auth, async (req: any, res: const isInstaller = installation.installedBy?.toString?.() === userId.toString(); - if (!isCreator && !isInstaller && !isGlobalAdmin) { - return res.status(403).json({ error: 'Only pod admins or installers can remove agents' }); + if (!isInstaller && !isGlobalAdmin) { + return res.status(403).json({ error: 'Only the agent installer or an instance admin can remove an installed agent' }); } await AgentInstallation.uninstall(name, podId, instanceId); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8bdad2dcd8..0102e8cfb4 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1602,6 +1602,21 @@ "license": "License (Apache-2.0)" } }, + "agentSettings": { + "wakeTitle": "Wake", + "wakeOnMessage": "Wake on messages", + "boardWake": "Wake on board changes", + "heartbeat": "Heartbeat", + "everyMinutes_one": "Every minute", + "everyMinutes_other": "Every {{count}} minutes", + "podsTitle": "Pods", + "remove": "Remove from pod", + "removeConfirm": "Remove this agent from {{podName}}? Its identity and memory stay intact.", + "noPods": "No pods to manage", + "loadError": "Could not load settings.", + "saveError": "Could not save wake settings.", + "removeError": "Could not remove the agent from this pod." + }, "agentProfile": { "loading": "Loading…", "nav": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index d8fd38c82b..24103336c7 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1594,6 +1594,21 @@ "title": "其他接入方式" } }, + "agentSettings": { + "wakeTitle": "唤醒", + "wakeOnMessage": "收到消息时唤醒", + "boardWake": "看板变更时唤醒", + "heartbeat": "心跳间隔", + "everyMinutes_one": "每分钟", + "everyMinutes_other": "每{{count}}分钟", + "podsTitle": "Pod", + "remove": "从 Pod 中移除", + "removeConfirm": "从 {{podName}} 移除此智能体?身份和记忆会保留。", + "noPods": "没有可管理的 Pod", + "loadError": "无法加载设置。", + "saveError": "无法保存唤醒设置。", + "removeError": "无法从此 Pod 移除智能体。" + }, "agentProfile": { "loading": "加载中…", "nav": { diff --git a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx new file mode 100644 index 0000000000..6be790f0bb --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import V2AgentWakePods from '../agents/V2AgentWakePods'; +import i18n, { i18nReady } from '../../i18n'; + +jest.mock('axios', () => ({ + __esModule: true, + default: { get: jest.fn(), patch: jest.fn(), delete: jest.fn() }, +})); + +const client = (jest.requireMock('axios') as { default: { get: jest.Mock; patch: jest.Mock; delete: jest.Mock } }).default; +const renderSettings = () => render( + , +); + +describe('per-agent Wake and Pods settings', () => { + beforeAll(async () => { + await i18nReady; + await i18n.changeLanguage('en'); + }); + + beforeEach(() => { + jest.clearAllMocks(); + client.get.mockResolvedValue({ data: { installations: [{ + podId: 'pod-1', podName: 'Launch', wakeOnMessage: true, boardWake: false, heartbeatMinutes: 60, + }] } }); + client.patch.mockResolvedValue({ data: {} }); + client.delete.mockResolvedValue({ data: {} }); + }); + + it('writes settings to the selected installation with the instance identity', async () => { + renderSettings(); + fireEvent.click(await screen.findByLabelText('Wake on board changes')); + await waitFor(() => expect(client.patch).toHaveBeenCalledWith( + '/api/registry/agents/test-agent/manage-settings/pods/pod-1?instanceId=my-machine', + { wakeOnMessage: true, boardWake: true, heartbeatMinutes: 60 }, + )); + }); + + it('removes a pod installation and leaves the settings view open', async () => { + const confirm = jest.spyOn(window, 'confirm').mockReturnValue(true); + renderSettings(); + fireEvent.click(await screen.findByRole('button', { name: 'Remove from pod' })); + await waitFor(() => expect(client.delete).toHaveBeenCalledWith( + '/api/registry/agents/test-agent/pods/pod-1?instanceId=my-machine', + )); + expect(await screen.findByText('No pods to manage')).toBeInTheDocument(); + confirm.mockRestore(); + }); +}); diff --git a/frontend/src/v2/agents/V2AgentWakePods.tsx b/frontend/src/v2/agents/V2AgentWakePods.tsx new file mode 100644 index 0000000000..1bf5fbab9f --- /dev/null +++ b/frontend/src/v2/agents/V2AgentWakePods.tsx @@ -0,0 +1,126 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import axios from 'axios'; +import './v2-agent-wake-pods.css'; + +interface InstallationSettings { + podId: string; + podName: string; + wakeOnMessage: boolean; + boardWake: boolean; + heartbeatMinutes: number; +} + +const V2AgentWakePods: React.FC<{ + agentName: string; + instanceId: string; + onRemoved?: () => void; +}> = ({ agentName, instanceId, onRemoved }) => { + const { t } = useTranslation(); + const [installations, setInstallations] = useState([]); + const [busyPod, setBusyPod] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const query = `instanceId=${encodeURIComponent(instanceId)}`; + const base = `/api/registry/agents/${encodeURIComponent(agentName)}/manage-settings`; + + const load = useCallback(async () => { + try { + const response = await axios.get<{ installations: InstallationSettings[] }>(`${base}?${query}`); + setInstallations(response.data.installations); + setError(null); + } catch { + setError(t('agentSettings.loadError')); + } finally { + setLoading(false); + } + }, [base, query, t]); + + useEffect(() => { void load(); }, [load]); + + const save = async (entry: InstallationSettings, changes: Partial) => { + setBusyPod(entry.podId); + setError(null); + try { + const next = { ...entry, ...changes }; + await axios.patch(`${base}/pods/${encodeURIComponent(entry.podId)}?${query}`, { + wakeOnMessage: next.wakeOnMessage, + boardWake: next.boardWake, + heartbeatMinutes: next.heartbeatMinutes, + }); + setInstallations((current) => current.map((item) => item.podId === entry.podId ? next : item)); + } catch { + setError(t('agentSettings.saveError')); + } finally { + setBusyPod(null); + } + }; + + const remove = async (entry: InstallationSettings) => { + // eslint-disable-next-line no-alert + if (!window.confirm(t('agentSettings.removeConfirm', { podName: entry.podName }))) return; + setBusyPod(entry.podId); + setError(null); + try { + await axios.delete( + `/api/registry/agents/${encodeURIComponent(agentName)}/pods/${encodeURIComponent(entry.podId)}?${query}`, + ); + setInstallations((current) => current.filter((item) => item.podId !== entry.podId)); + onRemoved?.(); + } catch { + setError(t('agentSettings.removeError')); + } finally { + setBusyPod(null); + } + }; + + if (loading) return
{t('agentProfile.loading')}
; + + return ( +
+ {error &&

{error}

} +
+

{t('agentSettings.wakeTitle')}

+ {installations.map((entry) => ( +
+ {entry.podName} + + + +
+ ))} +
+
+

{t('agentSettings.podsTitle')}

+ {installations.length ? installations.map((entry) => ( +
+ {entry.podName} + +
+ )) :

{t('agentSettings.noPods')}

} +
+
+ ); +}; + +export default V2AgentWakePods; diff --git a/frontend/src/v2/agents/v2-agent-wake-pods.css b/frontend/src/v2/agents/v2-agent-wake-pods.css new file mode 100644 index 0000000000..68a12b0ac2 --- /dev/null +++ b/frontend/src/v2/agents/v2-agent-wake-pods.css @@ -0,0 +1,14 @@ +.v2-agent-wake-pods { display: grid; gap: 24px; } +.v2-agent-wake-pods__section { border: 1px solid var(--v2-border); border-radius: 6px; padding: 20px; background: var(--v2-surface); } +.v2-agent-wake-pods__section h2 { margin: 0 0 16px; color: var(--v2-text-primary); font: 700 20px/28px var(--v2-font-display); } +.v2-agent-wake-pods__pod + .v2-agent-wake-pods__pod { border-top: 1px solid var(--v2-border-soft); margin-top: 16px; padding-top: 16px; } +.v2-agent-wake-pods__pod > a, .v2-agent-wake-pods__pod-row > a { color: var(--v2-accent-text); font: 600 14px/20px var(--v2-font); } +.v2-agent-wake-pods__row, .v2-agent-wake-pods__pod-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 44px; color: var(--v2-text-primary); font: 500 14px/20px var(--v2-font); } +.v2-agent-wake-pods__row input { width: 18px; height: 18px; accent-color: var(--v2-ink); } +.v2-agent-wake-pods__row select { max-width: 55%; border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px; background: var(--v2-surface); color: var(--v2-text-primary); } +.v2-agent-wake-pods__pod-row + .v2-agent-wake-pods__pod-row { border-top: 1px solid var(--v2-border-soft); } +.v2-agent-wake-pods__pod-row button { border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px 10px; background: var(--v2-surface); color: var(--v2-text-secondary); cursor: pointer; } +.v2-agent-wake-pods__pod-row button:disabled { opacity: .5; cursor: default; } +.v2-agent-wake-pods__error { margin: 0; color: var(--v2-danger); } +.v2-agent-wake-pods__empty, .v2-agent-wake-pods__loading { margin: 0; color: var(--v2-text-muted); } +@media (max-width: 600px) { .v2-agent-wake-pods__section { padding: 16px; } } From 53980511ceca1e974189ade2d188f0d3760cecaa Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:39:55 -0700 Subject: [PATCH 02/17] Expose installed wake settings to settings reader Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- .../registry.wake-settings-projection.test.js | 19 +++++++++++++++++++ backend/routes/registry/helpers.ts | 2 ++ 2 files changed, 21 insertions(+) create mode 100644 backend/__tests__/unit/routes/registry.wake-settings-projection.test.js diff --git a/backend/__tests__/unit/routes/registry.wake-settings-projection.test.js b/backend/__tests__/unit/routes/registry.wake-settings-projection.test.js new file mode 100644 index 0000000000..317999df85 --- /dev/null +++ b/backend/__tests__/unit/routes/registry.wake-settings-projection.test.js @@ -0,0 +1,19 @@ +const { buildAgentInstallationPayload } = require('../../../routes/registry/helpers'); + +it('projects wake and heartbeat settings for the shared Settings reader', () => { + const payload = buildAgentInstallationPayload({ + agentName: 'test-agent', instanceId: 'default', podId: 'pod-1', installedBy: 'owner', + config: new Map([ + ['wakeOnMessage', { enabled: true, humanOnly: true }], + ['boardWake', { enabled: false }], + ['heartbeat', { everyMinutes: 90 }], + ['runtime', { runtimeType: 'wrapper', webhookSecret: 'never-return' }], + ]), + }); + expect(payload.config).toEqual(expect.objectContaining({ + wakeOnMessage: { enabled: true, humanOnly: true }, + boardWake: { enabled: false }, + heartbeat: { everyMinutes: 90 }, + })); + expect(JSON.stringify(payload)).not.toContain('never-return'); +}); diff --git a/backend/routes/registry/helpers.ts b/backend/routes/registry/helpers.ts index f1dcf10d17..75369618a9 100644 --- a/backend/routes/registry/helpers.ts +++ b/backend/routes/registry/helpers.ts @@ -469,6 +469,8 @@ const buildAgentInstallationPayload = (installation: any, { presetId: normalizedConfig.presetId || null, customizations: normalizedConfig.customizations || null, heartbeat: normalizedConfig.heartbeat || null, + wakeOnMessage: normalizedConfig.wakeOnMessage || null, + boardWake: normalizedConfig.boardWake || null, autonomy: normalizedConfig.autonomy || null, errorRouting: normalizedConfig.errorRouting || null, heartbeatChecklist: normalizedConfig.heartbeatChecklist || '', From 7cbaa4b86d79ba2d19bb614d0bd84fae7277d7b4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:48:17 -0700 Subject: [PATCH 03/17] Mount wake and pod controls in agent settings Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- .../service/agent-manage-settings.test.js | 6 ++-- .../registry.list-agents-config.test.js | 4 +++ backend/routes/registry/manage-settings.ts | 15 ++++++++-- backend/routes/registry/pod-agents.ts | 4 +-- frontend/src/i18n/locales/en.json | 28 +++++++++---------- frontend/src/i18n/locales/zh-CN.json | 28 +++++++++---------- .../src/v2/__tests__/V2AgentWakePods.test.tsx | 6 ++-- .../src/v2/agents/V2AgentSettingsPage.tsx | 15 +++++++++- frontend/src/v2/agents/V2AgentWakePods.tsx | 8 +++--- 9 files changed, 70 insertions(+), 44 deletions(-) diff --git a/backend/__tests__/service/agent-manage-settings.test.js b/backend/__tests__/service/agent-manage-settings.test.js index 9cff160cbf..78452291b3 100644 --- a/backend/__tests__/service/agent-manage-settings.test.js +++ b/backend/__tests__/service/agent-manage-settings.test.js @@ -114,12 +114,12 @@ describe('per-installation agent settings', () => { expect(await AgentMemory.exists({ _id: memory._id })).toBeTruthy(); }); - it('does not let a pod creator remove someone else’s installation', async () => { + it('keeps the pod creator’s authority to remove an installation', async () => { await AgentInstallation.updateOne({ podId: podA._id, agentName: 'settings-agent' }, { installedBy: other._id }); const response = await request(app) .delete(`/api/registry/agents/settings-agent/pods/${podA._id}?instanceId=same`) .set('Authorization', `Bearer ${token(owner)}`); - expect(response.status).toBe(403); - expect((await AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' })).status).toBe('active'); + expect(response.status).toBe(200); + expect((await AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' })).status).toBe('uninstalled'); }); }); diff --git a/backend/__tests__/unit/routes/registry.list-agents-config.test.js b/backend/__tests__/unit/routes/registry.list-agents-config.test.js index 67bd9bcf23..d4758ea892 100644 --- a/backend/__tests__/unit/routes/registry.list-agents-config.test.js +++ b/backend/__tests__/unit/routes/registry.list-agents-config.test.js @@ -113,6 +113,8 @@ describe('registry list pod agents config payload', () => { installedBy: 'user-1', config: new Map(Object.entries({ heartbeat: { enabled: true, everyMinutes: 60 }, + wakeOnMessage: { enabled: true, humanOnly: true }, + boardWake: { enabled: false }, autonomy: { autoJoinAgentOwnedPods: true }, errorRouting: { ownerDm: true }, heartbeatChecklist: '- Check updates', @@ -154,6 +156,8 @@ describe('registry list pod agents config payload', () => { customizations: null, environment: {}, heartbeat: { enabled: true, everyMinutes: 60 }, + wakeOnMessage: { enabled: true, humanOnly: true }, + boardWake: { enabled: false }, autonomy: { autoJoinAgentOwnedPods: true }, errorRouting: { ownerDm: true }, heartbeatChecklist: '- Check updates', diff --git a/backend/routes/registry/manage-settings.ts b/backend/routes/registry/manage-settings.ts index f8e115506f..80dc7f98fd 100644 --- a/backend/routes/registry/manage-settings.ts +++ b/backend/routes/registry/manage-settings.ts @@ -1,4 +1,6 @@ // Owner/admin controls for one agent installation at a time. +import rateLimit from 'express-rate-limit'; +import { cloudflareIpRateLimitKeyGenerator } from '../../middleware/ipRateLimit'; const express = require('express'); const auth = require('../../middleware/auth'); const { AgentInstallation } = require('../../models/AgentRegistry'); @@ -7,6 +9,15 @@ const User = require('../../models/User'); const { getUserId, normalizeConfigMap, normalizeInstanceId } = require('./helpers'); const router = express.Router(); +const settingsRateLimit = rateLimit({ + windowMs: 60_000, + max: 120, + standardHeaders: true, + legacyHeaders: false, + skip: () => process.env.NODE_ENV === 'test', + keyGenerator: (req: unknown) => cloudflareIpRateLimitKeyGenerator(req as never), + handler: (_req: unknown, res: any) => res.status(429).json({ code: 'rate_limited' }), +}); const canManage = (installation: any, userId: string, isAdmin: boolean) => ( isAdmin || String(installation.installedBy) === String(userId) @@ -27,7 +38,7 @@ const settings = (installation: any, podName: string) => { }; }; -router.get('/agents/:name/manage-settings', auth, async (req: any, res: any) => { +router.get('/agents/:name/manage-settings', settingsRateLimit, auth, async (req: any, res: any) => { try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: 'Unauthorized' }); @@ -50,7 +61,7 @@ router.get('/agents/:name/manage-settings', auth, async (req: any, res: any) => } }); -router.patch('/agents/:name/manage-settings/pods/:podId', auth, async (req: any, res: any) => { +router.patch('/agents/:name/manage-settings/pods/:podId', settingsRateLimit, auth, async (req: any, res: any) => { try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: 'Unauthorized' }); diff --git a/backend/routes/registry/pod-agents.ts b/backend/routes/registry/pod-agents.ts index 15ddb5a485..d0735dbdd3 100644 --- a/backend/routes/registry/pod-agents.ts +++ b/backend/routes/registry/pod-agents.ts @@ -111,8 +111,8 @@ podAgentsRouter.delete('/agents/:name/pods/:podId', auth, async (req: any, res: const isInstaller = installation.installedBy?.toString?.() === userId.toString(); - if (!isInstaller && !isGlobalAdmin) { - return res.status(403).json({ error: 'Only the agent installer or an instance admin can remove an installed agent' }); + if (!isCreator && !isInstaller && !isGlobalAdmin) { + return res.status(403).json({ error: 'Only pod admins or installers can remove agents' }); } await AgentInstallation.uninstall(name, podId, instanceId); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0102e8cfb4..0ef9a8447c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1602,21 +1602,6 @@ "license": "License (Apache-2.0)" } }, - "agentSettings": { - "wakeTitle": "Wake", - "wakeOnMessage": "Wake on messages", - "boardWake": "Wake on board changes", - "heartbeat": "Heartbeat", - "everyMinutes_one": "Every minute", - "everyMinutes_other": "Every {{count}} minutes", - "podsTitle": "Pods", - "remove": "Remove from pod", - "removeConfirm": "Remove this agent from {{podName}}? Its identity and memory stay intact.", - "noPods": "No pods to manage", - "loadError": "Could not load settings.", - "saveError": "Could not save wake settings.", - "removeError": "Could not remove the agent from this pod." - }, "agentProfile": { "loading": "Loading…", "nav": { @@ -2270,6 +2255,19 @@ "sentenceSeparator": " " }, "agentSettings": { + "wakeTitle": "Wake", + "wakeOnMessage": "Wake on messages", + "boardWake": "Wake on board changes", + "heartbeat": "Heartbeat", + "everyMinutes_one": "Every minute", + "everyMinutes_other": "Every {{count}} minutes", + "podsTitle": "Pods", + "remove": "Remove from pod", + "removeConfirm": "Remove this agent from {{podName}}? Its identity and memory stay intact.", + "noPods": "No pods to manage", + "loadError": "Could not load settings.", + "saveError": "Could not save wake settings.", + "removeError": "Could not remove the agent from this pod.", "loading": "Loading agent settings…", "unavailable": "Agent settings unavailable.", "yourTeam": "Your team", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 24103336c7..6c1cd1bb7d 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1594,21 +1594,6 @@ "title": "其他接入方式" } }, - "agentSettings": { - "wakeTitle": "唤醒", - "wakeOnMessage": "收到消息时唤醒", - "boardWake": "看板变更时唤醒", - "heartbeat": "心跳间隔", - "everyMinutes_one": "每分钟", - "everyMinutes_other": "每{{count}}分钟", - "podsTitle": "Pod", - "remove": "从 Pod 中移除", - "removeConfirm": "从 {{podName}} 移除此智能体?身份和记忆会保留。", - "noPods": "没有可管理的 Pod", - "loadError": "无法加载设置。", - "saveError": "无法保存唤醒设置。", - "removeError": "无法从此 Pod 移除智能体。" - }, "agentProfile": { "loading": "加载中…", "nav": { @@ -2261,6 +2246,19 @@ "sentenceSeparator": "" }, "agentSettings": { + "wakeTitle": "唤醒", + "wakeOnMessage": "收到消息时唤醒", + "boardWake": "看板变更时唤醒", + "heartbeat": "心跳间隔", + "everyMinutes_one": "每分钟", + "everyMinutes_other": "每{{count}}分钟", + "podsTitle": "Pod", + "remove": "从 Pod 中移除", + "removeConfirm": "从 {{podName}} 移除此智能体?身份和记忆会保留。", + "noPods": "没有可管理的 Pod", + "loadError": "无法加载设置。", + "saveError": "无法保存唤醒设置。", + "removeError": "无法从此 Pod 移除智能体。", "loading": "正在加载智能体设置…", "unavailable": "无法打开智能体设置。", "yourTeam": "你的团队", diff --git a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx index 6be790f0bb..558910575d 100644 --- a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx @@ -4,12 +4,14 @@ import { MemoryRouter } from 'react-router-dom'; import V2AgentWakePods from '../agents/V2AgentWakePods'; import i18n, { i18nReady } from '../../i18n'; -jest.mock('axios', () => ({ +jest.mock('../../utils/axiosConfig', () => ({ __esModule: true, default: { get: jest.fn(), patch: jest.fn(), delete: jest.fn() }, })); -const client = (jest.requireMock('axios') as { default: { get: jest.Mock; patch: jest.Mock; delete: jest.Mock } }).default; +const client = (jest.requireMock('../../utils/axiosConfig') as { + default: { get: jest.Mock; patch: jest.Mock; delete: jest.Mock }; +}).default; const renderSettings = () => render( , ); diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index 5c54408f5b..048458cd89 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; import V2PageHead from '../components/V2PageHead'; import { useAgentSettings } from './useAgentSettings'; +import V2AgentWakePods from './V2AgentWakePods'; import './v2-agent-settings.css'; interface RuntimeKey { @@ -30,7 +31,8 @@ const errorLabel = (error: unknown, fallback: string): string => ( const V2AgentSettingsPage: React.FC = () => { const { t } = useTranslation(); const { agentName = '', instanceId = '' } = useParams<{ agentName: string; instanceId: string }>(); - const { access, installation, podId, loading, error: loadError } = useAgentSettings(agentName, instanceId); + const { access, installation, podId, loading, error: loadError, refresh } = useAgentSettings(agentName, instanceId); + const [removedLast, setRemovedLast] = useState(false); const [keys, setKeys] = useState([]); const [keysReadyFor, setKeysReadyFor] = useState(null); const [error, setError] = useState(null); @@ -118,7 +120,17 @@ const V2AgentSettingsPage: React.FC = () => { } }; + const onRemoved = () => { + if (!access) return; + setKeys([]); + setPendingAction(null); + setOneTimeKey(null); + if (access.pods.length === 1) setRemovedLast(true); + else void refresh().catch((requestError) => setError(errorLabel(requestError, t('agentSettings.unavailable')))); + }; + if (loading) return
{t('agentSettings.loading')}
; + if (removedLast) return
{t('agentSettings.noPod')}
; if (!access || !podId) return
{error || errorLabel(loadError, t('agentSettings.unavailable'))}
; @@ -193,6 +205,7 @@ const V2AgentSettingsPage: React.FC = () => { )} + ); }; diff --git a/frontend/src/v2/agents/V2AgentWakePods.tsx b/frontend/src/v2/agents/V2AgentWakePods.tsx index 1bf5fbab9f..1fe2b31114 100644 --- a/frontend/src/v2/agents/V2AgentWakePods.tsx +++ b/frontend/src/v2/agents/V2AgentWakePods.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import axios from 'axios'; +import axios from '../../utils/axiosConfig'; import './v2-agent-wake-pods.css'; interface InstallationSettings { @@ -15,7 +15,7 @@ interface InstallationSettings { const V2AgentWakePods: React.FC<{ agentName: string; instanceId: string; - onRemoved?: () => void; + onRemoved?: (podId: string) => void; }> = ({ agentName, instanceId, onRemoved }) => { const { t } = useTranslation(); const [installations, setInstallations] = useState([]); @@ -28,7 +28,7 @@ const V2AgentWakePods: React.FC<{ const load = useCallback(async () => { try { const response = await axios.get<{ installations: InstallationSettings[] }>(`${base}?${query}`); - setInstallations(response.data.installations); + setInstallations(Array.isArray(response.data.installations) ? response.data.installations : []); setError(null); } catch { setError(t('agentSettings.loadError')); @@ -67,7 +67,7 @@ const V2AgentWakePods: React.FC<{ `/api/registry/agents/${encodeURIComponent(agentName)}/pods/${encodeURIComponent(entry.podId)}?${query}`, ); setInstallations((current) => current.filter((item) => item.podId !== entry.podId)); - onRemoved?.(); + onRemoved?.(entry.podId); } catch { setError(t('agentSettings.removeError')); } finally { From 05fc7beb343443ba750cb437fd425644bce8ccb7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:51:59 -0700 Subject: [PATCH 04/17] Complete wake controls and last-pod safeguards Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- .../service/agent-manage-settings.test.js | 19 ++- backend/routes/registry/manage-settings.ts | 11 +- frontend/src/i18n/locales/en.json | 9 ++ frontend/src/i18n/locales/zh-CN.json | 9 ++ .../src/v2/__tests__/V2AgentWakePods.test.tsx | 45 ++++++- frontend/src/v2/agents/V2AgentProfile.tsx | 2 +- frontend/src/v2/agents/V2AgentWakePods.tsx | 121 ++++++++++++++---- frontend/src/v2/agents/v2-agent-wake-pods.css | 3 + 8 files changed, 175 insertions(+), 44 deletions(-) diff --git a/backend/__tests__/service/agent-manage-settings.test.js b/backend/__tests__/service/agent-manage-settings.test.js index 78452291b3..d82f2f891d 100644 --- a/backend/__tests__/service/agent-manage-settings.test.js +++ b/backend/__tests__/service/agent-manage-settings.test.js @@ -47,23 +47,26 @@ describe('per-installation agent settings', () => { .set('Authorization', `Bearer ${token(owner)}`); expect(response.status).toBe(200); expect(response.body.installations).toEqual([expect.objectContaining({ - podId: String(podA._id), wakeOnMessage: true, boardWake: false, heartbeatMinutes: 60, + podId: String(podA._id), wakeOnMessage: true, humanOnly: true, boardWake: false, + heartbeatEnabled: false, heartbeatMinutes: 60, })]); + expect(response.body.activePodCount).toBe(2); }); it('writes one installation and preserves its human-only default and runtime', async () => { const response = await request(app) .patch(`/api/registry/agents/settings-agent/manage-settings/pods/${podA._id}?instanceId=same`) .set('Authorization', `Bearer ${token(owner)}`) - .send({ wakeOnMessage: false, boardWake: true, heartbeatMinutes: 90 }); + .send({ wakeOnMessage: false, humanOnly: false, boardWake: true, + heartbeatEnabled: true, heartbeatMinutes: 90 }); expect(response.status).toBe(200); const [a, b] = await Promise.all([ AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' }), AgentInstallation.findOne({ podId: podB._id, agentName: 'settings-agent' }), ]); - expect(config(a).wakeOnMessage).toEqual({ enabled: false, humanOnly: true }); + expect(config(a).wakeOnMessage).toEqual({ enabled: false, humanOnly: false }); expect(config(a).boardWake).toEqual({ enabled: true }); - expect(config(a).heartbeat).toEqual({ everyMinutes: 90 }); + expect(config(a).heartbeat).toEqual({ enabled: true, everyMinutes: 90 }); expect(config(a).runtime).toEqual({ runtimeType: 'wrapper' }); expect(config(b).wakeOnMessage).toEqual({ enabled: false }); expect(config(b).heartbeat).toBeUndefined(); @@ -71,7 +74,8 @@ describe('per-installation agent settings', () => { it('refuses another pod member and invalid intervals before writing', async () => { const url = `/api/registry/agents/settings-agent/manage-settings/pods/${podA._id}?instanceId=same`; - const body = { wakeOnMessage: false, boardWake: true, heartbeatMinutes: 30 }; + const body = { wakeOnMessage: false, humanOnly: true, boardWake: true, + heartbeatEnabled: true, heartbeatMinutes: 30 }; const denied = await request(app).patch(url).set('Authorization', `Bearer ${token(other)}`).send(body); const malformed = await request(app).patch(url).set('Authorization', `Bearer ${token(owner)}`) .send({ ...body, heartbeatMinutes: 0 }); @@ -89,10 +93,11 @@ describe('per-installation agent settings', () => { const saved = await request(app) .patch(`/api/registry/agents/settings-agent/manage-settings/pods/${podB._id}?instanceId=same`) .set('Authorization', `Bearer ${token(admin)}`) - .send({ wakeOnMessage: true, boardWake: false, heartbeatMinutes: 120 }); + .send({ wakeOnMessage: true, humanOnly: false, boardWake: false, + heartbeatEnabled: true, heartbeatMinutes: 120 }); expect(saved.status).toBe(200); expect(config(await AgentInstallation.findOne({ podId: podB._id, agentName: 'settings-agent' })).heartbeat) - .toEqual({ everyMinutes: 120 }); + .toEqual({ enabled: true, everyMinutes: 120 }); }); it('removes one pod installation while keeping the agent identity and memory', async () => { diff --git a/backend/routes/registry/manage-settings.ts b/backend/routes/registry/manage-settings.ts index 80dc7f98fd..1887ed39de 100644 --- a/backend/routes/registry/manage-settings.ts +++ b/backend/routes/registry/manage-settings.ts @@ -30,8 +30,10 @@ const settings = (installation: any, podName: string) => { podId: String(installation.podId), podName, wakeOnMessage, + humanOnly: config.wakeOnMessage?.humanOnly === true, boardWake: typeof config.boardWake?.enabled === 'boolean' ? config.boardWake.enabled : wakeOnMessage, + heartbeatEnabled: config.heartbeat?.enabled === true, heartbeatMinutes: Number.isFinite(Number(config.heartbeat?.everyMinutes)) && Number(config.heartbeat?.everyMinutes) > 0 ? Math.max(1, Math.min(1440, Math.trunc(Number(config.heartbeat.everyMinutes)))) : 60, @@ -52,7 +54,7 @@ router.get('/agents/:name/manage-settings', settingsRateLimit, auth, async (req: const pods = await Pod.find({ _id: { $in: manageable.map((entry: any) => entry.podId) } }) .select('name').lean(); const names = new Map(pods.map((pod: any) => [String(pod._id), String(pod.name)])); - return res.json({ installations: manageable + return res.json({ activePodCount: installations.length, installations: manageable .filter((entry: any) => names.has(String(entry.podId))) .map((entry: any) => settings(entry, names.get(String(entry.podId)) || 'Pod')) }); } catch (error) { @@ -65,8 +67,9 @@ router.patch('/agents/:name/manage-settings/pods/:podId', settingsRateLimit, aut try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - const { wakeOnMessage, boardWake, heartbeatMinutes } = req.body || {}; + const { wakeOnMessage, humanOnly, boardWake, heartbeatEnabled, heartbeatMinutes } = req.body || {}; if (typeof wakeOnMessage !== 'boolean' || typeof boardWake !== 'boolean' + || typeof humanOnly !== 'boolean' || typeof heartbeatEnabled !== 'boolean' || !Number.isInteger(heartbeatMinutes) || heartbeatMinutes < 1 || heartbeatMinutes > 1440) { return res.status(400).json({ error: 'Invalid wake settings' }); } @@ -82,9 +85,9 @@ router.patch('/agents/:name/manage-settings/pods/:podId', settingsRateLimit, aut const config = normalizeConfigMap(installation.config) || {}; installation.config = new Map(Object.entries({ ...config, - wakeOnMessage: { ...config.wakeOnMessage, enabled: wakeOnMessage }, + wakeOnMessage: { ...config.wakeOnMessage, enabled: wakeOnMessage, humanOnly }, boardWake: { ...config.boardWake, enabled: boardWake }, - heartbeat: { ...config.heartbeat, everyMinutes: heartbeatMinutes }, + heartbeat: { ...config.heartbeat, enabled: heartbeatEnabled, everyMinutes: heartbeatMinutes }, })); await installation.save(); return res.json({ settings: settings(installation, '') }); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0ef9a8447c..778df3dbcf 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2256,14 +2256,23 @@ }, "agentSettings": { "wakeTitle": "Wake", + "mentionsAlwaysWake": "Mentions always wake this agent.", "wakeOnMessage": "Wake on messages", + "humanOnly": "Human messages only", "boardWake": "Wake on board changes", "heartbeat": "Heartbeat", + "heartbeatInterval": "Interval", + "turnsPerDay": "{{count}} turns/day · {{cap}} available", + "capUnavailable": "cap unavailable", + "overCap": "This exceeds the daily cap.", + "saveInterval": "Save interval", "everyMinutes_one": "Every minute", "everyMinutes_other": "Every {{count}} minutes", "podsTitle": "Pods", "remove": "Remove from pod", "removeConfirm": "Remove this agent from {{podName}}? Its identity and memory stay intact.", + "removeLastConfirm": "Remove this agent from its last pod, {{podName}}? Every key will be revoked and the agent will disconnect. Its identity and memory remain.", + "forbidden": "Only the installer or an admin can change this setting.", "noPods": "No pods to manage", "loadError": "Could not load settings.", "saveError": "Could not save wake settings.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 6c1cd1bb7d..5d1ff8cca5 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2247,14 +2247,23 @@ }, "agentSettings": { "wakeTitle": "唤醒", + "mentionsAlwaysWake": "提及始终会唤醒此智能体。", "wakeOnMessage": "收到消息时唤醒", + "humanOnly": "仅限真人消息", "boardWake": "看板变更时唤醒", "heartbeat": "心跳间隔", + "heartbeatInterval": "间隔", + "turnsPerDay": "每天 {{count}} 次 · 上限 {{cap}} 次", + "capUnavailable": "上限未知", + "overCap": "已超过每日上限。", + "saveInterval": "保存间隔", "everyMinutes_one": "每分钟", "everyMinutes_other": "每{{count}}分钟", "podsTitle": "Pod", "remove": "从 Pod 中移除", "removeConfirm": "从 {{podName}} 移除此智能体?身份和记忆会保留。", + "removeLastConfirm": "从最后一个 Pod「{{podName}}」移除此智能体?所有密钥都会撤销,智能体会断开连接。身份和记忆仍会保留。", + "forbidden": "只有安装者或管理员可以更改此设置。", "noPods": "没有可管理的 Pod", "loadError": "无法加载设置。", "saveError": "无法保存唤醒设置。", diff --git a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx index 558910575d..4f8bd257e6 100644 --- a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import V2AgentWakePods from '../agents/V2AgentWakePods'; import i18n, { i18nReady } from '../../i18n'; @@ -24,9 +24,12 @@ describe('per-agent Wake and Pods settings', () => { beforeEach(() => { jest.clearAllMocks(); - client.get.mockResolvedValue({ data: { installations: [{ - podId: 'pod-1', podName: 'Launch', wakeOnMessage: true, boardWake: false, heartbeatMinutes: 60, - }] } }); + client.get.mockImplementation((url: string) => Promise.resolve(url.includes('/hosted/availability') + ? { data: { caps: { turnsPerDay: 30 } } } + : { data: { activePodCount: 1, installations: [{ + podId: 'pod-1', podName: 'Launch', wakeOnMessage: true, humanOnly: true, + boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60, + }] } })); client.patch.mockResolvedValue({ data: {} }); client.delete.mockResolvedValue({ data: {} }); }); @@ -36,7 +39,7 @@ describe('per-agent Wake and Pods settings', () => { fireEvent.click(await screen.findByLabelText('Wake on board changes')); await waitFor(() => expect(client.patch).toHaveBeenCalledWith( '/api/registry/agents/test-agent/manage-settings/pods/pod-1?instanceId=my-machine', - { wakeOnMessage: true, boardWake: true, heartbeatMinutes: 60 }, + { wakeOnMessage: true, humanOnly: true, boardWake: true, heartbeatEnabled: false, heartbeatMinutes: 60 }, )); }); @@ -50,4 +53,36 @@ describe('per-agent Wake and Pods settings', () => { expect(await screen.findByText('No pods to manage')).toBeInTheDocument(); confirm.mockRestore(); }); + + it('does not show or restore the previous agent’s pod after identity changes', async () => { + let finishOld: (value: unknown) => void = () => {}; + client.get.mockImplementation((url: string) => url.includes('old-agent') + ? new Promise((resolve) => { finishOld = resolve; }) + : Promise.resolve(url.includes('/hosted/availability') ? { data: { caps: { turnsPerDay: 30 } } } + : { data: { installations: [{ + podId: 'pod-new', podName: 'New pod', wakeOnMessage: false, humanOnly: false, + boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 30, + }] } })); + const view = render( + , + ); + view.rerender( + , + ); + expect((await screen.findAllByText('New pod')).length).toBe(2); + await act(async () => { finishOld({ data: { installations: [{ + podId: 'pod-old', podName: 'Old pod', wakeOnMessage: true, boardWake: true, heartbeatMinutes: 60, + }] } }); }); + expect(screen.queryByText('Old pod')).not.toBeInTheDocument(); + expect(screen.getAllByText('New pod')).toHaveLength(2); + }); + + it('previews heartbeat turns against the plan cap before saving', async () => { + renderSettings(); + expect(await screen.findByText('24 turns/day · 30 available')).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Interval'), { target: { value: '30' } }); + expect(screen.getByText(/48 turns\/day · 30 available/)).toBeInTheDocument(); + expect(screen.getByText(/This exceeds the daily cap/)).toBeInTheDocument(); + expect(client.patch).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/v2/agents/V2AgentProfile.tsx b/frontend/src/v2/agents/V2AgentProfile.tsx index 47bb79126c..eef443167f 100644 --- a/frontend/src/v2/agents/V2AgentProfile.tsx +++ b/frontend/src/v2/agents/V2AgentProfile.tsx @@ -168,7 +168,7 @@ const V2AgentProfile: React.FC = () => { setRemoving(podId); try { await getClient().delete( - `/api/registry/agents/${encodeURIComponent(agentName)}/pods/${encodeURIComponent(podId)}`, + `/api/registry/agents/${encodeURIComponent(agentName)}/pods/${encodeURIComponent(podId)}?instanceId=${encodeURIComponent(instanceId || 'default')}`, { headers: { Authorization: `Bearer ${token}` } }, ); await Promise.all([fetchProfile(), fetchMemoryIndex()]); diff --git a/frontend/src/v2/agents/V2AgentWakePods.tsx b/frontend/src/v2/agents/V2AgentWakePods.tsx index 1fe2b31114..a3633436c3 100644 --- a/frontend/src/v2/agents/V2AgentWakePods.tsx +++ b/frontend/src/v2/agents/V2AgentWakePods.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; @@ -8,7 +8,9 @@ interface InstallationSettings { podId: string; podName: string; wakeOnMessage: boolean; + humanOnly: boolean; boardWake: boolean; + heartbeatEnabled: boolean; heartbeatMinutes: number; } @@ -22,24 +24,52 @@ const V2AgentWakePods: React.FC<{ const [busyPod, setBusyPod] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + const [activePodCount, setActivePodCount] = useState(0); + const [turnCap, setTurnCap] = useState(null); + const [draftMinutes, setDraftMinutes] = useState>({}); + const [loadedIdentity, setLoadedIdentity] = useState(null); const query = `instanceId=${encodeURIComponent(instanceId)}`; const base = `/api/registry/agents/${encodeURIComponent(agentName)}/manage-settings`; + const identity = `${agentName}:${instanceId}`; + const identityRef = useRef(identity); + identityRef.current = identity; - const load = useCallback(async () => { - try { - const response = await axios.get<{ installations: InstallationSettings[] }>(`${base}?${query}`); - setInstallations(Array.isArray(response.data.installations) ? response.data.installations : []); - setError(null); - } catch { - setError(t('agentSettings.loadError')); - } finally { - setLoading(false); - } - }, [base, query, t]); - - useEffect(() => { void load(); }, [load]); + useEffect(() => { + let active = true; + setLoading(true); + setInstallations([]); + setBusyPod(null); + setError(null); + setDraftMinutes({}); + const load = async () => { + try { + const [settingsResult, capResult] = await Promise.allSettled([ + axios.get<{ installations: InstallationSettings[]; activePodCount: number }>(`${base}?${query}`), + axios.get<{ caps?: { turnsPerDay?: number } }>('/api/hosted/availability'), + ]); + if (!active) return; + if (settingsResult.status === 'rejected') throw settingsResult.reason; + const rows = Array.isArray(settingsResult.value.data.installations) ? settingsResult.value.data.installations : []; + setInstallations(rows); + setActivePodCount(settingsResult.value.data.activePodCount ?? rows.length); + setDraftMinutes(Object.fromEntries(rows.map((row) => [row.podId, row.heartbeatMinutes]))); + setTurnCap(capResult.status === 'fulfilled' ? capResult.value.data.caps?.turnsPerDay ?? null : null); + } catch (requestError) { + if (active) setError((requestError as { response?: { status?: number } })?.response?.status === 403 + ? t('agentSettings.forbidden') : t('agentSettings.loadError')); + } finally { + if (active) { + setLoadedIdentity(identity); + setLoading(false); + } + } + }; + void load(); + return () => { active = false; }; + }, [base, query, identity, t]); const save = async (entry: InstallationSettings, changes: Partial) => { + const requestedIdentity = identity; setBusyPod(entry.podId); setError(null); try { @@ -47,41 +77,55 @@ const V2AgentWakePods: React.FC<{ await axios.patch(`${base}/pods/${encodeURIComponent(entry.podId)}?${query}`, { wakeOnMessage: next.wakeOnMessage, boardWake: next.boardWake, + humanOnly: next.humanOnly, + heartbeatEnabled: next.heartbeatEnabled, heartbeatMinutes: next.heartbeatMinutes, }); - setInstallations((current) => current.map((item) => item.podId === entry.podId ? next : item)); - } catch { - setError(t('agentSettings.saveError')); + if (identityRef.current === requestedIdentity) { + setInstallations((current) => current.map((item) => item.podId === entry.podId ? next : item)); + } + } catch (requestError) { + if (identityRef.current === requestedIdentity) setError((requestError as { response?: { status?: number } })?.response?.status === 403 + ? t('agentSettings.forbidden') : t('agentSettings.saveError')); } finally { - setBusyPod(null); + if (identityRef.current === requestedIdentity) setBusyPod(null); } }; const remove = async (entry: InstallationSettings) => { // eslint-disable-next-line no-alert - if (!window.confirm(t('agentSettings.removeConfirm', { podName: entry.podName }))) return; + if (!window.confirm(t(activePodCount === 1 ? 'agentSettings.removeLastConfirm' : 'agentSettings.removeConfirm', + { podName: entry.podName }))) return; + const requestedIdentity = identity; setBusyPod(entry.podId); setError(null); try { await axios.delete( `/api/registry/agents/${encodeURIComponent(agentName)}/pods/${encodeURIComponent(entry.podId)}?${query}`, ); - setInstallations((current) => current.filter((item) => item.podId !== entry.podId)); - onRemoved?.(entry.podId); - } catch { - setError(t('agentSettings.removeError')); + if (identityRef.current === requestedIdentity) { + setInstallations((current) => current.filter((item) => item.podId !== entry.podId)); + setActivePodCount((count) => Math.max(0, count - 1)); + onRemoved?.(entry.podId); + } + } catch (requestError) { + if (identityRef.current === requestedIdentity) setError((requestError as { response?: { status?: number } })?.response?.status === 403 + ? t('agentSettings.forbidden') : t('agentSettings.removeError')); } finally { - setBusyPod(null); + if (identityRef.current === requestedIdentity) setBusyPod(null); } }; - if (loading) return
{t('agentProfile.loading')}
; + if (loading || loadedIdentity !== identity) { + return
{t('agentProfile.loading')}
; + } return (
{error &&

{error}

}

{t('agentSettings.wakeTitle')}

+

{t('agentSettings.mentionsAlwaysWake')}

{installations.map((entry) => (
{entry.podName} @@ -90,6 +134,11 @@ const V2AgentWakePods: React.FC<{ { void save(entry, { wakeOnMessage: event.target.checked }); }} /> + {entry.wakeOnMessage && }
))}
diff --git a/frontend/src/v2/agents/v2-agent-wake-pods.css b/frontend/src/v2/agents/v2-agent-wake-pods.css index 68a12b0ac2..79364326ae 100644 --- a/frontend/src/v2/agents/v2-agent-wake-pods.css +++ b/frontend/src/v2/agents/v2-agent-wake-pods.css @@ -5,6 +5,9 @@ .v2-agent-wake-pods__pod > a, .v2-agent-wake-pods__pod-row > a { color: var(--v2-accent-text); font: 600 14px/20px var(--v2-font); } .v2-agent-wake-pods__row, .v2-agent-wake-pods__pod-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 44px; color: var(--v2-text-primary); font: 500 14px/20px var(--v2-font); } .v2-agent-wake-pods__row input { width: 18px; height: 18px; accent-color: var(--v2-ink); } +.v2-agent-wake-pods__subrow { padding-left: 18px; } +.v2-agent-wake-pods__hint { margin: 4px 0 12px; color: var(--v2-text-muted); font: 400 13px/19px var(--v2-font); } +.v2-agent-wake-pods__pod > button { border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px 10px; background: var(--v2-surface); color: var(--v2-text-primary); cursor: pointer; } .v2-agent-wake-pods__row select { max-width: 55%; border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px; background: var(--v2-surface); color: var(--v2-text-primary); } .v2-agent-wake-pods__pod-row + .v2-agent-wake-pods__pod-row { border-top: 1px solid var(--v2-border-soft); } .v2-agent-wake-pods__pod-row button { border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px 10px; background: var(--v2-surface); color: var(--v2-text-secondary); cursor: pointer; } From caac724f03d542baf32ccd78c24de81cd543b32e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:53:33 -0700 Subject: [PATCH 05/17] Route per-pod wake writes through shared settings hook Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- frontend/src/v2/__tests__/V2AgentWakePods.test.tsx | 14 ++++++++------ .../src/v2/__tests__/useAgentSettings.test.tsx | 10 ++++++---- frontend/src/v2/agents/V2AgentSettingsPage.tsx | 4 ++-- frontend/src/v2/agents/V2AgentWakePods.tsx | 6 ++++-- frontend/src/v2/agents/useAgentSettings.ts | 6 +++++- 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx index 4f8bd257e6..552a3f565c 100644 --- a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx @@ -12,8 +12,9 @@ jest.mock('../../utils/axiosConfig', () => ({ const client = (jest.requireMock('../../utils/axiosConfig') as { default: { get: jest.Mock; patch: jest.Mock; delete: jest.Mock }; }).default; +const updatePod = jest.fn().mockResolvedValue({}); const renderSettings = () => render( - , + , ); describe('per-agent Wake and Pods settings', () => { @@ -24,6 +25,7 @@ describe('per-agent Wake and Pods settings', () => { beforeEach(() => { jest.clearAllMocks(); + updatePod.mockResolvedValue({}); client.get.mockImplementation((url: string) => Promise.resolve(url.includes('/hosted/availability') ? { data: { caps: { turnsPerDay: 30 } } } : { data: { activePodCount: 1, installations: [{ @@ -37,8 +39,8 @@ describe('per-agent Wake and Pods settings', () => { it('writes settings to the selected installation with the instance identity', async () => { renderSettings(); fireEvent.click(await screen.findByLabelText('Wake on board changes')); - await waitFor(() => expect(client.patch).toHaveBeenCalledWith( - '/api/registry/agents/test-agent/manage-settings/pods/pod-1?instanceId=my-machine', + await waitFor(() => expect(updatePod).toHaveBeenCalledWith( + 'pod-1', { wakeOnMessage: true, humanOnly: true, boardWake: true, heartbeatEnabled: false, heartbeatMinutes: 60 }, )); }); @@ -64,10 +66,10 @@ describe('per-agent Wake and Pods settings', () => { boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 30, }] } })); const view = render( - , + , ); view.rerender( - , + , ); expect((await screen.findAllByText('New pod')).length).toBe(2); await act(async () => { finishOld({ data: { installations: [{ @@ -83,6 +85,6 @@ describe('per-agent Wake and Pods settings', () => { fireEvent.change(screen.getByLabelText('Interval'), { target: { value: '30' } }); expect(screen.getByText(/48 turns\/day · 30 available/)).toBeInTheDocument(); expect(screen.getByText(/This exceeds the daily cap/)).toBeInTheDocument(); - expect(client.patch).not.toHaveBeenCalled(); + expect(updatePod).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/v2/__tests__/useAgentSettings.test.tsx b/frontend/src/v2/__tests__/useAgentSettings.test.tsx index bfeaec3f8f..84cacbe4c8 100644 --- a/frontend/src/v2/__tests__/useAgentSettings.test.tsx +++ b/frontend/src/v2/__tests__/useAgentSettings.test.tsx @@ -21,8 +21,9 @@ const Probe = () => { Save + wakeOnMessage: true, humanOnly: true, boardWake: false, + heartbeatEnabled: true, heartbeatMinutes: 60, + }); }}>Save Wake {podId}
; }; @@ -45,10 +46,11 @@ test('reads one accessible installation and patches it with the instance id', as { displayName: 'Maya Updated', instanceId: 'default' }, )); expect(screen.getByTestId('pod')).toHaveTextContent('pod-1'); - fireEvent.click(screen.getByRole('button', { name: 'Save pod' })); + fireEvent.click(screen.getByRole('button', { name: 'Save Wake' })); await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( '/api/registry/agents/maya-claude/manage-settings/pods/pod-2', - { wakeOnMessage: true, boardWake: false, heartbeatMinutes: 60 }, + { wakeOnMessage: true, humanOnly: true, boardWake: false, + heartbeatEnabled: true, heartbeatMinutes: 60 }, { params: { instanceId: 'default' } }, )); }); diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index 048458cd89..4f31004e73 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -31,7 +31,7 @@ const errorLabel = (error: unknown, fallback: string): string => ( const V2AgentSettingsPage: React.FC = () => { const { t } = useTranslation(); const { agentName = '', instanceId = '' } = useParams<{ agentName: string; instanceId: string }>(); - const { access, installation, podId, loading, error: loadError, refresh } = useAgentSettings(agentName, instanceId); + const { access, installation, podId, loading, error: loadError, refresh, updatePod } = useAgentSettings(agentName, instanceId); const [removedLast, setRemovedLast] = useState(false); const [keys, setKeys] = useState([]); const [keysReadyFor, setKeysReadyFor] = useState(null); @@ -205,7 +205,7 @@ const V2AgentSettingsPage: React.FC = () => { )} - + ); }; diff --git a/frontend/src/v2/agents/V2AgentWakePods.tsx b/frontend/src/v2/agents/V2AgentWakePods.tsx index a3633436c3..00677f8ba9 100644 --- a/frontend/src/v2/agents/V2AgentWakePods.tsx +++ b/frontend/src/v2/agents/V2AgentWakePods.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; +import { AgentPodWakePatch } from './useAgentSettings'; import './v2-agent-wake-pods.css'; interface InstallationSettings { @@ -17,8 +18,9 @@ interface InstallationSettings { const V2AgentWakePods: React.FC<{ agentName: string; instanceId: string; + updatePod: (podId: string, patch: AgentPodWakePatch) => Promise; onRemoved?: (podId: string) => void; -}> = ({ agentName, instanceId, onRemoved }) => { +}> = ({ agentName, instanceId, updatePod, onRemoved }) => { const { t } = useTranslation(); const [installations, setInstallations] = useState([]); const [busyPod, setBusyPod] = useState(null); @@ -74,7 +76,7 @@ const V2AgentWakePods: React.FC<{ setError(null); try { const next = { ...entry, ...changes }; - await axios.patch(`${base}/pods/${encodeURIComponent(entry.podId)}?${query}`, { + await updatePod(entry.podId, { wakeOnMessage: next.wakeOnMessage, boardWake: next.boardWake, humanOnly: next.humanOnly, diff --git a/frontend/src/v2/agents/useAgentSettings.ts b/frontend/src/v2/agents/useAgentSettings.ts index 8905b65aa2..e32e7e1b10 100644 --- a/frontend/src/v2/agents/useAgentSettings.ts +++ b/frontend/src/v2/agents/useAgentSettings.ts @@ -24,7 +24,9 @@ export interface AgentSettingsPatch { export interface AgentPodWakePatch { wakeOnMessage: boolean; + humanOnly: boolean; boardWake: boolean; + heartbeatEnabled: boolean; heartbeatMinutes: number; } @@ -67,7 +69,9 @@ export const useAgentSettings = (agentName: string, instanceId: string) => { } return response.data.agent; } catch (candidateError) { - if ((candidateError as { response?: { status?: number } })?.response?.status !== 403) throw candidateError; + if (![403, 404].includes((candidateError as { response?: { status?: number } })?.response?.status || 0)) { + throw candidateError; + } } } throw new Error('No accessible installation'); From 66228f11fba66a33eabf9d44117b80bfa2e7f91f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:54:49 -0700 Subject: [PATCH 06/17] Clear settings after actual last pod removal Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- frontend/src/v2/agents/V2AgentSettingsPage.tsx | 4 ++-- frontend/src/v2/agents/V2AgentWakePods.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index 4f31004e73..b0d6c50224 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -120,12 +120,12 @@ const V2AgentSettingsPage: React.FC = () => { } }; - const onRemoved = () => { + const onRemoved = (_podId: string, wasLastPod: boolean) => { if (!access) return; setKeys([]); setPendingAction(null); setOneTimeKey(null); - if (access.pods.length === 1) setRemovedLast(true); + if (wasLastPod) setRemovedLast(true); else void refresh().catch((requestError) => setError(errorLabel(requestError, t('agentSettings.unavailable')))); }; diff --git a/frontend/src/v2/agents/V2AgentWakePods.tsx b/frontend/src/v2/agents/V2AgentWakePods.tsx index 00677f8ba9..f5ff6ff1c5 100644 --- a/frontend/src/v2/agents/V2AgentWakePods.tsx +++ b/frontend/src/v2/agents/V2AgentWakePods.tsx @@ -19,7 +19,7 @@ const V2AgentWakePods: React.FC<{ agentName: string; instanceId: string; updatePod: (podId: string, patch: AgentPodWakePatch) => Promise; - onRemoved?: (podId: string) => void; + onRemoved?: (podId: string, wasLastPod: boolean) => void; }> = ({ agentName, instanceId, updatePod, onRemoved }) => { const { t } = useTranslation(); const [installations, setInstallations] = useState([]); @@ -108,7 +108,7 @@ const V2AgentWakePods: React.FC<{ if (identityRef.current === requestedIdentity) { setInstallations((current) => current.filter((item) => item.podId !== entry.podId)); setActivePodCount((count) => Math.max(0, count - 1)); - onRemoved?.(entry.podId); + onRemoved?.(entry.podId, activePodCount === 1); } } catch (requestError) { if (identityRef.current === requestedIdentity) setError((requestError as { response?: { status?: number } })?.response?.status === 403 From b3c5503c6a5e8606f4e0130c0c8383bc431a90e8 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:57:12 -0700 Subject: [PATCH 07/17] Resolve Wake and Pods render gate defects Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- frontend/src/i18n/locales/en.json | 2 +- frontend/src/i18n/locales/zh-CN.json | 4 ++-- frontend/src/v2/__tests__/V2AgentWakePods.test.tsx | 11 +++++++++++ .../src/v2/__tests__/v2-layout-invariants.test.ts | 11 +++++++++++ frontend/src/v2/agents/V2AgentWakePods.tsx | 9 +++++++-- frontend/src/v2/agents/v2-agent-wake-pods.css | 4 ++-- 6 files changed, 34 insertions(+), 7 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 778df3dbcf..7469871f14 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2263,7 +2263,7 @@ "heartbeat": "Heartbeat", "heartbeatInterval": "Interval", "turnsPerDay": "{{count}} turns/day · {{cap}} available", - "capUnavailable": "cap unavailable", + "capUnavailable": "{{count}} turns/day · plan cap unavailable", "overCap": "This exceeds the daily cap.", "saveInterval": "Save interval", "everyMinutes_one": "Every minute", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 5d1ff8cca5..08eb618866 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2253,8 +2253,8 @@ "boardWake": "看板变更时唤醒", "heartbeat": "心跳间隔", "heartbeatInterval": "间隔", - "turnsPerDay": "每天 {{count}} 次 · 上限 {{cap}} 次", - "capUnavailable": "上限未知", + "turnsPerDay": "每天{{count}}次 · 上限{{cap}}次", + "capUnavailable": "每天{{count}}次 · 套餐上限暂不可用", "overCap": "已超过每日上限。", "saveInterval": "保存间隔", "everyMinutes_one": "每分钟", diff --git a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx index 552a3f565c..fb8ee156b9 100644 --- a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx @@ -87,4 +87,15 @@ describe('per-agent Wake and Pods settings', () => { expect(screen.getByText(/This exceeds the daily cap/)).toBeInTheDocument(); expect(updatePod).not.toHaveBeenCalled(); }); + + it('states the daily turn count clearly when availability cannot be loaded', async () => { + client.get.mockImplementation((url: string) => url.includes('/hosted/availability') + ? Promise.reject(new Error('offline')) + : Promise.resolve({ data: { activePodCount: 1, installations: [{ + podId: 'pod-1', podName: 'Launch', wakeOnMessage: false, humanOnly: false, + boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60, + }] } })); + renderSettings(); + expect(await screen.findByText('24 turns/day · plan cap unavailable')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index f7521f2a46..fcb3b54174 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -4681,3 +4681,14 @@ describe('GitHub gig card fits the chat column', () => { expect(phone).toContain('padding-left: 0'); }); }); + +describe('agent Wake and Pods controls', () => { + const css = read('../agents/v2-agent-wake-pods.css'); + test('action buttons keep a visible surface over the v2 button reset', () => { + const save = ruleBody(css, '.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__pod > button'); + const remove = ruleBody(css, '.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__pod-row button'); + expect(save).toContain('background: var(--v2-surface)'); + expect(remove).toContain('background: var(--v2-surface)'); + expect(remove).toContain('border: 1px solid var(--v2-border)'); + }); +}); diff --git a/frontend/src/v2/agents/V2AgentWakePods.tsx b/frontend/src/v2/agents/V2AgentWakePods.tsx index f5ff6ff1c5..501f19357a 100644 --- a/frontend/src/v2/agents/V2AgentWakePods.tsx +++ b/frontend/src/v2/agents/V2AgentWakePods.tsx @@ -164,8 +164,13 @@ const V2AgentWakePods: React.FC<{

- {t('agentSettings.turnsPerDay', { count: Math.ceil(1440 / (draftMinutes[entry.podId] ?? entry.heartbeatMinutes)), - cap: turnCap ?? t('agentSettings.capUnavailable') })} + {turnCap === null + ? t('agentSettings.capUnavailable', { + count: Math.ceil(1440 / (draftMinutes[entry.podId] ?? entry.heartbeatMinutes)), + }) + : t('agentSettings.turnsPerDay', { + count: Math.ceil(1440 / (draftMinutes[entry.podId] ?? entry.heartbeatMinutes)), cap: turnCap, + })} {turnCap !== null && Math.ceil(1440 / (draftMinutes[entry.podId] ?? entry.heartbeatMinutes)) > turnCap && ` ${t('agentSettings.overCap')}`}

diff --git a/frontend/src/v2/agents/v2-agent-wake-pods.css b/frontend/src/v2/agents/v2-agent-wake-pods.css index 79364326ae..864f37845a 100644 --- a/frontend/src/v2/agents/v2-agent-wake-pods.css +++ b/frontend/src/v2/agents/v2-agent-wake-pods.css @@ -7,10 +7,10 @@ .v2-agent-wake-pods__row input { width: 18px; height: 18px; accent-color: var(--v2-ink); } .v2-agent-wake-pods__subrow { padding-left: 18px; } .v2-agent-wake-pods__hint { margin: 4px 0 12px; color: var(--v2-text-muted); font: 400 13px/19px var(--v2-font); } -.v2-agent-wake-pods__pod > button { border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px 10px; background: var(--v2-surface); color: var(--v2-text-primary); cursor: pointer; } +.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__pod > button { border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px 10px; background: var(--v2-surface); color: var(--v2-text-primary); cursor: pointer; } .v2-agent-wake-pods__row select { max-width: 55%; border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px; background: var(--v2-surface); color: var(--v2-text-primary); } .v2-agent-wake-pods__pod-row + .v2-agent-wake-pods__pod-row { border-top: 1px solid var(--v2-border-soft); } -.v2-agent-wake-pods__pod-row button { border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px 10px; background: var(--v2-surface); color: var(--v2-text-secondary); cursor: pointer; } +.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__pod-row button { border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px 10px; background: var(--v2-surface); color: var(--v2-text-secondary); cursor: pointer; } .v2-agent-wake-pods__pod-row button:disabled { opacity: .5; cursor: default; } .v2-agent-wake-pods__error { margin: 0; color: var(--v2-danger); } .v2-agent-wake-pods__empty, .v2-agent-wake-pods__loading { margin: 0; color: var(--v2-text-muted); } From cee481d51b72f3840fba9ed82f3cdc66322b746d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:01:52 -0700 Subject: [PATCH 08/17] Warn when pod removal revokes remaining runtime keys Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- .../service/agent-manage-settings.test.js | 13 ++++++++- backend/routes/registry/manage-settings.ts | 5 +++- frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/zh-CN.json | 1 + .../src/v2/__tests__/V2AgentWakePods.test.tsx | 27 +++++++++++++++---- frontend/src/v2/agents/V2AgentWakePods.tsx | 17 ++++++++---- 6 files changed, 52 insertions(+), 12 deletions(-) diff --git a/backend/__tests__/service/agent-manage-settings.test.js b/backend/__tests__/service/agent-manage-settings.test.js index d82f2f891d..34124d9d93 100644 --- a/backend/__tests__/service/agent-manage-settings.test.js +++ b/backend/__tests__/service/agent-manage-settings.test.js @@ -47,10 +47,21 @@ describe('per-installation agent settings', () => { .set('Authorization', `Bearer ${token(owner)}`); expect(response.status).toBe(200); expect(response.body.installations).toEqual([expect.objectContaining({ - podId: String(podA._id), wakeOnMessage: true, humanOnly: true, boardWake: false, + podId: String(podA._id), status: 'active', wakeOnMessage: true, humanOnly: true, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60, })]); expect(response.body.activePodCount).toBe(2); + expect(response.body.podCount).toBe(2); + }); + + it('counts active connections separately from installed pod rows', async () => { + await AgentInstallation.updateOne({ podId: podB._id, agentName: 'settings-agent' }, { status: 'paused' }); + const response = await request(app).get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') + .set('Authorization', `Bearer ${token(admin)}`); + expect(response.status).toBe(200); + expect(response.body.podCount).toBe(2); + expect(response.body.activePodCount).toBe(1); + expect(response.body.installations.find((entry) => entry.podId === String(podB._id)).status).toBe('paused'); }); it('writes one installation and preserves its human-only default and runtime', async () => { diff --git a/backend/routes/registry/manage-settings.ts b/backend/routes/registry/manage-settings.ts index 1887ed39de..3434eb3f10 100644 --- a/backend/routes/registry/manage-settings.ts +++ b/backend/routes/registry/manage-settings.ts @@ -29,6 +29,7 @@ const settings = (installation: any, podName: string) => { return { podId: String(installation.podId), podName, + status: installation.status, wakeOnMessage, humanOnly: config.wakeOnMessage?.humanOnly === true, boardWake: typeof config.boardWake?.enabled === 'boolean' @@ -54,7 +55,9 @@ router.get('/agents/:name/manage-settings', settingsRateLimit, auth, async (req: const pods = await Pod.find({ _id: { $in: manageable.map((entry: any) => entry.podId) } }) .select('name').lean(); const names = new Map(pods.map((pod: any) => [String(pod._id), String(pod.name)])); - return res.json({ activePodCount: installations.length, installations: manageable + return res.json({ podCount: installations.length, + activePodCount: installations.filter((entry: any) => entry.status === 'active').length, + installations: manageable .filter((entry: any) => names.has(String(entry.podId))) .map((entry: any) => settings(entry, names.get(String(entry.podId)) || 'Pod')) }); } catch (error) { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 7469871f14..1cf9c8351f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2272,6 +2272,7 @@ "remove": "Remove from pod", "removeConfirm": "Remove this agent from {{podName}}? Its identity and memory stay intact.", "removeLastConfirm": "Remove this agent from its last pod, {{podName}}? Every key will be revoked and the agent will disconnect. Its identity and memory remain.", + "removeDisconnectConfirm": "Remove this agent from {{podName}}? No active installation will remain, so every key will be revoked and the agent will disconnect. Its identity and memory remain.", "forbidden": "Only the installer or an admin can change this setting.", "noPods": "No pods to manage", "loadError": "Could not load settings.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 08eb618866..f3b824e790 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2263,6 +2263,7 @@ "remove": "从 Pod 中移除", "removeConfirm": "从 {{podName}} 移除此智能体?身份和记忆会保留。", "removeLastConfirm": "从最后一个 Pod「{{podName}}」移除此智能体?所有密钥都会撤销,智能体会断开连接。身份和记忆仍会保留。", + "removeDisconnectConfirm": "从「{{podName}}」移除此智能体?移除后没有活跃安装,所有密钥都会撤销,智能体会断开连接。身份和记忆仍会保留。", "forbidden": "只有安装者或管理员可以更改此设置。", "noPods": "没有可管理的 Pod", "loadError": "无法加载设置。", diff --git a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx index fb8ee156b9..aebbfd3c44 100644 --- a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx @@ -28,8 +28,8 @@ describe('per-agent Wake and Pods settings', () => { updatePod.mockResolvedValue({}); client.get.mockImplementation((url: string) => Promise.resolve(url.includes('/hosted/availability') ? { data: { caps: { turnsPerDay: 30 } } } - : { data: { activePodCount: 1, installations: [{ - podId: 'pod-1', podName: 'Launch', wakeOnMessage: true, humanOnly: true, + : { data: { activePodCount: 1, podCount: 1, installations: [{ + podId: 'pod-1', podName: 'Launch', status: 'active', wakeOnMessage: true, humanOnly: true, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60, }] } })); client.patch.mockResolvedValue({ data: {} }); @@ -62,7 +62,7 @@ describe('per-agent Wake and Pods settings', () => { ? new Promise((resolve) => { finishOld = resolve; }) : Promise.resolve(url.includes('/hosted/availability') ? { data: { caps: { turnsPerDay: 30 } } } : { data: { installations: [{ - podId: 'pod-new', podName: 'New pod', wakeOnMessage: false, humanOnly: false, + podId: 'pod-new', podName: 'New pod', status: 'active', wakeOnMessage: false, humanOnly: false, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 30, }] } })); const view = render( @@ -91,11 +91,28 @@ describe('per-agent Wake and Pods settings', () => { it('states the daily turn count clearly when availability cannot be loaded', async () => { client.get.mockImplementation((url: string) => url.includes('/hosted/availability') ? Promise.reject(new Error('offline')) - : Promise.resolve({ data: { activePodCount: 1, installations: [{ - podId: 'pod-1', podName: 'Launch', wakeOnMessage: false, humanOnly: false, + : Promise.resolve({ data: { activePodCount: 1, podCount: 1, installations: [{ + podId: 'pod-1', podName: 'Launch', status: 'active', wakeOnMessage: false, humanOnly: false, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60, }] } })); renderSettings(); expect(await screen.findByText('24 turns/day · plan cap unavailable')).toBeInTheDocument(); }); + + it('warns about key revocation when removing the only active installation beside a paused one', async () => { + client.get.mockImplementation((url: string) => Promise.resolve(url.includes('/hosted/availability') + ? { data: { caps: { turnsPerDay: 30 } } } + : { data: { activePodCount: 1, podCount: 2, installations: [ + { podId: 'pod-1', podName: 'Launch', status: 'active', wakeOnMessage: true, + humanOnly: true, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60 }, + { podId: 'pod-2', podName: 'Paused', status: 'paused', wakeOnMessage: false, + humanOnly: false, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60 }, + ] } })); + const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false); + renderSettings(); + fireEvent.click((await screen.findAllByRole('button', { name: 'Remove from pod' }))[0]); + expect(confirm).toHaveBeenCalledWith(expect.stringContaining('No active installation will remain')); + expect(client.delete).not.toHaveBeenCalled(); + confirm.mockRestore(); + }); }); diff --git a/frontend/src/v2/agents/V2AgentWakePods.tsx b/frontend/src/v2/agents/V2AgentWakePods.tsx index 501f19357a..f3086e620c 100644 --- a/frontend/src/v2/agents/V2AgentWakePods.tsx +++ b/frontend/src/v2/agents/V2AgentWakePods.tsx @@ -8,6 +8,7 @@ import './v2-agent-wake-pods.css'; interface InstallationSettings { podId: string; podName: string; + status: string; wakeOnMessage: boolean; humanOnly: boolean; boardWake: boolean; @@ -27,6 +28,7 @@ const V2AgentWakePods: React.FC<{ const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [activePodCount, setActivePodCount] = useState(0); + const [podCount, setPodCount] = useState(0); const [turnCap, setTurnCap] = useState(null); const [draftMinutes, setDraftMinutes] = useState>({}); const [loadedIdentity, setLoadedIdentity] = useState(null); @@ -46,14 +48,15 @@ const V2AgentWakePods: React.FC<{ const load = async () => { try { const [settingsResult, capResult] = await Promise.allSettled([ - axios.get<{ installations: InstallationSettings[]; activePodCount: number }>(`${base}?${query}`), + axios.get<{ installations: InstallationSettings[]; activePodCount: number; podCount: number }>(`${base}?${query}`), axios.get<{ caps?: { turnsPerDay?: number } }>('/api/hosted/availability'), ]); if (!active) return; if (settingsResult.status === 'rejected') throw settingsResult.reason; const rows = Array.isArray(settingsResult.value.data.installations) ? settingsResult.value.data.installations : []; setInstallations(rows); - setActivePodCount(settingsResult.value.data.activePodCount ?? rows.length); + setActivePodCount(settingsResult.value.data.activePodCount ?? rows.filter((row) => row.status === 'active').length); + setPodCount(settingsResult.value.data.podCount ?? rows.length); setDraftMinutes(Object.fromEntries(rows.map((row) => [row.podId, row.heartbeatMinutes]))); setTurnCap(capResult.status === 'fulfilled' ? capResult.value.data.caps?.turnsPerDay ?? null : null); } catch (requestError) { @@ -96,7 +99,10 @@ const V2AgentWakePods: React.FC<{ const remove = async (entry: InstallationSettings) => { // eslint-disable-next-line no-alert - if (!window.confirm(t(activePodCount === 1 ? 'agentSettings.removeLastConfirm' : 'agentSettings.removeConfirm', + const willRevokeKeys = activePodCount <= (entry.status === 'active' ? 1 : 0); + const confirmKey = podCount === 1 ? 'agentSettings.removeLastConfirm' + : willRevokeKeys ? 'agentSettings.removeDisconnectConfirm' : 'agentSettings.removeConfirm'; + if (!window.confirm(t(confirmKey, { podName: entry.podName }))) return; const requestedIdentity = identity; setBusyPod(entry.podId); @@ -107,8 +113,9 @@ const V2AgentWakePods: React.FC<{ ); if (identityRef.current === requestedIdentity) { setInstallations((current) => current.filter((item) => item.podId !== entry.podId)); - setActivePodCount((count) => Math.max(0, count - 1)); - onRemoved?.(entry.podId, activePodCount === 1); + setPodCount((count) => Math.max(0, count - 1)); + if (entry.status === 'active') setActivePodCount((count) => Math.max(0, count - 1)); + onRemoved?.(entry.podId, podCount === 1); } } catch (requestError) { if (identityRef.current === requestedIdentity) setError((requestError as { response?: { status?: number } })?.response?.status === 403 From 1da297f12d80a08c5829a62e5a09ea0e8be50ad3 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:11:05 -0700 Subject: [PATCH 09/17] Keep agent settings cap preview clear of BYO telemetry Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- .../service/agent-manage-settings.test.js | 1 + backend/routes/registry/manage-settings.ts | 4 +++- .../src/v2/__tests__/V2AgentWakePods.test.tsx | 23 +++++++------------ frontend/src/v2/agents/V2AgentWakePods.tsx | 16 ++++++------- 4 files changed, 19 insertions(+), 25 deletions(-) diff --git a/backend/__tests__/service/agent-manage-settings.test.js b/backend/__tests__/service/agent-manage-settings.test.js index 34124d9d93..b503b8cdf4 100644 --- a/backend/__tests__/service/agent-manage-settings.test.js +++ b/backend/__tests__/service/agent-manage-settings.test.js @@ -52,6 +52,7 @@ describe('per-installation agent settings', () => { })]); expect(response.body.activePodCount).toBe(2); expect(response.body.podCount).toBe(2); + expect(response.body.turnCap).toBe(30); }); it('counts active connections separately from installed pod rows', async () => { diff --git a/backend/routes/registry/manage-settings.ts b/backend/routes/registry/manage-settings.ts index 3434eb3f10..26ab896c6f 100644 --- a/backend/routes/registry/manage-settings.ts +++ b/backend/routes/registry/manage-settings.ts @@ -1,6 +1,7 @@ // Owner/admin controls for one agent installation at a time. import rateLimit from 'express-rate-limit'; import { cloudflareIpRateLimitKeyGenerator } from '../../middleware/ipRateLimit'; +import { hostedCapsForOwner } from '../../services/hostedRuntimeService'; const express = require('express'); const auth = require('../../middleware/auth'); const { AgentInstallation } = require('../../models/AgentRegistry'); @@ -55,7 +56,8 @@ router.get('/agents/:name/manage-settings', settingsRateLimit, auth, async (req: const pods = await Pod.find({ _id: { $in: manageable.map((entry: any) => entry.podId) } }) .select('name').lean(); const names = new Map(pods.map((pod: any) => [String(pod._id), String(pod.name)])); - return res.json({ podCount: installations.length, + const { turnsPerDay } = await hostedCapsForOwner(userId); + return res.json({ turnCap: turnsPerDay, podCount: installations.length, activePodCount: installations.filter((entry: any) => entry.status === 'active').length, installations: manageable .filter((entry: any) => names.has(String(entry.podId))) diff --git a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx index aebbfd3c44..a3ccb6e1ba 100644 --- a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx @@ -26,12 +26,10 @@ describe('per-agent Wake and Pods settings', () => { beforeEach(() => { jest.clearAllMocks(); updatePod.mockResolvedValue({}); - client.get.mockImplementation((url: string) => Promise.resolve(url.includes('/hosted/availability') - ? { data: { caps: { turnsPerDay: 30 } } } - : { data: { activePodCount: 1, podCount: 1, installations: [{ + client.get.mockResolvedValue({ data: { turnCap: 30, activePodCount: 1, podCount: 1, installations: [{ podId: 'pod-1', podName: 'Launch', status: 'active', wakeOnMessage: true, humanOnly: true, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60, - }] } })); + }] } }); client.patch.mockResolvedValue({ data: {} }); client.delete.mockResolvedValue({ data: {} }); }); @@ -60,8 +58,7 @@ describe('per-agent Wake and Pods settings', () => { let finishOld: (value: unknown) => void = () => {}; client.get.mockImplementation((url: string) => url.includes('old-agent') ? new Promise((resolve) => { finishOld = resolve; }) - : Promise.resolve(url.includes('/hosted/availability') ? { data: { caps: { turnsPerDay: 30 } } } - : { data: { installations: [{ + : Promise.resolve({ data: { turnCap: 30, installations: [{ podId: 'pod-new', podName: 'New pod', status: 'active', wakeOnMessage: false, humanOnly: false, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 30, }] } })); @@ -88,26 +85,22 @@ describe('per-agent Wake and Pods settings', () => { expect(updatePod).not.toHaveBeenCalled(); }); - it('states the daily turn count clearly when availability cannot be loaded', async () => { - client.get.mockImplementation((url: string) => url.includes('/hosted/availability') - ? Promise.reject(new Error('offline')) - : Promise.resolve({ data: { activePodCount: 1, podCount: 1, installations: [{ + it('states the daily turn count clearly when the cap is unavailable', async () => { + client.get.mockResolvedValue({ data: { activePodCount: 1, podCount: 1, installations: [{ podId: 'pod-1', podName: 'Launch', status: 'active', wakeOnMessage: false, humanOnly: false, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60, - }] } })); + }] } }); renderSettings(); expect(await screen.findByText('24 turns/day · plan cap unavailable')).toBeInTheDocument(); }); it('warns about key revocation when removing the only active installation beside a paused one', async () => { - client.get.mockImplementation((url: string) => Promise.resolve(url.includes('/hosted/availability') - ? { data: { caps: { turnsPerDay: 30 } } } - : { data: { activePodCount: 1, podCount: 2, installations: [ + client.get.mockResolvedValue({ data: { turnCap: 30, activePodCount: 1, podCount: 2, installations: [ { podId: 'pod-1', podName: 'Launch', status: 'active', wakeOnMessage: true, humanOnly: true, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60 }, { podId: 'pod-2', podName: 'Paused', status: 'paused', wakeOnMessage: false, humanOnly: false, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60 }, - ] } })); + ] } }); const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false); renderSettings(); fireEvent.click((await screen.findAllByRole('button', { name: 'Remove from pod' }))[0]); diff --git a/frontend/src/v2/agents/V2AgentWakePods.tsx b/frontend/src/v2/agents/V2AgentWakePods.tsx index f3086e620c..96b4ee20f6 100644 --- a/frontend/src/v2/agents/V2AgentWakePods.tsx +++ b/frontend/src/v2/agents/V2AgentWakePods.tsx @@ -47,18 +47,16 @@ const V2AgentWakePods: React.FC<{ setDraftMinutes({}); const load = async () => { try { - const [settingsResult, capResult] = await Promise.allSettled([ - axios.get<{ installations: InstallationSettings[]; activePodCount: number; podCount: number }>(`${base}?${query}`), - axios.get<{ caps?: { turnsPerDay?: number } }>('/api/hosted/availability'), - ]); + const settingsResult = await axios.get<{ + installations: InstallationSettings[]; activePodCount: number; podCount: number; turnCap?: number; + }>(`${base}?${query}`); if (!active) return; - if (settingsResult.status === 'rejected') throw settingsResult.reason; - const rows = Array.isArray(settingsResult.value.data.installations) ? settingsResult.value.data.installations : []; + const rows = Array.isArray(settingsResult.data.installations) ? settingsResult.data.installations : []; setInstallations(rows); - setActivePodCount(settingsResult.value.data.activePodCount ?? rows.filter((row) => row.status === 'active').length); - setPodCount(settingsResult.value.data.podCount ?? rows.length); + setActivePodCount(settingsResult.data.activePodCount ?? rows.filter((row) => row.status === 'active').length); + setPodCount(settingsResult.data.podCount ?? rows.length); setDraftMinutes(Object.fromEntries(rows.map((row) => [row.podId, row.heartbeatMinutes]))); - setTurnCap(capResult.status === 'fulfilled' ? capResult.value.data.caps?.turnsPerDay ?? null : null); + setTurnCap(settingsResult.data.turnCap ?? null); } catch (requestError) { if (active) setError((requestError as { response?: { status?: number } })?.response?.status === 403 ? t('agentSettings.forbidden') : t('agentSettings.loadError')); From 4feb1a7a34da93c6af83c4004aa987af8aecf149 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:13:15 -0700 Subject: [PATCH 10/17] Expose writable agent pod for Reconnect Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- backend/__tests__/service/agent-manage-settings.test.js | 4 ++++ backend/routes/registry/manage-settings.ts | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/service/agent-manage-settings.test.js b/backend/__tests__/service/agent-manage-settings.test.js index b503b8cdf4..e429a29cca 100644 --- a/backend/__tests__/service/agent-manage-settings.test.js +++ b/backend/__tests__/service/agent-manage-settings.test.js @@ -53,6 +53,10 @@ describe('per-installation agent settings', () => { expect(response.body.activePodCount).toBe(2); expect(response.body.podCount).toBe(2); expect(response.body.turnCap).toBe(30); + expect(response.body.writablePodId).toBe(String(podA._id)); + const otherResponse = await request(app).get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') + .set('Authorization', `Bearer ${token(other)}`); + expect(otherResponse.body.writablePodId).toBe(String(podB._id)); }); it('counts active connections separately from installed pod rows', async () => { diff --git a/backend/routes/registry/manage-settings.ts b/backend/routes/registry/manage-settings.ts index 26ab896c6f..98eb5f802a 100644 --- a/backend/routes/registry/manage-settings.ts +++ b/backend/routes/registry/manage-settings.ts @@ -56,11 +56,13 @@ router.get('/agents/:name/manage-settings', settingsRateLimit, auth, async (req: const pods = await Pod.find({ _id: { $in: manageable.map((entry: any) => entry.podId) } }) .select('name').lean(); const names = new Map(pods.map((pod: any) => [String(pod._id), String(pod.name)])); + const visible = manageable.filter((entry: any) => names.has(String(entry.podId))); + const writable = visible.find((entry: any) => entry.status === 'active') || visible[0]; const { turnsPerDay } = await hostedCapsForOwner(userId); return res.json({ turnCap: turnsPerDay, podCount: installations.length, activePodCount: installations.filter((entry: any) => entry.status === 'active').length, - installations: manageable - .filter((entry: any) => names.has(String(entry.podId))) + writablePodId: writable ? String(writable.podId) : null, + installations: visible .map((entry: any) => settings(entry, names.get(String(entry.podId)) || 'Pod')) }); } catch (error) { console.error('Error loading agent settings:', error); From 762b2e5f108bc12b91e704da244656688f2d8a69 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:22:28 -0700 Subject: [PATCH 11/17] Require exact instance for agent uninstall Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- .../__tests__/service/agent-manage-settings.test.js | 10 ++++++++++ backend/routes/registry/pod-agents.ts | 11 +++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/service/agent-manage-settings.test.js b/backend/__tests__/service/agent-manage-settings.test.js index e429a29cca..104fc66a86 100644 --- a/backend/__tests__/service/agent-manage-settings.test.js +++ b/backend/__tests__/service/agent-manage-settings.test.js @@ -143,4 +143,14 @@ describe('per-installation agent settings', () => { expect(response.status).toBe(200); expect((await AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' })).status).toBe('uninstalled'); }); + + it('never removes another instance when the requested instance is missing', async () => { + const response = await request(app) + .delete(`/api/registry/agents/settings-agent/pods/${podA._id}?instanceId=missing`) + .set('Authorization', `Bearer ${token(owner)}`); + expect(response.status).toBe(404); + expect((await AgentInstallation.findOne({ + podId: podA._id, agentName: 'settings-agent', instanceId: 'same', + })).status).toBe('active'); + }); }); diff --git a/backend/routes/registry/pod-agents.ts b/backend/routes/registry/pod-agents.ts index d0735dbdd3..1086b9f2e2 100644 --- a/backend/routes/registry/pod-agents.ts +++ b/backend/routes/registry/pod-agents.ts @@ -14,6 +14,7 @@ const { revokeRuntimeTokensAfterLastUninstall } = require('./tokens'); const { getUserId, isGlobalAdminUser, + normalizeInstanceId, normalizeDisplayName, resolveInstallation, buildAgentProfileId, @@ -29,10 +30,12 @@ const podAgentsRouter = express.Router(); podAgentsRouter.delete('/agents/:name/pods/:podId', auth, async (req: any, res: any) => { try { const { name, podId } = req.params; - const { installation, instanceId } = await resolveInstallation({ - agentName: name, - podId, - instanceId: req.query.instanceId, + // Destructive writes must never use resolveInstallation's single-install + // fallback: a stale instanceId could otherwise remove a different agent. + const instanceId = normalizeInstanceId(req.query.instanceId); + const installation = await AgentInstallation.findOne({ + agentName: String(name).toLowerCase(), podId, instanceId, + status: { $ne: 'uninstalled' }, }); const userId = getUserId(req); if (!userId) { From e9418182d479fdb314b5c04bfafd945fb9fa6c7c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:26:20 -0700 Subject: [PATCH 12/17] fix: target exact installation from detach callers Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- cli/__tests__/detach.test.mjs | 13 ++++++++++++- cli/src/commands/agent.js | 4 +++- frontend/src/components/ChatRoom.tsx | 5 +++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/cli/__tests__/detach.test.mjs b/cli/__tests__/detach.test.mjs index f5d1acc268..b28db37c04 100644 --- a/cli/__tests__/detach.test.mjs +++ b/cli/__tests__/detach.test.mjs @@ -65,7 +65,7 @@ describe('performDetach', () => { client, agentName: 'my-bot', podId: 'pod-1', }); - expect(del).toHaveBeenCalledWith('/api/registry/agents/my-bot/pods/pod-1'); + expect(del).toHaveBeenCalledWith('/api/registry/agents/my-bot/pods/pod-1?instanceId=default'); expect(result.backend.skipped).toBe(false); expect(result.backend.alreadyGone).toBeUndefined(); expect(result.localCleaned).toBe(true); @@ -75,6 +75,17 @@ describe('performDetach', () => { expect(fs.existsSync(sessionFilePath('my-bot'))).toBe(false); }); + test('targets the saved nondefault installation when detaching', async () => { + seedAgentState('my-bot'); + const del = jest.fn().mockResolvedValue({ success: true }); + + await performDetach({ + client: { del }, agentName: 'my-bot', podId: 'pod-1', instanceId: 'machine-2', + }); + + expect(del).toHaveBeenCalledWith('/api/registry/agents/my-bot/pods/pod-1?instanceId=machine-2'); + }); + test('treats backend 404 as "already uninstalled" and still cleans local state', async () => { seedAgentState('stale-bot'); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 4a362e63cd..df08dcbf8d 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -2681,6 +2681,7 @@ export const performDetach = async ({ client, agentName, podId, + instanceId = 'default', skipBackend = false, log = () => {}, }) => { @@ -2696,7 +2697,7 @@ export const performDetach = async ({ let backendResult = { skipped: true }; if (!skipBackend) { try { - const body = await client.del(`/api/registry/agents/${encodeURIComponent(agentName)}/pods/${encodeURIComponent(podId)}`); + const body = await client.del(`/api/registry/agents/${encodeURIComponent(agentName)}/pods/${encodeURIComponent(podId)}?instanceId=${encodeURIComponent(instanceId)}`); backendResult = { skipped: false, body }; log(`backend: uninstalled '${agentName}' from pod ${podId}`); } catch (err) { @@ -3307,6 +3308,7 @@ Docs: client, agentName: record.agentName, podId: record.podId, + instanceId: record.instanceId || 'default', skipBackend: !!opts.force, log: (line) => console.warn(`[detach] ${line}`), }); diff --git a/frontend/src/components/ChatRoom.tsx b/frontend/src/components/ChatRoom.tsx index 2787310a5f..ab9b6675f5 100644 --- a/frontend/src/components/ChatRoom.tsx +++ b/frontend/src/components/ChatRoom.tsx @@ -1209,11 +1209,12 @@ const ChatRoom = () => { } }; - const handleRemovePodAgent = async (agentName) => { + const handleRemovePodAgent = async (agentName, instanceId) => { const token = localStorage.getItem('token'); if (!token || !agentName || !roomId) return; try { await axios.delete(`/api/registry/agents/${agentName}/pods/${roomId}`, { + params: { instanceId: instanceId || 'default' }, headers: { Authorization: `Bearer ${token}` } }); await refreshPodAgents(); @@ -3000,7 +3001,7 @@ const ChatRoom = () => { From 59de3c287911792d541eb2593cd8cb8eb29f46c2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:27:01 -0700 Subject: [PATCH 13/17] chore(cli): reserve 0.1.132 for exact detach Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- cli/package-lock.json | 4 ++-- cli/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index d52f3c8cf6..5adcfa88de 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.122", + "version": "0.1.132", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.122", + "version": "0.1.132", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index 9b2aa00f99..5d514599d2 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.122", + "version": "0.1.132", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", From 002bc59e250621198a5ec624dcdee567edaf05e1 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:32:30 -0700 Subject: [PATCH 14/17] fix: trim instance ID edges with linear patterns Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- backend/routes/registry/helpers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/routes/registry/helpers.ts b/backend/routes/registry/helpers.ts index 75369618a9..06cbbb3603 100644 --- a/backend/routes/registry/helpers.ts +++ b/backend/routes/registry/helpers.ts @@ -513,7 +513,8 @@ const normalizeInstanceId = (raw: any) => { .toLowerCase() .replace(/[^a-z0-9-]/g, '-') .replace(/-+/g, '-') - .replace(/^-+|-+$/g, ''); + .replace(/^-+/, '') + .replace(/-+$/, ''); return normalized || 'default'; }; From eb70de9bc97add3784b4b9168278d842cffae665 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:35:27 -0700 Subject: [PATCH 15/17] fix: avoid regex edge trim in instance normalization Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- backend/routes/registry/helpers.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/routes/registry/helpers.ts b/backend/routes/registry/helpers.ts index 06cbbb3603..2d810332f8 100644 --- a/backend/routes/registry/helpers.ts +++ b/backend/routes/registry/helpers.ts @@ -508,13 +508,13 @@ const getPluginSpecBase = (spec: any) => { }; const normalizeInstanceId = (raw: any) => { - const normalized = String(raw || '') + let normalized = String(raw || '') .trim() .toLowerCase() .replace(/[^a-z0-9-]/g, '-') - .replace(/-+/g, '-') - .replace(/^-+/, '') - .replace(/-+$/, ''); + .replace(/-+/g, '-'); + if (normalized.startsWith('-')) normalized = normalized.slice(1); + if (normalized.endsWith('-')) normalized = normalized.slice(0, -1); return normalized || 'default'; }; From dca9d0327a2dc1b2384d67dcd3559c1570703343 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:35:52 -0700 Subject: [PATCH 16/17] test: preserve instance ID normalization across edge trim Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- .../routes/registry.helpers.normalize-instance-id.test.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 backend/__tests__/unit/routes/registry.helpers.normalize-instance-id.test.js diff --git a/backend/__tests__/unit/routes/registry.helpers.normalize-instance-id.test.js b/backend/__tests__/unit/routes/registry.helpers.normalize-instance-id.test.js new file mode 100644 index 0000000000..005374fa35 --- /dev/null +++ b/backend/__tests__/unit/routes/registry.helpers.normalize-instance-id.test.js @@ -0,0 +1,7 @@ +const { normalizeInstanceId } = require('../../../routes/registry/helpers'); + +it('collapses separators and trims the edges of an instance ID', () => { + expect(normalizeInstanceId(' ---Machine -- 2--- ')).toBe('machine-2'); + expect(normalizeInstanceId('---')).toBe('default'); + expect(normalizeInstanceId(null)).toBe('default'); +}); From 42a754a124b208dc7b149b08de20db8a954ec000 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:36:12 -0700 Subject: [PATCH 17/17] docs(cli): show instance ID in detach recovery hint Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- cli/src/commands/agent.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index df08dcbf8d..c1221fda67 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -3280,7 +3280,7 @@ Docs: console.error( `No local state for '${name}'. If the agent is still installed on ` + `the backend, uninstall it via the Agent Hub UI or:\n` - + ` curl -X DELETE /api/registry/agents/${name}/pods/ \\\n` + + ` curl -X DELETE '/api/registry/agents/${name}/pods/?instanceId=' \\\n` + ` -H "Authorization: Bearer "`, ); process.exit(1);