From ad461873f5ebe8cddc072a7bbc2b4ac9f24da9ef Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 29 Aug 2026 20:33:31 +0800 Subject: [PATCH 01/21] refactor: converge daily review on scheduled tasks Migrate legacy configuration and reports into the ScheduledTask, ordinary Session, and Artifact authorities, then remove the dedicated runtime and UI chain. Generated-by: Codex --- .../e2e/accessibility-coverage.spec.ts | 13 +- apps/desktop/e2e/module-hub.spec.ts | 6 +- .../__tests__/daily-review-fixture.test.ts | 60 -- .../__tests__/module-hub-boundary.test.ts | 6 +- ...module-hub-daily-review-controller.test.ts | 310 --------- .../main/__tests__/module-hub-host.test.ts | 4 +- .../module-hub-services-adapter.test.ts | 37 +- .../src/main/__tests__/nav-selection.test.ts | 4 +- apps/desktop/src/main/capability-snapshot.ts | 4 +- apps/desktop/src/main/e2e-fixture.ts | 5 - .../main/e2e-fixture/scenarios-settings.ts | 65 -- .../src/main/markdown-save-ipc-main.ts | 3 - .../main/runtime-host-renderer-ipc-main.ts | 4 - .../src/main/runtime-host-upgrade-copy.ts | 1 + apps/desktop/src/preload/bridge-contract.d.ts | 28 - apps/desktop/src/preload/preload.ts | 114 ---- .../runtime-host-renderer-operations.ts | 2 - .../src/renderer/app-shell-command-actions.ts | 6 - .../src/renderer/app-shell-detail-panel.tsx | 2 +- .../src/renderer/app-shell-e2e-fixture.ts | 2 - .../src/renderer/app-shell-overlays.tsx | 2 - apps/desktop/src/renderer/app-shell.tsx | 53 +- .../src/renderer/command-palette-commands.ts | 58 -- .../src/renderer/daily-review-actions.ts | 34 - .../renderer/features/module-hub/README.md | 10 +- .../module-hub/controller/module-hub-route.ts | 1 - .../controller/use-daily-review-controller.ts | 448 ------------- .../controller/use-module-hub-controller.ts | 39 +- .../src/renderer/features/module-hub/index.ts | 5 +- .../src/renderer/features/module-hub/ports.ts | 35 - .../renderer/features/module-hub/testing.ts | 28 - .../module-hub/ui/module-hub-host.tsx | 66 +- .../src/renderer/locales/conversation-copy.ts | 2 +- .../locales/permission-center-copy.ts | 2 +- .../locales/settings-daily-review-copy.ts | 85 --- .../locales/settings-navigation-copy.ts | 2 - .../renderer/locales/settings-shared-copy.ts | 6 - .../src/renderer/locales/shell-copy.ts | 81 --- .../renderer/locales/shell-remaining-copy.ts | 18 - apps/desktop/src/renderer/maka-tokens.css | 2 +- .../src/renderer/model-catalog-choices.ts | 129 ---- apps/desktop/src/renderer/nav-selection.ts | 10 +- .../desktop/create-module-hub-services.ts | 39 +- .../settings/daily-review-settings-page.tsx | 194 ------ .../src/renderer/settings/settings-modal.tsx | 7 - .../src/renderer/settings/settings-nav.ts | 6 - .../renderer/settings/settings-surface.tsx | 8 +- apps/desktop/src/renderer/styles.css | 1 - .../src/renderer/styles/chat-message.css | 2 +- .../src/renderer/styles/daily-review.css | 128 ---- .../src/shared/desktop-session-projection.ts | 14 - apps/desktop/stories/module-hubs.stories.tsx | 210 +----- .../settings/settings-pages.stories.tsx | 48 -- docs/architecture/scheduled-task-unified.md | 13 +- docs/astryx-full-surface-audit.md | 4 +- docs/astryx-surface-file-inventory.md | 7 +- docs/astryx-surface-file-inventory.paths | 3 - docs/computer-use-ui-coverage.md | 4 +- ...d-architecture-astryx-review-2026-08-09.md | 2 - ...model-metadata-firstscreen-optimization.md | 4 +- docs/web-search-provider-capability.md | 3 +- packages/core/package.json | 1 - .../core/src/__tests__/daily-review.test.ts | 76 --- packages/core/src/__tests__/health.test.ts | 4 +- .../core/src/__tests__/model-catalog.test.ts | 4 +- .../core/src/__tests__/scheduled-task.test.ts | 2 + packages/core/src/daily-review.ts | 468 ------------- packages/core/src/e2e-fixture.ts | 3 +- packages/core/src/model-catalog.ts | 1 + packages/core/src/scheduled-task.ts | 11 + packages/core/src/settings.ts | 1 - packages/core/src/usage-stats/types.ts | 1 + .../daily-review-coordinator.test.ts | 367 ----------- .../__tests__/daily-review-protocol.test.ts | 111 ---- .../daily-review-two-client-uds.test.ts | 222 ------- .../src/__tests__/execution-host.test.ts | 316 ++++++++- .../execution-model-composition.test.ts | 26 - .../src/__tests__/protocol.test.ts | 4 + .../__tests__/scheduled-task-protocol.test.ts | 1 + .../runtime-host/src/protocol/daily-review.ts | 621 ------------------ packages/runtime-host/src/protocol/index.ts | 6 +- .../runtime-host/src/protocol/operations.ts | 5 - .../src/protocol/scheduled-task.ts | 12 +- .../src/server/daily-review-coordinator.ts | 580 ---------------- .../src/server/execution-composition.ts | 44 +- .../src/server/execution-model-authority.ts | 101 --- .../src/server/operation-dispatcher.ts | 2 - .../src/server/scheduled-task-coordinator.ts | 10 +- packages/storage/package.json | 2 +- .../__tests__/daily-review-authority.test.ts | 201 ------ .../legacy-daily-review-migration.test.ts | 211 ++++++ .../src/__tests__/public-entrypoints.test.ts | 1 - .../__tests__/sqlite-workflow-store.test.ts | 2 +- .../storage/src/daily-review-authority.ts | 370 ----------- .../src/legacy-daily-review-migration.ts | 522 +++++++++++++++ .../storage/src/operational-target-schema.ts | 46 +- packages/storage/src/scheduled-task-store.ts | 46 ++ .../storage/src/sqlite-workflow-schema.ts | 22 +- .../storage/src/storage-writer-composition.ts | 10 +- packages/ui/src/components.tsx | 4 +- packages/ui/src/daily-review-copy.ts | 181 ----- packages/ui/src/daily-review-helpers.ts | 142 ---- packages/ui/src/daily-review-panel.tsx | 500 -------------- packages/ui/src/daily-review-view-state.ts | 96 --- packages/ui/src/index.ts | 2 - packages/ui/src/markdown-body.tsx | 2 +- packages/ui/src/module-hub-selector.tsx | 45 +- packages/ui/src/module-pages.tsx | 46 +- packages/ui/src/module-panel-types.ts | 39 -- packages/ui/src/nav-selection.ts | 2 +- packages/ui/src/primitives/stat-tile.tsx | 2 +- packages/ui/src/scheduled-task-copy.ts | 4 + ...scheduled-task-daily-review-preset.test.ts | 53 ++ .../ui/src/scheduled-task-form-dialog.tsx | 20 +- packages/ui/src/scheduled-task-helpers.ts | 12 +- packages/ui/src/scheduled-task-panel.tsx | 2 + packages/ui/src/shared-ui-copy.ts | 24 - packages/ui/src/styles.css | 2 +- scripts/audit-alignment.mjs | 1 - scripts/generate-astryx-surface-inventory.mjs | 2 +- scripts/storybook-visual-smoke.mjs | 2 - 121 files changed, 1405 insertions(+), 6700 deletions(-) delete mode 100644 apps/desktop/src/main/__tests__/daily-review-fixture.test.ts delete mode 100644 apps/desktop/src/main/__tests__/module-hub-daily-review-controller.test.ts delete mode 100644 apps/desktop/src/renderer/daily-review-actions.ts delete mode 100644 apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts delete mode 100644 apps/desktop/src/renderer/locales/settings-daily-review-copy.ts delete mode 100644 apps/desktop/src/renderer/settings/daily-review-settings-page.tsx delete mode 100644 apps/desktop/src/renderer/styles/daily-review.css delete mode 100644 packages/core/src/__tests__/daily-review.test.ts delete mode 100644 packages/core/src/daily-review.ts delete mode 100644 packages/runtime-host/src/__tests__/daily-review-coordinator.test.ts delete mode 100644 packages/runtime-host/src/__tests__/daily-review-protocol.test.ts delete mode 100644 packages/runtime-host/src/__tests__/daily-review-two-client-uds.test.ts delete mode 100644 packages/runtime-host/src/protocol/daily-review.ts delete mode 100644 packages/runtime-host/src/server/daily-review-coordinator.ts delete mode 100644 packages/storage/src/__tests__/daily-review-authority.test.ts create mode 100644 packages/storage/src/__tests__/legacy-daily-review-migration.test.ts delete mode 100644 packages/storage/src/daily-review-authority.ts create mode 100644 packages/storage/src/legacy-daily-review-migration.ts delete mode 100644 packages/ui/src/daily-review-copy.ts delete mode 100644 packages/ui/src/daily-review-helpers.ts delete mode 100644 packages/ui/src/daily-review-panel.tsx delete mode 100644 packages/ui/src/daily-review-view-state.ts create mode 100644 packages/ui/src/scheduled-task-daily-review-preset.test.ts diff --git a/apps/desktop/e2e/accessibility-coverage.spec.ts b/apps/desktop/e2e/accessibility-coverage.spec.ts index fe4e97fe80..82610bc1fb 100644 --- a/apps/desktop/e2e/accessibility-coverage.spec.ts +++ b/apps/desktop/e2e/accessibility-coverage.spec.ts @@ -91,19 +91,8 @@ test('module pages and global overlays expose named actionable controls', async await assertAxHealth(cdp, 'extensions/mcp'); await navigation.getByRole('button', { name: /定时任务/ }).click(); - const automationsNavigation = page.getByRole('navigation', { name: /定时任务内容/ }); - await expect( - automationsNavigation.getByRole('button', { name: '定时任务', exact: true }), - ).toHaveAttribute('aria-current', 'true'); + await expect(page.locator('[data-module="scheduled-tasks"]')).toBeVisible(); await assertAxHealth(cdp, 'automations/scheduled-tasks'); - const dailyReviewButton = automationsNavigation.getByRole('button', { - name: '每日回顾', - exact: true, - }); - await dailyReviewButton.click(); - await expect(dailyReviewButton).toHaveAttribute('aria-current', 'true'); - await assertAxHealth(cdp, 'automations/daily-review'); - await page.keyboard.press('Shift+Slash'); const keyboardHelpDialog = page.getByRole('dialog', { name: '键盘快捷键' }); await expect(keyboardHelpDialog).toBeVisible(); diff --git a/apps/desktop/e2e/module-hub.spec.ts b/apps/desktop/e2e/module-hub.spec.ts index 91609cfcfd..0f7e1985e0 100644 --- a/apps/desktop/e2e/module-hub.spec.ts +++ b/apps/desktop/e2e/module-hub.spec.ts @@ -19,7 +19,7 @@ import { expect, test } from './fixtures'; -test('Module Hub switches all four leaves and opens scheduled creation once', async ({ +test('Module Hub switches its three leaves and opens scheduled creation once', async ({ window: page, }) => { const expand = page.getByRole('button', { name: '展开侧边栏' }); @@ -36,10 +36,6 @@ test('Module Hub switches all four leaves and opens scheduled creation once', as await sidebar.getByRole('button', { name: /定时任务/ }).click(); await expect(page.locator('[data-module="scheduled-tasks"]')).toBeVisible(); - const automations = page.getByRole('navigation', { name: /定时任务内容/ }); - await automations.getByRole('button', { name: '每日回顾', exact: true }).click(); - await expect(page.locator('[data-module="daily-review"]')).toBeVisible(); - await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k'); const palette = page.getByRole('dialog', { name: '命令面板' }); await expect(palette).toBeVisible(); diff --git a/apps/desktop/src/main/__tests__/daily-review-fixture.test.ts b/apps/desktop/src/main/__tests__/daily-review-fixture.test.ts deleted file mode 100644 index 101dd161f9..0000000000 --- a/apps/desktop/src/main/__tests__/daily-review-fixture.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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 { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import { openInteractiveDailyReviewAuthorityForWrite } from '@maka/storage/daily-review-authority'; -import { - resolveStorageRoot, - tryAcquireInteractiveRootOwner, -} from '@maka/storage/root-authority'; -import { writeDailyReviewArchives } from '../e2e-fixture/scenarios-settings.js'; - -test('Daily Review fixture seeds archives through the storage authority', async () => { - const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-daily-review-fixture-')); - try { - await writeDailyReviewArchives(workspaceRoot, Date.UTC(2026, 4, 21, 12, 0, 0)); - - const capability = await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' }); - const owner = await tryAcquireInteractiveRootOwner(capability); - assert.ok(owner); - if (!owner) return; - try { - const writer = await openInteractiveDailyReviewAuthorityForWrite(owner.lease); - try { - const page = await writer.listArchivePage(null, 180); - assert.deepEqual( - page.archives.map((archive) => archive.id), - ['2026-05-21-1d', '2026-05-15-7d'], - ); - assert.ok(await writer.getArchive('2026-05-21-1d')); - assert.ok(await writer.getArchive('2026-05-15-7d')); - } finally { - writer.close(); - } - } finally { - await owner.close(); - } - } finally { - await rm(workspaceRoot, { recursive: true, force: true }); - } -}); diff --git a/apps/desktop/src/main/__tests__/module-hub-boundary.test.ts b/apps/desktop/src/main/__tests__/module-hub-boundary.test.ts index 60b6b65d30..83fc039ae5 100644 --- a/apps/desktop/src/main/__tests__/module-hub-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-boundary.test.ts @@ -131,8 +131,8 @@ describe('Module Hub feature boundary', () => { ); assert.equal(commands.includes('dailyReviewBridge'), false); assert.equal(commands.includes('saveDailyReviewMarkdown'), false); - assert.equal(commands.includes('copyTodayDailyReview()'), true); - assert.equal(commands.includes('pasteTodayDailyReview()'), true); - assert.equal(commands.includes('saveTodayDailyReview()'), true); + assert.equal(commands.includes('copyTodayDailyReview()'), false); + assert.equal(commands.includes('pasteTodayDailyReview()'), false); + assert.equal(commands.includes('saveTodayDailyReview()'), false); }); }); diff --git a/apps/desktop/src/main/__tests__/module-hub-daily-review-controller.test.ts b/apps/desktop/src/main/__tests__/module-hub-daily-review-controller.test.ts deleted file mode 100644 index 7fdb355eb5..0000000000 --- a/apps/desktop/src/main/__tests__/module-hub-daily-review-controller.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -/* - * 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 { afterEach, test } from 'node:test'; -import { act, createElement } from 'react'; -import type { DailyReviewSummary } from '@maka/core/daily-review'; -import { - createFakeModuleHubServices, - createDailyReviewBridge, - type DailyReviewController, - type ModuleHubServices, - useDailyReviewController, -} from '../../renderer/features/module-hub/testing.js'; -import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -function summary(sessionCount = 2): DailyReviewSummary { - return { - day: { fromMs: Date.UTC(2026, 7, 24), toMs: Date.UTC(2026, 7, 25) }, - totals: { - sessionCount, - requestCount: 7, - totalTokens: 1234, - costUsd: 0.25, - errorCount: 0, - }, - sessions: [], - topTools: [], - topModels: [], - }; -} - -function dailyReviewService( - day: ModuleHubServices['dailyReview']['day'], -): ModuleHubServices['dailyReview'] { - return { - day, - runOnce: async () => ({ archiveId: 'archive-1' }), - listArchives: async () => [], - getArchive: async () => null, - saveMarkdownToFile: async () => ({ ok: true, path: '/tmp/review.md' }), - }; -} - -test('stable page bridge retries rather than exposing a stale default-Host read', async () => { - const hostA = { profileId: 'profile-a', hostId: 'host-a' }; - const hostB = { profileId: 'profile-b', hostId: 'host-b' }; - let currentHost = hostA; - const reads: string[] = []; - const firstRead = deferred<{ ok: true; data: DailyReviewSummary }>(); - const services = createFakeModuleHubServices({ - runtimeHosts: { - getDefault: async () => currentHost, - subscribeChanges: () => () => undefined, - }, - dailyReview: dailyReviewService(async (_offset, _span, host) => { - reads.push(host.hostId); - if (host.hostId === hostA.hostId) return firstRead.promise; - return { ok: true, data: summary(9) }; - }), - }); - const bridge = createDailyReviewBridge(services, 'en'); - const pending = bridge.fetchDay(0, 1); - - currentHost = hostB; - firstRead.resolve({ ok: true, data: summary(1) }); - - assert.equal((await pending).totals.sessionCount, 9); - assert.deepEqual(reads, ['host-a', 'host-b']); -}); - -test('today paste captures its composer claim before reading and drops a late result', async () => { - const { root } = installReactRenderer(); - const pendingDay = deferred<{ ok: true; data: DailyReviewSummary }>(); - const appended: string[] = []; - const successes: string[] = []; - let claimCurrent = true; - let claims = 0; - const services = createFakeModuleHubServices({ - dailyReview: dailyReviewService(async () => pendingDay.promise), - }); - let controller: DailyReviewController | undefined; - - function Probe() { - controller = useDailyReviewController({ - services, - uiLocale: 'en', - toastApi: { - success: (title) => successes.push(title), - error: () => undefined, - }, - appendComposerText: (text) => appended.push(text), - captureActiveComposerClaim: () => { - claims += 1; - return { - isCurrent: () => claimCurrent, - append: (text) => appended.push(text), - }; - }, - isDailyReviewSurfaceActive: () => true, - }); - return null; - } - - await act(async () => root.render(createElement(Probe))); - const bridgeBefore = controller?.bridge; - await act(async () => root.render(createElement(Probe))); - assert.equal(controller?.bridge, bridgeBefore); - - let paste!: Promise; - await act(async () => { - paste = controller!.pasteToday(); - await Promise.resolve(); - }); - assert.equal(claims, 1); - claimCurrent = false; - pendingDay.resolve({ ok: true, data: summary() }); - await act(async () => paste); - - assert.deepEqual(appended, []); - assert.deepEqual(successes, []); -}); - -test('today paste rechecks its composer claim after an async failure Host fence', async () => { - const { root } = installReactRenderer(); - const host = { profileId: 'profile-a', hostId: 'host-a' }; - const finalHostRecheck = deferred(); - const errors: string[] = []; - let claimCurrent = true; - let hostReads = 0; - const services = createFakeModuleHubServices({ - runtimeHosts: { - getDefault: async () => { - hostReads += 1; - return hostReads === 3 ? finalHostRecheck.promise : host; - }, - subscribeChanges: () => () => undefined, - }, - dailyReview: dailyReviewService(async () => { - throw new Error('offline'); - }), - }); - let controller: DailyReviewController | undefined; - - function Probe() { - controller = useDailyReviewController({ - services, - uiLocale: 'en', - toastApi: { - success: () => undefined, - error: (title) => errors.push(title), - }, - appendComposerText: () => undefined, - captureActiveComposerClaim: () => ({ - isCurrent: () => claimCurrent, - append: () => undefined, - }), - isDailyReviewSurfaceActive: () => false, - }); - return null; - } - - await act(async () => root.render(createElement(Probe))); - const paste = controller!.pasteToday(); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); - assert.equal(hostReads, 3); - - claimCurrent = false; - finalHostRecheck.resolve(host); - await act(async () => paste); - - assert.deepEqual(errors, []); -}); - -test('page actions suppress late feedback after leaving Daily Review', async () => { - const { root } = installReactRenderer(); - const clipboard = deferred(); - const save = deferred< - | { ok: true; path: string } - | { ok: false; reason: 'canceled' | 'write_failed' | 'invalid_input' } - >(); - let active = true; - const successes: string[] = []; - const errors: string[] = []; - const services = createFakeModuleHubServices({ - dailyReview: { - ...dailyReviewService(async () => ({ ok: true, data: summary() })), - saveMarkdownToFile: async () => save.promise, - }, - clipboard: { writeText: async () => clipboard.promise }, - }); - let controller: DailyReviewController | undefined; - - function Probe() { - controller = useDailyReviewController({ - services, - uiLocale: 'en', - toastApi: { - success: (title) => successes.push(title), - error: (title) => errors.push(title), - }, - appendComposerText: () => undefined, - captureActiveComposerClaim: () => undefined, - isDailyReviewSurfaceActive: () => active, - }); - return null; - } - - await act(async () => root.render(createElement(Probe))); - const actionInput = { - day: summary().day, - range: 1 as const, - totals: summary().totals, - markdown: '# Review', - label: 'Today', - }; - // No caller predicate: the controller's live surface predicate is the - // ownership fence, even if a Host model snapshot was captured before leave. - const copyPromise = controller!.copyMarkdown(actionInput); - const savePromise = controller!.saveMarkdown(actionInput); - active = false; - clipboard.resolve(); - save.resolve({ ok: true, path: '/tmp/review.md' }); - await act(async () => Promise.all([copyPromise, savePromise])); - - assert.deepEqual(successes, []); - assert.deepEqual(errors, []); - - // Command Palette ownership is separate from the page surface: its public - // command still reports success while Daily Review is not selected. - await act(async () => controller!.saveToday()); - assert.deepEqual(successes, ['Today review saved']); -}); - -test('current default-Host Daily Review failures retain their diagnostic target', async () => { - const { root } = installReactRenderer(); - const errors: Array<{ title: string; profileId?: string }> = []; - const services = createFakeModuleHubServices({ - runtimeHosts: { - getDefault: async () => ({ - profileId: 'remote-profile', - hostId: 'remote-host', - }), - subscribeChanges: () => () => undefined, - }, - dailyReview: dailyReviewService(async () => { - throw new Error('offline'); - }), - }); - let controller: DailyReviewController | undefined; - - function Probe() { - controller = useDailyReviewController({ - services, - uiLocale: 'en', - toastApi: { - success: () => undefined, - error: (title, _description, _details, target) => - errors.push({ - title, - profileId: - target && 'profileId' in target ? target.profileId : undefined, - }), - }, - appendComposerText: () => undefined, - captureActiveComposerClaim: () => undefined, - isDailyReviewSurfaceActive: () => false, - }); - return null; - } - - await act(async () => root.render(createElement(Probe))); - await act(async () => controller!.copyToday()); - - assert.deepEqual(errors, [ - { title: 'Copy failed', profileId: 'remote-profile' }, - ]); -}); - -afterEach(() => cleanupFakeDom()); diff --git a/apps/desktop/src/main/__tests__/module-hub-host.test.ts b/apps/desktop/src/main/__tests__/module-hub-host.test.ts index 7a523ff8aa..63da1a90f2 100644 --- a/apps/desktop/src/main/__tests__/module-hub-host.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-host.test.ts @@ -25,12 +25,11 @@ import { fileURLToPath } from 'node:url'; import type { NavSelection } from '@maka/ui'; import { resolveModuleHubHostRoute } from '../../renderer/features/module-hub/testing.js'; -test('Module Hub resolves all four leaf routes and no chat route', () => { +test('Module Hub resolves its three leaf routes and no chat route', () => { const cases: Array<[NavSelection, ReturnType]> = [ [{ section: 'extensions', module: 'skills' }, 'skills'], [{ section: 'extensions', module: 'mcp' }, 'mcp'], [{ section: 'automations', module: 'scheduled-tasks' }, 'scheduled-tasks'], - [{ section: 'automations', module: 'daily-review' }, 'daily-review'], [{ section: 'sessions' }, null], ]; for (const [selection, expected] of cases) { @@ -53,7 +52,6 @@ test('Host maps each route to one existing leaf and preserves the MCP exception' ' { - it('maps host-scoped Skills, Scheduled Tasks, Daily Review, and clipboard operations', async () => { + it('maps host-scoped Skills and Scheduled Tasks operations', async () => { const calls: Call[] = []; const host: ModuleHubRuntimeHostRef = { profileId: 'remote-a', @@ -60,14 +60,8 @@ describe('createDesktopModuleHubServices', () => { catalog: methodRecorder(calls, 'skills.catalog'), }), scheduledTasks: methodRecorder(calls, 'scheduledTasks'), - dailyReview: methodRecorder(calls, 'dailyReview'), } as unknown as DesktopModuleHubBridge; - const clipboard = { - async writeText(text: string) { - calls.push({ name: 'clipboard.writeText', args: [text] }); - }, - }; - const services = createDesktopModuleHubServices(bridge, { clipboard }); + const services = createDesktopModuleHubServices(bridge); assert.deepEqual(await services.runtimeHosts.getDefault(), host); await services.skills.list(host); @@ -98,16 +92,6 @@ describe('createDesktopModuleHubServices', () => { await services.scheduledTasks.clearRunHistory('task', host); await services.scheduledTasks.delete('task', host); - await services.dailyReview.day(0, 7, host); - await services.dailyReview.runOnce({ range: 7, offsetDays: -1 }); - await services.dailyReview.listArchives(); - await services.dailyReview.getArchive('archive'); - await services.dailyReview.saveMarkdownToFile({ - markdown: '# Review', - defaultName: 'review.md', - }); - await services.clipboard.writeText('review'); - assert.deepEqual(calls, [ { name: 'skills.list', args: [host] }, { name: 'skills.sources.list', args: [host] }, @@ -129,15 +113,6 @@ describe('createDesktopModuleHubServices', () => { { name: 'scheduledTasks.snooze', args: ['task', host] }, { name: 'scheduledTasks.clearRunHistory', args: ['task', host] }, { name: 'scheduledTasks.delete', args: ['task', host] }, - { name: 'dailyReview.day', args: [0, 7, host] }, - { name: 'dailyReview.runOnce', args: [{ range: 7, offsetDays: -1 }] }, - { name: 'dailyReview.listArchives', args: [] }, - { name: 'dailyReview.getArchive', args: ['archive'] }, - { - name: 'dailyReview.saveMarkdownToFile', - args: [{ markdown: '# Review', defaultName: 'review.md' }], - }, - { name: 'clipboard.writeText', args: ['review'] }, ]); }); @@ -176,11 +151,8 @@ describe('createDesktopModuleHubServices', () => { scheduledDueHandler = handler; }), }), - dailyReview: methodRecorder([], 'dailyReview'), } as unknown as DesktopModuleHubBridge; - const services = createDesktopModuleHubServices(bridge, { - clipboard: { writeText: async () => undefined }, - }); + const services = createDesktopModuleHubServices(bridge); const hostEvents: unknown[] = []; const taskEvents: unknown[] = []; const dueEvents: unknown[] = []; @@ -238,7 +210,6 @@ describe('createDesktopModuleHubServices', () => { catalog: methodRecorder([], 'skills.catalog'), }), scheduledTasks: methodRecorder([], 'scheduledTasks'), - dailyReview: methodRecorder([], 'dailyReview'), }; const services = createDesktopModuleHubServices( { @@ -259,7 +230,6 @@ describe('createDesktopModuleHubServices', () => { }, }, } as unknown as DesktopModuleHubBridge, - { clipboard: { writeText: async () => undefined } }, ); assert.equal(services.clientSettings.supported, true); assert.equal(await services.clientSettings.getKeepSystemAwake(), true); @@ -276,7 +246,6 @@ describe('createDesktopModuleHubServices', () => { const oldPreload = createDesktopModuleHubServices( base as unknown as DesktopModuleHubBridge, - { clipboard: { writeText: async () => undefined } }, ); assert.equal(oldPreload.clientSettings.supported, false); oldPreload.clientSettings.subscribeChanges(() => undefined)(); diff --git a/apps/desktop/src/main/__tests__/nav-selection.test.ts b/apps/desktop/src/main/__tests__/nav-selection.test.ts index c2ff5f3ecf..84c5778ca2 100644 --- a/apps/desktop/src/main/__tests__/nav-selection.test.ts +++ b/apps/desktop/src/main/__tests__/nav-selection.test.ts @@ -22,7 +22,7 @@ import { describe, it } from 'node:test'; import { parseNavigationState } from '../../renderer/nav-selection.js'; describe('Navigation selection persistence', () => { - it('hydrates the hub-shaped navigation state written by supported versions', () => { + it('redirects retired Daily Review module memory to Scheduled Tasks', () => { assert.deepEqual( parseNavigationState( JSON.stringify({ @@ -37,7 +37,7 @@ describe('Navigation selection persistence', () => { selection: { section: 'extensions', module: 'mcp' }, moduleMemory: { extensions: 'mcp', - automations: 'daily-review', + automations: 'scheduled-tasks', }, }, ); diff --git a/apps/desktop/src/main/capability-snapshot.ts b/apps/desktop/src/main/capability-snapshot.ts index 8c430d9b00..a11ada48d4 100644 --- a/apps/desktop/src/main/capability-snapshot.ts +++ b/apps/desktop/src/main/capability-snapshot.ts @@ -80,7 +80,7 @@ export function buildCapabilitySnapshotCollection(input: { feature: { state: 'partial', source: 'runtime', - reason: 'Daily Review 已聚合本地任务 / 工具 / 模型活动;当前不包含屏幕与应用级录制', + reason: '普通任务与产物可保留本地任务 / 工具 / 模型活动;当前不包含屏幕与应用级录制', }, requiredPermissions: [ { id: 'screen_recording', required: false, status: permissions.screen_recording.status }, @@ -90,7 +90,7 @@ export function buildCapabilitySnapshotCollection(input: { runtimeProbe: { state: 'not_run', source: 'runtime_probe', - reason: '打开 Daily Review 可查看本地活动聚合结果', + reason: '在普通任务历史与定时任务执行记录中查看本地活动结果', }, }), staticCapability({ diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index dff4e6aab5..b92ef7ba78 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -51,7 +51,6 @@ import { seedMcpFixture, seedSkillsMarketFixture } from './e2e-fixture/scenarios import { longSidebarSessions } from './e2e-fixture/scenarios-sessions.js'; import { writeConnections, - writeDailyReviewArchives, writeScheduledTasks, writeSettings, } from './e2e-fixture/scenarios-settings.js'; @@ -70,7 +69,6 @@ const E2E_FIXTURE_SCENARIOS = new Set([ 'settings-usage', 'module-skills', 'module-mcp', - 'module-daily-review', 'scheduled-tasks', 'sidebar-search-modal-open', ]); @@ -199,8 +197,6 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState return { ...state, activeSessionId: TURN_SESSION_ID, sidebarSection: 'skills', sidebarCollapsed: false }; case 'module-mcp': return { ...state, activeSessionId: TURN_SESSION_ID, sidebarSection: 'mcp', sidebarCollapsed: false }; - case 'module-daily-review': - return { ...state, activeSessionId: TURN_SESSION_ID, sidebarSection: 'daily-review', sidebarCollapsed: false }; case 'scheduled-tasks': return { ...state, activeSessionId: TURN_SESSION_ID, sidebarSection: 'automations', sidebarCollapsed: false }; case 'sidebar-search-modal-open': @@ -254,7 +250,6 @@ export async function seedE2eFixture(input: { } } if (scenario === 'scheduled-tasks') await writeScheduledTasks(input.workspaceRoot, now); - if (scenario === 'module-daily-review') await writeDailyReviewArchives(input.workspaceRoot, now); if (scenario === 'module-skills') await seedSkillsMarketFixture(input.workspaceRoot); if (scenario === 'module-mcp') await seedMcpFixture(input.workspaceRoot); if (scenario === 'settings-usage') { diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts b/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts index 0236a0de9f..ec8e32a7ee 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts @@ -18,7 +18,6 @@ */ import { join } from 'node:path'; -import type { DailyReviewArchive } from '@maka/core/daily-review'; import type { E2eFixtureScenario } from '@maka/core/e2e-fixture'; import type { ConnectionCatalogEntryDraft, @@ -26,7 +25,6 @@ import type { ConnectionModel, } from '@maka/core/runtime-policy'; import { createDefaultSettings } from '@maka/core/settings'; -import { openInteractiveDailyReviewAuthorityForWrite } from '@maka/storage/daily-review-authority'; import { openInteractiveRuntimePolicyStoresForWrite } from '@maka/storage/runtime-policy-stores'; import { openInteractiveScheduledTaskStoreForWrite } from '@maka/storage/scheduled-task-store'; import { @@ -283,66 +281,3 @@ export async function writeScheduledTasks(workspaceRoot: string, now: number): P await owner.close(); } } - -export async function writeDailyReviewArchives(workspaceRoot: string, now: number): Promise { - const dayFromMs = new Date(2026, 4, 21).getTime(); - const dayToMs = new Date(2026, 4, 22).getTime(); - const daily: DailyReviewArchive = { - id: '2026-05-21-1d', - day: { fromMs: dayFromMs, toMs: dayToMs }, - range: 1, - status: 'ok', - generatedAt: now - 10 * 60_000, - trigger: 'manual', - modelKey: 'zai-live::glm-4.5', - totals: { - sessionCount: 8, - requestCount: 34, - totalTokens: 128_640, - costUsd: 1.82, - errorCount: 1, - }, - sections: { - summary: '今天主要围绕 Maka 桌面端的侧边栏、权限中心和每日回顾展开,重点是把入口、报告保存和设置项接到真实运行链路。', - gaps: '权限中心按钮已经接入系统设置跳转;每日回顾外部通知仍缺少报告自动推送运行时,需要保持不可用状态而不是展示假开关。', - usage: '模型请求集中在 UI 逆向与合约验证,工具调用以文件检索、构建和截图 smoke 为主。', - code: '建议继续收敛 Settings 与模块页的 shared page shell,减少同类 surface 在 styles.css 里的重复规则。', - }, - }; - const deep: DailyReviewArchive = { - ...daily, - id: '2026-05-15-7d', - day: { fromMs: new Date(2026, 4, 15).getTime(), toMs: dayToMs }, - range: 7, - generatedAt: now - 5 * 60_000, - trigger: 'cron', - totals: { - ...daily.totals, - sessionCount: 12, - requestCount: 58, - totalTokens: 211_300, - costUsd: 3.94, - errorCount: 1, - }, - sections: { - summary: '深度分析覆盖最近一轮 Maka UI 打磨:参考布局学习、权限中心重画、Daily Review 从聚合面板走向可保存报告。', - gaps: '第一性原理层面需要把“模块页 shell / Settings row / 状态 pill / 操作按钮”抽成真实组件,否则后续仍会在 CSS 中继续堆叠局部规则。', - usage: '高频动作是读取源码、运行 contract、构建 renderer、生成 e2e-fixture 截图。失败成本主要来自多处页面壳层行为不统一。', - code: '下一步优先建立模块页 PageShell、SettingsActionRow 和 StatusPill primitives,再迁移 Daily Review、权限中心、计划任务和技能页。', - }, - }; - const capability = await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' }); - const owner = await tryAcquireInteractiveRootOwner(capability); - if (!owner) throw new Error('Unable to acquire the Daily Review fixture root'); - try { - const store = await openInteractiveDailyReviewAuthorityForWrite(owner.lease); - try { - await store.publishArchive(daily, 180); - await store.publishArchive(deep, 180); - } finally { - store.close(); - } - } finally { - await owner.close(); - } -} diff --git a/apps/desktop/src/main/markdown-save-ipc-main.ts b/apps/desktop/src/main/markdown-save-ipc-main.ts index cdee09b8ad..b4701e0bc0 100644 --- a/apps/desktop/src/main/markdown-save-ipc-main.ts +++ b/apps/desktop/src/main/markdown-save-ipc-main.ts @@ -25,9 +25,6 @@ export function registerMarkdownSaveIpc(input: { readonly ipcMain: Pick; readonly mainWindowController: ReturnType; }): void { - input.ipcMain.handle('daily-review:saveMarkdownToFile', (_event, value) => - saveMarkdownViaDialog(input.mainWindowController, value, 'Save daily review'), - ); input.ipcMain.handle('chat:saveConversationToFile', (_event, value) => saveMarkdownViaDialog(input.mainWindowController, value, 'Save conversation'), ); diff --git a/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts b/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts index 611b2023a1..417b946a89 100644 --- a/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts @@ -90,10 +90,6 @@ function request( switch (operation) { case 'context.diagnostics.query': return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); - case 'daily-review.mutate': - return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); - case 'daily-review.query': - return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); case 'execution.inspect.query': return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); case 'scheduled-task.mutate': diff --git a/apps/desktop/src/main/runtime-host-upgrade-copy.ts b/apps/desktop/src/main/runtime-host-upgrade-copy.ts index bcaa1a24ef..7c3cd6812c 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-copy.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-copy.ts @@ -34,6 +34,7 @@ export interface RuntimeHostUpgradeDialog { type ActivityKey = | 'goal' | 'scheduledTask' + // Decode-only copy for an older Host that is still resident during upgrade. | 'dailyReview' | 'execution' | 'resource' diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index df55b7d7aa..80ecec4603 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -86,13 +86,6 @@ import type { } from '@maka/core/oauth-subscription'; import type { CreateScheduledTaskInput, ScheduledTask, UpdateScheduledTaskInput } from '@maka/core/scheduled-task'; import type { ProjectRecord } from '@maka/core/project'; -import type { - DailyReviewArchive, - DailyReviewArchiveSummary, - DailyReviewConfig, - DailyReviewRange, - DailyReviewSummary, -} from '@maka/core/daily-review'; import type { WebSearchProvider, WebSearchResponse } from '@maka/core/web-search'; import type { BrowserState, BrowserViewRect } from '@maka/core/browser'; import type { Task, TaskLedgerChangedEvent } from '@maka/core/task-ledger'; @@ -1516,27 +1509,6 @@ export interface MakaBridge { }, host?: DesktopRuntimeHostRef): Promise; test(input: { provider?: WebSearchProvider; apiKey?: string }, host?: DesktopRuntimeHostRef): Promise; }; - dailyReview: { - day(offsetDays: number, daySpan?: number, host?: DesktopRuntimeHostRef): Promise>; - getConfig?(host?: DesktopRuntimeHostRef): Promise; - setConfig?(patch: Partial, host?: DesktopRuntimeHostRef): Promise; - runOnce?(input: { range: DailyReviewRange; offsetDays?: number; modelKey?: string }): Promise<{ archiveId: string }>; - listArchives?(): Promise; - getArchive?(archiveId: string): Promise; - saveMarkdownToFile(input: { - markdown: string; - defaultName: string; - }): Promise< - { ok: true; path: string } | { ok: false; reason: 'canceled' | 'write_failed' | 'invalid_input' } - >; - /** - * PR-DAILY-REVIEW-FULL-0 — pipeline + archive surface. Each - * method may reject with a string error code when the - * backend is not yet wired or when prerequisites are missing - * (e.g. no model configured). Renderer gracefully handles - * rejection by showing the disabled / fallback form. - */ - }; appWindow: { setTitlebarControlsVisible(visible: boolean): Promise; setThemeSource(themePref: ThemePreference): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index c3f7025879..5287d757ac 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -165,13 +165,6 @@ import type { } from '@maka/core/oauth-subscription'; import type { CreateScheduledTaskInput, ScheduledTask, UpdateScheduledTaskInput } from '@maka/core/scheduled-task'; import type { ProjectRecord } from '@maka/core/project'; -import type { - DailyReviewArchive, - DailyReviewArchiveSummary, - DailyReviewConfig, - DailyReviewRange, - DailyReviewSummary, -} from '@maka/core/daily-review'; import type { WebSearchProvider, WebSearchResponse } from '@maka/core/web-search'; import type { BrowserState, BrowserViewRect } from '@maka/core/browser'; import { createBrowserSelectionCoordinator } from './browser-selection.js'; @@ -187,10 +180,6 @@ import { isSessionTrace, } from '@maka/core/session-trace'; import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; -import { - DAILY_REVIEW_RANGES, - normalizeDailyReviewConfig, -} from '@maka/core/daily-review'; import type { AgentGraphClientSnapshot, AgentGraphClientSnapshotOptions, @@ -237,7 +226,6 @@ import { } from './projected-session-runtime-host.js'; import { projectDesktopAttachmentRefs, - projectDesktopDailyReviewSummary, projectDesktopSessionEvent, projectDesktopSessionSummary, projectDesktopTurnRecord, @@ -1023,46 +1011,6 @@ async function loadSessionUsageSummary( ) as Promise>; } -async function updateDailyReviewConfig( - patch: Partial, - target?: DesktopRuntimeHostRef, -): Promise { - const host = scopedRuntimeHost(await selectedRuntimeHostScope(target)); - for (let attempt = 0; attempt < 3; attempt += 1) { - const current = await host.query('daily-review.query', { kind: 'config' }); - if (current.kind !== 'config') throw new Error('Invalid Daily Review config'); - const config = normalizeDailyReviewConfig({ ...current.config, ...patch }); - const result = await host.command('daily-review.mutate', { - kind: 'update_config', - expectedRevision: current.revision, - config, - }); - if (result.kind === 'config_committed' || result.kind === 'config_unchanged') { - return result.config; - } - } - throw new Error('Daily Review config kept changing while Desktop updated it'); -} - -async function listDailyReviewArchives(): Promise { - const host = scopedRuntimeHost(await activeRuntimeHostRef()); - const archives: DailyReviewArchiveSummary[] = []; - let beforeArchiveId: string | null = null; - do { - const result: OperationOutput<'daily-review.query'> = await host.query( - 'daily-review.query', { - kind: 'archives', - beforeArchiveId, - limit: 32, - }, - ); - if (result.kind !== 'archives') throw new Error('Invalid Daily Review archive page'); - archives.push(...result.archives); - beforeArchiveId = result.nextBeforeArchiveId; - } while (beforeArchiveId !== null); - return archives; -} - function executeWebSearchQuery(input: { query: string; limit?: number; @@ -2970,68 +2918,6 @@ const makaBridge = { ); }, }, - dailyReview: { - day(offsetDays: number, daySpan?: number, host?: DesktopRuntimeHostRef): Promise> { - return bridgeResult(async () => { - const scope = await selectedRuntimeHostScope(host); - const result = await scopedRuntimeHost(scope).query('daily-review.query', { - kind: 'summary', - offsetDays: integer(offsetDays, 0), - daySpan: Math.max(1, Math.min(30, integer(daySpan, 1))), - }); - if (result.kind !== 'summary') throw new Error('Invalid Daily Review summary'); - return projectDesktopDailyReviewSummary(scope, result.summary); - }, 'DAILY_REVIEW_DAY_FAILED'); - }, - async getConfig(host?: DesktopRuntimeHostRef): Promise { - const result = await scopedRuntimeHost( - await selectedRuntimeHostScope(host), - ).query('daily-review.query', { - kind: 'config', - }); - if (result.kind !== 'config') throw new Error('Invalid Daily Review config'); - return result.config; - }, - setConfig(patch: Partial, host?: DesktopRuntimeHostRef): Promise { - return updateDailyReviewConfig(patch, host); - }, - async runOnce(input: { range: DailyReviewRange; offsetDays?: number; modelKey?: string }): Promise<{ archiveId: string }> { - const result = await runtimeHost.command('daily-review.mutate', { - kind: 'run', - range: DAILY_REVIEW_RANGES.includes(input.range) ? input.range : 1, - offsetDays: integer(input.offsetDays, 0), - modelKeyOverride: input.modelKey ?? '', - replaceExisting: false, - }); - if (result.kind !== 'archive') throw new Error('Invalid Daily Review run'); - return { archiveId: result.archive.id }; - }, - listArchives(): Promise { - return listDailyReviewArchives(); - }, - async getArchive(archiveId: string): Promise { - const result = await runtimeHost.query('daily-review.query', { - kind: 'archive', - archiveId, - }); - if (result.kind !== 'archive') throw new Error('Invalid Daily Review archive'); - return result.archive; - }, - /** - * PR-DAILY-REVIEW-EXPORT-FILE-0: render the markdown in the renderer - * (where the human-readable title context lives) and ship the bytes - * to main for the save dialog + write. Main never sees the raw - * telemetry; only the formatted output. - */ - saveMarkdownToFile(input: { - markdown: string; - defaultName: string; - }): Promise< - { ok: true; path: string } | { ok: false; reason: 'canceled' | 'write_failed' | 'invalid_input' } - > { - return ipcRenderer.invoke('daily-review:saveMarkdownToFile', input); - }, - }, webSearch: { query(input: { query: string; diff --git a/apps/desktop/src/preload/runtime-host-renderer-operations.ts b/apps/desktop/src/preload/runtime-host-renderer-operations.ts index 1e6b2da5d4..5d1d9b7506 100644 --- a/apps/desktop/src/preload/runtime-host-renderer-operations.ts +++ b/apps/desktop/src/preload/runtime-host-renderer-operations.ts @@ -24,13 +24,11 @@ export const RENDERER_RUNTIME_HOST_QUERY_OPERATIONS = [ // made of" for `/context` (#1580, #2323). Admitted on the same terms as the // inspect query beside it: it reads a projection and writes nothing. 'context.diagnostics.query', - 'daily-review.query', 'execution.inspect.query', 'scheduled-task.query', ] as const satisfies readonly (keyof OperationSpecMap)[]; export const RENDERER_RUNTIME_HOST_COMMAND_OPERATIONS = [ - 'daily-review.mutate', 'scheduled-task.mutate', 'web-search.execute', ] as const satisfies readonly (keyof OperationSpecMap)[]; diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index 0cfcb58143..60017947e8 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -90,9 +90,6 @@ export interface AppShellCommandListOptions { openSkillsFolder: () => Promise; openWorkspaceFolder: () => Promise; refreshConnections: () => Promise; - copyTodayDailyReview: () => Promise; - pasteTodayDailyReview: () => Promise; - saveTodayDailyReview: () => Promise; setNavSelection: (selection: NavSelection) => void; setPermissionMode: (mode: PermissionMode) => Promise; setThemePref: (themePref: ThemePreference) => void; @@ -320,9 +317,6 @@ export function buildAppShellCommandList( } : undefined, activePermissionMode: options.activePermissionMode, - onCopyTodayDailyReview: () => optionsRef.current.copyTodayDailyReview(), - onPasteTodayDailyReviewIntoComposer: () => optionsRef.current.pasteTodayDailyReview(), - onSaveTodayDailyReviewToFile: () => optionsRef.current.saveTodayDailyReview(), onCopyDiagnostics: async () => { const { captureComposerImportOwner, diff --git a/apps/desktop/src/renderer/app-shell-detail-panel.tsx b/apps/desktop/src/renderer/app-shell-detail-panel.tsx index 8fc3e48bf8..b94ddb4db6 100644 --- a/apps/desktop/src/renderer/app-shell-detail-panel.tsx +++ b/apps/desktop/src/renderer/app-shell-detail-panel.tsx @@ -23,7 +23,7 @@ type AppShellDetailPanelProps = Omit< ComponentPropsWithoutRef<'div'>, 'className' | 'data-agents-view' > & { - agentsView: 'skills' | 'mcp' | 'cron' | 'daily-review' | 'im_hub'; + agentsView: 'skills' | 'mcp' | 'cron' | 'im_hub'; }; export function AppShellDetailPanel({ diff --git a/apps/desktop/src/renderer/app-shell-e2e-fixture.ts b/apps/desktop/src/renderer/app-shell-e2e-fixture.ts index b37fc542e1..7cdbfa0399 100644 --- a/apps/desktop/src/renderer/app-shell-e2e-fixture.ts +++ b/apps/desktop/src/renderer/app-shell-e2e-fixture.ts @@ -156,8 +156,6 @@ export function createAppShellE2eFixtureActions(options: { setNavSelection({ section: 'extensions', module: 'skills' }); } else if (state.sidebarSection === 'mcp') { setNavSelection({ section: 'extensions', module: 'mcp' }); - } else if (state.sidebarSection === 'daily-review') { - setNavSelection({ section: 'automations', module: 'daily-review' }); } else if (state.sidebarSection === 'sessions') { setNavSelection({ section: 'sessions' }); } diff --git a/apps/desktop/src/renderer/app-shell-overlays.tsx b/apps/desktop/src/renderer/app-shell-overlays.tsx index d1509582ec..490be420b8 100644 --- a/apps/desktop/src/renderer/app-shell-overlays.tsx +++ b/apps/desktop/src/renderer/app-shell-overlays.tsx @@ -84,7 +84,6 @@ export function AppShellOverlays(props: { settingsProviderCatalogOpen: boolean; settingsConnectionDetailSlug: string | undefined; settingsCreateProviderType: ProviderType | undefined; - onOpenDailyReview(): void; onOpenKeyboardHelp(): void; onOpenSettingsSession(sessionId: string): void; archivedTasks: ArchivedTasksBridge; @@ -187,7 +186,6 @@ export function AppShellOverlays(props: { openProviderCatalog={settingsProviderCatalogOpen} initialConnectionSlug={settingsConnectionDetailSlug} initialCreateProviderType={settingsCreateProviderType} - onOpenDailyReview={props.onOpenDailyReview} onOpenKeyboardHelp={props.onOpenKeyboardHelp} onOpenSession={props.onOpenSettingsSession} archivedTasks={props.archivedTasks} diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index b899ce429f..eb9a328731 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -38,6 +38,7 @@ import type { import type { SessionSummary } from '@maka/core/session'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; +import type { ScheduledTaskEffect } from '@maka/core/scheduled-task'; import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog'; import type { UiLocale, UiLocalePreference } from '@maka/core/ui-locale'; import { collapseSessionRevisions } from '@maka/core/session-revisions'; @@ -272,7 +273,7 @@ type FirstSendObservationWaiter = { * `data-agents-view`; the toolbar now lives in the window titlebar, which is not * a descendant of the detail panel, so the condition belongs here. */ -const VIEWS_WITHOUT_WORKSPACE_ACTIONS = new Set(['skills', 'cron', 'daily-review']); +const VIEWS_WITHOUT_WORKSPACE_ACTIONS = new Set(['skills', 'cron']); type AppShellProps = { /** Pre-mount snapshot prefetched by main.tsx — see prefetchOnboardingSnapshot. */ @@ -1473,32 +1474,29 @@ function AppShellContent({ }, toastApi, }); - const captureActiveComposerClaim = useCallback(() => { - const sessionId = activeIdRef.current; - const composer = composerRef.current; - if ( - !sessionId || - !composer || - navSelectionRef.current.section !== 'sessions' - ) { - return undefined; - } - return { - isCurrent: () => - activeIdRef.current === sessionId && - navSelectionRef.current.section === 'sessions' && - composerRef.current === composer, - append: (text: string) => composer.appendText(text), - }; - }, []); const moduleHub = useModuleHubController({ selection: navSelection, selectModule: setNavSelection, ...(projectCapabilities.viewClientPath ? { openSkillsFolder } : {}), useSkillInChat, - openSession: (sessionId) => openSessionInChatRef.current(sessionId), - appendComposerText: (text) => composerRef.current?.appendText(text), - captureActiveComposerClaim, + ...(newChatModel && taskEntry.selectors.projectPath && taskEntry.selectors.target + ? { + agentRunTemplateEffect: { + kind: 'agent_run', + execution: { + cwd: taskEntry.selectors.projectPath, + projectId: taskEntry.selectors.target.projectId, + llmConnectionId: newChatModel.llmConnectionId, + llmConnectionSlug: newChatModel.llmConnectionSlug, + model: newChatModel.model, + ...(newChatThinkingLevel ? { thinkingLevel: newChatThinkingLevel } : {}), + permissionMode: newTaskPermissionMode, + collaborationMode: newChatPlanModeActive ? 'plan' : 'agent', + orchestrationMode: newChatOrchestrationMode, + }, + } satisfies Extract, + } + : {}), }); refreshProjectSkillsRef.current = moduleHub.commands.refreshProjectSkills; const workHubProjectsRef = useRef(projects); @@ -2690,9 +2688,6 @@ function AppShellContent({ openSkillsFolder, openWorkspaceFolder, refreshConnections: defaultHostConnections.refreshConnections, - copyTodayDailyReview: moduleHub.commands.copyTodayDailyReview, - pasteTodayDailyReview: moduleHub.commands.pasteTodayDailyReview, - saveTodayDailyReview: moduleHub.commands.saveTodayDailyReview, setNavSelection, setPermissionMode, setThemePref, @@ -2701,9 +2696,7 @@ function AppShellContent({ const agentsView = navSelection.section === 'automations' - ? navSelection.module === 'daily-review' - ? 'daily-review' - : 'cron' + ? 'cron' : navSelection.section === 'extensions' ? navSelection.module : 'im_hub'; @@ -3291,10 +3284,6 @@ function AppShellContent({ settingsProviderCatalogOpen={settingsProviderCatalogOpen} settingsConnectionDetailSlug={settingsConnectionDetailSlug} settingsCreateProviderType={settingsCreateProviderType} - onOpenDailyReview={() => { - closeSettings(); - setNavSelection({ section: 'automations', module: 'daily-review' }); - }} onOpenKeyboardHelp={openHelp} onOpenSettingsSession={(sessionId) => { closeSettings(); diff --git a/apps/desktop/src/renderer/command-palette-commands.ts b/apps/desktop/src/renderer/command-palette-commands.ts index b9a020bec0..04bd6d4e95 100644 --- a/apps/desktop/src/renderer/command-palette-commands.ts +++ b/apps/desktop/src/renderer/command-palette-commands.ts @@ -25,7 +25,6 @@ import { Blocks, - CalendarDays, Clock, Clipboard, Download, @@ -94,12 +93,6 @@ export function buildCommandList(args: { * durable archive without the clipboard detour. */ onSaveActiveConversationToFile?(): Promise | void; - /** - * PR-CMD-PALETTE-COPY-DAILY-REVIEW-0: copy today's Daily Review - * as Markdown from anywhere via ⌘K. Same Markdown formatter - * `` uses; renderer wires the bridge. - */ - onCopyTodayDailyReview?(): Promise | void; /** * PR-CMD-PALETTE-OPEN-MEMORY-0: open the local MEMORY.md file in * the OS default editor from anywhere via ⌘K. The renderer wires @@ -115,19 +108,6 @@ export function buildCommandList(args: { */ onSetPermissionMode?(mode: ChatDefaultPermissionMode): Promise | void; activePermissionMode?: PermissionMode; - /** - * PR-CMD-PALETTE-PASTE-DAILY-REVIEW-0: fetch today's review and - * paste the Markdown into the composer instead of the clipboard. - * Useful when the user wants to ask the model "summarize my day" - * without leaving the chat. - */ - onPasteTodayDailyReviewIntoComposer?(): Promise | void; - /** - * PR-DAILY-REVIEW-EXPORT-FILE-0: save today's review as a Markdown - * file via the native save dialog. Persistent archive without - * round-tripping the clipboard. - */ - onSaveTodayDailyReviewToFile?(): Promise | void; /** Copy redacted Desktop and active Runtime Host diagnostics for issue reports. */ onCopyDiagnostics?(): Promise | void; /** @@ -275,14 +255,6 @@ export function buildCommandList(args: { keywords: [...copy.staticKeywords['nav:mcp']], run: () => select({ section: 'extensions', module: 'mcp' }), }); - cmds.push({ - id: 'nav:daily-review', - kind: 'action', - ...staticCopy('nav:daily-review'), - Icon: CalendarDays, - keywords: [...copy.staticKeywords['nav:daily-review']], - run: () => select({ section: 'automations', module: 'daily-review' }), - }); } // One palette command per Settings section so ⌘K → label lands the user @@ -353,36 +325,6 @@ export function buildCommandList(args: { run: () => args.onSaveActiveConversationToFile!(), }); } - if (args.onCopyTodayDailyReview) { - cmds.push({ - id: 'diag:copy-today-daily-review', - kind: 'action', - ...staticCopy('diag:copy-today-daily-review'), - Icon: CalendarDays, - keywords: [...copy.staticKeywords['diag:copy-today-daily-review']], - run: () => args.onCopyTodayDailyReview!(), - }); - } - if (args.onPasteTodayDailyReviewIntoComposer && args.activeSessionId) { - cmds.push({ - id: 'diag:paste-today-daily-review', - kind: 'action', - ...staticCopy('diag:paste-today-daily-review'), - Icon: CalendarDays, - keywords: [...copy.staticKeywords['diag:paste-today-daily-review']], - run: () => args.onPasteTodayDailyReviewIntoComposer!(), - }); - } - if (args.onSaveTodayDailyReviewToFile) { - cmds.push({ - id: 'diag:save-today-daily-review', - kind: 'action', - ...staticCopy('diag:save-today-daily-review'), - Icon: CalendarDays, - keywords: [...copy.staticKeywords['diag:save-today-daily-review']], - run: () => args.onSaveTodayDailyReviewToFile!(), - }); - } if (args.onCopyDiagnostics) { cmds.push({ id: 'diag:copy-diagnostics', diff --git a/apps/desktop/src/renderer/daily-review-actions.ts b/apps/desktop/src/renderer/daily-review-actions.ts deleted file mode 100644 index 02bdc38119..0000000000 --- a/apps/desktop/src/renderer/daily-review-actions.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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 { DailyReviewArchive } from '@maka/core/daily-review'; -import type { UiLocale } from '@maka/core/ui-locale'; -import { dailyReviewArchiveId } from '@maka/core/daily-review'; - -import { generalizedErrorMessage, generalizedErrorMessageChinese } from '@maka/core/redaction'; - -export function dailyReviewExportDefaultName( - input: Pick, -): string { - return `maka-daily-review-${dailyReviewArchiveId(input.day, input.range)}.md`; -} - -export function dailyReviewActionErrorMessage(error: unknown, fallback: string, locale: UiLocale): string { - return locale === 'zh' ? generalizedErrorMessageChinese(error, fallback) : generalizedErrorMessage(error, fallback); -} diff --git a/apps/desktop/src/renderer/features/module-hub/README.md b/apps/desktop/src/renderer/features/module-hub/README.md index f93eb29dfc..9fb4d42db1 100644 --- a/apps/desktop/src/renderer/features/module-hub/README.md +++ b/apps/desktop/src/renderer/features/module-hub/README.md @@ -27,9 +27,7 @@ Automations: - Scheduled Tasks projection, mutations, due/change subscriptions, and the create-dialog request nonce; - the keep-system-awake client setting shown by Scheduled Tasks; -- Daily Review page bridge, page actions, and Command Palette commands; -- selection and header composition for Skills, MCP, Scheduled Tasks, and Daily - Review. +- selection and header composition for Skills, MCP, and Scheduled Tasks. `AppShell` still owns top-level `NavSelection`, module-memory persistence, Session/Project navigation, and the Composer. Those capabilities cross the @@ -69,12 +67,6 @@ only selects and mounts that leaf; moving MCP internals is a separate change. - Keep-awake reads, external updates, and writes share a generation so a slow completion cannot overwrite newer confirmed settings; failed writes still reject for the panel's optimistic revert. -- The Daily Review page bridge is stable for one services/locale pair. Page - feedback is live-surface fenced, while Command Palette commands remain usable - off-page. -- Daily Review paste captures the active Composer before its first await and - validates the Session, navigation owner, and Composer handle before append and - feedback. - Opening Scheduled Task creation selects the page and increments the request nonce; the page acknowledgement resets it to zero. diff --git a/apps/desktop/src/renderer/features/module-hub/controller/module-hub-route.ts b/apps/desktop/src/renderer/features/module-hub/controller/module-hub-route.ts index 156a0e139b..965d83d719 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/module-hub-route.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/module-hub-route.ts @@ -23,7 +23,6 @@ export type ModuleHubHostRoute = | 'skills' | 'mcp' | 'scheduled-tasks' - | 'daily-review' | null; export function resolveModuleHubHostRoute( diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts deleted file mode 100644 index 64b68812c9..0000000000 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts +++ /dev/null @@ -1,448 +0,0 @@ -/* - * 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 { useMemo, useRef } from 'react'; -import type { - DailyReviewArchive, - DailyReviewArchiveSummary, - DailyReviewRange, - DailyReviewSummary, -} from '@maka/core/daily-review'; -import type { UiLocale } from '@maka/core/ui-locale'; -import { - formatDailyReviewMarkdown, - type DailyReviewMarkdownActionInput, - useMountedRef, -} from '@maka/ui'; -import { - dailyReviewActionErrorMessage, - dailyReviewExportDefaultName, -} from '../../../daily-review-actions.js'; -import { getShellCopy } from '../../../locales/shell-copy.js'; -import { getShellRemainingCopy } from '../../../locales/shell-remaining-copy.js'; -import type { ModuleHubRuntimeHostRef, ModuleHubServices } from '../ports.js'; -import { - defaultRuntimeHostDiagnosticTarget, - defaultRuntimeHostOperationHost, - isDefaultRuntimeHostCurrent, - runOnDefaultRuntimeHost, -} from './default-runtime-host.js'; - -type DailyReviewFeedbackOptions = { - readonly shouldShowFeedback?: () => boolean; -}; - -export interface ModuleHubToastApi { - success(title: string, description?: string): void; - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: { sessionId: string } | { profileId: string }, - ): void; -} - -export interface ActiveComposerClaim { - /** True only while the same Session, navigation owner and composer still own this claim. */ - isCurrent(): boolean; - /** Appends to the composer that was captured with the claim. */ - append(text: string): void; -} - -/** Structural equivalent of the UI bridge; kept here so @maka/ui stays leaf-only. */ -export interface DailyReviewBridge { - fetchDay(offsetDays: number, daySpan?: number): Promise; - runOnce?(input: { - range: DailyReviewRange; - offsetDays?: number; - }): Promise<{ archiveId: string }>; - listArchives?(): Promise; - getArchive?(archiveId: string): Promise; -} - -export interface DailyReviewController { - readonly bridge: DailyReviewBridge; - copyMarkdown( - input: DailyReviewMarkdownActionInput, - options?: DailyReviewFeedbackOptions, - ): Promise; - appendMarkdown(input: DailyReviewMarkdownActionInput): void; - saveMarkdown( - input: DailyReviewMarkdownActionInput, - options?: DailyReviewFeedbackOptions, - ): Promise; - copyToday(): Promise; - pasteToday(): Promise; - saveToday(): Promise; -} - -export interface UseDailyReviewControllerInput { - readonly services: ModuleHubServices; - readonly uiLocale: UiLocale; - readonly toastApi: ModuleHubToastApi; - readonly appendComposerText: (text: string) => void; - readonly captureActiveComposerClaim: () => ActiveComposerClaim | undefined; - readonly isDailyReviewSurfaceActive: () => boolean; -} - -class StaleDailyReviewHostError extends Error { - constructor() { - super('The default Runtime Host changed while loading Daily Review'); - this.name = 'StaleDailyReviewHostError'; - } -} - -async function operationFailureIsCurrent( - services: ModuleHubServices, - error: unknown, -): Promise { - if (error instanceof StaleDailyReviewHostError) return false; - const host = defaultRuntimeHostOperationHost(error); - return host ? isDefaultRuntimeHostCurrent(services.runtimeHosts, host) : true; -} - -/** - * Runs a Daily Review read against the default Host and refuses to expose a - * result after that Host changes. One retry absorbs the normal profile-switch - * race without making the page bridge identity depend on Host state. - */ -async function readCurrentDefaultHost( - services: ModuleHubServices, - operation: (host: ModuleHubRuntimeHostRef) => Promise, -): Promise { - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - const result = await runOnDefaultRuntimeHost( - services.runtimeHosts, - operation, - ); - if ( - await isDefaultRuntimeHostCurrent(services.runtimeHosts, result.host) - ) { - return result.value; - } - } catch (error) { - if (await operationFailureIsCurrent(services, error)) throw error; - } - } - throw new StaleDailyReviewHostError(); -} - -export function createDailyReviewBridge( - services: ModuleHubServices, - locale: UiLocale, -): DailyReviewBridge { - const copy = getShellRemainingCopy(locale).dailyReview; - return { - async fetchDay(offsetDays: number, daySpan?: number) { - return readCurrentDefaultHost(services, async (host) => { - const result = await services.dailyReview.day( - offsetDays, - daySpan, - host, - ); - if (!result.ok) throw new Error(result.error.message); - return result.data; - }); - }, - runOnce(input) { - return services.dailyReview.runOnce(input); - }, - listArchives() { - return services.dailyReview.listArchives(); - }, - async getArchive(archiveId: string) { - const archive = await services.dailyReview.getArchive(archiveId); - if (!archive) throw new Error(copy.archiveMissing); - return archive; - }, - }; -} - -export function useDailyReviewController( - input: UseDailyReviewControllerInput, -): DailyReviewController { - const mountedRef = useMountedRef(); - const inputRef = useRef(input); - inputRef.current = input; - - const bridge = useMemo( - () => createDailyReviewBridge(input.services, input.uiLocale), - [input.services, input.uiLocale], - ); - - return useMemo(() => { - const copy = getShellCopy(input.uiLocale).commandActions; - const showIfMounted = (predicate: () => boolean = () => true) => - mountedRef.current && predicate(); - const shouldReportOperationFailure = async ( - error: unknown, - predicate: () => boolean = () => true, - ) => { - if (!showIfMounted(predicate)) return false; - if (!(await operationFailureIsCurrent(input.services, error))) - return false; - return showIfMounted(predicate); - }; - - async function copyMarkdown( - markdownInput: DailyReviewMarkdownActionInput, - options: DailyReviewFeedbackOptions = {}, - ) { - const shouldShowFeedback = options.shouldShowFeedback ?? (() => true); - const shouldShowPageFeedback = () => - inputRef.current.isDailyReviewSurfaceActive() && shouldShowFeedback(); - try { - await input.services.clipboard.writeText(markdownInput.markdown); - if (showIfMounted(shouldShowPageFeedback)) { - inputRef.current.toastApi.success( - copy.reviewCopied(markdownInput.label), - copy.reviewSummary( - markdownInput.totals.sessionCount, - markdownInput.totals.requestCount, - ), - ); - } - } catch (error) { - if (showIfMounted(shouldShowPageFeedback)) { - inputRef.current.toastApi.error( - copy.copyFailedTitle, - dailyReviewActionErrorMessage( - error, - copy.clipboardDenied, - input.uiLocale, - ), - ); - } - } - } - - function appendMarkdown(markdownInput: DailyReviewMarkdownActionInput) { - inputRef.current.appendComposerText(markdownInput.markdown); - if (showIfMounted(inputRef.current.isDailyReviewSurfaceActive)) { - inputRef.current.toastApi.success( - copy.reviewPasted(markdownInput.label), - copy.reviewSummary( - markdownInput.totals.sessionCount, - markdownInput.totals.requestCount, - ), - ); - } - } - - async function persistMarkdown( - markdownInput: DailyReviewMarkdownActionInput, - shouldShowFeedback: () => boolean, - ) { - try { - const result = await input.services.dailyReview.saveMarkdownToFile({ - markdown: markdownInput.markdown, - defaultName: dailyReviewExportDefaultName({ - range: markdownInput.range, - day: markdownInput.day, - }), - }); - if (!showIfMounted(shouldShowFeedback)) return; - if (result.ok) { - inputRef.current.toastApi.success( - copy.reviewSaved(markdownInput.label), - copy.reviewSummary( - markdownInput.totals.sessionCount, - markdownInput.totals.requestCount, - ), - ); - } else if (result.reason === 'invalid_input') { - inputRef.current.toastApi.error( - copy.saveFailedTitle, - copy.invalidExport, - ); - } else if (result.reason === 'write_failed') { - inputRef.current.toastApi.error( - copy.saveFailedTitle, - copy.writeFailed, - ); - } - // A canceled save dialog deliberately has no feedback. - } catch (error) { - if (showIfMounted(shouldShowFeedback)) { - inputRef.current.toastApi.error( - copy.saveFailedTitle, - dailyReviewActionErrorMessage( - error, - copy.reviewSaveFallback, - input.uiLocale, - ), - ); - } - } - } - - async function saveMarkdown( - markdownInput: DailyReviewMarkdownActionInput, - options: DailyReviewFeedbackOptions = {}, - ) { - const shouldShowFeedback = options.shouldShowFeedback ?? (() => true); - await persistMarkdown( - markdownInput, - () => - inputRef.current.isDailyReviewSurfaceActive() && shouldShowFeedback(), - ); - } - - async function readToday() { - return readCurrentDefaultHost(input.services, async (host) => { - const result = await input.services.dailyReview.day(0, 1, host); - if (!result.ok) throw new Error(result.error.message); - return result.data; - }); - } - - async function copyToday() { - let summary; - try { - summary = await readToday(); - } catch (error) { - if (await shouldReportOperationFailure(error)) { - inputRef.current.toastApi.error( - copy.copyFailedTitle, - dailyReviewActionErrorMessage( - error, - copy.reviewCopyFallback, - input.uiLocale, - ), - undefined, - defaultRuntimeHostDiagnosticTarget(error), - ); - } - return; - } - - try { - const markdown = formatDailyReviewMarkdown( - summary, - copy.today, - input.uiLocale, - ); - await input.services.clipboard.writeText(markdown); - if (showIfMounted()) { - inputRef.current.toastApi.success( - copy.reviewCopiedTitle, - copy.reviewSummary( - summary.totals.sessionCount, - summary.totals.requestCount, - ), - ); - } - } catch (error) { - if (showIfMounted()) { - inputRef.current.toastApi.error( - copy.copyFailedTitle, - dailyReviewActionErrorMessage( - error, - copy.clipboardDenied, - input.uiLocale, - ), - ); - } - } - } - - async function pasteToday() { - // Capture before the first await. The claim owns both the validity check - // and append target, so a Session/nav/composer switch cannot redirect a - // late Daily Review result into the new owner. - const claim = inputRef.current.captureActiveComposerClaim(); - if (!claim) return; - try { - const summary = await readToday(); - if (!claim.isCurrent() || !showIfMounted()) return; - claim.append( - formatDailyReviewMarkdown(summary, copy.today, input.uiLocale), - ); - if (!claim.isCurrent() || !showIfMounted()) return; - inputRef.current.toastApi.success( - copy.reviewPastedTitle, - copy.reviewSummary( - summary.totals.sessionCount, - summary.totals.requestCount, - ), - ); - } catch (error) { - if (await shouldReportOperationFailure(error, claim.isCurrent)) { - inputRef.current.toastApi.error( - copy.pasteFailedTitle, - dailyReviewActionErrorMessage( - error, - copy.reviewUnavailable, - input.uiLocale, - ), - undefined, - defaultRuntimeHostDiagnosticTarget(error), - ); - } - } - } - - async function saveToday() { - let summary; - try { - summary = await readToday(); - } catch (error) { - if (await shouldReportOperationFailure(error)) { - inputRef.current.toastApi.error( - copy.saveFailedTitle, - dailyReviewActionErrorMessage( - error, - copy.reviewUnavailable, - input.uiLocale, - ), - undefined, - defaultRuntimeHostDiagnosticTarget(error), - ); - } - return; - } - const markdown = formatDailyReviewMarkdown( - summary, - copy.today, - input.uiLocale, - ); - await persistMarkdown( - { - day: summary.day, - range: 1, - totals: summary.totals, - markdown, - label: copy.today, - }, - () => true, - ); - } - - return { - bridge, - copyMarkdown, - appendMarkdown, - saveMarkdown, - copyToday, - pasteToday, - saveToday, - }; - }, [bridge, input.services, input.uiLocale, mountedRef]); -} diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts index 8ebfce96b5..08d6d4c60a 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts @@ -18,16 +18,11 @@ */ import { useEffect, useMemo, useRef } from 'react'; -import type { ScheduledTask } from '@maka/core/scheduled-task'; +import type { ScheduledTask, ScheduledTaskEffect } from '@maka/core/scheduled-task'; import type { NavSelection } from '@maka/ui'; import { useToast, useUiLocale } from '@maka/ui'; import { useModuleHubServices } from '../services-context.js'; import { startModuleHubLifecycle } from './module-hub-lifecycle.js'; -import { - useDailyReviewController, - type ActiveComposerClaim, - type DailyReviewController, -} from './use-daily-review-controller.js'; import { useKeepSystemAwakeController, type KeepSystemAwakeController, @@ -47,8 +42,7 @@ export interface ModuleHubHostModel { readonly skills: SkillsHostModel; readonly scheduledTasks: ScheduledTasksController; readonly keepSystemAwake: KeepSystemAwakeController; - readonly dailyReview: DailyReviewController; - readonly openSession: (sessionId: string) => void; + readonly agentRunTemplateEffect?: Extract; } export interface ModuleHubController { @@ -56,9 +50,6 @@ export interface ModuleHubController { readonly commands: { refreshProjectSkills(): Promise; openScheduledTaskCreate(): void; - copyTodayDailyReview(): Promise; - pasteTodayDailyReview(): Promise; - saveTodayDailyReview(): Promise; }; readonly selectors: { readonly scheduledTasks: readonly ScheduledTask[]; @@ -72,9 +63,7 @@ export interface UseModuleHubControllerInput { readonly selectModule: (selection: NavSelection) => void; readonly openSkillsFolder?: () => void | Promise; readonly useSkillInChat: (skillId: string, skillName: string) => void; - readonly openSession: (sessionId: string) => void; - readonly appendComposerText: (text: string) => void; - readonly captureActiveComposerClaim: () => ActiveComposerClaim | undefined; + readonly agentRunTemplateEffect?: Extract; } /** Public ownership boundary for every Module Hub surface except the MCP leaf. */ @@ -101,19 +90,6 @@ export function useModuleHubController( selectModule: input.selectModule, }); const keepSystemAwake = useKeepSystemAwakeController(services); - const selectionRef = useRef(input.selection); - selectionRef.current = input.selection; - const dailyReview = useDailyReviewController({ - services, - uiLocale, - toastApi, - appendComposerText: input.appendComposerText, - captureActiveComposerClaim: input.captureActiveComposerClaim, - isDailyReviewSurfaceActive: () => - selectionRef.current.section === 'automations' && - selectionRef.current.module === 'daily-review', - }); - const refreshProjectSkillsRef = useRef(skills.refreshProjectSkills); const refreshScheduledTasksRef = useRef(scheduledTasks.refresh); refreshProjectSkillsRef.current = skills.refreshProjectSkills; @@ -135,15 +111,11 @@ export function useModuleHubController( skills: skills.host, scheduledTasks, keepSystemAwake, - dailyReview, - openSession: input.openSession, + agentRunTemplateEffect: input.agentRunTemplateEffect, }, commands: { refreshProjectSkills: skills.refreshProjectSkills, openScheduledTaskCreate: scheduledTasks.openCreate, - copyTodayDailyReview: dailyReview.copyToday, - pasteTodayDailyReview: dailyReview.pasteToday, - saveTodayDailyReview: dailyReview.saveToday, }, selectors: { scheduledTasks: scheduledTasks.scheduledTasks, @@ -151,10 +123,9 @@ export function useModuleHubController( }, }), [ - dailyReview, - input.openSession, input.selectModule, input.selection, + input.agentRunTemplateEffect, keepSystemAwake, scheduledTasks, skills.host, diff --git a/apps/desktop/src/renderer/features/module-hub/index.ts b/apps/desktop/src/renderer/features/module-hub/index.ts index a99b752aff..ff14e4fe6e 100644 --- a/apps/desktop/src/renderer/features/module-hub/index.ts +++ b/apps/desktop/src/renderer/features/module-hub/index.ts @@ -19,8 +19,5 @@ export { useModuleHubController } from './controller/use-module-hub-controller.js'; export { ModuleHubServicesProvider } from './services-context.js'; -export type { - ModuleHubClipboardService, - ModuleHubServices, -} from './ports.js'; +export type { ModuleHubServices } from './ports.js'; export { ModuleHubHost } from './ui/module-hub-host.js'; diff --git a/apps/desktop/src/renderer/features/module-hub/ports.ts b/apps/desktop/src/renderer/features/module-hub/ports.ts index 7abb633628..485fc8e1c1 100644 --- a/apps/desktop/src/renderer/features/module-hub/ports.ts +++ b/apps/desktop/src/renderer/features/module-hub/ports.ts @@ -17,13 +17,6 @@ * under the License. */ -import type { - DailyReviewArchive, - DailyReviewArchiveSummary, - DailyReviewRange, - DailyReviewSummary, -} from '@maka/core/daily-review'; -import type { Result } from '@maka/core/result'; import type { CreateScheduledTaskInput, ScheduledTask, @@ -209,38 +202,10 @@ export interface ModuleHubClientSettingsService { subscribeChanges(handler: () => void): ModuleHubUnsubscribe; } -export interface ModuleHubDailyReviewService { - day( - offsetDays: number, - daySpan: number | undefined, - host: ModuleHubRuntimeHostRef, - ): Promise>; - runOnce(input: { - range: DailyReviewRange; - offsetDays?: number; - modelKey?: string; - }): Promise<{ archiveId: string }>; - listArchives(): Promise; - getArchive(archiveId: string): Promise; - saveMarkdownToFile(input: { - markdown: string; - defaultName: string; - }): Promise< - | { ok: true; path: string } - | { ok: false; reason: 'canceled' | 'write_failed' | 'invalid_input' } - >; -} - -export interface ModuleHubClipboardService { - writeText(text: string): Promise; -} - /** Environment capabilities owned by the Module Hub feature slice. */ export interface ModuleHubServices { runtimeHosts: ModuleHubRuntimeHostsService; skills: ModuleHubSkillsService; scheduledTasks: ModuleHubScheduledTasksService; clientSettings: ModuleHubClientSettingsService; - dailyReview: ModuleHubDailyReviewService; - clipboard: ModuleHubClipboardService; } diff --git a/apps/desktop/src/renderer/features/module-hub/testing.ts b/apps/desktop/src/renderer/features/module-hub/testing.ts index ca504b3ce7..0cc7fbc7dc 100644 --- a/apps/desktop/src/renderer/features/module-hub/testing.ts +++ b/apps/desktop/src/renderer/features/module-hub/testing.ts @@ -26,11 +26,6 @@ export type { ModuleHubServices } from "./ports.js"; export { startModuleHubLifecycle } from "./controller/module-hub-lifecycle.js"; export { resolveModuleHubHostRoute } from "./controller/module-hub-route.js"; export type { ModuleHubHostModel } from "./controller/use-module-hub-controller.js"; -export { - createDailyReviewBridge, - useDailyReviewController, - type DailyReviewController, -} from "./controller/use-daily-review-controller.js"; export { useKeepSystemAwakeController, type KeepSystemAwakeController, @@ -100,18 +95,6 @@ export function createFakeModuleHubHostModel( keepSystemAwake: undefined, setKeepSystemAwake: async () => undefined, }, - dailyReview: { - bridge: { - fetchDay: async () => notConfigured("dailyReview.fetchDay"), - }, - copyMarkdown: async () => undefined, - appendMarkdown: () => undefined, - saveMarkdown: async () => undefined, - copyToday: async () => undefined, - pasteToday: async () => undefined, - saveToday: async () => undefined, - }, - openSession: () => undefined, ...overrides, }; } @@ -159,17 +142,6 @@ export function createFakeModuleHubServices( setKeepSystemAwake: async (next) => next, subscribeChanges: noopSubscription, }, - dailyReview: { - day: async () => notConfigured("dailyReview.day"), - runOnce: async () => notConfigured("dailyReview.runOnce"), - listArchives: async () => [], - getArchive: async () => null, - saveMarkdownToFile: async () => - notConfigured("dailyReview.saveMarkdownToFile"), - }, - clipboard: { - writeText: async () => undefined, - }, ...overrides, }; } diff --git a/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx index 3b437d3a4f..dc51a3c648 100644 --- a/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx +++ b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx @@ -18,7 +18,6 @@ */ import { - DailyReviewPage, ModuleHubSelector, ScheduledTasksPage, SkillsPage, @@ -43,7 +42,6 @@ export function ModuleHubHost(props: { model: ModuleHubHostModel }) { subtitle: copy.extensions.description, badge: ( model.selectModule({ section: 'extensions', module }) @@ -65,55 +63,33 @@ export function ModuleHubHost(props: { model: ModuleHubHostModel }) { ); } - if (route === 'scheduled-tasks' || route === 'daily-review') { + if (route === 'scheduled-tasks') { const header: ModuleHubHeader = { title: copy.automations.title, subtitle: copy.automations.description, - badge: ( - - model.selectModule({ section: 'automations', module }) - } - /> - ), + badge: null, }; - if (route === 'scheduled-tasks') { - const keepAwake = model.keepSystemAwake; - const tasks = model.scheduledTasks; - return ( - - ); - } - const dailyReview = model.dailyReview; + const keepAwake = model.keepSystemAwake; + const tasks = model.scheduledTasks; return ( - ); } diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 918f95f605..8ebe8db91e 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -367,7 +367,7 @@ export interface DesktopConversationCopy { * The call-kind tables are typed against the core union, so a kind added to the * runtime fails this file at compile time instead of reaching a Chinese panel * as `daily_review`. The table also labels decode-only historical kinds such as - * `semantic_compact`; the Runtime no longer emits them. + * `semantic_compact` and `daily_review`; the Runtime no longer emits them. */ type CallKindCopy = Record, string>; diff --git a/apps/desktop/src/renderer/locales/permission-center-copy.ts b/apps/desktop/src/renderer/locales/permission-center-copy.ts index 3a2e4713ea..a8e72a7058 100644 --- a/apps/desktop/src/renderer/locales/permission-center-copy.ts +++ b/apps/desktop/src/renderer/locales/permission-center-copy.ts @@ -155,7 +155,7 @@ const PERMISSION_CENTER_COPY = { osPermissions: { accessibility: { label: 'Accessibility', purpose: 'Computer Use needs it to read window focus and simulate keyboard or mouse input.', impact: 'Computer Use · automated keyboard and mouse input' }, screen_recording: { label: 'Screen Recording', purpose: 'Computer Use needs it to read window contents; future screen activity recording will use it too.', impact: 'Computer Use · screenshot context' }, - notifications: { label: 'Notifications', purpose: 'System alerts use it for permission requests and completed reviews.', impact: 'Permission alerts · Daily Review completion' }, + notifications: { label: 'Notifications', purpose: 'System alerts use it for permission requests and Scheduled Task delivery.', impact: 'Permission alerts · Scheduled Task notifications' }, automation: { label: 'Automation (Apple Events)', purpose: 'Computer Use needs per-target authorization to control other apps.', impact: 'Computer Use · cross-app automation' }, }, osStates: { diff --git a/apps/desktop/src/renderer/locales/settings-daily-review-copy.ts b/apps/desktop/src/renderer/locales/settings-daily-review-copy.ts deleted file mode 100644 index 0c015bd453..0000000000 --- a/apps/desktop/src/renderer/locales/settings-daily-review-copy.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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 { UiCatalog, UiLocale } from '@maka/core/ui-locale'; - -export type DailyReviewSettingsCopy = { - defaultModel: string; - saveFailed: string; - aria: string; - unavailable: string; - loadFailed(error: string): string; - scheduleTitle: string; - scheduleDescription: string; - enabled: string; - enabledHelp: string; - executeTime: string; - executeTimeHelp: string; - executeTimePlaceholder: string; - executeTimeInvalid: string; - analysisTitle: string; - analysisDescription: string; - model: string; - modelHelp: string; -}; - -const SETTINGS_DAILY_REVIEW_COPY = { - zh: { - defaultModel: '跟随任务默认', - saveFailed: '保存每日回顾设置失败', - aria: '每日回顾', - unavailable: '当前版本无法读取每日回顾设置。', - loadFailed: (error) => `读取每日回顾设置失败:${error}`, - scheduleTitle: '定时分析', - scheduleDescription: '按本地时间自动分析前一个完整自然日的活动。', - enabled: '启用定时分析', - enabledHelp: '每天生成一份昨日活动分析。', - executeTime: '执行时间', - executeTimeHelp: '使用 24 小时制的本地时间。', - executeTimePlaceholder: 'HH:mm', - executeTimeInvalid: '请输入 24 小时制时间,例如 08:00。', - analysisTitle: '分析', - analysisDescription: '选择用于生成固定结构报告的模型。', - model: '分析模型', - modelHelp: '未指定时跟随当前任务的默认模型。', - }, - en: { - defaultModel: 'Follow task default', - saveFailed: 'Failed to save Daily Review settings', - aria: 'Daily Review', - unavailable: 'Daily Review settings are unavailable in this build.', - loadFailed: (error) => `Failed to load Daily Review settings: ${error}`, - scheduleTitle: 'Schedule', - scheduleDescription: 'Analyze the previous complete local day automatically.', - enabled: 'Enable scheduled analysis', - enabledHelp: 'Generate an analysis of yesterday’s activity each day.', - executeTime: 'Run time', - executeTimeHelp: 'Uses your local time in 24-hour format.', - executeTimePlaceholder: 'HH:mm', - executeTimeInvalid: 'Enter a 24-hour time, for example 08:00.', - analysisTitle: 'Analysis', - analysisDescription: 'Choose the model used to generate the fixed report structure.', - model: 'Analysis model', - modelHelp: 'Follows the current task default when unspecified.', - }, -} satisfies UiCatalog; - -export function getDailyReviewSettingsCopy(locale: UiLocale): DailyReviewSettingsCopy { - return SETTINGS_DAILY_REVIEW_COPY[locale]; -} diff --git a/apps/desktop/src/renderer/locales/settings-navigation-copy.ts b/apps/desktop/src/renderer/locales/settings-navigation-copy.ts index 0bfb6ba761..8a7cbd248b 100644 --- a/apps/desktop/src/renderer/locales/settings-navigation-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-navigation-copy.ts @@ -44,7 +44,6 @@ const SETTINGS_NAVIGATION_COPY_BY_LOCALE = { 'archived-tasks': { label: '已归档任务', description: '恢复或彻底删除已归档的任务。' }, 'import-tasks': { label: '导入任务', description: '把本机其他 Agent 的对话记录转换成 Maka 任务。' }, memory: { label: '记忆', description: 'Maka 记住的内容,以及本地 MEMORY.md 文件。' }, - 'daily-review': { label: '每日回顾', description: '每天分析本机任务,生成摘要、遗漏提醒和建议。' }, 'bot-chat': { label: '远程接入', description: '通过 Telegram、飞书、微信等平台从其他设备与 Maka 对话。' }, search: { label: '联网搜索', description: '联网搜索供应商(如 Tavily)凭据与隐私边界。' }, data: { label: '数据', description: '本地工作区路径、备份与恢复。' }, @@ -70,7 +69,6 @@ const SETTINGS_NAVIGATION_COPY_BY_LOCALE = { 'archived-tasks': { label: 'Archived tasks', description: 'Restore or permanently delete archived tasks.' }, 'import-tasks': { label: 'Import tasks', description: 'Convert conversations from another local agent into Maka tasks.' }, memory: { label: 'Memory', description: 'What Maka remembers, and the local MEMORY.md file.' }, - 'daily-review': { label: 'Daily Review', description: 'Analyze local tasks for summaries, reminders, and suggestions.' }, 'bot-chat': { label: 'Remote Access', description: 'Chat with Maka from other devices through Telegram, Feishu, or WeChat.' }, search: { label: 'Web Search', description: 'Credentials and privacy boundaries for providers such as Tavily.' }, data: { label: 'Data', description: 'Local workspace paths, backup, and restore.' }, diff --git a/apps/desktop/src/renderer/locales/settings-shared-copy.ts b/apps/desktop/src/renderer/locales/settings-shared-copy.ts index 834619160d..38bf027e2b 100644 --- a/apps/desktop/src/renderer/locales/settings-shared-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-shared-copy.ts @@ -62,8 +62,6 @@ export type SettingsSharedCopy = { memoryDocumentHelp: string; memoryEntries: string; memoryEntriesHelp: string; - reviewSchedule: string; - reviewScheduleHelp: string; buildInfo: string; reference: string; }; @@ -106,8 +104,6 @@ const SETTINGS_SHARED_COPY_BY_LOCALE = { searchBehaviorHelp: '什么时候发起搜索,以及每次取回多少结果。', dataLocation: '数据位置', dataLocationHelp: '任务、设置、使用统计与凭据都以文件形式存放在本机的这个位置。', - reviewSchedule: '回顾计划', - reviewScheduleHelp: '每日回顾的生成时间与使用的模型。', buildInfo: '版本信息', reference: '参考', }, @@ -148,8 +144,6 @@ const SETTINGS_SHARED_COPY_BY_LOCALE = { searchBehaviorHelp: 'When a search runs, and how many results it returns.', dataLocation: 'Data location', dataLocationHelp: 'Tasks, settings, usage statistics, and credentials are stored as files in this location on your machine.', - reviewSchedule: 'Review schedule', - reviewScheduleHelp: 'When the daily review runs, and which model writes it.', buildInfo: 'Build info', reference: 'Reference', }, diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 855143e079..b2c988a9dd 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -44,15 +44,11 @@ export const STATIC_COMMAND_IDS = [ 'nav:automations', 'nav:skills', 'nav:mcp', - 'nav:daily-review', 'diag:open-workspace', 'diag:open-project-folder', 'diag:open-skills', 'diag:export-conversation', 'diag:save-conversation-file', - 'diag:copy-today-daily-review', - 'diag:paste-today-daily-review', - 'diag:save-today-daily-review', 'diag:copy-diagnostics', 'diag:test-network-proxy', 'diag:open-local-memory', @@ -90,7 +86,6 @@ const STATIC_COMMAND_KEYWORDS: Record = { 'nav:automations': ['automations', 'plan', 'task', 'schedule', 'cron', '定时任务', '计划', '提醒'], 'nav:skills': ['skills', '技能'], 'nav:mcp': ['mcp', 'server', 'tools', '扩展', '工具'], - 'nav:daily-review': ['daily', 'review', 'today', '每日', '回顾', '今天'], 'diag:open-workspace': ['workspace', 'folder', 'open', 'finder', '工作区', '文件夹', '目录'], 'diag:open-project-folder': ['project', 'folder', 'open', 'finder', '项目', '目录', '文件夹'], 'diag:open-skills': ['skills', 'folder', 'open', 'finder', '技能', '文件夹'], @@ -107,21 +102,6 @@ const STATIC_COMMAND_KEYWORDS: Record = { '导出', 'md', ], - 'diag:copy-today-daily-review': ['daily', 'review', 'today', 'copy', 'markdown', '今日', '回顾', '复制', '剪贴板'], - 'diag:paste-today-daily-review': ['daily', 'review', 'paste', 'composer', '今日', '回顾', '粘贴', '输入框'], - 'diag:save-today-daily-review': [ - 'daily', - 'review', - 'save', - 'file', - 'export', - 'markdown', - '今日', - '回顾', - '保存', - '文件', - '导出', - ], 'diag:copy-diagnostics': [ 'env', 'environment', @@ -234,16 +214,7 @@ type ShellCopy = { openFailedTitle: string; memoryOpenFallback: string; today: string; - reviewCopiedTitle: string; - reviewSummary(sessions: number, requests: number): string; - reviewCopyFallback: string; - reviewPastedTitle: string; - reviewCopied(label: string): string; - reviewPasted(label: string): string; - reviewSaved(label: string): string; - reviewSaveFallback: string; pasteFailedTitle: string; - reviewUnavailable: string; diagnosticsCopiedTitle: string; diagnosticsCopiedDescription: string; clipboardDenied: string; @@ -555,7 +526,6 @@ const ZH_STATIC_COMMANDS: Record = { 'nav:automations': { label: '侧栏 · 定时任务', group: '导航' }, 'nav:skills': { label: '打开 · 技能', group: '导航' }, 'nav:mcp': { label: '打开 · MCP', group: '导航' }, - 'nav:daily-review': { label: '打开 · 每日回顾', group: '导航' }, 'diag:open-workspace': { label: '打开工作区文件夹', hint: 'Finder', @@ -581,21 +551,6 @@ const ZH_STATIC_COMMANDS: Record = { hint: '用系统保存对话框', group: '诊断', }, - 'diag:copy-today-daily-review': { - label: '复制今日回顾为 Markdown', - hint: '复制到剪贴板', - group: '诊断', - }, - 'diag:paste-today-daily-review': { - label: '把今日回顾粘到 composer', - hint: '不进剪贴板', - group: '诊断', - }, - 'diag:save-today-daily-review': { - label: '保存今日回顾为 .md 文件', - hint: '用系统保存对话框', - group: '诊断', - }, 'diag:copy-diagnostics': { label: '复制诊断信息', hint: '⇧⌘D · 脱敏日志 · 仅写入剪贴板', @@ -651,7 +606,6 @@ const EN_STATIC_COMMANDS: Record = { 'nav:automations': { label: 'Sidebar · Automations', group: 'Navigation' }, 'nav:skills': { label: 'Open · Skills', group: 'Navigation' }, 'nav:mcp': { label: 'Open · MCP', group: 'Navigation' }, - 'nav:daily-review': { label: 'Open · Daily Review', group: 'Navigation' }, 'diag:open-workspace': { label: 'Open workspace folder', hint: 'Finder', @@ -677,21 +631,6 @@ const EN_STATIC_COMMANDS: Record = { hint: 'Use the system save dialog', group: 'Diagnostics', }, - 'diag:copy-today-daily-review': { - label: "Copy today's review as Markdown", - hint: 'Copy to clipboard', - group: 'Diagnostics', - }, - 'diag:paste-today-daily-review': { - label: "Paste today's review into the composer", - hint: 'Skip the clipboard', - group: 'Diagnostics', - }, - 'diag:save-today-daily-review': { - label: "Save today's review as an .md file", - hint: 'Use the system save dialog', - group: 'Diagnostics', - }, 'diag:copy-diagnostics': { label: 'Copy diagnostics', hint: '⇧⌘D · Redacted logs · clipboard only', @@ -719,7 +658,6 @@ const ZH_SETTINGS_SECTIONS: Record = { 'archived-tasks': '已归档任务', 'import-tasks': '导入任务', memory: '记忆', - 'daily-review': '每日回顾', 'bot-chat': '远程接入', search: '联网搜索', data: '数据', @@ -738,7 +676,6 @@ const EN_SETTINGS_SECTIONS: Record = { 'archived-tasks': 'Archived tasks', 'import-tasks': 'Import tasks', memory: 'Memory', - 'daily-review': 'Daily Review', 'bot-chat': 'Remote Access', search: 'Web Search', data: 'Data', @@ -860,16 +797,7 @@ const SHELL_COPY_BY_LOCALE = { openFailedTitle: '打开失败', memoryOpenFallback: '无法打开 MEMORY.md,请稍后重试。', today: '今天', - reviewCopiedTitle: '已复制今日回顾为 Markdown', - reviewSummary: (sessions: number, requests: number) => `${sessions} 个任务 · ${requests} 个请求`, - reviewCopyFallback: '今日回顾暂时不可用,或剪贴板被系统拒绝。', - reviewPastedTitle: '已追加今日回顾到输入框', - reviewCopied: (label: string) => `已复制${label}回顾`, - reviewPasted: (label: string) => `已追加${label}回顾到输入框`, - reviewSaved: (label: string) => `已保存${label}回顾`, - reviewSaveFallback: '保存每日回顾失败,请稍后重试。', pasteFailedTitle: '粘贴失败', - reviewUnavailable: '今日回顾暂时不可用,请稍后重试。', diagnosticsCopiedTitle: '已复制诊断信息', diagnosticsCopiedDescription: '检查内容后,可直接粘贴到问题报告', clipboardDenied: '剪贴板不可用或被系统拒绝', @@ -1385,16 +1313,7 @@ const SHELL_COPY_BY_LOCALE = { openFailedTitle: 'Open failed', memoryOpenFallback: 'MEMORY.md could not be opened. Try again later.', today: 'Today', - reviewCopiedTitle: "Today's review copied as Markdown", - reviewSummary: (sessions: number, requests: number) => `${sessions} tasks · ${requests} requests`, - reviewCopyFallback: "Today's review is unavailable, or the clipboard was denied.", - reviewPastedTitle: "Today's review added to the composer", - reviewCopied: (label: string) => `${label} review copied`, - reviewPasted: (label: string) => `${label} review added to the composer`, - reviewSaved: (label: string) => `${label} review saved`, - reviewSaveFallback: 'The Daily Review could not be saved. Try again later.', pasteFailedTitle: 'Paste failed', - reviewUnavailable: "Today's review is temporarily unavailable. Try again later.", diagnosticsCopiedTitle: 'Diagnostics copied', diagnosticsCopiedDescription: 'Review the contents, then paste them into the issue report', clipboardDenied: 'The clipboard is unavailable or was denied', diff --git a/apps/desktop/src/renderer/locales/shell-remaining-copy.ts b/apps/desktop/src/renderer/locales/shell-remaining-copy.ts index 75ace87c6e..4a2c515310 100644 --- a/apps/desktop/src/renderer/locales/shell-remaining-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-remaining-copy.ts @@ -61,15 +61,6 @@ const zhCopy = { deleteFailed: "删除计划失败", deleteFallback: "删除定时任务失败,请稍后重试。", }, - dailyReview: { - yesterday: "昨天", - today: "今天", - followSettings: "跟随设置", - unavailable: "每日回顾生成暂不可用", - historyUnavailable: "每日回顾历史暂不可用", - archiveMissing: "找不到每日回顾报告", - settingsUnavailable: "每日回顾设置暂不可用", - }, connections: { refreshFailed: "刷新模型连接失败", refreshFallback: "模型连接暂时无法刷新,请稍后重试。", @@ -141,15 +132,6 @@ const enCopy: ShellRemainingCopy = { deleteFailed: "Failed to delete task", deleteFallback: "The scheduled task could not be deleted. Try again later.", }, - dailyReview: { - yesterday: "Yesterday", - today: "Today", - followSettings: "Follow Settings", - unavailable: "Daily Review generation is unavailable", - historyUnavailable: "Daily Review history is unavailable", - archiveMissing: "Daily Review report not found", - settingsUnavailable: "Daily Review settings are unavailable", - }, connections: { refreshFailed: "Failed to refresh model connections", refreshFallback: diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index 6d974afe36..58d919aa37 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -900,7 +900,7 @@ /* === reading measure (#520 PR4 item 16, DESIGN.md §7) ================== One reading column, and the only one: the transcript turn, the composer, - header notices, hero, plan mode, WorkHub, the Daily Review report and the + header notices, hero, plan mode, WorkHub, report Artifacts and the Artifact Preview all stop at this edge, so a turn and the box you answer it in line up. diff --git a/apps/desktop/src/renderer/model-catalog-choices.ts b/apps/desktop/src/renderer/model-catalog-choices.ts index cb7f1fe809..e9cd8cdf4f 100644 --- a/apps/desktop/src/renderer/model-catalog-choices.ts +++ b/apps/desktop/src/renderer/model-catalog-choices.ts @@ -20,19 +20,11 @@ import { buildConnectionModelCatalogEntries, type ModelCatalogEntry, - type SavedModelChoice, } from '@maka/core/model-catalog'; import { CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, - PROVIDER_DEFAULTS, - connectionEnabledModelIds, - providerDefaultsOf, } from '@maka/core/llm-connections'; import type { LlmConnection, ProviderType } from '@maka/core/llm-connections'; -import type { UiLocale } from '@maka/core/ui-locale'; -import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; - -const DAILY_REVIEW_MODEL_KEY_SEPARATOR = '::'; export function buildCatalogRecommendedDefaultModel(providerType: ProviderType): string { const entry = selectableCatalogEntries({ @@ -43,76 +35,6 @@ export function buildCatalogRecommendedDefaultModel(providerType: ProviderType): return entry?.id ?? ''; } -export function buildCatalogDailyReviewModelOptions( - connections: readonly LlmConnection[], - currentModelKey: string, - locale: UiLocale = 'zh', -): Array { - const current = parseDailyReviewModelKey(currentModelKey); - const candidates: Array<{ key: string; label: string; safeSourceLabel: string }> = []; - const seenKeys = new Set(); - const providerCounts = enabledProviderCounts(connections); - - for (const connection of connections) { - if (!isModelConsumerConnection(connection)) continue; - const savedModelIds: SavedModelChoice[] = current?.connectionSlug === connection.slug - ? [{ id: current.model, source: 'daily_review_model' }] - : []; - const safeSourceLabel = safeConnectionLabel(connection.providerType, connection.slug, providerCounts); - for (const entry of dailyReviewCatalogEntries(connection, savedModelIds)) { - const key = dailyReviewModelKey(connection.slug, entry.id); - if (seenKeys.has(key)) continue; - seenKeys.add(key); - candidates.push({ key, label: dailyReviewModelDisplayLabel(entry, locale), safeSourceLabel }); - } - } - - const options: Array = []; - const modelCounts = new Map(); - for (const candidate of candidates) { - modelCounts.set(candidate.label, (modelCounts.get(candidate.label) ?? 0) + 1); - } - for (const candidate of candidates) { - const label = (modelCounts.get(candidate.label) ?? 0) > 1 - ? `${candidate.label} · ${candidate.safeSourceLabel}` - : candidate.label; - options.push([candidate.key, label]); - } - - const trimmedCurrent = currentModelKey.trim(); - if (trimmedCurrent && !options.some(([value]) => value === trimmedCurrent)) { - const label = current?.model || trimmedCurrent.split(DAILY_REVIEW_MODEL_KEY_SEPARATOR).pop() || trimmedCurrent; - const sourceLabel = current?.connectionSlug ? ` · ${current.connectionSlug}` : ''; - options.push([trimmedCurrent, `${label}${sourceLabel} · ${getShellRemainingCopy(locale).models.unavailable}`]); - } - - return options; -} - -function dailyReviewCatalogEntries( - connection: Pick< - LlmConnection, - 'slug' | 'providerType' | 'defaultModel' | 'enabledModelIds' | 'models' | 'modelSource' | 'modelsFetchedAt' - >, - savedModelIds: Iterable, -): ModelCatalogEntry[] { - const savedChoices = Array.from(savedModelIds); - const enabledIds = new Set(connectionEnabledModelIds(connection)); - const visibleIds = new Set(enabledIds); - for (const choice of savedChoices) { - const id = typeof choice === 'string' ? choice.trim() : choice?.id.trim(); - if (id) visibleIds.add(id); - } - return filterUnsupportedCodexModels( - connection.providerType, - buildConnectionModelCatalogEntries({ connection, savedModelIds: savedChoices }), - ) - .filter((entry) => visibleIds.has(entry.id) && ( - entry.canUseAsChatDefault || entry.provenance.sources?.userChoice?.includes('daily_review_model') - )) - .map((entry) => enabledIds.has(entry.id) ? entry : { ...entry, canUseAsChatDefault: false }); -} - function selectableCatalogEntries( connection: Pick< LlmConnection, @@ -144,54 +66,3 @@ function filterUnsupportedCodexModels(providerType: ProviderType, entries: Model if (providerType !== 'openai-codex') return entries; return entries.filter((entry) => !CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(entry.id.trim())); } - -function modelDisplayLabel(entry: Pick): string { - return entry.displayName?.trim() || entry.id; -} - -function dailyReviewModelDisplayLabel( - entry: Pick, - locale: UiLocale = 'zh', -): string { - const label = modelDisplayLabel(entry); - return entry.canUseAsChatDefault ? label : `${label} · ${getShellRemainingCopy(locale).models.unavailable}`; -} - -function isModelConsumerConnection(connection: Pick): boolean { - // Unknown providerType (legacy seed, or a connection persisted on a branch - // that registers a provider this build doesn't know) → not a model consumer. - // Mirrors `isRealConnection` in connection-readiness.ts. - return connection.enabled && providerDefaultsOf(connection.providerType) !== undefined; -} - -function enabledProviderCounts(connections: readonly LlmConnection[]): Map { - const counts = new Map(); - for (const connection of connections) { - if (!isModelConsumerConnection(connection)) continue; - counts.set(connection.providerType, (counts.get(connection.providerType) ?? 0) + 1); - } - return counts; -} - -function safeConnectionLabel( - providerType: ProviderType, - connectionSlug: string, - providerCounts: ReadonlyMap, -): string { - const label = PROVIDER_DEFAULTS[providerType].label; - return (providerCounts.get(providerType) ?? 0) > 1 ? `${label} · ${connectionSlug}` : label; -} - -function dailyReviewModelKey(connectionSlug: string, model: string): string { - return `${connectionSlug}${DAILY_REVIEW_MODEL_KEY_SEPARATOR}${model}`; -} - -function parseDailyReviewModelKey(value: string): { connectionSlug: string; model: string } | undefined { - const trimmed = value.trim(); - const index = trimmed.indexOf(DAILY_REVIEW_MODEL_KEY_SEPARATOR); - if (index <= 0) return undefined; - const connectionSlug = trimmed.slice(0, index); - const model = trimmed.slice(index + DAILY_REVIEW_MODEL_KEY_SEPARATOR.length); - if (!connectionSlug || !model) return undefined; - return { connectionSlug, model }; -} diff --git a/apps/desktop/src/renderer/nav-selection.ts b/apps/desktop/src/renderer/nav-selection.ts index aac3e57a64..ef4724b671 100644 --- a/apps/desktop/src/renderer/nav-selection.ts +++ b/apps/desktop/src/renderer/nav-selection.ts @@ -47,7 +47,7 @@ function isExtensionModule(value: unknown): value is ExtensionModule { } function isAutomationModule(value: unknown): value is AutomationModule { - return value === 'scheduled-tasks' || value === 'daily-review'; + return value === 'scheduled-tasks'; } function parseSelection(value: unknown): NavSelection | null { @@ -66,6 +66,9 @@ function parseSelection(value: unknown): NavSelection | null { if (candidate.section === 'automations' && candidate.module === 'plan-reminders') { return { section: 'automations', module: 'scheduled-tasks' }; } + if (candidate.section === 'automations' && candidate.module === 'daily-review') { + return { section: 'automations', module: 'scheduled-tasks' }; + } return null; } @@ -74,7 +77,10 @@ function parseModuleMemory(value: unknown): NavModuleMemory { const candidate = value as { extensions?: unknown; automations?: unknown }; return { extensions: isExtensionModule(candidate.extensions) ? candidate.extensions : DEFAULT_MODULE_MEMORY.extensions, - automations: isAutomationModule(candidate.automations) ? candidate.automations : DEFAULT_MODULE_MEMORY.automations, + automations: + isAutomationModule(candidate.automations) || candidate.automations === 'daily-review' + ? 'scheduled-tasks' + : DEFAULT_MODULE_MEMORY.automations, }; } diff --git a/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts b/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts index 4c4880ce4c..1cdb5c5b4a 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts @@ -18,10 +18,7 @@ */ import type { MakaBridge } from '../../../preload/bridge-contract.js'; -import type { - ModuleHubClipboardService, - ModuleHubServices, -} from '../../features/module-hub/index.js'; +import type { ModuleHubServices } from '../../features/module-hub/index.js'; type DesktopModuleHubSettingsBridge = Partial< Pick @@ -29,20 +26,15 @@ type DesktopModuleHubSettingsBridge = Partial< export type DesktopModuleHubBridge = Pick< MakaBridge, - 'dailyReview' | 'runtimeHostProfiles' | 'scheduledTasks' | 'skills' + 'runtimeHostProfiles' | 'scheduledTasks' | 'skills' > & { /** Optional at runtime so a renderer can coexist with an older preload. */ readonly settings?: DesktopModuleHubSettingsBridge; }; -export interface DesktopModuleHubServiceDependencies { - readonly clipboard?: ModuleHubClipboardService; -} - /** The only Desktop-to-Module-Hub adapter. */ export function createDesktopModuleHubServices( bridge: DesktopModuleHubBridge = window.maka, - dependencies: DesktopModuleHubServiceDependencies = {}, ): ModuleHubServices { const getClientSettings = bridge.settings?.getClient; const updateClientSettings = bridge.settings?.updateClient; @@ -123,32 +115,5 @@ export function createDesktopModuleHubServices( return subscribeClientSettings.call(bridge.settings, handler); }, }, - dailyReview: { - day: (offsetDays, daySpan, host) => - bridge.dailyReview.day(offsetDays, daySpan, host), - runOnce: (input) => { - const runOnce = bridge.dailyReview.runOnce; - if (!runOnce) throw new Error('Daily Review run is unavailable'); - return runOnce(input); - }, - listArchives: () => { - const listArchives = bridge.dailyReview.listArchives; - if (!listArchives) throw new Error('Daily Review history is unavailable'); - return listArchives(); - }, - getArchive: (archiveId) => { - const getArchive = bridge.dailyReview.getArchive; - if (!getArchive) throw new Error('Daily Review history is unavailable'); - return getArchive(archiveId); - }, - saveMarkdownToFile: (input) => - bridge.dailyReview.saveMarkdownToFile(input), - }, - clipboard: { - writeText(text) { - const clipboard = dependencies.clipboard ?? navigator.clipboard; - return clipboard.writeText(text); - }, - }, }; } diff --git a/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx b/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx deleted file mode 100644 index a26a9ee009..0000000000 --- a/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx +++ /dev/null @@ -1,194 +0,0 @@ -/* - * 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 { useEffect, useMemo, useState } from 'react'; -import { Banner } from '@astryxdesign/core'; -import type { DailyReviewConfig } from '@maka/core/daily-review'; -import type { LlmConnection } from '@maka/core/llm-connections'; -import { Selector, Switch, TextInput, useMountedRef, useUiLocale } from '@maka/ui'; -import { buildCatalogDailyReviewModelOptions } from '../model-catalog-choices'; -import { getDailyReviewSettingsCopy, type DailyReviewSettingsCopy } from '../locales/settings-daily-review-copy'; -import { settingsActionErrorMessage } from './settings-error-copy'; -import { SettingsPage, SettingsRow, SettingsSection } from './settings-section'; -import { SettingsSkeletonStack } from './settings-skeleton'; -import { useActionGuard } from './use-action-guard'; -import { - useRuntimeHostSettingsErrorReporter, - useRuntimeHostSettingsTarget, -} from './runtime-host-settings-target.js'; - -const DAILY_REVIEW_DEFAULT_MODEL_VALUE = '__maka_daily_review_default_model__'; - -function buildDailyReviewModelOptions( - connections: readonly LlmConnection[], - currentModelKey: string, - copy: DailyReviewSettingsCopy, - locale: 'zh' | 'en', -): Array<{ value: string; label: string }> { - return [ - { value: DAILY_REVIEW_DEFAULT_MODEL_VALUE, label: copy.defaultModel }, - ...buildCatalogDailyReviewModelOptions(connections, currentModelKey, locale).map(([value, label]) => ({ - value, - label, - })), - ]; -} - -export function DailyReviewSettingsPage(props: { connections: readonly LlmConnection[] }) { - const host = useRuntimeHostSettingsTarget(); - const locale = useUiLocale(); - const copy = getDailyReviewSettingsCopy(locale); - const reportHostError = useRuntimeHostSettingsErrorReporter(); - const dailyReviewIpc = window.maka.dailyReview; - const hasConfigIpc = Boolean(dailyReviewIpc.getConfig && dailyReviewIpc.setConfig); - const [config, setConfig] = useState(null); - const [loading, setLoading] = useState(hasConfigIpc); - const [loadError, setLoadError] = useState(null); - const [savingKey, setSavingKey] = useState(null); - const [executeTimeDraft, setExecuteTimeDraft] = useState('08:00'); - const [executeTimeInvalid, setExecuteTimeInvalid] = useState(false); - const mountedRef = useMountedRef(); - const saveConfigGuard = useActionGuard(); - - useEffect(() => { - if (!hasConfigIpc || !dailyReviewIpc.getConfig) { - setLoading(false); - return; - } - let cancelled = false; - setLoading(true); - setLoadError(null); - dailyReviewIpc.getConfig(host).then((next) => { - if (!cancelled && mountedRef.current) { - setConfig(next); - setLoading(false); - } - }).catch((error: unknown) => { - if (!cancelled && mountedRef.current) { - setLoadError(settingsActionErrorMessage(error, locale)); - setLoading(false); - } - }); - return () => { cancelled = true; }; - }, [dailyReviewIpc, hasConfigIpc, host, locale, mountedRef]); - - useEffect(() => { - setExecuteTimeDraft(config?.executeTime ?? '08:00'); - setExecuteTimeInvalid(false); - }, [config?.executeTime]); - - async function patchConfig(key: string, patch: Partial) { - if (!dailyReviewIpc.setConfig || !config || saveConfigGuard.current !== null) return; - saveConfigGuard.begin(key); - setSavingKey(key); - try { - const next = await dailyReviewIpc.setConfig(patch, host); - if (mountedRef.current && saveConfigGuard.current === key) setConfig(next); - } catch (error) { - if (mountedRef.current && saveConfigGuard.current === key) { - reportHostError( - copy.saveFailed, - settingsActionErrorMessage(error, locale), - ); - } - } finally { - if (saveConfigGuard.current === key) saveConfigGuard.finish(); - if (mountedRef.current) setSavingKey(null); - } - } - - const modelOptions = useMemo( - () => buildDailyReviewModelOptions(props.connections, config?.modelKey ?? '', copy, locale), - [config?.modelKey, copy, locale, props.connections], - ); - const formDisabled = !hasConfigIpc || loading || Boolean(loadError) || !config || savingKey !== null; - const scheduleDisabled = formDisabled; - const selectedModelValue = config?.modelKey.trim() || DAILY_REVIEW_DEFAULT_MODEL_VALUE; - - if (loading) { - return ( - - - - ); - } - - return ( - - {!hasConfigIpc ? : null} - {loadError ? : null} - - void patchConfig('enabled', { enabled })} - /> - } - /> - { - setExecuteTimeDraft(executeTime); - setExecuteTimeInvalid(false); - }} - onBlur={() => { - if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/u.test(executeTimeDraft)) { - setExecuteTimeInvalid(true); - } else if (executeTimeDraft !== config?.executeTime) { - void patchConfig('executeTime', { executeTime: executeTimeDraft }); - } - }} - />} - /> - - - void patchConfig('modelKey', { - modelKey: value === DAILY_REVIEW_DEFAULT_MODEL_VALUE ? '' : value, - })} - />} - /> - - - ); -} diff --git a/apps/desktop/src/renderer/settings/settings-modal.tsx b/apps/desktop/src/renderer/settings/settings-modal.tsx index 6c2b09a738..b680006371 100644 --- a/apps/desktop/src/renderer/settings/settings-modal.tsx +++ b/apps/desktop/src/renderer/settings/settings-modal.tsx @@ -57,12 +57,6 @@ export function SettingsModal(props: { openProviderCatalog?: boolean; initialConnectionSlug?: string; initialCreateProviderType?: ProviderType; - /** - * PR-DAILY-REVIEW-MVP-0 follow-up: navigate to the sidebar's - * Daily Review module. Optional so the settings page degrades - * gracefully when the shell does not provide the jump. - */ - onOpenDailyReview?(): void; /** Opens the keyboard sheet; 关于 is its click-reachable home. */ onOpenKeyboardHelp?(): void; /** @@ -110,7 +104,6 @@ export function SettingsModal(props: { initialConnectionSlug={props.initialConnectionSlug} initialCreateProviderType={props.initialCreateProviderType} initialFocusRef={activeNavRef} - onOpenDailyReview={props.onOpenDailyReview} onOpenKeyboardHelp={props.onOpenKeyboardHelp} onOpenSession={props.onOpenSession} archivedTasks={props.archivedTasks} diff --git a/apps/desktop/src/renderer/settings/settings-nav.ts b/apps/desktop/src/renderer/settings/settings-nav.ts index 7c949a2a88..8fe7ed39d0 100644 --- a/apps/desktop/src/renderer/settings/settings-nav.ts +++ b/apps/desktop/src/renderer/settings/settings-nav.ts @@ -23,7 +23,6 @@ import { BarChart3, Bot, Brain, - CalendarDays, Cpu, Database, FolderOpen, @@ -76,9 +75,6 @@ type AccountSecretProbeResult = // node:test without a DOM / React. export type { SettingsNavGroup }; -// PR-SETTINGS-IA-CONSOLIDATE-0 + PR-SETTINGS-REVIEW-0: WAWQAQ msg -// `886f6406` rolled back the 记忆+回顾 merge — the combined page was -// too dense. 记忆 and 每日回顾 are separate nav items again. export const SETTINGS_NAV: SettingsNavItem[] = [ { id: 'general', Icon: SettingsIcon, enabled: true, group: 'preferences' }, { id: 'appearance', Icon: Palette, enabled: true, group: 'preferences' }, @@ -91,7 +87,6 @@ export const SETTINGS_NAV: SettingsNavItem[] = [ { id: 'usage', Icon: BarChart3, enabled: true, group: 'activity' }, { id: 'archived-tasks', Icon: ListTodo, enabled: true, group: 'activity' }, { id: 'import-tasks', Icon: Upload, enabled: true, group: 'activity' }, - { id: 'daily-review', Icon: CalendarDays, enabled: true, group: 'activity' }, { id: 'data', Icon: Database, enabled: true, group: 'system' }, { id: 'permissions', Icon: ShieldCheck, enabled: true, group: 'system' }, { id: 'health', Icon: Activity, enabled: true, group: 'system' }, @@ -113,7 +108,6 @@ const SETTINGS_SECTION_SCOPES: Record< usage: 'runtime-host', 'archived-tasks': 'client', 'import-tasks': 'runtime-host', - 'daily-review': 'runtime-host', data: 'mixed', permissions: 'runtime-host', health: 'runtime-host', diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index 8addb78be9..933f2c6d26 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -66,7 +66,6 @@ import { ProjectsSettingsPage } from './projects-settings-page'; import { AboutSettingsPage } from './about-settings-page'; import { AppearanceSettingsPage } from './appearance-settings-page'; import { BotChatSettingsPage } from './bot-chat-settings-page'; -import { DailyReviewSettingsPage } from './daily-review-settings-page'; import { DataSettingsPage } from './data-settings-page'; import { GeneralSettingsPage } from './general-settings-page'; import { HealthCenterPage } from './health-center-page'; @@ -159,7 +158,6 @@ export function SettingsSurface(props: { initialConnectionSlug?: string; initialCreateProviderType?: ProviderType; initialFocusRef: RefObject; - onOpenDailyReview?(): void; onOpenKeyboardHelp?(): void; onOpenSession?(sessionId: string): void; archivedTasks: ArchivedTasksBridge; @@ -414,7 +412,7 @@ export function SettingsSurface(props: { return () => props.onSelectedRuntimeHostProfileIdChange(undefined); }, [props.onSelectedRuntimeHostProfileIdChange, selectedProfileId, showsRuntimeHost]); const sectionNeedsSettings = ['general', 'subagents', 'memory', 'search'].includes(section); - const sectionNeedsConnections = ['general', 'models', 'subagents', 'daily-review'].includes(section); + const sectionNeedsConnections = ['general', 'models', 'subagents'].includes(section); const runtimeHostAvailabilityStatus: RuntimeHostAvailabilityStatus = selectedRuntimeHost ? 'ready' @@ -1026,7 +1024,6 @@ export function SettingsSurface(props: { onReloadUsage={reloadUsage} onThemeChange={props.onThemeChange} onThemePaletteChange={props.onThemePaletteChange} - onOpenDailyReview={props.onOpenDailyReview} onOpenKeyboardHelp={props.onOpenKeyboardHelp} onOpenSession={props.onOpenSession} archivedTasks={props.archivedTasks} @@ -1081,7 +1078,6 @@ function SettingsPageBody(props: { onReloadUsage(range?: UsageRange): Promise; onThemeChange(pref: ThemePreference): void; onThemePaletteChange(palette: ThemePalette): void; - onOpenDailyReview?(): void; onOpenKeyboardHelp?(): void; onOpenSession?(sessionId: string): void; archivedTasks: ArchivedTasksBridge; @@ -1220,8 +1216,6 @@ function SettingsPageBody(props: { onReloadSettings={props.onReloadSettings} /> ); - case 'daily-review': - return ; case 'search': return ( ({ - ...session, - id: projectSessionId(host, session.id), - })), - }; -} - export function projectDesktopUsageStats( host: DesktopHostRef, stats: UsageStats, diff --git a/apps/desktop/stories/module-hubs.stories.tsx b/apps/desktop/stories/module-hubs.stories.tsx index 82ab26e470..9d32ea98bb 100644 --- a/apps/desktop/stories/module-hubs.stories.tsx +++ b/apps/desktop/stories/module-hubs.stories.tsx @@ -18,13 +18,11 @@ */ import type { Meta, StoryObj } from '@storybook/react-vite'; -import type { DailyReviewArchive, DailyReviewSummary } from '@maka/core/daily-review'; import type { ScheduledTask, ScheduledTaskRun } from '@maka/core/scheduled-task'; import type { McpConfigFile, McpServerStatus } from '@maka/core/mcp'; import { MCP_CONFIG_VERSION } from '@maka/core/mcp'; import { ScheduledTasksPage, - DailyReviewPage, getSharedUiCopy, ModuleHubSelector, SkillsPage, @@ -384,63 +382,6 @@ const LONG_CONTENT_TASKS: ScheduledTask[] = ([ }, ] satisfies StoryScheduledTask[]).map(storyScheduledTask); -const DAILY_REVIEW_SUMMARY: DailyReviewSummary = { - day: { fromMs: Date.UTC(2026, 6, 1), toMs: Date.UTC(2026, 6, 2) }, - totals: { - sessionCount: 6, - requestCount: 42, - totalTokens: 18_320, - costUsd: 0.21, - errorCount: 1, - }, - sessions: [ - { - id: 's-1', - name: '整理 Storybook 表面覆盖', - lastMessageAt: NOW - 12 * 60_000, - lastMessagePreview: '先把高频页面补齐。', - }, - { - id: 's-2', - name: 'PR #435 发布风险清单', - lastMessageAt: NOW - 2 * 60 * 60_000, - lastMessagePreview: '权限弹窗的状态要全。', - }, - ], - topTools: [ - { key: 'Bash', label: 'Bash', requests: 18, totalTokens: 4_200, costUsd: 0.05 }, - { key: 'Read', label: 'Read', requests: 12, totalTokens: 2_100, costUsd: 0.02 }, - ], - topModels: [ - { - key: 'claude-sonnet-4-5', - label: 'Claude Sonnet 4.5', - requests: 28, - totalTokens: 12_400, - costUsd: 0.16, - }, - ], -}; - -const DAILY_REVIEW_ARCHIVE: DailyReviewArchive = { - id: '2026-07-01-1d', - day: DAILY_REVIEW_SUMMARY.day, - range: 1, - status: 'ok', - generatedAt: NOW - 5 * 60_000, - trigger: 'manual', - modelKey: 'openai::gpt-5', - totals: DAILY_REVIEW_SUMMARY.totals, - sections: { - summary: '今天聚焦 Daily Review 的信息架构和页面重构,完成了时间范围、活动概览与报告详情的职责拆分。', - gaps: '报告导出仍需在真实桌面环境验证文件保存路径。', - usage: '共完成 42 次模型请求,主要活动集中在六个对话中。', - code: '继续让设置页只承载持久配置,把即时动作留在功能主页面。', - }, -}; - -type DailyReviewBridge = NonNullable['bridge']>; - const configuredMcpConfig: McpConfigFile = { version: MCP_CONFIG_VERSION, mcpServers: { @@ -590,7 +531,7 @@ const withFailedMcpBridge = withScopedMakaBridge({ function ModuleSurface(props: { children: ReactNode; - agentsView: 'skills' | 'mcp' | 'cron' | 'daily-review'; + agentsView: 'skills' | 'mcp' | 'cron'; }) { return (
{}} />, + badge: {}} />, }} skills={props.skills ?? []} managedSkillSources={[]} @@ -664,7 +605,7 @@ function ExtensionsMcpSurface() { hubHeader={{ title: copy.title, subtitle: copy.description, - badge: {}} />, + badge: {}} />, }} /> @@ -683,7 +624,7 @@ function ScheduledTasksSurface(props: { hubHeader={{ title: copy.title, subtitle: copy.description, - badge: {}} />, + badge: null, }} tasks={props.tasks ?? []} keepSystemAwake={props.keepSystemAwake ?? false} @@ -703,34 +644,10 @@ function ScheduledTasksSurface(props: { ); } -function ScheduledDailyReviewSurface( - props: { bridge: DailyReviewBridge } & Pick< - ComponentProps, - 'onCopyMarkdown' | 'onAppendMarkdown' | 'onSaveMarkdown' - >, -) { - const copy = getSharedUiCopy(useUiLocale()).moduleHubs.automations; - return ( - - {}} />, - }} - bridge={props.bridge} - onCopyMarkdown={props.onCopyMarkdown} - onAppendMarkdown={props.onAppendMarkdown} - onSaveMarkdown={props.onSaveMarkdown} - /> - - ); -} - function ModuleHubHostSurface(props: { selection: | { section: 'extensions'; module: 'skills' | 'mcp' } - | { section: 'automations'; module: 'scheduled-tasks' | 'daily-review' }; + | { section: 'automations'; module: 'scheduled-tasks' }; }) { const base = createFakeModuleHubHostModel(props.selection); const model = { @@ -744,18 +661,10 @@ function ModuleHubHostSurface(props: { ...base.scheduledTasks, scheduledTasks: CONFIGURED_TASKS, }, - dailyReview: { - ...base.dailyReview, - bridge: { - fetchDay: async () => DAILY_REVIEW_SUMMARY, - }, - }, }; const agentsView = props.selection.section === 'extensions' ? props.selection.module - : props.selection.module === 'daily-review' - ? 'daily-review' - : 'cron'; + : 'cron'; return ( @@ -826,14 +735,6 @@ export const HostAutomationsScheduledTasks: Story = { ), }; -export const HostAutomationsDailyReview: Story = { - render: () => ( - - ), -}; - // Real path: sidebar → 扩展 → 技能, with several installed Skills. export const ExtensionsSkillsInstalled: Story = { render: () => , @@ -1102,102 +1003,3 @@ export const ScheduledTasksNarrow: Story = { export const ScheduledTasksLongContent: Story = { render: () => , }; - -// Real path: sidebar → 定时任务 → 每日回顾, with reviews already generated. -export const ScheduledDailyReview: Story = { - render: () => ( - DAILY_REVIEW_SUMMARY, - listArchives: async () => [], - runOnce: async () => ({ archiveId: DAILY_REVIEW_ARCHIVE.id }), - getArchive: async () => DAILY_REVIEW_ARCHIVE, - }} - /> - ), -}; - -// Real path: sidebar → scheduled tasks → Daily Review after the initial activity request fails. -export const ScheduledDailyReviewInitialLoadFailed: Story = { - render: () => ( - { - throw new Error('activity fixture unavailable'); - }, - listArchives: async () => [], - runOnce: async () => ({ archiveId: DAILY_REVIEW_ARCHIVE.id }), - getArchive: async () => DAILY_REVIEW_ARCHIVE, - }} - /> - ), -}; - -// Real path: sidebar → scheduled tasks → Daily Review while a new range loads. -export const ScheduledDailyReviewRefreshing: Story = { - render: () => ( - { - if (range === 1) return DAILY_REVIEW_SUMMARY; - return new Promise(() => undefined); - }, - listArchives: async () => [], - runOnce: async () => ({ archiveId: DAILY_REVIEW_ARCHIVE.id }), - getArchive: async () => DAILY_REVIEW_ARCHIVE, - }} - /> - ), - play: async ({ canvasElement }) => { - const button = await waitForStoryButton( - canvasElement, - (candidate) => candidate.textContent?.includes('最近 7 天') === true, - ); - button.click(); - await waitForStorySelector(canvasElement, '.maka-daily-review-content'); - }, -}; - -// Real path after an analysis exists and the user opens its dedicated detail route. -// Real path: sidebar → scheduled tasks → Daily Review → view analysis. -export const ScheduledDailyReviewReport: Story = { - render: function Render() { - const staleSummary = { - ...DAILY_REVIEW_SUMMARY, - day: { - fromMs: DAILY_REVIEW_SUMMARY.day.fromMs - 86_400_000, - toMs: DAILY_REVIEW_SUMMARY.day.toMs - 86_400_000, - }, - totals: { ...DAILY_REVIEW_SUMMARY.totals, requestCount: 999 }, - }; - return ( - range === 1 ? DAILY_REVIEW_SUMMARY : staleSummary, - listArchives: async () => [{ - id: DAILY_REVIEW_ARCHIVE.id, - day: DAILY_REVIEW_ARCHIVE.day, - range: DAILY_REVIEW_ARCHIVE.range, - status: DAILY_REVIEW_ARCHIVE.status, - generatedAt: DAILY_REVIEW_ARCHIVE.generatedAt, - trigger: DAILY_REVIEW_ARCHIVE.trigger, - modelKey: DAILY_REVIEW_ARCHIVE.modelKey, - totals: DAILY_REVIEW_ARCHIVE.totals, - }], - getArchive: async () => { - await new Promise((resolve) => globalThis.setTimeout(resolve, 100)); - return DAILY_REVIEW_ARCHIVE; - }, - }} - /> - ); - }, - play: async ({ canvasElement }) => { - const view = await waitForStoryButton( - canvasElement, - (candidate) => candidate.textContent?.includes('查看分析') === true, - ); - view.click(); - await waitForStoryText(canvasElement, '返回活动'); - }, -}; diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index b1dbd489c4..ffaaf6ed58 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -47,7 +47,6 @@ import { buildChatModelChoices } from '@maka/core/chat-model-choice'; import type { LocalMemoryBackupInfo, LocalMemoryEntryPreview, LocalMemoryState } from '@maka/core/local-memory'; import { buildHealthSnapshot } from '@maka/core/health'; import { createDefaultSettings, mergeSettings } from '@maka/core/settings'; -import { DEFAULT_DAILY_REVIEW_CONFIG } from '@maka/core/daily-review'; import { SettingsSurface } from '../../src/renderer/settings/settings-surface'; import { createUiLocaleUpdateGate } from '../../src/renderer/settings/ui-locale-update-gate'; import { @@ -63,7 +62,6 @@ import type { DesktopSessionSummary, } from '../../src/preload/bridge-contract.js'; import { withScopedMakaBridge } from '../maka-bridge'; -import { getDailyReviewSettingsCopy } from '../../src/renderer/locales/settings-daily-review-copy'; import { getUsageSettingsCopy } from '../../src/renderer/locales/settings-usage-copy'; /** @@ -73,7 +71,6 @@ import { getUsageSettingsCopy } from '../../src/renderer/locales/settings-usage- * function, so CI could not tell us. A story that drives the UI by its visible * text has to source that text where the UI does. */ -const DAILY_REVIEW_DEFAULT_MODEL_LABEL = getDailyReviewSettingsCopy('zh').defaultModel; /** A 1×1 transparent PNG: the picker needs a valid data URL, not real art. */ const STORY_ICON_PREVIEW = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; @@ -869,14 +866,6 @@ const makaBridge = { capabilities: { getSnapshot: async () => capabilitySnapshot, }, - dailyReview: { - getConfig: async () => DEFAULT_DAILY_REVIEW_CONFIG, - setConfig: async (patch: Record) => ({ - ...DEFAULT_DAILY_REVIEW_CONFIG, - ...patch, - }), - runOnce: async () => ({ ok: true }), - }, e2eFixture: { getState: async () => null, }, @@ -1617,7 +1606,6 @@ function SettingsStoryFrame(props: SettingsStoryProps) { openProviderCatalog={props.openProviderCatalog} initialConnectionSlug={props.initialConnectionSlug} initialFocusRef={initialFocusRef} - onOpenDailyReview={noop} onOpenSession={noop} archivedTasks={archivedTasks} onTaskImported={noop} @@ -1652,19 +1640,6 @@ async function waitForStoryCondition(predicate: () => boolean, errorMessage: str throw new Error(errorMessage); } -async function openDailyReviewModelSelector(canvasElement: HTMLElement): Promise { - const selector = await waitForStoryButton( - canvasElement, - (candidate) => candidate.textContent?.includes(DAILY_REVIEW_DEFAULT_MODEL_LABEL) === true, - ); - await userEvent.click(selector); - await waitForStoryCondition( - () => selector.getAttribute('aria-expanded') === 'true', - 'Daily Review model selector did not open', - ); - return selector; -} - // Real path: sidebar footer 设置 → 模型. export const Models: Story = { decorators: [withSettingsBridge], @@ -2127,29 +2102,6 @@ export const BotChatNeedsAttention: Story = { decorators: [withBotAttentionBridge], render: () => , }; -// Real path: 设置 → 每日回顾. -export const DailyReview: Story = { - decorators: [withSettingsBridge], - render: () => , -}; - -// Real path at a narrow desktop window. -// Real path: Settings → Daily Review at a narrow window. -export const DailyReviewNarrow: Story = { - ...DailyReview, - parameters: { viewport: { defaultViewport: 'mobile2' } }, -}; - -// Real path with the Astryx model selector expanded. -// Real path: Settings → Daily Review → Analysis model. -export const DailyReviewModelSelectorOpen: Story = { - decorators: [withSettingsBridge], - render: () => , - play: async ({ canvasElement }) => { - await openDailyReviewModelSelector(canvasElement); - }, -}; - // Real path: 设置 → 数据. export const Data: Story = { decorators: [withSettingsBridge], diff --git a/docs/architecture/scheduled-task-unified.md b/docs/architecture/scheduled-task-unified.md index c81d158694..7806d7d84f 100644 --- a/docs/architecture/scheduled-task-unified.md +++ b/docs/architecture/scheduled-task-unified.md @@ -21,10 +21,11 @@ ## Problem -Maka previously had two clocks: +Maka previously had multiple clocks: 1. Desktop-owned notification schedules 2. Runtime Host standalone session schedules +3. Daily Review's private scheduler, resident state, archive, and model-call path The product word was one (“定时任务”), the data paths were two. Agent-created work never appeared in the desktop catalog. @@ -48,6 +49,15 @@ One noun: **`ScheduledTask`**. - `agent_run` — freeze the execution template at create; on fire, Host creates a stable Session and admits the root AgentRun itself. +Daily Review is a Scheduled Tasks preset, not an effect or runtime domain. Existing enabled +Daily Review configuration upgrades once to a system-owned `ScheduledTask`. Existing reports +upgrade to ordinary Sessions with Markdown artifacts and transcript messages, after which the +legacy tables are retired. New review executions use `agent_run`; their transcript, recovery, and +artifacts therefore have the same owners as any other Session. If the old configuration cannot yet +resolve an immutable model Connection, report projection remains idempotent but retirement waits; +the inert legacy rows are retried on a later Host start after model setup and are never scheduled or +written by the new runtime. + ### UI The scheduled-task panel consumes `ScheduledTask` directly. Its preload facade uses the shared @@ -78,6 +88,7 @@ the same Host protocol. The separate Headless runtime is outside this interactiv - `packages/core/src/scheduled-task.ts` - `packages/storage/src/scheduled-task-store.ts` +- `packages/storage/src/legacy-daily-review-migration.ts` (one-time upgrade only) - `packages/runtime/src/scheduled-task-tools.ts` - `packages/runtime-host/src/protocol/scheduled-task.ts` - `packages/runtime-host/src/server/scheduled-task-coordinator.ts` diff --git a/docs/astryx-full-surface-audit.md b/docs/astryx-full-surface-audit.md index ee1d9f7613..8ef4f72211 100644 --- a/docs/astryx-full-surface-audit.md +++ b/docs/astryx-full-surface-audit.md @@ -48,7 +48,7 @@ Aligned core: app-shell family, chat-message-surface, chat-composer-region, chat ### Modules / packages/ui -Aligned: skills-panel, scheduled-task-*, daily-review-panel, module-pages, composer, chat-view (loading now Spinner), prompt rail, quote chip, session sidebar/history/list, search-modal, model/permission pickers, mermaid, toast, tool-activity shell. +Aligned: skills-panel, scheduled-task-*, module-pages, composer, chat-view (loading now Spinner), prompt rail, quote chip, session sidebar/history/list, search-modal, model/permission pickers, mermaid, toast, tool-activity shell. ## Fixed in this pass @@ -59,7 +59,6 @@ Aligned: skills-panel, scheduled-task-*, daily-review-panel, module-pages, compo | task-ledger | error → `Banner` | | web-search | live error → `Banner`; no results → `EmptyState` | | memory settings | warning callouts → `Banner`; dead CSS removed | -| daily-review settings | loading → `SettingsSkeletonStack` | | permission center | error wrap in `SettingsPage` | | provider oauth | error → `Banner` | | custom pet | empty → `EmptyState isCompact` | @@ -93,7 +92,6 @@ Aligned: skills-panel, scheduled-task-*, daily-review-panel, module-pages, compo - workbar launcher shortcut → `Kbd`. - keyboard-help headings → `Heading`/`Text`. - web tool result links → Astryx `Link`. -- daily-review metrics → optional `StatTile`. ### P3 — CSS chrome debt diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index e5b534da65..3247e327eb 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 225 files — blocker 0, polish 1, aligned 224. +**Totals:** 220 files — blocker 0, polish 1, aligned 219. ## Exclusions (explicit) @@ -82,7 +82,6 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx` | settings-page | Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner | aligned — uses Astryx (Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner) | aligned | | `apps/desktop/src/renderer/settings/bot-wechat-login.tsx` | settings-module | Banner, Button, Collapsible, Dialog, DialogHeader, EmptyState, Layout, LayoutContent, Spinner | aligned — uses Astryx (Banner, Button, Collapsible, Dialog, DialogHeader, EmptyState, Layout, LayoutContent) | aligned | | `apps/desktop/src/renderer/settings/custom-pet-settings-section.tsx` | settings-module | Badge, Button, EmptyState | aligned — uses Astryx (Badge, Button, EmptyState) | aligned | -| `apps/desktop/src/renderer/settings/daily-review-settings-page.tsx` | settings-page | Banner, Selector | aligned — uses Astryx (Banner, Selector) | aligned | | `apps/desktop/src/renderer/settings/data-settings-page.tsx` | settings-page | Banner, Button, Selector | aligned — uses Astryx (Banner, Button, Selector) | aligned | | `apps/desktop/src/renderer/settings/general-settings-page.tsx` | settings-page | Banner, Button, Selector | aligned — uses Astryx (Banner, Button, Selector) | aligned | | `apps/desktop/src/renderer/settings/health-center-page.tsx` | settings-page | Banner, Button, Text, VStack | aligned — uses Astryx (Banner, Button, Text, VStack) | aligned | @@ -136,7 +135,6 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/styles/composer-mention.css` | shell-chrome-or-panel | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/composer.css` | shell-chrome-or-panel | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/custom-pet-companion.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | -| `apps/desktop/src/renderer/styles/daily-review.css` | module-hub | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/deep-research.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/error.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/help.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | @@ -205,7 +203,6 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/components.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | Button, IconButton, List, ListItem | raw ` { - const day = { - fromMs: new Date(2026, 7, 3, 0, 0, 0, 0).getTime(), - toMs: new Date(2026, 7, 4, 0, 0, 0, 0).getTime(), - }; - - it('parses canonical identities without accepting impossible calendar dates', () => { - assert.deepEqual(parseDailyReviewArchiveId('2026-08-03-30d'), { - localDate: '2026-08-03', - range: 30, - }); - assert.equal(parseDailyReviewArchiveId('2026-02-30-1d'), null); - assert.equal(parseDailyReviewArchiveId('2026-08-03-deep'), null); - }); - - it('rejects structurally invalid canonical archives', () => { - const valid = { - id: '2026-08-03-1d', - day, - range: 1, - status: 'ok', - generatedAt: day.toMs, - trigger: 'manual', - modelKey: '', - sections: { summary: 'Today' }, - totals: { - sessionCount: 1, - requestCount: 2, - totalTokens: 3, - costUsd: 0.04, - errorCount: 0, - }, - }; - const invalid = [ - { ...valid, day: undefined }, - { ...valid, status: 'unknown' }, - { ...valid, generatedAt: Number.NaN }, - { ...valid, trigger: 'unknown' }, - { ...valid, modelKey: 42 }, - { ...valid, sections: { summary: 42 } }, - { ...valid, sections: {} }, - { ...valid, sections: { summary: ' ' } }, - { ...valid, totals: { requestCount: 2 } }, - { ...valid, range: 7 }, - { ...valid, id: '2026-02-30-1d' }, - ]; - - for (const archive of invalid) { - assert.throws(() => normalizeDailyReviewArchive(archive)); - } - }); -}); diff --git a/packages/core/src/__tests__/health.test.ts b/packages/core/src/__tests__/health.test.ts index c5f2dfaa30..44c2202d52 100644 --- a/packages/core/src/__tests__/health.test.ts +++ b/packages/core/src/__tests__/health.test.ts @@ -226,12 +226,12 @@ describe('HealthSignal contract', () => { feature: { state: 'partial', source: 'runtime', - reason: 'Daily Review 已聚合本地会话 / 工具 / 模型活动;当前不包含屏幕与应用级录制', + reason: '普通任务与产物可保留本地会话 / 工具 / 模型活动;当前不包含屏幕与应用级录制', }, runtimeProbe: { state: 'not_run', source: 'runtime_probe', - reason: '打开 Daily Review 可查看本地活动聚合结果', + reason: '在普通任务历史与定时任务执行记录中查看本地活动结果', }, }), ); diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index 81b36bbfff..689f9f8103 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -261,8 +261,8 @@ test('connection catalogs preserve user-choice provenance without inventing avai }); test('every picker sees a model the user enabled but no catalog describes', () => { - // The projection lives in the builder, not in one caller: chat, Daily Review - // and the settings selectors all read it. `deepseek-v4-pro-beta` is enabled + // The projection lives in the builder, not in one caller: chat and the + // settings selectors both read it. `deepseek-v4-pro-beta` is enabled // but absent from the snapshot this build shipped, and the entry it gets is // selectable — that is the whole of #1584 seen from the picker side. const entries = buildConnectionModelCatalogEntries({ diff --git a/packages/core/src/__tests__/scheduled-task.test.ts b/packages/core/src/__tests__/scheduled-task.test.ts index e2082e3223..e40d11eb3c 100644 --- a/packages/core/src/__tests__/scheduled-task.test.ts +++ b/packages/core/src/__tests__/scheduled-task.test.ts @@ -175,6 +175,7 @@ describe('scheduled-task catalog', () => { const now = Date.UTC(2026, 0, 5, 8, 0, 0); const execution = { cwd: '/tmp/project', + llmConnectionId: 'connection-anthropic', llmConnectionSlug: 'anthropic', model: 'claude-sonnet-4-5-20250929', permissionMode: 'ask', @@ -240,6 +241,7 @@ describe('decodePersistedScheduledTask', () => { kind: 'agent_run', execution: { cwd: '/repo', + llmConnectionId: 'connection-anthropic', llmConnectionSlug: 'anthropic', model: 'claude', permissionMode: 'ask', diff --git a/packages/core/src/daily-review.ts b/packages/core/src/daily-review.ts deleted file mode 100644 index ba9c6ff757..0000000000 --- a/packages/core/src/daily-review.ts +++ /dev/null @@ -1,468 +0,0 @@ -/* - * 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. - */ - -/** - * Daily Review domain values and pure projection helpers. - * - * A review summarizes local Session and usage facts over local-time day - * boundaries. Runtime ownership, persistence, scheduling, and model execution - * stay outside this module so every Client observes the same domain contract. - */ - -import type { UsageBucket, UsageQuery, UsageSummaryV2 } from './usage-stats/types.js'; -import type { SessionSummary } from './session.js'; - -/** Inclusive `from` and exclusive `to` millisecond bounds for one day. */ -export interface DayRangeMs { - readonly fromMs: number; - readonly toMs: number; -} - -/** - * One row in the "today's active sessions" list. Subset of - * `SessionSummary` so the renderer doesn't have to know about flags / - * labels it won't show. - */ -export interface DailyReviewSessionRow { - readonly id: string; - readonly name: string; - readonly lastMessageAt: number; - readonly lastMessagePreview?: string; -} - -export interface DailyReviewTopEntry { - readonly key: string; - readonly label: string; - readonly requests: number; - readonly totalTokens: number; - readonly costUsd: number; -} - -export interface DailyReviewTotals { - readonly sessionCount: number; - readonly requestCount: number; - readonly totalTokens: number; - readonly costUsd: number; - readonly errorCount: number; -} - -export interface DailyReviewSummary { - readonly day: DayRangeMs; - readonly totals: DailyReviewTotals; - readonly sessions: ReadonlyArray; - readonly topTools: ReadonlyArray; - readonly topModels: ReadonlyArray; -} - -/** - * Returns the local-TZ day boundary that contains `nowMs`. We use the - * user's local timezone because the user thinks in their own day, not - * UTC — a session at 23:30 is "today" for them, not yesterday. - */ -export function localDayBoundsForInstant(nowMs: number): DayRangeMs { - const d = new Date(nowMs); - d.setHours(0, 0, 0, 0); - const fromMs = d.getTime(); - const next = new Date(fromMs); - next.setDate(next.getDate() + 1); - return { fromMs, toMs: next.getTime() }; -} - -/** - * Returns the local-TZ day boundary for a date offset by `offsetDays` - * from `nowMs` (0 = today, -1 = yesterday, +1 = tomorrow). Always - * snaps to the resulting day's local midnight; safe across DST. - */ -export function localDayBoundsAt(nowMs: number, offsetDays: number): DayRangeMs { - const d = new Date(nowMs); - d.setHours(0, 0, 0, 0); - d.setDate(d.getDate() + offsetDays); - const fromMs = d.getTime(); - const next = new Date(fromMs); - next.setDate(next.getDate() + 1); - return { fromMs, toMs: next.getTime() }; -} - -/** - * Filters `sessions` to those with a `lastMessageAt` inside the day - * window, then truncates to the most-recent `limit`. Returns a - * lightweight row shape (drop the labels / flags / status fields). - */ -export function pickDailyReviewSessions( - sessions: ReadonlyArray, - day: DayRangeMs, - limit: number, -): DailyReviewSessionRow[] { - const matching: DailyReviewSessionRow[] = []; - for (const session of sessions) { - const ts = session.lastMessageAt; - if (ts === undefined) continue; - if (ts < day.fromMs || ts >= day.toMs) continue; - matching.push({ - id: session.id, - name: session.name, - lastMessageAt: ts, - lastMessagePreview: session.lastMessagePreview, - }); - } - // Most recent first; the panel ordering should match what the - // sidebar shows in the "today" group. - matching.sort((a, b) => b.lastMessageAt - a.lastMessageAt); - return matching.slice(0, Math.max(0, limit)); -} - -/** - * Reduces a `UsageBucket[]` (already grouped by tool or model in the - * telemetry repo) into the renderer-friendly `DailyReviewTopEntry[]` - * sorted by request count, then capped at `limit`. - */ -export function pickDailyReviewTopEntries( - buckets: ReadonlyArray, - limit: number, -): DailyReviewTopEntry[] { - const rows = buckets.map( - (b): DailyReviewTopEntry => ({ - key: b.key, - label: b.label, - requests: b.requests, - totalTokens: b.totalTokens, - costUsd: b.costUsd, - }), - ); - rows.sort((a, b) => b.requests - a.requests); - return rows.slice(0, Math.max(0, limit)); -} - -/** Pure assembler — the IPC handler in main calls this. */ -export function buildDailyReviewSummary(input: { - day: DayRangeMs; - usageSummary: UsageSummaryV2; - sessions: ReadonlyArray; - topTools: ReadonlyArray; - topModels: ReadonlyArray; -}): DailyReviewSummary { - return { - day: input.day, - totals: { - sessionCount: input.sessions.length, - requestCount: input.usageSummary.totalRequests, - totalTokens: input.usageSummary.totalTokens.total, - costUsd: input.usageSummary.totalCostUsd, - errorCount: input.usageSummary.errorRequests, - }, - sessions: input.sessions, - topTools: input.topTools, - topModels: input.topModels, - }; -} - -/** Builds the canonical telemetry query for one day window. */ -export function dailyUsageQuery(day: DayRangeMs): UsageQuery { - return { range: { from: day.fromMs, to: day.toMs } }; -} - -/** Default cap for activity sessions and generated report evidence. */ -export const DAILY_REVIEW_LIST_LIMIT = 8; - -/** Config and durable archive contract shared by Host and Client adapters. */ - -export type DailyReviewRange = 1 | 7 | 30; - -export const DAILY_REVIEW_RANGES: readonly DailyReviewRange[] = [1, 7, 30] as const; - -export type DailyReviewSectionKey = 'summary' | 'gaps' | 'usage' | 'code'; - -export const DAILY_REVIEW_SECTION_KEYS: readonly DailyReviewSectionKey[] = [ - 'summary', - 'gaps', - 'usage', - 'code', -] as const; - -export interface DailyReviewConfig { - readonly enabled: boolean; - /** Local-TZ HH:mm string, e.g. "08:00". */ - readonly executeTime: string; - /** - * Composite model key (e.g. `connectionSlug::modelId`). Empty string - * means "use the chat default model". The pipeline treats empty as - * "no explicit model selected". - */ - readonly modelKey: string; -} - -export type DailyReviewArchiveStatus = 'ok' | 'no_model' | 'no_data' | 'failed' | 'skipped'; - -export const DAILY_REVIEW_ARCHIVE_STATUSES: readonly DailyReviewArchiveStatus[] = [ - 'ok', - 'no_model', - 'no_data', - 'failed', - 'skipped', -] as const; - -export type DailyReviewTrigger = 'cron' | 'manual'; - -export interface DailyReviewArchiveSectionContent { - readonly summary?: string; - readonly gaps?: string; - readonly usage?: string; - readonly code?: string; -} - -export interface DailyReviewArchive { - /** Stable id: `YYYY-MM-DD-{range}d`. Re-runs for the same range overwrite. */ - readonly id: string; - readonly day: DayRangeMs; - readonly range: DailyReviewRange; - readonly status: DailyReviewArchiveStatus; - readonly generatedAt: number; - readonly trigger: DailyReviewTrigger; - readonly modelKey: string; - readonly sections: DailyReviewArchiveSectionContent; - readonly totals: DailyReviewTotals; - readonly errorMessage?: string; -} - -/** Lightweight row for the history list — drops the section bodies. */ -export interface DailyReviewArchiveSummary { - readonly id: string; - readonly day: DayRangeMs; - readonly range: DailyReviewRange; - readonly status: DailyReviewArchiveStatus; - readonly generatedAt: number; - readonly trigger: DailyReviewTrigger; - readonly modelKey: string; - readonly totals: DailyReviewTotals; - readonly errorMessage?: string; -} - -export const DEFAULT_DAILY_REVIEW_CONFIG: DailyReviewConfig = { - enabled: false, - executeTime: '08:00', - modelKey: '', -}; - -const EXECUTE_TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; - -/** Returns true if the string parses as a local HH:mm time. */ -export function isDailyReviewExecuteTime(value: unknown): value is string { - return typeof value === 'string' && EXECUTE_TIME_RE.test(value); -} - -/** Coerces an arbitrary partial config to a fully-valid `DailyReviewConfig`. */ -export function normalizeDailyReviewConfig( - input: - | (Partial & { - readonly sections?: unknown; - readonly deepEnabled?: unknown; - readonly includeClaudeCode?: unknown; - readonly externalNotify?: unknown; - }) - | null - | undefined, -): DailyReviewConfig { - const base = DEFAULT_DAILY_REVIEW_CONFIG; - if (!input) return base; - return { - enabled: typeof input.enabled === 'boolean' ? input.enabled : base.enabled, - executeTime: isDailyReviewExecuteTime(input.executeTime) ? input.executeTime : base.executeTime, - modelKey: typeof input.modelKey === 'string' ? input.modelKey : base.modelKey, - }; -} - -/** Builds the canonical archive id for a given range. */ -export function dailyReviewArchiveId(day: DayRangeMs, range: DailyReviewRange): string { - const d = new Date(day.fromMs); - const yyyy = d.getFullYear(); - const mm = String(d.getMonth() + 1).padStart(2, '0'); - const dd = String(d.getDate()).padStart(2, '0'); - return `${yyyy}-${mm}-${dd}-${range}d`; -} - -export interface ParsedDailyReviewArchiveId { - readonly localDate: string; - readonly range: DailyReviewRange; -} - -/** Parses the durable local-date label without reinterpreting it in the current timezone. */ -export function parseDailyReviewArchiveId(value: unknown): ParsedDailyReviewArchiveId | null { - if (typeof value !== 'string') return null; - const match = /^(\d{4})-(\d{2})-(\d{2})-(1|7|30)d$/.exec(value); - if (!match) return null; - const year = Number(match[1]); - const month = Number(match[2]); - const day = Number(match[3]); - const date = new Date(Date.UTC(year, month - 1, day)); - if ( - date.getUTCFullYear() !== year || - date.getUTCMonth() !== month - 1 || - date.getUTCDate() !== day - ) { - return null; - } - return { - localDate: `${match[1]}-${match[2]}-${match[3]}`, - range: Number(match[4]) as DailyReviewRange, - }; -} - -/** Maps the retired daily/deep read format onto the one range contract. */ -export function normalizeDailyReviewArchive(input: unknown): DailyReviewArchive { - if (!isRecord(input)) throw invalidDailyReviewArchive('record'); - let range: DailyReviewRange; - let expectedIdSuffix: string; - if ('range' in input) { - if (!DAILY_REVIEW_RANGES.includes(input.range as DailyReviewRange)) { - throw invalidDailyReviewArchive('range'); - } - range = input.range as DailyReviewRange; - expectedIdSuffix = `${range}d`; - } else if (input.mode === 'daily' || input.mode === 'deep') { - range = input.mode === 'deep' ? 7 : 1; - expectedIdSuffix = input.mode; - } else { - throw invalidDailyReviewArchive('range'); - } - - if (typeof input.id !== 'string' || input.id.length === 0) { - throw invalidDailyReviewArchive('id'); - } - if ( - !isRecord(input.day) || - !isFiniteNumber(input.day.fromMs) || - !isFiniteNumber(input.day.toMs) || - input.day.toMs <= input.day.fromMs - ) { - throw invalidDailyReviewArchive('day'); - } - const day = { fromMs: input.day.fromMs, toMs: input.day.toMs }; - const canonicalId = parseDailyReviewArchiveId(input.id); - if ( - 'range' in input - ? !canonicalId || canonicalId.range !== range - : !hasValidLegacyDailyReviewArchiveId(input.id, expectedIdSuffix) - ) { - throw invalidDailyReviewArchive('id'); - } - if (!DAILY_REVIEW_ARCHIVE_STATUSES.includes(input.status as DailyReviewArchiveStatus)) { - throw invalidDailyReviewArchive('status'); - } - const status = input.status as DailyReviewArchiveStatus; - if (!isFiniteNumber(input.generatedAt)) throw invalidDailyReviewArchive('generatedAt'); - if (input.trigger !== 'cron' && input.trigger !== 'manual') { - throw invalidDailyReviewArchive('trigger'); - } - if (typeof input.modelKey !== 'string') throw invalidDailyReviewArchive('modelKey'); - const sections = normalizeDailyReviewArchiveSections(input.sections); - if (status === 'ok' && !Object.values(sections).some((content) => content.trim().length > 0)) { - throw invalidDailyReviewArchive('sections'); - } - const totals = normalizeDailyReviewArchiveTotals(input.totals); - if (input.errorMessage !== undefined && typeof input.errorMessage !== 'string') { - throw invalidDailyReviewArchive('errorMessage'); - } - - return { - id: input.id, - day, - range, - status, - generatedAt: input.generatedAt, - trigger: input.trigger, - modelKey: input.modelKey, - sections, - totals, - ...(input.errorMessage === undefined ? {} : { errorMessage: input.errorMessage }), - }; -} - -function normalizeDailyReviewArchiveSections(input: unknown): DailyReviewArchiveSectionContent { - if (!isRecord(input)) throw invalidDailyReviewArchive('sections'); - const sections: Record = {}; - for (const key of DAILY_REVIEW_SECTION_KEYS) { - const value = input[key]; - if (value === undefined) continue; - if (typeof value !== 'string') throw invalidDailyReviewArchive(`sections.${key}`); - sections[key] = value; - } - return sections; -} - -function normalizeDailyReviewArchiveTotals(input: unknown): DailyReviewTotals { - if (!isRecord(input)) throw invalidDailyReviewArchive('totals'); - for (const key of ['sessionCount', 'requestCount', 'totalTokens', 'errorCount'] as const) { - if (!isNonNegativeInteger(input[key])) throw invalidDailyReviewArchive(`totals.${key}`); - } - if (!isFiniteNumber(input.costUsd) || input.costUsd < 0) { - throw invalidDailyReviewArchive('totals.costUsd'); - } - return { - sessionCount: input.sessionCount as number, - requestCount: input.requestCount as number, - totalTokens: input.totalTokens as number, - costUsd: input.costUsd, - errorCount: input.errorCount as number, - }; -} - -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === 'object' && !Array.isArray(value)); -} - -function isFiniteNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value); -} - -function isNonNegativeInteger(value: unknown): value is number { - return isFiniteNumber(value) && Number.isInteger(value) && value >= 0; -} - -function hasValidLegacyDailyReviewArchiveId(id: string, suffix: string): boolean { - const match = /^(\d{4})-(\d{2})-(\d{2})-(daily|deep)$/.exec(id); - if (!match || match[4] !== suffix) return false; - const year = Number(match[1]); - const month = Number(match[2]); - const day = Number(match[3]); - const date = new Date(Date.UTC(year, month - 1, day)); - return ( - date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day - ); -} - -function invalidDailyReviewArchive(field: string): Error { - return new Error(`Invalid Daily Review archive ${field}`); -} - -/** Strips the section bodies down to a lightweight history-list row. */ -export function dailyReviewArchiveToSummary( - archive: DailyReviewArchive, -): DailyReviewArchiveSummary { - return { - id: archive.id, - day: archive.day, - range: archive.range, - status: archive.status, - generatedAt: archive.generatedAt, - trigger: archive.trigger, - modelKey: archive.modelKey, - totals: archive.totals, - ...(archive.errorMessage === undefined ? {} : { errorMessage: archive.errorMessage }), - }; -} diff --git a/packages/core/src/e2e-fixture.ts b/packages/core/src/e2e-fixture.ts index b684297249..b9b22cf6a5 100644 --- a/packages/core/src/e2e-fixture.ts +++ b/packages/core/src/e2e-fixture.ts @@ -35,7 +35,6 @@ export type E2eFixtureScenario = | 'settings-usage' | 'module-skills' | 'module-mcp' - | 'module-daily-review' | 'scheduled-tasks' | 'sidebar-search-modal-open'; @@ -56,7 +55,7 @@ export interface E2eFixtureState { locale?: UiLocale; timezone?: string; searchModalOpen?: boolean; - sidebarSection?: 'sessions' | 'automations' | 'skills' | 'mcp' | 'daily-review'; + sidebarSection?: 'sessions' | 'automations' | 'skills' | 'mcp'; sidebarCollapsed?: boolean; workbarCollapsed?: boolean; workbarTab?: 'review' | 'terminal' | 'tasks' | 'browser' | 'files' | 'inspector'; diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 72a56c67a4..7fddc658f2 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -77,6 +77,7 @@ export type ModelCatalogUserChoiceSource = | 'connection_default' | 'saved_model' | 'session_model' + // Decode-only provenance for catalogs persisted by the retired product path. | 'daily_review_model'; export type SavedModelChoice = diff --git a/packages/core/src/scheduled-task.ts b/packages/core/src/scheduled-task.ts index e741812c34..b1521595b6 100644 --- a/packages/core/src/scheduled-task.ts +++ b/packages/core/src/scheduled-task.ts @@ -75,6 +75,8 @@ export type ScheduledTaskEffect = export interface ScheduledTaskExecutionTemplate { readonly cwd: string; readonly projectId?: string | null; + /** Immutable Connection entity identity. Absent only on tasks written by older builds. */ + readonly llmConnectionId?: string; readonly llmConnectionSlug: string; readonly model: string; readonly thinkingLevel?: ThinkingLevel; @@ -513,6 +515,12 @@ function normalizeExecution( if (typeof value.llmConnectionSlug !== 'string' || !value.llmConnectionSlug.trim()) { return fail('execution.llmConnectionSlug is required'); } + if ( + value.llmConnectionId !== undefined && + (typeof value.llmConnectionId !== 'string' || !value.llmConnectionId.trim()) + ) { + return fail('execution.llmConnectionId is invalid'); + } if (typeof value.model !== 'string' || !value.model.trim()) { return fail('execution.model is required'); } @@ -541,6 +549,9 @@ function normalizeExecution( value: { cwd: value.cwd.trim(), ...(projectId === undefined ? {} : { projectId }), + ...(value.llmConnectionId === undefined + ? {} + : { llmConnectionId: value.llmConnectionId.trim() }), llmConnectionSlug: value.llmConnectionSlug.trim(), model: value.model.trim(), ...(value.thinkingLevel === undefined ? {} : { thinkingLevel: value.thinkingLevel }), diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 001bde9aa6..91e784f005 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -72,7 +72,6 @@ export const SETTINGS_SECTIONS = [ 'appearance', 'projects', 'memory', - 'daily-review', 'models', 'subagents', 'usage', diff --git a/packages/core/src/usage-stats/types.ts b/packages/core/src/usage-stats/types.ts index fc4d266ead..f4beee4790 100644 --- a/packages/core/src/usage-stats/types.ts +++ b/packages/core/src/usage-stats/types.ts @@ -28,6 +28,7 @@ export const MODEL_CALL_KINDS = [ 'goal_evaluation', 'session_title', 'session_recap', + // Decode-only historical ledger fact; new reviews are ordinary Session calls. 'daily_review', 'memory_extraction', ] as const; diff --git a/packages/runtime-host/src/__tests__/daily-review-coordinator.test.ts b/packages/runtime-host/src/__tests__/daily-review-coordinator.test.ts deleted file mode 100644 index c781604f41..0000000000 --- a/packages/runtime-host/src/__tests__/daily-review-coordinator.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -/* - * 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 { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import { localDayBoundsAt, type DailyReviewArchive } from '@maka/core/daily-review'; -import { openInteractiveDailyReviewAuthorityForWrite } from '@maka/storage/daily-review-authority'; -import { acquireOperationalStateDatabase } from '@maka/storage/operational-state-store'; -import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; -import { openInteractiveUsageStoresForWrite } from '@maka/storage/usage-stores'; -import type { ConnectionContext } from '../server/operation-dispatcher.js'; -import { HostDailyReviewCoordinator } from '../server/daily-review-coordinator.js'; - -const CONTEXT: ConnectionContext = { - hostEpoch: 'host-epoch', - connectionId: 'connection-id', - principal: 'local_os_user', - acquireResidency: () => ({ release: () => undefined }), -}; - -test('Daily Review refuses to archive an incomplete canonical Usage projection', async () => { - await withCoordinator(async ({ coordinator, store, root, drainCount }) => { - appendCorruptAuthorityEvent(root, 'session-missing', 'run-missing'); - const outcome = await coordinator.handlers['daily-review.mutate']( - { - kind: 'run', - range: 1, - offsetDays: 0, - modelKeyOverride: '', - replaceExisting: false, - }, - CONTEXT, - ); - - assert.deepEqual(outcome, { - ok: false, - error: { - code: 'projection_incomplete', - message: 'Daily Review is waiting for canonical Usage repair', - }, - }); - assert.equal(drainCount(), 0); - assert.deepEqual(await store.listArchivePage(null, 1), { - archives: [], - nextBeforeArchiveId: null, - }); - }); -}); - -test('Daily Review conflicts rather than coalescing different generation options', async () => { - let releaseModel: (() => void) | undefined; - let notifyModelStarted: (() => void) | undefined; - const modelStarted = new Promise((resolve) => { - notifyModelStarted = resolve; - }); - const modelGate = new Promise((resolve) => { - releaseModel = resolve; - }); - let modelCalls = 0; - - await withCoordinator( - async ({ coordinator, usage }) => { - const now = Date.now(); - await usage.telemetry.recordLlmCall({ - id: 'daily-review-conflict-source', - callKind: 'main', - callId: 'daily-review-conflict-source', - connectionSlug: 'test', - providerId: 'test', - modelId: 'test', - inputTokens: 1, - outputTokens: 1, - cacheHitInputTokens: 0, - cacheMissInputTokens: 1, - cachedInputTokens: 0, - cacheWriteInputTokens: 0, - reasoningTokens: 0, - totalTokens: 2, - costUsd: 0, - latencyMs: 1, - status: 'success', - startedAt: now, - date: new Date(now).toISOString().slice(0, 10), - ts: now, - }); - const first = coordinator.handlers['daily-review.mutate']( - { - kind: 'run', - range: 1, - offsetDays: 0, - modelKeyOverride: 'provider::model-a', - replaceExisting: true, - }, - CONTEXT, - ); - await modelStarted; - const coalesced = coordinator.handlers['daily-review.mutate']( - { - kind: 'run', - range: 1, - offsetDays: 0, - modelKeyOverride: 'provider::model-a', - replaceExisting: false, - }, - CONTEXT, - ); - const conflicting = await coordinator.handlers['daily-review.mutate']( - { - kind: 'run', - range: 1, - offsetDays: 0, - modelKeyOverride: 'provider::model-b', - replaceExisting: true, - }, - CONTEXT, - ); - assert.equal(conflicting.ok, false); - if (!conflicting.ok) assert.equal(conflicting.error.code, 'operation_conflict'); - assert.equal(modelCalls, 1); - assert.ok(releaseModel); - releaseModel(); - const [firstResult, coalescedResult] = await Promise.all([first, coalesced]); - assert.equal(firstResult.ok, true); - assert.deepEqual(coalescedResult, firstResult); - }, - { - generate: async () => { - modelCalls += 1; - notifyModelStarted?.(); - await modelGate; - return { ok: false, errorClass: 'configuration' }; - }, - }, - ); -}); - -test('Daily Review does not coalesce cron and manual archive provenance', async () => { - let releaseModel: (() => void) | undefined; - let notifyModelStarted: (() => void) | undefined; - const modelStarted = new Promise((resolve) => { - notifyModelStarted = resolve; - }); - const modelGate = new Promise((resolve) => { - releaseModel = resolve; - }); - let modelCalls = 0; - - await withCoordinator( - async ({ coordinator, store, usage }) => { - const snapshot = await store.readConfig(); - const update = await store.updateConfig(snapshot.revision, { - enabled: true, - executeTime: '00:00', - modelKey: 'provider::model-a', - }); - assert.equal(update.kind, 'committed'); - const now = localDayBoundsAt(Date.now(), -1).fromMs + 1; - await usage.telemetry.recordLlmCall({ - id: 'daily-review-cron-source', - callKind: 'main', - callId: 'daily-review-cron-source', - connectionSlug: 'test', - providerId: 'test', - modelId: 'test', - inputTokens: 1, - outputTokens: 1, - cacheHitInputTokens: 0, - cacheMissInputTokens: 1, - cachedInputTokens: 0, - cacheWriteInputTokens: 0, - reasoningTokens: 0, - totalTokens: 2, - costUsd: 0, - latencyMs: 1, - status: 'success', - startedAt: now, - date: new Date(now).toISOString().slice(0, 10), - ts: now, - }); - - const recovery = coordinator.recover(); - await modelStarted; - const manual = await coordinator.handlers['daily-review.mutate']( - { - kind: 'run', - range: 1, - offsetDays: -1, - modelKeyOverride: '', - replaceExisting: false, - }, - CONTEXT, - ); - assert.equal(manual.ok, false); - if (!manual.ok) assert.equal(manual.error.code, 'operation_conflict'); - assert.equal(modelCalls, 1); - assert.ok(releaseModel); - releaseModel(); - await recovery; - }, - { - generate: async () => { - modelCalls += 1; - notifyModelStarted?.(); - await modelGate; - return { ok: false, errorClass: 'configuration' }; - }, - }, - false, - ); -}); - -test('Daily Review archive cursors stay stable across concurrent inserts', async () => { - await withCoordinator(async ({ coordinator, store }) => { - await store.publishArchive(archive('2026-08-01-1d'), 180); - await store.publishArchive(archive('2026-08-03-1d'), 180); - const first = await coordinator.handlers['daily-review.query']( - { kind: 'archives', beforeArchiveId: null, limit: 1 }, - CONTEXT, - ); - assert.equal(first.ok, true); - if (!first.ok || first.result.kind !== 'archives') return; - assert.deepEqual( - first.result.archives.map((item) => item.id), - ['2026-08-03-1d'], - ); - assert.equal(first.result.nextBeforeArchiveId, '2026-08-03-1d'); - - await store.publishArchive(archive('2026-08-04-1d'), 180); - const second = await coordinator.handlers['daily-review.query']( - { - kind: 'archives', - beforeArchiveId: first.result.nextBeforeArchiveId, - limit: 1, - }, - CONTEXT, - ); - assert.equal(second.ok, true); - if (!second.ok || second.result.kind !== 'archives') return; - assert.deepEqual( - second.result.archives.map((item) => item.id), - ['2026-08-01-1d'], - ); - assert.equal(second.result.nextBeforeArchiveId, null); - }); -}); - -async function withCoordinator( - run: (input: { - coordinator: HostDailyReviewCoordinator; - store: Awaited>; - usage: Awaited>; - root: string; - drainCount: () => number; - }) => Promise, - model: ConstructorParameters[0]['model'] = { - generate: async () => ({ ok: false, errorClass: 'configuration' }), - }, - recoverBeforeRun = true, -): Promise { - const base = await mkdtemp(join(tmpdir(), 'maka-daily-review-coordinator-')); - const root = join(base, 'interactive'); - const capability = await resolveStorageRoot({ - path: root, - kind: 'interactive', - }); - const owner = await tryAcquireInteractiveRootOwner(capability); - assert.ok(owner); - if (!owner) return; - const store = await openInteractiveDailyReviewAuthorityForWrite(owner.lease); - const usage = await openInteractiveUsageStoresForWrite(owner.lease); - let drains = 0; - const coordinator = new HostDailyReviewCoordinator({ - store, - usage, - sessions: { list: async () => [] }, - model, - acquireResidency: () => ({ release: () => undefined }), - requestDrain: () => { - drains += 1; - }, - }); - try { - if (recoverBeforeRun) await coordinator.recover(); - await run({ - coordinator, - store, - usage, - root, - drainCount: () => drains, - }); - } finally { - await coordinator.close(); - await usage.close(); - if (!owner.closed) await owner.close(); - await rm(base, { recursive: true, force: true }); - } -} - -function appendCorruptAuthorityEvent(root: string, sessionId: string, runId: string): void { - const lease = acquireOperationalStateDatabase(root); - try { - lease.transaction('write', () => { - lease.database - .prepare(` - INSERT INTO core_agent_runs(session_id, run_id, created_at, record_json) - VALUES (?, ?, 0, '{}') - `) - .run(sessionId, runId); - lease.database - .prepare(` - UPDATE core_agent_runs SET latest_model_call_sequence = 0 - WHERE session_id = ? AND run_id = ? - `) - .run(sessionId, runId); - lease.database - .prepare(` - INSERT INTO core_agent_run_events( - session_id, run_id, sequence, event_id, event_type, event_ts, record_json - ) VALUES (?, ?, 0, 'corrupt-model-call', 'model_call_attempt_recorded', 0, '{}') - `) - .run(sessionId, runId); - }); - } finally { - lease.close(); - } -} - -function archive(id: string): DailyReviewArchive { - const [year, month, day] = id.slice(0, 10).split('-').map(Number); - const fromMs = new Date(year ?? 0, (month ?? 1) - 1, day ?? 1).getTime(); - return { - id, - day: { fromMs, toMs: new Date(year ?? 0, (month ?? 1) - 1, (day ?? 1) + 1).getTime() }, - range: 1, - status: 'ok', - generatedAt: fromMs, - trigger: 'manual', - modelKey: 'test::model', - sections: { summary: 'Stable archive.' }, - totals: { - sessionCount: 1, - requestCount: 1, - totalTokens: 2, - costUsd: 0, - errorCount: 0, - }, - }; -} diff --git a/packages/runtime-host/src/__tests__/daily-review-protocol.test.ts b/packages/runtime-host/src/__tests__/daily-review-protocol.test.ts deleted file mode 100644 index ff8587b64e..0000000000 --- a/packages/runtime-host/src/__tests__/daily-review-protocol.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * 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 { DailyReviewArchive } from '@maka/core/daily-review'; -import { - DAILY_REVIEW_PAGE_MAX_ITEMS, - decodeDailyReviewMutateInput, - decodeDailyReviewQueryResult, - decodeRequestFrame, -} from '../protocol/index.js'; - -test('Daily Review protocol rejects open and unbounded inputs', () => { - assert.throws(() => - decodeDailyReviewMutateInput({ - kind: 'run', - range: 1, - offsetDays: 0, - modelKeyOverride: '', - replaceExisting: false, - extra: true, - }), - ); - assert.throws(() => - decodeRequestFrame({ - requestId: 'request-1', - operation: 'daily-review.query', - input: { - kind: 'archives', - beforeArchiveId: null, - limit: DAILY_REVIEW_PAGE_MAX_ITEMS + 1, - }, - }), - ); - assert.throws(() => - decodeDailyReviewQueryResult({ - kind: 'archives', - archives: Array.from({ length: DAILY_REVIEW_PAGE_MAX_ITEMS + 1 }, archiveSummary), - beforeArchiveId: null, - nextBeforeArchiveId: null, - }), - ); - assert.throws(() => - decodeDailyReviewQueryResult({ - kind: 'archives', - archives: [archiveSummary()], - beforeArchiveId: null, - nextBeforeArchiveId: '2026-08-02-1d', - }), - ); - assert.throws(() => - decodeDailyReviewQueryResult({ - kind: 'archives', - archives: [{ ...archiveSummary(), range: 7 }], - beforeArchiveId: null, - nextBeforeArchiveId: null, - }), - ); - assert.throws(() => - decodeDailyReviewQueryResult({ - kind: 'archive', - archive: { - ...archive(), - totals: { ...archive().totals, costUsd: -1 }, - }, - }), - ); -}); - -function archive(): DailyReviewArchive { - const fromMs = new Date(2026, 7, 3).getTime(); - return { - id: '2026-08-03-1d', - day: { fromMs, toMs: new Date(2026, 7, 4).getTime() }, - range: 1, - status: 'ok', - generatedAt: fromMs + 1, - trigger: 'manual', - modelKey: 'openrouter::openrouter/free', - sections: { summary: 'One review.' }, - totals: { - sessionCount: 1, - requestCount: 2, - totalTokens: 3, - costUsd: 0, - errorCount: 0, - }, - }; -} - -function archiveSummary() { - const { sections: _sections, ...summary } = archive(); - return summary; -} diff --git a/packages/runtime-host/src/__tests__/daily-review-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/daily-review-two-client-uds.test.ts deleted file mode 100644 index fac9c490a5..0000000000 --- a/packages/runtime-host/src/__tests__/daily-review-two-client-uds.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -/* - * 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 { defineInteractiveRuntimeHostComposition } from '../server/host-composition.js'; -import assert from 'node:assert/strict'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import type { DailyReviewArchive } from '@maka/core/daily-review'; -import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; -import { openInteractiveUsageStoresForWrite } from '@maka/storage/usage-stores'; -import { connectRuntimeHost, type RuntimeHostConnection } from '../client/index.js'; -import { RUNTIME_HOST_PROTOCOL_VERSION } from '../protocol/index.js'; -import { createExecutionRuntimeHostComposition } from '../server/execution-composition.js'; -import { RuntimeHostKernel } from '../server/host-kernel.js'; - -const PROTOCOL = { - min: RUNTIME_HOST_PROTOCOL_VERSION, - max: RUNTIME_HOST_PROTOCOL_VERSION, -} as const; - -test('two Clients share Daily Review config, generation, and restart recovery', async () => { - const base = await mkdtemp(join(tmpdir(), 'maka-host-daily-review-uds-')); - const root = join(base, 'interactive'); - const capability = await resolveStorageRoot({ - path: root, - kind: 'interactive', - }); - let owner = await tryAcquireInteractiveRootOwner(capability); - assert.ok(owner); - if (!owner) return; - let host: Awaited> | undefined; - let desktop: RuntimeHostConnection | undefined; - let tui: RuntimeHostConnection | undefined; - let setupUsage: Awaited> | undefined; - try { - const usage = await openInteractiveUsageStoresForWrite(owner.lease); - setupUsage = usage; - const now = Date.now(); - await usage.telemetry.recordLlmCall({ - id: 'usage-daily-review-test', - callKind: 'main', - callId: 'daily-review-source-call', - connectionSlug: 'missing-provider', - providerId: 'missing-provider', - modelId: 'missing-model', - inputTokens: 2, - outputTokens: 3, - cacheHitInputTokens: 0, - cacheMissInputTokens: 2, - cachedInputTokens: 0, - cacheWriteInputTokens: 0, - reasoningTokens: 0, - totalTokens: 5, - costUsd: 0, - latencyMs: 1, - status: 'success', - startedAt: now - 1, - date: new Date(now).toISOString().slice(0, 10), - ts: now, - }); - host = await RuntimeHostKernel.start({ - owner, - idleGraceMs: 30_000, - composition: defineInteractiveRuntimeHostComposition(createExecutionRuntimeHostComposition), - }); - owner = undefined; - [desktop, tui] = await Promise.all([connect(root), connect(root)]); - - const initial = await desktop.request('daily-review.query', { kind: 'config' }); - assert.deepEqual(initial, { - kind: 'config', - revision: 0, - config: { enabled: false, executeTime: '08:00', modelKey: '' }, - }); - const mutations = await Promise.all([ - desktop.request('daily-review.mutate', { - kind: 'update_config', - expectedRevision: 0, - config: { enabled: false, executeTime: '09:00', modelKey: '' }, - }), - tui.request('daily-review.mutate', { - kind: 'update_config', - expectedRevision: 0, - config: { enabled: false, executeTime: '10:00', modelKey: '' }, - }), - ]); - assert.equal(mutations.filter((result) => result.kind === 'config_committed').length, 1); - assert.equal(mutations.filter((result) => result.kind === 'revision_conflict').length, 1); - assert.deepEqual( - await desktop.request('daily-review.query', { kind: 'config' }), - await tui.request('daily-review.query', { kind: 'config' }), - ); - - const run = { - kind: 'run' as const, - range: 1 as const, - offsetDays: -2, - modelKeyOverride: '', - replaceExisting: false, - }; - const [desktopRun, tuiRun] = await Promise.all([ - desktop.request('daily-review.mutate', run), - tui.request('daily-review.mutate', run), - ]); - assert.deepEqual(tuiRun, desktopRun); - assert.equal(desktopRun.kind, 'archive'); - if (desktopRun.kind !== 'archive') return; - assert.equal(desktopRun.archive.status, 'no_data'); - - const noModel = await desktop.request('daily-review.mutate', { - ...run, - offsetDays: 0, - modelKeyOverride: 'missing-provider::missing-model', - }); - assert.equal(noModel.kind, 'archive'); - if (noModel.kind !== 'archive') return; - assert.equal(noModel.archive.status, 'no_model'); - assert.equal(noModel.archive.totals.requestCount, 1); - - const enabled = await desktop.request('daily-review.mutate', { - kind: 'update_config', - expectedRevision: 1, - config: { - enabled: true, - executeTime: '00:00', - modelKey: 'missing-provider::missing-model', - }, - }); - assert.equal(enabled.kind, 'config_committed'); - const scheduled = await waitForScheduledArchive(desktop); - - await Promise.all([desktop.close(), tui.close()]); - desktop = undefined; - tui = undefined; - await host.close(); - host = undefined; - - owner = await tryAcquireInteractiveRootOwner(capability); - assert.ok(owner); - if (!owner) return; - host = await RuntimeHostKernel.start({ - owner, - idleGraceMs: 30_000, - composition: defineInteractiveRuntimeHostComposition(createExecutionRuntimeHostComposition), - }); - owner = undefined; - tui = await connect(root); - assert.deepEqual( - await tui.request('daily-review.query', { - kind: 'archive', - archiveId: desktopRun.archive.id, - }), - { kind: 'archive', archive: desktopRun.archive }, - ); - assert.deepEqual( - await tui.request('daily-review.query', { - kind: 'archive', - archiveId: scheduled.id, - }), - { kind: 'archive', archive: scheduled }, - ); - } finally { - await Promise.allSettled([desktop?.close(), tui?.close()]); - await host?.close().catch(() => undefined); - await setupUsage?.close().catch(() => undefined); - await owner?.close().catch(() => undefined); - await rm(base, { recursive: true, force: true }); - } -}); - -async function connect(rootPath: string): Promise { - const result = await connectRuntimeHost({ - rootPath, - protocol: PROTOCOL, - }); - assert.equal(result.kind, 'connected'); - if (result.kind !== 'connected') throw new Error('Unable to connect to Runtime Host'); - return result.connection; -} - -async function waitForScheduledArchive( - connection: RuntimeHostConnection, -): Promise { - const deadline = Date.now() + 5_000; - while (Date.now() < deadline) { - const page = await connection.request('daily-review.query', { - kind: 'archives', - beforeArchiveId: null, - limit: 10, - }); - if (page.kind === 'archives') { - const scheduled = page.archives.find((archive) => archive.trigger === 'cron'); - if (scheduled) { - const result = await connection.request('daily-review.query', { - kind: 'archive', - archiveId: scheduled.id, - }); - if (result.kind === 'archive' && result.archive) return result.archive; - } - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error('Scheduled Daily Review archive was not published'); -} diff --git a/packages/runtime-host/src/__tests__/execution-host.test.ts b/packages/runtime-host/src/__tests__/execution-host.test.ts index de2c0c7ac6..b5b04022ca 100644 --- a/packages/runtime-host/src/__tests__/execution-host.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host.test.ts @@ -34,6 +34,7 @@ import { createServer, type Server } from 'node:http'; import { connect, type Socket } from 'node:net'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import { test } from 'node:test'; import { TOOL_BOUNDARY_PROTOCOL_V1 } from '@maka/core/runtime-event'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; @@ -161,23 +162,315 @@ test('production Host resumes a Session through the ScheduledTask authority', { }); }); -test('production Host fails slug-only ScheduledTask Agent runs before binding execution identity', { +test('production Host migrates Daily Review into ScheduledTask Session and Artifact authorities', { timeout: 30_000, }, async () => { await withExecutionRoot(async (fixture) => { + const archive = { + id: '2026-08-28-1d', + day: { fromMs: 1_772_140_800_000, toMs: 1_772_227_200_000 }, + range: 1, + status: 'ok', + generatedAt: 1_772_227_200_001, + trigger: 'cron', + modelKey: 'fake::fake-model', + sections: { summary: 'A migrated report.' }, + totals: { + sessionCount: 2, + requestCount: 3, + totalTokens: 4, + costUsd: 0.01, + errorCount: 0, + }, + } as const; + const database = new DatabaseSync(join(fixture.root, 'runtime.sqlite')); + database.exec(` + CREATE TABLE workflow_daily_review_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + config_json TEXT NOT NULL + ); + CREATE TABLE workflow_daily_review_authority_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + revision INTEGER NOT NULL CHECK (revision >= 0) + ); + CREATE TABLE workflow_daily_review_archives ( + archive_id TEXT PRIMARY KEY, + generated_at INTEGER NOT NULL, + day_from_ms INTEGER NOT NULL, + record_json TEXT NOT NULL + ); + CREATE INDEX workflow_daily_review_archives_order + ON workflow_daily_review_archives(generated_at DESC, day_from_ms DESC, archive_id); + `); + database + .prepare('INSERT INTO workflow_daily_review_state(singleton, config_json) VALUES (1, ?)') + .run( + JSON.stringify({ + enabled: true, + executeTime: '23:58', + modelKey: archive.modelKey, + }), + ); + database + .prepare( + `INSERT INTO workflow_daily_review_archives( + archive_id, generated_at, day_from_ms, record_json + ) VALUES (?, ?, ?, ?)`, + ) + .run(archive.id, archive.generatedAt, archive.day.fromMs, JSON.stringify(archive)); + database.close(); + + const unresolvedHost = await fixture.startHost(); + const unresolvedDesktop = await connectClient(fixture.root); + try { + const taskPage = await unresolvedDesktop.request('scheduled-task.query', { kind: 'list' }); + assert.equal(taskPage.kind, 'page'); + if (taskPage.kind !== 'page') return; + assert.equal( + taskPage.tasks.some((candidate) => candidate.id === 'system-daily-review'), + false, + ); + const artifacts = await unresolvedDesktop.request('artifact.query', { + kind: 'list_start', + sessionId: 'daily-review-archive-2026-08-28-1d', + }); + assert.equal(artifacts.kind, 'page'); + if (artifacts.kind !== 'page') return; + assert.equal(artifacts.artifacts.length, 1); + } finally { + await unresolvedDesktop.close(); + await fixture.stopHost(unresolvedHost); + } + + const pending = new DatabaseSync(join(fixture.root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal( + Boolean( + pending + .prepare( + "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'workflow_daily_review_state'", + ) + .get(), + ), + true, + ); + } finally { + pending.close(); + } + + const policyOwner = await tryAcquireInteractiveRootOwner(fixture.capability); + assert.ok(policyOwner); + if (!policyOwner) return; + const policy = await openInteractiveRuntimePolicyStoresForWrite(policyOwner.lease); + const current = await policy.connectionCatalog.getSnapshot(); + const createdConnection = await policy.connectionCatalog.create({ + expectedCatalogRevision: current.revision, + connection: { + slug: 'fake', + name: 'Migrated Daily Review fixture', + providerType: 'moonshot', + enabled: true, + enabledModelIds: ['fake-model'], + }, + }); + assert.equal(createdConnection.kind, 'committed'); + if (createdConnection.kind !== 'committed') return; + const connection = createdConnection.snapshot.connections.find(({ slug }) => slug === 'fake'); + assert.ok(connection); + if (!connection) return; + const credential = await policy.credentialVault.set({ + locator: { + scope: 'connection', + connectionId: connection.connectionId, + kind: 'api_key', + }, + expected: null, + secret: 'daily-review-migration-test-key', + }); + assert.equal(credential.kind, 'committed'); + await policyOwner.close(); + + const host = await fixture.startHost(); + const desktop = await connectClient(fixture.root); + try { + const taskPage = await desktop.request('scheduled-task.query', { kind: 'list' }); + assert.equal(taskPage.kind, 'page'); + if (taskPage.kind !== 'page') return; + const task = taskPage.tasks.find((candidate) => candidate.id === 'system-daily-review'); + assert.ok(task); + assert.equal(task?.createdBy.kind, 'system'); + assert.equal(task?.status, 'active'); + assert.equal( + task?.effect.kind === 'agent_run' ? task.effect.execution.llmConnectionId : undefined, + connection.connectionId, + ); + assert.deepEqual(task?.schedule, { + kind: 'calendar', + recurrence: 'daily', + anchorAt: task?.schedule.kind === 'calendar' ? task.schedule.anchorAt : -1, + }); + if (task?.schedule.kind === 'calendar') { + const anchor = new Date(task.schedule.anchorAt); + assert.equal( + `${anchor.getHours()}:${String(anchor.getMinutes()).padStart(2, '0')}`, + '23:58', + ); + } + + const sessionId = 'daily-review-archive-2026-08-28-1d'; + const session = await desktop.request('session.catalog.query', { kind: 'get', sessionId }); + assert.equal(session.kind, 'session'); + assert.equal(session.session?.id, sessionId); + assert.ok(session.session && !('kind' in session.session)); + if (!session.session || 'kind' in session.session) return; + assert.deepEqual(session.session.labels, ['migrated:daily-review']); + + const artifacts = await desktop.request('artifact.query', { kind: 'list_start', sessionId }); + assert.equal(artifacts.kind, 'page'); + if (artifacts.kind !== 'page') return; + assert.equal(artifacts.artifacts.length, 1); + const artifact = artifacts.artifacts[0]; + assert.equal(artifact?.id, 'daily-review-report-2026-08-28-1d'); + const report = await desktop.request('artifact.query', { + kind: 'read_text', + sessionId, + artifactId: artifact!.id, + }); + assert.equal(report.kind, 'text'); + if (report.kind !== 'text' || !report.preview.ok) return; + assert.match(report.preview.text, /A migrated report\./u); + assert.match(report.preview.text, /Archive ID: 2026-08-28-1d/u); + assert.match(report.preview.text, /Trigger: cron/u); + assert.match(report.preview.text, /Model: fake::fake-model/u); + assert.match(report.preview.text, /Generated at: 1772227200001/u); + + const fired = await desktop.request('scheduled-task.mutate', { + kind: 'trigger_now', + taskId: 'system-daily-review', + }); + assert.equal(fired.kind, 'task'); + if (fired.kind !== 'task') return; + assert.equal(fired.task.runs.length, 1); + assert.ok(fired.task.runs[0]?.sessionId); + assert.ok(fired.task.runs[0]?.runId); + const runSession = await desktop.request('session.catalog.query', { + kind: 'get', + sessionId: fired.task.runs[0]!.sessionId!, + }); + assert.equal(runSession.kind, 'session'); + assert.ok( + runSession.session && !('kind' in runSession.session), + `${fired.task.lastError ?? 'ScheduledTask Session missing'}: ${JSON.stringify(runSession)}`, + ); + assert.equal(runSession.session?.id, fired.task.runs[0]?.sessionId); + } finally { + await desktop.close(); + await fixture.stopHost(host); + } + + const owner = await tryAcquireInteractiveRootOwner(fixture.capability); + assert.ok(owner); + if (!owner) return; + let stores: Awaited> | undefined; + try { + stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const messages = await stores.sessionStore.readMessagesSnapshot( + 'daily-review-archive-2026-08-28-1d', + ); + assert.equal(messages.length, 1); + assert.equal(messages[0]?.type, 'assistant'); + assert.match(messages[0]?.text ?? '', /A migrated report\./u); + } finally { + await stores?.sessionStore.close?.(); + await owner.close(); + } + + const retired = new DatabaseSync(join(fixture.root, 'runtime.sqlite'), { readOnly: true }); + try { + const tables = retired + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name LIKE 'workflow_daily_review_%'", + ) + .all(); + assert.deepEqual(tables, []); + } finally { + retired.close(); + } + + const restartedHost = await fixture.startHost(); + const restartedDesktop = await connectClient(fixture.root); + try { + const taskPage = await restartedDesktop.request('scheduled-task.query', { kind: 'list' }); + assert.equal(taskPage.kind, 'page'); + if (taskPage.kind !== 'page') return; + assert.equal( + taskPage.tasks.filter((candidate) => candidate.id === 'system-daily-review').length, + 1, + ); + const artifacts = await restartedDesktop.request('artifact.query', { + kind: 'list_start', + sessionId: 'daily-review-archive-2026-08-28-1d', + }); + assert.equal(artifacts.kind, 'page'); + if (artifacts.kind !== 'page') return; + assert.equal(artifacts.artifacts.length, 1); + } finally { + await restartedDesktop.close(); + await fixture.stopHost(restartedHost); + } + }); +}); + +test('production Host starts ScheduledTask Agent runs in an ordinary Session', { + timeout: 30_000, +}, async () => { + await withExecutionRoot(async (fixture) => { + const owner = await tryAcquireInteractiveRootOwner(fixture.capability); + assert.ok(owner); + if (!owner) return; + const policy = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + const current = await policy.connectionCatalog.getSnapshot(); + const createdConnection = await policy.connectionCatalog.create({ + expectedCatalogRevision: current.revision, + connection: { + slug: 'fake', + name: 'ScheduledTask fixture', + providerType: 'moonshot', + enabled: true, + enabledModelIds: ['fake-model'], + }, + }); + assert.equal(createdConnection.kind, 'committed'); + if (createdConnection.kind !== 'committed') return; + const connection = createdConnection.snapshot.connections.find(({ slug }) => slug === 'fake'); + assert.ok(connection); + if (!connection) return; + const credential = await policy.credentialVault.set({ + locator: { + scope: 'connection', + connectionId: connection.connectionId, + kind: 'api_key', + }, + expected: null, + secret: 'scheduled-task-test-key', + }); + assert.equal(credential.kind, 'committed'); + await owner.close(); + const host = await fixture.startHost(); const desktop = await connectClient(fixture.root); try { const created = await desktop.request('scheduled-task.mutate', { kind: 'create', input: { - title: 'legacy agent-run identity proof', - intentBody: 'Do not execute with a replacement account.', + title: 'scheduled agent-run Session proof', + intentBody: 'Run through the ordinary Session authority.', schedule: { kind: 'once', runAt: Date.now() + 60_000 }, effect: { kind: 'agent_run', execution: { cwd: fixture.root, + llmConnectionId: connection.connectionId, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -196,14 +489,17 @@ test('production Host fails slug-only ScheduledTask Agent runs before binding ex }); assert.equal(fired.kind, 'task'); if (fired.kind !== 'task') return; - assert.equal( - fired.task.lastError, - 'ScheduledTask Agent runs require an immutable model connection identity', - ); assert.equal(fired.task.runs.length, 1); - assert.equal(fired.task.runs[0]?.outcome, 'failed'); - assert.equal(fired.task.runs[0]?.sessionId, undefined); - assert.equal(fired.task.runs[0]?.runId, undefined); + const sessionId = fired.task.runs[0]?.sessionId; + assert.ok(sessionId); + assert.ok(fired.task.runs[0]?.runId); + const session = await desktop.request('session.catalog.query', { + kind: 'get', + sessionId: sessionId!, + }); + assert.equal(session.kind, 'session'); + assert.ok(session.session, fired.task.lastError ?? 'ScheduledTask Session was not created'); + assert.equal(session.session?.id, sessionId); } finally { await desktop.close(); await fixture.stopHost(host); diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 2b8b245124..0d24a96648 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -83,7 +83,6 @@ import type { ClientCapabilityHostFrame } from '../protocol/index.js'; import { createExecutionRuntimeHostComposition } from '../server/execution-composition.js'; import { createHostChildAgentToolComposition } from '../server/child-agent-composition.js'; import { - createHostDailyReviewModel, createHostGoalEvaluator, createHostMemoryExtractionModel, createHostSessionEffectModel, @@ -2565,31 +2564,6 @@ test('Host auxiliary models meter provider usage and abort physical requests', { ), ); - const dailyReview = createHostDailyReviewModel({ - runtimePolicy: policy, - oauthCredentials: new HostOAuthExecutionAuthority(policy), - usage, - requestDrain: () => assert.fail('Daily Review telemetry must not drain the Host'), - newId: () => 'daily-review-call-1', - }); - assert.deepEqual( - await dailyReview.generate({ - modelKey: `goal-evaluator-provider::${MODEL_ID}`, - prompt: 'Generate one Daily Review.', - abortSignal: new AbortController().signal, - }), - { - ok: true, - text: SUMMARY_TEXT, - modelKey: `goal-evaluator-provider::${MODEL_ID}`, - }, - ); - const dailyReviewLogs = await usage.telemetry.logs({ range: 'all' }); - const dailyReviewLog = dailyReviewLogs.rows.find((row) => row.callKind === 'daily_review'); - assert.ok(dailyReviewLog); - assert.equal(dailyReviewLog.callId, 'daily_review_daily-review-call-1'); - assert.equal(dailyReviewLog.sessionId, undefined); - const memoryModel = createHostMemoryExtractionModel({ runtimePolicy: policy, oauthCredentials: new HostOAuthExecutionAuthority(policy), diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index bc348ac164..f8680d860c 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -171,6 +171,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 33); }); + test('publishes a new compatibility epoch for retired Daily Review operations', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 64); + }); + test('publishes a new compatibility epoch for Session trace pagination', () => { // Epoch 34 peers cannot exchange the paged trace and usage frames. Also a // floor, for the same reason as above. diff --git a/packages/runtime-host/src/__tests__/scheduled-task-protocol.test.ts b/packages/runtime-host/src/__tests__/scheduled-task-protocol.test.ts index 3d9ea5e7d1..06e17820df 100644 --- a/packages/runtime-host/src/__tests__/scheduled-task-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/scheduled-task-protocol.test.ts @@ -169,6 +169,7 @@ function agentRunEffect(projectId: string | null | undefined): ScheduledTaskEffe execution: { cwd: '/workspace', ...(projectId === undefined ? {} : { projectId }), + llmConnectionId: 'connection-openai', llmConnectionSlug: 'openai', model: 'gpt-5', permissionMode: 'ask', diff --git a/packages/runtime-host/src/protocol/daily-review.ts b/packages/runtime-host/src/protocol/daily-review.ts deleted file mode 100644 index 20c0a57e0a..0000000000 --- a/packages/runtime-host/src/protocol/daily-review.ts +++ /dev/null @@ -1,621 +0,0 @@ -/* - * 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 { - DAILY_REVIEW_ARCHIVE_STATUSES, - DAILY_REVIEW_LIST_LIMIT, - DAILY_REVIEW_RANGES, - DAILY_REVIEW_SECTION_KEYS, - isDailyReviewExecuteTime, - normalizeDailyReviewArchive, - normalizeDailyReviewConfig, - parseDailyReviewArchiveId, - type DailyReviewArchive, - type DailyReviewArchiveSummary, - type DailyReviewConfig, - type DailyReviewRange, - type DailyReviewSummary, -} from '@maka/core/daily-review'; -import { - requireCount, - requireEncodedByteLimit, - requireEntityId, - requireExactRecord, - requireRecord, - requireShapedRecord, -} from './codec.js'; -import { invalidProtocolFrame } from './errors.js'; -import { defineOperation } from './operation-spec.js'; - -export const DAILY_REVIEW_PAGE_MAX_ITEMS = 32; -export const DAILY_REVIEW_RESULT_MAX_BYTES = 64 * 1024; -export const DAILY_REVIEW_MODEL_KEY_MAX_BYTES = 1024; -export const DAILY_REVIEW_OFFSET_DAYS_MAX = 3_650; - -const QUERY_ERRORS = [ - 'host_not_ready', - 'host_draining', - 'operation_unavailable', - 'invalid_request', - 'projection_incomplete', - 'persistence_failed', - 'internal_failure', -] as const; -const MUTATION_ERRORS = [...QUERY_ERRORS, 'operation_conflict'] as const; - -export type DailyReviewQueryInput = - | { readonly kind: 'config' } - | { - readonly kind: 'summary'; - readonly daySpan: number; - readonly offsetDays: number; - } - | { - readonly kind: 'archives'; - readonly beforeArchiveId: string | null; - readonly limit: number; - } - | { readonly kind: 'archive'; readonly archiveId: string }; - -export type DailyReviewQueryResult = - | { - readonly kind: 'config'; - readonly revision: number; - readonly config: DailyReviewConfig; - } - | { readonly kind: 'summary'; readonly summary: DailyReviewSummary } - | { - readonly kind: 'archives'; - readonly archives: readonly DailyReviewArchiveSummary[]; - readonly beforeArchiveId: string | null; - readonly nextBeforeArchiveId: string | null; - } - | { readonly kind: 'archive'; readonly archive: DailyReviewArchive | null }; - -export type DailyReviewMutateInput = - | { - readonly kind: 'update_config'; - readonly expectedRevision: number; - readonly config: DailyReviewConfig; - } - | { - readonly kind: 'run'; - readonly range: DailyReviewRange; - readonly offsetDays: number; - readonly modelKeyOverride: string; - readonly replaceExisting: boolean; - } - | { readonly kind: 'delete'; readonly archiveId: string }; - -export type DailyReviewMutateResult = - | { - readonly kind: 'config_committed' | 'config_unchanged'; - readonly revision: number; - readonly config: DailyReviewConfig; - } - | { - readonly kind: 'revision_conflict'; - readonly expectedRevision: number; - readonly actualRevision: number; - } - | { readonly kind: 'archive'; readonly archive: DailyReviewArchive } - | { - readonly kind: 'deleted'; - readonly archiveId: string; - readonly deleted: boolean; - }; - -export const DAILY_REVIEW_OPERATION_SPECS = { - 'daily-review.query': defineOperation< - DailyReviewQueryInput, - DailyReviewQueryResult, - (typeof QUERY_ERRORS)[number] - >({ - mode: 'query', - availability: 'ready', - errors: QUERY_ERRORS, - decodeInput: decodeDailyReviewQueryInput, - decodeOutput: decodeDailyReviewQueryResult, - }), - 'daily-review.mutate': defineOperation< - DailyReviewMutateInput, - DailyReviewMutateResult, - (typeof MUTATION_ERRORS)[number] - >({ - mode: 'command', - availability: 'ready', - errors: MUTATION_ERRORS, - decodeInput: decodeDailyReviewMutateInput, - decodeOutput: decodeDailyReviewMutateResult, - }), -} as const; - -export function decodeDailyReviewQueryInput(value: unknown): DailyReviewQueryInput { - const record = requireRecord(value, 'Daily Review query input'); - switch (record.kind) { - case 'config': - requireExactRecord(record, 'Daily Review config query input', ['kind']); - return { kind: 'config' }; - case 'summary': { - const input = requireExactRecord(record, 'Daily Review summary query input', [ - 'kind', - 'daySpan', - 'offsetDays', - ]); - const daySpan = requireCount(input.daySpan, 'Daily Review day span'); - if (daySpan === 0 || daySpan > 30) { - throw invalidProtocolFrame('Daily Review day span is out of range'); - } - return { - kind: 'summary', - daySpan, - offsetDays: requireOffsetDays(input.offsetDays), - }; - } - case 'archives': { - const input = requireExactRecord(record, 'Daily Review archives query input', [ - 'kind', - 'beforeArchiveId', - 'limit', - ]); - const limit = requireCount(input.limit, 'Daily Review page limit'); - if (limit === 0 || limit > DAILY_REVIEW_PAGE_MAX_ITEMS) { - throw invalidProtocolFrame('Daily Review page limit is out of range'); - } - return { - kind: 'archives', - beforeArchiveId: - input.beforeArchiveId === null ? null : requireArchiveId(input.beforeArchiveId), - limit, - }; - } - case 'archive': { - const input = requireExactRecord(record, 'Daily Review archive query input', [ - 'kind', - 'archiveId', - ]); - return { kind: 'archive', archiveId: requireArchiveId(input.archiveId) }; - } - default: - throw invalidProtocolFrame('Invalid Daily Review query kind'); - } -} - -export function decodeDailyReviewQueryResult(value: unknown): DailyReviewQueryResult { - const record = requireRecord(value, 'Daily Review query result'); - let result: DailyReviewQueryResult; - switch (record.kind) { - case 'config': { - const output = requireExactRecord(record, 'Daily Review config query result', [ - 'kind', - 'revision', - 'config', - ]); - result = { - kind: 'config', - revision: requireCount(output.revision, 'Daily Review revision'), - config: requireConfig(output.config), - }; - break; - } - case 'summary': { - const output = requireExactRecord(record, 'Daily Review summary query result', [ - 'kind', - 'summary', - ]); - result = { kind: 'summary', summary: requireSummary(output.summary) }; - break; - } - case 'archives': { - const output = requireExactRecord(record, 'Daily Review archives query result', [ - 'kind', - 'archives', - 'beforeArchiveId', - 'nextBeforeArchiveId', - ]); - if (!Array.isArray(output.archives) || output.archives.length > DAILY_REVIEW_PAGE_MAX_ITEMS) { - throw invalidProtocolFrame('Daily Review archive page exceeds item limit'); - } - const beforeArchiveId = - output.beforeArchiveId === null ? null : requireArchiveId(output.beforeArchiveId); - const nextBeforeArchiveId = - output.nextBeforeArchiveId === null ? null : requireArchiveId(output.nextBeforeArchiveId); - const archives = output.archives.map(requireArchiveSummary); - if ( - archives.some((archive, index) => { - if (beforeArchiveId !== null && archive.id >= beforeArchiveId) return true; - const previous = archives[index - 1]; - return previous !== undefined && previous.id <= archive.id; - }) - ) { - throw invalidProtocolFrame('Invalid Daily Review archive page order'); - } - if ( - nextBeforeArchiveId !== null && - (archives.length === 0 || archives.at(-1)?.id !== nextBeforeArchiveId) - ) { - throw invalidProtocolFrame('Invalid Daily Review archive page cursor'); - } - result = { - kind: 'archives', - archives, - beforeArchiveId, - nextBeforeArchiveId, - }; - break; - } - case 'archive': { - const output = requireExactRecord(record, 'Daily Review archive query result', [ - 'kind', - 'archive', - ]); - result = { - kind: 'archive', - archive: output.archive === null ? null : requireArchive(output.archive), - }; - break; - } - default: - throw invalidProtocolFrame('Invalid Daily Review query result kind'); - } - requireEncodedByteLimit(result, 'Daily Review query result', DAILY_REVIEW_RESULT_MAX_BYTES); - return result; -} - -export function decodeDailyReviewMutateInput(value: unknown): DailyReviewMutateInput { - const record = requireRecord(value, 'Daily Review mutation input'); - if (record.kind === 'update_config') { - const input = requireExactRecord(record, 'Daily Review config mutation input', [ - 'kind', - 'expectedRevision', - 'config', - ]); - return { - kind: 'update_config', - expectedRevision: requireCount(input.expectedRevision, 'Daily Review expected revision'), - config: requireConfig(input.config), - }; - } - if (record.kind === 'run') { - const input = requireExactRecord(record, 'Daily Review run input', [ - 'kind', - 'range', - 'offsetDays', - 'modelKeyOverride', - 'replaceExisting', - ]); - if (typeof input.replaceExisting !== 'boolean') { - throw invalidProtocolFrame('Invalid Daily Review replaceExisting flag'); - } - return { - kind: 'run', - range: requireRange(input.range), - offsetDays: requireOffsetDays(input.offsetDays), - modelKeyOverride: requireModelKey(input.modelKeyOverride), - replaceExisting: input.replaceExisting, - }; - } - if (record.kind === 'delete') { - const input = requireExactRecord(record, 'Daily Review delete input', ['kind', 'archiveId']); - return { kind: 'delete', archiveId: requireArchiveId(input.archiveId) }; - } - throw invalidProtocolFrame('Invalid Daily Review mutation kind'); -} - -export function decodeDailyReviewMutateResult(value: unknown): DailyReviewMutateResult { - const record = requireRecord(value, 'Daily Review mutation result'); - let result: DailyReviewMutateResult; - if (record.kind === 'config_committed' || record.kind === 'config_unchanged') { - const output = requireExactRecord(record, 'Daily Review config mutation result', [ - 'kind', - 'revision', - 'config', - ]); - result = { - kind: record.kind, - revision: requireCount(output.revision, 'Daily Review revision'), - config: requireConfig(output.config), - }; - } else if (record.kind === 'revision_conflict') { - const output = requireExactRecord(record, 'Daily Review revision conflict result', [ - 'kind', - 'expectedRevision', - 'actualRevision', - ]); - result = { - kind: 'revision_conflict', - expectedRevision: requireCount(output.expectedRevision, 'Daily Review expected revision'), - actualRevision: requireCount(output.actualRevision, 'Daily Review actual revision'), - }; - } else if (record.kind === 'archive') { - const output = requireExactRecord(record, 'Daily Review run result', ['kind', 'archive']); - result = { kind: 'archive', archive: requireArchive(output.archive) }; - } else if (record.kind === 'deleted') { - const output = requireExactRecord(record, 'Daily Review delete result', [ - 'kind', - 'archiveId', - 'deleted', - ]); - if (typeof output.deleted !== 'boolean') { - throw invalidProtocolFrame('Invalid Daily Review delete result'); - } - result = { - kind: 'deleted', - archiveId: requireArchiveId(output.archiveId), - deleted: output.deleted, - }; - } else { - throw invalidProtocolFrame('Invalid Daily Review mutation result kind'); - } - requireEncodedByteLimit(result, 'Daily Review mutation result', DAILY_REVIEW_RESULT_MAX_BYTES); - return result; -} - -function requireRange(value: unknown): DailyReviewRange { - if (!DAILY_REVIEW_RANGES.includes(value as DailyReviewRange)) { - throw invalidProtocolFrame('Invalid Daily Review range'); - } - return value as DailyReviewRange; -} - -function requireOffsetDays(value: unknown): number { - if ( - !Number.isSafeInteger(value) || - (value as number) < -DAILY_REVIEW_OFFSET_DAYS_MAX || - (value as number) > DAILY_REVIEW_OFFSET_DAYS_MAX - ) { - throw invalidProtocolFrame('Daily Review offsetDays is out of range'); - } - return value as number; -} - -function requireConfig(value: unknown): DailyReviewConfig { - const record = requireExactRecord(value, 'Daily Review config', [ - 'enabled', - 'executeTime', - 'modelKey', - ]); - if (typeof record.enabled !== 'boolean' || !isDailyReviewExecuteTime(record.executeTime)) { - throw invalidProtocolFrame('Invalid Daily Review config'); - } - const modelKey = requireModelKey(record.modelKey); - return normalizeDailyReviewConfig({ - enabled: record.enabled, - executeTime: record.executeTime, - modelKey, - }); -} - -function requireModelKey(value: unknown): string { - if ( - typeof value !== 'string' || - Buffer.byteLength(value, 'utf8') > DAILY_REVIEW_MODEL_KEY_MAX_BYTES - ) { - throw invalidProtocolFrame('Invalid Daily Review model key'); - } - return value; -} - -function requireArchiveId(value: unknown): string { - const archiveId = requireEntityId(value, 'Daily Review archive id'); - if (!parseDailyReviewArchiveId(archiveId)) { - throw invalidProtocolFrame('Invalid Daily Review archive id'); - } - return archiveId; -} - -function requireArchive(value: unknown): DailyReviewArchive { - const record = requireShapedRecord( - value, - 'Daily Review archive', - ['id', 'day', 'range', 'status', 'generatedAt', 'trigger', 'modelKey', 'sections', 'totals'], - ['errorMessage'], - ); - const sections = requireRecord(record.sections, 'Daily Review archive sections'); - if ( - Object.keys(sections).some( - (key) => !DAILY_REVIEW_SECTION_KEYS.some((sectionKey) => sectionKey === key), - ) - ) { - throw invalidProtocolFrame('Unknown Daily Review archive section'); - } - const normalizedSections: Record = {}; - for (const key of DAILY_REVIEW_SECTION_KEYS) { - if (sections[key] === undefined) continue; - normalizedSections[key] = requireBoundedText( - sections[key], - `Daily Review archive section ${key}`, - DAILY_REVIEW_RESULT_MAX_BYTES, - true, - ); - } - const metadata = requireArchiveMetadata(record); - try { - return normalizeDailyReviewArchive({ - ...metadata, - sections: normalizedSections, - }); - } catch { - throw invalidProtocolFrame('Invalid Daily Review archive'); - } -} - -function requireArchiveSummary(value: unknown): DailyReviewArchiveSummary { - const record = requireShapedRecord( - value, - 'Daily Review archive summary', - ['id', 'day', 'range', 'status', 'generatedAt', 'trigger', 'modelKey', 'totals'], - ['errorMessage'], - ); - return requireArchiveMetadata(record); -} - -function requireArchiveMetadata(record: Record): DailyReviewArchiveSummary { - if (!DAILY_REVIEW_ARCHIVE_STATUSES.includes(record.status as DailyReviewArchive['status'])) { - throw invalidProtocolFrame('Invalid Daily Review archive status'); - } - if (record.trigger !== 'cron' && record.trigger !== 'manual') { - throw invalidProtocolFrame('Invalid Daily Review archive trigger'); - } - const errorMessage = - record.errorMessage === undefined - ? undefined - : requireBoundedText( - record.errorMessage, - 'Daily Review archive error message', - DAILY_REVIEW_RESULT_MAX_BYTES, - true, - ); - const id = requireArchiveId(record.id); - const range = requireRange(record.range); - if (parseDailyReviewArchiveId(id)?.range !== range) { - throw invalidProtocolFrame('Daily Review archive id does not match its range'); - } - return { - id, - day: requireDay(record.day), - range, - status: record.status as DailyReviewArchive['status'], - generatedAt: requireCount(record.generatedAt, 'Daily Review generated time'), - trigger: record.trigger, - modelKey: requireModelKey(record.modelKey), - totals: requireTotals(record.totals), - ...(errorMessage === undefined ? {} : { errorMessage }), - }; -} - -function requireSummary(value: unknown): DailyReviewSummary { - const record = requireExactRecord(value, 'Daily Review summary', [ - 'day', - 'totals', - 'sessions', - 'topTools', - 'topModels', - ]); - const day = requireDay(record.day); - const totals = requireTotals(record.totals); - if (!Array.isArray(record.sessions) || record.sessions.length > DAILY_REVIEW_LIST_LIMIT) { - throw invalidProtocolFrame('Invalid Daily Review sessions'); - } - if (!Array.isArray(record.topTools) || record.topTools.length > DAILY_REVIEW_LIST_LIMIT) { - throw invalidProtocolFrame('Invalid Daily Review top tools'); - } - if (!Array.isArray(record.topModels) || record.topModels.length > DAILY_REVIEW_LIST_LIMIT) { - throw invalidProtocolFrame('Invalid Daily Review top models'); - } - return { - day, - totals, - sessions: record.sessions.map(requireSession), - topTools: record.topTools.map(requireTopEntry), - topModels: record.topModels.map(requireTopEntry), - }; -} - -function requireSession(value: unknown): DailyReviewSummary['sessions'][number] { - const session = requireShapedRecord( - value, - 'Daily Review Session row', - ['id', 'name', 'lastMessageAt'], - ['lastMessagePreview'], - ); - const preview = - session.lastMessagePreview === undefined - ? undefined - : requireBoundedText( - session.lastMessagePreview, - 'Daily Review Session preview', - 8 * 1024, - true, - ); - return { - id: requireEntityId(session.id, 'Daily Review Session id'), - name: requireBoundedText(session.name, 'Daily Review Session name', 4 * 1024, false), - lastMessageAt: requireCount(session.lastMessageAt, 'Daily Review last message time'), - ...(preview === undefined ? {} : { lastMessagePreview: preview }), - }; -} - -function requireTopEntry(value: unknown): DailyReviewSummary['topModels'][number] { - const entry = requireExactRecord(value, 'Daily Review top entry', [ - 'key', - 'label', - 'requests', - 'totalTokens', - 'costUsd', - ]); - if (typeof entry.costUsd !== 'number' || !Number.isFinite(entry.costUsd) || entry.costUsd < 0) { - throw invalidProtocolFrame('Invalid Daily Review top entry cost'); - } - return { - key: requireBoundedText(entry.key, 'Daily Review top entry key', 4 * 1024, false), - label: requireBoundedText(entry.label, 'Daily Review top entry label', 4 * 1024, false), - requests: requireCount(entry.requests, 'Daily Review top entry requests'), - totalTokens: requireCount(entry.totalTokens, 'Daily Review top entry tokens'), - costUsd: entry.costUsd, - }; -} - -function requireBoundedText( - value: unknown, - label: string, - maxBytes: number, - allowEmpty: boolean, -): string { - if ( - typeof value !== 'string' || - (!allowEmpty && value.length === 0) || - Buffer.byteLength(value, 'utf8') > maxBytes - ) { - throw invalidProtocolFrame(`Invalid ${label}`); - } - return value; -} - -function requireDay(value: unknown): DailyReviewSummary['day'] { - const day = requireExactRecord(value, 'Daily Review day', ['fromMs', 'toMs']); - const fromMs = requireCount(day.fromMs, 'Daily Review day fromMs'); - const toMs = requireCount(day.toMs, 'Daily Review day toMs'); - if (toMs <= fromMs) throw invalidProtocolFrame('Invalid Daily Review day range'); - return { fromMs, toMs }; -} - -function requireTotals(value: unknown): DailyReviewSummary['totals'] { - const totals = requireExactRecord(value, 'Daily Review totals', [ - 'sessionCount', - 'requestCount', - 'totalTokens', - 'costUsd', - 'errorCount', - ]); - if ( - typeof totals.costUsd !== 'number' || - !Number.isFinite(totals.costUsd) || - totals.costUsd < 0 - ) { - throw invalidProtocolFrame('Invalid Daily Review cost'); - } - return { - sessionCount: requireCount(totals.sessionCount, 'Daily Review session count'), - requestCount: requireCount(totals.requestCount, 'Daily Review request count'), - totalTokens: requireCount(totals.totalTokens, 'Daily Review token count'), - costUsd: totals.costUsd, - errorCount: requireCount(totals.errorCount, 'Daily Review error count'), - }; -} diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 8b03815a81..7d520f1e45 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -65,7 +65,6 @@ import { isCanonicalRuntimeHostWebSocketPath } from './websocket-path.js'; export * from './access-authority.js'; export * from './agent-graph.js'; export * from './interaction.js'; -export * from './daily-review.js'; export * from './client-capability.js'; export * from './configuration-change.js'; export * from './goal.js'; @@ -95,7 +94,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 73 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 74 as const; +// 74: Daily Review operations retire, and ScheduledTask agent templates may +// carry immutable Connection identity. Older peers expose the retired domain +// and cannot preserve the execution binding, so mixed peers fail at handshake. // 73: Transcript pages carry a Host-owned Turn range boundary. Older peers // cannot preserve both the complete edge Turn and the bounded projection. // 71: Session Guests can submit durable exact Turn access requests and Owners diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 8476927962..a979664ef6 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -24,7 +24,6 @@ import { requireExactRecord, requireId, requireRecord, requireString } from './c import { CONNECTION_EFFECT_OPERATION_SPECS } from './connection-effects.js'; import { CONFIGURATION_OPERATION_SPECS } from './configuration.js'; import { DEEP_RESEARCH_OPERATION_SPECS } from './deep-research.js'; -import { DAILY_REVIEW_OPERATION_SPECS } from './daily-review.js'; import { CONTEXT_OPERATION_SPECS } from './context.js'; import { EXECUTION_INSPECT_OPERATION_SPECS } from './execution-inspect.js'; import { EXTERNAL_SESSION_OPERATION_SPECS } from './external-session.js'; @@ -152,7 +151,6 @@ export * from './connection-effects.js'; export * from './access-authority.js'; export * from './configuration.js'; export * from './deep-research.js'; -export * from './daily-review.js'; export * from './context.js'; export * from './agent-graph.js'; export * from './execution-inspect.js'; @@ -190,7 +188,6 @@ export const HOST_OPERATION_SPECS = composeOperationSpecMaps( CONTEXT_OPERATION_SPECS, CONNECTION_EFFECT_OPERATION_SPECS, DEEP_RESEARCH_OPERATION_SPECS, - DAILY_REVIEW_OPERATION_SPECS, EXECUTION_INSPECT_OPERATION_SPECS, EXTERNAL_SESSION_OPERATION_SPECS, RUNTIME_POLICY_OPERATION_SPECS, @@ -259,8 +256,6 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'credential.vault.delete', 'credential.vault.query', 'credential.vault.set', - 'daily-review.mutate', - 'daily-review.query', 'deep-research.query', 'execution.inspect.query', 'external-session.catalog.query', diff --git a/packages/runtime-host/src/protocol/scheduled-task.ts b/packages/runtime-host/src/protocol/scheduled-task.ts index 48b3d48746..f60e01cdd3 100644 --- a/packages/runtime-host/src/protocol/scheduled-task.ts +++ b/packages/runtime-host/src/protocol/scheduled-task.ts @@ -543,7 +543,7 @@ function decodeExecution(value: unknown): ScheduledTaskExecutionTemplate { 'collaborationMode', 'orchestrationMode', ], - ['projectId', 'thinkingLevel', 'backend'], + ['projectId', 'llmConnectionId', 'thinkingLevel', 'backend'], ); if (!isPermissionMode(execution.permissionMode)) { throw invalidProtocolFrame('Invalid ScheduledTask permission mode'); @@ -569,6 +569,16 @@ function decodeExecution(value: unknown): ScheduledTaskExecutionTemplate { ...(Object.hasOwn(execution, 'projectId') ? { projectId: execution.projectId as string | null } : {}), + ...(Object.hasOwn(execution, 'llmConnectionId') + ? { + llmConnectionId: boundedText( + execution.llmConnectionId, + 'ScheduledTask connection id', + 256, + true, + ), + } + : {}), llmConnectionSlug: boundedText( execution.llmConnectionSlug, 'ScheduledTask connection slug', diff --git a/packages/runtime-host/src/server/daily-review-coordinator.ts b/packages/runtime-host/src/server/daily-review-coordinator.ts deleted file mode 100644 index 864e507b01..0000000000 --- a/packages/runtime-host/src/server/daily-review-coordinator.ts +++ /dev/null @@ -1,580 +0,0 @@ -/* - * 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 { - DAILY_REVIEW_LIST_LIMIT, - buildDailyReviewSummary, - dailyReviewArchiveId, - dailyUsageQuery, - localDayBoundsAt, - localDayBoundsForInstant, - pickDailyReviewSessions, - pickDailyReviewTopEntries, - type DailyReviewArchive, - type DailyReviewArchiveSectionContent, - type DailyReviewRange, - type DailyReviewSummary, -} from '@maka/core/daily-review'; -import { collapseSessionRevisions } from '@maka/core/session-revisions'; -import { mergeUsageBuckets, mergeUsageSummary } from '@maka/core/usage-ledger-merge'; -import { - authenticateInteractiveDailyReviewAuthorityWriter, - type InteractiveDailyReviewAuthorityWriter, -} from '@maka/storage/daily-review-authority'; -import type { ExecutionSessionWriter } from '@maka/storage/execution-stores'; -import { - authenticateInteractiveUsageStoresWriter, - type InteractiveUsageStoresWriter, -} from '@maka/storage/usage-stores'; -import { - DAILY_REVIEW_RESULT_MAX_BYTES, - type DailyReviewMutateInput, - type DailyReviewQueryInput, - type OperationOutcome, -} from '../protocol/index.js'; -import type { DailyReviewOperationHandlerMap } from './operation-dispatcher.js'; -import type { HostDailyReviewModel } from './execution-model-authority.js'; -import type { RuntimeHostResidency } from './host-kernel.js'; -import { - CanonicalUsageProjectionIncompleteError, - readCompleteCanonicalUsage, -} from './canonical-usage-reader.js'; - -const ARCHIVE_LIMIT = 180; -const SCHEDULER_INTERVAL_MS = 60_000; - -export interface HostDailyReviewCoordinatorInput { - readonly store: InteractiveDailyReviewAuthorityWriter; - readonly usage: InteractiveUsageStoresWriter; - readonly sessions: Pick; - readonly model: HostDailyReviewModel; - readonly acquireResidency: () => RuntimeHostResidency; - readonly requestDrain: () => void; - readonly now?: () => number; - readonly setInterval?: (callback: () => void, delayMs: number) => unknown; - readonly clearInterval?: (timer: unknown) => void; -} - -/** Host-owned Daily Review data, generation, archive, and scheduling boundary. */ -export class HostDailyReviewCoordinator { - readonly handlers: DailyReviewOperationHandlerMap = { - 'daily-review.query': (input) => this.#query(input), - 'daily-review.mutate': (input) => this.#mutate(input), - }; - - readonly #store: InteractiveDailyReviewAuthorityWriter; - readonly #usage: InteractiveUsageStoresWriter; - readonly #sessions: HostDailyReviewCoordinatorInput['sessions']; - readonly #model: HostDailyReviewModel; - readonly #acquireResidency: () => RuntimeHostResidency; - readonly #requestDrain: () => void; - readonly #now: () => number; - readonly #setInterval: (callback: () => void, delayMs: number) => unknown; - readonly #clearInterval: (timer: unknown) => void; - readonly #inFlight = new Map< - string, - { - readonly modelKey: string; - readonly trigger: 'cron' | 'manual'; - readonly promise: Promise; - } - >(); - readonly #abortControllers = new Set(); - - #prepared = false; - #started = false; - #schedulerEnabled = false; - #draining = false; - #timer: unknown; - #residency: RuntimeHostResidency | undefined; - #closeTask: Promise | undefined; - - constructor(input: HostDailyReviewCoordinatorInput) { - this.#store = authenticateInteractiveDailyReviewAuthorityWriter(input.store); - this.#usage = authenticateInteractiveUsageStoresWriter(input.usage); - this.#sessions = input.sessions; - this.#model = input.model; - this.#acquireResidency = input.acquireResidency; - this.#requestDrain = input.requestDrain; - this.#now = input.now ?? Date.now; - this.#setInterval = - input.setInterval ?? ((callback, delayMs) => setInterval(callback, delayMs)); - this.#clearInterval = - input.clearInterval ?? ((timer) => clearInterval(timer as NodeJS.Timeout)); - } - - async recover(): Promise { - await this.prepareRecovery(); - await this.start(); - } - - async prepareRecovery(): Promise { - if (this.#prepared) return; - const snapshot = await this.#store.readConfig(); - this.#schedulerEnabled = snapshot.config.enabled; - this.#prepared = true; - } - - async start(): Promise { - if (!this.#prepared) throw new Error('Daily Review recovery was not prepared'); - if (this.#started) return; - this.#started = true; - this.#reconcileScheduler(this.#schedulerEnabled); - try { - await this.#tickScheduler(); - } catch (error) { - if (isRetryableSchedulerError(error)) return; - throw error; - } - } - - beginDrain(): void { - if (this.#draining) return; - this.#draining = true; - this.#stopScheduler(); - for (const controller of this.#abortControllers) { - controller.abort(new DOMException('Runtime Host is draining', 'AbortError')); - } - } - - close(): Promise { - this.#closeTask ??= (async () => { - this.beginDrain(); - await Promise.allSettled([...this.#inFlight.values()].map((entry) => entry.promise)); - })(); - return this.#closeTask; - } - - async #query(input: DailyReviewQueryInput): Promise> { - if (!this.#prepared) return queryFailure('host_not_ready', 'Daily Review is not ready'); - if (this.#draining) return queryFailure('host_draining', 'Runtime Host is draining'); - try { - switch (input.kind) { - case 'config': { - const snapshot = await this.#store.readConfig(); - return querySuccess({ kind: 'config', ...snapshot }); - } - case 'summary': - return querySuccess({ - kind: 'summary', - summary: await this.#buildSummary(input.offsetDays, input.daySpan), - }); - case 'archives': { - const beforeArchiveId = input.beforeArchiveId; - const page = await this.#store.listArchivePage(beforeArchiveId, input.limit); - return querySuccess({ - kind: 'archives', - archives: page.archives, - beforeArchiveId, - nextBeforeArchiveId: page.nextBeforeArchiveId, - }); - } - case 'archive': - return querySuccess({ - kind: 'archive', - archive: await this.#store.getArchive(input.archiveId), - }); - } - } catch (error) { - return this.#readFailure(error); - } - } - - async #mutate(input: DailyReviewMutateInput): Promise> { - if (!this.#prepared) return mutateFailure('host_not_ready', 'Daily Review is not ready'); - if (this.#draining) return mutateFailure('host_draining', 'Runtime Host is draining'); - try { - if (input.kind === 'update_config') { - const result = await this.#store.updateConfig(input.expectedRevision, input.config); - if (result.kind === 'revision_conflict') { - return mutateSuccess(result); - } - this.#reconcileScheduler(result.snapshot.config.enabled); - void this.#tickScheduler().catch((error: unknown) => this.#handleSchedulerError(error)); - return mutateSuccess({ - kind: result.kind === 'committed' ? 'config_committed' : 'config_unchanged', - ...result.snapshot, - }); - } - if (input.kind === 'delete') { - return mutateSuccess({ - kind: 'deleted', - archiveId: input.archiveId, - deleted: await this.#store.deleteArchive(input.archiveId), - }); - } - return mutateSuccess({ - kind: 'archive', - archive: await this.#run({ - range: input.range, - offsetDays: input.offsetDays, - modelKeyOverride: input.modelKeyOverride, - trigger: 'manual', - replaceExisting: input.replaceExisting, - }), - }); - } catch (error) { - if (this.#draining || isAbort(error)) { - return mutateFailure('host_draining', 'Runtime Host is draining'); - } - if (error instanceof CanonicalUsageProjectionIncompleteError) { - return mutateFailure( - 'projection_incomplete', - 'Daily Review is waiting for canonical Usage repair', - ); - } - if (error instanceof DailyReviewRunConflictError) { - return mutateFailure('operation_conflict', error.message); - } - this.#requestDrain(); - return mutateFailure('persistence_failed', 'Daily Review mutation failed'); - } - } - - async #buildSummary(offsetDays: number, daySpan: number): Promise { - const offset = Math.trunc(offsetDays); - const span = Math.max(1, Math.min(30, Math.trunc(daySpan))); - const now = this.#now(); - const endDay = offset === 0 ? localDayBoundsForInstant(now) : localDayBoundsAt(now, offset); - const startDay = localDayBoundsAt(endDay.fromMs, -(span - 1)); - const range = { fromMs: startDay.fromMs, toMs: endDay.toMs }; - const query = dailyUsageQuery(range); - const canonical = await readCompleteCanonicalUsage(this.#usage, query, now); - const [usageSummary, toolBuckets, modelBuckets, sessions] = await Promise.all([ - this.#usage.telemetry.summary(query), - this.#usage.telemetry.buckets(query, 'tool'), - this.#usage.telemetry.buckets(query, 'model'), - this.#sessions.list(), - ]); - return buildDailyReviewSummary({ - day: range, - usageSummary: mergeUsageSummary(usageSummary, canonical, query, now), - sessions: pickDailyReviewSessions( - collapseSessionRevisions(sessions), - range, - DAILY_REVIEW_LIST_LIMIT, - ), - topTools: pickDailyReviewTopEntries(toolBuckets, DAILY_REVIEW_LIST_LIMIT), - topModels: pickDailyReviewTopEntries( - mergeUsageBuckets(modelBuckets, canonical, query, 'model', now).buckets, - DAILY_REVIEW_LIST_LIMIT, - ), - }); - } - - async #run(input: { - readonly range: DailyReviewRange; - readonly offsetDays: number; - readonly modelKeyOverride: string; - readonly trigger: 'cron' | 'manual'; - readonly replaceExisting: boolean; - }): Promise { - const summary = await this.#buildSummary(input.offsetDays, input.range); - const archiveId = dailyReviewArchiveId(summary.day, input.range); - const existing = await this.#store.getArchive(archiveId); - if (existing && !input.replaceExisting) return existing; - const config = await this.#store.readConfig(); - const modelKey = input.modelKeyOverride.trim() || config.config.modelKey; - const inFlight = this.#inFlight.get(archiveId); - if (inFlight) { - if (inFlight.modelKey === modelKey && inFlight.trigger === input.trigger) { - return inFlight.promise; - } - throw new DailyReviewRunConflictError(archiveId); - } - const pending = this.#generateArchive(archiveId, summary, modelKey, input); - const entry = { modelKey, trigger: input.trigger, promise: pending }; - this.#inFlight.set(archiveId, entry); - try { - return await pending; - } finally { - if (this.#inFlight.get(archiveId) === entry) this.#inFlight.delete(archiveId); - } - } - - async #generateArchive( - archiveId: string, - summary: DailyReviewSummary, - modelKey: string, - input: { - readonly range: DailyReviewRange; - readonly trigger: 'cron' | 'manual'; - }, - ): Promise { - const base = { - id: archiveId, - day: summary.day, - range: input.range, - generatedAt: this.#now(), - trigger: input.trigger, - modelKey, - totals: summary.totals, - } as const; - if (summary.totals.sessionCount + summary.totals.requestCount === 0) { - return this.#publish({ - ...base, - status: 'no_data', - sections: buildRuleBasedSections(summary, input.range), - errorMessage: 'No local activity data was available for this review.', - }); - } - - const controller = new AbortController(); - this.#abortControllers.add(controller); - try { - const result = await this.#model.generate({ - modelKey, - prompt: buildModelPrompt(summary, input.range), - abortSignal: controller.signal, - }); - if (!result.ok) { - if (result.errorClass === 'aborted') { - throw ( - controller.signal.reason ?? new DOMException('Daily Review was aborted', 'AbortError') - ); - } - if (result.errorClass === 'persistence') { - this.#requestDrain(); - throw new Error('Daily Review model accounting failed'); - } - return this.#publish({ - ...base, - status: result.errorClass === 'configuration' ? 'no_model' : 'failed', - sections: buildRuleBasedSections(summary, input.range), - errorMessage: - result.errorClass === 'configuration' - ? 'No executable analysis model is configured.' - : result.errorClass === 'timeout' - ? 'The analysis model timed out while generating this review.' - : 'The analysis model failed to generate this review.', - }); - } - let sections: DailyReviewArchiveSectionContent; - try { - sections = parseSections(result.text); - } catch { - return this.#publish({ - ...base, - modelKey: result.modelKey, - status: 'failed', - sections: buildRuleBasedSections(summary, input.range), - errorMessage: 'The analysis model returned an invalid review.', - }); - } - return this.#publish({ - ...base, - modelKey: result.modelKey, - status: 'ok', - sections, - }); - } finally { - this.#abortControllers.delete(controller); - } - } - - async #publish(archive: DailyReviewArchive): Promise { - const encodedBytes = Buffer.byteLength(JSON.stringify(archive), 'utf8'); - if (encodedBytes > DAILY_REVIEW_RESULT_MAX_BYTES) { - throw new Error('Daily Review archive exceeds the protocol result budget'); - } - return this.#store.publishArchive(archive, ARCHIVE_LIMIT); - } - - #reconcileScheduler(enabled: boolean): void { - if (!enabled || this.#draining) { - this.#stopScheduler(); - return; - } - this.#residency ??= this.#acquireResidency(); - this.#timer ??= this.#setInterval(() => { - void this.#tickScheduler().catch((error: unknown) => this.#handleSchedulerError(error)); - }, SCHEDULER_INTERVAL_MS); - } - - #stopScheduler(): void { - if (this.#timer !== undefined) { - this.#clearInterval(this.#timer); - this.#timer = undefined; - } - this.#residency?.release(); - this.#residency = undefined; - } - - async #tickScheduler(): Promise { - if (!this.#prepared || this.#draining) return; - const { config } = await this.#store.readConfig(); - this.#reconcileScheduler(config.enabled); - const now = this.#now(); - if (!config.enabled || !scheduledTimeHasPassed(now, config.executeTime)) return; - // This is a latest-complete-day digest, not a historical job ledger. Without durable - // enablement history, older backfill could generate reviews for days before scheduling began. - const day = localDayBoundsAt(now, -1); - const archiveId = dailyReviewArchiveId(day, 1); - if (await this.#store.getArchive(archiveId)) return; - await this.#run({ - range: 1, - offsetDays: -1, - modelKeyOverride: '', - trigger: 'cron', - replaceExisting: false, - }); - } - - #readFailure(error: unknown): OperationOutcome<'daily-review.query'> { - if (this.#draining) return queryFailure('host_draining', 'Runtime Host is draining'); - if (error instanceof CanonicalUsageProjectionIncompleteError) { - return queryFailure( - 'projection_incomplete', - 'Daily Review is waiting for canonical Usage repair', - ); - } - this.#requestDrain(); - return queryFailure( - 'persistence_failed', - error instanceof Error ? 'Daily Review data is unavailable' : 'Daily Review query failed', - ); - } - - #handleSchedulerError(error: unknown): void { - if (this.#draining || isAbort(error) || isRetryableSchedulerError(error)) return; - this.#requestDrain(); - } -} - -function scheduledTimeHasPassed(nowMs: number, executeTime: string): boolean { - const now = new Date(nowMs); - const [hours, minutes] = executeTime.split(':').map(Number); - return now.getHours() * 60 + now.getMinutes() >= (hours ?? 0) * 60 + (minutes ?? 0); -} - -function buildModelPrompt(summary: DailyReviewSummary, range: DailyReviewRange): string { - return [ - 'You are Maka Daily Review. Use only the supplied local activity facts.', - 'Return JSON without a Markdown fence. The only allowed top-level keys are summary, gaps, usage, and code. Each value must be a string. Omit unsupported sections.', - JSON.stringify({ - rangeDays: range, - day: summary.day, - totals: summary.totals, - sessions: summary.sessions.map((session) => ({ - name: session.name, - lastMessageAt: session.lastMessageAt, - preview: session.lastMessagePreview ?? '', - })), - topModels: summary.topModels, - topTools: summary.topTools, - }), - ].join('\n'); -} - -function parseSections(text: string): DailyReviewArchiveSectionContent { - const trimmed = text.trim(); - let parsed: unknown; - try { - parsed = JSON.parse(trimmed); - } catch { - parsed = { summary: trimmed }; - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('Daily Review model returned an invalid result'); - } - const record = parsed as Record; - const sections = { - ...(typeof record.summary === 'string' && record.summary.trim() - ? { summary: record.summary.trim() } - : {}), - ...(typeof record.gaps === 'string' && record.gaps.trim() ? { gaps: record.gaps.trim() } : {}), - ...(typeof record.usage === 'string' && record.usage.trim() - ? { usage: record.usage.trim() } - : {}), - ...(typeof record.code === 'string' && record.code.trim() ? { code: record.code.trim() } : {}), - }; - if (Object.keys(sections).length === 0) { - throw new Error('Daily Review model returned no usable sections'); - } - return sections; -} - -function buildRuleBasedSections( - summary: DailyReviewSummary, - range: DailyReviewRange, -): DailyReviewArchiveSectionContent { - const topModel = summary.topModels[0]; - const topTool = summary.topTools[0]; - return { - summary: `${range}-day activity covered ${summary.totals.sessionCount} Sessions and ${summary.totals.requestCount} requests.`, - gaps: - summary.totals.errorCount > 0 - ? `${summary.totals.errorCount} failed requests need review.` - : 'No failed requests were visible in local usage data.', - ...(topModel - ? { - usage: `${topModel.label} was the most-used model with ${topModel.requests} requests.`, - } - : {}), - ...(topTool - ? { - code: `${topTool.label} was the most-used tool with ${topTool.requests} invocations.`, - } - : {}), - }; -} - -function isAbort(error: unknown): boolean { - return error instanceof Error && error.name === 'AbortError'; -} - -function isRetryableSchedulerError(error: unknown): boolean { - return ( - error instanceof CanonicalUsageProjectionIncompleteError || - error instanceof DailyReviewRunConflictError - ); -} - -class DailyReviewRunConflictError extends Error { - constructor(archiveId: string) { - super(`Daily Review archive ${archiveId} is already being generated with different options`); - this.name = 'DailyReviewRunConflictError'; - } -} - -function querySuccess( - result: Extract, { ok: true }>['result'], -): OperationOutcome<'daily-review.query'> { - return { ok: true, result }; -} - -function queryFailure( - code: Extract, { ok: false }>['error']['code'], - message: string, -): OperationOutcome<'daily-review.query'> { - return { ok: false, error: { code, message } }; -} - -function mutateSuccess( - result: Extract, { ok: true }>['result'], -): OperationOutcome<'daily-review.mutate'> { - return { ok: true, result }; -} - -function mutateFailure( - code: Extract, { ok: false }>['error']['code'], - message: string, -): OperationOutcome<'daily-review.mutate'> { - return { ok: false, error: { code, message } }; -} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index ac1b5e85f7..7c48b938f7 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -87,6 +87,7 @@ import { } from '@maka/storage/execution-stores'; import { createExternalSessionAdapterRegistry } from '@maka/storage/external-sessions'; import { createGitWorktreeChildExecutor } from '@maka/storage/git-worktree-child-executor'; +import { migrateLegacyDailyReview } from '@maka/storage/legacy-daily-review-migration'; import { runWithStorageRootLease } from '@maka/storage/root-authority'; import { createInteractiveContextOffloadReader } from '@maka/storage/context-offload-store'; import { openStorageWriterComposition } from '@maka/storage/storage-writer-composition'; @@ -109,7 +110,6 @@ import { HostConfigurationCoordinator } from './configuration-coordinator.js'; import { HostContextCoordinator } from './context-coordinator.js'; import { HostClientCapabilityCoordinator } from './client-capability-coordinator.js'; import { HostDeepResearchCoordinator } from './deep-research-coordinator.js'; -import { HostDailyReviewCoordinator } from './daily-review-coordinator.js'; import { createHostAiSdkBackend } from './execution-model-composition.js'; import { createInteractiveRunComposer, @@ -119,7 +119,6 @@ import { import { createHostGoalEvaluator, - createHostDailyReviewModel, createHostMemoryExtractionModel, createHostSessionEffectModel, } from './execution-model-authority.js'; @@ -276,7 +275,7 @@ export async function createExecutionRuntimeHostComposition( const openedScheduledTaskStore = storage.scheduledTasks; const openedPlanStore = storage.plan; const openedDeepResearchStore = storage.deepResearch; - const openedDailyReviewStore = storage.dailyReview; + const legacyDailyReview = storage.legacyDailyReview; const openedGoalStore = storage.goal; const memoryStore = storage.memoryBundle; const longTermMemoryStore = storage.longTermMemory; @@ -510,7 +509,6 @@ export async function createExecutionRuntimeHostComposition( let scheduledTaskTool: MakaTool | undefined; let goal: HostGoalCoordinator | undefined; let deepResearch: HostDeepResearchCoordinator | undefined; - let dailyReview: HostDailyReviewCoordinator | undefined; const rootPort: HostMessageRootPort = { readSessionHeader: (sessionId) => requireRootCoordinator(rootCoordinator).readSessionHeader(sessionId), @@ -582,19 +580,6 @@ export async function createExecutionRuntimeHostComposition( onProjectionChanged: (sessionId) => continuityCoordinator.enqueueSessionDomainChanged(sessionId, 'deep_research'), }); - dailyReview = new HostDailyReviewCoordinator({ - store: openedDailyReviewStore, - usage: openedUsageStores, - sessions: stores.sessionStore, - model: createHostDailyReviewModel({ - runtimePolicy: runtimePolicyStores, - oauthCredentials, - usage: openedUsageStores, - requestDrain: context.requestDrain, - }), - acquireResidency: () => context.acquireResidency('daily-review'), - requestDrain: context.requestDrain, - }); let poisonFailure: Error | undefined; let draining = false; let recoveryTask: Promise | undefined; @@ -1612,6 +1597,14 @@ export async function createExecutionRuntimeHostComposition( state: async () => { await skills.recover(); await openedArtifactStore.recover(); + await migrateLegacyDailyReview({ + legacy: legacyDailyReview, + scheduledTasks: openedScheduledTaskStore, + sessions: stores.sessionStore, + artifacts: openedArtifactStore, + runtimePolicy: runtimePolicyStores, + workspaceRoot: context.owner.capability.canonicalPath, + }); }, }, drain: [ @@ -1651,16 +1644,6 @@ export async function createExecutionRuntimeHostComposition( handlers: [requireDeepResearch(deepResearch).handlers], close: [() => deepResearch?.close()], }), - createRuntimeHostDomainModule({ - id: 'daily-review', - handlers: [requireDailyReview(dailyReview).handlers], - recovery: { - domains: () => requireDailyReview(dailyReview).prepareRecovery(), - schedulers: () => requireDailyReview(dailyReview).start(), - }, - drain: [() => dailyReview?.beginDrain()], - close: [() => requireDailyReview(dailyReview).close()], - }), createRuntimeHostDomainModule({ id: 'scheduled-task', handlers: [requireScheduledTasks(scheduledTasks).handlers], @@ -1990,13 +1973,6 @@ function requireDeepResearch( return coordinator; } -function requireDailyReview( - coordinator: HostDailyReviewCoordinator | undefined, -): HostDailyReviewCoordinator { - if (!coordinator) throw new Error('Runtime Host Daily Review coordinator is not composed'); - return coordinator; -} - function requireSessionManager(manager: SessionManager | undefined): SessionManager { if (!manager) throw new Error('Runtime Host SessionManager is not composed'); return manager; diff --git a/packages/runtime-host/src/server/execution-model-authority.ts b/packages/runtime-host/src/server/execution-model-authority.ts index 9cc68188b3..d80e7e2c79 100644 --- a/packages/runtime-host/src/server/execution-model-authority.ts +++ b/packages/runtime-host/src/server/execution-model-authority.ts @@ -126,21 +126,6 @@ export interface HostSessionEffectModel { export type HostSessionEffectModelInput = Omit; -export type HostDailyReviewModelResult = - | { readonly ok: true; readonly text: string; readonly modelKey: string } - | { - readonly ok: false; - readonly errorClass: HostAuxiliaryModelFailureClass; - }; - -export interface HostDailyReviewModel { - generate(input: { - readonly modelKey: string; - readonly prompt: string; - readonly abortSignal: AbortSignal; - }): Promise; -} - export interface HostMemoryExtractionModel { generate(input: { readonly snapshot: MemoryExtractionSourceSnapshot; @@ -209,45 +194,6 @@ export function createHostMemoryExtractionModel( }); } -/** Creates root-scoped Daily Review calls on the canonical Host model authority. */ -export function createHostDailyReviewModel( - input: HostSessionEffectModelInput, -): HostDailyReviewModel { - const authority = createAuxiliaryModelCallAuthority(input); - return Object.freeze({ - generate: async ({ - modelKey, - prompt, - abortSignal, - }: Parameters[0]) => { - const effectiveAbortSignal = AbortSignal.any([abortSignal, AbortSignal.timeout(60_000)]); - try { - const header = await readAuxiliaryPreflight(authority, effectiveAbortSignal, () => - resolveDailyReviewHeader(authority.runtimePolicy, modelKey), - ); - const result = await runHostAuxiliaryModelCall(authority, { - transportContextId: 'daily-review', - header, - callKind: 'daily_review', - callId: `daily_review_${authority.newId()}`, - abortSignal: effectiveAbortSignal, - buildRequest: () => ({ prompt, maxOutputTokens: 2_048 }), - }); - return { - ok: true as const, - text: result.text, - modelKey: `${header.llmConnectionSlug}::${result.modelId}`, - }; - } catch (error) { - return { - ok: false as const, - errorClass: auxiliaryModelErrorClass(error, effectiveAbortSignal), - }; - } - }, - }); -} - /** Creates tool-free Session title and recap calls on canonical Host model authority. */ export function createHostSessionEffectModel( input: HostSessionEffectModelInput, @@ -739,53 +685,6 @@ interface ResolvedExecutionTarget { readonly proxySecret?: string; } -async function resolveDailyReviewHeader( - runtimePolicy: RuntimePolicyStoresWriter, - modelKey: string, -): Promise< - Pick -> { - const explicit = parseDailyReviewModelKey(modelKey); - if (modelKey.trim() && !explicit) { - throw new AuxiliaryModelCallConfigurationError('Daily Review model key is invalid'); - } - if (explicit) { - return { - llmConnectionSlug: explicit.connectionSlug, - model: explicit.modelId, - thinkingLevel: 'off', - }; - } - const catalog = await runtimePolicy.connectionCatalog.getSnapshot(); - const target = catalog.defaultTarget; - const connection = target - ? catalog.connections.find((candidate) => candidate.connectionId === target.connectionId) - : undefined; - if (!target || !connection) { - throw new AuxiliaryModelCallConfigurationError( - 'Daily Review has no canonical default model target', - ); - } - return { - llmConnectionId: connection.connectionId, - llmConnectionSlug: connection.slug, - model: target.modelId, - thinkingLevel: 'off', - }; -} - -function parseDailyReviewModelKey( - modelKey: string, -): { readonly connectionSlug: string; readonly modelId: string } | undefined { - const trimmed = modelKey.trim(); - if (!trimmed) return undefined; - const separator = trimmed.indexOf('::'); - if (separator <= 0 || separator >= trimmed.length - 2) return undefined; - const connectionSlug = trimmed.slice(0, separator).trim(); - const modelId = trimmed.slice(separator + 2).trim(); - return connectionSlug && modelId ? { connectionSlug, modelId } : undefined; -} - export async function resolveExecutionTarget( header: Pick< BackendFactoryContext['header'], diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts index b752189df3..125a3c88ac 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -146,7 +146,6 @@ export type ScheduledTaskOperationKey = Extract; export type ProjectCatalogOperationKey = Extract; export type DeepResearchOperationKey = Extract; -export type DailyReviewOperationKey = Extract; export type WebSearchOperationKey = Extract; export type NetworkProxyOperationKey = Extract; export type ConfigurationOperationKey = Extract; @@ -213,7 +212,6 @@ export type ProjectCatalogOperationHandlerMap = Pick< ProjectCatalogOperationKey >; export type DeepResearchOperationHandlerMap = Pick; -export type DailyReviewOperationHandlerMap = Pick; export type WebSearchOperationHandlerMap = Pick; export type NetworkProxyOperationHandlerMap = Pick; export type ConfigurationOperationHandlerMap = Pick; diff --git a/packages/runtime-host/src/server/scheduled-task-coordinator.ts b/packages/runtime-host/src/server/scheduled-task-coordinator.ts index d8389aa14d..cb1f8b006f 100644 --- a/packages/runtime-host/src/server/scheduled-task-coordinator.ts +++ b/packages/runtime-host/src/server/scheduled-task-coordinator.ts @@ -593,10 +593,7 @@ export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority ); } - // Persisted agent-run templates currently identify their model connection - // by reusable slug only. Do not resolve that slug to a potentially - // different Connection entity. #3927 will make the exact ID durable. - if (task.effect.kind === 'agent_run') { + if (task.effect.kind === 'agent_run' && !task.effect.execution.llmConnectionId) { return this.#settleFailure(claim, SCHEDULED_AGENT_RUN_IDENTITY_REQUIRED); } @@ -648,7 +645,9 @@ export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority const execution = task.effect.execution; const catalog = await this.#runtimePolicy.connectionCatalog.getSnapshot(); const connection = catalog.connections.find( - (candidate) => candidate.slug === execution.llmConnectionSlug, + (candidate) => + candidate.connectionId === execution.llmConnectionId && + candidate.slug === execution.llmConnectionSlug, ); if (!connection) { throw new Error('ScheduledTask model connection does not exist'); @@ -826,6 +825,7 @@ function executionTemplateFromHeader(header: SessionHeader): ScheduledTaskExecut return { cwd: header.cwd, ...(header.projectId === undefined ? {} : { projectId: header.projectId }), + ...(header.llmConnectionId === null ? {} : { llmConnectionId: header.llmConnectionId }), llmConnectionSlug: header.llmConnectionSlug, model: header.model, ...(header.thinkingLevel === undefined ? {} : { thinkingLevel: header.thinkingLevel }), diff --git a/packages/storage/package.json b/packages/storage/package.json index cbcbaddc18..0a91281d74 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -12,7 +12,6 @@ "./config-transfer": "./dist/config-transfer.js", "./context-offload-store": "./dist/context-offload-store.js", "./credential-store": "./dist/credential-store.js", - "./daily-review-authority": "./dist/daily-review-authority.js", "./deep-research-authority": "./dist/deep-research-authority.js", "./deep-research-store": "./dist/deep-research-store.js", "./encrypted-file-managed-secret-store": "./dist/encrypted-file-managed-secret-store.js", @@ -24,6 +23,7 @@ "./git-worktree-child-executor": "./dist/git-worktree-child-executor.js", "./goal-authority": "./dist/goal-authority.js", "./interaction-store": "./dist/interaction-store-public.js", + "./legacy-daily-review-migration": "./dist/legacy-daily-review-migration.js", "./long-term-memory-store": "./dist/long-term-memory-store.js", "./managed-secret-store": "./dist/managed-secret-store.js", "./mcp-config-store": "./dist/mcp-config-store.js", diff --git a/packages/storage/src/__tests__/daily-review-authority.test.ts b/packages/storage/src/__tests__/daily-review-authority.test.ts deleted file mode 100644 index 3ec04a3622..0000000000 --- a/packages/storage/src/__tests__/daily-review-authority.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -/* - * 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 { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { after, test } from 'node:test'; -import type { DailyReviewArchive } from '@maka/core/daily-review'; -import { - authenticateInteractiveDailyReviewAuthorityWriter, - openInteractiveDailyReviewAuthorityForWrite, -} from '../daily-review-authority.js'; -import { - resolveStorageRoot, - StorageRootAuthorityError, - tryAcquireInteractiveRootOwner, -} from '../root-authority.js'; -import { - removeTrackedControlDirectories, - trackControlDirectory, -} from './fixtures/control-directory-hygiene.js'; - -// The control directory of each resolved root lives outside that root, so a -// temporary root's removal leaves it behind; reclaim the recorded rootIds here. -after(removeTrackedControlDirectories); - -test('Daily Review authority serializes config revisions and preserves archives', async () => { - await withInteractiveRoot(async ({ capability }) => { - const owner = await tryAcquireInteractiveRootOwner(capability); - assert.ok(owner); - if (!owner) return; - try { - const [first, second] = await Promise.all([ - openInteractiveDailyReviewAuthorityForWrite(owner.lease), - openInteractiveDailyReviewAuthorityForWrite(owner.lease), - ]); - assert.equal(first, second); - assert.equal(authenticateInteractiveDailyReviewAuthorityWriter(first), first); - assert.deepEqual(await first.readConfig(), { - revision: 0, - config: { enabled: false, executeTime: '08:00', modelKey: '' }, - }); - - assert.deepEqual( - await first.updateConfig(0, { - enabled: true, - executeTime: '09:30', - modelKey: 'openrouter::openrouter/free', - }), - { - kind: 'committed', - snapshot: { - revision: 1, - config: { - enabled: true, - executeTime: '09:30', - modelKey: 'openrouter::openrouter/free', - }, - }, - }, - ); - assert.deepEqual( - await second.updateConfig(0, { - enabled: false, - executeTime: '10:00', - modelKey: '', - }), - { kind: 'revision_conflict', expectedRevision: 0, actualRevision: 1 }, - ); - - const stored = await first.publishArchive(archive(), 180); - assert.deepEqual(await second.getArchive(stored.id), stored); - assert.deepEqual( - (await second.listArchivePage(null, 180)).archives.map((item) => item.id), - [stored.id], - ); - - first.close(); - assert.throws(() => authenticateInteractiveDailyReviewAuthorityWriter(first), isInvalidLease); - const reopened = await openInteractiveDailyReviewAuthorityForWrite(owner.lease); - assert.equal((await reopened.readConfig()).revision, 1); - assert.deepEqual(await reopened.getArchive(stored.id), stored); - reopened.close(); - } finally { - if (!owner.closed) await owner.close(); - } - }); -}); - -test('Daily Review authority rejects operations after its root lease closes', async () => { - await withInteractiveRoot(async ({ capability }) => { - const owner = await tryAcquireInteractiveRootOwner(capability); - assert.ok(owner); - if (!owner) return; - const writer = await openInteractiveDailyReviewAuthorityForWrite(owner.lease); - await owner.close(); - await assert.rejects(() => writer.readConfig(), isInvalidLease); - await assert.rejects(() => writer.publishArchive(archive(), 180), isInvalidLease); - writer.close(); - }); -}); - -test('Daily Review authority publishes and prunes archives in one operation', async () => { - await withInteractiveRoot(async ({ capability }) => { - const owner = await tryAcquireInteractiveRootOwner(capability); - assert.ok(owner); - if (!owner) return; - try { - const writer = await openInteractiveDailyReviewAuthorityForWrite(owner.lease); - const older = archive(); - const newer = { - ...archive(new Date(2026, 7, 4).getTime()), - id: '2026-08-04-1d', - generatedAt: older.generatedAt + 1, - }; - await writer.publishArchive(older, 1); - await writer.publishArchive(newer, 1); - assert.deepEqual( - (await writer.listArchivePage(null, 180)).archives.map((item) => item.id), - [newer.id], - ); - assert.equal(await writer.getArchive(older.id), null); - const oldest = archive(new Date(2026, 7, 2).getTime()); - await writer.publishArchive(oldest, 1); - assert.deepEqual( - (await writer.listArchivePage(null, 180)).archives.map((item) => item.id), - [oldest.id], - ); - assert.deepEqual(await writer.getArchive(oldest.id), oldest); - await assert.rejects(() => writer.publishArchive({ ...newer, id: '2026-08-05-1d' }, 1)); - assert.deepEqual( - (await writer.listArchivePage(null, 180)).archives.map((item) => item.id), - [oldest.id], - ); - writer.close(); - } finally { - if (!owner.closed) await owner.close(); - } - }); -}); - -function archive(fromMs = new Date(2026, 7, 3).getTime()): DailyReviewArchive { - const start = new Date(fromMs); - const toMs = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 1).getTime(); - return { - id: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String( - start.getDate(), - ).padStart(2, '0')}-1d`, - day: { fromMs, toMs }, - range: 1, - status: 'ok', - generatedAt: toMs + 1, - trigger: 'manual', - modelKey: 'openrouter::openrouter/free', - sections: { summary: 'One durable review.' }, - totals: { - sessionCount: 1, - requestCount: 2, - totalTokens: 3, - costUsd: 0, - errorCount: 0, - }, - }; -} - -async function withInteractiveRoot( - run: (input: { - capability: Awaited>>; - }) => Promise, -): Promise { - const base = await mkdtemp(join(tmpdir(), 'maka-daily-review-authority-')); - try { - const capability = trackControlDirectory( - await resolveStorageRoot({ path: join(base, 'interactive'), kind: 'interactive' }), - ); - await run({ capability }); - } finally { - await rm(base, { recursive: true, force: true }); - } -} - -function isInvalidLease(error: unknown): boolean { - return error instanceof StorageRootAuthorityError && error.code === 'invalid_lease'; -} diff --git a/packages/storage/src/__tests__/legacy-daily-review-migration.test.ts b/packages/storage/src/__tests__/legacy-daily-review-migration.test.ts new file mode 100644 index 0000000000..49310873e9 --- /dev/null +++ b/packages/storage/src/__tests__/legacy-daily-review-migration.test.ts @@ -0,0 +1,211 @@ +/* + * 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 { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; +import { openLegacyDailyReviewMigrationForWrite } from '../legacy-daily-review-migration.js'; +import { openInteractiveScheduledTaskStoreForWrite } from '../scheduled-task-store.js'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +after(removeTrackedControlDirectories); + +test('reads released Daily Review config and archives without changing legacy rows', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-daily-review-migration-')); + const root = join(base, 'root'); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const scheduledTasks = await openInteractiveScheduledTaskStoreForWrite(owner.lease); + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + installLegacyDailyReviewTables(database); + const config = { + enabled: true, + executeTime: '09:30', + modelKey: 'openrouter::openrouter/free', + }; + const archive = { + id: '2026-08-28-1d', + day: { fromMs: 1_772_140_800_000, toMs: 1_772_227_200_000 }, + range: 1, + status: 'ok', + generatedAt: 1_772_227_200_001, + trigger: 'cron', + modelKey: config.modelKey, + sections: { summary: 'A durable report.' }, + totals: { + sessionCount: 2, + requestCount: 3, + totalTokens: 4, + costUsd: 0.01, + errorCount: 0, + }, + }; + database + .prepare('INSERT INTO workflow_daily_review_state(singleton, config_json) VALUES (1, ?)') + .run(JSON.stringify(config)); + database + .prepare( + 'INSERT INTO workflow_daily_review_authority_state(singleton, revision) VALUES (1, 7)', + ) + .run(); + database + .prepare( + `INSERT INTO workflow_daily_review_archives( + archive_id, generated_at, day_from_ms, record_json + ) VALUES (?, ?, ?, ?)`, + ) + .run(archive.id, archive.generatedAt, archive.day.fromMs, JSON.stringify(archive)); + + const migration = await openLegacyDailyReviewMigrationForWrite(owner.lease); + const snapshot = await migration.read(); + + assert.deepEqual(snapshot?.config, config); + assert.deepEqual(snapshot?.archives, [archive]); + assert.match(snapshot?.token ?? '', /^sha256:[a-f0-9]{64}$/u); + assert.equal(rowCount(database, 'workflow_daily_review_state'), 1); + assert.equal(rowCount(database, 'workflow_daily_review_archives'), 1); + migration.close(); + } finally { + database.close(); + scheduledTasks.close(); + if (owner) await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); + +test('retires legacy tables only for the exact migrated snapshot', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-daily-review-retirement-')); + const root = join(base, 'root'); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const scheduledTasks = await openInteractiveScheduledTaskStoreForWrite(owner.lease); + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + installLegacyDailyReviewTables(database); + database + .prepare('INSERT INTO workflow_daily_review_state(singleton, config_json) VALUES (1, ?)') + .run(JSON.stringify({ enabled: false, executeTime: '08:00', modelKey: '' })); + const migration = await openLegacyDailyReviewMigrationForWrite(owner.lease); + const snapshot = await migration.read(); + assert.ok(snapshot); + if (!snapshot) return; + + await assert.rejects(migration.retire(`sha256:${'0'.repeat(64)}`), /changed during migration/); + assert.equal(tableExists(database, 'workflow_daily_review_state'), true); + + assert.equal(await migration.retire(snapshot.token), true); + assert.equal(await migration.read(), null); + assert.equal(tableExists(database, 'workflow_daily_review_state'), false); + assert.equal(tableExists(database, 'workflow_daily_review_authority_state'), false); + assert.equal(tableExists(database, 'workflow_daily_review_archives'), false); + migration.close(); + } finally { + database.close(); + scheduledTasks.close(); + if (owner) await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); + +test('does not recreate retired Daily Review tables when the workspace reopens', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-daily-review-reopen-')); + const root = join(base, 'root'); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + let owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + let scheduledTasks = await openInteractiveScheduledTaskStoreForWrite(owner.lease); + let database = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + installLegacyDailyReviewTables(database); + const migration = await openLegacyDailyReviewMigrationForWrite(owner.lease); + const snapshot = await migration.read(); + assert.ok(snapshot); + if (!snapshot) return; + await migration.retire(snapshot.token); + migration.close(); + database.close(); + scheduledTasks.close(); + await owner.close(); + + owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + scheduledTasks = await openInteractiveScheduledTaskStoreForWrite(owner.lease); + database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + assert.equal(tableExists(database, 'workflow_daily_review_state'), false); + } finally { + database.close(); + scheduledTasks.close(); + if (owner) await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); + +function rowCount(database: DatabaseSync, table: string): number { + const row = database.prepare(`SELECT count(*) AS count FROM ${table}`).get() as { count: number }; + return row.count; +} + +function tableExists(database: DatabaseSync, table: string): boolean { + return Boolean( + database + .prepare("SELECT 1 AS present FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get(table), + ); +} + +function installLegacyDailyReviewTables(database: DatabaseSync): void { + database.exec(` + CREATE TABLE IF NOT EXISTS workflow_daily_review_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + config_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS workflow_daily_review_authority_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + revision INTEGER NOT NULL CHECK (revision >= 0) + ); + CREATE TABLE IF NOT EXISTS workflow_daily_review_archives ( + archive_id TEXT PRIMARY KEY, + generated_at INTEGER NOT NULL, + day_from_ms INTEGER NOT NULL, + record_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS workflow_daily_review_archives_order + ON workflow_daily_review_archives(generated_at DESC, day_from_ms DESC, archive_id); + `); +} diff --git a/packages/storage/src/__tests__/public-entrypoints.test.ts b/packages/storage/src/__tests__/public-entrypoints.test.ts index bdbb85bf7c..60792f1f6b 100644 --- a/packages/storage/src/__tests__/public-entrypoints.test.ts +++ b/packages/storage/src/__tests__/public-entrypoints.test.ts @@ -48,7 +48,6 @@ const SQLITE_BACKED_ENTRYPOINTS = [ './agent-graph-control-store', './agent-run-store', './artifact-stores', - './daily-review-authority', './deep-research-authority', './deep-research-store', './execution-stores', diff --git a/packages/storage/src/__tests__/sqlite-workflow-store.test.ts b/packages/storage/src/__tests__/sqlite-workflow-store.test.ts index 23fb82b95a..c025c7ed0e 100644 --- a/packages/storage/src/__tests__/sqlite-workflow-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-workflow-store.test.ts @@ -106,7 +106,7 @@ describe('SQLite workflow stores', () => { }); }); - test('migrates released workflow schema 9 projections to event-only schema 10', async () => { + test('migrates released workflow schema 9 projections to the current event-only schema', async () => { await withRoot(async (root) => { const taskStore = createSqliteTaskLedgerStore(root); await taskStore.create(SESSION_ID, [{ subject: 'Preserve event authority' }]); diff --git a/packages/storage/src/daily-review-authority.ts b/packages/storage/src/daily-review-authority.ts deleted file mode 100644 index f4cf3b6fd2..0000000000 --- a/packages/storage/src/daily-review-authority.ts +++ /dev/null @@ -1,370 +0,0 @@ -/* - * 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 { - DEFAULT_DAILY_REVIEW_CONFIG, - dailyReviewArchiveId, - dailyReviewArchiveToSummary, - normalizeDailyReviewArchive, - normalizeDailyReviewConfig, - parseDailyReviewArchiveId, - type DailyReviewArchive, - type DailyReviewArchiveSummary, - type DailyReviewConfig, -} from '@maka/core/daily-review'; -import { acquireOperationalStateDatabase } from './operational-state-store.js'; -import { - assertStorageRootLease, - runWithStorageRootLease, - StorageRootAuthorityError, - type StorageRootLease, -} from './root-authority.js'; - -const writerBrand: unique symbol = Symbol('InteractiveDailyReviewAuthorityWriter'); -const writers = new WeakSet(); -const writerByLease = new WeakMap(); - -export interface DailyReviewAuthoritySnapshot { - readonly revision: number; - readonly config: DailyReviewConfig; -} - -export interface DailyReviewArchivePage { - readonly archives: readonly DailyReviewArchiveSummary[]; - readonly nextBeforeArchiveId: string | null; -} - -export type DailyReviewConfigMutationResult = - | { - readonly kind: 'committed' | 'unchanged'; - readonly snapshot: DailyReviewAuthoritySnapshot; - } - | { - readonly kind: 'revision_conflict'; - readonly expectedRevision: number; - readonly actualRevision: number; - }; - -export interface InteractiveDailyReviewAuthorityWriter { - readonly kind: 'interactive'; - readonly access: 'write'; - readonly [writerBrand]: true; - readConfig(): Promise; - updateConfig( - expectedRevision: number, - config: DailyReviewConfig, - ): Promise; - publishArchive(archive: DailyReviewArchive, maxArchives: number): Promise; - listArchivePage(beforeArchiveId: string | null, limit: number): Promise; - getArchive(archiveId: string): Promise; - deleteArchive(archiveId: string): Promise; - close(): void; -} - -export function authenticateInteractiveDailyReviewAuthorityWriter( - writer: InteractiveDailyReviewAuthorityWriter, -): InteractiveDailyReviewAuthorityWriter { - if (!writers.has(writer)) { - throw new StorageRootAuthorityError( - 'invalid_lease', - 'Expected an authentic interactive Daily Review authority writer', - ); - } - return writer; -} - -export async function openInteractiveDailyReviewAuthorityForWrite( - lease: StorageRootLease<'interactive', 'write'>, -): Promise { - await assertStorageRootLease(lease, 'interactive', 'write'); - const existing = writerByLease.get(lease); - if (existing) return existing; - - const writer = createWriterFacade(lease); - await writer.readConfig(); - await assertStorageRootLease(lease, 'interactive', 'write'); - const raced = writerByLease.get(lease); - if (raced) { - writer.close(); - return raced; - } - writers.add(writer); - writerByLease.set(lease, writer); - return writer; -} - -function createWriterFacade( - lease: StorageRootLease<'interactive', 'write'>, -): InteractiveDailyReviewAuthorityWriter { - let closed = false; - const run = (operation: (root: string) => T): Promise => { - if (closed) { - return Promise.reject( - new StorageRootAuthorityError('invalid_lease', 'Daily Review authority writer is closed'), - ); - } - return runWithStorageRootLease(lease, 'interactive', 'write', async (root) => operation(root)); - }; - const withDatabase = ( - root: string, - mode: 'read' | 'write', - operation: (database: import('node:sqlite').DatabaseSync) => T, - ): T => { - const database = acquireOperationalStateDatabase(root); - try { - return database.transaction(mode, () => operation(database.database)); - } finally { - database.close(); - } - }; - - const writer: InteractiveDailyReviewAuthorityWriter = { - kind: 'interactive', - access: 'write', - [writerBrand]: true, - readConfig: () => - run((root) => withDatabase(root, 'read', (database) => readConfigSnapshot(database))), - updateConfig: (expectedRevision, config) => - run((root) => - withDatabase(root, 'write', (database) => { - const current = readConfigSnapshot(database); - if (current.revision !== expectedRevision) { - return { - kind: 'revision_conflict' as const, - expectedRevision, - actualRevision: current.revision, - }; - } - const next = normalizeDailyReviewConfig(config); - if (sameConfig(current.config, next)) { - return { kind: 'unchanged' as const, snapshot: current }; - } - const revision = current.revision + 1; - database - .prepare( - ` - INSERT INTO workflow_daily_review_state(singleton, config_json) - VALUES (1, ?) - ON CONFLICT(singleton) DO UPDATE SET config_json = excluded.config_json - `, - ) - .run(JSON.stringify(next)); - database - .prepare( - ` - INSERT INTO workflow_daily_review_authority_state(singleton, revision) - VALUES (1, ?) - ON CONFLICT(singleton) DO UPDATE SET revision = excluded.revision - `, - ) - .run(revision); - return { - kind: 'committed' as const, - snapshot: { revision, config: next }, - }; - }), - ), - publishArchive: (archive, maxArchives) => - run((root) => - withDatabase(root, 'write', (database) => { - assertArchiveId(archive.id); - const limit = requireArchiveLimit(maxArchives); - const normalized = normalizeDailyReviewArchive(archive); - if (normalized.id !== archive.id) { - throw new Error(`Daily Review archive id mismatch: ${archive.id}`); - } - if (normalized.id !== dailyReviewArchiveId(normalized.day, normalized.range)) { - throw new Error(`Daily Review archive day mismatch: ${archive.id}`); - } - database - .prepare( - ` - INSERT INTO workflow_daily_review_archives( - archive_id, generated_at, day_from_ms, record_json - ) VALUES (?, ?, ?, ?) - ON CONFLICT(archive_id) DO UPDATE SET - generated_at = excluded.generated_at, - day_from_ms = excluded.day_from_ms, - record_json = excluded.record_json - `, - ) - .run( - normalized.id, - normalized.generatedAt, - normalized.day.fromMs, - JSON.stringify(normalized), - ); - database - .prepare( - ` - DELETE FROM workflow_daily_review_archives - WHERE archive_id IN ( - SELECT archive_id - FROM workflow_daily_review_archives - WHERE archive_id <> ? - ORDER BY generated_at DESC, day_from_ms DESC, archive_id - LIMIT -1 OFFSET ? - ) - `, - ) - .run(normalized.id, limit - 1); - return normalized; - }), - ), - listArchivePage: (beforeArchiveId, limit) => - run((root) => - withDatabase(root, 'read', (database) => { - if (beforeArchiveId !== null) assertArchiveId(beforeArchiveId); - const pageLimit = requirePageLimit(limit); - const rows = ( - beforeArchiveId === null - ? database - .prepare( - ` - SELECT archive_id AS archiveId, record_json AS recordJson - FROM workflow_daily_review_archives - ORDER BY archive_id DESC - LIMIT ? - `, - ) - .all(pageLimit + 1) - : database - .prepare( - ` - SELECT archive_id AS archiveId, record_json AS recordJson - FROM workflow_daily_review_archives - WHERE archive_id < ? - ORDER BY archive_id DESC - LIMIT ? - `, - ) - .all(beforeArchiveId, pageLimit + 1) - ) as Array<{ - archiveId: string; - recordJson: string; - }>; - const archives = rows - .slice(0, pageLimit) - .map((row) => - dailyReviewArchiveToSummary(decodeArchive(row.archiveId, row.recordJson)), - ); - return { - archives, - nextBeforeArchiveId: rows.length > pageLimit ? (archives.at(-1)?.id ?? null) : null, - }; - }), - ), - getArchive: (archiveId) => - run((root) => - withDatabase(root, 'read', (database) => { - assertArchiveId(archiveId); - const row = database - .prepare( - ` - SELECT record_json AS recordJson - FROM workflow_daily_review_archives - WHERE archive_id = ? - `, - ) - .get(archiveId) as { recordJson?: unknown } | undefined; - return typeof row?.recordJson === 'string' - ? decodeArchive(archiveId, row.recordJson) - : null; - }), - ), - deleteArchive: (archiveId) => - run((root) => - withDatabase(root, 'write', (database) => { - assertArchiveId(archiveId); - return ( - database - .prepare('DELETE FROM workflow_daily_review_archives WHERE archive_id = ?') - .run(archiveId).changes > 0 - ); - }), - ), - close: () => { - if (closed) return; - closed = true; - if (writerByLease.get(lease) === writer) writerByLease.delete(lease); - writers.delete(writer); - }, - }; - return Object.freeze(writer); -} - -function readConfigSnapshot( - database: import('node:sqlite').DatabaseSync, -): DailyReviewAuthoritySnapshot { - const configRow = database - .prepare( - 'SELECT config_json AS configJson FROM workflow_daily_review_state WHERE singleton = 1', - ) - .get() as { configJson?: unknown } | undefined; - const revisionRow = database - .prepare('SELECT revision FROM workflow_daily_review_authority_state WHERE singleton = 1') - .get() as { revision?: unknown } | undefined; - const revision = - typeof revisionRow?.revision === 'number' && - Number.isSafeInteger(revisionRow.revision) && - revisionRow.revision >= 0 - ? revisionRow.revision - : 0; - const config = - typeof configRow?.configJson === 'string' - ? normalizeDailyReviewConfig(JSON.parse(configRow.configJson) as Partial) - : DEFAULT_DAILY_REVIEW_CONFIG; - return { revision, config }; -} - -function decodeArchive(archiveId: string, recordJson: string): DailyReviewArchive { - const archive = normalizeDailyReviewArchive(JSON.parse(recordJson)); - if (archive.id !== archiveId) { - throw new Error(`Daily Review archive id mismatch: ${archiveId}`); - } - return archive; -} - -function assertArchiveId(archiveId: string): void { - if (!parseDailyReviewArchiveId(archiveId)) { - throw new Error(`Invalid Daily Review archive id: ${archiveId}`); - } -} - -function requireArchiveLimit(value: number): number { - if (!Number.isSafeInteger(value) || value <= 0) { - throw new Error(`Invalid Daily Review archive limit: ${value}`); - } - return value; -} - -function requirePageLimit(value: number): number { - if (!Number.isSafeInteger(value) || value <= 0) { - throw new Error(`Invalid Daily Review page limit: ${value}`); - } - return value; -} - -function sameConfig(left: DailyReviewConfig, right: DailyReviewConfig): boolean { - return ( - left.enabled === right.enabled && - left.executeTime === right.executeTime && - left.modelKey === right.modelKey - ); -} diff --git a/packages/storage/src/legacy-daily-review-migration.ts b/packages/storage/src/legacy-daily-review-migration.ts new file mode 100644 index 0000000000..632fac8b96 --- /dev/null +++ b/packages/storage/src/legacy-daily-review-migration.ts @@ -0,0 +1,522 @@ +/* + * 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 { DatabaseSync } from 'node:sqlite'; +import { isDeepStrictEqual } from 'node:util'; +import type { StoredMessage } from '@maka/core/session'; +import type { InteractiveArtifactStoreWriter } from './artifact-stores.js'; +import type { ExecutionSessionWriter } from './execution-stores.js'; +import { acquireOperationalStateDatabase } from './operational-state-store.js'; +import { assertReleasedDailyReviewMigrationShape } from './operational-target-schema.js'; +import { + assertStorageRootLease, + runWithStorageRootLease, + type StorageRootLease, +} from './root-authority.js'; +import type { RuntimePolicyStoresWriter } from './runtime-policy-stores.js'; +import type { InteractiveScheduledTaskStoreWriter } from './scheduled-task-store.js'; + +export const DAILY_REVIEW_SYSTEM_TASK_ID = 'system-daily-review'; + +export const DAILY_REVIEW_SCHEDULED_TASK_INTENT = `Review the previous local day of Maka work using ordinary Session history. Identify completed work, unfinished or missed follow-ups, and useful patterns. Write a concise Markdown report and save it as a normal Session artifact. Do not use a dedicated Daily Review store or archive.`; + +const LEGACY_TABLES = [ + 'workflow_daily_review_state', + 'workflow_daily_review_authority_state', + 'workflow_daily_review_archives', +] as const; + +export interface LegacyDailyReviewConfig { + readonly enabled: boolean; + readonly executeTime: string; + readonly modelKey: string; +} + +export interface LegacyDailyReviewArchive { + readonly id: string; + readonly day: { readonly fromMs: number; readonly toMs: number }; + readonly range: 1 | 7 | 30; + readonly status: 'ok' | 'no_model' | 'no_data' | 'failed' | 'skipped'; + readonly generatedAt: number; + readonly trigger: 'cron' | 'manual'; + readonly modelKey: string; + readonly sections: Readonly<{ + summary?: string; + gaps?: string; + usage?: string; + code?: string; + }>; + readonly totals: Readonly<{ + sessionCount: number; + requestCount: number; + totalTokens: number; + costUsd: number; + errorCount: number; + }>; + readonly errorMessage?: string; +} + +export interface LegacyDailyReviewMigrationSnapshot { + readonly token: `sha256:${string}`; + readonly config: LegacyDailyReviewConfig; + readonly archives: readonly LegacyDailyReviewArchive[]; +} + +export interface LegacyDailyReviewMigrationWriter { + read(): Promise; + retire(token: LegacyDailyReviewMigrationSnapshot['token']): Promise; + close(): void; +} + +export async function migrateLegacyDailyReview(input: { + readonly legacy: LegacyDailyReviewMigrationWriter; + readonly scheduledTasks: InteractiveScheduledTaskStoreWriter; + readonly sessions: Pick< + ExecutionSessionWriter, + 'createStableSession' | 'readMessagesSnapshot' | 'appendMessage' + >; + readonly artifacts: InteractiveArtifactStoreWriter; + readonly runtimePolicy: RuntimePolicyStoresWriter; + readonly workspaceRoot: string; + readonly now?: () => number; +}): Promise { + const snapshot = await input.legacy.read(); + if (snapshot === null) return false; + const now = input.now?.() ?? Date.now(); + const target = await resolveExecutionTarget(snapshot.config.modelKey, input.runtimePolicy); + for (const archive of snapshot.archives) { + await migrateArchive(input, archive, target); + } + // The legacy scheduler accepted an enabled configuration before a model was + // configured. Do not turn that recoverable state into a permanently broken + // ScheduledTask: the inert legacy rows remain the one-shot migration source + // until a canonical Connection identity can be frozen on a later Host start. + if (target === undefined) return false; + const task = await input.scheduledTasks.ensureSystemTask( + DAILY_REVIEW_SYSTEM_TASK_ID, + { + title: 'Daily Review', + intentBody: DAILY_REVIEW_SCHEDULED_TASK_INTENT, + schedule: { + kind: 'calendar', + recurrence: 'daily', + anchorAt: localAnchorAt(now, snapshot.config.executeTime), + }, + effect: { + kind: 'agent_run', + execution: { + cwd: input.workspaceRoot, + projectId: null, + llmConnectionId: target.connectionId, + llmConnectionSlug: target.connectionSlug, + model: target.model, + permissionMode: (await input.runtimePolicy.runtimePolicy.getSnapshot()).policy + .chatDefaults.permissionMode, + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + createdBy: { kind: 'system' }, + }, + now, + ); + if (!snapshot.config.enabled && task.status === 'active') { + await input.scheduledTasks.pause(task.id, now); + } + if (!(await input.legacy.retire(snapshot.token))) { + throw new Error('Legacy Daily Review state disappeared during migration'); + } + return true; +} + +export async function openLegacyDailyReviewMigrationForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + let closed = false; + const run = (mode: 'read' | 'write', operation: (database: DatabaseSync) => T): Promise => { + if (closed) return Promise.reject(new Error('Legacy Daily Review migration is closed')); + return runWithStorageRootLease(lease, 'interactive', 'write', async (root) => { + const database = acquireOperationalStateDatabase(root); + try { + return database.transaction(mode, () => operation(database.database)); + } finally { + database.close(); + } + }); + }; + return Object.freeze({ + read: () => run('read', readSnapshot), + retire: (token: LegacyDailyReviewMigrationSnapshot['token']) => + run('write', (database) => { + const current = readSnapshot(database); + if (current === null) return false; + if (current.token !== token) { + throw new Error('Legacy Daily Review state changed during migration'); + } + database.exec(` + DROP INDEX IF EXISTS workflow_daily_review_archives_order; + DROP TABLE workflow_daily_review_archives; + DROP TABLE workflow_daily_review_authority_state; + DROP TABLE workflow_daily_review_state; + `); + return true; + }), + close: () => { + closed = true; + }, + }); +} + +function readSnapshot(database: DatabaseSync): LegacyDailyReviewMigrationSnapshot | null { + const present = LEGACY_TABLES.filter((table) => tableExists(database, table)); + if (present.length === 0) return null; + if (present.length !== LEGACY_TABLES.length) { + throw new Error('Legacy Daily Review tables are incomplete'); + } + assertReleasedDailyReviewMigrationShape(database); + + const configRow = database + .prepare( + 'SELECT config_json AS configJson FROM workflow_daily_review_state WHERE singleton = 1', + ) + .get() as { configJson?: unknown } | undefined; + const revisionRow = database + .prepare('SELECT revision FROM workflow_daily_review_authority_state WHERE singleton = 1') + .get() as { revision?: unknown } | undefined; + const archiveRows = database + .prepare( + `SELECT archive_id AS archiveId, generated_at AS generatedAt, + day_from_ms AS dayFromMs, record_json AS recordJson + FROM workflow_daily_review_archives + ORDER BY archive_id`, + ) + .all() as Array<{ + archiveId?: unknown; + generatedAt?: unknown; + dayFromMs?: unknown; + recordJson?: unknown; + }>; + if (configRow !== undefined && typeof configRow.configJson !== 'string') { + throw new Error('Legacy Daily Review config is invalid'); + } + if ( + revisionRow !== undefined && + (!Number.isSafeInteger(revisionRow.revision) || (revisionRow.revision as number) < 0) + ) { + throw new Error('Legacy Daily Review revision is invalid'); + } + const config = decodeConfig(configRow?.configJson); + const archives = archiveRows.map(decodeArchiveRow); + const tokenInput = JSON.stringify({ + configJson: configRow?.configJson ?? null, + revision: revisionRow?.revision ?? null, + archives: archiveRows, + }); + return { + token: `sha256:${createHash('sha256').update(tokenInput).digest('hex')}`, + config, + archives, + }; +} + +async function migrateArchive( + input: Parameters[0], + archive: LegacyDailyReviewArchive, + fallbackTarget: + | { + readonly connectionId: string; + readonly connectionSlug: string; + readonly model: string; + } + | undefined, +): Promise { + const sessionId = `daily-review-archive-${archive.id}`; + const turnId = `legacy-daily-review-${archive.id}`; + const report = renderArchiveMarkdown(archive); + const fingerprint = sha256( + JSON.stringify({ + kind: 'legacy-daily-review-archive', + archive, + workspace: input.workspaceRoot, + }), + ); + const archiveTarget = parseModelKey(archive.modelKey) ?? + fallbackTarget ?? { + connectionSlug: 'legacy-daily-review-unconfigured', + model: 'legacy-daily-review-unconfigured', + }; + const created = await input.sessions.createStableSession({ + sessionId, + requestFingerprint: fingerprint, + input: { + cwd: input.workspaceRoot, + projectId: null, + name: archiveTitle(archive), + labels: ['migrated:daily-review'], + ...(fallbackTarget && archiveTarget.connectionSlug === fallbackTarget.connectionSlug + ? { llmConnectionId: fallbackTarget.connectionId } + : {}), + llmConnectionSlug: archiveTarget.connectionSlug, + model: archiveTarget.model, + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }); + if (created.kind === 'conflict') { + throw new Error(`Legacy Daily Review Session identity conflicts: ${sessionId}`); + } + const messageId = `daily-review-message-${archive.id}`; + const messages = await input.sessions.readMessagesSnapshot(sessionId); + const existing = messages.find((message) => message.id === messageId); + const message: StoredMessage = { + type: 'assistant', + id: messageId, + turnId, + ts: archive.generatedAt, + text: report, + modelId: archiveTarget.model, + }; + if (existing === undefined) await input.sessions.appendMessage(sessionId, message); + else if (!isDeepStrictEqual(existing, message)) { + throw new Error(`Legacy Daily Review transcript identity conflicts: ${messageId}`); + } + await input.artifacts.create({ + id: `daily-review-report-${archive.id}`, + sessionId, + turnId, + name: `daily-review-${archive.id}.md`, + kind: 'file', + content: report, + mimeType: 'text/markdown', + source: 'snapshot', + summary: 'Migrated Daily Review report', + now: archive.generatedAt, + }); +} + +async function resolveExecutionTarget( + modelKey: string, + runtimePolicy: RuntimePolicyStoresWriter, +): Promise< + | { + readonly connectionId: string; + readonly connectionSlug: string; + readonly model: string; + } + | undefined +> { + const explicit = parseModelKey(modelKey); + const catalog = await runtimePolicy.connectionCatalog.getSnapshot(); + if (explicit) { + const connection = catalog.connections.find( + (candidate) => candidate.slug === explicit.connectionSlug, + ); + return connection ? { connectionId: connection.connectionId, ...explicit } : undefined; + } + const target = catalog.defaultTarget; + const connection = target + ? catalog.connections.find((candidate) => candidate.connectionId === target.connectionId) + : undefined; + if (target && connection) { + return { + connectionId: connection.connectionId, + connectionSlug: connection.slug, + model: target.modelId, + }; + } + return undefined; +} + +function parseModelKey( + value: string, +): { readonly connectionSlug: string; readonly model: string } | undefined { + const separator = value.indexOf('::'); + if (separator <= 0 || separator >= value.length - 2) return undefined; + const connectionSlug = value.slice(0, separator).trim(); + const model = value.slice(separator + 2).trim(); + return connectionSlug && model ? { connectionSlug, model } : undefined; +} + +function localAnchorAt(now: number, executeTime: string): number { + const [hours, minutes] = executeTime.split(':').map(Number); + const anchor = new Date(now); + anchor.setHours(hours ?? 8, minutes ?? 0, 0, 0); + return anchor.getTime(); +} + +function archiveTitle(archive: LegacyDailyReviewArchive): string { + const localDate = archive.id.replace(/-(?:1|7|30)d$/u, ''); + return `Daily Review · ${localDate} · ${archive.range}d`; +} + +function renderArchiveMarkdown(archive: LegacyDailyReviewArchive): string { + const headings = { + summary: 'Summary', + gaps: 'Gaps and follow-ups', + usage: 'Usage', + code: 'Code', + } as const; + const sections = Object.entries(headings).flatMap(([key, heading]) => { + const content = archive.sections[key as keyof LegacyDailyReviewArchive['sections']]; + return content?.trim() ? [`## ${heading}\n\n${content.trim()}`] : []; + }); + const metadata = [ + `- Archive ID: ${archive.id}`, + `- Day from: ${archive.day.fromMs}`, + `- Day to: ${archive.day.toMs}`, + `- Generated at: ${archive.generatedAt}`, + `- Trigger: ${archive.trigger}`, + `- Model: ${archive.modelKey || '(default)'}`, + `- Status: ${archive.status}`, + `- Range: ${archive.range} day${archive.range === 1 ? '' : 's'}`, + `- Sessions: ${archive.totals.sessionCount}`, + `- Requests: ${archive.totals.requestCount}`, + `- Tokens: ${archive.totals.totalTokens}`, + `- Cost: $${archive.totals.costUsd}`, + `- Errors: ${archive.totals.errorCount}`, + ...(archive.errorMessage ? [`- Error: ${archive.errorMessage}`] : []), + ]; + return [`# ${archiveTitle(archive)}`, metadata.join('\n'), ...sections].join('\n\n') + '\n'; +} + +function sha256(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +function decodeConfig(value: unknown): LegacyDailyReviewConfig { + if (value === undefined) return { enabled: false, executeTime: '08:00', modelKey: '' }; + const input = JSON.parse(value as string) as unknown; + if (!isRecord(input)) throw new Error('Legacy Daily Review config is invalid'); + return { + enabled: typeof input.enabled === 'boolean' ? input.enabled : false, + executeTime: + typeof input.executeTime === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/u.test(input.executeTime) + ? input.executeTime + : '08:00', + modelKey: typeof input.modelKey === 'string' ? input.modelKey : '', + }; +} + +function decodeArchiveRow(row: { + archiveId?: unknown; + generatedAt?: unknown; + dayFromMs?: unknown; + recordJson?: unknown; +}): LegacyDailyReviewArchive { + if ( + typeof row.archiveId !== 'string' || + !isFiniteNumber(row.generatedAt) || + !isFiniteNumber(row.dayFromMs) || + typeof row.recordJson !== 'string' + ) { + throw new Error('Legacy Daily Review archive row is invalid'); + } + const input = JSON.parse(row.recordJson) as unknown; + if (!isRecord(input)) throw new Error(`Legacy Daily Review archive ${row.archiveId} is invalid`); + const range = + input.range === 1 || input.range === 7 || input.range === 30 + ? input.range + : input.mode === 'daily' + ? 1 + : input.mode === 'deep' + ? 7 + : null; + if ( + input.id !== row.archiveId || + range === null || + !isRecord(input.day) || + !isFiniteNumber(input.day.fromMs) || + !isFiniteNumber(input.day.toMs) || + input.day.fromMs !== row.dayFromMs || + input.day.toMs <= input.day.fromMs || + input.generatedAt !== row.generatedAt || + !['ok', 'no_model', 'no_data', 'failed', 'skipped'].includes(String(input.status)) || + (input.trigger !== 'cron' && input.trigger !== 'manual') || + typeof input.modelKey !== 'string' || + !isRecord(input.sections) || + !isRecord(input.totals) + ) { + throw new Error(`Legacy Daily Review archive ${row.archiveId} is invalid`); + } + const sections: Record = {}; + for (const key of ['summary', 'gaps', 'usage', 'code']) { + const section = input.sections[key]; + if (section === undefined) continue; + if (typeof section !== 'string') { + throw new Error(`Legacy Daily Review archive ${row.archiveId} is invalid`); + } + sections[key] = section; + } + const totals = decodeTotals(input.totals, row.archiveId); + if (input.errorMessage !== undefined && typeof input.errorMessage !== 'string') { + throw new Error(`Legacy Daily Review archive ${row.archiveId} is invalid`); + } + return { + id: row.archiveId, + day: { fromMs: input.day.fromMs, toMs: input.day.toMs }, + range, + status: input.status as LegacyDailyReviewArchive['status'], + generatedAt: row.generatedAt, + trigger: input.trigger, + modelKey: input.modelKey, + sections, + totals, + ...(input.errorMessage === undefined ? {} : { errorMessage: input.errorMessage }), + }; +} + +function decodeTotals( + input: Record, + archiveId: string, +): LegacyDailyReviewArchive['totals'] { + const integerKeys = ['sessionCount', 'requestCount', 'totalTokens', 'errorCount'] as const; + if ( + integerKeys.some((key) => !Number.isSafeInteger(input[key]) || (input[key] as number) < 0) || + !isFiniteNumber(input.costUsd) || + input.costUsd < 0 + ) { + throw new Error(`Legacy Daily Review archive ${archiveId} is invalid`); + } + return { + sessionCount: input.sessionCount as number, + requestCount: input.requestCount as number, + totalTokens: input.totalTokens as number, + costUsd: input.costUsd, + errorCount: input.errorCount as number, + }; +} + +function tableExists(database: DatabaseSync, table: string): boolean { + return Boolean( + database + .prepare("SELECT 1 AS present FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get(table), + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} diff --git a/packages/storage/src/operational-target-schema.ts b/packages/storage/src/operational-target-schema.ts index 244850d365..92c2d994e1 100644 --- a/packages/storage/src/operational-target-schema.ts +++ b/packages/storage/src/operational-target-schema.ts @@ -51,7 +51,14 @@ export function isCurrentOperationalTargetSchema(database: DatabaseSync): boolea export function assertCurrentOperationalTargetSchema(database: DatabaseSync): void { const target = (cachedTargetSchema ??= buildOperationalTargetSchema()); - const actual = readSchema(database); + const actual = new Map(readSchema(database)); + const dailyReviewObjects = readSchemaObjects(database).filter((object) => + DAILY_REVIEW_MIGRATION_TABLES.has(object.tableName), + ); + if (dailyReviewObjects.length > 0) { + assertReleasedDailyReviewMigrationShape(database); + for (const object of dailyReviewObjects) actual.delete(object.key); + } for (const [name, required] of target) { const observed = actual.get(name); if (observed === undefined) throw incomplete(`missing required schema object ${name}`); @@ -140,8 +147,45 @@ const RELEASED_LEGACY_RETIREMENT_DDL: ReadonlyMap = new Map([ FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE )`, ], + [ + 'workflow_daily_review_state', + `CREATE TABLE workflow_daily_review_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + config_json TEXT NOT NULL + )`, + ], + [ + 'workflow_daily_review_authority_state', + `CREATE TABLE workflow_daily_review_authority_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + revision INTEGER NOT NULL CHECK (revision >= 0) + )`, + ], + [ + 'workflow_daily_review_archives', + `CREATE TABLE workflow_daily_review_archives ( + archive_id TEXT PRIMARY KEY, + generated_at INTEGER NOT NULL, + day_from_ms INTEGER NOT NULL, + record_json TEXT NOT NULL + ); + CREATE INDEX workflow_daily_review_archives_order + ON workflow_daily_review_archives(generated_at DESC, day_from_ms DESC, archive_id)`, + ], +]); + +const DAILY_REVIEW_MIGRATION_TABLES = new Set([ + 'workflow_daily_review_state', + 'workflow_daily_review_authority_state', + 'workflow_daily_review_archives', ]); +export function assertReleasedDailyReviewMigrationShape(database: DatabaseSync): void { + for (const table of DAILY_REVIEW_MIGRATION_TABLES) { + assertReleasedLegacyRetirementShape(database, table); + } +} + let cachedLegacyRetirementSchema: ReadonlyMap> | undefined; function buildLegacyRetirementSchema(): ReadonlyMap> { diff --git a/packages/storage/src/scheduled-task-store.ts b/packages/storage/src/scheduled-task-store.ts index 9f28ec3dcc..a7827e3d3d 100644 --- a/packages/storage/src/scheduled-task-store.ts +++ b/packages/storage/src/scheduled-task-store.ts @@ -67,6 +67,7 @@ interface ScheduledTaskStore { list(): Promise; get(id: string): Promise; create(input: unknown, now?: number): Promise; + ensureSystemTask(id: string, input: unknown, now?: number): Promise; update(id: string, patch: unknown, now?: number): Promise; pause(id: string, now?: number): Promise; resume(id: string, now?: number): Promise; @@ -199,6 +200,7 @@ function createWriterFacade( list: () => run(() => store.list()), get: (id) => run(() => store.get(id)), create: (input, now) => run(() => store.create(input, now)), + ensureSystemTask: (id, input, now) => run(() => store.ensureSystemTask(id, input, now)), update: (id, patch, now) => run(() => store.update(id, patch, now)), pause: (id, now) => run(() => store.pause(id, now)), resume: (id, now) => run(() => store.resume(id, now)), @@ -275,6 +277,50 @@ class SqliteScheduledTaskStore implements ScheduledTaskStore { return task; } + async ensureSystemTask(id: string, input: unknown, now = Date.now()): Promise { + if (!/^[A-Za-z0-9_-]{1,160}$/u.test(id)) { + throw storeError('invalid_input', 'Scheduled system task id is invalid'); + } + const normalized = normalizeCreateScheduledTaskInput(input, now); + if (!normalized.ok) throw storeError('invalid_input', normalized.message); + if (normalized.value.createdBy.kind !== 'system') { + throw storeError('invalid_input', 'Scheduled system task must be created by system'); + } + let result: ScheduledTask | undefined; + await this.mutate((state) => { + const existing = state.tasks.find((task) => task.id === id); + if (existing) { + if (existing.createdBy.kind !== 'system') { + throw storeError('operation_conflict', `Scheduled task id is already owned: ${id}`); + } + result = existing; + return state; + } + const value = normalized.value; + const task: ScheduledTask = { + id, + title: value.title, + intent: { kind: 'text', body: value.intentBody }, + schedule: value.schedule, + effect: value.effect, + status: 'active', + nextFireAt: value.nextFireAt, + lastFireAt: null, + fireCount: 0, + maxFires: value.maxFires ?? null, + expiresAt: value.expiresAt ?? null, + createdBy: value.createdBy, + createdAt: now, + updatedAt: now, + runs: [], + lastError: null, + }; + result = task; + return { ...state, tasks: [...state.tasks, task] }; + }); + return result!; + } + async update(id: string, patch: unknown, now = Date.now()): Promise { const normalized = normalizeUpdateScheduledTaskInput(patch, now); if (!normalized.ok) throw storeError('invalid_input', normalized.message); diff --git a/packages/storage/src/sqlite-workflow-schema.ts b/packages/storage/src/sqlite-workflow-schema.ts index ab3486f07e..4a013b0184 100644 --- a/packages/storage/src/sqlite-workflow-schema.ts +++ b/packages/storage/src/sqlite-workflow-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_WORKFLOW_SCHEMA_VERSION = 10; +export const SQLITE_WORKFLOW_SCHEMA_VERSION = 11; const RELEASED_WORKFLOW_PROJECTION_TABLES = [ { @@ -97,26 +97,6 @@ export function migrateSqliteWorkflowDatabase(db: DatabaseSync): void { record_json TEXT NOT NULL ); - CREATE TABLE IF NOT EXISTS workflow_daily_review_state ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - config_json TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS workflow_daily_review_authority_state ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - revision INTEGER NOT NULL CHECK (revision >= 0) - ); - - CREATE TABLE IF NOT EXISTS workflow_daily_review_archives ( - archive_id TEXT PRIMARY KEY, - generated_at INTEGER NOT NULL, - day_from_ms INTEGER NOT NULL, - record_json TEXT NOT NULL - ); - - CREATE INDEX IF NOT EXISTS workflow_daily_review_archives_order - ON workflow_daily_review_archives(generated_at DESC, day_from_ms DESC, archive_id); - CREATE TABLE IF NOT EXISTS workflow_work_board_items ( item_id TEXT PRIMARY KEY, revision INTEGER NOT NULL CHECK (revision >= 1), diff --git a/packages/storage/src/storage-writer-composition.ts b/packages/storage/src/storage-writer-composition.ts index 9ed4ba4680..902fa5e22a 100644 --- a/packages/storage/src/storage-writer-composition.ts +++ b/packages/storage/src/storage-writer-composition.ts @@ -20,11 +20,11 @@ import { openInteractiveArtifactStoreForWrite } from './artifact-stores.js'; import type { ContextOffloadLimits } from '@maka/core/context-offload'; import { openInteractiveContextOffloadStoreForWrite } from './context-offload-store.js'; -import { openInteractiveDailyReviewAuthorityForWrite } from './daily-review-authority.js'; import { openInteractiveDeepResearchStoreForWrite } from './deep-research-authority.js'; import { openInteractiveExecutionStoresForWrite } from './execution-stores.js'; import { openInteractiveGoalAuthorityForWrite } from './goal-authority.js'; import { openInteractiveLongTermMemoryStoreForWrite } from './long-term-memory-store.js'; +import { openLegacyDailyReviewMigrationForWrite } from './legacy-daily-review-migration.js'; import { openInteractiveMemoryBundleStoreForWrite } from './memory-bundle-store.js'; import { openInteractivePlanStoreForWrite } from './plan-authority.js'; import { openInteractiveProjectCatalogForWrite } from './project-catalog-authority.js'; @@ -51,7 +51,7 @@ export interface StorageWriterComposition { readonly scheduledTasks: Awaited>; readonly plan: Awaited>; readonly deepResearch: Awaited>; - readonly dailyReview: Awaited>; + readonly legacyDailyReview: Awaited>; readonly goal: Awaited>; readonly memoryBundle: Awaited>; readonly longTermMemory: Awaited>; @@ -142,8 +142,8 @@ async function createComposition( () => openInteractiveDeepResearchStoreForWrite(lease), closeWriter, ); - const dailyReview = await openWriter( - () => openInteractiveDailyReviewAuthorityForWrite(lease), + const legacyDailyReview = await openWriter( + () => openLegacyDailyReviewMigrationForWrite(lease), closeWriter, ); const goal = await openWriter(() => openInteractiveGoalAuthorityForWrite(lease), closeWriter); @@ -188,7 +188,7 @@ async function createComposition( scheduledTasks, plan, deepResearch, - dailyReview, + legacyDailyReview, goal, memoryBundle, longTermMemory, diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index bfde2290b7..7c93880bbe 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -35,7 +35,7 @@ export type { SessionViewMode, } from './session-rail-context.js'; export type { SidebarUpdateReminder } from './session-sidebar-nav.js'; -export type { BundledSkillCatalogEntry, DailyReviewMarkdownActionInput, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, SkillGovernanceDetails } from './module-panel-types.js'; +export type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, SkillGovernanceDetails } from './module-panel-types.js'; export { describeLoadToolResult, formatRedactedJson, formatToolIntent, loadToolDisplayName } from './tool-format.js'; export { formatBytes, ToolCallDetail, ToolTrow } from './tool-activity.js'; export { ToolResultPreview } from './tool-activity/tool-result-preview.js'; @@ -63,7 +63,7 @@ export type { TurnPresentation, TurnPresentationDeriver, } from './chat-turn.js'; -export { ScheduledTasksPage, DailyReviewPage, SkillsPage } from './module-pages.js'; +export { ScheduledTasksPage, SkillsPage } from './module-pages.js'; export { Composer } from './composer.js'; export type { ComposerProps, diff --git a/packages/ui/src/daily-review-copy.ts b/packages/ui/src/daily-review-copy.ts deleted file mode 100644 index 3afe0196f8..0000000000 --- a/packages/ui/src/daily-review-copy.ts +++ /dev/null @@ -1,181 +0,0 @@ -/* - * 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 { DailyReviewArchive } from '@maka/core/daily-review'; - -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; - -type ArchiveSectionKey = keyof DailyReviewArchive['sections']; - -export interface DailyReviewCopy { - archive: { - section: Record; - status: Record; - trigger: Record; - title: (date: string, range: string) => string; - range: Record; - generated: (trigger: string, time: string) => string; - sessionCount: (count: number) => string; - defaultModel: string; - opening: string; - noContent: string; - /** The panel-empty (tier 2) sentence under `noContent`. */ - noContentHelp: string; - }; - date: { - today: string; - yesterday: string; - daysAgo: (count: number) => string; - recent7Days: string; - recent30Days: string; - shiftedRange: (range: string, days: number) => string; - unit: { day: string; week: string; month: string }; - earlier: (unit: string) => string; - later: (unit: string) => string; - }; - emptyOverview: { - todayTitle: string; - rangeTitle: (label: string) => string; - todayBody: string; - rangeBody: (label: string) => string; - }; - export: { - ariaLabel: string; - copyTitle: string; - copying: string; - copy: string; - appendTitle: string; - appending: string; - append: string; - saveTitle: string; - saving: string; - save: string; - }; - page: { - title: string; - generateAnalysis: string; - retryAnalysis: string; - viewAnalysis: string; - backToActivity: string; - timeRange: string; - rangeOptions: ReadonlyArray; - rangeSwitch: string; - }; - overview: { - ariaLabel: (label: string) => string; - refreshFailed: (error: string) => string; - retry: string; - conversations: string; - requests: string; - tokens: string; - cost: string; - activeConversations: string; - }; - errorFallback: string; - markdown: { - separator: ':' | ':'; - title: (dayLabel: string) => string; - conversations: string; - requests: string; - tokens: string; - cost: string; - errors: string; - activeConversations: string; - modelUsage: string; - toolCalls: string; - requestCount: (count: number) => string; - }; -} - -const DAILY_REVIEW_COPY = { - zh: { - archive: { - section: { summary: '任务摘要', gaps: '遗漏提醒', usage: '使用洞察', code: '代码建议' }, - status: { ok: '已生成', no_model: '缺少模型', no_data: '无数据', failed: '生成失败', skipped: '已跳过' }, - trigger: { cron: '定时', manual: '手动' }, - title: (date, mode) => `${date} · ${mode}`, - range: { 1: '单日', 7: '7 天', 30: '30 天' }, - generated: (trigger, time) => `${trigger}生成 ${time}`, - sessionCount: (count) => `${count} 任务`, - defaultModel: '默认任务模型', - opening: '正在打开这份报告…', - noContent: '这份报告没有生成正文内容。', - noContentHelp: '这一天没有归档内容。', - }, - date: { - today: '今天', yesterday: '昨天', daysAgo: (count) => `${count} 天前`, recent7Days: '最近 7 天', recent30Days: '最近 30 天', shiftedRange: (range, days) => `${range}(往前 ${days} 天)`, - unit: { day: '天', week: '周', month: '月' }, earlier: (unit) => `查看更早一${unit}`, later: (unit) => `查看更晚一${unit}`, - }, - emptyOverview: { - todayTitle: '等待记录今天活动', rangeTitle: (label) => `${label}无活动`, todayBody: '今天还没有发起任务,也没有调用模型。', rangeBody: (label) => `${label}范围内没有发起任务,也没有调用模型。`, - }, - export: { - ariaLabel: '回顾导出操作', copyTitle: '复制为 Markdown 摘要,方便分享 / 贴到笔记', copying: '复制中…', copy: '复制', appendTitle: '追加到当前输入框草稿', appending: '追加中…', append: '粘到输入框', saveTitle: '保存为 Markdown 文件', saving: '保存中…', save: '保存', - }, - page: { - title: '每日回顾', generateAnalysis: '生成分析', retryAnalysis: '重新生成', viewAnalysis: '查看分析', backToActivity: '返回活动', timeRange: '时间范围', rangeOptions: [['1', '今日'], ['7', '最近 7 天'], ['30', '最近 30 天']], rangeSwitch: '时间范围切换', - }, - overview: { - ariaLabel: (label) => `${label}概览`, refreshFailed: (error) => `每日回顾刷新失败:${error}`, retry: '重试', conversations: '任务', requests: '模型调用', tokens: 'Token', cost: '费用', activeConversations: '活跃任务', - }, - errorFallback: '每日回顾暂时不可用,请稍后重试。', - markdown: { - separator: ':', title: (dayLabel) => `# Maka · 每日回顾 · ${dayLabel}`, conversations: '任务', requests: '模型调用', tokens: 'Token', cost: '费用', errors: '错误', activeConversations: '活跃任务', modelUsage: '模型使用', toolCalls: '工具调用', requestCount: (count) => `${count} 次`, - }, - }, - en: { - archive: { - section: { summary: 'Task summary', gaps: 'Missed items', usage: 'Usage insights', code: 'Code suggestions' }, - status: { ok: 'Generated', no_model: 'Model unavailable', no_data: 'No data', failed: 'Generation failed', skipped: 'Skipped' }, - trigger: { cron: 'Scheduled', manual: 'Manual' }, - title: (date, mode) => `${date} · ${mode}`, - range: { 1: '1 day', 7: '7 days', 30: '30 days' }, - generated: (trigger, time) => `${trigger} · ${time}`, - sessionCount: (count) => `${count} ${count === 1 ? 'task' : 'tasks'}`, - defaultModel: 'Default task model', - opening: 'Opening this report…', - noContent: 'This report has no generated content.', - noContentHelp: 'Nothing archived for this day.', - }, - date: { - today: 'Today', yesterday: 'Yesterday', daysAgo: (count) => `${count} days ago`, recent7Days: 'Last 7 days', recent30Days: 'Last 30 days', shiftedRange: (range, days) => `${range} (${days} days earlier)`, - unit: { day: 'day', week: 'week', month: 'month' }, earlier: (unit) => `View previous ${unit}`, later: (unit) => `View next ${unit}`, - }, - emptyOverview: { - todayTitle: "Waiting for today's activity", rangeTitle: (label) => `No activity for ${label.toLowerCase()}`, todayBody: 'No tasks or model requests have started today.', rangeBody: (label) => `No tasks or model requests were made during ${label.toLowerCase()}.`, - }, - export: { - ariaLabel: 'Review export actions', copyTitle: 'Copy a Markdown summary to share or add to notes', copying: 'Copying…', copy: 'Copy', appendTitle: 'Append to the current composer draft', appending: 'Appending…', append: 'Add to composer', saveTitle: 'Save as a Markdown file', saving: 'Saving…', save: 'Save', - }, - page: { - title: 'Daily review', generateAnalysis: 'Generate analysis', retryAnalysis: 'Generate again', viewAnalysis: 'View analysis', backToActivity: 'Back to activity', timeRange: 'Time range', rangeOptions: [['1', 'Today'], ['7', 'Last 7 days'], ['30', 'Last 30 days']], rangeSwitch: 'Change time range', - }, - overview: { - ariaLabel: (label) => `${label} overview`, refreshFailed: (error) => `Failed to refresh daily review: ${error}`, retry: 'Retry', conversations: 'Tasks', requests: 'Model calls', tokens: 'Tokens', cost: 'Cost', activeConversations: 'Active tasks', - }, - errorFallback: 'Daily review is temporarily unavailable. Try again later.', - markdown: { - separator: ':', title: (dayLabel) => `# Maka · Daily review · ${dayLabel}`, conversations: 'Tasks', requests: 'Model calls', tokens: 'Tokens', cost: 'Cost', errors: 'Errors', activeConversations: 'Active tasks', modelUsage: 'Model usage', toolCalls: 'Tool calls', requestCount: (count) => `${count} ${count === 1 ? 'call' : 'calls'}`, - }, - }, -} satisfies UiCatalog; - -export function getDailyReviewCopy(locale: UiLocale): DailyReviewCopy { - return DAILY_REVIEW_COPY[locale]; -} diff --git a/packages/ui/src/daily-review-helpers.ts b/packages/ui/src/daily-review-helpers.ts deleted file mode 100644 index 04b3be6026..0000000000 --- a/packages/ui/src/daily-review-helpers.ts +++ /dev/null @@ -1,142 +0,0 @@ -/* - * 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. - */ - -/** - * Pure helpers backing the DailyReviewPanel — formatters, error - * mappers, and the Markdown serializer. - * - * PR-UI-LIB-EXTRACT-2 (WAWQAQ msg `510fef52`, round 3/10): pulled - * out of `components.tsx`. `formatDailyReviewMarkdown` was already - * a public export from `@maka/ui` (consumed by the desktop - * renderer's main.tsx + the `daily-review-copy-feedback-contract` - * test); the other four were internal to the panel file. byte- - * for-byte equivalent; behavior unchanged; `index.ts` re-exports - * everything from the new module so the public API surface stays - * identical. - * - * Why this seam: serializing a Daily Review to Markdown is a pure - * `summary → string` transform that has nothing to do with React. - * Living next to ~600 lines of DailyReviewPanel JSX made it hard - * to test (or even find) the formatter in isolation. - */ - -import type { - DailyReviewArchive, - DailyReviewArchiveSummary, - DailyReviewRange, - DailyReviewSummary, -} from '@maka/core/daily-review'; - -import type { UiLocale } from '@maka/core/ui-locale'; -import { generalizedErrorMessage, generalizedErrorMessageChinese } from '@maka/core/redaction'; -import { uiLocaleToIntlLocale } from '@maka/core/ui-locale'; -import { getDailyReviewCopy } from './daily-review-copy.js'; - -export function dailyReviewScopeKey(offsetDays: number, range: DailyReviewRange): string { - return `${offsetDays}:${range}`; -} - -export function dailyReviewPanelErrorMessage(error: unknown, locale: UiLocale): string { - const fallback = getDailyReviewCopy(locale).errorFallback; - return locale === 'zh' - ? generalizedErrorMessageChinese(error, fallback) - : generalizedErrorMessage(error, fallback); -} - -export function formatDailyReviewArchiveTitle( - archive: DailyReviewArchive | DailyReviewArchiveSummary, - locale: UiLocale, -): string { - const copy = getDailyReviewCopy(locale); - const d = new Date(archive.day.fromMs); - const date = d.toLocaleDateString(uiLocaleToIntlLocale(locale), { month: '2-digit', day: '2-digit' }); - const rangeLabel = copy.archive.range[archive.range]; - return copy.archive.title(date, rangeLabel); -} - -export function formatDailyReviewArchiveGeneratedAt(generatedAt: number, locale: UiLocale): string { - return new Date(generatedAt).toLocaleString(uiLocaleToIntlLocale(locale), { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }); -} - -/** - * PR-DAILY-REVIEW-COPY-0: produce a Markdown summary of the current - * Daily Review for clipboard share. Sessions list is title-only — - * we deliberately skip lastMessagePreview because the message body - * may contain content the user does not want in a shared note. - */ -export function formatDailyReviewMarkdown( - summary: DailyReviewSummary, - dayLabel: string, - locale: UiLocale, -): string { - const copy = getDailyReviewCopy(locale).markdown; - const intlLocale = uiLocaleToIntlLocale(locale); - const lines: string[] = []; - lines.push(copy.title(dayLabel)); - lines.push(''); - lines.push(`- ${copy.conversations}${copy.separator} ${summary.totals.sessionCount}`); - lines.push(`- ${copy.requests}${copy.separator} ${summary.totals.requestCount}`); - lines.push(`- ${copy.tokens}${copy.separator} ${summary.totals.totalTokens.toLocaleString(intlLocale)}`); - lines.push(`- ${copy.cost}${copy.separator} $${summary.totals.costUsd.toFixed(2)}`); - if (summary.totals.errorCount > 0) { - lines.push(`- ${copy.errors}${copy.separator} ${summary.totals.errorCount}`); - } - if (summary.sessions.length > 0) { - lines.push(''); - lines.push(`## ${copy.activeConversations}`); - for (const session of summary.sessions) { - lines.push(`- ${session.name}`); - } - } - if (summary.topModels.length > 0) { - lines.push(''); - lines.push(`## ${copy.modelUsage}`); - for (const entry of summary.topModels) { - const cost = entry.costUsd > 0 ? ` · $${entry.costUsd.toFixed(2)}` : ''; - lines.push(`- ${entry.label}${copy.separator} ${copy.requestCount(entry.requests)} · ${entry.totalTokens.toLocaleString(intlLocale)} tok${cost}`); - } - } - if (summary.topTools.length > 0) { - lines.push(''); - lines.push(`## ${copy.toolCalls}`); - for (const entry of summary.topTools) { - lines.push(`- ${entry.label}${copy.separator} ${copy.requestCount(entry.requests)}`); - } - } - return lines.join('\n'); -} - -/** - * Archive headers used to print the raw internal model key - * (`::`, e.g. `zai-live::glm-4.5`) — an - * implementation detail leaking into UI copy (designer audit P1-8). - * Show just the model id; the connection is not something the reader - * needs to decode a report. - */ -export function formatDailyReviewModelLabel(modelKey: string): string { - const separatorIndex = modelKey.lastIndexOf('::'); - if (separatorIndex === -1) return modelKey; - const modelId = modelKey.slice(separatorIndex + 2).trim(); - return modelId.length > 0 ? modelId : modelKey; -} diff --git a/packages/ui/src/daily-review-panel.tsx b/packages/ui/src/daily-review-panel.tsx deleted file mode 100644 index 45df6a744d..0000000000 --- a/packages/ui/src/daily-review-panel.tsx +++ /dev/null @@ -1,500 +0,0 @@ -/* - * 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 { useEffect, useMemo, useReducer, useRef, useState } from 'react'; -import type { - DailyReviewArchive, - DailyReviewArchiveSectionContent, - DailyReviewArchiveSummary, - DailyReviewRange, - DailyReviewSectionKey, - DailyReviewSummary, -} from '@maka/core/daily-review'; -import { DAILY_REVIEW_RANGES, DAILY_REVIEW_SECTION_KEYS } from '@maka/core/daily-review'; -import { uiLocaleToIntlLocale } from '@maka/core/ui-locale'; -import { - Banner, - Button, - Divider, - EmptyState, - Heading, - HStack, - List, - ListItem, - SegmentedControl, - SegmentedControlItem, - Skeleton, - StackItem, - Toolbar, - Text, - VStack, -} from '@astryxdesign/core'; -import { ICON_SIZE, ArrowLeft, CalendarDays, ChevronLeft, ChevronRight } from './icons.js'; -import { - dailyReviewPanelErrorMessage, - dailyReviewScopeKey, - formatDailyReviewArchiveGeneratedAt, - formatDailyReviewArchiveTitle, - formatDailyReviewModelLabel, -} from './daily-review-helpers.js'; -import type { DailyReviewBridge, DailyReviewMarkdownActionInput } from './module-panel-types.js'; -import type { ModuleHubHeader } from './module-hub-selector.js'; -import { getDailyReviewCopy } from './daily-review-copy.js'; -import { ModulePage } from './primitives/module-page.js'; -import { Markdown } from './markdown.js'; -import { RelativeTime } from './relative-time.js'; -import { useUiLocale } from './locale-context.js'; -import { useMountedRef } from './use-mounted-ref.js'; -import { - createDailyReviewActivityState, - dailyReviewActivityReducer, - shiftDailyReviewScope, - type DailyReviewScope, -} from './daily-review-view-state.js'; - -type DailyReviewRoute = - | { kind: 'activity' } - | { kind: 'report'; archive: DailyReviewArchive }; - -type DailyReviewArchiveState = - | { status: 'loading' } - | { status: 'ready'; archives: DailyReviewArchiveSummary[] } - | { status: 'error'; error: string }; - -export function DailyReviewPanel(props: { - bridge: DailyReviewBridge; - hubHeader?: ModuleHubHeader; - onSelectSession?: (sessionId: string) => void; - onCopyMarkdown?: (input: DailyReviewMarkdownActionInput) => Promise | void; - onAppendMarkdown?: (input: DailyReviewMarkdownActionInput) => Promise | void; - onSaveMarkdown?: (input: DailyReviewMarkdownActionInput) => Promise | void; -}) { - const locale = useUiLocale(); - const copy = getDailyReviewCopy(locale); - const intlLocale = uiLocaleToIntlLocale(locale); - const mounted = useMountedRef(); - const bridgeRef = useRef(props.bridge); - bridgeRef.current = props.bridge; - - const [activityState, dispatchActivity] = useReducer( - dailyReviewActivityReducer, - { range: 1, offsetDays: 0 }, - createDailyReviewActivityState, - ); - const [reloadToken, setReloadToken] = useState(0); - const [archiveState, setArchiveState] = useState({ status: 'loading' }); - const [archivesReloadToken, setArchivesReloadToken] = useState(0); - const [route, setRoute] = useState({ kind: 'activity' }); - const [pendingAction, setPendingAction] = useState(null); - const [actionError, setActionError] = useState(null); - - const { range, offsetDays } = activityState.selection; - const scopeKey = dailyReviewScopeKey(offsetDays, range); - const resolvedView = activityState.resolvedView; - const displayedSummary = resolvedView?.summary ?? null; - const visibleSummary = resolvedView?.scopeKey === scopeKey ? resolvedView.summary : null; - const loading = activityState.pendingScopeKey !== null; - const error = actionError - ?? activityState.error - ?? (archiveState.status === 'error' ? archiveState.error : null); - const currentArchive = useMemo(() => { - if (!visibleSummary || archiveState.status !== 'ready') return null; - return archiveState.archives.find((archive) => - archive.range === range - && archive.day.fromMs === visibleSummary.day.fromMs - && archive.day.toMs === visibleSummary.day.toMs, - ) ?? null; - }, [archiveState, range, visibleSummary]); - - useEffect(() => { - let cancelled = false; - const requestedScope = { range, offsetDays }; - dispatchActivity({ type: 'selected', scope: requestedScope }); - bridgeRef.current.fetchDay(offsetDays, range).then((next) => { - if (cancelled) return; - dispatchActivity({ type: 'resolved', scope: requestedScope, summary: next }); - }).catch((nextError: unknown) => { - if (cancelled) return; - dispatchActivity({ - type: 'rejected', - scope: requestedScope, - error: dailyReviewPanelErrorMessage(nextError, locale), - }); - }); - return () => { - cancelled = true; - }; - }, [locale, offsetDays, range, reloadToken]); - - useEffect(() => { - const listArchives = bridgeRef.current.listArchives; - if (!listArchives) { - setArchiveState({ status: 'ready', archives: [] }); - return; - } - let cancelled = false; - setArchiveState({ status: 'loading' }); - listArchives().then((next) => { - if (!cancelled) setArchiveState({ status: 'ready', archives: next }); - }).catch((nextError: unknown) => { - if (!cancelled) { - setArchiveState({ - status: 'error', - error: dailyReviewPanelErrorMessage(nextError, locale), - }); - } - }); - return () => { - cancelled = true; - }; - }, [archivesReloadToken, locale]); - - const rangeLabel = formatScopeLabel(activityState.selection); - const displayedRangeLabel = resolvedView ? formatScopeLabel(resolvedView.scope) : rangeLabel; - - function formatScopeLabel(scope: DailyReviewScope): string { - if (scope.range === 1) { - if (scope.offsetDays === 0) return copy.date.today; - if (scope.offsetDays === -1) return copy.date.yesterday; - return copy.date.daysAgo(-scope.offsetDays); - } - const base = scope.range === 7 ? copy.date.recent7Days : copy.date.recent30Days; - return scope.offsetDays === 0 ? base : copy.date.shiftedRange(base, -scope.offsetDays); - } - - function selectScope(scope: DailyReviewScope) { - setActionError(null); - dispatchActivity({ type: 'selected', scope }); - setRoute({ kind: 'activity' }); - } - - function changeRange(value: string) { - const next = Number(value) as DailyReviewRange; - if (!DAILY_REVIEW_RANGES.includes(next)) return; - selectScope({ range: next, offsetDays: 0 }); - } - - async function openArchive(summaryRow: DailyReviewArchiveSummary) { - const getArchive = props.bridge.getArchive; - if (!getArchive || pendingAction !== null) return; - setPendingAction('open'); - try { - const archive = await getArchive(summaryRow.id); - if (mounted.current) setRoute({ kind: 'report', archive }); - } catch (nextError) { - if (mounted.current) setActionError(dailyReviewPanelErrorMessage(nextError, locale)); - } finally { - if (mounted.current) setPendingAction(null); - } - } - - async function generateAnalysis() { - const runOnce = props.bridge.runOnce; - const getArchive = props.bridge.getArchive; - if (!runOnce || !getArchive || pendingAction !== null) return; - setPendingAction('generate'); - setActionError(null); - try { - const result = await runOnce({ range, offsetDays }); - const archive = await getArchive(result.archiveId); - if (!mounted.current) return; - setArchivesReloadToken((value) => value + 1); - setRoute({ kind: 'report', archive }); - } catch (nextError) { - if (mounted.current) setActionError(dailyReviewPanelErrorMessage(nextError, locale)); - } finally { - if (mounted.current) setPendingAction(null); - } - } - - const totals = displayedSummary?.totals; - const currentTotals = visibleSummary?.totals; - const hasActivity = Boolean(currentTotals && currentTotals.sessionCount + currentTotals.requestCount > 0); - const canAnalyze = Boolean( - props.bridge.runOnce - && props.bridge.listArchives - && props.bridge.getArchive, - ); - - const primaryAction = currentArchive?.status === 'ok' ? ( -