perf(sessions): archive a worktree's sessions through one server batch
Removing a worktree archived its sessions one SDK call at a time and then re-rendered the whole sidebar once per streamed session.updated echo. On a worktree with 121 sessions that meant 14.8s of main-thread work, 121 requests, and 328 localStorage writes. - Add POST /api/openchamber/sessions/archive: validates a batch (max 500 ids, per-request archivedAt), archives sequentially, and reports partial failures instead of dropping the batch. VS Code serves no such route and answers 501; the shared UI then falls back to the per-session path. - Plan batches from the sessions this client actually holds, live directory stores first, so worktree-only sessions still batch. - Claim (id, archivedAt) pairs before the request and consume the matching session.updated echoes, so the server's own confirmations no longer fan out into 121 store publications. Runtime-scoped, TTL 30s, released on response or fallback; non-matching updates pass. - Make the managed-chats persistence a real trailing debounce instead of a 50ms throttle, so a burst of publications coalesces into one localStorage write. Benchmark (121 sessions, production build, real Chrome): 14785ms -> ~1030ms, long tasks 100 -> 1, global store publications 236 -> 1, persistence writes 328 -> 3.
This commit is contained in:
@@ -253,6 +253,40 @@ const latestCompletedAssistantMessageID = async ({ client, sessionID, directory
|
||||
return asNonEmptyString(latest?.id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Upper bound on one archive batch.
|
||||
*
|
||||
* The batch is applied one session at a time against OpenCode, so an unbounded
|
||||
* list would hold a request open for as long as the list is large. Callers with
|
||||
* more sessions than this send several batches and keep their own partial
|
||||
* results.
|
||||
*/
|
||||
const MAX_ARCHIVE_BATCH = 500;
|
||||
|
||||
const parseArchiveRequest = (payload) => {
|
||||
const rawIds = payload?.ids;
|
||||
if (!Array.isArray(rawIds) || rawIds.length === 0) {
|
||||
return { ok: false, error: 'ids must be a non-empty array of session ids' };
|
||||
}
|
||||
if (rawIds.length > MAX_ARCHIVE_BATCH) {
|
||||
return { ok: false, error: `ids must contain at most ${MAX_ARCHIVE_BATCH} session ids` };
|
||||
}
|
||||
|
||||
const ids = [];
|
||||
for (const value of rawIds) {
|
||||
const id = asNonEmptyString(value);
|
||||
if (!id) return { ok: false, error: 'ids must contain non-empty session ids' };
|
||||
ids.push(id);
|
||||
}
|
||||
|
||||
const archivedAt = payload?.archivedAt;
|
||||
if (archivedAt !== undefined && (!Number.isSafeInteger(archivedAt) || archivedAt <= 0)) {
|
||||
return { ok: false, error: 'archivedAt must be a positive integer timestamp' };
|
||||
}
|
||||
|
||||
return { ok: true, ids, archivedAt: archivedAt ?? Date.now() };
|
||||
};
|
||||
|
||||
const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated, sanitizeProjects, validateDirectoryPath }) => {
|
||||
const projectID = asNonEmptyString(payload?.projectId) || asNonEmptyString(payload?.projectID);
|
||||
if (projectID) {
|
||||
@@ -579,6 +613,64 @@ export const createOpenChamberSessionService = (dependencies) => {
|
||||
return { model, agent, variant, promptDispatched: true, dispatchedAsCommand: Boolean(resolvedCommand) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Archive a batch of sessions in one request.
|
||||
*
|
||||
* The UI archives every session linked to a worktree before removing it.
|
||||
* Doing that from the browser costs one request per session plus a store
|
||||
* reconciliation between each of them, which is what made deleting a
|
||||
* worktree with many sessions take tens of seconds. Here the batch stays on
|
||||
* the server, next to OpenCode, and the client reconciles once.
|
||||
*
|
||||
* Sessions are updated one at a time on purpose: they are archived against a
|
||||
* single OpenCode instance, and a fan-out of concurrent writes would trade a
|
||||
* UI stall for server event-loop starvation. One failed session never stops
|
||||
* the batch — it is reported in `failedIds` while the rest still archive, so
|
||||
* callers keep the partial-failure behaviour they already show.
|
||||
*/
|
||||
const archive = async (payload = {}) => {
|
||||
const parsed = parseArchiveRequest(payload);
|
||||
if (!parsed.ok) {
|
||||
throw new OpenChamberControlError(parsed.error, 400);
|
||||
}
|
||||
|
||||
const resolvedDirectory = await resolveRequestedDirectory({
|
||||
payload,
|
||||
readSettingsFromDiskMigrated,
|
||||
sanitizeProjects,
|
||||
validateDirectoryPath,
|
||||
});
|
||||
if (!resolvedDirectory.ok) {
|
||||
throw new OpenChamberControlError(resolvedDirectory.error, resolvedDirectory.status || 400);
|
||||
}
|
||||
|
||||
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
|
||||
|
||||
const directory = resolvedDirectory.directory;
|
||||
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
|
||||
const client = createOpencodeClient({ baseUrl, headers: getOpenCodeAuthHeaders() });
|
||||
|
||||
const archived = [];
|
||||
const failedIds = [];
|
||||
for (const sessionID of parsed.ids) {
|
||||
try {
|
||||
const response = await client.session.update({
|
||||
sessionID,
|
||||
directory,
|
||||
time: { archived: parsed.archivedAt },
|
||||
});
|
||||
const session = response?.data;
|
||||
if (session?.id) archived.push(session);
|
||||
else failedIds.push(sessionID);
|
||||
} catch (error) {
|
||||
console.warn('[OpenChamberSessions] failed to archive session', sessionID, error);
|
||||
failedIds.push(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
return { directory, archived, failedIds };
|
||||
};
|
||||
|
||||
const create = async (payload = {}) => {
|
||||
const title = asNonEmptyString(payload.title);
|
||||
const prompt = asNonEmptyString(payload.prompt);
|
||||
@@ -813,6 +905,7 @@ export const createOpenChamberSessionService = (dependencies) => {
|
||||
|
||||
return {
|
||||
create,
|
||||
archive,
|
||||
send: (sessionID, payload) => runExisting('send', sessionID, payload),
|
||||
fork: (sessionID, payload) => runExisting('fork', sessionID, payload),
|
||||
};
|
||||
@@ -843,6 +936,15 @@ export const registerOpenChamberSessionRoutes = (app, dependencies) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/openchamber/sessions/archive', express.json({ limit: '1mb' }), async (req, res) => {
|
||||
try {
|
||||
return res.json(await service.archive(req.body && typeof req.body === 'object' ? req.body : {}));
|
||||
} catch (error) {
|
||||
console.error('[OpenChamberSessions] failed to archive sessions:', error);
|
||||
return sendServiceError(res, error, 'Failed to archive sessions');
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/api/openchamber/sessions/:sessionId/send',
|
||||
express.json({ limit: '1mb' }),
|
||||
|
||||
@@ -17,6 +17,7 @@ const getWorktreeBootstrapStatusMock = vi.fn(async () => ({
|
||||
const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } }));
|
||||
const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } }));
|
||||
const sessionMessagesMock = vi.fn(async () => ({ data: [] }));
|
||||
const sessionUpdateMock = vi.fn(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } }));
|
||||
|
||||
let existingSessionMessages = [];
|
||||
let dispatchedUserMessageSeq = 0;
|
||||
@@ -78,6 +79,7 @@ vi.mock('@opencode-ai/sdk/v2', () => ({
|
||||
fork: sessionForkMock,
|
||||
messages: sessionMessagesMock,
|
||||
command: sessionCommandMock,
|
||||
update: sessionUpdateMock,
|
||||
},
|
||||
command: {
|
||||
list: commandListMock,
|
||||
@@ -132,6 +134,92 @@ describe('openchamber session routes', () => {
|
||||
sessionCommandMock.mockResolvedValue({ data: {} });
|
||||
commandListMock.mockReset();
|
||||
commandListMock.mockResolvedValue({ data: [] });
|
||||
sessionUpdateMock.mockReset();
|
||||
sessionUpdateMock.mockImplementation(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } }));
|
||||
});
|
||||
|
||||
describe('archiving a batch of sessions', () => {
|
||||
it('archives every id against the resolved directory and returns the archived sessions', async () => {
|
||||
const { app } = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/sessions/archive')
|
||||
.send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'], archivedAt: 1700 })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.directory).toBe('/repo/app');
|
||||
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_b']);
|
||||
expect(response.body.failedIds).toEqual([]);
|
||||
expect(sessionUpdateMock).toHaveBeenCalledTimes(2);
|
||||
expect(sessionUpdateMock).toHaveBeenCalledWith({
|
||||
sessionID: 'ses_a',
|
||||
directory: '/repo/app',
|
||||
time: { archived: 1700 },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps archiving after a failed session and reports it as failed', async () => {
|
||||
sessionUpdateMock.mockImplementation(async ({ sessionID }) => {
|
||||
if (sessionID === 'ses_b') throw new Error('session.update failed');
|
||||
return { data: { id: sessionID } };
|
||||
});
|
||||
|
||||
const { app } = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/sessions/archive')
|
||||
.send({ directory: '/repo/app', ids: ['ses_a', 'ses_b', 'ses_c'] })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_c']);
|
||||
expect(response.body.failedIds).toEqual(['ses_b']);
|
||||
});
|
||||
|
||||
it('reports a session the server did not confirm as failed instead of archived', async () => {
|
||||
sessionUpdateMock.mockImplementation(async ({ sessionID }) => (
|
||||
sessionID === 'ses_b' ? { data: null } : { data: { id: sessionID } }
|
||||
));
|
||||
|
||||
const { app } = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/openchamber/sessions/archive')
|
||||
.send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'] })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a']);
|
||||
expect(response.body.failedIds).toEqual(['ses_b']);
|
||||
});
|
||||
|
||||
it('rejects an empty batch, an oversized batch, and non-string ids', async () => {
|
||||
const { app } = createApp();
|
||||
|
||||
await request(app).post('/api/openchamber/sessions/archive').send({ directory: '/repo/app', ids: [] }).expect(400);
|
||||
await request(app)
|
||||
.post('/api/openchamber/sessions/archive')
|
||||
.send({ directory: '/repo/app', ids: Array.from({ length: 501 }, (_, index) => `ses_${index}`) })
|
||||
.expect(400);
|
||||
await request(app)
|
||||
.post('/api/openchamber/sessions/archive')
|
||||
.send({ directory: '/repo/app', ids: ['ses_a', ''] })
|
||||
.expect(400);
|
||||
await request(app)
|
||||
.post('/api/openchamber/sessions/archive')
|
||||
.send({ directory: '/repo/app', ids: ['ses_a'], archivedAt: -1 })
|
||||
.expect(400);
|
||||
|
||||
expect(sessionUpdateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a directory the runtime does not accept', async () => {
|
||||
const { app } = createApp({
|
||||
validateDirectoryPath: async () => ({ ok: false, error: 'Invalid directory' }),
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post('/api/openchamber/sessions/archive')
|
||||
.send({ directory: '/elsewhere', ids: ['ses_a'] })
|
||||
.expect(400);
|
||||
|
||||
expect(sessionUpdateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a session for a directory', async () => {
|
||||
|
||||
Reference in New Issue
Block a user