Skip to content

Commit eab04e2

Browse files
committed
fix(desktop): close release readiness gaps
1 parent 7fc3676 commit eab04e2

14 files changed

Lines changed: 454 additions & 92 deletions

File tree

.github/workflows/desktop-build.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ jobs:
3030
name: '${{ matrix.name }}'
3131
runs-on: '${{ matrix.os }}'
3232
timeout-minutes: 120
33+
permissions:
34+
contents: 'read'
3335
strategy:
3436
fail-fast: false
3537
matrix:

packages/channels/whatsapp/src/WhatsAppAdapter.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,15 @@ describe('WhatsApp connection lifecycle', () => {
144144
lastDisconnect: { error: { output: { statusCode: 401 } } },
145145
});
146146

147-
expect(onTerminalDisconnect).toHaveBeenCalledOnce();
147+
await vi.waitFor(() => expect(onTerminalDisconnect).toHaveBeenCalledOnce());
148148
expect(onTerminalDisconnect).toHaveBeenCalledWith(
149149
expect.objectContaining({
150150
message: expect.stringContaining('logged out'),
151151
}),
152152
);
153+
await vi.waitFor(async () => {
154+
await expect(stat(stateDir)).rejects.toMatchObject({ code: 'ENOENT' });
155+
});
153156
await adapter.disconnect();
154157
});
155158

@@ -165,6 +168,7 @@ describe('WhatsApp connection lifecycle', () => {
165168
});
166169

167170
await expect(connecting).rejects.toThrow('logged out');
171+
await expect(stat(stateDir)).rejects.toMatchObject({ code: 'ENOENT' });
168172
expect(onTerminalDisconnect).not.toHaveBeenCalled();
169173
await adapter.disconnect();
170174
});

packages/channels/whatsapp/src/WhatsAppAdapter.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { chmod, mkdir, readdir } from 'node:fs/promises';
1+
import { chmod, mkdir, readdir, rm } from 'node:fs/promises';
22
import { homedir } from 'node:os';
33
import { join } from 'node:path';
44
import makeWASocket, {
@@ -160,8 +160,14 @@ export class WhatsAppChannel extends ChannelBase {
160160
const error = new Error(
161161
'WhatsApp logged out; reconfigure the channel to pair again.',
162162
);
163-
failed(error);
164163
process.stderr.write(`[WhatsApp:${this.name}] ${error.message}\n`);
164+
void rm(authDir, { recursive: true, force: true })
165+
.catch((cleanupError) => {
166+
process.stderr.write(
167+
`[WhatsApp:${this.name}] Failed to clear logged-out credentials: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`,
168+
);
169+
})
170+
.finally(() => failed(error));
165171
return;
166172
}
167173
this.reconnectAttempts += 1;

packages/cli/src/serve/channel-management-service.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as path from 'node:path';
1010
import { PairingStore } from '@qwen-code/channel-base';
1111
import type { CreatePairingRequestResult } from '@qwen-code/channel-base';
1212
import { describe, expect, it, vi } from 'vitest';
13+
import { daemonChannelStateDir } from '../commands/channel/runtime.js';
1314
import type { ChannelSettingsSnapshot } from './channel-settings-store.js';
1415
import {
1516
createChannelManagementService,
@@ -558,6 +559,49 @@ describe('createChannelManagementService', () => {
558559
expect(persisted().channels['bot']).toBeDefined();
559560
});
560561

562+
it('removes adapter state when a channel is deleted', async () => {
563+
const previousQwenHome = process.env['QWEN_HOME'];
564+
const qwenHome = await fs.mkdtemp(
565+
path.join(os.tmpdir(), 'channel-management-remove-'),
566+
);
567+
process.env['QWEN_HOME'] = qwenHome;
568+
try {
569+
const stateDir = daemonChannelStateDir(WORKSPACE, 'bot');
570+
await fs.mkdir(stateDir, { recursive: true });
571+
await fs.writeFile(path.join(stateDir, 'credentials.json'), 'secret');
572+
const { service, store } = setup({ committedNames: ['bot'] });
573+
574+
store.remove.mockRejectedValueOnce(new Error('settings write failed'));
575+
await expect(
576+
service.remove('bot', { expectedRevision: 'rev-1' }),
577+
).rejects.toThrow('settings write failed');
578+
await expect(
579+
fs.readFile(path.join(stateDir, 'credentials.json'), 'utf8'),
580+
).resolves.toBe('secret');
581+
582+
await service.remove('bot', { expectedRevision: 'rev-1' });
583+
584+
await expect(fs.stat(stateDir)).rejects.toMatchObject({ code: 'ENOENT' });
585+
expect(store.remove).toHaveBeenCalledWith('bot', {
586+
expectedRevision: 'rev-1',
587+
});
588+
589+
const tombstone = `${stateDir}.deleting-stale`;
590+
await fs.mkdir(tombstone, { recursive: true });
591+
await fs.writeFile(path.join(tombstone, 'credentials.json'), 'secret');
592+
await expect(
593+
service.remove('bot', { expectedRevision: 'rev-2' }),
594+
).rejects.toMatchObject({ code: 'channel_instance_not_found' });
595+
await expect(fs.stat(tombstone)).rejects.toMatchObject({
596+
code: 'ENOENT',
597+
});
598+
} finally {
599+
if (previousQwenHome === undefined) delete process.env['QWEN_HOME'];
600+
else process.env['QWEN_HOME'] = previousQwenHome;
601+
await fs.rm(qwenHome, { recursive: true, force: true });
602+
}
603+
});
604+
561605
it('rejects stale removal before changing runtime state', async () => {
562606
const { service, store, manager } = setup({ committedNames: ['bot'] });
563607

packages/cli/src/serve/channel-management-service.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import { randomUUID } from 'node:crypto';
8+
import { readdir, rename, rm } from 'node:fs/promises';
9+
import { basename, dirname, join } from 'node:path';
710
import { redactLogCredentials } from '@qwen-code/acp-bridge/logRedaction';
811
import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths';
912
import {
@@ -12,6 +15,7 @@ import {
1215
type PairingRequest,
1316
} from '@qwen-code/channel-base';
1417
import { resolveChannelCwd } from '../commands/channel/channel-cwd.js';
18+
import { daemonChannelStateDir } from '../commands/channel/runtime.js';
1519
import { getPlugin } from '../commands/channel/channel-registry.js';
1620
import type {
1721
ChannelSecretUpdate,
@@ -73,6 +77,28 @@ export interface ChannelPairingRequestsSnapshot {
7377
requests: PairingRequest[];
7478
}
7579

80+
async function removeStagedChannelState(stateDir: string): Promise<void> {
81+
const parent = dirname(stateDir);
82+
const prefix = `${basename(stateDir)}.deleting-`;
83+
const entries = await readdir(parent, { withFileTypes: true }).catch(
84+
(error: unknown) => {
85+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
86+
throw error;
87+
},
88+
);
89+
await Promise.all(
90+
entries
91+
.filter((entry) => entry.name.startsWith(prefix))
92+
.map((entry) =>
93+
rm(join(parent, entry.name), {
94+
recursive: true,
95+
force: true,
96+
maxRetries: 3,
97+
}),
98+
),
99+
);
100+
}
101+
76102
export interface ChannelPairingApprovalResult
77103
extends ChannelPairingRequestsSnapshot {
78104
approved: PairingRequest;
@@ -443,8 +469,10 @@ export function createChannelManagementService(
443469
},
444470
async remove(name, request) {
445471
assertManageableInstanceName(name);
472+
const stateDir = daemonChannelStateDir(opts.workspaceCwd, name);
446473
const current = opts.store.snapshot();
447474
if (!Object.hasOwn(current.channels, name)) {
475+
await removeStagedChannelState(stateDir);
448476
throw new ChannelManagementError(
449477
'channel_instance_not_found',
450478
`Channel "${name}" is not configured in this workspace.`,
@@ -456,7 +484,22 @@ export function createChannelManagementService(
456484
assertOwnedRuntime(name);
457485
await stopChannel(name);
458486
}
459-
const persisted = await opts.store.remove(name, request);
487+
const stagedStateDir = `${stateDir}.deleting-${randomUUID()}`;
488+
let stagedState = false;
489+
try {
490+
await rename(stateDir, stagedStateDir);
491+
stagedState = true;
492+
} catch (error) {
493+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
494+
}
495+
let persisted: ChannelSettingsSnapshot;
496+
try {
497+
persisted = await opts.store.remove(name, request);
498+
} catch (error) {
499+
if (stagedState) await rename(stagedStateDir, stateDir);
500+
throw error;
501+
}
502+
await removeStagedChannelState(stateDir);
460503
diagnostics.delete(name);
461504
return resultFor(name, persisted);
462505
},

packages/cli/src/serve/multi-workspace-sessions.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,59 @@ describe('multi-workspace session dispatch', () => {
10811081
expect(res.body.features).toContain('multi_workspace_session_shell');
10821082
});
10831083

1084+
it('restores an unqualified dormant session through its persisted workspace owner', async () => {
1085+
await withRuntimeDir(async () => {
1086+
const sessionId = 'dormant-secondary-session';
1087+
await writeStoredSession({
1088+
sessionId,
1089+
cwd: SECONDARY_CWD,
1090+
timestamp: '2026-07-08T00:00:00.000Z',
1091+
prompt: 'secondary owner',
1092+
mtime: new Date('2026-07-08T00:00:00.000Z'),
1093+
});
1094+
const { app, registry, primaryBridge, secondaryBridge } = makeHarness({
1095+
primarySummaries: [],
1096+
secondarySummaries: [],
1097+
});
1098+
registry.beginReplacement(registry.primaryEntry, 'policy-2');
1099+
1100+
const response = await request(app)
1101+
.post(`/session/${sessionId}/load`)
1102+
.set('Host', host())
1103+
.send({});
1104+
1105+
expect(response.status).toBe(200);
1106+
expect(response.body.workspaceCwd).toBe(SECONDARY_CWD);
1107+
expect(primaryBridge.restoreCalls).toEqual([]);
1108+
expect(secondaryBridge.restoreCalls).toEqual([
1109+
{
1110+
action: 'load',
1111+
req: expect.objectContaining({
1112+
sessionId,
1113+
workspaceCwd: SECONDARY_CWD,
1114+
}),
1115+
},
1116+
]);
1117+
});
1118+
});
1119+
1120+
it('does not fall back to primary for an unqualified unknown session', async () => {
1121+
const { app, primaryBridge, secondaryBridge } = makeHarness({
1122+
primarySummaries: [],
1123+
secondarySummaries: [],
1124+
});
1125+
1126+
const response = await request(app)
1127+
.post('/session/missing-unqualified/load')
1128+
.set('Host', host())
1129+
.send({});
1130+
1131+
expect(response.status).toBe(404);
1132+
expect(response.body.code).toBe('session_not_found');
1133+
expect(primaryBridge.restoreCalls).toEqual([]);
1134+
expect(secondaryBridge.restoreCalls).toEqual([]);
1135+
});
1136+
10841137
it('aggregates daemon status session count and exposes workspace metadata', async () => {
10851138
const { app } = makeHarness();
10861139
const res = await request(app).get('/daemon/status').set('Host', host());

0 commit comments

Comments
 (0)