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
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import {
createSessionNavigationRowActions,
type SessionNavigationSessionService,
} from '../../renderer/features/session-navigation/testing.js';
import { getShellCopy } from '../../renderer/locales/shell-copy.js';

const copy = getShellCopy('en').sessionRowActions;

/** What Electron IPC does to a thrown error: keep the message, drop the class. */
function ipcError(token: string): Error {
return new Error(`Error invoking remote method 'sessions:archive': Error: ${token}`);
}

type Harness = {
/** Each `archive` call, in order. */
archives: string[];
/** Each error toast, in order. */
errors: Array<{ title: string; description?: string }>;
/** Each success toast, in order. */
successes: Array<{ title: string; description?: string }>;
/** Archive refusals per session id, popped one per call; absent archives fine. */
refusals: Map<string, Error[]>;
};

function harness(): Harness {
return { archives: [], errors: [], successes: [], refusals: new Map() };
}

function installService(h: Harness): SessionNavigationSessionService {
return {
list: async () => [],
setFlagged: async () => undefined,
archive: async (sessionId: string) => {
h.archives.push(sessionId);
const refusal = h.refusals.get(sessionId)?.shift();
if (refusal) throw refusal;
},
unarchive: async () => undefined,
rename: async () => undefined,
remove: async () => ({ disposition: 'removed', archivedSubtaskCount: 0 }),
previewRemoval: async () => 0,
previewRemovals: async () => ({
archivableSubtaskCount: 0,
removedSubtaskCount: 0,
worktreeCount: 0,
}),
moveToProject: async () => ({ ok: true }),
};
}

function createActions(h: Harness) {
return createSessionNavigationRowActions({
uiLocale: 'en',
acquireAutomaticQueryBlock: () => ({ release: () => undefined }),
clearSessionRendererState: () => undefined,
pendingSessionRowActionsRef: { current: new Set<string>() },
refreshSessions: async () => [],
service: installService(h),
sessionsRef: { current: [] },
toastApi: {
success: (title: string, description?: string) => {
h.successes.push({ title, description });
},
error: (title: string, description?: string) => {
h.errors.push({ title, description });
},
confirm: async () => true,
},
});
}

describe('archiveSession refusals', () => {
it('names the delegation action when the Host refuses for a live WorkHub delegation', async () => {
const h = harness();
h.refusals.set('s1', [
ipcError('session_archive_refused: workhub_delegation'),
]);
const actions = createActions(h);

await actions.archiveSession('s1');

assert.deepEqual(h.errors, [
{ title: copy.archiveFailedTitle, description: copy.archiveRefusedDelegation },
]);
});

it('names the subtask action when the Host refuses for a live linked child', async () => {
const h = harness();
h.refusals.set('s1', [ipcError('session_archive_refused: linked_child')]);
const actions = createActions(h);

await actions.archiveSession('s1');

assert.deepEqual(h.errors, [
{ title: copy.archiveFailedTitle, description: copy.archiveRefusedSubtasks },
]);
});

it('keeps the generic line for a refusal it cannot explain', async () => {
const h = harness();
h.refusals.set('s1', [ipcError('persistence_failed: disk on fire')]);
const actions = createActions(h);

await actions.archiveSession('s1');

assert.equal(h.errors.length, 1);
assert.equal(h.errors[0]!.title, copy.archiveFailedTitle);
assert.notEqual(h.errors[0]!.description, copy.archiveRefusedDelegation);
assert.notEqual(h.errors[0]!.description, copy.archiveRefusedSubtasks);
});
});

describe('archiveSelected sweep', () => {
it('retries a parent refused ahead of its own subtask and reports success', async () => {
const h = harness();
// The selection lists the parent first; the Host refuses it while the
// subtask later in the sweep is still live.
h.refusals.set('parent', [ipcError('session_archive_refused: linked_child')]);
const actions = createActions(h);

await actions.archiveSelected(['parent', 'child']);

assert.deepEqual(h.archives, ['parent', 'child', 'parent']);
assert.deepEqual(h.errors, []);
assert.deepEqual(h.successes, [
{ title: copy.bulkArchivedTitle(2), description: copy.bulkArchiveDescription },
]);
});

it('retries a genuine refusal once and still reports it as failed', async () => {
const h = harness();
h.refusals.set('s1', [
ipcError('session_archive_refused: workhub_delegation'),
ipcError('session_archive_refused: workhub_delegation'),
]);
const actions = createActions(h);

await actions.archiveSelected(['s1']);

assert.deepEqual(h.archives, ['s1', 's1']);
assert.deepEqual(h.errors, [
{ title: copy.bulkArchiveFailedTitle, description: copy.archiveRefusedDelegation },
]);
});
});
26 changes: 25 additions & 1 deletion apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,11 @@ export function registerRuntimeHostSessionCatalogIpc(
ipcMain.handle('sessions:archive', async (_event, sessionId: string, options?: unknown) => {
requestsRevisionFamily(options);
const ids = await actionIds(sessionId, { revisionFamily: true });
await deps.client.setSessionLifecycle(sessionId, 'archived');
try {
await deps.client.setSessionLifecycle(sessionId, 'archived');
} catch (error) {
throw asArchiveRefusal(error);
}
await finishSessionRetirement(deps, ids, 'archived');
});
ipcMain.handle('sessions:unarchive', async (_event, sessionId: string, options?: unknown) => {
Expand Down Expand Up @@ -384,6 +388,26 @@ async function updateConfiguration(
return { ok: true, session: toDesktopHostSessionSummary(session) };
}

/**
* Electron IPC strips the error class off a `RuntimeHostOperationError`, so a
* refusal the rail can explain has to travel as a stable token in the message
* text. These are the archive guard's `session_busy` refusals a user can act
* on; any other failure keeps the Host's own message.
*/
const ARCHIVE_REFUSAL_TOKENS: ReadonlyArray<readonly [needle: string, reason: string]> = [
['has an active WorkHub delegation', 'workhub_delegation'],
['has an undelivered WorkHub result', 'workhub_result'],
['has a live linked child Session', 'linked_child'],
];

function asArchiveRefusal(error: unknown): unknown {
if (!(error instanceof RuntimeHostOperationError) || error.code !== 'session_busy') return error;
for (const [needle, reason] of ARCHIVE_REFUSAL_TOKENS) {
if (error.message.includes(needle)) return new Error(`session_archive_refused: ${reason}`);
}
return error;
}

const EXPECTED_UPDATE_FAILURES = [
'session_busy',
'operation_conflict',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export function createSessionNavigationRowActions(deps: {
actionId: 'flag' | 'archive' | 'rename' | 'delete' | 'move',
errorTitle: string,
action: () => Promise<void>,
describeError?: (error: unknown) => string | undefined,
): Promise<void> {
const sessionPrefix = `${sessionId}:`;
if (Array.from(pendingSessionRowActionsRef.current).some((key) => key.startsWith(sessionPrefix))) return;
Expand All @@ -175,7 +176,7 @@ export function createSessionNavigationRowActions(deps: {
} catch (error) {
toastApi.error(
errorTitle,
localizedShellErrorMessage(error, copy.actionFallback, uiLocale),
describeError?.(error) ?? localizedShellErrorMessage(error, copy.actionFallback, uiLocale),
undefined,
{ sessionId },
);
Expand All @@ -184,6 +185,23 @@ export function createSessionNavigationRowActions(deps: {
}
}

/**
* Archive refusals worth explaining arrive re-keyed by the main process as
* `session_archive_refused: <reason>` — Electron IPC strips the error class,
* so the guard's session_busy message cannot carry it any other way. Each
* token names the action that unblocks the archive; anything else keeps the
* generic failure line.
*/
function archiveRefusalDescription(error: unknown): string | undefined {
const reason = (error instanceof Error ? error.message : String(error)).match(
/session_archive_refused: (\S+)/,
)?.[1];
if (reason === 'workhub_delegation') return copy.archiveRefusedDelegation;
if (reason === 'workhub_result') return copy.archiveRefusedResult;
if (reason === 'linked_child') return copy.archiveRefusedSubtasks;
return undefined;
}

async function flagSession(sessionId: string, flagged: boolean) {
return runSessionRowAction(sessionId, 'flag', flagged ? copy.flagFailedTitle : copy.unflagFailedTitle, async () => {
await service.setFlagged(sessionId, flagged, { revisionFamily: true });
Expand All @@ -192,13 +210,19 @@ export function createSessionNavigationRowActions(deps: {
}

async function archiveSession(sessionId: string) {
return runSessionRowAction(sessionId, 'archive', copy.archiveFailedTitle, async () => {
await withAutomaticQueryBlock(sessionId, async (familyIds) => {
await service.archive(sessionId, { revisionFamily: true });
for (const id of familyIds) clearSessionRendererState(id);
await refreshSessions();
});
});
return runSessionRowAction(
sessionId,
'archive',
copy.archiveFailedTitle,
async () => {
await withAutomaticQueryBlock(sessionId, async (familyIds) => {
await service.archive(sessionId, { revisionFamily: true });
for (const id of familyIds) clearSessionRendererState(id);
await refreshSessions();
});
},
archiveRefusalDescription,
);
}

async function unarchiveSession(sessionId: string) {
Expand Down Expand Up @@ -495,6 +519,12 @@ export function createSessionNavigationRowActions(deps: {
* the delete sweep's shape, which would carry a `restored` field that can
* never be anything but empty.
*
* A selection can hold a parent picked before its own subtask; the Host
* refuses that first call while the subtask is still live. One retry after
* the sweep settles the order inside the selection without teaching the rail
* about subtask links — and a refusal that was never about ordering fails
* once more, exactly as before.
*
* Like the sweep, it raises no toast per task: one action is one message, and
* a run of them is what a sweep exists to avoid.
*/
Expand All @@ -503,36 +533,45 @@ export function createSessionNavigationRowActions(deps: {
new Set(sessionIds.flatMap((sessionId) => revisionFamilySessionIds(sessionsRef.current, sessionId))),
);
return withAutomaticQueryBlockOn(familyIds, async () => {
const failed: string[] = [];
let firstFailure: SessionArchiveOutcome['firstFailure'];
let archived = 0;
for (const sessionId of sessionIds) {
const failures = new Map<string, { error?: unknown }>();
const attempt = async (sessionId: string): Promise<void> => {
const key = `${sessionId}:archive`;
if (
Array.from(pendingSessionRowActionsRef.current).some((pending) =>
pending.startsWith(`${sessionId}:`),
)
) {
failed.push(sessionId);
continue;
failures.set(sessionId, {});
return;
}
pendingSessionRowActionsRef.current.add(key);
try {
const rowFamilyIds = revisionFamilySessionIds(sessionsRef.current, sessionId);
await service.archive(sessionId, { revisionFamily: true });
for (const id of rowFamilyIds) clearSessionRendererState(id);
archived += 1;
failures.delete(sessionId);
} catch (error) {
failed.push(sessionId);
firstFailure ??= { error, sessionId };
failures.set(sessionId, { error });
} finally {
pendingSessionRowActionsRef.current.delete(key);
}
}
};
for (const sessionId of sessionIds) await attempt(sessionId);
for (const sessionId of [...failures.keys()]) await attempt(sessionId);
const failed = [...failures.keys()];
const firstFailedId = failed.find((sessionId) => failures.get(sessionId)?.error !== undefined);
// Once, after the whole sweep. Refreshing per task would re-render the rail
// under the user's cursor for every id in the selection.
await refreshSessions();
return { archived, failed, firstFailure };
return {
archived,
failed,
firstFailure: firstFailedId
? { error: failures.get(firstFailedId)!.error, sessionId: firstFailedId }
: undefined,
};
});
}

Expand Down Expand Up @@ -561,7 +600,8 @@ export function createSessionNavigationRowActions(deps: {
toastApi.error(
copy.bulkArchiveFailedTitle,
outcome.firstFailure
? localizedShellErrorMessage(outcome.firstFailure.error, copy.actionFallback, uiLocale)
? archiveRefusalDescription(outcome.firstFailure.error) ??
localizedShellErrorMessage(outcome.firstFailure.error, copy.actionFallback, uiLocale)
: copy.bulkFailedBody(outcome.failed.length),
undefined,
outcome.firstFailure ? { sessionId: outcome.firstFailure.sessionId } : undefined,
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/renderer/locales/shell-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,10 @@ type ShellCopy = {
'session_busy' | 'operation_conflict' | 'operation_unavailable' | 'not_found',
string
>;
/** Why the Host refused an archive, keyed by the guard's refusal token. */
archiveRefusedDelegation: string;
archiveRefusedResult: string;
archiveRefusedSubtasks: string;
currentConversation: string;
deleteTitle(name: string): string;
deleteDescription: string;
Expand Down Expand Up @@ -886,6 +890,9 @@ const SHELL_COPY_BY_LOCALE = {
operation_unavailable: '当前无法移动这个任务。',
not_found: '找不到该项目或任务。',
},
archiveRefusedDelegation: 'WorkHub 仍在向该任务委派工作。先停止或更换委派,再归档。',
archiveRefusedResult: '委派结果还没有送达 WorkHub。请打开 WorkHub 收取结果,或停止该委派。',
archiveRefusedSubtasks: '该任务还有进行中的子任务。请等子任务完成或停止它们,再归档该任务。',
currentConversation: '当前任务',
deleteTitle: (name: string) => `删除 "${name}"`,
deleteDescription: '任务和全部消息会从磁盘上永久移除。该操作不可撤销。',
Expand Down Expand Up @@ -1410,6 +1417,9 @@ const SHELL_COPY_BY_LOCALE = {
operation_unavailable: '目前無法移動這個任務。',
not_found: '找不到該專案或任務。',
},
archiveRefusedDelegation: 'WorkHub 仍在向該任務委派工作。先停止或更換委派,再歸檔。',
archiveRefusedResult: '委派結果還沒有送達 WorkHub。請開啟 WorkHub 收取結果,或停止該委派。',
archiveRefusedSubtasks: '該任務還有進行中的子任務。請等子任務完成或停止它們,再歸檔該任務。',
currentConversation: '目前任務',
deleteTitle: (name: string) => `刪除 "${name}"`,
deleteDescription: '任務和全部訊息會從磁碟上永久移除。該操作不可撤銷。',
Expand Down Expand Up @@ -1936,6 +1946,12 @@ const SHELL_COPY_BY_LOCALE = {
operation_unavailable: 'This task cannot be moved right now.',
not_found: 'That project or task could not be found.',
},
archiveRefusedDelegation:
'WorkHub still delegates work to this task. Stop or replace the delegation, then archive it.',
archiveRefusedResult:
"WorkHub has not received this task's result yet. Open WorkHub so it can collect it, or stop the delegation.",
archiveRefusedSubtasks:
'This task has subtasks still in flight. Wait for them to finish or stop them, then archive this task.',
currentConversation: 'Current task',
deleteTitle: (name: string) => `Delete "${name}"`,
deleteDescription:
Expand Down
Loading