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
13 changes: 13 additions & 0 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"src/renderer/locales/settings-web-search-copy.ts",
"src/renderer/locales/shell-copy.ts",
"src/renderer/locales/shell-remaining-copy.ts",
"src/renderer/locales/storage-usage-copy.ts",
"src/renderer/locales/task-readiness-copy.ts",
"src/renderer/locales/work-board-error-copy.ts",
"src/renderer/main.tsx",
Expand Down Expand Up @@ -1505,6 +1506,15 @@
"actionFactories": [],
"dependencyPaths": {}
},
"src/renderer/locales/storage-usage-copy.ts": {
"bridgePaths": {},
"environmentCapabilities": {},
"hookCalls": {},
"lifecycleMethods": {},
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {}
},
"src/renderer/locales/task-readiness-copy.ts": {
"bridgePaths": {},
"environmentCapabilities": {},
Expand Down Expand Up @@ -2107,6 +2117,7 @@
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
"../features/storage-usage/index.js": 1,
"../locales/settings-data-copy": 1,
"../locales/settings-shared-copy.js": 1,
"../open-path": 1,
Expand Down Expand Up @@ -3218,8 +3229,10 @@
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
"../features/storage-usage/index.js": 1,
"../locales/settings-shared-copy.js": 1,
"../locales/settings-tasks-copy.js": 1,
"../locales/storage-usage-copy.js": 1,
"./settings-error-copy": 1,
"./settings-section": 1,
"./task-catalog-rows": 1,
Expand Down
142 changes: 142 additions & 0 deletions apps/desktop/src/main/__tests__/session-storage-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* 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 { test } from 'node:test';
import {
STORAGE_USAGE_SESSION_MAX_ITEMS,
type SessionStorageUsage,
} from '@maka/runtime-host/protocol';
import {
createSessionStorageLoader,
SESSION_STORAGE_CACHE_MAX_ENTRIES,
SESSION_STORAGE_FAILURE_COOLDOWN_MS,
SESSION_STORAGE_RESULT_TTL_MS,
} from '../../renderer/features/storage-usage/testing.js';
import {
createSessionStorageUsageReader,
SESSION_STORAGE_HOST_FAILURE_COOLDOWN_MS,
} from '../../preload/session-storage-usage.js';

function usage(sessionId: string): SessionStorageUsage {
return { sessionId, bytes: { transcript: 1, runtime: 2, artifacts: 3 }, worktreeCount: 0 };
}

test('rows that mount together are measured in bounded requests, one at a time', async () => {
const queries: string[][] = [];
let inFlight = 0;
let maxInFlight = 0;
const loader = createSessionStorageLoader(async (sessionIds) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
queries.push([...sessionIds]);
await new Promise((resolve) => setImmediate(resolve));
inFlight -= 1;
return Object.fromEntries(sessionIds.map((id) => [id, usage(id)]));
});
const count = STORAGE_USAGE_SESSION_MAX_ITEMS * 2 + 1;
const ids = Array.from({ length: count }, (_, index) => `session-${index}`);
const measured = await Promise.all([...ids, 'session-0'].map((id) => loader.load(id)));
assert.deepEqual(
queries.map((query) => query.length),
[STORAGE_USAGE_SESSION_MAX_ITEMS, STORAGE_USAGE_SESSION_MAX_ITEMS, 1],
);
assert.equal(maxInFlight, 1);
assert.equal(measured[count], measured[0]);
await loader.load('session-5');
assert.equal(queries.length, 3, 'a fresh measurement is served from the cache');
});

test('failures and unknown tasks wait out a cooldown; measured sizes expire', async () => {
let now = 0;
let fail = true;
const queries: string[][] = [];
const loader = createSessionStorageLoader(
async (sessionIds) => {
queries.push([...sessionIds]);
if (fail) throw new Error('Runtime Host unavailable');
return { 'session-a': usage('session-a') };
},
{ now: () => now },
);
assert.equal(await loader.load('session-a'), undefined);
fail = false;
assert.equal(await loader.load('session-a'), undefined, 'a remount inside the cooldown waits');
now += SESSION_STORAGE_FAILURE_COOLDOWN_MS;
assert.equal((await loader.load('session-a'))?.sessionId, 'session-a');
assert.equal(await loader.load('session-gone'), undefined);
assert.equal(await loader.load('session-gone'), undefined);
now += SESSION_STORAGE_RESULT_TTL_MS;
await loader.load('session-a');
assert.deepEqual(queries, [['session-a'], ['session-a'], ['session-gone'], ['session-a']]);
});

test('the size cache drops expired entries and stays within its cap', async () => {
let now = 0;
const loader = createSessionStorageLoader(
async (sessionIds) => Object.fromEntries(sessionIds.map((id) => [id, usage(id)])),
{ now: () => now },
);
await Promise.all(['session-a', 'session-b'].map((id) => loader.load(id)));
assert.equal(loader.size(), 2);
now += SESSION_STORAGE_RESULT_TTL_MS;
await loader.load('session-c');
assert.equal(loader.size(), 1, 'expired entries are evicted on the next flush');

const many = Array.from(
{ length: SESSION_STORAGE_CACHE_MAX_ENTRIES + 10 },
(_, index) => `bulk-${index}`,
);
await Promise.all(many.map((id) => loader.load(id)));
assert.ok(loader.size() <= SESSION_STORAGE_CACHE_MAX_ENTRIES);
});

test('preload keeps one Host’s task sizes when another Host fails', async () => {
const routes: Record<string, { scope: string; sessionId: string }> = {
'desktop-a1': { scope: 'host-a', sessionId: 'a1' },
'desktop-a2': { scope: 'host-a', sessionId: 'a2' },
'desktop-b1': { scope: 'host-b', sessionId: 'b1' },
};
let now = 0;
const queried: string[] = [];
const read = createSessionStorageUsageReader(
{
resolve: async (sessionId) => {
const route = routes[sessionId];
if (!route) throw new Error('The Runtime Host for this task is unavailable');
return { ...route, scopeKey: route.scope };
},
query: async (scope, sessionIds) => {
queried.push(scope);
// An older Host rejects the unknown operation by dropping the connection.
if (scope === 'host-b') throw new Error('Runtime Host disconnected');
// `a2` is not on its Host, so the Host omits it.
return sessionIds.filter((id) => id !== 'a2').map(usage);
},
},
{ now: () => now },
);
const ids = ['desktop-a1', 'desktop-b1', 'desktop-a2', 'desktop-missing'];
assert.deepEqual(await read(ids), { 'desktop-a1': usage('a1') });
// The failed Host is left alone for a cooldown instead of being dropped again.
assert.deepEqual(await read(ids), { 'desktop-a1': usage('a1') });
now += SESSION_STORAGE_HOST_FAILURE_COOLDOWN_MS;
await read(ids);
assert.deepEqual(queried, ['host-a', 'host-b', 'host-a', 'host-a', 'host-b']);
});
189 changes: 189 additions & 0 deletions apps/desktop/src/main/__tests__/storage-usage-section.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/*
* 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 { afterEach, test } from 'node:test';
import { parseHTML } from 'linkedom';
import { act, createElement } from 'react';
import { createRoot } from 'react-dom/client';
import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui';
import type { StorageUsageQueryResult } from '@maka/runtime-host/protocol';
import {
StorageUsageSection,
StorageUsageServicesProvider,
TaskStorageSize,
type StorageUsageHostTarget,
} from '../../renderer/features/storage-usage/index.js';
import { RuntimeHostSettingsTarget } from '../../renderer/settings/runtime-host-settings-target.js';

const originalGlobals = {
document: globalThis.document,
window: globalThis.window,
HTMLElement: globalThis.HTMLElement,
getComputedStyle: globalThis.getComputedStyle,
IntersectionObserver: globalThis.IntersectionObserver,
IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT,
};

afterEach(() => {
Object.assign(globalThis, originalGlobals);
});

test('the Storage section reads the selected Host once and states its caveats', async () => {
const { document, window } = parseHTML('<div id="root"></div>');
Object.assign(globalThis, {
document,
window,
HTMLElement: window.HTMLElement,
getComputedStyle: (element: Element) =>
({ color: (element as HTMLElement).style?.color || 'currentColor' }) as CSSStyleDeclaration,
IS_REACT_ACT_ENVIRONMENT: true,
});
const host = { profileId: 'profile-1', hostId: 'host-1' };
const requests: StorageUsageHostTarget[] = [];
const usage: StorageUsageQueryResult = {
measuredAt: 1,
totals: [
{ kind: 'memory', bytes: 2048, exact: true },
{ kind: 'database', bytes: 3 * 1024 * 1024, exact: true },
{ kind: 'artifacts', bytes: 1024 * 1024, exact: false },
],
reclaimableBytes: 512 * 1024,
worktreeCount: 2,
};
const container = document.getElementById('root') as unknown as HTMLElement;
const root = createRoot(container);
await act(async () => {
root.render(
createElement(LocaleProvider, {
locale: 'en',
children: createElement(AstryxLocaleProvider, {
children: createElement(ToastProvider, {
children: createElement(StorageUsageServicesProvider, {
services: {
loadUsage: async (target) => {
requests.push(target);
return usage;
},
loadSessionUsage: async () => ({}),
},
children: createElement(RuntimeHostSettingsTarget, {
host,
children: createElement(StorageUsageSection, { hostVerified: true }),
}),
}),
}),
}),
}),
);
});

const text = container.textContent ?? '';
assert.deepEqual(requests, [host]);
assert.match(text, /Total/);
assert.match(text, /≈ 4\.0 MB/);
// Rows follow the protocol's kind order, not the order the Host sent them in.
assert.ok(text.indexOf('Task database') < text.indexOf('Artifacts'));
assert.ok(text.indexOf('Artifacts') < text.indexOf('Long-term memory'));
assert.match(text, /3\.0 MB/);
assert.match(text, /≈ 1\.0 MB/);
assert.match(text, /Usage history is kept after a task is deleted/);
assert.match(text, /2 worktrees/);
assert.match(text, /512\.0 KB/);
// Only measured kinds are listed; nothing offers to delete or compact.
assert.doesNotMatch(text, /Offloaded context/);
assert.doesNotMatch(text, /Delete|Compact|Vacuum/);

await act(async () => root.unmount());
});

test('a task row is measured only after it scrolls into view', async () => {
const { document, window } = parseHTML('<div id="root"></div>');
const observers: Array<{
readonly callback: (entries: Array<{ isIntersecting: boolean }>) => void;
disconnected: boolean;
}> = [];
class FakeIntersectionObserver {
readonly #record: (typeof observers)[number];
constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) {
this.#record = { callback, disconnected: false };
observers.push(this.#record);
}
observe() {}
disconnect() {
this.#record.disconnected = true;
}
}
Object.assign(globalThis, {
document,
window,
HTMLElement: window.HTMLElement,
getComputedStyle: (element: Element) =>
({ color: (element as HTMLElement).style?.color || 'currentColor' }) as CSSStyleDeclaration,
IntersectionObserver: FakeIntersectionObserver,
IS_REACT_ACT_ENVIRONMENT: true,
});
const requested: string[][] = [];
const services = {
loadUsage: async (): Promise<StorageUsageQueryResult> => {
throw new Error('not used');
},
loadSessionUsage: async (sessionIds: readonly string[]) => {
requested.push([...sessionIds]);
return Object.fromEntries(
sessionIds.map((sessionId) => [
sessionId,
{
sessionId,
bytes: { transcript: 1024, runtime: 1024, artifacts: 0 },
worktreeCount: 0,
},
]),
);
},
};
const container = document.getElementById('root') as unknown as HTMLElement;
const root = createRoot(container);
await act(async () => {
root.render(
createElement(LocaleProvider, {
locale: 'en',
children: createElement(StorageUsageServicesProvider, {
services,
children: [
createElement(TaskStorageSize, { key: 'seen', sessionId: 'seen' }),
createElement(TaskStorageSize, { key: 'unseen', sessionId: 'unseen' }),
],
}),
}),
);
});
assert.equal(observers.length, 2);
assert.deepEqual(requested, [], 'nothing is measured before a row is visible');

await act(async () => {
observers[0]!.callback([{ isIntersecting: true }]);
});
assert.deepEqual(requested, [['seen']]);
assert.equal(observers[0]!.disconnected, true);
assert.match(container.textContent ?? '', /Uses 2\.0 KB/);

await act(async () => root.unmount());
});
4 changes: 4 additions & 0 deletions apps/desktop/src/main/runtime-host-renderer-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ function request(
return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value));
case 'scheduled-task.query':
return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value));
case 'storage.usage.query':
return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value));
case 'storage.usage.sessions.query':
return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value));
case 'web-search.execute':
return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value));
}
Expand Down
Loading