diff --git a/apps/desktop/e2e/accessibility-coverage.spec.ts b/apps/desktop/e2e/accessibility-coverage.spec.ts index fe4e97fe80..ae3af0d00f 100644 --- a/apps/desktop/e2e/accessibility-coverage.spec.ts +++ b/apps/desktop/e2e/accessibility-coverage.spec.ts @@ -91,19 +91,17 @@ 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 automationsNavigation = page.getByRole('navigation', { name: /定时任务内容/ }); const dailyReviewButton = automationsNavigation.getByRole('button', { name: '每日回顾', exact: true, }); await dailyReviewButton.click(); + await expect(page.locator('[data-module="daily-review"]')).toBeVisible(); 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/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 2a725c5029..3f361f01c2 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -501,11 +501,20 @@ export const test = base.extend<{ promptRailMotionWindow: Page; requestHeaderRowWindow: Page; newTaskTargetWindow: Page; + dailyReviewWindow: Page; }>({ // Seeded: a pre-staged connection clears onboarding so the composer is ready. window: async ({}, use) => { await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh' }, use); }, + dailyReviewWindow: async ({}, use) => { + await withE2eWindow({ + seed: false, + readinessSelector: '[data-module="daily-review"]', + e2eFixtureScenario: 'module-daily-review', + locale: 'zh', + }, use); + }, onboardingWindow: async ({}, use) => { await withE2eWindow({ seed: false, diff --git a/apps/desktop/e2e/module-hub.spec.ts b/apps/desktop/e2e/module-hub.spec.ts index 91609cfcfd..59fe853ffc 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 four leaves and opens scheduled creation once', async ({ window: page, }) => { const expand = page.getByRole('button', { name: '展开侧边栏' }); @@ -39,7 +39,18 @@ test('Module Hub switches all four leaves and opens scheduled creation once', as const automations = page.getByRole('navigation', { name: /定时任务内容/ }); await automations.getByRole('button', { name: '每日回顾', exact: true }).click(); await expect(page.locator('[data-module="daily-review"]')).toBeVisible(); - + await expect( + automations.getByRole('button', { name: '每日回顾', exact: true }), + ).toHaveAttribute('aria-current', 'true'); + await page.getByRole('button', { name: '设置每日回顾', exact: true }).click(); + const presetDialog = page.getByRole('dialog', { name: '新建定时任务' }); + await expect(presetDialog).toBeVisible(); + await expect(presetDialog.getByRole('textbox', { name: '标题' })).toHaveValue('每日回顾'); + await expect(presetDialog.getByRole('textbox', { name: '备注' })).toHaveValue(/普通任务历史/); + await page.keyboard.press('Escape'); + await expect(presetDialog).toBeHidden(); + await automations.getByRole('button', { name: '定时任务', exact: true }).click(); + await expect(page.locator('[data-module="scheduled-tasks"]')).toBeVisible(); await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k'); const palette = page.getByRole('dialog', { name: '命令面板' }); await expect(palette).toBeVisible(); @@ -50,3 +61,13 @@ test('Module Hub switches all four leaves and opens scheduled creation once', as await expect(createDialog).toHaveCount(1); await expect(page.locator('[data-module="scheduled-tasks"]')).toBeVisible(); }); + +test('Daily Review manages its backing task through the Scheduled Tasks inspector', async ({ + dailyReviewWindow: page, +}) => { + await page.getByRole('button', { name: '管理日程', exact: true }).click(); + await expect(page.locator('[data-module="scheduled-tasks"]')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Daily Review', exact: true })).toBeVisible(); + await page.getByRole('button', { name: '编辑', exact: true }).click(); + await expect(page.getByRole('dialog', { name: '编辑定时任务' })).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 index 101dd161f9..d0a2f2bb19 100644 --- a/apps/desktop/src/main/__tests__/daily-review-fixture.test.ts +++ b/apps/desktop/src/main/__tests__/daily-review-fixture.test.ts @@ -22,38 +22,58 @@ 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 { + scheduledTaskPresetSessionLabel, + scheduledTaskSessionLabel, +} from '@maka/core/scheduled-task'; +import { openInteractiveScheduledTaskStoreForWrite } from '@maka/storage/scheduled-task-store'; import { resolveStorageRoot, tryAcquireInteractiveRootOwner, } from '@maka/storage/root-authority'; -import { writeDailyReviewArchives } from '../e2e-fixture/scenarios-settings.js'; +import { dailyReviewSessions } from '../e2e-fixture/scenarios-sessions.js'; +import { + writeConnections, + writeScheduledTasks, +} from '../e2e-fixture/scenarios-settings.js'; -test('Daily Review fixture seeds archives through the storage authority', async () => { +test('Daily Review fixture uses the ScheduledTask and ordinary Session shapes', async () => { const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-daily-review-fixture-')); + const now = Date.UTC(2026, 4, 22, 11, 0, 0); try { - await writeDailyReviewArchives(workspaceRoot, Date.UTC(2026, 4, 21, 12, 0, 0)); + await writeConnections(workspaceRoot, now, 'module-daily-review'); + await writeScheduledTasks(workspaceRoot, now, 'module-daily-review'); const capability = await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' }); const owner = await tryAcquireInteractiveRootOwner(capability); assert.ok(owner); if (!owner) return; + const store = await openInteractiveScheduledTaskStoreForWrite(owner.lease); 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(); - } + const task = await store.get('system-daily-review'); + assert.equal(task?.presetId, 'daily-review'); + assert.equal(task?.createdBy.kind, 'system'); + assert.equal(task?.schedule.kind, 'calendar'); + assert.equal(task?.effect.kind, 'agent_run'); + assert.ok(task?.effect.kind === 'agent_run' && task.effect.execution.llmConnectionId); } finally { + store.close(); await owner.close(); } + + const sessions = dailyReviewSessions(now); + assert.deepEqual(sessions.map(({ header }) => header.labels), [ + [ + 'scheduled-task', + scheduledTaskSessionLabel('system-daily-review'), + scheduledTaskPresetSessionLabel('daily-review'), + ], + ['migrated:daily-review'], + ]); + assert.deepEqual(sessions.map(({ messages }) => messages[0]?.type), [ + 'assistant', + 'assistant', + ]); } 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 index 7fdb355eb5..5bfaa71d8d 100644 --- 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 @@ -20,291 +20,233 @@ 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 type { ArtifactDescriptor } from '@maka/core/artifacts'; +import { + scheduledTaskPresetSessionLabel, + type ScheduledTask, +} from '@maka/core/scheduled-task'; +import type { SessionSummary } from '@maka/core/session'; 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) => { + const promise = new Promise((resolvePromise) => { 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: [], - }; + return { promise, resolve }; } -function dailyReviewService( - day: ModuleHubServices['dailyReview']['day'], -): ModuleHubServices['dailyReview'] { +const task = { + id: 'user-created-review', + presetId: 'daily-review', + title: 'Daily Review', + intent: { kind: 'text', body: 'Review ordinary Session history.' }, + schedule: { kind: 'calendar', recurrence: 'daily', anchorAt: Date.now() + 60_000 }, + effect: { kind: 'notify', channel: 'local' }, + status: 'active', + nextFireAt: Date.now() + 60_000, + lastFireAt: null, + fireCount: 0, + maxFires: null, + expiresAt: null, + createdBy: { kind: 'user' }, + createdAt: Date.now(), + updatedAt: Date.now(), + runs: [], + lastError: null, +} satisfies ScheduledTask; + +function session(input: Partial & Pick): SessionSummary { return { - day, - runOnce: async () => ({ archiveId: 'archive-1' }), - listArchives: async () => [], - getArchive: async () => null, - saveMarkdownToFile: async () => ({ ok: true, path: '/tmp/review.md' }), + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionSlug: 'fixture', + connectionLocked: true, + model: 'fixture-model', + permissionMode: 'ask', + ...input, }; } -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 }>(); +test('projects the Daily Review preset from ordinary Session and usage services', async () => { + const { root } = installReactRenderer(); + const hosts: string[] = []; + const sessions = [ + session({ + id: 'report', + name: 'Daily Review report', + labels: [scheduledTaskPresetSessionLabel('daily-review')], + lastMessageAt: Date.now() - 1_000, + lastMessagePreview: 'Review body', + }), + session({ + id: 'ordinary', + name: 'Ordinary task', + lastMessageAt: Date.now() - 2_000, + }), + ]; const services = createFakeModuleHubServices({ runtimeHosts: { - getDefault: async () => currentHost, + getDefault: async () => ({ profileId: 'profile-a', hostId: 'host-a' }), + subscribeChanges: () => () => undefined, + }, + dailyReview: { + supported: true, + listSessions: async (host) => { + hosts.push(`sessions:${host.hostId}`); + return sessions; + }, + listArtifacts: async (sessionId) => { + hosts.push(`artifacts:${sessionId}`); + return [{ + id: 'report-artifact', + sessionId, + turnId: 'report-turn', + createdAt: Date.now(), + name: 'daily-review.md', + kind: 'file', + sizeBytes: 100, + mimeType: 'text/markdown', + source: 'tool_result', + status: 'live', + } satisfies ArtifactDescriptor]; + }, + readUsage: async (_range, host) => { + hosts.push(`usage:${host.hostId}`); + return { totalRequests: 7, totalTokens: 1_234, totalCostUsd: 0.25 }; + }, 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, - }); + controller = useDailyReviewController({ services, tasks: [task] }); 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(controller?.task, task); + const view = await controller!.bridge.load(1); + assert.deepEqual(hosts, ['sessions:host-a', 'usage:host-a', 'artifacts:report']); + assert.deepEqual(view.totals, { + sessionCount: 2, + totalRequests: 7, + totalTokens: 1_234, + totalCostUsd: 0.25, }); - assert.equal(claims, 1); - claimCurrent = false; - pendingDay.resolve({ ok: true, data: summary() }); - await act(async () => paste); - - assert.deepEqual(appended, []); - assert.deepEqual(successes, []); + assert.deepEqual(view.reports.map((report) => report.sessionId), ['report']); + assert.deepEqual(view.sessions.map((candidate) => candidate.sessionId), [ + 'report', + 'ordinary', + ]); }); -test('today paste rechecks its composer claim after an async failure Host fence', async () => { +test('invalidates the projection for ordinary Session and Runtime Host changes', async () => { const { root } = installReactRenderer(); - const host = { profileId: 'profile-a', hostId: 'host-a' }; - const finalHostRecheck = deferred(); - const errors: string[] = []; - let claimCurrent = true; - let hostReads = 0; + let sessionChanged: (() => void) | undefined; + let hostChanged: (() => void) | undefined; + let disposed = 0; const services = createFakeModuleHubServices({ runtimeHosts: { - getDefault: async () => { - hostReads += 1; - return hostReads === 3 ? finalHostRecheck.promise : host; + getDefault: async () => ({ profileId: 'profile-a', hostId: 'host-a' }), + subscribeChanges: (handler) => { + hostChanged = () => handler({ + profileId: 'profile-a', + hostId: 'host-a', + readiness: 'ready', + isDefault: true, + }); + return () => { disposed += 1; }; + }, + }, + dailyReview: { + supported: true, + listSessions: async () => [], + listArtifacts: async () => [], + readUsage: async () => ({ totalRequests: 0, totalTokens: 0, totalCostUsd: 0 }), + subscribeChanges: (handler) => { + sessionChanged = handler; + return () => { disposed += 1; }; }, - 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, - }); + controller = useDailyReviewController({ services, tasks: [task] }); 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, []); + assert.equal(controller?.revision, 0); + await act(async () => sessionChanged?.()); + assert.equal(controller?.revision, 1); + await act(async () => hostChanged?.()); + assert.equal(controller?.revision, 2); + await act(async () => root.unmount()); + assert.equal(disposed, 2); }); -test('page actions suppress late feedback after leaving Daily Review', async () => { +test('prefers the migrated system task over another Daily Review preset task', 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 }, - }); + const systemTask = { + ...task, + id: 'system-daily-review', + createdBy: { kind: 'system' as const }, + }; + const services = createFakeModuleHubServices(); 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, - }); + controller = useDailyReviewController({ services, tasks: [task, systemTask] }); 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']); + assert.equal(controller?.task, systemTask); }); -test('current default-Host Daily Review failures retain their diagnostic target', async () => { +test('rejects a projection if the default Runtime Host changes mid-read', async () => { const { root } = installReactRenderer(); - const errors: Array<{ title: string; profileId?: string }> = []; + const hostA = { profileId: 'profile-a', hostId: 'host-a' }; + const hostB = { profileId: 'profile-b', hostId: 'host-b' }; + let currentHost = hostA; + const pendingSessions = deferred(); const services = createFakeModuleHubServices({ runtimeHosts: { - getDefault: async () => ({ - profileId: 'remote-profile', - hostId: 'remote-host', - }), + getDefault: async () => currentHost, + subscribeChanges: () => () => undefined, + }, + dailyReview: { + supported: true, + listSessions: async () => pendingSessions.promise, + listArtifacts: async () => [], + readUsage: async () => ({ totalRequests: 0, totalTokens: 0, totalCostUsd: 0 }), 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, - }); + controller = useDailyReviewController({ services, tasks: [task] }); return null; } await act(async () => root.render(createElement(Probe))); - await act(async () => controller!.copyToday()); - - assert.deepEqual(errors, [ - { title: 'Copy failed', profileId: 'remote-profile' }, - ]); + const load = controller!.bridge.load(1); + currentHost = hostB; + pendingSessions.resolve([]); + await assert.rejects(load, /default Runtime Host changed/); }); 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..33a877cfc5 100644 --- a/apps/desktop/src/main/__tests__/module-hub-host.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-host.test.ts @@ -25,7 +25,7 @@ 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 presentation routes and no chat route', () => { const cases: Array<[NavSelection, ReturnType]> = [ [{ section: 'extensions', module: 'skills' }, 'skills'], [{ section: 'extensions', module: 'mcp' }, 'mcp'], diff --git a/apps/desktop/src/main/__tests__/module-hub-scheduled-tasks-controller.test.ts b/apps/desktop/src/main/__tests__/module-hub-scheduled-tasks-controller.test.ts index ef8cf0d3cd..dc232224f8 100644 --- a/apps/desktop/src/main/__tests__/module-hub-scheduled-tasks-controller.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-scheduled-tasks-controller.test.ts @@ -322,8 +322,8 @@ test('mutations keep titles current, preserve refreshes, and fence confirm conti calls.push('list'); return listed; }, - triggerNow: async (id) => { - calls.push(`trigger:${id}`); + triggerNow: async (id, _host, intentBody) => { + calls.push(`trigger:${id}:${intentBody ?? ''}`); return listed[0]!; }, delete: async (id) => { @@ -341,8 +341,8 @@ test('mutations keep titles current, preserve refreshes, and fence confirm conti }; await act(async () => renderController(root, services, activeProps)); await act(async () => controller().refresh()); - await act(async () => controller().triggerNow('task-a')); - assert.deepEqual(calls, ['list', 'trigger:task-a', 'list']); + await act(async () => controller().triggerNow('task-a', 'Manual range')); + assert.deepEqual(calls, ['list', 'trigger:task-a:Manual range', 'list']); assert.ok( records.some( (record) => record.kind === 'success' && record.detail === 'Latest title', diff --git a/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts b/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts index 4bf36c15f3..de1596f6bb 100644 --- a/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts @@ -19,6 +19,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; +import type { SessionSummary } from '@maka/core/session'; import type { DesktopRuntimeHostProfileChangedEvent } from '../../preload/bridge-contract.js'; import type { ModuleHubRuntimeHostRef } from '../../renderer/features/module-hub/testing.js'; import { @@ -43,8 +44,172 @@ function methodRecorder(calls: Call[], prefix: string) { ); } +function session( + input: Partial & { id: string; runtimeHostId: string }, +): SessionSummary & { runtimeHostId: string } { + return { + name: input.id, + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionSlug: 'fixture', + connectionLocked: true, + model: 'fixture-model', + permissionMode: 'ask', + ...input, + }; +} + describe('createDesktopModuleHubServices', () => { - it('maps host-scoped Skills, Scheduled Tasks, Daily Review, and clipboard operations', async () => { + it('projects Daily Review from the ordinary Session catalog and shared usage ledger', async () => { + const host: ModuleHubRuntimeHostRef = { + profileId: 'remote-a', + hostId: 'host-a', + }; + const sessions = [ + session({ id: 'local-session', runtimeHostId: 'host-a', lastMessageAt: 10 }), + session({ + id: 'local-session-revision', + runtimeHostId: 'host-a', + revisionRootSessionId: 'local-session', + revisionParentSessionId: 'local-session', + revisionState: 'committed', + lastMessageAt: 20, + }), + session({ id: 'other-session', runtimeHostId: 'host-b' }), + ]; + const ranges: unknown[] = []; + const usageHosts: unknown[] = []; + let sessionChanged: (() => void) | undefined; + let artifactChanged: (() => void) | undefined; + let notifications = 0; + const bridge = { + contract: { version: 2 }, + runtimeHostProfiles: { + getDefaultHost: async () => host, + subscribeChanges: () => () => undefined, + }, + skills: Object.assign(methodRecorder([], 'skills'), { + sources: methodRecorder([], 'skills.sources'), + catalog: methodRecorder([], 'skills.catalog'), + }), + scheduledTasks: methodRecorder([], 'scheduledTasks'), + artifacts: { + list: async () => [], + subscribeChanges(handler: () => void) { + artifactChanged = handler; + return () => undefined; + }, + }, + sessions: { + listWithCoverage: async () => ({ + sessions, + completeHostIds: ['host-a', 'host-b'], + }), + subscribeChanges(handler: () => void) { + sessionChanged = handler; + return () => undefined; + }, + }, + settings: { + usageStats: async (range: unknown, requestedHost: unknown) => { + ranges.push(range); + usageHosts.push(requestedHost); + return { + summary: { + totalRequests: 5, + totalTokens: 500, + totalCostUsd: 0.05, + }, + }; + }, + }, + } as unknown as DesktopModuleHubBridge; + const services = createDesktopModuleHubServices(bridge); + const range = { from: 100, to: 200 }; + + assert.equal(services.dailyReview.supported, true); + assert.deepEqual(await services.dailyReview.listSessions(host), [sessions[1]]); + assert.deepEqual(await services.dailyReview.listArtifacts('local-session-revision'), []); + assert.deepEqual(await services.dailyReview.readUsage(range, host), { + totalRequests: 5, + totalTokens: 500, + totalCostUsd: 0.05, + }); + services.dailyReview.subscribeChanges(() => { notifications += 1; }); + sessionChanged?.(); + artifactChanged?.(); + + assert.deepEqual(ranges, [range]); + assert.deepEqual(usageHosts, [host]); + assert.equal(notifications, 2); + }); + + it('rejects Daily Review when the target Host catalog is incomplete', async () => { + const host = { profileId: 'remote-a', hostId: 'host-a' }; + const bridge = { + contract: { version: 2 }, + runtimeHostProfiles: { + getDefaultHost: async () => host, + subscribeChanges: () => () => undefined, + }, + skills: Object.assign(methodRecorder([], 'skills'), { + sources: methodRecorder([], 'skills.sources'), + catalog: methodRecorder([], 'skills.catalog'), + }), + scheduledTasks: methodRecorder([], 'scheduledTasks'), + sessions: { + listWithCoverage: async () => ({ sessions: [], completeHostIds: ['host-b'] }), + subscribeChanges: () => () => undefined, + }, + settings: { + usageStats: async () => ({ + summary: { totalRequests: 0, totalTokens: 0, totalCostUsd: 0 }, + }), + }, + } as unknown as DesktopModuleHubBridge; + + await assert.rejects( + createDesktopModuleHubServices(bridge).dailyReview.listSessions(host), + /Session catalog is unavailable/, + ); + }); + + it('gates Daily Review when the renderer is paired with an older preload', async () => { + const bridge = { + contract: { version: 1 }, + runtimeHostProfiles: { + getDefaultHost: async () => ({ profileId: 'local', hostId: 'local' }), + subscribeChanges: () => () => undefined, + }, + skills: Object.assign(methodRecorder([], 'skills'), { + sources: methodRecorder([], 'skills.sources'), + catalog: methodRecorder([], 'skills.catalog'), + }), + scheduledTasks: methodRecorder([], 'scheduledTasks'), + sessions: { + listWithCoverage: async () => ({ sessions: [], completeHostIds: ['local'] }), + subscribeChanges: () => () => undefined, + }, + settings: { + usageStats: async () => ({ + summary: { totalRequests: 0, totalTokens: 0, totalCostUsd: 0 }, + }), + }, + } as unknown as DesktopModuleHubBridge; + const service = createDesktopModuleHubServices(bridge).dailyReview; + + assert.equal(service.supported, false); + await assert.rejects( + service.listSessions({ profileId: 'local', hostId: 'local' }), + /newer Desktop bridge/, + ); + }); + + it('maps host-scoped Skills and Scheduled Tasks operations', async () => { const calls: Call[] = []; const host: ModuleHubRuntimeHostRef = { profileId: 'remote-a', @@ -60,14 +225,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); @@ -93,21 +252,11 @@ describe('createDesktopModuleHubServices', () => { await services.scheduledTasks.create(createInput, host); await services.scheduledTasks.update('task', updateInput, host); await services.scheduledTasks.setEnabled('task', true, host); - await services.scheduledTasks.triggerNow('task', host); + await services.scheduledTasks.triggerNow('task', host, 'Manual range'); await services.scheduledTasks.snooze('task', host); 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] }, @@ -125,19 +274,10 @@ describe('createDesktopModuleHubServices', () => { { name: 'scheduledTasks.create', args: [createInput, host] }, { name: 'scheduledTasks.update', args: ['task', updateInput, host] }, { name: 'scheduledTasks.setEnabled', args: ['task', true, host] }, - { name: 'scheduledTasks.triggerNow', args: ['task', host] }, + { name: 'scheduledTasks.triggerNow', args: ['task', host, 'Manual range'] }, { 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 +316,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 +375,6 @@ describe('createDesktopModuleHubServices', () => { catalog: methodRecorder([], 'skills.catalog'), }), scheduledTasks: methodRecorder([], 'scheduledTasks'), - dailyReview: methodRecorder([], 'dailyReview'), }; const services = createDesktopModuleHubServices( { @@ -259,7 +395,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 +411,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..e9dc771b54 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('preserves Daily Review as an Automations presentation route', () => { assert.deepEqual( parseNavigationState( JSON.stringify({ diff --git a/apps/desktop/src/main/__tests__/scheduled-task-template-effect.test.ts b/apps/desktop/src/main/__tests__/scheduled-task-template-effect.test.ts new file mode 100644 index 0000000000..f193c38cad --- /dev/null +++ b/apps/desktop/src/main/__tests__/scheduled-task-template-effect.test.ts @@ -0,0 +1,59 @@ +/* + * 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 { resolveScheduledTaskAgentRunTemplate } from '../../renderer/scheduled-task-template-effect.js'; + +const template = { + projectPath: '/workspace/default', + projectId: 'project-default', + model: { + llmConnectionId: 'connection-default', + llmConnectionSlug: 'default', + model: 'model-default', + }, + permissionMode: 'ask' as const, + collaborationMode: 'agent' as const, + orchestrationMode: 'default' as const, +}; + +test('Scheduled Task Agent binding only uses a default-Host Task Entry snapshot', () => { + assert.deepEqual( + resolveScheduledTaskAgentRunTemplate({ usesDefaultHost: true, ...template }), + { + kind: 'agent_run', + execution: { + cwd: '/workspace/default', + projectId: 'project-default', + llmConnectionId: 'connection-default', + llmConnectionSlug: 'default', + model: 'model-default', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + ); + + assert.equal( + resolveScheduledTaskAgentRunTemplate({ usesDefaultHost: false, ...template }), + undefined, + ); +}); 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..d1eaa9723b 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -48,10 +48,9 @@ import { turnSession, } from './e2e-fixture/scenarios-chat.js'; import { seedMcpFixture, seedSkillsMarketFixture } from './e2e-fixture/scenarios-modules.js'; -import { longSidebarSessions } from './e2e-fixture/scenarios-sessions.js'; +import { dailyReviewSessions, longSidebarSessions } from './e2e-fixture/scenarios-sessions.js'; import { writeConnections, - writeDailyReviewArchives, writeScheduledTasks, writeSettings, } from './e2e-fixture/scenarios-settings.js'; @@ -253,8 +252,14 @@ export async function seedE2eFixture(input: { catalog.close(); } } - if (scenario === 'scheduled-tasks') await writeScheduledTasks(input.workspaceRoot, now); - if (scenario === 'module-daily-review') await writeDailyReviewArchives(input.workspaceRoot, now); + if (scenario === 'module-daily-review') { + for (const seed of dailyReviewSessions(now)) { + await writeSession(input.workspaceRoot, seed.header, seed.messages); + } + } + if (scenario === 'scheduled-tasks' || scenario === 'module-daily-review') { + await writeScheduledTasks(input.workspaceRoot, now, scenario); + } 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-sessions.ts b/apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts index a7a88d55c3..7f477b7445 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts @@ -18,6 +18,10 @@ */ import type { SessionHeader, StoredMessage } from '@maka/core/session'; +import { + scheduledTaskPresetSessionLabel, + scheduledTaskSessionLabel, +} from '@maka/core/scheduled-task'; import { header, LONG_SIDEBAR_PROJECT_ID, @@ -65,3 +69,56 @@ export function longSidebarSessions( }; }); } + +export function dailyReviewSessions( + now: number, +): Array<{ header: SessionHeader; messages: StoredMessage[] }> { + return [ + { + header: { + ...header({ + id: 'daily-review-current', + name: '每日回顾 · 5月22日', + connection: 'zai-live', + model: 'glm-5.1', + now, + lastMessageAt: now - 35 * 60_000, + }), + labels: [ + 'scheduled-task', + scheduledTaskSessionLabel('system-daily-review'), + scheduledTaskPresetSessionLabel('daily-review'), + ], + }, + messages: [{ + type: 'assistant', + id: 'daily-review-current-message', + turnId: 'daily-review-current-turn', + ts: now - 35 * 60_000, + text: '已完成迁移设计与窄测试;下一步需要确认 Daily Review 页面保留历史入口。', + modelId: 'glm-5.1', + }], + }, + { + header: { + ...header({ + id: 'daily-review-migrated', + name: 'Daily Review · 2026-05-21 · 1d', + connection: 'zai-live', + model: 'glm-5.1', + now, + lastMessageAt: now - 24 * 60 * 60_000, + }), + labels: ['migrated:daily-review'], + }, + messages: [{ + type: 'assistant', + id: 'daily-review-migrated-message', + turnId: 'daily-review-migrated-turn', + ts: now - 24 * 60 * 60_000, + text: '这份旧版回顾已迁移为普通任务,并保留原始 Markdown 报告 Artifact。', + modelId: 'glm-5.1', + }], + }, + ]; +} diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts b/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts index 0236a0de9f..f343a38ceb 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 { @@ -191,7 +189,11 @@ function model( return { id, capabilities, contextWindow }; } -export async function writeScheduledTasks(workspaceRoot: string, now: number): Promise { +export async function writeScheduledTasks( + workspaceRoot: string, + now: number, + scenario: E2eFixtureScenario = 'scheduled-tasks', +): Promise { const scheduledRunAt = Date.UTC(2026, 11, 18, 3, 0, 0); const pausedRunAt = Date.UTC(2026, 11, 20, 3, 0, 0); // The panel's default 创建时间倒序 sort keys on `createdAt` and only falls @@ -222,6 +224,42 @@ export async function writeScheduledTasks(workspaceRoot: string, now: number): P at, ); try { + if (scenario === 'module-daily-review') { + const runtimePolicy = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + 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 Error('Daily Review fixture model target is missing'); + const anchorAt = new Date(now); + anchorAt.setHours(18, 0, 0, 0); + if (anchorAt.getTime() <= now) anchorAt.setDate(anchorAt.getDate() + 1); + await store.ensureSystemTask( + 'system-daily-review', + { + title: 'Daily Review', + presetId: 'daily-review', + intentBody: 'Review ordinary Session history and save a Markdown report as an Artifact.', + schedule: { kind: 'calendar', recurrence: 'daily', anchorAt: anchorAt.getTime() }, + effect: { + kind: 'agent_run', + execution: { + cwd: workspaceRoot, + projectId: null, + llmConnectionId: connection.connectionId, + llmConnectionSlug: connection.slug, + model: target.modelId, + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + createdBy: { kind: 'system' }, + }, + now - 9 * 60_000, + ); + } await create( '同步项目风险', '提醒我整理 Sidebar gate、搜索接入和计划任务剩余风险。', @@ -283,66 +321,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/main/runtime-host-usage-ipc-main.ts b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts index 91a162a0ad..3a837e1a67 100644 --- a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts @@ -66,7 +66,7 @@ export function registerRuntimeHostUsageIpc( handleReconnectableRead( deps.ipcMain, "settings:usageStats", - (_event, range: UsageRange = "24h") => + (_event, range: TimeRange = "24h") => loadUsageStats(deps.client, normalizeUsageRange(range)), ); handleReconnectableRead( @@ -158,7 +158,7 @@ export function registerRuntimeHostUsageIpc( async function loadUsageStats( client: DesktopRuntimeHostClient, - range: UsageRange, + range: TimeRange, ): Promise { const query = { range: resolveUsageRange(range, Date.now()) } satisfies UsageQuery; const [summaryResult, llmResult, toolResult, pricing] = await Promise.all([ @@ -422,10 +422,23 @@ function toLlmQuery(query: UsageQuery) { return llmQuery; } -function normalizeUsageRange(range: unknown): UsageRange { - return range === "24h" || range === "7d" || range === "30d" || range === "all" - ? range - : "24h"; +function normalizeUsageRange(range: unknown): TimeRange { + if (range === "24h" || range === "7d" || range === "30d" || range === "all") return range; + if ( + typeof range === "object" && + range !== null && + !Array.isArray(range) && + Number.isFinite((range as { from?: unknown }).from) && + Number.isFinite((range as { to?: unknown }).to) && + (range as { from: number }).from >= 0 && + (range as { from: number; to: number }).to > (range as { from: number }).from + ) { + return { + from: (range as { from: number }).from, + to: (range as { to: number }).to, + }; + } + return "24h"; } function toToolQuery(query: UsageQuery) { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index df55b7d7aa..32efb961c5 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -78,6 +78,7 @@ import type { ArtifactSaveResult, ArtifactTextReadResult, } from '@maka/core/artifacts'; +import type { TimeRange } from '@maka/core/usage-stats/types'; import type { CapabilitySnapshotCollection, PermissionSnapshot } from '@maka/core/capabilities'; import type { LocalMemoryState } from '@maka/core/local-memory'; import type { @@ -86,13 +87,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'; @@ -712,7 +706,8 @@ export interface MakaBridge { decision: 'approve' | 'reject', ): Promise; }; - + /** Renderer/preload compatibility marker; absent in older loaded documents. */ + readonly contract: { readonly version: 2 }; runtimeHost: { query( operation: K, @@ -1331,7 +1326,7 @@ export interface MakaBridge { subscribeExternalChanged(handler: () => void, host?: DesktopRuntimeHostRef): () => void; testNetworkProxy(input?: TestProxyInput, host?: DesktopRuntimeHostRef): Promise; testBotChannel(provider: BotProvider): Promise; - usageStats(range?: UsageRange, host?: DesktopRuntimeHostRef): Promise; + usageStats(range?: UsageRange | TimeRange, host?: DesktopRuntimeHostRef): Promise; bots: { listStatuses(): Promise>; restart(provider: BotProvider): Promise; @@ -1489,7 +1484,11 @@ export interface MakaBridge { host?: DesktopRuntimeHostRef, ): Promise; setEnabled(id: string, enabled: boolean, host?: DesktopRuntimeHostRef): Promise; - triggerNow(id: string, host?: DesktopRuntimeHostRef): Promise; + triggerNow( + id: string, + host?: DesktopRuntimeHostRef, + intentBody?: string, + ): Promise; snooze(id: string, host?: DesktopRuntimeHostRef): Promise; clearRunHistory(id: string, host?: DesktopRuntimeHostRef): Promise; delete(id: string, host?: DesktopRuntimeHostRef): Promise; @@ -1516,27 +1515,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..1d999ec0c5 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -157,6 +157,7 @@ import type { ArtifactSaveResult, ArtifactTextReadResult, } from '@maka/core/artifacts'; +import type { TimeRange } from '@maka/core/usage-stats/types'; import type { CapabilitySnapshotCollection, PermissionSnapshot } from '@maka/core/capabilities'; import type { LocalMemoryState } from '@maka/core/local-memory'; import type { @@ -165,13 +166,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 +181,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 +227,6 @@ import { } from './projected-session-runtime-host.js'; import { projectDesktopAttachmentRefs, - projectDesktopDailyReviewSummary, projectDesktopSessionEvent, projectDesktopSessionSummary, projectDesktopTurnRecord, @@ -1023,46 +1012,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; @@ -1178,6 +1127,7 @@ const browserSelection = createBrowserSelectionCoordinator(runtimeHostSessionRef }, browserDocumentId); const makaBridge = { + contract: { version: 2 as const }, runtimeHost, sessionCollaboration: { async prepareInvitation(sessionId, preset, allowInsecure = false) { @@ -2827,8 +2777,12 @@ const makaBridge = { taskId: id, }, host); }, - triggerNow(id: string, host?: DesktopRuntimeHostRef): Promise { - return mutateScheduledTask({ kind: 'trigger_now', taskId: id }, host); + triggerNow( + id: string, + host?: DesktopRuntimeHostRef, + intentBody?: string, + ): Promise { + return mutateScheduledTask({ kind: 'trigger_now', taskId: id, ...(intentBody ? { intentBody } : {}) }, host); }, snooze(id: string, host?: DesktopRuntimeHostRef): Promise { return mutateScheduledTask({ @@ -2883,7 +2837,7 @@ const makaBridge = { testBotChannel(provider: BotProvider): Promise { return ipcRenderer.invoke('settings:testBotChannel', provider); }, - async usageStats(range?: UsageRange, host?: DesktopRuntimeHostRef): Promise { + async usageStats(range?: UsageRange | TimeRange, host?: DesktopRuntimeHostRef): Promise { const scope = await selectedRuntimeHostScope(host); const stats = await ipcRenderer.invoke('settings:usageStats', scope, range) as UsageStats; return projectDesktopUsageStats(scope, stats); @@ -2970,68 +2924,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-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..86bc5a5801 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -83,6 +83,7 @@ import { } from './task-readiness-notice'; import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery'; import { LiveTurnReconciler } from './live-turn-reconciler'; +import { resolveScheduledTaskAgentRunTemplate } from './scheduled-task-template-effect'; import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads'; import { AgentGraphPanel } from './agent-graph-panel'; import { ChatComposerRegion } from './chat-composer-region'; @@ -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,25 @@ 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, + openSession: openSessionInChat, + ...(() => { + const agentRunTemplateEffect = resolveScheduledTaskAgentRunTemplate({ + usesDefaultHost: taskEntry.selectors.usesDefaultHost, + projectPath: taskEntry.selectors.projectPath, + projectId: taskEntry.selectors.target?.projectId, + model: newChatModel, + thinkingLevel: newChatThinkingLevel, + permissionMode: newTaskPermissionMode, + collaborationMode: newChatPlanModeActive ? 'plan' : 'agent', + orchestrationMode: newChatOrchestrationMode, + }); + return agentRunTemplateEffect ? { agentRunTemplateEffect } : {}; + })(), }); refreshProjectSkillsRef.current = moduleHub.commands.refreshProjectSkills; const workHubProjectsRef = useRef(projects); @@ -2690,9 +2684,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 +2692,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 +3280,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..143a43f7f1 100644 --- a/apps/desktop/src/renderer/features/module-hub/README.md +++ b/apps/desktop/src/renderer/features/module-hub/README.md @@ -27,7 +27,8 @@ 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; +- Daily Review presentation over the ordinary Session catalog and shared usage + ledger; - selection and header composition for Skills, MCP, Scheduled Tasks, and Daily Review. @@ -69,14 +70,30 @@ 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. +- Daily Review recognizes its system-owned migration task or a user-created + `presetId: daily-review` task. It reads activity from the ordinary Session + catalog, collapses physical revisions, reads model totals from the shared + usage ledger, and recognizes completed report Sessions through the stable + `scheduled-task-preset:daily-review` relation. +- Daily Review owns no scheduler, resident configuration, model execution, + transcript, artifact storage, archive store, or IPC protocol. Its setup and + run actions delegate to Scheduled Tasks; manage selects the backing task's + ordinary inspector; opening a report selects the normal Session conversation. +- A manual review snapshots the selected calendar range onto that one + ScheduledTask fire without rewriting its recurring prompt; a paused schedule + stays paused after the manual run. +- Earlier/later activity ranges remain available through exact-range reads of + the Session catalog and shared usage ledger. Report quote/copy/save behavior + comes from the ordinary transcript and Artifact surfaces rather than a + Daily Review export protocol. +- Session and default-Host changes invalidate the view. A read is rejected if + the default Host changes mid-flight so results from two authorities cannot be + mixed on one page, or if that Host is absent from the catalog coverage. An + older preload without this projection's bridge version is gated instead of + receiving newer range or manual-intent arguments. - Opening Scheduled Task creation selects the page and increments the request - nonce; the page acknowledgement resets it to zero. + nonce; an optional preset id pre-fills the same dialog and the page + acknowledgement resets the request to zero. There is intentionally no feature-level reducer or store: these projections and commands have real lifecycle ownership, while navigation persistence remains a 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 index 64b68812c9..05a3f551cb 100644 --- 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 @@ -17,432 +17,89 @@ * 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 { useEffect, useMemo, useState } from 'react'; import { - formatDailyReviewMarkdown, - type DailyReviewMarkdownActionInput, - useMountedRef, -} from '@maka/ui'; + scheduledTaskPresetSessionLabel, + type ScheduledTask, +} from '@maka/core/scheduled-task'; 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'; + dailyReviewRangeBounds, + projectDailyReviewView, + type DailyReviewProjectionBridge, +} from '@maka/ui'; +import type { 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; + readonly supported: boolean; + readonly bridge: DailyReviewProjectionBridge; + readonly revision: number; + readonly task?: ScheduledTask; } -export interface UseDailyReviewControllerInput { - readonly services: ModuleHubServices; - readonly uiLocale: UiLocale; - readonly toastApi: ModuleHubToastApi; - readonly appendComposerText: (text: string) => void; - readonly captureActiveComposerClaim: () => ActiveComposerClaim | undefined; - readonly isDailyReviewSurfaceActive: () => boolean; +function isDailyReviewTask(task: ScheduledTask): boolean { + return task.id === 'system-daily-review' || task.presetId === 'daily-review'; } -class StaleDailyReviewHostError extends Error { - constructor() { - super('The default Runtime Host changed while loading Daily Review'); - this.name = 'StaleDailyReviewHostError'; - } +function findDailyReviewTask(tasks: readonly ScheduledTask[]): ScheduledTask | undefined { + return tasks.find((task) => task.id === 'system-daily-review') ?? tasks.find(isDailyReviewTask); } -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; -} +export function useDailyReviewController(input: { + readonly services: ModuleHubServices; + readonly tasks: readonly ScheduledTask[]; +}): DailyReviewController { + const [revision, setRevision] = useState(0); + const task = findDailyReviewTask(input.tasks); + + useEffect(() => { + const invalidate = () => setRevision((value) => value + 1); + const unsubscribeSessions = input.services.dailyReview.subscribeChanges(invalidate); + const unsubscribeHosts = input.services.runtimeHosts.subscribeChanges(invalidate); + return () => { + unsubscribeSessions(); + unsubscribeHosts(); + }; + }, [input.services]); -/** - * 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 bridge = useMemo(() => ({ + async load(range, offsetDays = 0) { + const bounds = dailyReviewRangeBounds(range, Date.now(), offsetDays); const result = await runOnDefaultRuntimeHost( - services.runtimeHosts, - operation, + input.services.runtimeHosts, + async (host) => { + const sessions = await input.services.dailyReview.listSessions(host); + const presetLabel = scheduledTaskPresetSessionLabel('daily-review'); + const reportSessions = sessions.filter( + (session) => + session.labels.includes('migrated:daily-review') || + session.labels.includes(presetLabel), + ); + const [usage, reportArtifacts] = await Promise.all([ + input.services.dailyReview.readUsage(bounds, host), + Promise.all( + reportSessions.map((session) => + input.services.dailyReview.listArtifacts(session.id), + ), + ).then((groups) => groups.flat()), + ]); + return { sessions, usage, reportArtifacts }; + }, ); - if ( - await isDefaultRuntimeHostCurrent(services.runtimeHosts, result.host) - ) { - return result.value; + if (!(await isDefaultRuntimeHostCurrent(input.services.runtimeHosts, result.host))) { + throw new Error('The default Runtime Host changed while loading Daily Review'); } - } 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; + return projectDailyReviewView({ + sessions: result.value.sessions, + reportArtifacts: result.value.reportArtifacts, + usage: result.value.usage, + ...bounds, }); }, - 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, - ), - ); - } - } - } + }), [input.services, input.tasks]); - 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]); + return { bridge, revision, task, supported: input.services.dailyReview.supported }; } 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..7a26c7a01a 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, @@ -36,6 +31,10 @@ import { useScheduledTasksController, type ScheduledTasksController, } from './use-scheduled-tasks-controller.js'; +import { + useDailyReviewController, + type DailyReviewController, +} from './use-daily-review-controller.js'; import { useSkillsController, type SkillsHostModel, @@ -46,8 +45,9 @@ export interface ModuleHubHostModel { readonly selectModule: (selection: NavSelection) => void; readonly skills: SkillsHostModel; readonly scheduledTasks: ScheduledTasksController; - readonly keepSystemAwake: KeepSystemAwakeController; readonly dailyReview: DailyReviewController; + readonly keepSystemAwake: KeepSystemAwakeController; + readonly agentRunTemplateEffect?: Extract; readonly openSession: (sessionId: string) => void; } @@ -56,9 +56,6 @@ export interface ModuleHubController { readonly commands: { refreshProjectSkills(): Promise; openScheduledTaskCreate(): void; - copyTodayDailyReview(): Promise; - pasteTodayDailyReview(): Promise; - saveTodayDailyReview(): Promise; }; readonly selectors: { readonly scheduledTasks: readonly ScheduledTask[]; @@ -73,8 +70,7 @@ export interface UseModuleHubControllerInput { 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. */ @@ -100,20 +96,11 @@ export function useModuleHubController( selection: input.selection, 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', + tasks: scheduledTasks.scheduledTasks, }); - + const keepSystemAwake = useKeepSystemAwakeController(services); const refreshProjectSkillsRef = useRef(skills.refreshProjectSkills); const refreshScheduledTasksRef = useRef(scheduledTasks.refresh); refreshProjectSkillsRef.current = skills.refreshProjectSkills; @@ -134,16 +121,14 @@ export function useModuleHubController( selectModule: input.selectModule, skills: skills.host, scheduledTasks, - keepSystemAwake, dailyReview, + keepSystemAwake, + agentRunTemplateEffect: input.agentRunTemplateEffect, openSession: input.openSession, }, commands: { refreshProjectSkills: skills.refreshProjectSkills, openScheduledTaskCreate: scheduledTasks.openCreate, - copyTodayDailyReview: dailyReview.copyToday, - pasteTodayDailyReview: dailyReview.pasteToday, - saveTodayDailyReview: dailyReview.saveToday, }, selectors: { scheduledTasks: scheduledTasks.scheduledTasks, @@ -151,10 +136,11 @@ export function useModuleHubController( }, }), [ - dailyReview, - input.openSession, input.selectModule, input.selection, + input.agentRunTemplateEffect, + input.openSession, + dailyReview, keepSystemAwake, scheduledTasks, skills.host, diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts index 7c3c71fbb8..88de69dddc 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts @@ -40,14 +40,19 @@ import { export interface ScheduledTasksController { readonly scheduledTasks: ScheduledTask[]; readonly createRequestNonce: number; - openCreate(): void; + readonly createRequestTemplateId?: string; + readonly inspectRequestNonce: number; + readonly inspectRequestTaskId?: string; + openCreate(templateId?: string): void; handleCreateRequest(): void; + openInspect(taskId: string): void; + handleInspectRequest(): void; refresh(options?: { shouldShowError?: () => boolean }): Promise; refreshSurface(): Promise; create(input: ScheduledTaskCreateInput): Promise; update(id: string, patch: UpdateScheduledTaskInput): Promise; toggle(id: string, enabled: boolean): Promise; - triggerNow(id: string): Promise; + triggerNow(id: string, intentBody?: string): Promise; snooze(id: string): Promise; clearRunHistory(id: string): Promise; delete(id: string): Promise; @@ -72,6 +77,9 @@ export function useScheduledTasksController(options: { const notificationsCopy = getShellRemainingCopy(uiLocale).notifications; const [scheduledTasks, setScheduledTasks] = useState([]); const [createRequestNonce, setCreateRequestNonce] = useState(0); + const [createRequestTemplateId, setCreateRequestTemplateId] = useState(); + const [inspectRequestNonce, setInspectRequestNonce] = useState(0); + const [inspectRequestTaskId, setInspectRequestTaskId] = useState(); const refreshGenerationRef = useRef(0); const scheduledTasksRef = useRef(scheduledTasks); const selectionRef = useRef(options.selection); @@ -224,15 +232,32 @@ export function useScheduledTasksController(options: { return { scheduledTasks, createRequestNonce, - openCreate() { + createRequestTemplateId, + inspectRequestNonce, + inspectRequestTaskId, + openCreate(templateId) { selectModuleRef.current({ section: 'automations', module: 'scheduled-tasks', }); + setCreateRequestTemplateId(templateId); setCreateRequestNonce((current) => current + 1); }, handleCreateRequest() { setCreateRequestNonce(0); + setCreateRequestTemplateId(undefined); + }, + openInspect(taskId) { + selectModuleRef.current({ + section: 'automations', + module: 'scheduled-tasks', + }); + setInspectRequestTaskId(taskId); + setInspectRequestNonce((current) => current + 1); + }, + handleInspectRequest() { + setInspectRequestNonce(0); + setInspectRequestTaskId(undefined); }, refresh, refreshSurface() { @@ -268,10 +293,10 @@ export function useScheduledTasksController(options: { errorFallback: copy.updateFallback, }); }, - async triggerNow(id) { + async triggerNow(id, intentBody) { const task = scheduledTasksRef.current.find((entry) => entry.id === id); await runMutation({ - run: (host) => services.scheduledTasks.triggerNow(id, host), + run: (host) => services.scheduledTasks.triggerNow(id, host, intentBody), successTitle: copy.triggered, successDetail: task?.title, errorTitle: copy.triggerFailed, 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..fc3783a8ae 100644 --- a/apps/desktop/src/renderer/features/module-hub/ports.ts +++ b/apps/desktop/src/renderer/features/module-hub/ports.ts @@ -17,18 +17,14 @@ * 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, UpdateScheduledTaskInput, } from '@maka/core/scheduled-task'; +import type { ArtifactDescriptor } from '@maka/core/artifacts'; +import type { SessionSummary } from '@maka/core/session'; +import type { DailyReviewUsageSummary } from '@maka/ui'; import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, @@ -185,7 +181,11 @@ export interface ModuleHubScheduledTasksService { enabled: boolean, host: ModuleHubRuntimeHostRef, ): Promise; - triggerNow(id: string, host: ModuleHubRuntimeHostRef): Promise; + triggerNow( + id: string, + host: ModuleHubRuntimeHostRef, + intentBody?: string, + ): Promise; snooze(id: string, host: ModuleHubRuntimeHostRef): Promise; clearRunHistory(id: string, host: ModuleHubRuntimeHostRef): Promise; delete(id: string, host: ModuleHubRuntimeHostRef): Promise; @@ -210,29 +210,14 @@ export interface ModuleHubClientSettingsService { } export interface ModuleHubDailyReviewService { - day( - offsetDays: number, - daySpan: number | undefined, + readonly supported: boolean; + listSessions(host: ModuleHubRuntimeHostRef): Promise; + listArtifacts(sessionId: string): Promise; + readUsage( + range: { readonly from: number; readonly to: number }, 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; + ): Promise; + subscribeChanges(handler: () => void): ModuleHubUnsubscribe; } /** Environment capabilities owned by the Module Hub feature slice. */ @@ -240,7 +225,6 @@ export interface ModuleHubServices { runtimeHosts: ModuleHubRuntimeHostsService; skills: ModuleHubSkillsService; scheduledTasks: ModuleHubScheduledTasksService; - clientSettings: ModuleHubClientSettingsService; dailyReview: ModuleHubDailyReviewService; - clipboard: ModuleHubClipboardService; + clientSettings: ModuleHubClientSettingsService; } diff --git a/apps/desktop/src/renderer/features/module-hub/testing.ts b/apps/desktop/src/renderer/features/module-hub/testing.ts index ca504b3ce7..ac731e13fb 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, @@ -40,6 +35,10 @@ export { type ScheduledTasksController, type ScheduledTasksToastApi, } from "./controller/use-scheduled-tasks-controller.js"; +export { + useDailyReviewController, + type DailyReviewController, +} from "./controller/use-daily-review-controller.js"; export type { ModuleHubRuntimeHostChangedEvent, ModuleHubRuntimeHostRef, @@ -83,8 +82,13 @@ export function createFakeModuleHubHostModel( scheduledTasks: { scheduledTasks: [], createRequestNonce: 0, + createRequestTemplateId: undefined, + inspectRequestNonce: 0, + inspectRequestTaskId: undefined, openCreate: () => undefined, handleCreateRequest: () => undefined, + openInspect: () => undefined, + handleInspectRequest: () => undefined, refresh: async () => undefined, refreshSurface: async () => undefined, create: async () => false, @@ -95,22 +99,24 @@ export function createFakeModuleHubHostModel( clearRunHistory: async () => undefined, delete: async () => undefined, }, + dailyReview: { + supported: true, + bridge: { + load: async () => ({ + totals: { sessionCount: 0, totalRequests: 0, totalTokens: 0, totalCostUsd: 0 }, + sessions: [], + reports: [], + hasMigratedReports: false, + }), + }, + revision: 0, + task: undefined, + }, keepSystemAwake: { supported: false, 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, }; @@ -153,23 +159,19 @@ export function createFakeModuleHubServices( subscribeChanges: noopSubscription, subscribeDue: noopSubscription, }, + dailyReview: { + supported: true, + listSessions: async () => [], + listArtifacts: async () => [], + readUsage: async () => ({ totalRequests: 0, totalTokens: 0, totalCostUsd: 0 }), + subscribeChanges: noopSubscription, + }, clientSettings: { supported: true, getKeepSystemAwake: async () => false, 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..697f5dc406 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 @@ -79,41 +79,52 @@ export function ModuleHubHost(props: { model: ModuleHubHostModel }) { /> ), }; - if (route === 'scheduled-tasks') { - const keepAwake = model.keepSystemAwake; - const tasks = model.scheduledTasks; + if (route === 'daily-review') { + const dailyReview = model.dailyReview; + const task = dailyReview.task; return ( - model.scheduledTasks.openCreate('daily-review') + : undefined} + onManageSchedule={task ? () => model.scheduledTasks.openInspect(task.id) : undefined} + onRunNow={task && dailyReview.supported + ? (intentBody) => model.scheduledTasks.triggerNow(task.id, intentBody) + : undefined} + onSelectSession={model.openSession} /> ); } - 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..4a3d481ef8 100644 --- a/apps/desktop/src/renderer/nav-selection.ts +++ b/apps/desktop/src/renderer/nav-selection.ts @@ -74,7 +74,9 @@ 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 + : 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..0349ef2087 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,38 +18,35 @@ */ import type { MakaBridge } from '../../../preload/bridge-contract.js'; -import type { - ModuleHubClipboardService, - ModuleHubServices, -} from '../../features/module-hub/index.js'; +import { collapseSessionRevisions } from '@maka/core/session-revisions'; +import type { ModuleHubServices } from '../../features/module-hub/index.js'; type DesktopModuleHubSettingsBridge = Partial< - Pick + Pick >; export type DesktopModuleHubBridge = Pick< MakaBridge, - 'dailyReview' | 'runtimeHostProfiles' | 'scheduledTasks' | 'skills' + 'artifacts' | 'runtimeHostProfiles' | 'scheduledTasks' | 'sessions' | 'skills' > & { + /** Optional at runtime while a loaded renderer still has an older preload. */ + readonly contract?: MakaBridge['contract']; /** 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; const subscribeClientSettings = bridge.settings?.subscribeClientChanged; + const readUsageStats = bridge.settings?.usageStats; const clientSettingsSupported = typeof getClientSettings === 'function' && typeof updateClientSettings === 'function'; + const dailyReviewSupported = bridge.contract?.version === 2; return { runtimeHosts: { @@ -91,7 +88,8 @@ export function createDesktopModuleHubServices( bridge.scheduledTasks.update(id, patch, host), setEnabled: (id, enabled, host) => bridge.scheduledTasks.setEnabled(id, enabled, host), - triggerNow: (id, host) => bridge.scheduledTasks.triggerNow(id, host), + triggerNow: (id, host, intentBody) => + bridge.scheduledTasks.triggerNow(id, host, intentBody), snooze: (id, host) => bridge.scheduledTasks.snooze(id, host), clearRunHistory: (id, host) => bridge.scheduledTasks.clearRunHistory(id, host), @@ -100,6 +98,42 @@ export function createDesktopModuleHubServices( bridge.scheduledTasks.subscribeChanges(handler), subscribeDue: (handler) => bridge.scheduledTasks.subscribeDue(handler), }, + dailyReview: { + supported: dailyReviewSupported, + async listSessions(host) { + if (!dailyReviewSupported) { + throw new Error('Daily Review requires a newer Desktop bridge'); + } + const catalog = await bridge.sessions.listWithCoverage(); + if (!catalog.completeHostIds.includes(host.hostId)) { + throw new Error('The Runtime Host Session catalog is unavailable'); + } + return collapseSessionRevisions( + catalog.sessions.filter((session) => session.runtimeHostId === host.hostId), + ); + }, + listArtifacts: (sessionId) => bridge.artifacts.list(sessionId), + async readUsage(range, host) { + if (!dailyReviewSupported) { + throw new Error('Daily Review requires a newer Desktop bridge'); + } + if (!readUsageStats) throw new Error('Usage statistics are unavailable'); + const stats = await readUsageStats.call(bridge.settings, range, host); + return { + totalRequests: stats.summary.totalRequests, + totalTokens: stats.summary.totalTokens, + totalCostUsd: stats.summary.totalCostUsd, + }; + }, + subscribeChanges(handler) { + const unsubscribeSessions = bridge.sessions.subscribeChanges(() => handler()); + const unsubscribeArtifacts = bridge.artifacts.subscribeChanges(() => handler()); + return () => { + unsubscribeSessions(); + unsubscribeArtifacts(); + }; + }, + }, clientSettings: { supported: clientSettingsSupported, async getKeepSystemAwake() { @@ -123,32 +157,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/scheduled-task-template-effect.ts b/apps/desktop/src/renderer/scheduled-task-template-effect.ts new file mode 100644 index 0000000000..0283895919 --- /dev/null +++ b/apps/desktop/src/renderer/scheduled-task-template-effect.ts @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ScheduledTaskEffect } from '@maka/core/scheduled-task'; + +type AgentRunEffect = Extract; +type AgentRunExecution = AgentRunEffect['execution']; + +interface ScheduledTaskAgentRunTemplateInput { + readonly usesDefaultHost: boolean; + readonly projectPath?: string; + readonly projectId?: string | null; + readonly model?: Pick< + AgentRunExecution, + 'llmConnectionId' | 'llmConnectionSlug' | 'model' + >; + readonly thinkingLevel?: AgentRunExecution['thinkingLevel']; + readonly permissionMode: AgentRunExecution['permissionMode']; + readonly collaborationMode: AgentRunExecution['collaborationMode']; + readonly orchestrationMode: AgentRunExecution['orchestrationMode']; +} + +/** + * Scheduled Tasks are stored by the default Runtime Host. Never freeze a + * different Host's workspace or connection into that Host-owned task. + */ +export function resolveScheduledTaskAgentRunTemplate( + input: ScheduledTaskAgentRunTemplateInput, +): AgentRunEffect | undefined { + if ( + !input.usesDefaultHost || + !input.projectPath || + input.projectId === undefined || + !input.model + ) { + return undefined; + } + return { + kind: 'agent_run', + execution: { + cwd: input.projectPath, + projectId: input.projectId, + llmConnectionId: input.model.llmConnectionId, + llmConnectionSlug: input.model.llmConnectionSlug, + model: input.model.model, + ...(input.thinkingLevel ? { thinkingLevel: input.thinkingLevel } : {}), + permissionMode: input.permissionMode, + collaborationMode: input.collaborationMode, + orchestrationMode: input.orchestrationMode, + }, + }; +} 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 ( span { + min-width: 112px; text-align: center; - font-variant-numeric: tabular-nums; +} + +.maka-daily-review-schedule { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.maka-daily-review-schedule-summary { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-2); +} + +.maka-daily-review-separator { + color: var(--muted-foreground); } .maka-daily-review-metrics { display: grid; - /* Fixed 4-up: auto-fit wrapped the four totals 3 + 1 whenever the page - column was narrower than the viewport-based breakpoint could see. */ grid-template-columns: repeat(4, minmax(0, 1fr)); - /* No rules, in or around the strip. Four labelled numbers on their own grid - read as four numbers; hairlines between them only redraw the columns. The - page keeps lines for one job — saying where a list row ends. */ - gap: var(--space-2) var(--space-6); - padding-block: var(--space-2) var(--space-5); + gap: var(--space-4); + margin: 0; + padding: var(--space-2) 0 var(--space-4); + border-bottom: 1px solid var(--border-soft); } -.maka-daily-review-metric { +.maka-daily-review-metrics > div { + display: flex; min-width: 0; + flex-direction: column; + gap: var(--space-1); } -/* The report is a reading column on an uncapped module page, so it is the - container that has to stop it — same rule as a transcript turn, and on the - same box. The cap belongs here and not on the prose inside: a section is a - heading, a divider and its prose, and capping only the last of the three - left the first two running to the window edge. Without any cap the prose ran - the window width and the code blocks inside it ran wider still, since they - come from `components.code` and so bypass Astryx's own. */ -.maka-daily-review-report { - max-width: var(--maka-reading-measure); +.maka-daily-review-metrics dd, +.maka-daily-review-metrics dt { + margin: 0; } -.maka-daily-review-report-prose { - color: var(--foreground-secondary); - overflow-wrap: anywhere; +.maka-daily-review-metrics dd { + color: var(--foreground); + font: var(--maka-text-heading-4); + font-variant-numeric: tabular-nums; } -.maka-daily-review-report-prose p, -.maka-daily-review-report-prose ul, -.maka-daily-review-report-prose ol { - margin: 0 0 var(--space-2); +.maka-daily-review-metrics dt { + color: var(--muted-foreground); + font: var(--maka-text-supporting); } -.maka-daily-review-report-prose :last-child { - margin-bottom: 0; +.maka-daily-review-activity, +.maka-daily-review-history { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--space-2); } -.maka-daily-review-report-prose ul, -.maka-daily-review-report-prose ol { - padding-inline-start: var(--space-5); +.maka-daily-review-section-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); } -.maka-daily-review-report-prose ul { - list-style: disc; +.maka-daily-review-section-heading > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--space-1); } -.maka-daily-review-report-prose ol { - list-style: decimal; -} +@media (max-width: 760px) { + .maka-daily-review-toolbar, + .maka-daily-review-period-controls { + align-items: stretch; + flex-direction: column; + } -@media (max-width: 560px) { - .maka-daily-review-range-label { - min-width: 0; + .maka-daily-review-schedule-summary { + flex-wrap: wrap; } .maka-daily-review-metrics { diff --git a/apps/desktop/src/shared/desktop-session-projection.ts b/apps/desktop/src/shared/desktop-session-projection.ts index 44a66567ea..c0d97b6ebe 100644 --- a/apps/desktop/src/shared/desktop-session-projection.ts +++ b/apps/desktop/src/shared/desktop-session-projection.ts @@ -17,7 +17,6 @@ * under the License. */ -import type { DailyReviewSummary } from '@maka/core/daily-review'; import type { AttachmentRef, MessageContent, @@ -227,19 +226,6 @@ export function projectDesktopSessionSummary( }; } -export function projectDesktopDailyReviewSummary( - host: DesktopHostRef, - summary: DailyReviewSummary, -): DailyReviewSummary { - return { - ...summary, - sessions: summary.sessions.map((session) => ({ - ...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..50f2ca2eee 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, @@ -352,6 +350,21 @@ const CONFIGURED_TASKS: ScheduledTask[] = ([ }, ] satisfies StoryScheduledTask[]).map(storyScheduledTask); +const DAILY_REVIEW_TASK = storyScheduledTask({ + id: 'system-daily-review', + presetId: 'daily-review', + title: 'Daily Review', + intent: { kind: 'text', body: '回顾普通任务历史,并把 Markdown 报告保存为 Artifact。' }, + schedule: { kind: 'calendar', anchorAt: TASK_NOW - 86_400_000, recurrence: 'daily' }, + effect: { kind: 'notify', channel: 'local' }, + status: 'active', + createdAt: TASK_NOW - 30 * 86_400_000, + updatedAt: TASK_NOW - 86_400_000, + nextFireAt: TASK_NOW + 14 * 3_600_000, + runs: [CONFIGURED_CRON_LAST_RUN], + fireCount: 12, +}); + const LONG_CONTENT_TASKS: ScheduledTask[] = ([ { id: 'task-hostile-content', @@ -384,63 +397,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 +546,7 @@ const withFailedMcpBridge = withScopedMakaBridge({ function ModuleSurface(props: { children: ReactNode; - agentsView: 'skills' | 'mcp' | 'cron' | 'daily-review'; + agentsView: 'skills' | 'mcp' | 'cron'; }) { return (
{}} />, + badge: null, }} tasks={props.tasks ?? []} keepSystemAwake={props.keepSystemAwake ?? false} @@ -703,30 +659,6 @@ 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' } @@ -742,20 +674,60 @@ function ModuleHubHostSurface(props: { }, scheduledTasks: { ...base.scheduledTasks, - scheduledTasks: CONFIGURED_TASKS, + scheduledTasks: [...CONFIGURED_TASKS, DAILY_REVIEW_TASK], }, dailyReview: { - ...base.dailyReview, + supported: true, bridge: { - fetchDay: async () => DAILY_REVIEW_SUMMARY, + load: async () => ({ + totals: { + sessionCount: 18, + totalRequests: 42, + totalTokens: 128_400, + totalCostUsd: 1.23, + }, + sessions: [ + { + sessionId: 'ordinary-today', + title: '修复 Scheduled Task 恢复流程', + activityAt: NOW - 1_800_000, + preview: '迁移与恢复测试已经通过。', + status: 'active' as const, + }, + { + sessionId: 'report-today', + title: 'Daily Review · 7月1日', + activityAt: NOW - 3_600_000, + preview: '合并了三个 PR,并保留两个待跟进事项。', + status: 'active' as const, + }, + ], + reports: [ + { + sessionId: 'report-today', + title: 'Daily Review · 7月1日', + generatedAt: NOW, + preview: '合并了三个 PR,并保留两个待跟进事项。', + migrated: false, + }, + { + sessionId: 'report-migrated', + title: 'Daily Review · 2026-06-30 · 1d', + generatedAt: NOW - 86_400_000, + preview: '旧报告已迁移为普通任务与 Artifact。', + migrated: true, + }, + ], + hasMigratedReports: true, + }), }, + revision: 0, + task: DAILY_REVIEW_TASK, }, }; const agentsView = props.selection.section === 'extensions' ? props.selection.module - : props.selection.module === 'daily-review' - ? 'daily-review' - : 'cron'; + : 'cron'; return ( @@ -826,6 +798,8 @@ export const HostAutomationsScheduledTasks: Story = { ), }; +// Daily Review remains a first-class Automations experience while reading the +// ordinary Session catalog and shared usage ledger through the production Host. export const HostAutomationsDailyReview: 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..e068a309c0 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,12 +49,51 @@ 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. When an enabled legacy configuration upgrades after its local execution +time and yesterday's report is absent, migration makes the system task due once before retiring the +old snapshot. Migrated calendar tasks also carry ScheduledTask's generic `catchUp: once` policy, so +resuming a previously disabled task admits at most its latest missed occurrence. The ordinary +scheduler owns both catch-up paths; no legacy scheduler survives. + ### UI The scheduled-task panel consumes `ScheduledTask` directly. Its preload facade uses the shared `runtime-host:query` / `runtime-host:command` transport and the canonical Host protocol codecs. Host change frames are signals only; Desktop re-queries the canonical record. +Daily Review remains visible under Automations, but it is now a product projection rather than a +runtime domain: + +- Setup opens the ordinary Scheduled Task dialog with the Daily Review preset and freezes the + current Agent execution template. +- Enablement, recurrence, next run, retry history, and manual trigger come from that + `ScheduledTask`. +- A manual review snapshots the selected 1/7/30-day calendar range as a one-shot intent on the + ordinary fire claim; it does not rewrite the task's recurring intent. Manually firing a paused + task leaves its schedule paused after the run. +- The 1/7/30 day overview and its earlier/later navigation read ordinary Session activity and the + canonical model-call usage ledger for the selected local-calendar range. +- Report history filters the Session catalog by the generic `scheduled-task:` relation; + migrated reports use the temporary `migrated:daily-review` provenance label. +- Opening a report enters the ordinary Session transcript, where its Markdown report is available + as a normal Artifact. Existing transcript quoting and Artifact preview/copy/save actions replace + the former report-only export controls. +- Managing the schedule selects the backing task directly in the Scheduled Tasks inspector. Agent + tasks keep their frozen execution target while allowing their title, prompt, recurrence, and + next fire time to be edited through the ordinary task form. + +The migration banner is a retirement aid, not a permanent compatibility path. Once the legacy +snapshot has been materialized and its exact revision is still current, the migration drops the +old tables. New code never reads or writes them again, so there is no ongoing dual-read or +dual-write state. + ### Authority invariant Runtime Host is the only catalog writer, scheduler, clock, and fire admission authority. No @@ -64,7 +104,8 @@ Before an effect crosses its irreversible boundary, the Store persists one uniqu task. For `agent_run`, it also persists the stable Session/Turn/Run/message identity before Session creation or execution admission. Recovery can therefore reconcile the exact Host execution without creating a duplicate Session. Native delivery claims are not replayed after an unknown -outcome. +outcome. New Agent-created tasks must freeze an immutable Connection ID at creation; older +persisted tasks without one remain decodable but fail closed before execution. ### Runtime boundary @@ -78,6 +119,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..fc4a5b0c8d 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:** 224 files — blocker 0, polish 1, aligned 223. ## 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,7 @@ 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/daily-review.css` | styles | 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 +204,7 @@ 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..c01f250d7b 100644 --- a/packages/core/src/__tests__/scheduled-task.test.ts +++ b/packages/core/src/__tests__/scheduled-task.test.ts @@ -26,6 +26,7 @@ import { isScheduledTaskDue, nextScheduledTaskStateAfterFire, normalizeCreateScheduledTaskInput, + normalizeUpdateScheduledTaskInput, pauseScheduledTask, resumeScheduledTask, type ScheduledTask, @@ -95,6 +96,47 @@ describe('scheduled-task catalog', () => { assert.ok(typeof resumed.nextFireAt === 'number'); }); + it('fires one missed calendar occurrence when catch-up is requested', () => { + const now = new Date(2026, 7, 29, 12, 0).getTime(); + const anchorAt = new Date(2026, 7, 29, 8, 0).getTime(); + const normalized = normalizeCreateScheduledTaskInput( + { + title: 'Daily Review', + intentBody: '', + schedule: { kind: 'calendar', recurrence: 'daily', anchorAt, catchUp: 'once' }, + effect: { kind: 'notify', channel: 'local' }, + createdBy: { kind: 'system' }, + }, + now, + ); + assert.equal(normalized.ok, true); + if (!normalized.ok) return; + assert.ok(normalized.value.nextFireAt > now); + + const task: ScheduledTask = { + id: 'daily-review', + title: normalized.value.title, + intent: { kind: 'text', body: normalized.value.intentBody }, + schedule: normalized.value.schedule, + effect: normalized.value.effect, + status: 'paused', + nextFireAt: null, + lastFireAt: null, + fireCount: 0, + maxFires: null, + expiresAt: null, + createdBy: normalized.value.createdBy, + createdAt: now, + updatedAt: now, + runs: [], + lastError: null, + }; + const resumed = resumeScheduledTask(task, now + 86_400_000); + assert.ok(!('error' in resumed)); + if ('error' in resumed) return; + assert.equal(resumed.nextFireAt, now + 86_400_000); + }); + it('does not resume a task whose fire budget is already spent', () => { const task: ScheduledTask = { id: 'spent', @@ -145,6 +187,30 @@ describe('scheduled-task catalog', () => { } }); + it('keeps preset provenance presentation-only and canonical', () => { + const now = Date.UTC(2026, 0, 5, 8, 0, 0); + const input = { + title: 'Daily Review', + intentBody: 'Review ordinary Session history.', + schedule: { kind: 'once', runAt: now + 60_000 }, + effect: { kind: 'notify', channel: 'local' }, + createdBy: { kind: 'user' }, + }; + const normalized = normalizeCreateScheduledTaskInput( + { ...input, presetId: 'daily-review' }, + now, + ); + assert.equal(normalized.ok, true); + if (!normalized.ok) return; + assert.equal(normalized.value.presetId, 'daily-review'); + assert.deepEqual(normalized.value.schedule, input.schedule); + assert.deepEqual(normalized.value.effect, input.effect); + + for (const presetId of ['', 'DailyReview', 'daily review', 'daily-review:system']) { + assert.equal(normalizeCreateScheduledTaskInput({ ...input, presetId }, now).ok, false); + } + }); + it('clamps monthly recurrence to the last calendar day', () => { const runAt = new Date(2026, 0, 31, 9, 30).getTime(); const monthly = { kind: 'calendar' as const, recurrence: 'monthly' as const, anchorAt: runAt }; @@ -175,6 +241,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', @@ -206,6 +273,53 @@ describe('scheduled-task catalog', () => { } }); + it('requires immutable Connection identity for new Agent tasks', () => { + const now = Date.UTC(2026, 0, 5, 8, 0, 0); + const result = normalizeCreateScheduledTaskInput( + { + title: 'Legacy creator Session', + intentBody: 'run', + schedule: { kind: 'once', runAt: now + 60_000 }, + effect: { + kind: 'agent_run', + execution: { + cwd: '/repo', + llmConnectionSlug: 'legacy', + model: 'legacy-model', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + createdBy: { kind: 'user' }, + }, + now, + ); + assert.deepEqual(result, { + ok: false, + message: 'Agent execution requires immutable Connection identity', + }); + assert.deepEqual( + normalizeUpdateScheduledTaskInput( + { + effect: { + kind: 'agent_run', + execution: { + cwd: '/repo', + llmConnectionSlug: 'legacy', + model: 'legacy-model', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + }, + now, + ), + { ok: false, message: 'Agent execution requires immutable Connection identity' }, + ); + }); + it('rejects future recurrence anchors outside the scheduling horizon', () => { const now = Date.UTC(2026, 0, 5, 8, 0, 0); for (const schedule of [ @@ -240,6 +354,7 @@ describe('decodePersistedScheduledTask', () => { kind: 'agent_run', execution: { cwd: '/repo', + llmConnectionId: 'connection-anthropic', llmConnectionSlug: 'anthropic', model: 'claude', permissionMode: 'ask', @@ -275,6 +390,13 @@ describe('decodePersistedScheduledTask', () => { assert.equal(decodePersistedScheduledTask(markPersisted(base)), base); }); + it('keeps older persisted Agent tasks without Connection identity decodable', () => { + if (base.effect.kind !== 'agent_run') return; + const { llmConnectionId: _, ...execution } = base.effect.execution; + const stored = { ...base, effect: { kind: 'agent_run' as const, execution } }; + assert.equal(decodePersistedScheduledTask(markPersisted(stored)), stored); + }); + it('leaves effects without an execution template alone', () => { const notify: ScheduledTask = { ...base, effect: { kind: 'notify', channel: 'local' } }; assert.equal(decodePersistedScheduledTask(markPersisted(notify)), notify); 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/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..27678b274f 100644 --- a/packages/core/src/scheduled-task.ts +++ b/packages/core/src/scheduled-task.ts @@ -37,6 +37,7 @@ import { isBotDeliveryProvider, type BotProvider } from './bot-chat-settings.js' import type { PersistedValue } from './persisted-value.js'; export const SCHEDULED_TASK_TITLE_MAX_CHARS = 120; +export const SCHEDULED_TASK_PRESET_ID_MAX_CHARS = 80; export const SCHEDULED_TASK_INTENT_MAX_CHARS = 8_000; export const SCHEDULED_TASK_CRON_MAX_CHARS = 80; export const SCHEDULED_TASK_CHAT_ID_MAX_CHARS = 160; @@ -46,6 +47,18 @@ export const SCHEDULED_TASK_RUN_MESSAGE_MAX_CHARS = 1_024; export const SCHEDULED_TASK_MAX_DELAY_MS = 366 * 24 * 60 * 60 * 1000; export const SCHEDULED_TASK_MIN_INTERVAL_SECONDS = 10; export const SCHEDULED_TASK_MAX_INTERVAL_SECONDS = 366 * 86_400; +export const SCHEDULED_TASK_SESSION_LABEL_PREFIX = 'scheduled-task:'; +export const SCHEDULED_TASK_PRESET_SESSION_LABEL_PREFIX = 'scheduled-task-preset:'; + +/** Stable Session-catalog relation for ordinary Sessions started by a task. */ +export function scheduledTaskSessionLabel(taskId: string): string { + return `${SCHEDULED_TASK_SESSION_LABEL_PREFIX}${taskId}`; +} + +/** Stable Session-catalog relation for runs of a system or product preset. */ +export function scheduledTaskPresetSessionLabel(presetId: string): string { + return `${SCHEDULED_TASK_PRESET_SESSION_LABEL_PREFIX}${presetId}`; +} export const SCHEDULED_TASK_STATUSES = ['active', 'paused', 'completed', 'expired'] as const; export type ScheduledTaskStatus = (typeof SCHEDULED_TASK_STATUSES)[number]; @@ -62,6 +75,8 @@ export type ScheduledTaskSchedule = kind: 'calendar'; recurrence: 'daily' | 'weekly' | 'monthly'; anchorAt: number; + /** On resume, admit at most the latest missed calendar occurrence. */ + catchUp?: 'once'; } | { kind: 'cron'; expression: string; startAt: number }; @@ -75,6 +90,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; @@ -100,6 +117,8 @@ export interface ScheduledTaskCreatedBy { export interface ScheduledTask { id: string; + /** Optional immutable UI provenance; it never affects scheduling or execution. */ + presetId?: string; title: string; intent: { kind: 'text'; body: string }; schedule: ScheduledTaskSchedule; @@ -119,6 +138,7 @@ export interface ScheduledTask { export interface CreateScheduledTaskInput { title: string; + presetId?: string; intentBody: string; schedule: ScheduledTaskSchedule; effect: ScheduledTaskEffect; @@ -153,10 +173,15 @@ export function normalizeCreateScheduledTaskInput( if (!isObject(input)) return fail('Scheduled task input must be an object'); const title = normalizeTitle(input.title); if (!title.ok) return title; + const presetId = normalizePresetId(input.presetId); + if (!presetId.ok) return presetId; const schedule = normalizeSchedule(input.schedule, now); if (!schedule.ok) return schedule; const effect = normalizeEffect(input.effect); if (!effect.ok) return effect; + if (effect.value.kind === 'agent_run' && !effect.value.execution.llmConnectionId) { + return fail('Agent execution requires immutable Connection identity'); + } const intentBody = normalizeIntent(input.intentBody ?? input.intent?.body, { required: effect.value.kind !== 'notify', }); @@ -178,6 +203,7 @@ export function normalizeCreateScheduledTaskInput( ok: true, value: { title: title.value, + ...(presetId.value === undefined ? {} : { presetId: presetId.value }), intentBody: intentBody.value, schedule: schedule.value, effect: effect.value, @@ -189,6 +215,19 @@ export function normalizeCreateScheduledTaskInput( }; } +function normalizePresetId(value: unknown): ScheduledTaskNormalizeResult { + if (value === undefined) return { ok: true, value: undefined }; + if ( + typeof value !== 'string' || + value.length === 0 || + [...value].length > SCHEDULED_TASK_PRESET_ID_MAX_CHARS || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(value) + ) { + return fail('presetId must be a lowercase kebab-case identifier'); + } + return { ok: true, value }; +} + export function normalizeUpdateScheduledTaskInput( input: unknown, now: number, @@ -213,6 +252,9 @@ export function normalizeUpdateScheduledTaskInput( if (input.effect !== undefined) { const effect = normalizeEffect(input.effect); if (!effect.ok) return effect; + if (effect.value.kind === 'agent_run' && !effect.value.execution.llmConnectionId) { + return fail('Agent execution requires immutable Connection identity'); + } patch.effect = effect.value; } if (Object.prototype.hasOwnProperty.call(input, 'maxFires')) { @@ -304,6 +346,9 @@ export function nextScheduledTaskStateAfterFire( if (task.expiresAt !== null && run.at >= task.expiresAt) { return { ...base, status: 'expired', nextFireAt: null }; } + if (task.status === 'paused') { + return { ...base, status: 'paused', nextFireAt: null }; + } const nextFireAt = computeNextFireAt(task.schedule, run.at); if (nextFireAt === null) { return { ...base, status: 'completed', nextFireAt: null }; @@ -344,6 +389,21 @@ export function resumeScheduledTask( }; } const nextFireAt = computeNextFireAt(task.schedule, now); + if ( + task.schedule.kind === 'calendar' && + task.schedule.catchUp === 'once' && + task.schedule.anchorAt <= now + ) { + const latestMissed = latestCalendarFireAt(task.schedule, now); + if (task.lastFireAt === null || task.lastFireAt < latestMissed) { + return { + ...task, + status: 'active', + nextFireAt: now, + updatedAt: now, + }; + } + } if (nextFireAt === null) { return { error: 'Schedule has no remaining fire' }; } @@ -444,9 +504,17 @@ function normalizeSchedule( } const anchorAt = asFiniteNumber(value.anchorAt); if (anchorAt === null) return fail('calendar schedule requires anchorAt'); + if (value.catchUp !== undefined && value.catchUp !== 'once') { + return fail('calendar catchUp must be once'); + } return { ok: true, - value: { kind: 'calendar', recurrence: value.recurrence, anchorAt }, + value: { + kind: 'calendar', + recurrence: value.recurrence, + anchorAt, + ...(value.catchUp === 'once' ? { catchUp: 'once' as const } : {}), + }, }; } if (value.kind === 'cron') { @@ -513,6 +581,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 +615,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 }), @@ -638,6 +715,38 @@ function nextCalendarFireAt( return addMonthsClamped(anchor, afterDate, 481); } +function latestCalendarFireAt( + schedule: Extract, + at: number, +): number { + const anchor = new Date(schedule.anchorAt); + const current = new Date(at); + if (schedule.recurrence === 'daily') { + current.setHours( + anchor.getHours(), + anchor.getMinutes(), + anchor.getSeconds(), + anchor.getMilliseconds(), + ); + if (current.getTime() > at) current.setDate(current.getDate() - 1); + return current.getTime(); + } + if (schedule.recurrence === 'weekly') { + current.setHours( + anchor.getHours(), + anchor.getMinutes(), + anchor.getSeconds(), + anchor.getMilliseconds(), + ); + const dayDelta = (current.getDay() - anchor.getDay() + 7) % 7; + current.setDate(current.getDate() - dayDelta); + if (current.getTime() > at) current.setDate(current.getDate() - 7); + return current.getTime(); + } + const candidate = addMonthsClamped(anchor, current, 0); + return candidate <= at ? candidate : addMonthsClamped(anchor, current, -1); +} + function addMonthsClamped(anchor: Date, base: Date, offset: number): number { const targetMonth = base.getMonth() + offset; const year = base.getFullYear() + Math.floor(targetMonth / 12); 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..992261074a 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'; @@ -66,6 +67,7 @@ import { openInteractiveExecutionStoresForWrite, } from '@maka/storage/execution-stores'; import { openInteractiveRuntimePolicyStoresForWrite } from '@maka/storage/runtime-policy-stores'; +import { openInteractiveScheduledTaskStoreForWrite } from '@maka/storage/scheduled-task-store'; import { resolveRootControlNamespace, resolveStorageRoot, @@ -161,23 +163,576 @@ 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 archiveDay = new Date(); + archiveDay.setHours(0, 0, 0, 0); + archiveDay.setDate(archiveDay.getDate() - 2); + const archiveDayEnd = new Date(archiveDay); + archiveDayEnd.setDate(archiveDayEnd.getDate() + 1); + const archiveDate = `${archiveDay.getFullYear()}-${String(archiveDay.getMonth() + 1).padStart(2, '0')}-${String(archiveDay.getDate()).padStart(2, '0')}`; + const archive = { + id: `${archiveDate}-1d`, + day: { fromMs: archiveDay.getTime(), toMs: archiveDayEnd.getTime() }, + range: 1, + status: 'ok', + generatedAt: archiveDayEnd.getTime() + 1, + trigger: 'cron', + modelKey: '', + 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: '00:00', + modelKey: 'fake::fake-model', + }), + ); + 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 disabledPolicyOwner = await tryAcquireInteractiveRootOwner(fixture.capability); + assert.ok(disabledPolicyOwner); + if (!disabledPolicyOwner) return; + const disabledPolicy = await openInteractiveRuntimePolicyStoresForWrite( + disabledPolicyOwner.lease, + ); + const unsafeDefaults = await disabledPolicy.runtimePolicy.getSnapshot(); + const unsafeDefaultsResult = await disabledPolicy.runtimePolicy.mutate({ + expectedRevision: unsafeDefaults.revision, + operation: { + kind: 'set_chat_defaults', + value: { permissionMode: 'bypass' }, + }, + }); + assert.equal(unsafeDefaultsResult.kind, 'committed'); + const disabledCatalog = await disabledPolicy.connectionCatalog.getSnapshot(); + const disabledConnectionResult = await disabledPolicy.connectionCatalog.create({ + expectedCatalogRevision: disabledCatalog.revision, + connection: { + slug: 'fake', + name: 'Disabled Daily Review fixture', + providerType: 'moonshot', + enabled: false, + enabledModelIds: ['fake-model'], + }, + }); + assert.equal(disabledConnectionResult.kind, 'committed'); + await disabledPolicyOwner.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-${archive.id}`, + }); + 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, + ); + assert.equal( + Number( + ( + pending + .prepare('SELECT count(*) AS count FROM workflow_daily_review_archives') + .get() as { count: number } + ).count, + ), + 0, + ); + } 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 disabledConnection = current.connections.find(({ slug }) => slug === 'fake'); + assert.ok(disabledConnection); + if (!disabledConnection) return; + const enabledConnection = await policy.connectionCatalog.update({ + expected: { + connectionId: disabledConnection.connectionId, + revision: disabledConnection.revision, + }, + changes: { + name: 'Migrated Daily Review fixture', + enabled: true, + enabledModelIds: ['fake-model'], + }, + }); + assert.equal(enabledConnection.kind, 'committed'); + if (enabledConnection.kind !== 'committed') return; + const connection = enabledConnection.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?.presetId, 'daily-review'); + assert.equal(task?.status, 'active'); + assert.equal( + task?.effect.kind === 'agent_run' ? task.effect.execution.llmConnectionId : undefined, + connection.connectionId, + ); + assert.equal( + task?.effect.kind === 'agent_run' ? task.effect.execution.permissionMode : undefined, + 'ask', + ); + assert.deepEqual(task?.schedule, { + kind: 'calendar', + recurrence: 'daily', + anchorAt: task?.schedule.kind === 'calendar' ? task.schedule.anchorAt : -1, + catchUp: 'once', + }); + if (task?.schedule.kind === 'calendar') { + const anchor = new Date(task.schedule.anchorAt); + assert.equal( + `${anchor.getHours()}:${String(anchor.getMinutes()).padStart(2, '0')}`, + '0:00', + ); + } + assert.equal(task?.fireCount, 1); + assert.equal(task?.runs.length, 1); + assert.ok(task?.runs[0]?.sessionId, task?.runs[0]?.message); + + const sessionId = `daily-review-archive-${archive.id}`; + 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-${archive.id}`); + 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, new RegExp(`Archive ID: ${archive.id}`, 'u')); + assert.match(report.preview.text, /Trigger: cron/u); + assert.match(report.preview.text, /Model: \(default\)/u); + assert.match(report.preview.text, new RegExp(`Generated at: ${archive.generatedAt}`, '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, 2); + assert.ok(fired.task.runs[1]?.sessionId); + assert.ok(fired.task.runs[1]?.runId); + const runSession = await desktop.request('session.catalog.query', { + kind: 'get', + sessionId: fired.task.runs[1]!.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[1]?.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-${archive.id}`, + ); + 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-${archive.id}`, + }); + 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 retires disabled Daily Review after projecting its reports', { + timeout: 30_000, +}, async () => { + await withExecutionRoot(async (fixture) => { + const archive = { + id: '2026-08-20-1d', + day: { fromMs: 1_771_132_800_000, toMs: 1_771_219_200_000 }, + range: 1, + status: 'ok', + generatedAt: 1_771_219_200_001, + trigger: 'cron', + modelKey: '', + sections: { summary: 'A disabled review report.' }, + totals: { + sessionCount: 1, + requestCount: 1, + totalTokens: 1, + costUsd: 0, + 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: false, executeTime: '08:00', 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 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; + assert.equal( + taskPage.tasks.some((candidate) => candidate.id === 'system-daily-review'), + false, + ); + const artifacts = await desktop.request('artifact.query', { + kind: 'list_start', + sessionId: `daily-review-archive-${archive.id}`, + }); + assert.equal(artifacts.kind, 'page'); + if (artifacts.kind !== 'page') return; + assert.equal(artifacts.artifacts.length, 1); + } finally { + await desktop.close(); + await fixture.stopHost(host); + } + + 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(); + } + }); +}); + +test('production Host binds a legacy Agent ScheduledTask to its canonical Connection', { + 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 catalog = await policy.connectionCatalog.getSnapshot(); + const created = await policy.connectionCatalog.create({ + expectedCatalogRevision: catalog.revision, + connection: { + slug: 'legacy-ready', + name: 'Legacy ready task', + providerType: 'moonshot', + enabled: true, + enabledModelIds: ['legacy-model'], + }, + }); + assert.equal(created.kind, 'committed'); + if (created.kind !== 'committed') return; + const connection = created.snapshot.connections.find(({ slug }) => slug === 'legacy-ready'); + assert.ok(connection); + if (!connection) return; + assert.equal( + ( + await policy.credentialVault.set({ + locator: { + scope: 'connection', + connectionId: connection.connectionId, + kind: 'api_key', + }, + expected: null, + secret: 'legacy-scheduled-task-key', + }) + ).kind, + 'committed', + ); + const scheduledTasks = await openInteractiveScheduledTaskStoreForWrite(owner.lease); + const task = await scheduledTasks.create({ + title: 'Legacy Agent task', + intentBody: 'Continue the scheduled work.', + schedule: { kind: 'interval', everySeconds: 3_600, startAt: Date.now() + 3_600_000 }, + effect: { + kind: 'agent_run', + execution: { + cwd: fixture.root, + projectId: null, + llmConnectionId: connection.connectionId, + llmConnectionSlug: connection.slug, + model: 'legacy-model', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + createdBy: { kind: 'user' }, + }); + const unresolvedTask = await scheduledTasks.create({ + title: 'Unresolved legacy Agent task', + intentBody: 'Wait for a valid Connection.', + schedule: { kind: 'interval', everySeconds: 3_600, startAt: Date.now() + 3_600_000 }, + effect: { + kind: 'agent_run', + execution: { + cwd: fixture.root, + projectId: null, + llmConnectionId: 'removed-connection', + llmConnectionSlug: 'removed-connection', + model: 'removed-model', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + createdBy: { kind: 'user' }, + }); + scheduledTasks.close(); + await owner.close(); + + const database = new DatabaseSync(join(fixture.root, 'runtime.sqlite')); + database + .prepare( + `UPDATE workflow_scheduled_tasks + SET record_json = json_remove(record_json, '$.effect.execution.llmConnectionId') + WHERE task_id IN (?, ?)`, + ) + .run(task.id, unresolvedTask.id); + database.close(); + + const host = await fixture.startHost(); + const desktop = await connectClient(fixture.root); + try { + const page = await desktop.request('scheduled-task.query', { kind: 'list' }); + assert.equal(page.kind, 'page'); + if (page.kind !== 'page') return; + const repaired = page.tasks.find((candidate) => candidate.id === task.id); + assert.equal( + repaired?.effect.kind === 'agent_run' + ? repaired.effect.execution.llmConnectionId + : undefined, + connection.connectionId, + ); + assert.equal( + page.tasks.find((candidate) => candidate.id === unresolvedTask.id)?.status, + 'paused', + ); + } finally { + await desktop.close(); + await fixture.stopHost(host); + } + }); +}); + +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.', + presetId: 'daily-review', + 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 +751,24 @@ 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.ok(session.session && !('kind' in session.session)); + if (!session.session || 'kind' in session.session) return; + assert.equal(session.session?.id, sessionId); + assert.deepEqual(session.session?.labels, [ + 'scheduled-task', + `scheduled-task:${created.task.id}`, + 'scheduled-task-preset:daily-review', + ]); } 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-coordinator-recovery.test.ts b/packages/runtime-host/src/__tests__/scheduled-task-coordinator-recovery.test.ts index 073fa4c25e..21bafce217 100644 --- a/packages/runtime-host/src/__tests__/scheduled-task-coordinator-recovery.test.ts +++ b/packages/runtime-host/src/__tests__/scheduled-task-coordinator-recovery.test.ts @@ -57,6 +57,7 @@ test('ScheduledTask recovery distinguishes a settled fire from a newer pending f execution: { cwd: '/workspace', backend: 'ai-sdk', + llmConnectionId: 'connection-default', llmConnectionSlug: 'default', model: 'test-model', permissionMode: 'ask', 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..75afd65fd7 100644 --- a/packages/runtime-host/src/__tests__/scheduled-task-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/scheduled-task-protocol.test.ts @@ -34,6 +34,78 @@ import { import { decodeScheduledTaskMutateInput } from '../protocol/scheduled-task.js'; describe('ScheduledTask protocol', () => { + test('round-trips canonical preset provenance and rejects authority-like values', () => { + const task = { ...scheduledTask('task-1'), presetId: 'daily-review' }; + const decoded = decodeScheduledTaskQueryResult({ kind: 'task', task }); + assert.equal(decoded.kind === 'task' ? decoded.task?.presetId : undefined, 'daily-review'); + + const created = decodeScheduledTaskMutateInput({ + kind: 'create', + input: { + title: 'Daily Review', + presetId: 'daily-review', + intentBody: 'Review ordinary Session history.', + schedule: { kind: 'once', runAt: 1 }, + effect: { kind: 'notify', channel: 'local' }, + }, + }); + assert.equal(created.kind === 'create' ? created.input.presetId : undefined, 'daily-review'); + + for (const presetId of ['DailyReview', 'daily review', 'daily-review:system']) { + assert.throws(() => + decodeScheduledTaskQueryResult({ + kind: 'task', + task: { ...task, presetId }, + }), + ); + } + }); + + test('bounds an optional one-shot intent on manual trigger', () => { + assert.deepEqual( + decodeScheduledTaskMutateInput({ + kind: 'trigger_now', + taskId: 'task-1', + intentBody: 'Review the exact selected range.', + }), + { + kind: 'trigger_now', + taskId: 'task-1', + intentBody: 'Review the exact selected range.', + }, + ); + assert.throws(() => + decodeScheduledTaskMutateInput({ + kind: 'trigger_now', + taskId: 'task-1', + intentBody: 'x'.repeat(8_001), + }), + ); + }); + + test('round-trips calendar catch-up policy through the schedule authority', () => { + const decoded = decodeScheduledTaskMutateInput({ + kind: 'create', + input: { + title: 'Daily Review', + intentBody: '', + schedule: { + kind: 'calendar', + recurrence: 'daily', + anchorAt: 1_000, + catchUp: 'once', + }, + effect: { kind: 'notify', channel: 'local' }, + }, + }); + assert.equal( + decoded.kind === 'create' && decoded.input.schedule.kind === 'calendar' + ? decoded.input.schedule.catchUp + : undefined, + 'once', + ); + }); + test('requires Host-path authority only when a mutation submits a Host path', () => { const authority = createRuntimeHostConnectionAuthority({ principalKind: 'remote_owner', @@ -169,6 +241,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..d9c09a3404 100644 --- a/packages/runtime-host/src/protocol/scheduled-task.ts +++ b/packages/runtime-host/src/protocol/scheduled-task.ts @@ -29,6 +29,7 @@ import { SCHEDULED_TASK_MAX_DELAY_MS, SCHEDULED_TASK_MAX_INTERVAL_SECONDS, SCHEDULED_TASK_MIN_INTERVAL_SECONDS, + SCHEDULED_TASK_PRESET_ID_MAX_CHARS, SCHEDULED_TASK_RUN_HISTORY_LIMIT, SCHEDULED_TASK_RUN_MESSAGE_MAX_CHARS, SCHEDULED_TASK_SESSION_ID_MAX_CHARS, @@ -96,10 +97,8 @@ export type ScheduledTaskMutateInput = readonly input: Omit; } | { readonly kind: 'update'; readonly taskId: string; readonly patch: UpdateScheduledTaskInput } - | { - readonly kind: 'pause' | 'resume' | 'clear_history' | 'trigger_now' | 'delete'; - readonly taskId: string; - } + | { readonly kind: 'pause' | 'resume' | 'clear_history' | 'delete'; readonly taskId: string } + | { readonly kind: 'trigger_now'; readonly taskId: string; readonly intentBody?: string } | { readonly kind: 'snooze'; readonly taskId: string; readonly delayMs: number }; export type ScheduledTaskMutateResult = @@ -242,11 +241,31 @@ export function decodeScheduledTaskMutateInput(value: unknown): ScheduledTaskMut patch: decodeUpdateInput(input.patch), }; } + if (record.kind === 'trigger_now') { + const input = requireShapedRecord( + record, + 'ScheduledTask trigger input', + ['kind', 'taskId'], + ['intentBody'], + ); + return { + kind: 'trigger_now', + taskId: requireEntityId(input.taskId, 'ScheduledTask id'), + ...(input.intentBody === undefined + ? {} + : { + intentBody: boundedText( + input.intentBody, + 'ScheduledTask manual intent body', + SCHEDULED_TASK_INTENT_MAX_CHARS, + ), + }), + }; + } if ( record.kind === 'pause' || record.kind === 'resume' || record.kind === 'clear_history' || - record.kind === 'trigger_now' || record.kind === 'delete' ) { const input = requireExactRecord(record, 'ScheduledTask mutation input', ['kind', 'taskId']); @@ -289,24 +308,29 @@ export function decodeScheduledTaskMutateResult(value: unknown): ScheduledTaskMu } export function decodeScheduledTask(value: unknown): ScheduledTask { - const task = requireExactRecord(value, 'ScheduledTask', [ - 'id', - 'title', - 'intent', - 'schedule', - 'effect', - 'status', - 'nextFireAt', - 'lastFireAt', - 'fireCount', - 'maxFires', - 'expiresAt', - 'createdBy', - 'createdAt', - 'updatedAt', - 'runs', - 'lastError', - ]); + const task = requireShapedRecord( + value, + 'ScheduledTask', + [ + 'id', + 'title', + 'intent', + 'schedule', + 'effect', + 'status', + 'nextFireAt', + 'lastFireAt', + 'fireCount', + 'maxFires', + 'expiresAt', + 'createdBy', + 'createdAt', + 'updatedAt', + 'runs', + 'lastError', + ], + ['presetId'], + ); const intent = requireExactRecord(task.intent, 'ScheduledTask intent', ['kind', 'body']); if (intent.kind !== 'text') throw invalidProtocolFrame('Invalid ScheduledTask intent'); if (!isScheduledTaskStatus(task.status)) @@ -316,6 +340,7 @@ export function decodeScheduledTask(value: unknown): ScheduledTask { } return { id: requireEntityId(task.id, 'ScheduledTask id'), + ...(Object.hasOwn(task, 'presetId') ? { presetId: decodePresetId(task.presetId) } : {}), title: boundedText(task.title, 'ScheduledTask title', SCHEDULED_TASK_TITLE_MAX_CHARS, true), intent: { kind: 'text', @@ -349,10 +374,11 @@ function decodeCreateInput(value: unknown): Omit maxChars || (nonblank && !value.trim())) { throw invalidProtocolFrame(`Invalid ${label}`); 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..5d7c761286 100644 --- a/packages/runtime-host/src/server/scheduled-task-coordinator.ts +++ b/packages/runtime-host/src/server/scheduled-task-coordinator.ts @@ -21,7 +21,12 @@ import { randomUUID } from 'node:crypto'; import { botDisplayLabel } from '@maka/core/bot-events'; import { isBotDeliveryProvider } from '@maka/core/bot-chat-settings'; import { messageContentsEqual } from '@maka/core/events'; -import { type ScheduledTask, type ScheduledTaskExecutionTemplate } from '@maka/core/scheduled-task'; +import { + scheduledTaskSessionLabel, + scheduledTaskPresetSessionLabel, + type ScheduledTask, + type ScheduledTaskExecutionTemplate, +} from '@maka/core/scheduled-task'; import type { SessionHeader } from '@maka/core/session'; import { buildAgentScheduledTaskCreatePayload, @@ -161,10 +166,36 @@ export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority async prepareRecovery(): Promise { if (this.#prepared) return; await this.#store.ready(); + await this.#bindLegacyAgentRunConnections(); this.#prepared = true; await this.#refreshResidency(); } + async #bindLegacyAgentRunConnections(): Promise { + const tasks = await this.#store.list(); + for (const task of tasks) { + if (task.effect.kind !== 'agent_run' || task.effect.execution.llmConnectionId) continue; + const resolved = await this.#runtimePolicy.operations.resolveExecutionConnection({ + kind: 'catalog_slug', + connectionSlug: task.effect.execution.llmConnectionSlug, + }); + if ( + resolved.kind !== 'ready' || + !resolved.connection.enabledModelIds.includes(task.effect.execution.model) + ) { + if (task.status === 'active') { + await this.#store.repairLegacyAgentRun(task.id, null, this.#now()); + } + continue; + } + await this.#store.repairLegacyAgentRun( + task.id, + resolved.connection.connectionId, + this.#now(), + ); + } + } + async assertRecoveryAdmission( admission: RootTurnAdmission, state: 'pending_fire_required' | 'run_recorded', @@ -403,7 +434,7 @@ export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority if (input.kind === 'trigger_now') { return taskSuccess( await this.#exclusive(async () => { - const claim = await this.#store.claimNow(input.taskId, this.#now()); + const claim = await this.#store.claimNow(input.taskId, this.#now(), input.intentBody); await this.#refreshResidency(); const task = await this.#fulfill(claim, false); if (!task) throw new ScheduledTaskNativeUnavailableError(); @@ -593,10 +624,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 +676,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'); @@ -660,7 +690,11 @@ export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority ? { kind: 'project', projectId: execution.projectId } : { kind: 'host_path', path: execution.cwd }, name: task.title, - labels: ['scheduled-task'], + labels: [ + 'scheduled-task', + scheduledTaskSessionLabel(task.id), + ...(task.presetId ? [scheduledTaskPresetSessionLabel(task.presetId)] : []), + ], modelTarget: { kind: 'explicit', connectionId: connection.connectionId, @@ -826,6 +860,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..131176ced5 --- /dev/null +++ b/packages/storage/src/__tests__/legacy-daily-review-migration.test.ts @@ -0,0 +1,484 @@ +/* + * 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 { mkdir, mkdtemp, rm, writeFile } 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 { + migrateLegacyDailyReview, + openLegacyDailyReviewMigrationForWrite, +} from '../legacy-daily-review-migration.js'; +import { openInteractiveScheduledTaskStoreForWrite } from '../scheduled-task-store.js'; +import { openInteractiveRuntimePolicyStoresForWrite } from '../runtime-policy-stores.js'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +after(removeTrackedControlDirectories); + +test('reads the released file Daily Review config and archives', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-daily-review-file-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 archiveRoot = join(root, 'daily-reviews', 'archive'); + const archive = { + id: '2026-08-28-deep', + day: { fromMs: 1_772_140_800_000, toMs: 1_772_227_200_000 }, + mode: 'deep', + status: 'ok', + generatedAt: 1_772_227_200_001, + trigger: 'cron', + modelKey: '', + sections: { summary: 'A released file report.' }, + totals: { + sessionCount: 2, + requestCount: 3, + totalTokens: 4, + costUsd: 0.01, + errorCount: 0, + }, + }; + try { + await mkdir(archiveRoot, { recursive: true }); + await writeFile( + join(root, 'daily-reviews', 'config.json'), + JSON.stringify({ + enabled: true, + executeTime: '09:30', + modelKey: 'openrouter::openrouter/free', + deepEnabled: true, + }), + ); + await writeFile(join(archiveRoot, `${archive.id}.json`), JSON.stringify(archive)); + await writeFile(join(archiveRoot, '2026-08-27-1d.json'), '{'); + + const migration = await openLegacyDailyReviewMigrationForWrite(owner.lease); + const snapshot = await migration.read(); + + assert.deepEqual(snapshot?.config, { + enabled: true, + executeTime: '09:30', + modelKey: 'openrouter::openrouter/free', + }); + assert.deepEqual(snapshot?.archives, [ + { + id: archive.id, + day: archive.day, + range: 7, + status: archive.status, + generatedAt: archive.generatedAt, + trigger: archive.trigger, + modelKey: archive.modelKey, + sections: archive.sections, + totals: archive.totals, + }, + ]); + migration.close(); + } finally { + scheduledTasks.close(); + await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); + +test('uses an existing runnable user Daily Review task as the legacy replacement', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-daily-review-user-task-')); + 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 runtimePolicy = await openInteractiveRuntimePolicyStoresForWrite(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: true, + executeTime: '23:59', + modelKey: 'fake::fake-model', + }), + ); + const catalog = await runtimePolicy.connectionCatalog.getSnapshot(); + const createdConnection = await runtimePolicy.connectionCatalog.create({ + expectedCatalogRevision: catalog.revision, + connection: { + slug: 'fake', + name: 'Daily Review migration 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; + assert.equal( + ( + await runtimePolicy.credentialVault.set({ + locator: { + scope: 'connection', + connectionId: connection.connectionId, + kind: 'api_key', + }, + expected: null, + secret: 'daily-review-migration-key', + }) + ).kind, + 'committed', + ); + const existing = await scheduledTasks.create({ + presetId: 'daily-review', + title: 'My Daily Review', + intentBody: 'Review my work.', + schedule: { kind: 'calendar', recurrence: 'daily', anchorAt: Date.now(), catchUp: 'once' }, + effect: { + kind: 'agent_run', + execution: { + cwd: root, + projectId: null, + llmConnectionId: connection.connectionId, + llmConnectionSlug: connection.slug, + model: 'fake-model', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + createdBy: { kind: 'user' }, + }); + const legacy = await openLegacyDailyReviewMigrationForWrite(owner.lease); + + assert.equal( + await migrateLegacyDailyReview({ + legacy, + scheduledTasks, + sessions: null as never, + artifacts: null as never, + runtimePolicy, + workspaceRoot: root, + now: () => new Date(2026, 7, 30, 12, 0).getTime(), + }), + true, + ); + assert.equal(await legacy.read(), null); + assert.deepEqual( + (await scheduledTasks.list()).map((task) => task.id), + [existing.id], + ); + legacy.close(); + } finally { + database.close(); + scheduledTasks.close(); + await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); + +test('keeps legacy config when a user Daily Review task is not an Agent run', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-daily-review-user-conflict-')); + 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 runtimePolicy = await openInteractiveRuntimePolicyStoresForWrite(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: true, executeTime: '08:00', modelKey: '' })); + const existing = await scheduledTasks.create({ + presetId: 'daily-review', + title: 'Daily reminder', + intentBody: '', + schedule: { kind: 'interval', everySeconds: 86_400, startAt: Date.now() + 60_000 }, + effect: { kind: 'notify', channel: 'local' }, + createdBy: { kind: 'user' }, + }); + const legacy = await openLegacyDailyReviewMigrationForWrite(owner.lease); + + assert.equal( + await migrateLegacyDailyReview({ + legacy, + scheduledTasks, + sessions: null as never, + artifacts: null as never, + runtimePolicy, + workspaceRoot: root, + }), + false, + ); + assert.ok(await legacy.read()); + assert.deepEqual( + (await scheduledTasks.list()).map((task) => task.id), + [existing.id], + ); + legacy.close(); + } finally { + database.close(); + scheduledTasks.close(); + await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); + +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 the released two-table Daily Review layout', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-daily-review-two-table-')); + 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, { authority: false }); + const config = { enabled: false, executeTime: '08:00', modelKey: '' }; + database + .prepare('INSERT INTO workflow_daily_review_state(singleton, config_json) VALUES (1, ?)') + .run(JSON.stringify(config)); + + const migration = await openLegacyDailyReviewMigrationForWrite(owner.lease); + const snapshot = await migration.read(); + + assert.deepEqual(snapshot?.config, config); + assert.equal(snapshot?.archives.length, 0); + assert.ok(snapshot); + if (!snapshot) return; + 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_archives'), false); + migration.close(); + } finally { + database.close(); + scheduledTasks.close(); + 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, + options: { readonly authority?: boolean } = {}, +): 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_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); + `); + if (options.authority !== false) { + database.exec(` + CREATE TABLE IF NOT EXISTS workflow_daily_review_authority_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + revision INTEGER NOT NULL CHECK (revision >= 0) + ); + `); + } +} diff --git a/packages/storage/src/__tests__/public-entrypoints.test.ts b/packages/storage/src/__tests__/public-entrypoints.test.ts index bdbb85bf7c..fa727b3f3f 100644 --- a/packages/storage/src/__tests__/public-entrypoints.test.ts +++ b/packages/storage/src/__tests__/public-entrypoints.test.ts @@ -48,13 +48,13 @@ const SQLITE_BACKED_ENTRYPOINTS = [ './agent-graph-control-store', './agent-run-store', './artifact-stores', - './daily-review-authority', './deep-research-authority', './deep-research-store', './execution-stores', './git-worktree-child-executor', './goal-authority', './interaction-store', + './legacy-daily-review-migration', './model-call-ledger', './operational-state-store', './plan-authority', diff --git a/packages/storage/src/__tests__/sqlite-workflow-store.test.ts b/packages/storage/src/__tests__/sqlite-workflow-store.test.ts index 23fb82b95a..194b613824 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' }]); @@ -749,6 +749,114 @@ describe('SQLite workflow stores', () => { }); }); + test('system task migration does not adopt a user-owned preset task', async () => { + await withRoot(async (root) => { + const now = Date.now(); + const { owner, open } = await scheduledTaskStoreRoot(root); + const store = await open(); + try { + const existing = await store.create( + { + presetId: 'daily-review', + title: 'User Daily Review', + intentBody: 'Review the previous local day.', + schedule: { kind: 'interval', everySeconds: 86_400, startAt: now + 1_000 }, + effect: { kind: 'notify', channel: 'local' }, + createdBy: { kind: 'user' }, + }, + now, + ); + + await assert.rejects( + store.ensureSystemTask( + 'system-daily-review', + { + presetId: 'daily-review', + title: 'Daily Review', + intentBody: 'Review the previous local day.', + schedule: { kind: 'interval', everySeconds: 86_400, startAt: now + 1_000 }, + effect: { kind: 'notify', channel: 'local' }, + createdBy: { kind: 'system' }, + }, + now, + ), + /already exists/u, + ); + + assert.equal((await store.list())[0]?.id, existing.id); + assert.equal((await store.list()).length, 1); + } finally { + store.close(); + await owner.close(); + } + }); + }); + + test('rejects a second Daily Review preset task', async () => { + await withRoot(async (root) => { + const now = Date.now(); + const { owner, open } = await scheduledTaskStoreRoot(root); + const store = await open(); + const input = { + presetId: 'daily-review', + title: 'Daily Review', + intentBody: 'Review the previous local day.', + schedule: { kind: 'interval' as const, everySeconds: 86_400, startAt: now + 1_000 }, + effect: { kind: 'notify' as const, channel: 'local' as const }, + createdBy: { kind: 'user' as const }, + }; + try { + await store.create(input, now); + await assert.rejects(() => store.create(input, now + 1), /already exists/u); + assert.equal((await store.list()).length, 1); + } finally { + store.close(); + await owner.close(); + } + }); + }); + + test('manual fire snapshots a one-shot intent and keeps a paused schedule paused', async () => { + await withRoot(async (root) => { + const now = Date.now(); + const { owner, open } = await scheduledTaskStoreRoot(root); + const store = await open(); + const task = await store.create( + { + title: 'Daily Review', + intentBody: 'Review the previous local day.', + schedule: { kind: 'interval', everySeconds: 86_400, startAt: now + 1_000 }, + effect: { kind: 'notify', channel: 'local' }, + createdBy: { kind: 'user' }, + }, + now, + ); + await store.pause(task.id, now + 1); + + const claim = await store.claimNow( + task.id, + now + 2, + 'Review the exact selected seven-day range.', + ); + assert.equal(claim.task.intent.body, 'Review the exact selected seven-day range.'); + assert.equal(claim.task.status, 'paused'); + assert.equal((await store.get(task.id))?.intent.body, 'Review the previous local day.'); + + await store.settleFire(claim.id, { + at: now + 3, + outcome: 'ok', + message: 'done', + }); + const settled = await store.get(task.id); + assert.equal(settled?.status, 'paused'); + assert.equal(settled?.nextFireAt, null); + assert.equal(settled?.intent.body, 'Review the previous local day.'); + assert.equal(settled?.fireCount, 1); + store.close(); + await owner.close(); + }); + }); + test('persists the exact ScheduledTask Agent execution identity before admission', async () => { await withRoot(async (root) => { const now = Date.now(); @@ -764,6 +872,7 @@ describe('SQLite workflow stores', () => { execution: { cwd: '/workspace', backend: 'ai-sdk', + llmConnectionId: 'connection-default', llmConnectionSlug: 'default', model: 'test-model', permissionMode: 'ask', @@ -837,6 +946,7 @@ describe('SQLite workflow stores', () => { kind: 'agent_run', execution: { cwd: '/workspace', + llmConnectionId: 'connection-default', llmConnectionSlug: 'default', model: 'test-model', permissionMode: 'ask', 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..0f4d4d4e3e --- /dev/null +++ b/packages/storage/src/legacy-daily-review-migration.ts @@ -0,0 +1,768 @@ +/* + * 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 { readFile, readdir, rm, rmdir } from 'node:fs/promises'; +import type { DatabaseSync } from 'node:sqlite'; +import { join } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import type { ScheduledTask } from '@maka/core/scheduled-task'; +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_REQUIRED_TABLES = [ + 'workflow_daily_review_state', + 'workflow_daily_review_archives', +] as const; + +const LEGACY_TABLES = [...LEGACY_REQUIRED_TABLES, 'workflow_daily_review_authority_state'] as const; +const LEGACY_FILE_ARCHIVE_ID = /^\d{4}-\d{2}-\d{2}-(?:1d|7d|30d|daily|deep)$/u; + +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; + retireArchives(token: LegacyDailyReviewMigrationSnapshot['token']): 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(); + for (const archive of snapshot.archives) { + await migrateArchive(input, archive); + } + let retirementToken = snapshot.token; + if (snapshot.archives.length > 0) { + if (!(await input.legacy.retireArchives(snapshot.token))) { + throw new Error('Legacy Daily Review state disappeared during migration'); + } + const remainder = await input.legacy.read(); + if (remainder === null) return true; + retirementToken = remainder.token; + } + if (!snapshot.config.enabled) { + if (!(await input.legacy.retire(retirementToken))) { + throw new Error('Legacy Daily Review state disappeared during migration'); + } + return true; + } + const existingTask = findExistingDailyReviewTask(await input.scheduledTasks.list()); + if (existingTask && !isCompatibleDailyReviewReplacement(existingTask)) return false; + const task = existingTask ?? (await createSystemDailyReviewTask(input, snapshot, now)); + // The legacy scheduler accepted an enabled configuration before a model was + // configured. Keep that inert configuration as the one-shot source until a + // canonical replacement can be installed. + if (task === undefined) return false; + if (task.status === 'active' && needsLegacyCatchUp(snapshot, now)) { + await input.scheduledTasks.makeDueNow(task.id, now); + } + if (!(await input.legacy.retire(retirementToken))) { + throw new Error('Legacy Daily Review state disappeared during migration'); + } + return true; +} + +function findExistingDailyReviewTask(tasks: readonly ScheduledTask[]): ScheduledTask | undefined { + return ( + tasks.find((task) => task.id === DAILY_REVIEW_SYSTEM_TASK_ID) ?? + tasks.find((task) => task.presetId === 'daily-review') + ); +} + +function isCompatibleDailyReviewReplacement(task: ScheduledTask): boolean { + if (task.id === DAILY_REVIEW_SYSTEM_TASK_ID && task.createdBy.kind !== 'system') return false; + return ( + (task.status === 'active' || task.status === 'paused') && + task.effect.kind === 'agent_run' && + Boolean(task.effect.execution.llmConnectionId) + ); +} + +async function createSystemDailyReviewTask( + input: Parameters[0], + snapshot: LegacyDailyReviewMigrationSnapshot, + now: number, +): Promise { + const target = await resolveExecutionTarget(snapshot.config.modelKey, input.runtimePolicy); + if (target === undefined) return undefined; + return input.scheduledTasks.ensureSystemTask( + DAILY_REVIEW_SYSTEM_TASK_ID, + { + title: 'Daily Review', + presetId: 'daily-review', + intentBody: DAILY_REVIEW_SCHEDULED_TASK_INTENT, + schedule: { + kind: 'calendar', + recurrence: 'daily', + anchorAt: localAnchorAt(now, snapshot.config.executeTime), + catchUp: 'once', + }, + effect: { + kind: 'agent_run', + execution: { + cwd: input.workspaceRoot, + projectId: null, + llmConnectionId: target.connectionId, + llmConnectionSlug: target.connectionSlug, + model: target.model, + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + createdBy: { kind: 'system' }, + }, + now, + ); +} + +export async function openLegacyDailyReviewMigrationForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + let closed = false; + const run = (operation: (root: string) => Promise): Promise => { + if (closed) return Promise.reject(new Error('Legacy Daily Review migration is closed')); + return runWithStorageRootLease(lease, 'interactive', 'write', operation); + }; + const read = async (root: string): Promise => { + const database = acquireOperationalStateDatabase(root); + try { + const databaseSnapshot = database.transaction('read', () => + readDatabaseSnapshot(database.database), + ); + const fileSnapshot = await readFileSnapshot(root); + if (databaseSnapshot === null && fileSnapshot === null) return null; + const archives = new Map(); + for (const archive of fileSnapshot?.snapshot.archives ?? []) + archives.set(archive.id, archive); + for (const archive of databaseSnapshot?.archives ?? []) archives.set(archive.id, archive); + const snapshot: LegacyDailyReviewMigrationSnapshot = { + token: sha256( + JSON.stringify({ + database: databaseSnapshot?.token ?? null, + files: fileSnapshot?.snapshot.token ?? null, + }), + ), + config: + databaseSnapshot?.config ?? fileSnapshot?.snapshot.config ?? decodeConfig(undefined), + archives: [...archives.values()].sort((left, right) => left.id.localeCompare(right.id)), + }; + return { snapshot, fileSnapshot }; + } finally { + database.close(); + } + }; + return Object.freeze({ + read: () => run(async (root) => (await read(root))?.snapshot ?? null), + retireArchives: (token: LegacyDailyReviewMigrationSnapshot['token']) => + run(async (root) => { + const current = await read(root); + if (current === null) return false; + if (current.snapshot.token !== token) { + throw new Error('Legacy Daily Review state changed during migration'); + } + const database = acquireOperationalStateDatabase(root); + try { + database.transaction('write', () => { + if (tableExists(database.database, 'workflow_daily_review_archives')) { + database.database.exec('DELETE FROM workflow_daily_review_archives;'); + } + }); + } finally { + database.close(); + } + if (current.fileSnapshot) await retireFileArchives(root, current.fileSnapshot); + return true; + }), + retire: (token: LegacyDailyReviewMigrationSnapshot['token']) => + run(async (root) => { + const current = await read(root); + if (current === null) return false; + if (current.snapshot.token !== token) { + throw new Error('Legacy Daily Review state changed during migration'); + } + const database = acquireOperationalStateDatabase(root); + try { + database.transaction('write', () => { + database.database.exec(` + DROP INDEX IF EXISTS workflow_daily_review_archives_order; + DROP TABLE IF EXISTS workflow_daily_review_archives; + DROP TABLE IF EXISTS workflow_daily_review_authority_state; + DROP TABLE IF EXISTS workflow_daily_review_state; + `); + }); + } finally { + database.close(); + } + if (current.fileSnapshot) await retireFileSnapshot(root, current.fileSnapshot); + return true; + }), + close: () => { + closed = true; + }, + }); +} + +interface LegacyDailyReviewMigrationSources { + readonly snapshot: LegacyDailyReviewMigrationSnapshot; + readonly fileSnapshot: LegacyDailyReviewFileSnapshot | null; +} + +interface LegacyDailyReviewFileSnapshot { + readonly snapshot: LegacyDailyReviewMigrationSnapshot; + readonly hasConfig: boolean; + readonly archiveFileNames: readonly string[]; +} + +async function readFileSnapshot(root: string): Promise { + const dailyReviewRoot = join(root, 'daily-reviews'); + const configPath = join(dailyReviewRoot, 'config.json'); + let configJson: string | undefined; + try { + configJson = await readFile(configPath, 'utf8'); + } catch (error) { + if (!isNotFound(error)) throw error; + } + const archiveRoot = join(dailyReviewRoot, 'archive'); + let archiveFileNames: string[] = []; + try { + archiveFileNames = (await readdir(archiveRoot, { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => entry.name) + .filter((name) => LEGACY_FILE_ARCHIVE_ID.test(name.slice(0, -'.json'.length))) + .sort(); + } catch (error) { + if (!isNotFound(error)) throw error; + } + const archiveRecords = await Promise.all( + archiveFileNames.map(async (name) => ({ + name, + raw: await readFile(join(archiveRoot, name), 'utf8'), + })), + ); + const readableArchives: Array<{ + readonly name: string; + readonly raw: string; + readonly archive: LegacyDailyReviewArchive; + }> = []; + for (const record of archiveRecords) { + try { + readableArchives.push({ + ...record, + archive: decodeArchiveFile(record.name.slice(0, -'.json'.length), record.raw), + }); + } catch { + // Released file stores treated an invalid archive as missing. Keep the + // unreadable file in place while allowing healthy siblings to migrate. + } + } + if (configJson === undefined && readableArchives.length === 0) return null; + return { + snapshot: { + token: sha256(JSON.stringify({ configJson: configJson ?? null, archives: readableArchives })), + config: decodeConfig(configJson), + archives: readableArchives.map(({ archive }) => archive), + }, + hasConfig: configJson !== undefined, + archiveFileNames: readableArchives.map(({ name }) => name), + }; +} + +async function retireFileSnapshot( + root: string, + snapshot: LegacyDailyReviewFileSnapshot, +): Promise { + const dailyReviewRoot = join(root, 'daily-reviews'); + const archiveRoot = join(dailyReviewRoot, 'archive'); + if (snapshot.hasConfig) await rm(join(dailyReviewRoot, 'config.json'), { force: true }); + await retireFileArchives(root, snapshot); + await rmdir(dailyReviewRoot).catch(ignoreMissingOrNonEmptyDirectory); +} + +async function retireFileArchives( + root: string, + snapshot: LegacyDailyReviewFileSnapshot, +): Promise { + const archiveRoot = join(root, 'daily-reviews', 'archive'); + await Promise.all( + snapshot.archiveFileNames.map((name) => rm(join(archiveRoot, name), { force: true })), + ); + await rmdir(archiveRoot).catch(ignoreMissingOrNonEmptyDirectory); +} + +function ignoreMissingOrNonEmptyDirectory(error: unknown): void { + if ( + error && + typeof error === 'object' && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTEMPTY') + ) { + return; + } + throw error; +} + +function decodeArchiveFile(id: string, raw: string): LegacyDailyReviewArchive { + const input = JSON.parse(raw) as unknown; + if (!isRecord(input) || !isRecord(input.day)) { + throw new Error(`Legacy Daily Review archive ${id} is invalid`); + } + return decodeArchiveRow({ + archiveId: id, + generatedAt: input.generatedAt, + dayFromMs: input.day.fromMs, + recordJson: raw, + }); +} + +function readDatabaseSnapshot(database: DatabaseSync): LegacyDailyReviewMigrationSnapshot | null { + return readSnapshot(database); +} + +function readSnapshot(database: DatabaseSync): LegacyDailyReviewMigrationSnapshot | null { + const present = LEGACY_TABLES.filter((table) => tableExists(database, table)); + if (present.length === 0) return null; + if (LEGACY_REQUIRED_TABLES.some((table) => !present.includes(table))) { + 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 = present.includes('workflow_daily_review_authority_state') + ? (database + .prepare('SELECT revision FROM workflow_daily_review_authority_state WHERE singleton = 1') + .get() as { revision?: unknown } | undefined) + : 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, +): 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, + }), + ); + // Archive provenance is immutable. In particular, do not backfill a report + // that used the legacy default with whichever Connection happens to exist on + // a later retry: that would make the same migration identity change shape. + const archiveTarget = parseModelKey(archive.modelKey) ?? { + 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'], + 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(); + const selected = explicit + ? (() => { + const connection = catalog.connections.find( + (candidate) => candidate.slug === explicit.connectionSlug, + ); + return connection + ? { + connectionId: connection.connectionId, + connectionSlug: connection.slug, + model: explicit.model, + } + : undefined; + })() + : (() => { + const target = catalog.defaultTarget; + const connection = target + ? catalog.connections.find((candidate) => candidate.connectionId === target.connectionId) + : undefined; + return target && connection + ? { + connectionId: connection.connectionId, + connectionSlug: connection.slug, + model: target.modelId, + } + : undefined; + })(); + if (!selected) return undefined; + const resolved = await runtimePolicy.operations.resolveExecutionConnection({ + kind: 'bound', + connectionId: selected.connectionId, + connectionSlug: selected.connectionSlug, + }); + if (resolved.kind !== 'ready' || !resolved.connection.enabledModelIds.includes(selected.model)) { + return undefined; + } + return selected; +} + +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 needsLegacyCatchUp(snapshot: LegacyDailyReviewMigrationSnapshot, now: number): boolean { + if (!snapshot.config.enabled || !scheduledTimeHasPassed(now, snapshot.config.executeTime)) { + return false; + } + const previousDay = new Date(now); + previousDay.setHours(0, 0, 0, 0); + previousDay.setDate(previousDay.getDate() - 1); + const archiveId = `${previousDay.getFullYear()}-${String(previousDay.getMonth() + 1).padStart(2, '0')}-${String(previousDay.getDate()).padStart(2, '0')}-1d`; + return !snapshot.archives.some((archive) => archive.id === archiveId); +} + +function scheduledTimeHasPassed(now: number, executeTime: string): boolean { + const current = new Date(now); + const [hours, minutes] = executeTime.split(':').map(Number); + return current.getHours() * 60 + current.getMinutes() >= (hours ?? 0) * 60 + (minutes ?? 0); +} + +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); +} + +function isNotFound(error: unknown): boolean { + return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'); +} diff --git a/packages/storage/src/operational-target-schema.ts b/packages/storage/src/operational-target-schema.ts index 244850d365..545d1bf944 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,55 @@ 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_REQUIRED_TABLES = new Set([ + 'workflow_daily_review_state', + 'workflow_daily_review_archives', +]); + +const DAILY_REVIEW_MIGRATION_OPTIONAL_TABLES = new Set(['workflow_daily_review_authority_state']); + +const DAILY_REVIEW_MIGRATION_TABLES = new Set([ + ...DAILY_REVIEW_MIGRATION_REQUIRED_TABLES, + ...DAILY_REVIEW_MIGRATION_OPTIONAL_TABLES, +]); + +export function assertReleasedDailyReviewMigrationShape(database: DatabaseSync): void { + for (const table of DAILY_REVIEW_MIGRATION_REQUIRED_TABLES) { + assertReleasedLegacyRetirementShape(database, table); + } + const presentTables = new Set(readSchemaObjects(database).map((object) => object.tableName)); + for (const table of DAILY_REVIEW_MIGRATION_OPTIONAL_TABLES) { + if (presentTables.has(table)) 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..5997519f43 100644 --- a/packages/storage/src/scheduled-task-store.ts +++ b/packages/storage/src/scheduled-task-store.ts @@ -67,14 +67,21 @@ interface ScheduledTaskStore { list(): Promise; get(id: string): Promise; create(input: unknown, now?: number): Promise; + ensureSystemTask(id: string, input: unknown, now?: number): Promise; + repairLegacyAgentRun( + id: string, + connectionId: string | null, + now?: number, + ): Promise; update(id: string, patch: unknown, now?: number): Promise; pause(id: string, now?: number): Promise; resume(id: string, now?: number): Promise; + makeDueNow(id: string, now?: number): Promise; snooze(id: string, delayMs: number, now?: number): Promise; clearRunHistory(id: string, now?: number): Promise; remove(id: string): Promise; claimNextDue(now?: number): Promise; - claimNow(id: string, now?: number): Promise; + claimNow(id: string, now?: number, intentBody?: string): Promise; listPendingFires(): Promise; bindFireExecution( claimId: string, @@ -199,14 +206,18 @@ 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)), + repairLegacyAgentRun: (id, connectionId, now) => + run(() => store.repairLegacyAgentRun(id, connectionId, 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)), + makeDueNow: (id, now) => run(() => store.makeDueNow(id, now)), snooze: (id, delayMs, now) => run(() => store.snooze(id, delayMs, now)), clearRunHistory: (id, now) => run(() => store.clearRunHistory(id, now)), remove: (id) => run(() => store.remove(id)), claimNextDue: (now) => run(() => store.claimNextDue(now)), - claimNow: (id, now) => run(() => store.claimNow(id, now)), + claimNow: (id, now, intentBody) => run(() => store.claimNow(id, now, intentBody)), listPendingFires: () => run(() => store.listPendingFires()), bindFireExecution: (claimId, execution) => run(() => store.bindFireExecution(claimId, execution)), @@ -255,6 +266,7 @@ class SqliteScheduledTaskStore implements ScheduledTaskStore { const value = normalized.value; const task: ScheduledTask = { id: randomUUID(), + ...(value.presetId === undefined ? {} : { presetId: value.presetId }), title: value.title, intent: { kind: 'text', body: value.intentBody }, schedule: value.schedule, @@ -271,10 +283,114 @@ class SqliteScheduledTaskStore implements ScheduledTaskStore { runs: [], lastError: null, }; - await this.mutate((state) => ({ ...state, tasks: [...state.tasks, task] })); + await this.mutate((state) => { + if ( + value.presetId === 'daily-review' && + state.tasks.some((candidate) => candidate.presetId === value.presetId) + ) { + throw storeError('operation_conflict', 'Daily Review ScheduledTask already exists'); + } + return { ...state, tasks: [...state.tasks, task] }; + }); 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 existingById = state.tasks.find((task) => task.id === id); + if (existingById) { + if (existingById.createdBy.kind !== 'system') { + throw storeError('operation_conflict', `Scheduled task id is already owned: ${id}`); + } + result = existingById; + return state; + } + const presetId = normalized.value.presetId; + const existingByPreset = + presetId === undefined ? undefined : state.tasks.find((task) => task.presetId === presetId); + if (existingByPreset) { + throw storeError('operation_conflict', `${presetId} ScheduledTask already exists`); + } + const value = normalized.value; + const task: ScheduledTask = { + id, + ...(value.presetId === undefined ? {} : { presetId: value.presetId }), + 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 repairLegacyAgentRun( + id: string, + connectionId: string | null, + now = Date.now(), + ): Promise { + const normalizedConnectionId = connectionId?.trim() ?? null; + if (connectionId !== null && !normalizedConnectionId) { + throw storeError('invalid_input', 'ScheduledTask Connection identity is required'); + } + let updated: ScheduledTask | undefined; + await this.mutate((state) => { + const current = state.tasks.find((task) => task.id === id); + if (!current) return state; + if (normalizedConnectionId === null) { + if (current.effect.kind !== 'agent_run' || current.effect.execution.llmConnectionId) { + throw storeError('operation_conflict', 'ScheduledTask is not a legacy Agent task'); + } + updated = pauseScheduledTask(current, now); + return { + ...state, + tasks: state.tasks.map((task) => (task.id === id ? updated! : task)), + }; + } + updated = bindLegacyAgentRunConnection(current, normalizedConnectionId, now); + return { + ...state, + tasks: state.tasks.map((task) => (task.id === id ? updated! : task)), + claims: state.claims.map((claim) => + claim.taskId === id + ? { + ...claim, + task: bindLegacyAgentRunConnection( + claim.task, + normalizedConnectionId, + claim.task.updatedAt, + ), + } + : claim, + ), + }; + }); + if (!updated) throw storeError('not_found', `No such scheduled task: ${id}`); + return updated; + } + async update(id: string, patch: unknown, now = Date.now()): Promise { const normalized = normalizeUpdateScheduledTaskInput(patch, now); if (!normalized.ok) throw storeError('invalid_input', normalized.message); @@ -398,6 +514,24 @@ class SqliteScheduledTaskStore implements ScheduledTaskStore { return updated; } + async makeDueNow(id: string, now = Date.now()): Promise { + let updated: ScheduledTask | undefined; + await this.mutate((state) => ({ + ...state, + tasks: state.tasks.map((task) => { + if (task.id !== id) return task; + assertNoPendingClaim(state.claims, id); + if (task.status !== 'active') { + throw storeError('operation_conflict', 'Only active scheduled tasks can be made due'); + } + updated = { ...task, nextFireAt: now, updatedAt: now }; + return updated; + }), + })); + if (!updated) throw storeError('not_found', `No such scheduled task: ${id}`); + return updated; + } + async clearRunHistory(id: string, now = Date.now()): Promise { let updated: ScheduledTask | undefined; await this.mutate((state) => ({ @@ -454,19 +588,32 @@ class SqliteScheduledTaskStore implements ScheduledTaskStore { return { claim: claimed ?? null, expired }; } - async claimNow(id: string, now = Date.now()): Promise { + async claimNow( + id: string, + now = Date.now(), + intentBody?: string, + ): Promise { + const normalizedIntent = + intentBody === undefined ? undefined : normalizeUpdateScheduledTaskInput({ intentBody }, now); + if (normalizedIntent && !normalizedIntent.ok) { + throw storeError('invalid_input', normalizedIntent.message); + } let claimed: ScheduledTaskFireClaim | undefined; await this.mutate((state) => { const task = state.tasks.find((entry) => entry.id === id); if (!task) throw storeError('not_found', `No such scheduled task: ${id}`); assertNoPendingClaim(state.claims, id); - if (task.status !== 'active') { - throw storeError('operation_conflict', 'Only active tasks can be triggered now'); + if (task.status !== 'active' && task.status !== 'paused') { + throw storeError('operation_conflict', 'Only active or paused tasks can be triggered now'); } if (task.expiresAt !== null && now >= task.expiresAt) { throw storeError('operation_conflict', 'Scheduled task has expired'); } - claimed = createClaim(task, now, now); + const manualIntent = normalizedIntent?.ok ? normalizedIntent.value.intentBody : undefined; + if (manualIntent !== undefined && task.effect.kind !== 'notify' && !manualIntent.trim()) { + throw storeError('invalid_input', 'Agent intent body is required'); + } + claimed = createClaim(task, now, now, manualIntent); return { ...state, claims: [...state.claims, claimed] }; }); return claimed!; @@ -668,13 +815,16 @@ function createClaim( task: ScheduledTask, scheduledFor: number, claimedAt: number, + intentBody?: string, ): ScheduledTaskFireClaim { return { id: randomUUID(), taskId: task.id, scheduledFor, claimedAt, - task: structuredClone(task), + task: structuredClone( + intentBody === undefined ? task : { ...task, intent: { kind: 'text', body: intentBody } }, + ), }; } @@ -684,6 +834,29 @@ function assertNoPendingClaim(claims: readonly ScheduledTaskFireClaim[], taskId: } } +function bindLegacyAgentRunConnection( + task: ScheduledTask, + connectionId: string, + updatedAt: number, +): ScheduledTask { + if (task.effect.kind !== 'agent_run') { + throw storeError('operation_conflict', 'ScheduledTask does not run an Agent'); + } + const current = task.effect.execution.llmConnectionId; + if (current !== undefined && current !== connectionId) { + throw storeError('operation_conflict', 'ScheduledTask already has another Connection identity'); + } + if (current === connectionId) return task; + return { + ...task, + effect: { + ...task.effect, + execution: { ...task.effect.execution, llmConnectionId: connectionId }, + }, + updatedAt, + }; +} + function storeError(code: ScheduledTaskStoreErrorCode, message: string): ScheduledTaskStoreError { return new ScheduledTaskStoreError(code, 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/__tests__/daily-review-panel.test.tsx b/packages/ui/src/__tests__/daily-review-panel.test.tsx new file mode 100644 index 0000000000..a8c2468ac5 --- /dev/null +++ b/packages/ui/src/__tests__/daily-review-panel.test.tsx @@ -0,0 +1,167 @@ +/* + * 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 } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import type { ScheduledTask } from '@maka/core/scheduled-task'; +import { DailyReviewPanel } from '../daily-review-panel.js'; +import { LocaleProvider } from '../locale-context.js'; +import type { DailyReviewViewState } from '../daily-review-view-state.js'; + +const originalGlobals = { + document: globalThis.document, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; +const originalDateNow = Date.now; +const mountedRoots: ReturnType[] = []; + +afterEach(async () => { + for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); + Date.now = originalDateNow; + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +function computedStyle(): CSSStyleDeclaration { + return { + direction: 'ltr', + writingMode: 'horizontal-tb', + getPropertyValue: () => '', + } as unknown as CSSStyleDeclaration; +} + +const EMPTY_VIEW: DailyReviewViewState = { + totals: { + sessionCount: 0, + totalRequests: 0, + totalTokens: 0, + totalCostUsd: 0, + }, + sessions: [], + reports: [], + hasMigratedReports: false, +}; + +async function renderDailyReview( + view: DailyReviewViewState, + task?: ScheduledTask, +): Promise { + const { document, window } = parseHTML('
'); + window.getComputedStyle = () => computedStyle(); + Object.assign(globalThis, { + document, + window, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Date.now = () => new Date(2026, 7, 30, 15, 45).getTime(); + + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoots.push(root); + await act(async () => { + root.render( + + view, + }} + canSetUp + task={task} + onSetUp={() => undefined} + onManageSchedule={() => undefined} + /> + , + ); + await Promise.resolve(); + await Promise.resolve(); + }); + return document; +} + +test('a completed Daily Review task shows its terminal state without Run Now', async () => { + const now = Date.now(); + const document = await renderDailyReview(EMPTY_VIEW, { + id: 'completed-review', + presetId: 'daily-review', + title: 'Daily Review', + intent: { kind: 'text', body: 'Review my work.' }, + schedule: { kind: 'once', runAt: now - 1_000 }, + effect: { kind: 'notify', channel: 'local' }, + status: 'completed', + nextFireAt: null, + lastFireAt: now - 1_000, + fireCount: 1, + maxFires: 1, + expiresAt: null, + createdBy: { kind: 'user' }, + createdAt: now - 2_000, + updatedAt: now, + runs: [], + lastError: null, + }); + const text = document.documentElement.textContent ?? ''; + assert.match(text, /每日回顾已完成/u); + assert.doesNotMatch(text, /立即生成回顾/u); + assert.match(text, /管理日程/u); +}); + +test('an empty Daily Review keeps one date-scoped content section', async () => { + const document = await renderDailyReview(EMPTY_VIEW); + const sections = document.querySelectorAll('.maka-daily-review-content section'); + assert.equal(sections.length, 1); + assert.match(sections[0]?.getAttribute('aria-label') ?? '', /8月30日/u); + const controls = document.querySelector('.maka-daily-review-period-controls'); + assert.equal(controls?.getAttribute('aria-controls'), sections[0]?.id); + assert.match(controls?.textContent ?? '', /8月30日/u); + assert.doesNotMatch(document.documentElement.textContent ?? '', /还没有回顾报告/u); +}); + +test('a review report appears once while migrated history stays visible', async () => { + const report = { + sessionId: 'daily-review-report', + title: 'Daily Review · 8月30日', + preview: '完成了三个任务。', + } as const; + const document = await renderDailyReview({ + ...EMPTY_VIEW, + totals: { ...EMPTY_VIEW.totals, sessionCount: 1 }, + sessions: [{ ...report, activityAt: Date.now(), status: 'active' }], + reports: [{ ...report, generatedAt: Date.now(), migrated: true }], + hasMigratedReports: true, + }); + const text = document.documentElement.textContent ?? ''; + assert.equal(text.split(report.title).length - 1, 1, text); + assert.match(text, /旧报告已迁移/u); + assert.match(text, /回顾报告/u); +}); diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index bfde2290b7..b08256b3fc 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, DailyReviewProjectionBridge, 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 { DailyReviewPage, 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 index 3afe0196f8..0047aaf609 100644 --- a/packages/ui/src/daily-review-copy.ts +++ b/packages/ui/src/daily-review-copy.ts @@ -17,165 +17,77 @@ * 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']; +import type { DailyReviewRange } from './daily-review-view-state.js'; 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; + setup: string; + runNow: string; + running: string; + manage: string; + refresh: string; + loading: string; + loadFailed: string; + retry: string; + }; + range: { + label: string; + options: ReadonlyArray; + earlier: string; + later: string; + current: string; }; overview: { - ariaLabel: (label: string) => string; - refreshFailed: (error: string) => string; - retry: string; - conversations: string; - requests: string; + tasks: string; + modelCalls: 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; + schedule: { + active: string; + paused: string; + completed: string; + expired: string; + }; + history: { + title: string; + count: (count: number) => string; + open: string; + migrated: string; + migrationNote: string; + }; + activity: { + title: string; + count: (count: number) => string; + emptyTitle: string; }; } -const DAILY_REVIEW_COPY = { +const 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} 次`, + title: '每日回顾', setup: '设置每日回顾', runNow: '立即生成回顾', running: '正在启动…', manage: '管理日程', refresh: '刷新', loading: '正在载入每日回顾…', loadFailed: '每日回顾暂时无法载入。', retry: '重试', }, + range: { label: '活动范围', options: [[1, '今日'], [7, '最近 7 天'], [30, '最近 30 天']], earlier: '查看更早一天', later: '查看更晚一天', current: '回到当前范围' }, + overview: { tasks: '任务', modelCalls: '模型调用', tokens: 'Token', cost: '费用' }, + schedule: { active: '每日回顾已启用', paused: '每日回顾已暂停', completed: '每日回顾已完成', expired: '每日回顾已过期' }, + history: { title: '回顾报告', count: (count) => `${count} 份`, open: '打开报告任务', migrated: '已迁移', migrationNote: '旧报告已迁移为普通任务和 Artifact' }, + activity: { title: '任务活动', count: (count) => `${count} 个任务`, emptyTitle: '这个范围内还没有任务' }, }, 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'}`, + title: 'Daily Review', setup: 'Set up Daily Review', runNow: 'Run review now', running: 'Starting…', manage: 'Manage schedule', refresh: 'Refresh', loading: 'Loading Daily Review…', loadFailed: 'Daily Review could not be loaded.', retry: 'Retry', }, + range: { label: 'Activity range', options: [[1, 'Today'], [7, 'Last 7 days'], [30, 'Last 30 days']], earlier: 'View one day earlier', later: 'View one day later', current: 'Return to the current range' }, + overview: { tasks: 'Tasks', modelCalls: 'Model calls', tokens: 'Tokens', cost: 'Cost' }, + schedule: { active: 'Daily Review is active', paused: 'Daily Review is paused', completed: 'Daily Review is completed', expired: 'Daily Review is expired' }, + history: { title: 'Review reports', count: (count) => `${count} ${count === 1 ? 'report' : 'reports'}`, open: 'Open report task', migrated: 'Migrated', migrationNote: 'Earlier reports are ordinary tasks and artifacts now' }, + activity: { title: 'Task activity', count: (count) => `${count} ${count === 1 ? 'task' : 'tasks'}`, emptyTitle: 'No tasks in this range yet' }, }, } satisfies UiCatalog; export function getDailyReviewCopy(locale: UiLocale): DailyReviewCopy { - return DAILY_REVIEW_COPY[locale]; + return 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 index 45df6a744d..a85f2d397d 100644 --- a/packages/ui/src/daily-review-panel.tsx +++ b/packages/ui/src/daily-review-panel.tsx @@ -17,484 +17,300 @@ * 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 { useCallback, useEffect, useRef, useState } from 'react'; +import type { ScheduledTask } from '@maka/core/scheduled-task'; import { uiLocaleToIntlLocale } from '@maka/core/ui-locale'; import { - Banner, - Button, - Divider, + Button as UiButton, EmptyState, Heading, - HStack, + IconButton, List, ListItem, SegmentedControl, SegmentedControlItem, - Skeleton, - StackItem, - Toolbar, + Spinner, + StatusDot, 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 type { DailyReviewProjectionBridge } from './module-panel-types.js'; +import type { ModuleHubHeader } from './module-hub-selector.js'; +import { formatScheduledTaskRecurrence } from './scheduled-task-helpers.js'; import { useUiLocale } from './locale-context.js'; -import { useMountedRef } from './use-mounted-ref.js'; +import { ChevronLeft, ChevronRight, ICON_SIZE, RefreshCcw, Sun } from './icons.js'; +import { ModulePage } from './primitives/module-page.js'; import { - createDailyReviewActivityState, - dailyReviewActivityReducer, - shiftDailyReviewScope, - type DailyReviewScope, + dailyReviewRangeBounds, + dailyReviewManualIntent, + type DailyReviewRange, + type DailyReviewViewState, } from './daily-review-view-state.js'; -type DailyReviewRoute = - | { kind: 'activity' } - | { kind: 'report'; archive: DailyReviewArchive }; - -type DailyReviewArchiveState = +type LoadState = | { status: 'loading' } - | { status: 'ready'; archives: DailyReviewArchiveSummary[] } - | { status: 'error'; error: string }; + | { status: 'error' } + | { status: 'ready'; value: DailyReviewViewState }; export function DailyReviewPanel(props: { - bridge: DailyReviewBridge; + bridge: DailyReviewProjectionBridge; + revision?: number; + task?: ScheduledTask; hubHeader?: ModuleHubHeader; - onSelectSession?: (sessionId: string) => void; - onCopyMarkdown?: (input: DailyReviewMarkdownActionInput) => Promise | void; - onAppendMarkdown?: (input: DailyReviewMarkdownActionInput) => Promise | void; - onSaveMarkdown?: (input: DailyReviewMarkdownActionInput) => Promise | void; + canSetUp: boolean; + onSetUp?(): void; + onManageSchedule?(): void; + onRunNow?(intentBody: string): Promise | void; + onSelectSession?(sessionId: string): 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, setRange] = useState(1); + const [offsetDays, setOffsetDays] = useState(0); + const [loadState, setLoadState] = useState({ status: 'loading' }); + const [runPending, setRunPending] = useState(false); + const loadGenerationRef = useRef(0); + const mountedRef = useRef(true); - 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(() => () => { + mountedRef.current = false; + }, []); - 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), - }); + const load = useCallback(async () => { + const generation = ++loadGenerationRef.current; + setLoadState({ status: 'loading' }); + try { + const value = await props.bridge.load(range, offsetDays); + if (mountedRef.current && generation === loadGenerationRef.current) { + setLoadState({ status: 'ready', value }); + } + } catch { + if (mountedRef.current && generation === loadGenerationRef.current) { + setLoadState({ status: 'error' }); } - }); - 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); - } + }, [offsetDays, props.bridge, range]); - 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); - } - } + useEffect(() => { + void load(); + }, [load, props.revision, props.task?.updatedAt]); - async function generateAnalysis() { - const runOnce = props.bridge.runOnce; - const getArchive = props.bridge.getArchive; - if (!runOnce || !getArchive || pendingAction !== null) return; - setPendingAction('generate'); - setActionError(null); + async function runNow() { + if (!props.onRunNow || runPending) return; + setRunPending(true); 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)); + await props.onRunNow(dailyReviewManualIntent(range, Date.now(), offsetDays)); + if (mountedRef.current) await load(); } finally { - if (mounted.current) setPendingAction(null); + if (mountedRef.current) setRunPending(false); } } - 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 view = loadState.status === 'ready' ? loadState.value : undefined; + const reportSessionIds = new Set(view?.reports.map((report) => report.sessionId)); + const activitySessions = view?.sessions.filter( + (session) => !reportSessionIds.has(session.sessionId), + ) ?? []; + const formatter = new Intl.NumberFormat(uiLocaleToIntlLocale(locale)); + const costFormatter = new Intl.NumberFormat(uiLocaleToIntlLocale(locale), { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 4, + }); + const dateFormatter = new Intl.DateTimeFormat(uiLocaleToIntlLocale(locale), { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + const rangeDateFormatter = new Intl.DateTimeFormat(uiLocaleToIntlLocale(locale), { + month: 'short', + day: 'numeric', + }); + const displayedBounds = dailyReviewRangeBounds(range, Date.now(), offsetDays); + const displayedRange = range === 1 + ? rangeDateFormatter.format(displayedBounds.from) + : `${rangeDateFormatter.format(displayedBounds.from)} – ${rangeDateFormatter.format(displayedBounds.to - 1)}`; - const primaryAction = currentArchive?.status === 'ok' ? ( -