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
6 changes: 5 additions & 1 deletion apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,9 @@
]
},
"src/renderer/features/storage-usage/index.ts": {
"appShell": [
"ArchiveRetentionNotices"
],
"composition": [
"StorageUsageServicesProvider"
]
Expand Down Expand Up @@ -696,6 +699,7 @@
"./features/session-collaboration": 1,
"./features/session-navigation": 1,
"./features/session-settings": 1,
"./features/storage-usage/index.js": 1,
"./features/task-entry": 1,
"./features/workbar": 1,
"./features/workhub": 1,
Expand Down Expand Up @@ -727,7 +731,7 @@
"react": 1
},
"importSpecifiers": 52,
"nonTriviaTokens": 7333
"nonTriviaTokens": 7330
},
"src/renderer/use-app-shell-session-list.ts": {
"importDeclarations": 4,
Expand Down
374 changes: 374 additions & 0 deletions apps/desktop/src/main/__tests__/archive-retention-notices.test.ts

Large diffs are not rendered by default.

20 changes: 8 additions & 12 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import { WorkHubControlOverlay, WorkHubDock, WorkHubMainNavigation } from './features/workhub';
import { WorkHubEnablementWatch } from './application/contracts/workhub-workspace/workhub-enablement.js';
import { RuntimeHostHandoffOverlay } from './features/runtime-host-management/index.js';
import { ArchiveRetentionNotices } from './features/storage-usage/index.js';
import {
useCallback,
useLayoutEffect,
Expand Down Expand Up @@ -251,7 +252,7 @@ function AppShellContent({
recordSessionChange,
sessionCatalogController,
commitSession,
activeCatalogSession,
activeCatalogSession: activeSession,
activeHostSession,
sharedSessionActive,
ownerActiveId,
Expand Down Expand Up @@ -288,8 +289,8 @@ function AppShellContent({
// that cannot move under it. See NEW_TASK_PENDING_KEY.
const attachmentDraftKey = activeId ?? NEW_TASK_PENDING_KEY;
const directoryHostId = activeId
? (activeCatalogSession?.profileKind === 'local'
? activeCatalogSession.runtimeHostId
? (activeSession?.profileKind === 'local'
? activeSession.runtimeHostId
: undefined)
: (taskEntry.selectors.selectedHost?.kind === 'local'
? taskEntry.selectors.target?.hostId
Expand Down Expand Up @@ -355,9 +356,7 @@ function AppShellContent({
const activeConnectionSnapshot = workHubActive || activeId
? sessionHostConnections.snapshot
: newTaskConnectionSnapshot;
const connections = activeConnectionSnapshot.connections;
const defaultConnection = activeConnectionSnapshot.defaultConnection;
const connectionModelChoices = activeConnectionSnapshot.chatModelChoices;
const { connections, defaultConnection, chatModelChoices: connectionModelChoices } = activeConnectionSnapshot;
const refreshConnections = activeId
? sessionHostConnections.refreshConnections
: newTaskConnections.refreshConnections;
Expand Down Expand Up @@ -423,7 +422,6 @@ function AppShellContent({
// Named Composer edits; the editor handle stays with Conversation.
const composerEditing = queueSurface.editing;
const rendererMountedRef = useRef(true);
const activeSession = activeCatalogSession;
const { setPermissionMode, setSessionModel, setSessionThinkingLevel, setSessionExecutor } = sessionSettingIntent.commands;
const modelConfigurationOverlay = sessionSettingIntent.overlay.modelConfiguration;
const activeSessionForModelControls = activeSession
Expand Down Expand Up @@ -810,20 +808,17 @@ function AppShellContent({
const {
revisionNavigation,
activeParentSession,
layout: railLayout,
layout: { collapsed: sessionListCollapsed, width: sessionListWidth },
} = useSessionNavigationReads({
catalog: sessionCatalogController,
activeSessionId: activeId,
});
const sessionListCollapsed = railLayout.collapsed;
const sessionListWidth = railLayout.width;
const sessionSideNavHandleRef = sessionRailLayoutStore.collapseHandleRef;
const titlebarParentSession = useMemo(() => {
if (!activeParentSession) return undefined;
const parentId = activeParentSession.id;
return {
name: activeParentSession.name,
onOpen: () => openSessionInChatRef.current(parentId),
onOpen: () => openSessionInChatRef.current(activeParentSession.id),
};
}, [activeParentSession]);
const archivedTasksBridge = useMemo<ArchivedTasksBridge>(
Expand Down Expand Up @@ -1213,6 +1208,7 @@ function AppShellContent({
sessionListWidth,
})}
>
<ArchiveRetentionNotices navigation={overlays.commands} />
<Diagnostics.PreviousMainProcessInterruptionNotice ready={appearanceHydrated} />
<ShellLifecycleSubscriptions {...shellLifecycle} />
<OnboardingConnectionSeed seed={defaultHostConnections.seedSnapshot} refresh={() => void defaultHostConnections.refreshConnections()} />
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/features/storage-usage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
// from this barrel and render these surfaces; they hold no storage state.

export { ArchiveRetentionSection } from './ui/archive-retention-section.js';
export { ArchiveRetentionNotices } from './ui/archive-retention-notices.js';
export { decodeRetentionNoticeState } from './model/retention-notices.js';
export { StorageUsageSection } from './ui/storage-usage-section.js';
export { TaskStorageSize } from './ui/task-storage-size.js';
export { StorageUsageServicesProvider } from './services-context.js';
export type { StorageUsageHostTarget, StorageUsageServices } from './ports.js';
export type { RetentionNoticeHost, RetentionNoticeState, StorageUsageHostTarget, StorageUsageServices } from './ports.js';
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import type { UiCatalog, UiLocale } from '@maka/core/ui-locale';

export interface ArchiveRetentionCopy {
noticeTitle(hostName: string): string;
readonly viewArchivedTasks: string;
readonly title: string;
/** What the setting covers, when its clock starts, what it keeps, and that it is final. */
readonly help: string;
Expand Down Expand Up @@ -55,6 +57,8 @@ export interface ArchiveRetentionCopy {

const COPY_BY_LOCALE = {
'zh-CN': {
noticeTitle: (hostName: string) => `「${hostName}」的自动清理`,
viewArchivedTasks: '查看已归档任务',
title: '自动清理',
help: '开启后,此运行时主机会删除归档超过所选天数的任务。适用于所有已归档任务,无论是手动还是自动归档的。计时从开启或修改天数时开始,因此不会立即删除积压的任务。已置顶的任务会保留。删除后无法恢复。',
enable: '自动删除已归档任务',
Expand Down Expand Up @@ -91,6 +95,8 @@ const COPY_BY_LOCALE = {
cancel: '取消',
},
'zh-TW': {
noticeTitle: (hostName: string) => `「${hostName}」的自動清理`,
viewArchivedTasks: '檢視已歸檔任務',
title: '自動清理',
help: '開啟後,此執行階段主機會刪除歸檔超過所選天數的任務。適用於所有已歸檔任務,無論是手動或自動歸檔的。計時從開啟或修改天數時開始,因此不會立即刪除積壓的任務。已置頂的任務會保留。刪除後無法復原。',
enable: '自動刪除已歸檔任務',
Expand Down Expand Up @@ -127,6 +133,8 @@ const COPY_BY_LOCALE = {
cancel: '取消',
},
en: {
noticeTitle: (hostName: string) => `Automatic cleanup on “${hostName}”`,
viewArchivedTasks: 'View archived tasks',
title: 'Automatic cleanup',
help: 'When on, this Runtime Host deletes tasks that have been archived for longer than the period you choose. It applies to every archived task, whether you archived it or it was archived automatically. The clock starts when you turn it on or change the period, so no backlog is deleted at once. Pinned tasks are kept. Deletion is permanent.',
enable: 'Delete archived tasks automatically',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/*
* 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 type { StorageRetentionQueryResult } from '@maka/runtime-host/protocol';
import type { RetentionNoticeHost, RetentionNoticeState, StorageUsageHostTarget, StorageUsageServices } from '../ports.js';

export const RETENTION_NOTICE_POLL_MS = 60_000;
export const RETENTION_NOTICE_COOLDOWN_MS = 15 * 60_000;
const DISABLED_POLL_MS = 15 * 60_000;

export type RetentionNotice =
| { readonly kind: 'deletion'; readonly deletion: NonNullable<StorageRetentionQueryResult['lastDeletion']> }
| { readonly kind: 'hold'; readonly hold: NonNullable<StorageRetentionQueryResult['hold']> }
| { readonly kind: 'paused' };

export function decodeRetentionNoticeState(value: unknown): RetentionNoticeState {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const record = value as Record<string, unknown>;
const time = (v: unknown): v is number => Number.isSafeInteger(v) && (v as number) >= 0;
return {
...(time(record.deletionAt) ? { deletionAt: record.deletionAt } : {}),
...(time(record.notifiedAt) ? { notifiedAt: record.notifiedAt } : {}),
...(typeof record.warning === 'string' ? { warning: record.warning } : {}),
...(typeof record.acknowledgedWarning === 'string'
? { acknowledgedWarning: record.acknowledgedWarning }
: {}),
};
}

function warningKey(retention: StorageRetentionQueryResult): string | undefined {
if (!retention.enabled) return undefined;
if (retention.hold) return `hold:${retention.revision}:${retention.hold.detectedAt}:${retention.hold.until}`;
return retention.lastSweep?.paused ? `paused:${retention.revision}:${retention.lastSweep.at}` : undefined;
}

/** A result actually presented in Settings need not be announced again. */
export function acknowledgeRetentionResults(
services: StorageUsageServices,
host: StorageUsageHostTarget,
retention: StorageRetentionQueryResult,
): void {
const notices = services.notices;
if (!notices) return;
const previous = decodeRetentionNoticeState(notices.readSeen(host.hostId));
const warning = warningKey(retention);
const deletionAt = Math.max(previous.deletionAt ?? -1, retention.lastDeletion?.at ?? -1);
const next = {
...previous,
...(deletionAt >= 0 ? { deletionAt } : {}),
...(warning ? { warning, acknowledgedWarning: warning } : {}),
};
if (next.deletionAt !== previous.deletionAt || next.warning !== previous.warning ||
next.acknowledgedWarning !== previous.acknowledgedWarning) notices.writeSeen(host.hostId, next);
}

/**
* Poll only connected Hosts while this window can present a notice. Host time
* identifies results; Client time only throttles successive deletion batches.
* Warnings are never throttled behind a deletion reminder.
*/
export function observeRetentionNotices(input: {
readonly services: Pick<StorageUsageServices, 'loadRetention'> & {
readonly notices: NonNullable<StorageUsageServices['notices']>;
};
readonly notify: (host: RetentionNoticeHost, notice: RetentionNotice) => (() => void) | void;
readonly now?: () => number;
readonly schedule?: (callback: () => void, delay: number) => () => void;
}): () => void {
const { services } = input;
const now = input.now ?? Date.now;
const schedule = input.schedule ?? ((callback, delay) => {
const timer = setTimeout(callback, delay);
return () => clearTimeout(timer);
});
const seen = new Map<string, RetentionNoticeState>();
const disabledReadAt = new Map<string, number>();
const warnings = new Map<string, { readonly key: string; readonly dismiss: () => void }>();
let closed = false;
let version = 0;
let running = false;
let requested = false;
let cancelTimer: (() => void) | undefined;

async function refresh(): Promise<void> {
if (closed) return;
cancelTimer?.();
cancelTimer = undefined;
if (running) {
requested = true;
return;
}
running = true;
const started = version;
try {
if (!services.notices.isVisible()) return;
const hosts = await services.notices.loadHosts();
if (closed || started !== version) return;
const present = new Set(hosts.map((host) => host.hostId));
for (const hostId of disabledReadAt.keys()) {
if (!present.has(hostId)) disabledReadAt.delete(hostId);
}
for (const [hostId, active] of warnings) {
if (!present.has(hostId)) {
active.dismiss();
warnings.delete(hostId);
}
}
const visited = new Set<string>();
for (const host of hosts) {
if (closed || started !== version || !services.notices.isVisible()) return;
if (visited.has(host.hostId)) continue;
visited.add(host.hostId);
const acknowledged = decodeRetentionNoticeState(services.notices.readSeen(host.hostId));
const previousWarning = warnings.get(host.hostId);
if (previousWarning && previousWarning.key === acknowledged.acknowledgedWarning) {
previousWarning.dismiss();
warnings.delete(host.hostId);
}
const disabledAt = disabledReadAt.get(host.hostId);
const elapsed = disabledAt === undefined ? undefined : now() - disabledAt;
// Focus changes do not turn the default-disabled policy into a full
// candidate scan every minute. A Client clock rollback forces a read.
if (elapsed !== undefined && elapsed >= 0 && elapsed < DISABLED_POLL_MS) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 (low): the disabled backoff for a Host is cleared only when a later read shows it enabled (L148). Enabling retention in Settings (archive-retention-section.tsx:123) only reloads the section, and acknowledgeRetentionResults notifies listeners only for a new acknowledgedWarning. So for up to 15 minutes after enabling, the observer won't read that Host. The impact is small: the Host deletes nothing before enabledAt + days, so only a hold or pause toast could be late, and Settings shows that banner in the meantime. The same delay applies when another client enables retention. If you want to close the gap, clear disabledReadAt for the Host after a successful setRetention (for example, by notifying the observer's listeners).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Only disabled Hosts back off. Every enabled, ready Host is re-queried on each focus/visibilitychange and every 60s while focused, and storage.retention.query runs countArchiveRetentionCandidates (a SQLite GROUP BY aggregate; a network round-trip for remote Hosts). That is the same scan the comment above calls too costly per minute. Consider a minimum interval since the last read for focus-triggered refreshes. Related: this backoff is not cleared when the user enables retention in Settings (setRetention fires no change listener), so a just-enabled Host stays unread for up to 15 minutes.

let retention: StorageRetentionQueryResult;
try {
retention = await services.loadRetention(host);
Comment thread
liugddx marked this conversation as resolved.
} catch {
// A disconnected Host must not prevent notices from other Hosts.
continue;
}
if (closed || started !== version || !services.notices.isVisible()) return;
if (retention.enabled) disabledReadAt.delete(host.hostId);
else disabledReadAt.set(host.hostId, now());
const persisted = decodeRetentionNoticeState(services.notices.readSeen(host.hostId));
const cached = seen.get(host.hostId);
const deletionAt = Math.max(cached?.deletionAt ?? -1, persisted.deletionAt ?? -1);
let state: RetentionNoticeState = {
...persisted,
...cached,
...(deletionAt >= 0 ? { deletionAt } : {}),
...(persisted.acknowledgedWarning ? { acknowledgedWarning: persisted.acknowledgedWarning } : {}),
};
const publish = (notice: RetentionNotice, next: RetentionNoticeState) => {
const dismiss = input.notify(host, notice);
if (notice.kind !== 'deletion' && next.warning && dismiss) warnings.set(host.hostId, { key: next.warning, dismiss });
state = next;
seen.set(host.hostId, state);
services.notices.writeSeen(host.hostId, state);
};
const warning = warningKey(retention);
const active = warnings.get(host.hostId);
if (active && (active.key !== warning || active.key === persisted.acknowledgedWarning)) {
active.dismiss();
warnings.delete(host.hostId);
}
if (warning && warning === persisted.warning) state = { ...state, warning };
if (warning && warning !== state.warning) {
publish(retention.hold ? { kind: 'hold', hold: retention.hold } : { kind: 'paused' }, { ...state, warning });
}
const deletion = retention.lastDeletion;
const time = now();
const cooled = state.notifiedAt === undefined || time < state.notifiedAt || time - state.notifiedAt >= RETENTION_NOTICE_COOLDOWN_MS;
if (deletion && deletion.count > 0 && deletion.at > (state.deletionAt ?? -1) && cooled) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: lastDeletion is per sweep pass, so passes that finish while the app is closed or unfocused, or that are superseded during the cooldown, are never announced, and the toast shows only the latest pass's count. The "Last automatic cleanup" wording is literally accurate, but users may read N as the total. Consider accumulating unannounced counts, or wording that makes the scope explicit.

publish({ kind: 'deletion', deletion }, { ...state, deletionAt: deletion.at, notifiedAt: time });
}
}
} catch {
// Profile discovery can be temporarily unavailable during Host handoff.
} finally {
running = false;
if (!closed) {
if (requested) {
requested = false;
void refresh();
} else {
cancelTimer = schedule(() => { void refresh(); }, RETENTION_NOTICE_POLL_MS);
}
}
}
}

const unsubscribe = services.notices.subscribeChanges(() => {
version += 1;
void refresh();
});
void refresh();
return () => {
closed = true;
cancelTimer?.();
unsubscribe();
for (const active of warnings.values()) active.dismiss();
warnings.clear();
};
}
24 changes: 24 additions & 0 deletions apps/desktop/src/renderer/features/storage-usage/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export interface StorageUsageHostTarget {
* deletes data itself; a Host with retention enabled does that on its own.
*/
export interface StorageUsageServices {
/** Available in the application shell; isolated Settings fixtures need no observer. */
readonly notices?: RetentionNoticeServices;
/** One Runtime Host's State Root footprint. */
loadUsage(host: StorageUsageHostTarget): Promise<StorageUsageQueryResult>;
/**
Expand All @@ -54,3 +56,25 @@ export interface StorageUsageServices {
input: StorageRetentionSetInput,
): Promise<StorageRetentionSetResult>;
}

export interface RetentionNoticeHost extends StorageUsageHostTarget {
readonly name: string;
}

export interface RetentionNoticeState {
readonly deletionAt?: number;
readonly warning?: string;
/** Unlike announcement, viewing the warning in Settings retires its toast. */
readonly acknowledgedWarning?: string;
/** Client time, used only to limit reminders during a long cleanup backlog. */
readonly notifiedAt?: number;
}

export interface RetentionNoticeServices {
/** Already-connected Hosts only; observing never starts or enables a Host. */
loadHosts(): Promise<readonly RetentionNoticeHost[]>;
subscribeChanges(handler: () => void): () => void;
isVisible(): boolean;
readSeen(hostId: string): unknown;
writeSeen(hostId: string, state: RetentionNoticeState): void;
}
Loading