From 49936bbc53af756b7fb809f778f1320dd0c4dae5 Mon Sep 17 00:00:00 2001 From: Joob1n Date: Fri, 11 Sep 2026 21:15:14 +0800 Subject: [PATCH] feat(desktop): move a Session between installations from Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export a task, and everything the model saw while it ran, to a `.maka-session` file -- and import one -- without closing the app. The work happens in the Runtime Host because the authority it needs is already held there. The Storage Root owner lock is an election taken with `tryLock`, and it refuses a second exclusive hold even inside the process that has one, so the Host cannot reach the export by calling it. It lends the lease instead. Export is fenced with `runSessionSubtreeQuiescentMutation` so no Turn starts in the subtree while the bundle is prepared; import needs no Session fence, because the Sessions it carries do not exist here yet. Settings › Import/export tasks gains a switch between the two halves. Import keeps the external-agent catalog and adds the bundle file as a source of its own -- a source needs no agent installed, which is also why a machine with no agent no longer sees an empty page. Export is a tree: a bundle can be rooted at any node, so every row exports, the nesting says which subtree a row would carry, and a row with descendants asks before writing them. Refs #5182 Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J --- .github/workflows/windows-recovery.yml | 1 + apps/desktop/renderer-architecture.json | 2 + .../import-tasks-settings-page.test.ts | 65 +++- .../__tests__/runtime-host-client-uds.test.ts | 4 + .../runtime-host-desktop-candidate.test.ts | 4 + ...ntime-host-session-bundle-ipc-main.test.ts | 228 ++++++++++++++ .../session-bundle-export-tree.test.ts | 195 ++++++++++++ apps/desktop/src/main/runtime-host-boot.ts | 1 + apps/desktop/src/main/runtime-host-client.ts | 14 + .../main/runtime-host-desktop-candidate.ts | 22 ++ .../runtime-host-session-bundle-ipc-main.ts | 187 +++++++++++ apps/desktop/src/preload/bridge-contract.d.ts | 50 +++ apps/desktop/src/preload/preload.ts | 34 ++ .../composition/desktop-feature-services.tsx | 9 +- .../features/session-bundle/export-tree.tsx | 134 ++++++++ .../renderer/features/session-bundle/index.ts | 24 ++ .../renderer/features/session-bundle/ports.ts | 68 ++++ .../session-bundle/services-context.tsx | 40 +++ .../session-bundle/session-bundle-tasks.tsx | 292 ++++++++++++++++++ .../features/session-bundle/testing.ts | 27 ++ .../locales/external-session-import-copy.ts | 100 +++++- .../locales/settings-navigation-copy.ts | 6 +- .../src/renderer/locales/shell-copy.ts | 4 +- .../desktop/create-session-bundle-services.ts | 39 +++ apps/desktop/src/renderer/reference-shell.css | 38 +++ .../settings/import-tasks-settings-page.tsx | 61 +++- .../renderer/settings/settings-surface.tsx | 27 +- .../settings/settings-pages.stories.tsx | 74 +++++ docs/astryx-surface-file-inventory.md | 5 +- docs/astryx-surface-file-inventory.paths | 3 + .../runtime-host-operator-command.test.ts | 4 + .../session-bundle-coordinator.test.ts | 198 ++++++++++++ packages/runtime-host/src/protocol/index.ts | 4 +- .../runtime-host/src/protocol/operations.ts | 2 + .../src/protocol/session-bundle.ts | 180 +++++++++++ .../src/server/execution-composition.ts | 18 +- .../src/server/operation-dispatcher.ts | 3 + .../src/server/session-bundle-coordinator.ts | 243 +++++++++++++++ packages/runtime/src/session-manager.ts | 31 ++ 39 files changed, 2407 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/runtime-host-session-bundle-ipc-main.test.ts create mode 100644 apps/desktop/src/main/__tests__/session-bundle-export-tree.test.ts create mode 100644 apps/desktop/src/main/runtime-host-session-bundle-ipc-main.ts create mode 100644 apps/desktop/src/renderer/features/session-bundle/export-tree.tsx create mode 100644 apps/desktop/src/renderer/features/session-bundle/index.ts create mode 100644 apps/desktop/src/renderer/features/session-bundle/ports.ts create mode 100644 apps/desktop/src/renderer/features/session-bundle/services-context.tsx create mode 100644 apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx create mode 100644 apps/desktop/src/renderer/features/session-bundle/testing.ts create mode 100644 apps/desktop/src/renderer/platform/desktop/create-session-bundle-services.ts create mode 100644 packages/runtime-host/src/__tests__/session-bundle-coordinator.test.ts create mode 100644 packages/runtime-host/src/protocol/session-bundle.ts create mode 100644 packages/runtime-host/src/server/session-bundle-coordinator.ts diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 886404f416..297632fea0 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -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' diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 800dc4b634..0dad6f6fa4 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -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, @@ -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, diff --git a/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts b/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts index 65ec8f1c33..537895220b 100644 --- a/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts +++ b/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts @@ -20,12 +20,16 @@ import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; import { parseHTML } from 'linkedom'; -import { act, createElement } from 'react'; +import { act, createElement, type ReactNode } 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'; +import { + SessionBundleServicesProvider, + SessionBundleTasks, +} from '../../renderer/features/session-bundle/index.js'; import { ImportTasksSettingsPage } from '../../renderer/settings/import-tasks-settings-page.js'; import { RuntimeHostSettingsTarget } from '../../renderer/settings/runtime-host-settings-target.js'; @@ -1258,6 +1262,7 @@ async function renderPage(options: { */ importBySource?: Record; onOpenImported?: (sessionId: string) => void; + offersBundleSource?: boolean; locale?: 'en' | 'zh-CN'; }): Promise<{ container: HTMLElement; @@ -1353,13 +1358,37 @@ async function renderPage(options: { const pageProps = { onImported: () => undefined, onOpenImported: options.onOpenImported ?? (() => undefined), + ...(options.offersBundleSource === undefined + ? {} + : { offersBundleSource: options.offersBundleSource }), }; - const page = createElement(ImportTasksSettingsPage, pageProps); + const bare = createElement(ImportTasksSettingsPage, pageProps); + // Composed the way the settings surface composes it. The page's bundle + // source renders a panel the feature provides, so a page rendered on its + // own is a composition production never has. + const page = createElement(SessionBundleTasks, { + isLocalTarget: options.offersBundleSource === true, + sessions: [], + renderSection: ({ children }: { children: ReactNode }) => + createElement('div', null, children), + children: bare, + }); const targeted = createElement(RuntimeHostSettingsTarget, { 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 withServices = createElement(SessionBundleServicesProvider, { + services: { + exportBundle: async () => ({ ok: false, reason: 'canceled' }) as const, + importBundle: async () => ({ ok: false, reason: 'canceled' }) as const, + }, + children: targeted, + }); + const withToasts = createElement(ToastProvider, { children: withServices }); + const localized = createElement(AstryxLocaleProvider, { children: withToasts }); root.render( createElement(LocaleProvider, { locale: options.locale ?? 'en', children: localized }), ); @@ -1637,3 +1666,31 @@ describe('ImportTasksSettingsPage batch import', () => { assert.equal(buttonWithText(container, 'Import selected')?.disabled, false); }); }); + +describe('ImportTasksSettingsPage bundle source', () => { + it('does not offer the bundle source where the feature is not mounted', async () => { + // An adapter is present so the switch renders at all; the question is + // whether the bundle joins it. Beside a Remote target it must not: the + // panel needs services this page does not have, and picking the source + // there would name a Local action on a Remote-scoped page. + const harness = await renderPage({ adapterIds: ['codex'], offersBundleSource: false }); + assert.match(harness.container.textContent, /Codex/); + assert.doesNotMatch(harness.container.textContent, /Maka session file/); + await act(async () => harness.root.unmount()); + }); + + it('offers it where the feature is mounted', async () => { + const harness = await renderPage({ adapterIds: ['codex'], offersBundleSource: true }); + assert.match(harness.container.textContent, /Maka session file/); + await act(async () => harness.root.unmount()); + }); + + it('says so when neither an agent nor the bundle source is available', async () => { + // Beside a Remote target with no agent installed there is nothing to pick, + // nothing to filter and nothing to list. Empty controls would be worse than + // the sentence that says why. + const harness = await renderPage({ adapterIds: [], offersBundleSource: false }); + assert.match(harness.container.textContent, /No supported Agent detected/); + await act(async () => harness.root.unmount()); + }); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index f12f5cb93f..3856a9a566 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -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, diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index bccc5ace56..ee10340cfd 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -1259,6 +1259,10 @@ function deps( }, ): DesktopRuntimeHostCandidateDeps { return { + mainWindowController: { + showSaveDialog: async () => ({ canceled: true }), + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + }, ipcMain, workspaceRoot: '/workspace', attachmentApprovals: createAttachmentApprovalRegistry(), diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-bundle-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-bundle-ipc-main.test.ts new file mode 100644 index 0000000000..d8a5297187 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-session-bundle-ipc-main.test.ts @@ -0,0 +1,228 @@ +/* + * 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 { createHash } from 'node:crypto'; +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[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 { sessionCount: 2, 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 { sessionCount: 0, 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 () => ({ sessionCount: 2, 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, sessionCount: 2 }); + // 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. No + // id: a list that grows with the subtree can outgrow a frame after the + // Sessions are committed, so the result is a count and this says "re-read". + assert.deepEqual(events, [{ reason: 'created' }]); +}); + +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; + dialog?: { + save?: { canceled: boolean; filePath?: string }; + open?: { canceled: boolean; filePaths: string[] }; + }; + onSessionsChanged?: (reason: string, sessionId?: string) => void; +}): RuntimeHostSessionBundleIpcDeps { + return { + client: { + exportSessionBundle: async () => ({ sessionCount: 0, compressedBytes: 0 }), + importSessionBundle: async () => ({ sessionCount: 0, 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(); + return { + handle(channel: string, handler: IpcHandler): void { + handlers.set(channel, handler); + }, + async invoke(channel: string, ...args: unknown[]): Promise { + 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'); +}); + +test('the digest it sends is over the ids the Host knows', async () => { + let sent: unknown; + const ipc = ipcHarness(); + registerRuntimeHostSessionBundleIpc( + deps({ + client: { + exportSessionBundle: async (input) => { + sent = input.expectedSubtreeDigest; + return { sessionCount: 2, compressedBytes: 1 }; + }, + }, + dialog: { save: { canceled: false, filePath: '/picked/Hello.maka-session' } }, + }), + ipc, + ); + + await ipc.invoke('session-bundle:export', 'root', 'Hello', ['child', 'root']); + + // Sorted and newline-joined, so the order a walk happened to produce cannot + // change it, and over the Host's own ids -- a digest taken further upstream + // would be over host-scoped ids and could never match what the Host fenced. + assert.equal( + sent, + createHash('sha256').update('child\nroot').digest('hex'), + 'both sides must hash the same thing', + ); +}); diff --git a/apps/desktop/src/main/__tests__/session-bundle-export-tree.test.ts b/apps/desktop/src/main/__tests__/session-bundle-export-tree.test.ts new file mode 100644 index 0000000000..8bc08f61e2 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-bundle-export-tree.test.ts @@ -0,0 +1,195 @@ +/* + * 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, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AstryxLocaleProvider, LocaleProvider } from '@maka/ui'; +import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; +import { ExportTree } from '../../renderer/features/session-bundle/testing.js'; + +// Named, not spread: `globalThis` carries getter-only properties, and copying +// the whole object back throws on the first one. +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, +}; + +afterEach(() => { + Object.assign(globalThis, originalGlobals); +}); + +describe('session bundle export tree', () => { + it('nests a subagent subtree and counts the whole thing', async () => { + const rendered = await render([ + task('root', 'Refactor compaction'), + subagentTask('child-a', 'Find the call sites', 'root', 'Explore'), + subagentTask('grandchild', 'What the child spawned', 'child-a', 'Explore'), + subagentTask('child-b', 'Check the checkpoint path', 'root', 'general-purpose'), + task('unrelated', 'Say hello'), + ]); + + // Three descendants, not the two children: a subagent spawns its own, and a + // bundle rooted here carries all of them. + assert.match(rendered.container.textContent, /Carries 3 subagent conversations/); + // The child that has one of its own says so too. + assert.match(rendered.container.textContent, /Carries 1 subagent conversation/); + // Nesting is structural, not an indent class: a child lives inside the list + // that belongs to its parent, which is what draws one continuous rule. + assert.equal( + rendered.container.querySelectorAll('.maka-export-subtree').length, + 2, + 'one per parent that has children', + ); + assert.equal( + rendered.container.querySelectorAll('.maka-export-tree > .maka-export-node').length, + 2, + 'two roots: the parent task and the unrelated one', + ); + await rendered.dispose(); + }); + + it('reads the link the catalog actually publishes', async () => { + // The catalog projection calls it `subagent`; `subagentParent` is on the + // full header and absent from a list. Reading only the second one made + // every row a root -- a flat list claiming every task stands alone. + const rendered = await render([ + task('root', 'Refactor compaction'), + { ...task('child', 'A child'), subagent: { parentSessionId: 'root', agentName: 'Explore' } }, + ]); + assert.equal(rendered.container.querySelectorAll('.maka-export-subtree').length, 1); + assert.match(rendered.container.textContent, /Carries 1 subagent conversation/); + await rendered.dispose(); + }); + + it('shows a task whose parent is not in the list', async () => { + // Its parent was archived, or paging never reached it. It is not a root and + // nothing here can nest it, so without this it is simply gone. + const rendered = await render([ + subagentTask('orphan', 'Parent is elsewhere', 'absent-parent', 'Explore'), + ]); + assert.match(rendered.container.textContent, /Parent is elsewhere/); + await rendered.dispose(); + }); + + it('a Session that names itself as its parent still renders', async () => { + // `subagentParent` is an ordinary field with no schema guarantee behind it, + // and a cycle here is an infinite render rather than a wrong number. + const rendered = await render([subagentTask('loop', 'Points at itself', 'loop', 'Explore')]); + assert.match(rendered.container.textContent, /Points at itself/); + await rendered.dispose(); + }); + + it('leaves archived tasks out', async () => { + const rendered = await render([{ ...task('archived', 'Put away'), isArchived: true }]); + assert.doesNotMatch(rendered.container.textContent, /Put away/); + await rendered.dispose(); + }); + + it('offers every row, because a bundle can be rooted at any node', async () => { + const rendered = await render([ + task('root', 'Refactor compaction'), + subagentTask('child', 'Find the call sites', 'root', 'Explore'), + ]); + const exports = Array.from( + rendered.container.querySelectorAll('button'), + ).filter((button) => button.textContent === 'Export'); + assert.equal(exports.length, 2, 'the child exports on its own too'); + await rendered.dispose(); + }); + it('shows an active grandchild whose parent is archived', async () => { + // Lineage decided over the whole catalog and rows hidden afterwards loses + // this one: it nests under a parent that is never drawn, so nobody renders + // it. Filtering first is what makes it a root of its own. + const rendered = await render([ + task('root', 'Refactor compaction'), + { ...subagentTask('gone', 'Archived middle', 'root', 'Explore'), isArchived: true }, + subagentTask('grandchild', 'Still running', 'gone', 'Explore'), + ]); + assert.match(rendered.container.textContent, /Still running/); + assert.doesNotMatch(rendered.container.textContent, /Archived middle/); + await rendered.dispose(); + }); +}); + +async function render(sessions: readonly DesktopSessionSummary[]): Promise<{ + container: HTMLElement; + dispose(): Promise; +}> { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + }); + 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(ExportTree, { sessions, isBusy: false, onExport: () => {} }), + }), + }), + ); + }); + return { container, dispose: async () => void (await act(async () => root.unmount())) }; +} + +function task(id: string, name: string): DesktopSessionSummary { + return { + id, + name, + createdAt: 0, + updatedAt: 0, + cwd: '/workspace', + isArchived: false, + status: 'idle', + backend: 'ai-sdk', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + revision: 1, + } as unknown as DesktopSessionSummary; +} + +function subagentTask( + id: string, + name: string, + parentSessionId: string, + agentName: string, +): DesktopSessionSummary { + return { + ...task(id, name), + // The full shape the guard requires: a partial one is not a link, and a + // fixture that omits `spawnedBy` silently renders five unrelated roots. + subagentParent: { + kind: 'subagent', + parentSessionId, + lifecycle: 'foreground', + spawnedBy: { parentRunId: 'run-1', parentTurnId: 'turn-1', toolCallId: 'call-1' }, + }, + subagentRuntime: { agentName }, + } as unknown as DesktopSessionSummary; +} diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 2242f354c4..88bcf9375b 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1128,6 +1128,7 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( attachmentApprovals, stat: (path) => import("node:fs/promises").then(({ stat }) => stat(path)), resizeImage: resizeImageForAttachment, + mainWindowController, nativeCapabilities: { browserTools: native.browserTools, resolveBrowserUrl: ({ sessionId, toolName, arguments: args }) => { diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 7fcf4a57e8..0b877254c3 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1013,6 +1013,20 @@ export class DesktopRuntimeHostClient { return requireSessionProjection(result.session); } + exportSessionBundle(input: { + readonly sessionId: string; + readonly destination: string; + readonly expectedSubtreeDigest?: string; + }): Promise<{ readonly sessionCount: number; readonly compressedBytes: number }> { + return this.request("session-bundle.export", input); + } + + importSessionBundle(input: { + readonly source: string; + }): Promise<{ readonly sessionCount: number; readonly artifactFiles: number }> { + return this.request("session-bundle.import", input); + } + updateSessionMetadata( sessionId: string, patch: SessionMetadataPatch, diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 081b592e1b..b54dfe76a2 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -81,6 +81,7 @@ import { } from "./runtime-host-session-catalog-ipc-main.js"; import { registerRuntimeHostWorkHubIpc } from "./runtime-host-workhub-ipc-main.js"; import { registerRuntimeHostExternalSessionsIpc } from "./runtime-host-external-sessions-ipc-main.js"; +import { registerRuntimeHostSessionBundleIpc } from "./runtime-host-session-bundle-ipc-main.js"; import { registerRuntimeHostCollaborationIpc } from './runtime-host-collaboration-ipc-main.js'; import type { DesktopCollaborationConnectionTarget } from './runtime-host-collaboration-invitation.js'; import { registerRuntimeHostAttachmentPreviewIpc } from './runtime-host-artifacts-ipc-main.js'; @@ -124,6 +125,19 @@ export interface DesktopRuntimeHostCandidateDeps { readonly attachmentApprovals: AttachmentApprovalRegistry; readonly stat: (path: string) => Promise<{ size: number }>; readonly resizeImage: (bytes: Uint8Array) => Promise; + /** Native file dialogs for moving a Session in or out as a bundle. */ + readonly mainWindowController: { + showSaveDialog(options: { + title?: string; + defaultPath?: string; + filters?: Array<{ name: string; extensions: string[] }>; + }): Promise<{ canceled: boolean; filePath?: string }>; + showOpenDialog(options: { + title?: string; + properties?: string[]; + filters?: Array<{ name: string; extensions: string[] }>; + }): Promise<{ canceled: boolean; filePaths: string[] }>; + }; readonly nativeCapabilities: DesktopNativeCapabilityProviderInput; readonly botRegistry: BotRegistry; readonly resolveBotCreateTarget: ( @@ -878,6 +892,14 @@ export async function createDesktopRuntimeHostCandidate( }, ipc, ); + registerRuntimeHostSessionBundleIpc( + { + client, + mainWindowController: deps.mainWindowController, + emitSessionsChanged, + }, + ipc, + ); } const stopSession = sessionCopyCleanup ? registerRuntimeHostSessionExecutionIpc( diff --git a/apps/desktop/src/main/runtime-host-session-bundle-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-bundle-ipc-main.ts new file mode 100644 index 0000000000..0dd32d8922 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-session-bundle-ipc-main.ts @@ -0,0 +1,187 @@ +/* + * 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 { createHash } from 'node:crypto'; +import type { IpcMain } from 'electron'; +import type { SessionChangedReason } from '@maka/core/session'; +import { RuntimeHostOperationError } from '@maka/runtime-host/client'; +import type { + SessionBundleExportIpcResult, + SessionBundleFailure, + SessionBundleFailureReason, + SessionBundleImportIpcResult, +} from '../preload/bridge-contract.js'; + +/** + * Settings › Import/export tasks — moving a Session between installations. + * + * The file is picked here and the work happens in the Runtime Host, because the + * Host holds the Storage Root authority for its whole lifetime and the lock + * that grants it is an election: it refuses a second exclusive hold even from + * the process already holding one. Doing this in the desktop process would + * require stopping the Host the user is looking at. + */ +type SessionBundleClient = { + exportSessionBundle(input: { + readonly sessionId: string; + readonly destination: string; + readonly expectedSubtreeDigest?: string; + }): Promise<{ readonly sessionCount: number; readonly compressedBytes: number }>; + importSessionBundle(input: { + readonly source: string; + }): Promise<{ readonly sessionCount: number; readonly artifactFiles: number }>; +}; + +interface DialogController { + showSaveDialog(options: { + title?: string; + defaultPath?: string; + filters?: Array<{ name: string; extensions: string[] }>; + }): Promise<{ canceled: boolean; filePath?: string }>; + showOpenDialog(options: { + title?: string; + properties?: string[]; + filters?: Array<{ name: string; extensions: string[] }>; + }): Promise<{ canceled: boolean; filePaths: string[] }>; +} + +export interface RuntimeHostSessionBundleIpcDeps { + readonly client: SessionBundleClient; + readonly mainWindowController: DialogController; + readonly emitSessionsChanged: (reason: SessionChangedReason, sessionId?: string) => void; +} + +const BUNDLE_EXTENSION = 'maka-session'; + +export { bundleFileName as __bundleFileNameForTests }; + +export function registerRuntimeHostSessionBundleIpc( + deps: RuntimeHostSessionBundleIpcDeps, + ipcMain: { handle(channel: string, listener: Parameters[1]): void }, +): void { + ipcMain.handle('session-bundle:export', async (_event, ...args: unknown[]) => { + const [sessionId, suggestedName, confirmedSubtree] = args as [ + string, + string, + readonly string[] | undefined, + ]; + const picked = await deps.mainWindowController.showSaveDialog({ + defaultPath: `${bundleFileName(suggestedName)}.${BUNDLE_EXTENSION}`, + filters: [{ name: 'Maka session', extensions: [BUNDLE_EXTENSION] }], + }); + if (picked.canceled || !picked.filePath) { + return { ok: false, reason: 'canceled' } satisfies SessionBundleExportIpcResult; + } + try { + const result = await deps.client.exportSessionBundle({ + sessionId, + destination: picked.filePath, + // The digest is computed here rather than sent from the renderer: this + // is the first place the ids are the Host's own, and it is the last + // place before the protocol frame, which only carries the 64 characters. + ...(confirmedSubtree + ? { expectedSubtreeDigest: subtreeDigest(confirmedSubtree) } + : {}), + }); + return { + ok: true, + sessionCount: result.sessionCount, + path: picked.filePath, + } satisfies SessionBundleExportIpcResult; + } catch (error) { + return failure(error) satisfies SessionBundleExportIpcResult; + } + }); + + ipcMain.handle('session-bundle:import', async () => { + const picked = await deps.mainWindowController.showOpenDialog({ + properties: ['openFile'], + filters: [{ name: 'Maka session', extensions: [BUNDLE_EXTENSION] }], + }); + const source = picked.filePaths[0]; + if (picked.canceled || !source) { + return { ok: false, reason: 'canceled' } satisfies SessionBundleImportIpcResult; + } + try { + const result = await deps.client.importSessionBundle({ source }); + // The Host republishes the catalog, but the desktop shell keeps its own + // list and only reads again when told. + // No ids: the result is a count, because a list that grows with the + // subtree can outgrow a frame after the Sessions are already committed. + // Unscoped is what tells the shell to read its catalog again. + deps.emitSessionsChanged('created'); + return { ok: true, sessionCount: result.sessionCount } satisfies SessionBundleImportIpcResult; + } catch (error) { + return failure(error) satisfies SessionBundleImportIpcResult; + } + }); +} + +/** + * A task name is written by a person, and this one becomes a proposed filename. + * + * Separators would move the dialog somewhere the name does not say, a leading + * dot proposes a hidden file, and a long name proposes one the filesystem may + * refuse. The dialog is still where the path is decided -- this only decides + * what it opens holding. + */ +function bundleFileName(name: unknown): string { + const proposed = (typeof name === 'string' ? name : '') + .replace(/[\u0000-\u001f/\\:*?"<>|]/g, ' ') + .replace(/\s+/g, ' ') + .replace(/^[.\s]+|[.\s]+$/g, '') + .slice(0, 80) + .trim(); + return proposed.length > 0 ? proposed : 'maka-session'; +} + +/** Must match the Host: sorted, newline-joined, hex `sha256`. */ +function subtreeDigest(sessionIds: readonly string[]): string { + return createHash('sha256') + .update([...sessionIds].sort().join('\n')) + .digest('hex'); +} + +function failure(error: unknown): SessionBundleFailure { + const reason = classify(error); + if (reason !== 'failed') return { ok: false, reason }; + // Nothing downstream can act on `failed`, so the message is all the user has. + // It also reaches the main-process log, because a reason code that says only + // "no" is how a cause gets lost. + console.error('[session-bundle] unclassified failure:', error); + return { + ok: false, + reason, + ...(error instanceof Error && error.message ? { detail: error.message } : {}), + }; +} + +function classify(error: unknown): SessionBundleFailureReason { + if (!(error instanceof RuntimeHostOperationError)) return 'failed'; + switch (error.code) { + case 'not_found': + case 'session_busy': + case 'operation_conflict': + case 'source_unreadable': + case 'candidate_set_stale': + return error.code; + default: + return 'failed'; + } +} diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index e389b888b4..00791158b8 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -143,6 +143,39 @@ import type { UsageProvenance } from '@maka/core/usage-ledger-merge'; import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import type { TestProxyInput } from '@maka/core/settings/network-settings'; import type { ExternalSessionImportIpcResult } from './external-session-import-result.js'; +/** + * What Settings › Import/export tasks gets back from a bundle operation. + * + * Stated here rather than in a file of its own: the renderer reaches this + * contract through the bridge, and a separate module would join the legacy + * AppShell closure, which the renderer architecture check freezes. + * + * `detail` is only set for `failed` -- the reason code no reader can act on. + * Without it the page says "that did not work" and the cause is gone, which is + * exactly the case where the user has nothing else to go on. + */ +export type SessionBundleFailureReason = + | 'canceled' + | 'candidate_set_stale' + | 'not_found' + | 'session_busy' + | 'operation_conflict' + | 'source_unreadable' + | 'failed'; + +export type SessionBundleFailure = { + readonly ok: false; + readonly reason: SessionBundleFailureReason; + readonly detail?: string; +}; + +export type SessionBundleExportIpcResult = + | { readonly ok: true; readonly sessionCount: number; readonly path: string } + | SessionBundleFailure; + +export type SessionBundleImportIpcResult = + | { readonly ok: true; readonly sessionCount: number } + | SessionBundleFailure; import type { DesktopSessionSummary } from '../shared/desktop-session-projection.js'; import type { SessionCollaborationCancelResult, @@ -1273,6 +1306,23 @@ export interface MakaBridge { sourceSessionId: string; }, host?: DesktopRuntimeHostRef): Promise>; }; + sessionBundles: { + /** + * Picks a destination, then writes the Session and its subagent subtree. + * + * `sessionId` is the projected, host-scoped id the renderer holds. Always + * routed to the Local Host: the picker returns a path on this machine, and + * that is the Host whose filesystem it names. + */ + export(input: { + sessionId: string; + suggestedName: string; + /** Projected ids of the Sessions the user was shown. See the Host operation. */ + confirmedSubtree?: readonly string[]; + }): Promise; + /** Picks a `.maka-session` file and merges it into the Local workspace. */ + import(): Promise; + }; projects: { getDefaultContext(host?: DesktopRuntimeHostRef): Promise<{ snapshot: DesktopProjectSnapshot; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 57d097c7e1..3b85dc55c6 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -17,6 +17,11 @@ * under the License. */ +import type { + SessionBundleExportIpcResult, + SessionBundleImportIpcResult, +} from './bridge-contract.js'; + import type { WorkHubAnswerInput, WorkHubAnswerResult } from '../shared/workhub-conversation.js'; import { contextBridge, ipcRenderer } from 'electron'; import { workHubControlBridge } from './workhub-control.js'; @@ -2676,6 +2681,35 @@ const makaBridge = { : result; }, }, + sessionBundles: { + // Both halves name a path the Electron picker chose, which is a path on + // THIS machine, and the protocol interprets it on the Host's filesystem. + // Those are the same filesystem only for the Local Host, so both are routed + // there explicitly -- not to whichever Host is active, and not to whichever + // one Settings happens to be pointed at. Carrying a bundle to or from a + // remote Host needs a byte transfer, not a path string. + async export(input: { + sessionId: string; + suggestedName: string; + confirmedSubtree?: readonly string[]; + }): Promise { + const scope = await localRuntimeHostRef(); + const { sessionId } = parseDesktopSessionKey(input.sessionId); + // Unprojected here, where the boundary already is: the renderer holds + // host-scoped ids and the Host knows only its own, so a digest computed + // upstream would compare two different alphabets and never match. + const confirmed = input.confirmedSubtree?.map( + (projected) => parseDesktopSessionKey(projected).sessionId, + ); + return (await ipcRenderer.invoke( + 'session-bundle:export', scope, sessionId, input.suggestedName, confirmed, + )) as SessionBundleExportIpcResult; + }, + async import(): Promise { + const scope = await localRuntimeHostRef(); + return (await ipcRenderer.invoke('session-bundle:import', scope)) as SessionBundleImportIpcResult; + }, + }, projects: { async getDefaultContext(host?: DesktopRuntimeHostRef): Promise<{ snapshot: DesktopProjectSnapshot; diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index 5c21481fdc..73f6a85885 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -39,6 +39,8 @@ import { createDesktopModuleHubServices } from '../platform/desktop/create-modul import { createDesktopRuntimeHostManagementServices } from '../platform/desktop/create-runtime-host-management-services'; import { createDesktopSessionCollaborationServices } from '../platform/desktop/create-session-collaboration-services'; import { createDesktopSessionNavigationServices } from '../platform/desktop/create-session-navigation-services'; +import { SessionBundleServicesProvider } from '../features/session-bundle'; +import { createDesktopSessionBundleServices } from '../platform/desktop/create-session-bundle-services.js'; import { createDesktopSessionSettingsServices } from '../platform/desktop/create-session-settings-services'; import { createDesktopTaskEntryServices } from '../platform/desktop/create-task-entry-services'; import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar-services'; @@ -60,6 +62,7 @@ export function createDesktopFeatureServices() { runtimeHostManagement: createDesktopRuntimeHostManagementServices(), sessionCollaboration: createDesktopSessionCollaborationServices(), sessionNavigation: createDesktopSessionNavigationServices(), + sessionBundle: createDesktopSessionBundleServices(), sessionSettings: createDesktopSessionSettingsServices(), taskEntry: createDesktopTaskEntryServices(), workbar: createDesktopWorkbarServices(), @@ -82,7 +85,11 @@ export function DesktopFeatureServicesProvider(props: { - {props.children} + + + {props.children} + + diff --git a/apps/desktop/src/renderer/features/session-bundle/export-tree.tsx b/apps/desktop/src/renderer/features/session-bundle/export-tree.tsx new file mode 100644 index 0000000000..facf8287ec --- /dev/null +++ b/apps/desktop/src/renderer/features/session-bundle/export-tree.tsx @@ -0,0 +1,134 @@ +/* + * 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 { ReactElement } from 'react'; +import { Badge } from '@astryxdesign/core/Badge'; +import { Button } from '@astryxdesign/core/Button'; +import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { HStack, VStack } from '@astryxdesign/core/Stack'; +import { projectLinkedSessionTree } from '@maka/core/session'; +import { useUiLocale } from '@maka/ui'; +import type { DesktopSessionSummary } from '../../../shared/desktop-session-projection.js'; +import { getExternalSessionImportCopy } from '../../locales/external-session-import-copy.js'; + +/** + * The local tasks, as the tree a bundle would carry. + * + * A bundle can be rooted at any node -- a subagent Session has its own complete + * history -- so every row exports, and the nesting says which subtree a row + * would take with it. + */ +export function ExportTree(props: { + sessions: readonly DesktopSessionSummary[]; + isBusy: boolean; + onExport: (session: DesktopSessionSummary, subtree: readonly string[]) => void; +}): ReactElement { + const copy = getExternalSessionImportCopy(useUiLocale()); + + // Filtered first, then projected. Deciding lineage over the whole catalog and + // hiding rows afterwards loses a Session: an active grandchild under an + // archived child nests under a parent that never draws it, so nobody renders + // it at all. + const visible = props.sessions.filter((session) => !session.isArchived); + // Two subtrees, on purpose. The visible one decides what is drawn; this one + // is every linked descendant, archived included, because that is the set the + // Host fences and exports. Confirming from the drawn one would name a + // different set than the file holds -- and would be refused as stale forever + // for any parent with an archived child. + const wholeTree = projectLinkedSessionTree(props.sessions); + const wholeSubtree = (sessionId: string): readonly string[] => { + const ids = [sessionId]; + const pending = [...(wholeTree.childrenByParentId.get(sessionId) ?? [])]; + while (pending.length > 0) { + const next = pending.pop(); + if (next === undefined) continue; + ids.push(next.id); + pending.push(...(wholeTree.childrenByParentId.get(next.id) ?? [])); + } + return ids; + }; + // The read model the rest of the app projects lineage with, rather than a + // second one maintained here. It resolves both spellings of the link, drops a + // parent that is not in the list, and refuses a cycle -- which `subagentParent` + // permits, being an ordinary field with no schema guarantee behind it. + const tree = projectLinkedSessionTree(visible); + const childrenOf = (sessionId: string): readonly DesktopSessionSummary[] => + (tree.childrenByParentId.get(sessionId) ?? []) as readonly DesktopSessionSummary[]; + + const descendantCount = (sessionId: string): number => { + // A subagent spawns its own, so this walks the subtree rather than counting + // one level. + let count = 0; + const pending = [...childrenOf(sessionId)]; + while (pending.length > 0) { + const next = pending.pop(); + if (next === undefined) continue; + count += 1; + pending.push(...childrenOf(next.id)); + } + return count; + }; + + if (tree.roots.length === 0) return ; + + const node = (session: DesktopSessionSummary, depth: number): ReactElement => { + const carried = descendantCount(session.id); + const agent = session.subagent?.agentName ?? session.subagentRuntime?.agentName; + const children = childrenOf(session.id); + return ( +
  • +
    + + + {session.name ?? session.id} + {agent ? : null} + + {carried > 0 && ( + {copy.exportCarriesSubagents(carried)} + )} + +
    + {children.length > 0 && ( + // The rule belongs to the container, not to each row: a border on the + // list that holds the children is exactly as tall as they are, while a + // mark drawn beside every child draws nothing between them. +
      + {children.map((child) => node(child, depth + 1))} +
    + )} +
  • + ); + }; + + return ( +
      + {tree.roots.map((session) => node(session as DesktopSessionSummary, 0))} +
    + ); +} diff --git a/apps/desktop/src/renderer/features/session-bundle/index.ts b/apps/desktop/src/renderer/features/session-bundle/index.ts new file mode 100644 index 0000000000..c38e78a938 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-bundle/index.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +export { SessionBundleImportPanel, SessionBundleTasks } from './session-bundle-tasks.js'; +export { SessionBundleServicesProvider } from './services-context.js'; +export type { SessionBundleServices } from './ports.js'; +/** The id the catalog page offers beside its adapters. Not an adapter: a file. */ +export const MAKA_BUNDLE_SOURCE_ID = 'maka-bundle'; diff --git a/apps/desktop/src/renderer/features/session-bundle/ports.ts b/apps/desktop/src/renderer/features/session-bundle/ports.ts new file mode 100644 index 0000000000..7445170812 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-bundle/ports.ts @@ -0,0 +1,68 @@ +/* + * 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. + */ + + +/** + * What this feature needs from the Desktop, and nothing more. + * + * The bridge is the composition zone's to hold. A feature that reached for it + * directly would be a feature that only runs in Electron, which is the coupling + * these ports exist to refuse. + */ +export type SessionBundleFailureReason = + | 'canceled' + // The subtree changed between the confirmation and the fence. + | 'candidate_set_stale' + | 'not_found' + | 'session_busy' + | 'operation_conflict' + | 'source_unreadable' + | 'failed'; + +/** Stated here, not imported: a feature that named a preload module would only + * run inside Electron. The Desktop's bridge satisfies this structurally. */ +export type SessionBundleExportOutcome = + | { readonly ok: true; readonly sessionCount: number; readonly path: string } + | { readonly ok: false; readonly reason: SessionBundleFailureReason; readonly detail?: string }; + +export type SessionBundleImportOutcome = + | { readonly ok: true; readonly sessionCount: number } + | { readonly ok: false; readonly reason: SessionBundleFailureReason; readonly detail?: string }; + +export interface SessionBundleServices { + /** Picks a destination, then writes the Session and its subagent subtree. */ + exportBundle(input: { + readonly sessionId: string; + readonly suggestedName: string; + /** + * The Session ids the user was shown, root included. + * + * Read from the catalog this renderer has; the save dialog opens, and only + * then does the Host discover and fence the real subtree. Another client + * can finish spawning a child in that gap, so the file would hold Sessions + * nobody was asked about. These become a digest at the boundary where the + * ids are already the Host's own, and the Host compares it against what it + * fenced -- a digest, because two subtrees of the same size are not the + * same subtree. + */ + readonly confirmedSubtree?: readonly string[]; + }): Promise; + /** Picks a `.maka-session` file and merges it into this workspace. */ + importBundle(): Promise; +} diff --git a/apps/desktop/src/renderer/features/session-bundle/services-context.tsx b/apps/desktop/src/renderer/features/session-bundle/services-context.tsx new file mode 100644 index 0000000000..a7ea5c4402 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-bundle/services-context.tsx @@ -0,0 +1,40 @@ +/* + * 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 { createContext, type ReactNode, useContext } from 'react'; +import type { SessionBundleServices } from './ports.js'; + +const SessionBundleServicesContext = createContext(undefined); + +export function SessionBundleServicesProvider(props: { + readonly services: SessionBundleServices; + readonly children?: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useSessionBundleServices(): SessionBundleServices { + const services = useContext(SessionBundleServicesContext); + if (!services) throw new Error('Session bundle services are unavailable'); + return services; +} diff --git a/apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx b/apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx new file mode 100644 index 0000000000..dc5eb8555f --- /dev/null +++ b/apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx @@ -0,0 +1,292 @@ +/* + * 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 { + createContext, + type ReactElement, + type ReactNode, + useContext, + useEffect, + useState, +} from 'react'; +import { Banner } from '@astryxdesign/core/Banner'; +import { Button } from '@astryxdesign/core/Button'; +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; +import { HStack, VStack } from '@astryxdesign/core/Stack'; +import { useMountedRef, useToast, useUiLocale } from '@maka/ui'; +import type { DesktopSessionSummary } from '../../../shared/desktop-session-projection.js'; +import { getExternalSessionImportCopy } from '../../locales/external-session-import-copy.js'; +import { getSettingsSharedCopy } from '../../locales/settings-shared-copy.js'; +import { ExportTree } from './export-tree.js'; +import { useSessionBundleServices } from './services-context.js'; + +/** + * Settings › Import/export tasks — moving a Session between installations. + * + * The catalog of other agents' conversations stays where it was; this wraps it + * with the second half of the subject and owns everything the bundle needs, so + * the legacy page keeps its shape and gains no state, no bridge call and no + * component of its own. + */ +export function SessionBundleTasks(props: { + /** + * Whether the Settings target is the Local Host. + * + * A bundle names a path the native picker chose, which is a path on this + * machine, and the protocol reads it on the Host's filesystem. Those are the + * same filesystem only for the Local Host, so beside any other target this + * feature has nothing coherent to offer and is not shown. + */ + isLocalTarget: boolean; + /** The adapter catalog, rendered when the import half is showing. */ + children: ReactNode; + /** Local tasks for the export half. Archived ones are left out here. */ + sessions?: readonly DesktopSessionSummary[]; + /** The settings surface's own section chrome, supplied rather than imported. */ + renderSection: (input: { + title?: string; + description?: string; + variant?: 'bare'; + children: ReactNode; + }) => ReactElement; +}): ReactElement { + const locale = useUiLocale(); + const copy = getExternalSessionImportCopy(locale); + const [mode, setMode] = useState<'import' | 'export'>('import'); + const services = useSessionBundleServices(); + const bundle = useSessionBundleActions(); + // Local-owner Sessions only. A bundle names a path the Electron picker chose, + // which is a path on this machine; a remote Host would read it on its own + // filesystem. A Guest projection is not ours to carry at all -- a Guest's + // Desktop does not even register these channels, and a remote owner is not + // granted the operations. + const exportable = (props.sessions ?? []).filter( + (session) => session.profileKind === 'local' && session.shared !== true, + ); + + // Its own label, and segment names no row action shares. `来源` is what an + // adapter is; this switch is not that. And a row's action is called 导出 too, + // so two controls answering to one name is a person tabbing to the wrong one. + const modeSwitch = ( + { + // The note reports what the other half just did. Carrying it across + // makes it read as this half's result. + bundle.clearNote(); + setMode(next as 'import' | 'export'); + }} + > + + + + ); + + // The section chrome belongs to the settings surface, not here: a feature + // that reached into the legacy page for it would be a feature that only + // renders inside that page. + // Not Local: the adapter catalog stands on its own -- but still inside the + // provider. Whether the catalog offers the bundle source and whether this + // feature is mounted are two decisions, and a panel that throws when they + // disagree turns a wiring slip into a blank page. Providing it always makes + // that disagreement impossible to crash on. + if (!props.isLocalTarget) { + return ( + + {props.children} + + ); + } + + return ( + <> + {props.renderSection({ variant: 'bare', children: modeSwitch })} + {mode === 'export' + ? props.renderSection({ + title: copy.exportTitle, + description: copy.exportDescription, + children: ( + + {bundle.banner} + + + ), + }) + : ( + + {props.children} + + )} + + ); +} + +function messageOf(error: unknown): string | undefined { + return error instanceof Error && error.message ? error.message : undefined; +} + +/** The Maka source's panel, rendered by the catalog page when it is selected. */ +export function SessionBundleImportPanel(): ReactElement { + const locale = useUiLocale(); + const copy = getExternalSessionImportCopy(locale); + const bundle = useSessionBundleActionsContext(); + return ( + + {bundle.banner} +

    {copy.makaImportDescription}

    + +