From 3da0b954497967cfd17c820080a6d1c5912fa76b Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:47:50 +0800 Subject: [PATCH 1/7] fix(storage): migrate legacy session statuses Generated-by: Codex --- .../sqlite-session-metadata-store.test.ts | 139 ++++++++++++++++++ .../src/sqlite-session-metadata-schema.ts | 19 ++- 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index a2291c29de..5c323f0fcb 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -133,6 +133,145 @@ describe('SqliteSessionMetadataStore', () => { } }); + test('migrates v24 legacy session statuses to active exactly once', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-status-v24-')); + const path = join(root, 'state.sqlite'); + try { + const setup = createSqliteSessionMetadataStore(path, { now: () => 10 }); + for (const id of ['legacy-review', 'legacy-done', 'legacy-unchanged']) { + await setup.create( + fullHeader({ + id, + status: 'active', + blockedReason: undefined, + statusUpdatedAt: id === 'legacy-done' ? 404 : 303, + }), + ); + } + setup.close(); + + const legacy = new DatabaseSync(path); + try { + legacy + .prepare( + ` + UPDATE session_metadata + SET status = ?, metadata_version = ?, committed_at = ? + WHERE session_id = ? + `, + ) + .run('review', 7, 100, 'legacy-review'); + legacy + .prepare( + ` + UPDATE session_metadata + SET + payload_json = json_set(payload_json, '$.status', ?), + metadata_version = ?, + committed_at = ? + WHERE session_id = ? + `, + ) + .run('done', 11, 4_000_000_000_000, 'legacy-done'); + legacy + .prepare( + ` + UPDATE session_metadata + SET metadata_version = ?, committed_at = ? + WHERE session_id = ? + `, + ) + .run(13, 300, 'legacy-unchanged'); + legacy + .prepare( + `UPDATE session_metadata_schema SET version = 24 WHERE scope = 'session_metadata'`, + ) + .run(); + } finally { + legacy.close(); + } + + const migrated = createSqliteSessionMetadataStore(path, { now: () => 20 }); + try { + assert.equal(migrated.schemaVersion(), 25); + assert.deepEqual((await migrated.read('legacy-review')).header.status, 'active'); + assert.deepEqual((await migrated.read('legacy-done')).header.status, 'active'); + assert.equal((await migrated.read('legacy-review')).metadataVersion, 8); + assert.equal((await migrated.read('legacy-done')).metadataVersion, 12); + assert.equal((await migrated.read('legacy-unchanged')).metadataVersion, 13); + assert.ok((await migrated.read('legacy-review')).committedAt >= 100); + assert.equal((await migrated.read('legacy-done')).committedAt, 4_000_000_000_000); + assert.equal((await migrated.read('legacy-review')).header.statusUpdatedAt, 303); + assert.equal((await migrated.read('legacy-done')).header.statusUpdatedAt, 404); + const page = await migrated.listCatalogPage({}, undefined, 10); + assert.deepEqual(page.records.map((record) => record.header.id).sort(), [ + 'legacy-done', + 'legacy-review', + 'legacy-unchanged', + ]); + assert.equal(page.hasMore, false); + } finally { + migrated.close(); + } + + const persisted = new DatabaseSync(path); + try { + const rows = ( + persisted + .prepare( + ` + SELECT + session_id AS sessionId, + status, + json_extract(payload_json, '$.status') AS payloadStatus, + metadata_version AS metadataVersion + FROM session_metadata + ORDER BY session_id + `, + ) + .all() as Array<{ + readonly sessionId: string; + readonly status: string; + readonly payloadStatus: string; + readonly metadataVersion: number; + }> + ).map((row) => ({ ...row })); + assert.deepEqual(rows, [ + { + sessionId: 'legacy-done', + status: 'active', + payloadStatus: 'active', + metadataVersion: 12, + }, + { + sessionId: 'legacy-review', + status: 'active', + payloadStatus: 'active', + metadataVersion: 8, + }, + { + sessionId: 'legacy-unchanged', + status: 'active', + payloadStatus: 'active', + metadataVersion: 13, + }, + ]); + } finally { + persisted.close(); + } + + const reopened = createSqliteSessionMetadataStore(path, { now: () => 30 }); + try { + assert.equal((await reopened.read('legacy-review')).metadataVersion, 8); + assert.equal((await reopened.read('legacy-done')).metadataVersion, 12); + } finally { + reopened.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test('atomically retires a revision family with CAS and tombstone retries', async () => { const store = createSqliteSessionMetadataStore(':memory:', { now: () => 100 }); const root = fullHeader({ diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 51fd3f8379..5aa42da01e 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 24; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 25; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -897,6 +897,23 @@ const MIGRATIONS: ReadonlyMap = new Map([ ON agent_graph_epochs(root_session_id, epoch DESC); `, ], + [ + 25, + ` + UPDATE session_metadata + SET + status = 'active', + payload_json = json_set(payload_json, '$.status', 'active'), + metadata_version = metadata_version + 1, + committed_at = MAX( + committed_at, + CAST(strftime('%s', 'now') AS INTEGER) * 1000 + ) + WHERE + status IN ('review', 'done') + OR json_extract(payload_json, '$.status') IN ('review', 'done'); + `, + ], ]); export function configureSqliteSessionMetadataDatabase(db: DatabaseSync): void { From 3da8787a5994d16672bd839613023aac5d7b21d3 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:50:22 +0800 Subject: [PATCH 2/7] test(storage): preserve untouched migration timestamp Generated-by: Codex --- .../storage/src/__tests__/sqlite-session-metadata-store.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index 5c323f0fcb..ea84fff6c1 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -201,6 +201,7 @@ describe('SqliteSessionMetadataStore', () => { assert.equal((await migrated.read('legacy-unchanged')).metadataVersion, 13); assert.ok((await migrated.read('legacy-review')).committedAt >= 100); assert.equal((await migrated.read('legacy-done')).committedAt, 4_000_000_000_000); + assert.equal((await migrated.read('legacy-unchanged')).committedAt, 300); assert.equal((await migrated.read('legacy-review')).header.statusUpdatedAt, 303); assert.equal((await migrated.read('legacy-done')).header.statusUpdatedAt, 404); const page = await migrated.listCatalogPage({}, undefined, 10); From a85c955bca7fe7cf8a233238c1c7f634696ddfdf Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:54:21 +0800 Subject: [PATCH 3/7] fix(runtime-host): normalize legacy session statuses Generated-by: Codex --- .../src/__tests__/protocol.test.ts | 25 +++++++++++++++++++ .../session-catalog-protocol.test.ts | 19 ++++++++++++++ .../src/protocol/session-catalog.ts | 5 ++-- .../src/protocol/session-continuity.ts | 10 +++----- .../src/protocol/session-status.ts | 13 ++++++++++ 5 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 packages/runtime-host/src/protocol/session-status.ts diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 8b58b3b6c7..113aecbf56 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -162,6 +162,27 @@ describe('Runtime Host bootstrap protocol', () => { assert.throws(() => decodeHostFrame(oversized), isInvalidFrame); }); + test('normalizes legacy Session statuses in continuity snapshots', () => { + for (const status of ['review', 'done']) { + const decoded = decodeSessionContinuitySnapshot({ + ...continuitySnapshot('epoch-1'), + session: { ...continuitySnapshot('epoch-1').session, status }, + }); + assert.equal(decoded.session.status, 'active'); + } + }); + + test('rejects unknown Session statuses in continuity snapshots', () => { + assert.throws( + () => + decodeSessionContinuitySnapshot({ + ...continuitySnapshot('epoch-1'), + session: { ...continuitySnapshot('epoch-1').session, status: 'unknown' }, + }), + isInvalidSessionStatus, + ); + }); + test('decodes only privacy-normalized bounded subscription live frames', () => { const envelope = { kind: 'subscription.session_event' as const, @@ -1308,6 +1329,10 @@ function isInvalidFrame(error: unknown): boolean { return error instanceof RuntimeHostProtocolError && error.code === 'invalid_frame'; } +function isInvalidSessionStatus(error: unknown): boolean { + return error instanceof RuntimeHostProtocolError && error.message === 'Invalid Session status'; +} + function queuedMessage( text = 'adjust this turn', placement: 'current_turn' | 'next_turn' = 'current_turn', diff --git a/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts b/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts index 5df44aa5f2..41e20f7091 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts @@ -366,6 +366,21 @@ describe('Session catalog protocol', () => { ); }); + test('normalizes legacy Session statuses in catalog projections', () => { + for (const status of ['review', 'done']) { + const decoded = decodeSessionCatalogItem({ ...projection(), status }); + if ('kind' in decoded) assert.fail('Expected a Session catalog projection'); + assert.equal(decoded.status, 'active'); + } + }); + + test('rejects unknown Session statuses in catalog projections', () => { + assert.throws( + () => decodeSessionCatalogItem({ ...projection(), status: 'unknown' }), + isInvalidSessionStatus, + ); + }); + test('bounds pages and preserves revision-pinned continuation results', () => { const sessions = Array.from({ length: SESSION_CATALOG_PAGE_MAX_ITEMS }, (_, index) => projection({ id: `session-${index}` }), @@ -425,3 +440,7 @@ function projection(overrides: Partial = {}): SessionC function isProtocolError(error: unknown): boolean { return error instanceof RuntimeHostProtocolError; } + +function isInvalidSessionStatus(error: unknown): boolean { + return error instanceof RuntimeHostProtocolError && error.message === 'Invalid Session status'; +} diff --git a/packages/runtime-host/src/protocol/session-catalog.ts b/packages/runtime-host/src/protocol/session-catalog.ts index 39ff6cfc55..cf55e66c6b 100644 --- a/packages/runtime-host/src/protocol/session-catalog.ts +++ b/packages/runtime-host/src/protocol/session-catalog.ts @@ -4,7 +4,6 @@ import { isPermissionMode, type PermissionMode } from '@maka/core/permission'; import { isSessionStartMode, type SessionStartMode } from '@maka/core/explore-agent'; import { isSessionBlockedReason, - isSessionStatus, isSessionToolProfile, type SessionBlockedReason, type SessionStatus, @@ -26,6 +25,7 @@ import { } from './codec.js'; import { invalidProtocolFrame } from './errors.js'; import { defineHostPathOperation, defineOperation } from './operation-spec.js'; +import { decodeSessionStatus } from './session-status.js'; import { decodeWorkspaceProjection, decodeWorkspaceTarget, @@ -868,8 +868,7 @@ function optionalThinkingLevel( } function sessionStatus(value: unknown): SessionStatus { - if (!isSessionStatus(value)) throw invalidProtocolFrame('Invalid Session status'); - return value; + return decodeSessionStatus(value); } function backend(value: unknown): SessionCatalogProjection['backend'] { diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index 51819d5f94..953b4225ce 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -2,7 +2,7 @@ import { TOOL_ACTIVITY_KINDS, TOOL_OUTPUT_DELTA_MAX_CHARS } from '@maka/core/eve import type { ToolResultPreviewContent } from '@maka/core/events'; import { decodeToolResultPreviewContent } from '@maka/core/tool-result-preview'; import type { ToolActivityKind } from '@maka/core/events'; -import { isSessionStatus, type SessionStatus } from '@maka/core/session'; +import type { SessionStatus } from '@maka/core/session'; import { assertExactKeys, requireCount, @@ -12,6 +12,7 @@ import { requireRecord, } from './codec.js'; import { invalidProtocolFrame } from './errors.js'; +import { decodeSessionStatus } from './session-status.js'; import { decodeSessionInteractionProjection, type SessionInteractionProjection, @@ -883,7 +884,7 @@ function decodeSessionContinuityIdentity(value: unknown): SessionContinuityIdent return { sessionId: requireEntityId(record.sessionId, 'sessionId'), metadataRevision: requirePositiveCount(record.metadataRevision, 'metadataRevision'), - status: requireSessionLifecycleStatus(record.status), + status: decodeSessionStatus(record.status), createdAt: requireCount(record.createdAt, 'createdAt'), lastUsedAt: requireCount(record.lastUsedAt, 'lastUsedAt'), isArchived: record.isArchived, @@ -963,11 +964,6 @@ function requireToolActivityKind(value: unknown): ToolActivityKind { throw invalidProtocolFrame('Invalid Session tool activity kind'); } -function requireSessionLifecycleStatus(value: unknown): SessionLifecycleStatus { - if (isSessionStatus(value)) return value; - throw invalidProtocolFrame('Invalid Session lifecycle status'); -} - function requireAgentGraphChangedReason(value: unknown): AgentGraphChangedReason { if ( value === 'observation' || diff --git a/packages/runtime-host/src/protocol/session-status.ts b/packages/runtime-host/src/protocol/session-status.ts new file mode 100644 index 0000000000..48d7249456 --- /dev/null +++ b/packages/runtime-host/src/protocol/session-status.ts @@ -0,0 +1,13 @@ +import { isSessionStatus, type SessionStatus } from '@maka/core/session'; +import { invalidProtocolFrame } from './errors.js'; + +/** + * Accept status values written by legacy Runtime Hosts without expanding the + * current wire status shape. This is decode-only compatibility: all outgoing + * values remain current SessionStatus values. + */ +export function decodeSessionStatus(value: unknown): SessionStatus { + const normalized = value === 'review' || value === 'done' ? 'active' : value; + if (!isSessionStatus(normalized)) throw invalidProtocolFrame('Invalid Session status'); + return normalized; +} From 4915b70fd3c34e546afda4b2d5f6a6483c255908 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:58:22 +0800 Subject: [PATCH 4/7] fix(core): retire legacy session statuses Generated-by: Codex --- .../core/src/__tests__/session-status.test.ts | 18 ++++++++++++++++++ packages/core/src/session.ts | 10 ---------- packages/ui/src/conversation-copy.ts | 4 ++-- packages/ui/src/session-status-presentation.ts | 2 -- .../ui/stories/session-list-panel.stories.tsx | 16 ---------------- 5 files changed, 20 insertions(+), 30 deletions(-) create mode 100644 packages/core/src/__tests__/session-status.test.ts diff --git a/packages/core/src/__tests__/session-status.test.ts b/packages/core/src/__tests__/session-status.test.ts new file mode 100644 index 0000000000..632093356d --- /dev/null +++ b/packages/core/src/__tests__/session-status.test.ts @@ -0,0 +1,18 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { SESSION_STATUSES, isSessionStatus } from '../session.js'; + +describe('session status contract', () => { + it('accepts only the current ordered session status collection', () => { + assert.deepEqual(SESSION_STATUSES, [ + 'active', + 'running', + 'waiting_for_user', + 'blocked', + 'archived', + 'aborted', + ]); + assert.equal(isSessionStatus('review'), false); + assert.equal(isSessionStatus('done'), false); + }); +}); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 77c357275b..febe64e75a 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -34,22 +34,12 @@ export { DEEP_RESEARCH_SESSION_LABEL, isDeepResearchSession } from './explore-ag * alongside `isArchived`; consolidating those two onto one authority is its own * change (#2984, PR 3) because it rewrites stored rows. * - * `review` and `done` have no writer in current source, but they stay: this - * list is read back out of storage, and narrowing it is a data migration, not a - * cleanup. `resolveLegacyStatus` in the JSONL importer (removed in #2656) let - * both values through into real SQLite stores verbatim, and `normalizeSession - * Header` throws on an unrecognised status for the WHOLE header — so one stored - * row carrying `done` fails an entire catalog page, not just its own row. - * Removing them needs a schema migration or a tolerant read, which is its own - * change with its own review. */ export const SESSION_STATUSES = [ 'active', 'running', 'waiting_for_user', 'blocked', - 'review', - 'done', 'archived', 'aborted', ] as const; diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 637967c47a..3a1fd2225e 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -430,7 +430,7 @@ const CONVERSATION_COPY = { revisionVersionsAriaLabel: '任务版本', revisionVersion: (current, total) => `版本 ${current} / ${total}`, previousRevision: '查看上一版本', nextRevision: '查看下一版本', }, sessions: { - status: { active: '可继续', running: '进行中', waiting_for_user: '等你确认', blocked: '需要处理', review: '待审核', done: '已完成', archived: '已归档', aborted: '已中止' }, + status: { active: '可继续', running: '进行中', waiting_for_user: '等你确认', blocked: '需要处理', archived: '已归档', aborted: '已中止' }, blockedReason: { NO_REAL_CONNECTION: '等待配置可用模型连接', auth: '需要重新登录', permission_required: '等待权限确认', tool_failed: '工具调用失败', unknown: '运行中断,可重试' }, listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: '任务操作', pin: '置顶', unpin: '取消置顶', rename: '重命名', archive: '归档', unarchive: '取消归档', delete: '删除', pinned: '置顶', recent: '最近', groupByTime: '按时间', groupByProject: '按项目', groupingAriaLabel: '任务分组方式', projectActionsAriaLabel: (name) => `${name} 项目操作`, projectNewTask: '新建任务', projectRename: '重命名', projectArchive: '归档', projectRestore: '恢复', projectRelink: '重新定位', projectUnavailable: '项目目录不可用', archivedProjects: '已归档项目', archivedProjectsAriaLabel: '展开已归档项目', worktreeAriaLabel: 'Git 工作树', promptRailAriaLabel: '按提问跳转', emptyPrompt: '(空提问)', jumpToPrompt: (preview) => `跳到提问:${preview}`, }, @@ -568,7 +568,7 @@ const CONVERSATION_COPY = { revisionVersionsAriaLabel: 'Task versions', revisionVersion: (current, total) => `Version ${current} of ${total}`, previousRevision: 'View previous version', nextRevision: 'View next version', }, sessions: { - status: { active: 'Ready', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Needs attention', review: 'Review', done: 'Done', archived: 'Archived', aborted: 'Stopped' }, + status: { active: 'Ready', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Needs attention', archived: 'Archived', aborted: 'Stopped' }, blockedReason: { NO_REAL_CONNECTION: 'Waiting for an available model connection', auth: 'Sign in again', permission_required: 'Waiting for permission', tool_failed: 'Tool call failed', unknown: 'Run interrupted; retry available' }, listAriaLabel: 'Task list', showMore: 'Show more', showMoreAriaLabel: (count) => `Show ${count} more tasks`, renameAriaLabel: 'Rename task', renameProjectTitle: 'Rename project', renameSubmit: 'Save', respondingAriaLabel: 'Responding', respondingTitle: 'This task is streaming a response', staleTitle: 'This task\'s model connection is unavailable; sending will switch to the default connection', staleAriaLabel: 'Stale task', stale: 'Stale', unreadAriaLabel: 'Unread messages', actionsAriaLabel: 'Task actions', pin: 'Pin', unpin: 'Unpin', rename: 'Rename', archive: 'Archive', unarchive: 'Unarchive', delete: 'Delete', pinned: 'Pinned', recent: 'Recent', groupByTime: 'By time', groupByProject: 'By project', groupingAriaLabel: 'Task grouping', projectActionsAriaLabel: (name) => `${name} project actions`, projectNewTask: 'New task', projectRename: 'Rename', projectArchive: 'Archive', projectRestore: 'Restore', projectRelink: 'Relocate', projectUnavailable: 'Project directory unavailable', archivedProjects: 'Archived projects', archivedProjectsAriaLabel: 'Expand archived projects', worktreeAriaLabel: 'Git worktree', promptRailAriaLabel: 'Jump by prompt', emptyPrompt: '(empty prompt)', jumpToPrompt: (preview) => `Jump to prompt: ${preview}`, }, diff --git a/packages/ui/src/session-status-presentation.ts b/packages/ui/src/session-status-presentation.ts index b99b1c8979..1b0d0b2270 100644 --- a/packages/ui/src/session-status-presentation.ts +++ b/packages/ui/src/session-status-presentation.ts @@ -42,8 +42,6 @@ const STATUS_SEMANTIC: Record = { running: 'active', waiting_for_user: 'attention', blocked: 'attention', - review: 'attention', - done: 'success', archived: 'neutral', aborted: 'neutral', }; diff --git a/packages/ui/stories/session-list-panel.stories.tsx b/packages/ui/stories/session-list-panel.stories.tsx index 2a2d9d2c5f..486bd0a445 100644 --- a/packages/ui/stories/session-list-panel.stories.tsx +++ b/packages/ui/stories/session-list-panel.stories.tsx @@ -184,22 +184,6 @@ const statusSessions = [ blockedReason: 'auth', lastMessageAt: NOW - 20 * 60 * 1000, }), - // `review` and `done` have no writer in current source, but stored rows can - // still carry them (see SESSION_STATUSES) and the rail has to draw them. They - // are also the two colours this change decided on purpose — attention and - // success — so the story that shows every status has to show them. - makeSession({ - id: 'status-review', - name: '待审核的文件 diff', - status: 'review', - lastMessageAt: NOW - 37 * 60 * 1000, - }), - makeSession({ - id: 'status-done', - name: '已完成的 smoke run', - status: 'done', - lastMessageAt: NOW - 2 * 60 * 60 * 1000, - }), makeSession({ id: 'status-archived', name: '归档的旧实验', From 28c1b7c5fc50d01ce7e5888e3ed18dea259db3b7 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:13:29 +0800 Subject: [PATCH 5/7] test: harden session status migration coverage Generated-by: Codex --- .../core/src/__tests__/session-status.test.ts | 3 + .../sqlite-session-metadata-store.test.ts | 97 ++++++++++++++++++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/packages/core/src/__tests__/session-status.test.ts b/packages/core/src/__tests__/session-status.test.ts index 632093356d..1fb9359417 100644 --- a/packages/core/src/__tests__/session-status.test.ts +++ b/packages/core/src/__tests__/session-status.test.ts @@ -12,6 +12,9 @@ describe('session status contract', () => { 'archived', 'aborted', ]); + for (const status of SESSION_STATUSES) { + assert.equal(isSessionStatus(status), true); + } assert.equal(isSessionStatus('review'), false); assert.equal(isSessionStatus('done'), false); }); diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index ea84fff6c1..2b1bc8ed27 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -136,15 +136,30 @@ describe('SqliteSessionMetadataStore', () => { test('migrates v24 legacy session statuses to active exactly once', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-session-status-v24-')); const path = join(root, 'state.sqlite'); + const sessionIds = ['legacy-review', 'legacy-done', 'legacy-both', 'legacy-unchanged']; + const migrationSnapshots = new Map< + string, + { + readonly committedAt: number; + readonly metadataVersion: number; + readonly statusUpdatedAt?: number; + } + >(); + const persistedRowsAfterMigration: Array<{ + readonly sessionId: string; + readonly status: string; + readonly payloadStatus: string; + readonly metadataVersion: number; + }> = []; try { const setup = createSqliteSessionMetadataStore(path, { now: () => 10 }); - for (const id of ['legacy-review', 'legacy-done', 'legacy-unchanged']) { + for (const id of sessionIds) { await setup.create( fullHeader({ id, status: 'active', blockedReason: undefined, - statusUpdatedAt: id === 'legacy-done' ? 404 : 303, + statusUpdatedAt: id === 'legacy-done' ? 404 : id === 'legacy-both' ? 505 : 303, }), ); } @@ -173,6 +188,19 @@ describe('SqliteSessionMetadataStore', () => { `, ) .run('done', 11, 4_000_000_000_000, 'legacy-done'); + legacy + .prepare( + ` + UPDATE session_metadata + SET + status = ?, + payload_json = json_set(payload_json, '$.status', ?), + metadata_version = ?, + committed_at = ? + WHERE session_id = ? + `, + ) + .run('review', 'done', 17, 500, 'legacy-both'); legacy .prepare( ` @@ -196,16 +224,29 @@ describe('SqliteSessionMetadataStore', () => { assert.equal(migrated.schemaVersion(), 25); assert.deepEqual((await migrated.read('legacy-review')).header.status, 'active'); assert.deepEqual((await migrated.read('legacy-done')).header.status, 'active'); + assert.deepEqual((await migrated.read('legacy-both')).header.status, 'active'); assert.equal((await migrated.read('legacy-review')).metadataVersion, 8); assert.equal((await migrated.read('legacy-done')).metadataVersion, 12); + assert.equal((await migrated.read('legacy-both')).metadataVersion, 18); assert.equal((await migrated.read('legacy-unchanged')).metadataVersion, 13); assert.ok((await migrated.read('legacy-review')).committedAt >= 100); assert.equal((await migrated.read('legacy-done')).committedAt, 4_000_000_000_000); + assert.ok((await migrated.read('legacy-both')).committedAt >= 500); assert.equal((await migrated.read('legacy-unchanged')).committedAt, 300); assert.equal((await migrated.read('legacy-review')).header.statusUpdatedAt, 303); assert.equal((await migrated.read('legacy-done')).header.statusUpdatedAt, 404); + assert.equal((await migrated.read('legacy-both')).header.statusUpdatedAt, 505); + for (const sessionId of sessionIds) { + const record = await migrated.read(sessionId); + migrationSnapshots.set(sessionId, { + committedAt: record.committedAt, + metadataVersion: record.metadataVersion, + statusUpdatedAt: record.header.statusUpdatedAt, + }); + } const page = await migrated.listCatalogPage({}, undefined, 10); assert.deepEqual(page.records.map((record) => record.header.id).sort(), [ + 'legacy-both', 'legacy-done', 'legacy-review', 'legacy-unchanged', @@ -237,7 +278,14 @@ describe('SqliteSessionMetadataStore', () => { readonly metadataVersion: number; }> ).map((row) => ({ ...row })); + persistedRowsAfterMigration.push(...rows); assert.deepEqual(rows, [ + { + sessionId: 'legacy-both', + status: 'active', + payloadStatus: 'active', + metadataVersion: 18, + }, { sessionId: 'legacy-done', status: 'active', @@ -263,11 +311,52 @@ describe('SqliteSessionMetadataStore', () => { const reopened = createSqliteSessionMetadataStore(path, { now: () => 30 }); try { - assert.equal((await reopened.read('legacy-review')).metadataVersion, 8); - assert.equal((await reopened.read('legacy-done')).metadataVersion, 12); + for (const sessionId of sessionIds) { + const record = await reopened.read(sessionId); + assert.deepEqual( + { + committedAt: record.committedAt, + metadataVersion: record.metadataVersion, + statusUpdatedAt: record.header.statusUpdatedAt, + }, + migrationSnapshots.get(sessionId), + ); + } } finally { reopened.close(); } + + const reopenedPersisted = new DatabaseSync(path); + try { + const rows = ( + reopenedPersisted + .prepare( + ` + SELECT + session_id AS sessionId, + status, + json_extract(payload_json, '$.status') AS payloadStatus + FROM session_metadata + ORDER BY session_id + `, + ) + .all() as Array<{ + readonly sessionId: string; + readonly status: string; + readonly payloadStatus: string; + }> + ).map((row) => ({ ...row })); + assert.deepEqual( + rows, + persistedRowsAfterMigration.map(({ sessionId, status, payloadStatus }) => ({ + sessionId, + status, + payloadStatus, + })), + ); + } finally { + reopenedPersisted.close(); + } } finally { await rm(root, { recursive: true, force: true }); } From 23a2a561a5cb7e145a806b932f32ed994d5417a7 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:32:15 +0800 Subject: [PATCH 6/7] fix(storage): preserve migration timestamp precision Generated-by: Codex --- packages/runtime-host/src/protocol/session-catalog.ts | 6 +----- .../src/__tests__/sqlite-session-metadata-store.test.ts | 5 +++-- packages/storage/src/sqlite-session-metadata-schema.ts | 1 + 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/runtime-host/src/protocol/session-catalog.ts b/packages/runtime-host/src/protocol/session-catalog.ts index cf55e66c6b..ed6f78a4da 100644 --- a/packages/runtime-host/src/protocol/session-catalog.ts +++ b/packages/runtime-host/src/protocol/session-catalog.ts @@ -644,7 +644,7 @@ export function decodeSessionCatalogProjection(value: unknown): SessionCatalogPr ...optionalEntityId(record, 'lastReadMessageId'), ...optionalTimestamp(record, 'lastMessageAt'), ...optionalText(record, 'lastMessagePreview', SESSION_CATALOG_PREVIEW_MAX_BYTES), - status: sessionStatus(record.status), + status: decodeSessionStatus(record.status), ...optionalBlockedReason(record), ...optionalTimestamp(record, 'statusUpdatedAt'), ...optionalEntityId(record, 'parentSessionId'), @@ -867,10 +867,6 @@ function optionalThinkingLevel( return { thinkingLevel: thinkingLevel(record.thinkingLevel) }; } -function sessionStatus(value: unknown): SessionStatus { - return decodeSessionStatus(value); -} - function backend(value: unknown): SessionCatalogProjection['backend'] { if (value !== 'ai-sdk' && value !== 'fake') { throw invalidProtocolFrame('Invalid Session backend'); diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index 2b1bc8ed27..247bbdf71d 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -219,6 +219,7 @@ describe('SqliteSessionMetadataStore', () => { legacy.close(); } + const migrationStartedAt = Date.now(); const migrated = createSqliteSessionMetadataStore(path, { now: () => 20 }); try { assert.equal(migrated.schemaVersion(), 25); @@ -229,9 +230,9 @@ describe('SqliteSessionMetadataStore', () => { assert.equal((await migrated.read('legacy-done')).metadataVersion, 12); assert.equal((await migrated.read('legacy-both')).metadataVersion, 18); assert.equal((await migrated.read('legacy-unchanged')).metadataVersion, 13); - assert.ok((await migrated.read('legacy-review')).committedAt >= 100); + assert.ok((await migrated.read('legacy-review')).committedAt >= migrationStartedAt); assert.equal((await migrated.read('legacy-done')).committedAt, 4_000_000_000_000); - assert.ok((await migrated.read('legacy-both')).committedAt >= 500); + assert.ok((await migrated.read('legacy-both')).committedAt >= migrationStartedAt); assert.equal((await migrated.read('legacy-unchanged')).committedAt, 300); assert.equal((await migrated.read('legacy-review')).header.statusUpdatedAt, 303); assert.equal((await migrated.read('legacy-done')).header.statusUpdatedAt, 404); diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 5aa42da01e..3c8c62a4da 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -908,6 +908,7 @@ const MIGRATIONS: ReadonlyMap = new Map([ committed_at = MAX( committed_at, CAST(strftime('%s', 'now') AS INTEGER) * 1000 + + CAST(substr(strftime('%f', 'now'), 4, 3) AS INTEGER) ) WHERE status IN ('review', 'done') From 91057e02229beb9f42129ec3ca520ab4845a4d3b Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 17 Aug 2026 19:07:02 +0800 Subject: [PATCH 7/7] fix(storage): use one migration clock read Generated-by: Codex --- packages/storage/src/sqlite-session-metadata-schema.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 3c8c62a4da..6f42a027dd 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -907,8 +907,7 @@ const MIGRATIONS: ReadonlyMap = new Map([ metadata_version = metadata_version + 1, committed_at = MAX( committed_at, - CAST(strftime('%s', 'now') AS INTEGER) * 1000 - + CAST(substr(strftime('%f', 'now'), 4, 3) AS INTEGER) + CAST(unixepoch('now', 'subsec') * 1000 AS INTEGER) ) WHERE status IN ('review', 'done')