| /* |
| * 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 { randomUUID } from 'node:crypto'; |
| import { DEFAULT_SESSION_NAME, normalizeUserSessionName } from '@maka/core/session-name'; |
| import { isExecutorId } from '@maka/core/executor-id'; |
| import { |
| isSessionBlockedReason, |
| isSessionConversationCopy, |
| isSubagentSessionParent, |
| isSubagentSessionRuntime, |
| isSubagentSessionSpawn, |
| isSessionStatus, |
| isWorkHubCoordinationSessionId, |
| subagentSessionRuntimeSummary, |
| WORKHUB_COORDINATION_SESSION_ROLE, |
| isSessionToolProfile, |
| type SessionHeader, |
| type SessionConversationCopy, |
| type SessionSummary, |
| type SessionRole, |
| type UserMessage, |
| } from '@maka/core/session'; |
| import { isCollaborationMode } from '@maka/core/collaboration'; |
| import { DEFAULT_TOOL_MODE, isToolMode } from '@maka/core/tool-mode'; |
| import { isOrchestrationMode } from '@maka/core/orchestration'; |
| import { decodePersistedPermissionMode, isPermissionMode } from '@maka/core/permission'; |
| import type { PersistedValue } from '@maka/core/persisted-value'; |
| import { isSubagentWorkspaceBinding } from '@maka/core/subagent-workspace'; |
| import type { CreateSessionInput } from '@maka/core/runtime-inputs'; |
| import { assertSafeSessionId, isSafeSessionId } from './session-store-contract.js'; |
| |
| export function assertCoordinationIdentityPairing( |
| sessionId: string, |
| role: SessionRole | undefined, |
| ): void { |
| if (isWorkHubCoordinationSessionId(sessionId) !== (role === WORKHUB_COORDINATION_SESSION_ROLE)) { |
| throw new Error('WorkHub Coordination Session identity and role must be claimed together'); |
| } |
| } |
| |
| export function buildSessionHeader( |
| workspaceRoot: string, |
| input: CreateSessionInput & { readonly role?: SessionRole }, |
| sessionId: string = randomUUID(), |
| conversationCopy?: SessionConversationCopy, |
| ): SessionHeader { |
| if ( |
| input.projectId !== undefined && |
| input.projectId !== null && |
| (typeof input.projectId !== 'string' || input.projectId.length === 0) |
| ) { |
| throw new Error('Invalid project id'); |
| } |
| const now = Date.now(); |
| assertSafeSessionId(sessionId); |
| assertCoordinationIdentityPairing(sessionId, input.role); |
| const name = |
| input.name === undefined ? DEFAULT_SESSION_NAME : normalizeRequiredSessionName(input.name); |
| const header: SessionHeader = { |
| id: sessionId, |
| ...(input.role === undefined ? {} : { role: input.role }), |
| workspaceRoot, |
| cwd: input.cwd, |
| ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), |
| createdAt: now, |
| name, |
| titleIsManual: false, |
| isFlagged: false, |
| labels: input.labels ?? [], |
| isArchived: false, |
| status: input.status ?? 'active', |
| ...(input.blockedReason ? { blockedReason: input.blockedReason } : {}), |
| statusUpdatedAt: now, |
| ...(input.parentSessionId ? { parentSessionId: input.parentSessionId } : {}), |
| ...(input.branchOfTurnId ? { branchOfTurnId: input.branchOfTurnId } : {}), |
| ...(input.subagentParent ? { subagentParent: input.subagentParent } : {}), |
| ...(input.subagentRuntime ? { subagentRuntime: input.subagentRuntime } : {}), |
| ...(input.subagentSpawn ? { subagentSpawn: input.subagentSpawn } : {}), |
| ...(input.subagentWorkspace ? { subagentWorkspace: input.subagentWorkspace } : {}), |
| ...(conversationCopy ? { conversationCopy } : {}), |
| ...(input.revisionRootSessionId ? { revisionRootSessionId: input.revisionRootSessionId } : {}), |
| ...(input.revisionParentSessionId |
| ? { revisionParentSessionId: input.revisionParentSessionId } |
| : {}), |
| ...(input.revisionOfTurnId ? { revisionOfTurnId: input.revisionOfTurnId } : {}), |
| ...(input.revisionIndex !== undefined ? { revisionIndex: input.revisionIndex } : {}), |
| ...(input.revisionState ? { revisionState: input.revisionState } : {}), |
| hasUnread: false, |
| backend: input.executorId ? 'plugin-executor' : 'ai-sdk', |
| ...(input.executorId ? { executorId: input.executorId } : {}), |
| ...(input.llmConnectionId === undefined ? {} : { llmConnectionId: input.llmConnectionId }), |
| llmConnectionSlug: input.llmConnectionSlug, |
| // A subagent Session's route is chosen by the spawn that created it and is |
| // never re-targeted, so it is born frozen. Every other Session freezes on |
| // its first user Message. |
| connectionLocked: input.subagentParent !== undefined, |
| model: input.model ?? 'default', |
| ...(input.toolProfile !== undefined ? { toolProfile: input.toolProfile } : {}), |
| toolMode: input.toolMode ?? DEFAULT_TOOL_MODE, |
| permissionMode: input.permissionMode, |
| collaborationMode: input.collaborationMode ?? 'agent', |
| orchestrationMode: input.orchestrationMode ?? 'default', |
| ...(input.thinkingLevel !== undefined ? { thinkingLevel: input.thinkingLevel } : {}), |
| // Born on the ledger: a Session created here records its execution facts as |
| // RuntimeEvents from its first turn, so there is no transcript to convert. |
| // Only an imported transcript (staged at 0) and a Session written before |
| // this field existed have anything for the converter to do. |
| transcriptLedgerVersion: 1, |
| schemaVersion: 1, |
| }; |
| assertValidSessionLineage(header); |
| return header; |
| } |
| |
| function normalizeRequiredSessionName(name: string): string { |
| const normalized = normalizeUserSessionName(name); |
| if (!normalized.ok) throw new Error(normalized.error); |
| return normalized.value; |
| } |
| |
| /** Validate and normalize a current SessionHeader before canonical persistence. */ |
| export function normalizeSessionHeader( |
| header: SessionHeader, |
| sessionId: string = header.id, |
| ): SessionHeader { |
| const valid = |
| header.id === sessionId && |
| (header.role === undefined || header.role === WORKHUB_COORDINATION_SESSION_ROLE) && |
| typeof header.workspaceRoot === 'string' && |
| typeof header.cwd === 'string' && |
| (header.projectId === undefined || |
| header.projectId === null || |
| (typeof header.projectId === 'string' && header.projectId.length > 0)) && |
| isFiniteNumber(header.createdAt) && |
| (header.lastMessageAt === undefined || isFiniteNumber(header.lastMessageAt)) && |
| typeof header.name === 'string' && |
| typeof header.titleIsManual === 'boolean' && |
| typeof header.isFlagged === 'boolean' && |
| Array.isArray(header.labels) && |
| header.labels.every((label) => typeof label === 'string') && |
| typeof header.isArchived === 'boolean' && |
| !Object.prototype.hasOwnProperty.call(header, 'archivedAt') && |
| isSessionStatus(header.status) && |
| (header.blockedReason === undefined || isSessionBlockedReason(header.blockedReason)) && |
| (header.statusUpdatedAt === undefined || isFiniteNumber(header.statusUpdatedAt)) && |
| (header.parentSessionId === undefined || typeof header.parentSessionId === 'string') && |
| (header.branchOfTurnId === undefined || typeof header.branchOfTurnId === 'string') && |
| isValidConversationCopyLineage(header) && |
| isValidRevisionLineage(header) && |
| isValidSubagentSessionLineage(header) && |
| isValidSessionExternalOrigin(header.externalOrigin) && |
| (header.lastReadMessageId === undefined || typeof header.lastReadMessageId === 'string') && |
| typeof header.hasUnread === 'boolean' && |
| isPersistedBackendKind(header.backend) && |
| isValidExecutorSelection(header) && |
| (header.llmConnectionId === undefined || |
| (typeof header.llmConnectionId === 'string' && header.llmConnectionId.length > 0)) && |
| typeof header.llmConnectionSlug === 'string' && |
| typeof header.connectionLocked === 'boolean' && |
| typeof header.model === 'string' && |
| (header.toolProfile === undefined || isSessionToolProfile(header.toolProfile)) && |
| (header.toolMode === undefined || isToolMode(header.toolMode)) && |
| isPermissionMode(header.permissionMode) && |
| isCollaborationMode(header.collaborationMode) && |
| isOrchestrationMode(header.orchestrationMode) && |
| (header.transcriptLedgerVersion === undefined || |
| header.transcriptLedgerVersion === 0 || |
| header.transcriptLedgerVersion === 1) && |
| header.schemaVersion === 1; |
| if (!valid) { |
| throw new Error(`Invalid session header for session ${sessionId}: malformed fields`); |
| } |
| const normalizedName = normalizeSessionName(header.name); |
| if (header.blockedReason === undefined) { |
| const { blockedReason: _blockedReason, ...withoutBlockedReason } = header; |
| return { ...withoutBlockedReason, name: normalizedName }; |
| } |
| return { ...header, name: normalizedName }; |
| } |
| |
| export function decodePersistedSessionHeader( |
| persisted: PersistedValue<SessionHeader>, |
| sessionId?: string, |
| ): SessionHeader { |
| const header = persisted as unknown as SessionHeader; |
| const permissionMode = decodePersistedPermissionMode(header.permissionMode); |
| if (permissionMode === undefined) { |
| return normalizeSessionHeader(header, sessionId ?? header.id); |
| } |
| return normalizeSessionHeader( |
| permissionMode === header.permissionMode ? header : { ...header, permissionMode }, |
| sessionId ?? header.id, |
| ); |
| } |
| |
| function isValidSessionExternalOrigin(origin: SessionHeader['externalOrigin']): boolean { |
| if (origin === undefined) return true; |
| return ( |
| typeof origin === 'object' && |
| origin !== null && |
| typeof origin.adapterId === 'string' && |
| origin.adapterId.length > 0 && |
| typeof origin.sourceSessionId === 'string' && |
| origin.sourceSessionId.length > 0 |
| ); |
| } |
| |
| function isValidRevisionLineage(header: SessionHeader): boolean { |
| const values = [ |
| header.revisionRootSessionId, |
| header.revisionParentSessionId, |
| header.revisionOfTurnId, |
| header.revisionIndex, |
| header.revisionState, |
| ]; |
| if (values.every((value) => value === undefined)) return true; |
| return ( |
| typeof header.revisionRootSessionId === 'string' && |
| isSafeSessionId(header.revisionRootSessionId) && |
| typeof header.revisionParentSessionId === 'string' && |
| isSafeSessionId(header.revisionParentSessionId) && |
| typeof header.revisionOfTurnId === 'string' && |
| header.revisionOfTurnId.length > 0 && |
| header.revisionOfTurnId.length <= 128 && |
| Number.isSafeInteger(header.revisionIndex) && |
| header.revisionIndex! >= 2 && |
| (header.revisionState === 'preparing' || header.revisionState === 'committed') |
| ); |
| } |
| |
| function assertValidSessionLineage(header: SessionHeader): void { |
| if (!isValidConversationCopyLineage(header)) { |
| throw new Error('Invalid Session conversation-copy lineage'); |
| } |
| if (!isValidRevisionLineage(header)) { |
| throw new Error('Invalid session revision lineage'); |
| } |
| if (!isValidSubagentSessionLineage(header)) { |
| throw new Error('Invalid subagent session lineage'); |
| } |
| } |
| |
| function isValidConversationCopyLineage(header: SessionHeader): boolean { |
| const copy = header.conversationCopy; |
| if (copy === undefined) return true; |
| if ( |
| !isSessionConversationCopy(copy) || |
| !isSafeSessionId(copy.sourceSessionId) || |
| copy.sourceSessionId === header.id || |
| header.subagentParent !== undefined |
| ) { |
| return false; |
| } |
| if (copy.kind === 'branch') { |
| const revisionClear = |
| header.revisionRootSessionId === undefined && |
| header.revisionParentSessionId === undefined && |
| header.revisionOfTurnId === undefined && |
| header.revisionIndex === undefined && |
| header.revisionState === undefined; |
| if (!revisionClear || header.parentSessionId !== copy.sourceSessionId) { |
| return false; |
| } |
| // An empty copy (absent `sourceTurnId`) records provenance |
| // (`parentSessionId`) but must not fabricate a `branchOfTurnId`, and is only |
| // valid for a side conversation; a through-turn copy must anchor to it. |
| return copy.sourceTurnId === undefined |
| ? header.branchOfTurnId === undefined && copy.intent === 'side_conversation' |
| : header.branchOfTurnId === copy.sourceTurnId; |
| } |
| // Revision copies always carry a turn boundary (enforced at decode). |
| return ( |
| copy.sourceTurnId !== undefined && |
| header.revisionParentSessionId === copy.sourceSessionId && |
| header.revisionOfTurnId === copy.sourceTurnId |
| ); |
| } |
| |
| function isValidSubagentSessionLineage(header: SessionHeader): boolean { |
| if (header.subagentParent === undefined) { |
| return ( |
| header.subagentRuntime === undefined && |
| header.subagentSpawn === undefined && |
| header.subagentWorkspace === undefined |
| ); |
| } |
| if ( |
| !isSubagentSessionParent(header.subagentParent) || |
| !isSafeSessionId(header.subagentParent.parentSessionId) || |
| header.parentSessionId !== undefined || |
| header.branchOfTurnId !== undefined || |
| header.revisionRootSessionId !== undefined || |
| header.revisionParentSessionId !== undefined || |
| header.revisionOfTurnId !== undefined || |
| header.revisionIndex !== undefined || |
| header.revisionState !== undefined |
| ) { |
| return false; |
| } |
| return ( |
| (header.subagentRuntime === undefined && |
| header.subagentSpawn === undefined && |
| header.subagentWorkspace === undefined) || |
| (isSubagentSessionRuntime(header.subagentRuntime) && |
| isSubagentSessionSpawn(header.subagentSpawn) && |
| (header.subagentWorkspace === undefined || |
| isSubagentWorkspaceBinding(header.subagentWorkspace))) |
| ); |
| } |
| |
| /** |
| * Decode guard for a durable session header. `'fake'` stays accepted: |
| * narrowing it here would make every session written by a build that shipped |
| * FakeBackend fail `normalizeSessionHeader` and read back as malformed (#3211). |
| */ |
| function isPersistedBackendKind(value: unknown): value is SessionHeader['backend'] { |
| return value === 'ai-sdk' || value === 'plugin-executor' || value === 'fake'; |
| } |
| |
| function isValidExecutorSelection(header: SessionHeader): boolean { |
| if (header.backend === 'plugin-executor') { |
| return isExecutorId(header.executorId); |
| } |
| return header.executorId === undefined; |
| } |
| |
| function isFiniteNumber(value: unknown): value is number { |
| return typeof value === 'number' && Number.isFinite(value); |
| } |
| |
| export function toSummary(header: SessionHeader): SessionSummary { |
| const lastMessageAt = header.lastMessageAt; |
| return { |
| id: header.id, |
| cwd: header.cwd, |
| ...(header.projectId !== undefined ? { projectId: header.projectId } : {}), |
| name: normalizeSessionName(header.name), |
| isFlagged: header.isFlagged, |
| isArchived: header.isArchived, |
| labels: header.labels, |
| hasUnread: header.hasUnread, |
| lastMessageAt, |
| status: header.status, |
| ...(header.blockedReason ? { blockedReason: header.blockedReason } : {}), |
| ...(header.statusUpdatedAt !== undefined ? { statusUpdatedAt: header.statusUpdatedAt } : {}), |
| ...(header.parentSessionId ? { parentSessionId: header.parentSessionId } : {}), |
| ...(header.branchOfTurnId ? { branchOfTurnId: header.branchOfTurnId } : {}), |
| ...(header.subagentParent ? { subagentParent: header.subagentParent } : {}), |
| ...(header.subagentRuntime |
| ? { |
| subagentRuntime: subagentSessionRuntimeSummary(header.subagentRuntime), |
| } |
| : {}), |
| ...(header.subagentWorkspace ? { subagentWorkspace: header.subagentWorkspace } : {}), |
| ...(header.revisionRootSessionId |
| ? { revisionRootSessionId: header.revisionRootSessionId } |
| : {}), |
| ...(header.revisionParentSessionId |
| ? { revisionParentSessionId: header.revisionParentSessionId } |
| : {}), |
| ...(header.revisionOfTurnId ? { revisionOfTurnId: header.revisionOfTurnId } : {}), |
| ...(header.revisionIndex !== undefined ? { revisionIndex: header.revisionIndex } : {}), |
| ...(header.revisionState ? { revisionState: header.revisionState } : {}), |
| backend: header.backend, |
| ...(header.executorId ? { executorId: header.executorId } : {}), |
| ...(header.llmConnectionId === undefined ? {} : { llmConnectionId: header.llmConnectionId }), |
| llmConnectionSlug: header.llmConnectionSlug, |
| connectionLocked: header.connectionLocked, |
| model: header.model, |
| permissionMode: header.permissionMode, |
| collaborationMode: header.collaborationMode ?? 'agent', |
| orchestrationMode: header.orchestrationMode ?? 'default', |
| ...(header.thinkingLevel !== undefined ? { thinkingLevel: header.thinkingLevel } : {}), |
| }; |
| } |
| |
| export function normalizeSessionName(name: string): string { |
| return name === 'New Session' ? DEFAULT_SESSION_NAME : name; |
| } |
| |
| export function createUserMessage(input: { |
| turnId: string; |
| text: string; |
| displayText?: string; |
| attachments?: UserMessage['attachments']; |
| inlineReferences?: UserMessage['inlineReferences']; |
| }): UserMessage { |
| return { |
| type: 'user', |
| id: randomUUID(), |
| turnId: input.turnId, |
| ts: Date.now(), |
| text: input.text, |
| ...(input.displayText !== undefined ? { displayText: input.displayText } : {}), |
| attachments: input.attachments, |
| ...(input.inlineReferences !== undefined ? { inlineReferences: input.inlineReferences } : {}), |
| }; |
| } |