Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/windows-recovery.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ on:
- 'packages/storage/src/native-file-lock.ts'
- 'packages/storage/src/root-authority.ts'
- 'packages/storage/src/runtime-policy/document-io.ts'
- 'packages/storage/src/session-bundle-file-service.ts'
- 'packages/storage/src/sqlite-long-term-memory-store.ts'
- 'packages/storage/src/stable-storage.ts'
- '.github/workflows/windows-recovery.yml'
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -2725,6 +2725,7 @@
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
"../features/session-bundle/index.js": 1,
"../locales/external-session-import-copy.js": 1,
"../locales/shell-copy.js": 1,
"./runtime-host-settings-target.js": 1,
Expand Down Expand Up @@ -3668,6 +3669,7 @@
"../../shared/settings-ownership.js": 1,
"../browser-storage": 1,
"../features/connection-settings": 1,
"../features/session-bundle": 1,
"../locales/settings-navigation-copy.js": 1,
"../locales/settings-shared-copy.js": 1,
"./about-settings-page": 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { afterEach, describe, it } from 'node:test';
import { parseHTML } from 'linkedom';
import { act, createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { AstryxLocaleProvider, LocaleProvider } from '@maka/ui';
import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui';
import type { DesktopRuntimeHostRef } from '../../preload/bridge-contract.js';
import type { DesktopExternalSessionCatalogItem } from '../../preload/external-session-catalog.js';
import type { ExternalSessionImportFailureReason } from '../../preload/external-session-import-result.js';
Expand Down Expand Up @@ -1359,7 +1359,11 @@ async function renderPage(options: {
host: TEST_RUNTIME_HOST,
children: page,
});
const localized = createElement(AstryxLocaleProvider, { children: targeted });
// The page asks for a confirmation before exporting a subtree, and a
// confirmation is a toast. The app has always provided one; the harness did
// not, which made every case fail on the provider rather than the case.
const withToasts = createElement(ToastProvider, { children: targeted });
const localized = createElement(AstryxLocaleProvider, { children: withToasts });
root.render(
createElement(LocaleProvider, { locale: options.locale ?? 'en', children: localized }),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,10 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn
candidateEntrypoint: new URL('file:///unused-runtime-host-candidate.js'),
ipcMain: ipc,
workspaceRoot: base,
mainWindowController: {
showSaveDialog: async () => ({ canceled: true }),
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
},
attachmentApprovals: createAttachmentApprovalRegistry(),
stat: async () => ({ size: 0 }),
resizeImage: async (bytes) => bytes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,10 @@ function deps(
},
): DesktopRuntimeHostCandidateDeps {
return {
mainWindowController: {
showSaveDialog: async () => ({ canceled: true }),
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
},
ipcMain,
workspaceRoot: '/workspace',
attachmentApprovals: createAttachmentApprovalRegistry(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/*
* 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 assert from 'node:assert/strict';
import test from 'node:test';
import type { IpcMain } from 'electron';
import { RuntimeHostOperationError } from '@maka/runtime-host/client';
import {
__bundleFileNameForTests as bundleFileName,
registerRuntimeHostSessionBundleIpc,
type RuntimeHostSessionBundleIpcDeps,
} from '../runtime-host-session-bundle-ipc-main.js';

type IpcHandler = Parameters<IpcMain['handle']>[1];

test('writes to the picked destination and reports what travelled', async () => {
const asked: unknown[] = [];
const ipc = ipcHarness();
registerRuntimeHostSessionBundleIpc(
deps({
client: {
exportSessionBundle: async (input) => {
asked.push(input);
return { sessionIds: ['root', 'child'], compressedBytes: 2048 };
},
},
dialog: { save: { canceled: false, filePath: '/picked/Hello.maka-session' } },
}),
ipc,
);

const result = await ipc.invoke('session-bundle:export', 'session-1', 'Hello');

assert.deepEqual(asked, [{ sessionId: 'session-1', destination: '/picked/Hello.maka-session' }]);
// The count is the subtree, not one: a bundle rooted at a Session carries the
// subagent conversations under it, and the page says so.
assert.deepEqual(result, {
ok: true,
sessionCount: 2,
path: '/picked/Hello.maka-session',
});
});

test('closing the save dialog asks the Host for nothing', async () => {
let called = false;
const ipc = ipcHarness();
registerRuntimeHostSessionBundleIpc(
deps({
client: {
exportSessionBundle: async () => {
called = true;
return { sessionIds: [], compressedBytes: 0 };
},
},
dialog: { save: { canceled: true } },
}),
ipc,
);

const result = await ipc.invoke('session-bundle:export', 'session-1', 'Hello');

assert.deepEqual(result, { ok: false, reason: 'canceled' });
assert.equal(called, false, 'a cancelled dialog is a decision, not a request');
});

test('publishes each imported Session so the shell reads the catalog again', async () => {
const events: Array<{ reason: string; sessionId?: string }> = [];
const ipc = ipcHarness();
registerRuntimeHostSessionBundleIpc(
deps({
client: {
importSessionBundle: async () => ({
sessionIds: ['root', 'child'],
artifactFiles: 3,
}),
},
dialog: { open: { canceled: false, filePaths: ['/picked/bundle.maka-session'] } },
onSessionsChanged: (reason, sessionId) => events.push({ reason, ...(sessionId ? { sessionId } : {}) }),
}),
ipc,
);

const result = await ipc.invoke('session-bundle:import');

assert.deepEqual(result, { ok: true, sessionIds: ['root', 'child'] });
// The Host republishes its own catalog; the desktop keeps a separate list and
// only re-reads when told, so an imported task is invisible without this.
assert.deepEqual(events, [
{ reason: 'created', sessionId: 'root' },
{ reason: 'created', sessionId: 'child' },
]);
});

test('keeps the reason a reader can act on', async () => {
const ipc = ipcHarness();
registerRuntimeHostSessionBundleIpc(
deps({
client: {
exportSessionBundle: async () => {
throw new RuntimeHostOperationError(
'session-bundle.export',
'session_busy',
'Session is running',
);
},
},
dialog: { save: { canceled: false, filePath: '/picked/Hello.maka-session' } },
}),
ipc,
);

assert.deepEqual(await ipc.invoke('session-bundle:export', 'session-1', 'Hello'), {
ok: false,
reason: 'session_busy',
});
});

test('carries the message of a failure no reason code describes', async () => {
const ipc = ipcHarness();
registerRuntimeHostSessionBundleIpc(
deps({
client: {
importSessionBundle: async () => {
throw new Error('Invalid Session id');
},
},
dialog: { open: { canceled: false, filePaths: ['/picked/bundle.maka-session'] } },
}),
ipc,
);

// `failed` is the code nothing downstream can act on, so the message is all
// the user has -- and losing it is how a wrong id reads as "that did not
// work" with no way to tell what was wrong.
assert.deepEqual(await ipc.invoke('session-bundle:import'), {
ok: false,
reason: 'failed',
detail: 'Invalid Session id',
});
});

function deps(input: {
client?: Partial<RuntimeHostSessionBundleIpcDeps['client']>;
dialog?: {
save?: { canceled: boolean; filePath?: string };
open?: { canceled: boolean; filePaths: string[] };
};
onSessionsChanged?: (reason: string, sessionId?: string) => void;
}): RuntimeHostSessionBundleIpcDeps {
return {
client: {
exportSessionBundle: async () => ({ sessionIds: [], compressedBytes: 0 }),
importSessionBundle: async () => ({ sessionIds: [], artifactFiles: 0 }),
...input.client,
},
mainWindowController: {
showSaveDialog: async () => input.dialog?.save ?? { canceled: true },
showOpenDialog: async () => input.dialog?.open ?? { canceled: true, filePaths: [] },
},
emitSessionsChanged: (reason, sessionId) => input.onSessionsChanged?.(reason, sessionId),
};
}

function ipcHarness() {
const handlers = new Map<string, IpcHandler>();
return {
handle(channel: string, handler: IpcHandler): void {
handlers.set(channel, handler);
},
async invoke(channel: string, ...args: unknown[]): Promise<unknown> {
const handler = handlers.get(channel);
assert.ok(handler, `missing handler: ${channel}`);
return handler({ sender: { id: 1 } } as never, ...args);
},
};
}

test('proposes a filename a task name cannot steer', () => {
// The dialog still decides the path. This decides what it opens holding, and
// a task name is written by a person.
assert.equal(bundleFileName('Refactor compaction'), 'Refactor compaction');
assert.equal(bundleFileName('../../etc/passwd'), 'etc passwd', 'no separators survive');
assert.equal(bundleFileName('.hidden'), 'hidden', 'not a dotfile');
assert.equal(bundleFileName(' '), 'maka-session', 'a blank name still names something');
assert.equal(bundleFileName(undefined), 'maka-session');
assert.ok(bundleFileName('x'.repeat(500)).length <= 80, 'bounded for the filesystem');
});
Loading