Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions server/src/routes/session-mgmt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,15 @@ sessionMgmtRoutes.patch('/:id/sessions/:name/label', async (c) => {

const label = typeof body.label === 'string' && body.label.trim() ? body.label.trim() : null;
await updateSessionLabel(c.env.DB, serverId, sessionName, label);
try {
WsBridge.get(serverId).sendToDaemon(JSON.stringify({
type: 'session.relabel',
sessionName,
label,
}));
} catch (err) {
logger.warn({ serverId, sessionName, err }, 'WsBridge session relabel relay failed');
}
return c.json({ ok: true });
});

Expand Down Expand Up @@ -186,6 +195,18 @@ sessionMgmtRoutes.patch('/:id/sessions/:name', async (c) => {
return c.json({ error: 'relay_failed' }, 502);
}
}
if (body.agentType == null && body.label !== undefined) {
try {
WsBridge.get(serverId).sendToDaemon(JSON.stringify({
type: 'session.relabel',
sessionName,
label: body.label ?? null,
}));
} catch (err) {
logger.error({ serverId, sessionName, err }, 'WsBridge session relabel relay failed');
return c.json({ error: 'relay_failed' }, 502);
}
}
return c.json({ ok: true });
});

Expand All @@ -208,6 +229,15 @@ sessionMgmtRoutes.patch('/:id/sessions/:name/rename', async (c) => {
if (!newName) return c.json({ error: 'name_required' }, 400);

await updateProjectName(c.env.DB, serverId, sessionName, newName);
try {
WsBridge.get(serverId).sendToDaemon(JSON.stringify({
type: 'session.rename',
sessionName,
projectName: newName,
}));
} catch (err) {
logger.warn({ serverId, sessionName, err }, 'WsBridge session rename relay failed');
}
return c.json({ ok: true });
});

Expand Down
12 changes: 12 additions & 0 deletions server/src/routes/sub-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,18 @@ subSessionRoutes.patch('/:id/sub-sessions/:subId', async (c) => {
return c.json({ error: 'relay_failed' }, 502);
}
}
if (body.type == null && body.label !== undefined) {
try {
WsBridge.get(serverId).sendToDaemon(JSON.stringify({
type: 'subsession.rename',
sessionName: `deck_sub_${subId}`,
label: body.label ?? null,
}));
} catch (err) {
logger.error({ serverId, subId, err }, 'WsBridge sub-session rename relay failed');
return c.json({ error: 'relay_failed' }, 502);
}
}
return c.json({ ok: true });
});

Expand Down
37 changes: 37 additions & 0 deletions server/test/session-mgmt-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,41 @@ describe('session-mgmt persistence routes', () => {
description: 'next persona',
});
});

it('PATCH /sessions/:name/rename updates the project name and relays session.rename', async () => {
const { updateProjectName } = await import('../src/db/queries.js');
const app = await buildApp();
const res = await app.request('/api/server/srv-1/sessions/deck_proj_brain/rename', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'new-proj' }),
});

expect(res.status).toBe(200);
expect(updateProjectName).toHaveBeenCalledWith({}, 'srv-1', 'deck_proj_brain', 'new-proj');
expect(sendToDaemonMock).toHaveBeenCalledTimes(1);
expect(JSON.parse(String(sendToDaemonMock.mock.calls[0]?.[0]))).toEqual({
type: 'session.rename',
sessionName: 'deck_proj_brain',
projectName: 'new-proj',
});
});

it('PATCH /sessions/:name/label updates the label and relays session.relabel', async () => {
const { updateSessionLabel } = await import('../src/db/queries.js');
const app = await buildApp();
const res = await app.request('/api/server/srv-1/sessions/deck_proj_brain/label', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: 'Main Label' }),
});

expect(res.status).toBe(200);
expect(updateSessionLabel).toHaveBeenCalledWith({}, 'srv-1', 'deck_proj_brain', 'Main Label');
expect(JSON.parse(String(sendToDaemonMock.mock.calls[0]?.[0]))).toEqual({
type: 'session.relabel',
sessionName: 'deck_proj_brain',
label: 'Main Label',
});
});
});
32 changes: 32 additions & 0 deletions server/test/sub-sessions-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,36 @@ describe('sub-session routes', () => {
expect(updateSubSessionMock).not.toHaveBeenCalled();
expect(sendToDaemonMock).not.toHaveBeenCalled();
});

it('PATCH /sub-sessions/:id relays subsession.rename when only the label changes', async () => {
const { getSubSessionById } = await import('../src/db/queries.js');
vi.mocked(getSubSessionById).mockResolvedValue({
id: 'sub12345',
server_id: 'srv1',
type: 'codex',
} as any);

const res = await app.request('/api/server/srv1/sub-sessions/sub12345', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label: 'Worker Label',
}),
});

expect(res.status).toBe(200);
expect(updateSubSessionMock).toHaveBeenCalledWith(
{},
'sub12345',
'srv1',
{
label: 'Worker Label',
},
);
expect(JSON.parse(String(sendToDaemonMock.mock.calls[0]?.[0]))).toEqual({
type: 'subsession.rename',
sessionName: 'deck_sub_sub12345',
label: 'Worker Label',
});
});
});
57 changes: 54 additions & 3 deletions src/daemon/command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { promisify } from 'node:util';
const execAsync = promisify(execCb);
const execFileAsync = promisify(execFileCb);
import { startP2pRun, cancelP2pRun, getP2pRun, listP2pRuns, serializeP2pRun, type P2pTarget } from './p2p-orchestrator.js';
import { buildSessionList } from './session-list.js';
import { getComboRoundCount, parseModePipeline, P2P_CONFIG_MODE, type P2pSessionConfig } from '../../shared/p2p-modes.js';
import type { P2pAdvancedRound, P2pContextReducerConfig } from '../../shared/p2p-advanced.js';
import { CRON_MSG } from '../../shared/cron-types.js';
Expand Down Expand Up @@ -203,7 +204,6 @@ import { resolveContextWindow } from '../util/model-context.js';
import { QWEN_MODEL_IDS } from '../../shared/qwen-models.js';
import { getQwenRuntimeConfig } from '../agent/qwen-runtime-config.js';
import { getQwenDisplayMetadata } from '../agent/provider-display.js';
import { buildSessionList } from './session-list.js';
import { getQwenOAuthQuotaUsageLabel, recordQwenOAuthRequest } from '../agent/provider-quota.js';
import { listProviderSessions as listProviderSessionsImpl } from './provider-sessions.js';

Expand Down Expand Up @@ -611,12 +611,63 @@ export function handleWebCommand(msg: unknown, serverLink: ServerLink): void {
break;
case 'subsession.rename': {
const sName = cmd.sessionName as string | undefined;
const label = cmd.label as string | undefined;
const label = cmd.label === null
? null
: (typeof cmd.label === 'string' ? cmd.label : undefined);
if (sName && label !== undefined) {
const record = getSession(sName);
if (record) {
upsertSession({ ...record, label, updatedAt: Date.now() });
const nextLabel = label ?? undefined;
upsertSession({ ...record, label: nextLabel, updatedAt: Date.now() });
logger.info({ sessionName: sName, label }, 'subsession.rename: label updated');
const id = sName.replace(/^deck_sub_/, '');
void buildSubSessionSync(id, { label: nextLabel }).then((payload) => {
try {
serverLink.send(payload);
} catch {
// not connected
}
});
}
}
break;
}
case 'session.rename': {
const sessionName = cmd.sessionName as string | undefined;
const projectName = typeof cmd.projectName === 'string' ? cmd.projectName.trim() : '';
if (sessionName && projectName) {
const record = getSession(sessionName);
if (record) {
upsertSession({ ...record, projectName, updatedAt: Date.now() });
logger.info({ sessionName, projectName }, 'session.rename: project name updated');
void buildSessionList().then((sessions) => {
try {
serverLink.send({ type: 'session_list', daemonVersion: serverLink.daemonVersion, sessions });
} catch {
// not connected
}
});
}
}
break;
}
case 'session.relabel': {
const sessionName = cmd.sessionName as string | undefined;
const label = cmd.label === null
? null
: (typeof cmd.label === 'string' ? cmd.label : undefined);
if (sessionName && label !== undefined) {
const record = getSession(sessionName);
if (record) {
upsertSession({ ...record, label: label ?? undefined, updatedAt: Date.now() });
logger.info({ sessionName, label }, 'session.relabel: label updated');
void buildSessionList().then((sessions) => {
try {
serverLink.send({ type: 'session_list', daemonVersion: serverLink.daemonVersion, sessions });
} catch {
// not connected
}
});
}
}
break;
Expand Down
134 changes: 133 additions & 1 deletion test/daemon/command-handler-stop.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

const { stopProjectMock, stopSubSessionMock, loggerErrorMock, loggerWarnMock } = vi.hoisted(() => ({
const { stopProjectMock, stopSubSessionMock, loggerErrorMock, loggerWarnMock, buildSessionListMock } = vi.hoisted(() => ({
stopProjectMock: vi.fn(),
stopSubSessionMock: vi.fn().mockResolvedValue({ ok: true, closed: ['deck_sub_worker'], failed: [] }),
loggerErrorMock: vi.fn(),
loggerWarnMock: vi.fn(),
buildSessionListMock: vi.fn(async () => []),
}));

vi.mock('../../src/store/session-store.js', () => ({
Expand Down Expand Up @@ -80,6 +81,10 @@ vi.mock('../../src/daemon/p2p-orchestrator.js', () => ({
serializeP2pRun: vi.fn(),
}));

vi.mock('../../src/daemon/session-list.js', () => ({
buildSessionList: buildSessionListMock,
}));

vi.mock('../../src/daemon/repo-handler.js', () => ({
handleRepoCommand: vi.fn(),
}));
Expand Down Expand Up @@ -164,4 +169,131 @@ describe('handleWebCommand shutdown failure paths', () => {
message: 'Shutdown failed: backend unavailable',
});
});

it('updates the main-session project name and pushes a refreshed session_list on session.rename', async () => {
const { getSession, upsertSession } = await import('../../src/store/session-store.js');
vi.mocked(getSession).mockReturnValue({
name: 'deck_proj_brain',
projectName: 'proj',
role: 'brain',
agentType: 'codex',
state: 'idle',
restarts: 0,
restartTimestamps: [],
createdAt: 1,
updatedAt: 1,
projectDir: '/tmp/proj',
} as any);
buildSessionListMock.mockResolvedValueOnce([
{
name: 'deck_proj_brain',
project: 'new-proj',
role: 'brain',
agentType: 'codex',
state: 'idle',
},
]);

handleWebCommand({ type: 'session.rename', sessionName: 'deck_proj_brain', projectName: 'new-proj' }, serverLink as any);
await flushAsync();

expect(upsertSession).toHaveBeenCalledWith(expect.objectContaining({
name: 'deck_proj_brain',
projectName: 'new-proj',
}));
expect(serverLink.send).toHaveBeenCalledWith({
type: 'session_list',
daemonVersion: '0.1.0',
sessions: [
{
name: 'deck_proj_brain',
project: 'new-proj',
role: 'brain',
agentType: 'codex',
state: 'idle',
},
],
});
});

it('updates the main-session label and pushes a refreshed session_list on session.relabel', async () => {
const { getSession, upsertSession } = await import('../../src/store/session-store.js');
vi.mocked(getSession).mockReturnValue({
name: 'deck_proj_brain',
projectName: 'proj',
role: 'brain',
agentType: 'codex',
state: 'idle',
label: null,
restarts: 0,
restartTimestamps: [],
createdAt: 1,
updatedAt: 1,
projectDir: '/tmp/proj',
} as any);
buildSessionListMock.mockResolvedValueOnce([
{
name: 'deck_proj_brain',
project: 'proj',
role: 'brain',
agentType: 'codex',
state: 'idle',
label: 'Main Label',
},
]);

handleWebCommand({ type: 'session.relabel', sessionName: 'deck_proj_brain', label: 'Main Label' }, serverLink as any);
await flushAsync();

expect(upsertSession).toHaveBeenCalledWith(expect.objectContaining({
name: 'deck_proj_brain',
label: 'Main Label',
}));
expect(serverLink.send).toHaveBeenCalledWith({
type: 'session_list',
daemonVersion: '0.1.0',
sessions: [
{
name: 'deck_proj_brain',
project: 'proj',
role: 'brain',
agentType: 'codex',
state: 'idle',
label: 'Main Label',
},
],
});
});

it('updates the sub-session label and emits subsession.sync on subsession.rename', async () => {
const { getSession, upsertSession } = await import('../../src/store/session-store.js');
vi.mocked(getSession).mockReturnValue({
name: 'deck_sub_worker',
projectName: 'proj',
role: 'w1',
agentType: 'codex',
state: 'idle',
label: 'old',
restarts: 0,
restartTimestamps: [],
createdAt: 1,
updatedAt: 1,
projectDir: '/tmp/proj',
runtimeType: 'process',
parentSession: 'deck_proj_brain',
} as any);

handleWebCommand({ type: 'subsession.rename', sessionName: 'deck_sub_worker', label: 'Worker Label' }, serverLink as any);
await flushAsync();

expect(upsertSession).toHaveBeenCalledWith(expect.objectContaining({
name: 'deck_sub_worker',
label: 'Worker Label',
}));
expect(serverLink.send).toHaveBeenCalledWith(expect.objectContaining({
type: 'subsession.sync',
id: 'worker',
label: 'Worker Label',
}));
});
});
2 changes: 1 addition & 1 deletion web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,7 @@ export function App() {
const handleRenameSession = useCallback(async (sessionName: string, newProjectName: string) => {
if (!selectedServerId || !newProjectName) return;
// Optimistic update
setSessions((prev) => prev.map((s) => s.name === sessionName ? { ...s, project: newProjectName, label: newProjectName } : s));
setSessions((prev) => prev.map((s) => s.name === sessionName ? { ...s, project: newProjectName } : s));
try {
await apiFetch(`/api/server/${selectedServerId}/sessions/${encodeURIComponent(sessionName)}/rename`, {
method: 'PATCH',
Expand Down
Loading
Loading