* feat(workspace): stop a Session's running work before archiving it Archiving was a pure visibility write, so a Session archived mid-turn kept running out of sight. The Workspace registry now owns archive admission through two Host events: `workspace/session-activity` reports what still runs and refuses the plain archive as `workspace/session-active`; `workspace/session-stop`, requested with `stopActivity`, stops that work the way the user's own stop actions do before the archive write. The API Session Controller reports and stops turns, subagent descendants, and owned jobs and rejects any model step for an archived Session; the Schedule plugin reports and deletes active reminders. The sidebar turns the refusal into a stop-and-archive confirmation listing the Host's answer. * test(workspace): pin the widened archive signature and prove child liveness by heartbeat The Client inspect catalog spec pins archiveSession's new options parameter, and the archive e2e reads the Bash child's liveness from a heartbeat file instead of a pid: the Linux sandbox runs the tool in its own PID namespace, so the pid it reports is not comparable from the test. * test(workspace): cover the archive-admission providers and dialog to the per-file gate Drop the provider's unused disposer and the dialog's unreachable confirm guard, read reminders without a catch that no composition can reach, and add the cases the coverage lane found missing: a stop without a jobs registry, a looping lineage, a descendant whose projections reject, the dialog's Escape guard and non-Error reason, and a family merged by another provider. * fix(workspace): write the archive before stopping, gate subagent lineage, and delete reminders under the schedule barriers Review round on #4765. The registry now writes the archive set before it dispatches `workspace/session-stop`, so the durable set already gates every wake the stops induce; the controller's `agent/pre-step` gate also blocks subagent descendants of an archived Session and contains each cancel or kill on its own. The Schedule plugin's stop listener runs as a management delete in the agent's transaction queue between the same two persistence barriers the `schedule_delete` tool uses. The confirm dialog selects singular or plural English lines and keys list rows by index. Docs: archive-admission section with type-equiv blocks on the workspace subsystem page, write-first order and residual windows in the READMEs and the Agent Note, and the missing zh reminders sentence. * test(web): scope the question composer's New Session wait to the Sessions tree The wide sidebar's New Session button carries the same visible label as a fresh session row, so the unscoped `getByText` either settled on the button before the row existed or hit a strict-mode violation once the row rendered first, which is how the snapshots lane failed on ece68c2ab5. Waiting inside the Sessions tree names the row alone. * fix(schedule): answer archive admission from the owned runtime's fold, not the projection registry The `sessionProjections.snapshot` API is the Client projection view and is not a Host read path (review on #4765). `ScheduleRuntime.activeRecords()` folds the live log the way every drive does, and the archive-admission listeners ask the owned runtime for that fold: a session without a live agent or without an owned runtime has nothing armed to fire, so it reports nothing and has nothing to stop. Covers the runtime-less agent, a fold that turns unreadable at the persistence barrier, and the stopping/faulted runtime answering nothing. * refactor(subagent): report and stop subagent descendants from the Subagent runtime The `subagent` family of archive admission moves out of the API Session Controller into `dsh-subagent`, which records the lineage at delegation and owns the descriptor (review on #4765): `SubagentRuntime` answers `workspace/session-activity` with the running descendants, labelled from each child's descriptor through a live Session observation, and `workspace/session-stop` by cancelling each of them with the parent cause. The controller keeps turns, owned jobs, and the lineage step gate, and composes its provider through `ctx.plugin(SessionActivityProvider)` with declared injections instead of a bare installer call. `dsh-workspace` becomes an optional type-only peer of `dsh-subagent`. * refactor(jobs): answer archive admission for owned jobs from the job registry seam The `job` family moves out of the API Session Controller into `dsh-jobs` (review on #4765): the seam's constructor installs the `workspace/session-activity` and `workspace/session-stop` listeners for every implementation through the abstract `list` and `kill` alone, so the controller no longer knows the jobs vocabulary and keeps only the turn family and the archived-lineage step gate. `dsh-workspace` becomes an optional type-only peer of `dsh-jobs`. * refactor(workspace): leave SessionActivityKindMap empty and let each provider merge its family The workspace package no longer names any activity family (review on #4765): `SessionActivityKindMap` is empty, and each provider merges its own key from a module both its faces import — `turn` in the API Session Controller's types, `job` in the job registry seam's view, `subagent` in the Subagent runtime's control types, `schedule` in the Schedule plugin's types. The confirm dialog imports those modules for its cases and keeps the generic line for any other key; the registry and controller suites merge test-owned families instead of borrowing the providers'. * refactor(agent): report and stop the running turn from the Agent registry The `turn` family moves out of the API Session Controller into `dsh-agent` (review on #4765): the registry's constructor installs the `workspace/session-activity` and `workspace/session-stop` listeners for every Agent it publishes, answering with its own lookup and cancelling the turn with the user cause, and merges the `turn` key from its client-safe types. The controller keeps only the archived-lineage step gate, renamed `ArchivedSessionGate`. `dsh-workspace` becomes an optional type-only peer of `dsh-agent`; the workspace package depends on none of the providers, so the edge stays acyclic. Since #3127 a foreground Bash command is a registered job from its start, so the archive dialog lists it beside the turn; the e2e and its ARIA golden expect that row, and the capture keeps the running job by design.
511 lines
20 KiB
TypeScript
511 lines
20 KiB
TypeScript
/** Session Remote owner: cold reads, explicit Agent commands, and live control state. */
|
|
|
|
import { hostname } from 'node:os'
|
|
import { resolve } from 'node:path'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import type {} from '@deepseek-ai/dsh-fs'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import z from '@deepseek-ai/schemastery'
|
|
import { errorChain } from '@deepseek-ai/dsh-llm'
|
|
import type {} from '@deepseek-ai/dsh-client-file-upload'
|
|
import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command'
|
|
import type { SessionId } from '@deepseek-ai/dsh-session'
|
|
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
|
|
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
|
|
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
import {
|
|
ApiSessionAgentController,
|
|
inspectApiSession,
|
|
type ApiSessionAgentResult,
|
|
} from './agent.ts'
|
|
import { SessionCommandController } from './commands.ts'
|
|
import { SessionControlController } from './control.ts'
|
|
import { SessionHistoryController } from './history.ts'
|
|
import { SessionFileReferences } from './file-references.ts'
|
|
import { ApiSessionList } from './list.ts'
|
|
import { buildModelCatalog } from './catalog.ts'
|
|
import { installModelSelectionProjection } from './model-selection-projection.ts'
|
|
import { SessionSkillCatalog } from './skill-catalog.ts'
|
|
import { SessionMediaReferences } from './media-references.ts'
|
|
import { ArchivedSessionGate } from './archived-session-gate.ts'
|
|
import type {
|
|
ModelCatalog,
|
|
SessionWorkspacePathApplication,
|
|
SessionAttachmentRequest,
|
|
SessionAttachmentValue,
|
|
SessionCancelRequest,
|
|
SessionCancelValue,
|
|
SessionControlFrame,
|
|
SessionCreateRequest,
|
|
SessionCreateValue,
|
|
SessionFollowFrame,
|
|
SessionFollowRequest,
|
|
SessionForkRequest,
|
|
SessionForkValue,
|
|
SessionListRequest,
|
|
SessionListValue,
|
|
SessionOpenWorkspacePathRequest,
|
|
SessionOpenWorkspacePathValue,
|
|
SessionPage,
|
|
SessionPageRequest,
|
|
SessionPromptRequest,
|
|
SessionPromptValue,
|
|
SessionRenameRequest,
|
|
SessionRenameValue,
|
|
SessionSearchRequest,
|
|
SessionSearchValue,
|
|
SessionSelectModelRequest,
|
|
SessionSelectModelValue,
|
|
SessionProjectionsRequest,
|
|
SessionProjectionsValue,
|
|
SessionProjectionValues,
|
|
SessionUpdateQueueRequest,
|
|
SessionUpdateQueueValue,
|
|
} from './types.ts'
|
|
|
|
export type * from './types.ts'
|
|
export { ApiSessionNotFound } from './agent.ts'
|
|
export { SessionFileReferences } from './file-references.ts'
|
|
export { SessionSkillCatalog } from './skill-catalog.ts'
|
|
|
|
declare module '@deepseek-ai/cordis' {
|
|
interface Context {
|
|
/** Host Session business API and Remote namespace owner. */
|
|
sessionController: SessionController
|
|
}
|
|
}
|
|
|
|
/** Session Controller deployment policy. */
|
|
export interface Config {
|
|
/** Override platform desktop-opener detection. */
|
|
readonly nativeOpen?: boolean
|
|
}
|
|
|
|
/** Host integrations replaceable by direct unit tests. */
|
|
export interface SessionControllerInternals {
|
|
/** Native default-application handoff. */
|
|
readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
|
/** Native file-association query. */
|
|
readonly fileApplications?: typeof nativeFileApplications
|
|
/** Explicit registered-application handoff. */
|
|
readonly openFileApplication?: typeof openNativeFileApplication
|
|
/** Native file-manager handoff. */
|
|
readonly revealPath?: (path: string, signal: AbortSignal) => Promise<void>
|
|
/** Native handoff availability probe. */
|
|
readonly canOpenPath?: () => boolean
|
|
}
|
|
|
|
/** Host service backing the generated `ctx.remote.session` namespace. */
|
|
export class SessionController extends TypertRemoteService {
|
|
static inject = [
|
|
'agentDefaultModel',
|
|
'agents',
|
|
'attachments',
|
|
'fileUploads',
|
|
'fs',
|
|
'llm',
|
|
'sessions',
|
|
'sessionProjections',
|
|
'sessionQuery',
|
|
'typert',
|
|
'workspaceRegistry',
|
|
]
|
|
|
|
static Config: z<Config> = z.object({
|
|
nativeOpen: z.boolean(),
|
|
})
|
|
|
|
private readonly agents: ApiSessionAgentController
|
|
private readonly commands: SessionCommandController
|
|
private readonly controlState: SessionControlController
|
|
private readonly history: SessionHistoryController
|
|
private readonly listState: ApiSessionList
|
|
private readonly openPath: (path: string, signal: AbortSignal) => Promise<void>
|
|
private readonly fileApplications: typeof nativeFileApplications
|
|
private readonly openFileApplication: typeof openNativeFileApplication
|
|
private readonly revealPath: (path: string, signal: AbortSignal) => Promise<void>
|
|
private readonly canOpenPath: () => boolean
|
|
private readonly promotions = new Set<Promise<void>>()
|
|
|
|
/**
|
|
* @param ctx - Host context containing the Session capability assembly.
|
|
* @param config - native-opener deployment policy.
|
|
* @param internals - host integrations replaceable by direct unit tests.
|
|
*/
|
|
constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) {
|
|
super(ctx, 'sessionController', { namespace: 'session' })
|
|
installModelSelectionProjection(ctx)
|
|
this.agents = new ApiSessionAgentController(ctx)
|
|
this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
|
|
ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => {
|
|
const result = await this.agents.resolveAgent(sessionId)
|
|
if ('error' in result) throw result.error
|
|
return result.agent
|
|
}), 'session-controller: file-upload Agent resolver')
|
|
this.controlState = new SessionControlController(ctx)
|
|
// Registered before history so reverse-order teardown closes every
|
|
// follower before waiting for already-admitted promotions.
|
|
ctx.effect(() => async () => {
|
|
await Promise.allSettled([...this.promotions])
|
|
}, 'session-controller.promotions')
|
|
this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) })
|
|
this.listState = new ApiSessionList(ctx)
|
|
this.fileApplications = internals.fileApplications ?? nativeFileApplications
|
|
this.openFileApplication = internals.openFileApplication ?? openNativeFileApplication
|
|
this.openPath = internals.openPath ?? openNativeAssociatedPath
|
|
this.revealPath = internals.revealPath ?? revealNativePath
|
|
this.canOpenPath = internals.canOpenPath
|
|
?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
|
|
ctx.plugin(SessionFileReferences)
|
|
ctx.plugin(SessionMediaReferences)
|
|
ctx.plugin(SessionSkillCatalog)
|
|
// An archived Session, or a subagent descendant of one, runs no model step
|
|
// until it is restored; what it still runs is stopped by the owners that
|
|
// answer the Workspace registry's archive-admission events.
|
|
ctx.plugin(ArchivedSessionGate)
|
|
|
|
ctx.on('session/created', (session) => {
|
|
ctx.emit('api-session/added', this.listState.summaryFor(session))
|
|
})
|
|
ctx.on('session/disposed', (session) => {
|
|
ctx.emit('api-session/removed', session.id)
|
|
})
|
|
const publishAgentAvailability = ({ agent }: { agent: Agent }): undefined => {
|
|
if (ctx.sessions.get(agent.id) === agent.session) {
|
|
ctx.emit('api-session/added', this.listState.summaryFor(agent.session))
|
|
}
|
|
}
|
|
ctx.on('agent/created', publishAgentAvailability)
|
|
ctx.on('agent/disposed', publishAgentAvailability)
|
|
ctx.on('agent/status', ({ agent, status }) => {
|
|
ctx.emit('api-session/status', agent.id, status === 'running')
|
|
})
|
|
ctx.on('agent/error', ({ agent, error }) => {
|
|
ctx.emit('api-session/error', agent.id, errorChain(error))
|
|
})
|
|
ctx.on('session/event', (session, event) => {
|
|
if (event.type === 'request/header') {
|
|
const agent = ctx.agents.get(session.id)
|
|
if (agent?.session === session) this.agents.consumeSelection(
|
|
agent,
|
|
event.data.header.config.provider,
|
|
event.data.header.config.model,
|
|
event.data.header.config.reasoningEffort,
|
|
)
|
|
}
|
|
if (event.type !== 'user/message' || event.data.source.kind !== 'user') return
|
|
ctx.emit('api-session/activity', session.id, event.time)
|
|
})
|
|
}
|
|
|
|
private promote(observation: SessionObservation): void {
|
|
const sessionId = observation.header.id
|
|
const task = (async () => {
|
|
using ownedObservation = observation
|
|
const result = await this.agents.resolveObservedAgent(ownedObservation)
|
|
if ('error' in result) this.ctx.emit('api-session/error', sessionId, result.error.message)
|
|
})().catch((error: unknown) => {
|
|
this.ctx.logger.error(`session-controller: background activation for "${sessionId}" failed: ${errorChain(error)}`)
|
|
})
|
|
this.promotions.add(task)
|
|
void task.finally(() => { this.promotions.delete(task) })
|
|
}
|
|
|
|
/**
|
|
* Resolve or resume one ordinary Session for another Host API domain.
|
|
* @param sessionId - Session identity whose Agent owns the operation.
|
|
* @returns the live Agent or the stable Session-domain failure.
|
|
*/
|
|
resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult> {
|
|
return this.agents.resolveAgent(sessionId)
|
|
}
|
|
|
|
/**
|
|
* Inspect one attached or persisted Session without activating its Agent.
|
|
* @param sessionId - durable Session identity.
|
|
* @param signal - optional caller cancellation for persistence reads.
|
|
* @returns the current attached state or persisted header and event prefix.
|
|
*/
|
|
inspect(
|
|
sessionId: SessionId,
|
|
signal?: AbortSignal,
|
|
): Promise<SessionInspection> {
|
|
const attached = this.ctx.sessions.get(sessionId)
|
|
if (attached !== undefined) {
|
|
return Promise.resolve({
|
|
meta: attached.header,
|
|
inheritedEventCount: attached.inheritedEventCount,
|
|
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
|
|
events: attached.snapshotEvents(),
|
|
})
|
|
}
|
|
return inspectApiSession(this.ctx, sessionId, signal)
|
|
}
|
|
|
|
/**
|
|
* Read all visible Session rows without resuming an Agent.
|
|
* @param _request - reserved empty list request.
|
|
* @param signal - cancellation for persistence reads.
|
|
* @returns visible Session summaries ordered by activity.
|
|
*/
|
|
@Remote('list')
|
|
async list(_request: SessionListRequest, signal: AbortSignal): Promise<SessionListValue> {
|
|
return { items: await this.listState.list(signal) }
|
|
}
|
|
|
|
/**
|
|
* Search visible Session content without resuming an Agent.
|
|
* @param request - literal message-content query.
|
|
* @param signal - cancellation for list and search reads.
|
|
* @returns authorized bounded Session search results.
|
|
*/
|
|
@Remote('search')
|
|
search(request: SessionSearchRequest, signal: AbortSignal): Promise<SessionSearchValue> {
|
|
return this.listState.search(request.query, signal)
|
|
}
|
|
|
|
/**
|
|
* Create or idempotently adopt one ordinary Session.
|
|
* @param request - requested identity, location, and Agent preset.
|
|
* @returns the Session identity and resolved preset when configured.
|
|
*/
|
|
@Remote('create')
|
|
create(request: SessionCreateRequest): Promise<SessionCreateValue> {
|
|
return this.commands.create(request)
|
|
}
|
|
|
|
/**
|
|
* Select one Session-local model after explicitly resuming the Session.
|
|
* @param request - Session identity and requested model selection.
|
|
* @returns the normalized selection installed for the Session.
|
|
*/
|
|
@Remote('selectModel')
|
|
selectModel(request: SessionSelectModelRequest): Promise<SessionSelectModelValue> {
|
|
return this.commands.selectModel(request)
|
|
}
|
|
|
|
/**
|
|
* Describe every currently routable model for Host-generation selectors.
|
|
* @returns provider-grouped models, the deployment default, and isolated provider failures.
|
|
*/
|
|
@Remote('modelCatalog')
|
|
modelCatalog(): Promise<ModelCatalog> {
|
|
return buildModelCatalog(this.ctx)
|
|
}
|
|
|
|
/**
|
|
* Report whether this deployment can hand a Session workspace path to a native desktop.
|
|
* @returns true when the matching open operation is available.
|
|
*/
|
|
@Remote
|
|
canOpenWorkspacePath(): boolean {
|
|
return this.canOpenPath()
|
|
}
|
|
|
|
/**
|
|
* Describe the serving desktop for authenticated file-action routes.
|
|
* @returns Host name, configured availability, and platform-specific file-manager behavior.
|
|
*/
|
|
workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | 'explorer' | 'directory' | null } {
|
|
const fileManager = nativeFileManager()
|
|
return { name: hostname(), available: fileManager !== null && this.canOpenPath(), fileManager }
|
|
}
|
|
|
|
/**
|
|
* Verify one path through the composed filesystem and open it on the Host desktop.
|
|
* @param request - path after best-effort Session workspace resolution.
|
|
* @param signal - caller lifetime; abort terminates the native command.
|
|
* @returns confirmation after the native opener accepts the path.
|
|
* @throws RemoteError when the request is invalid, has no verified Host mapping, is cancelled, or the opener fails.
|
|
*/
|
|
@Remote('openWorkspacePath')
|
|
async openWorkspacePath(
|
|
request: SessionOpenWorkspacePathRequest,
|
|
signal: AbortSignal,
|
|
): Promise<SessionOpenWorkspacePathValue> {
|
|
try {
|
|
const path = await this.verifyDesktopPath(request.path, signal)
|
|
if (request.action === 'reveal') await this.revealPath(path, signal)
|
|
else if (request.application !== undefined) await this.openFileApplication(path, request.application, signal)
|
|
else await this.openPath(path, signal)
|
|
return { opened: true }
|
|
} catch (error: unknown) {
|
|
if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {})
|
|
if (error instanceof RemoteError) throw error
|
|
throw new RemoteError(
|
|
'gateway/internal',
|
|
'path open failed',
|
|
{},
|
|
{ cause: error },
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query current file handlers on the serving desktop without activating an Agent.
|
|
* @param request - file path in Host filesystem syntax.
|
|
* @param signal - caller lifetime, propagated to filesystem and desktop queries.
|
|
* @returns OS application names, icons, and default selection; empty when desktop opening is unavailable.
|
|
* @throws RemoteError when the path is invalid, the query is cancelled, or native discovery fails.
|
|
*/
|
|
@Remote('workspacePathApplications')
|
|
async workspacePathApplications(
|
|
request: { readonly path: string }, signal: AbortSignal,
|
|
): Promise<readonly SessionWorkspacePathApplication[]> {
|
|
if (!this.canOpenPath()) return []
|
|
try {
|
|
const path = await this.verifyDesktopPath(request.path, signal)
|
|
return await this.fileApplications(path, signal)
|
|
} catch (error: unknown) {
|
|
if (signal.aborted) throw new RemoteError('gateway/cancelled', 'application query was aborted', {})
|
|
if (error instanceof RemoteError) throw error
|
|
throw new RemoteError('gateway/internal', 'file application query failed', {}, { cause: error })
|
|
}
|
|
}
|
|
|
|
private async verifyDesktopPath(path: string, signal: AbortSignal): Promise<string> {
|
|
if (path.length === 0) throw new RemoteError('gateway/bad-request', 'A non-empty file path is required', {})
|
|
signal.throwIfAborted()
|
|
const hostPath = resolve(path)
|
|
const { fs } = this.ctx
|
|
const mapped = fs.processPathFromHostPath(hostPath)
|
|
if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal })) !== hostPath) {
|
|
throw new RemoteError('gateway/bad-request', 'Path has no verified Host path', {})
|
|
}
|
|
signal.throwIfAborted()
|
|
return hostPath
|
|
}
|
|
|
|
/**
|
|
* Rename one Session after explicitly resuming it.
|
|
* @param request - Session identity and proposed title.
|
|
* @returns the accepted title and durable event sequence.
|
|
*/
|
|
@Remote('rename')
|
|
rename(request: SessionRenameRequest): Promise<SessionRenameValue> {
|
|
return this.commands.rename(request)
|
|
}
|
|
|
|
/**
|
|
* Fork one cold-readable exact event prefix into a new Session. An omitted
|
|
* boundary selects the latest completed-turn prefix; an open cut receives
|
|
* synthetic fork closers.
|
|
* @param request - source Session and optional exact inclusive event boundary.
|
|
* @returns the new Session identity.
|
|
*/
|
|
@Remote('fork')
|
|
fork(request: SessionForkRequest): Promise<SessionForkValue> {
|
|
return this.commands.fork(request)
|
|
}
|
|
|
|
/**
|
|
* Admit one prompt after explicitly resuming its Session.
|
|
* @param request - Session identity, prompt content, source metadata, and delivery mode.
|
|
* @param signal - caller cancellation before prompt admission begins.
|
|
* @returns acknowledgement that the Agent accepted the prompt.
|
|
*/
|
|
@Remote('prompt')
|
|
prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue> {
|
|
signal.throwIfAborted()
|
|
return this.commands.prompt(request)
|
|
}
|
|
|
|
/**
|
|
* Read one image proven reachable from the addressed Session log.
|
|
* @param request - Session and attachment identities used for authorization.
|
|
* @returns the durable attachment reference and base64-encoded bytes.
|
|
*/
|
|
@Remote('attachment')
|
|
attachment(request: SessionAttachmentRequest): Promise<SessionAttachmentValue> {
|
|
return this.commands.attachment(request)
|
|
}
|
|
|
|
/**
|
|
* Mutate one still-pending queue occurrence, resuming a cold Agent first.
|
|
* @param request - Session, queue item, and requested mutation.
|
|
* @returns acknowledgement that the queue mutation was applied.
|
|
*/
|
|
@Remote('updateQueue')
|
|
updateQueue(request: SessionUpdateQueueRequest): Promise<SessionUpdateQueueValue> {
|
|
return this.commands.updateQueue(request)
|
|
}
|
|
|
|
/**
|
|
* Cancel one active Agent turn without dropping its pending inbox.
|
|
* @param request - Session whose active Agent turn is cancelled.
|
|
* @returns acknowledgement that cancellation was requested.
|
|
*/
|
|
@Remote('cancel')
|
|
cancel(request: SessionCancelRequest): SessionCancelValue {
|
|
return this.commands.cancel(request)
|
|
}
|
|
|
|
/**
|
|
* Read one cold-safe, message-aligned Session history page.
|
|
* @param request - durable address, backward cursor, and page budget.
|
|
* @param signal - cancellation for persistence reads.
|
|
* @returns one chronological page.
|
|
*/
|
|
@Remote('page')
|
|
page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
|
|
return this.history.page(request, signal)
|
|
}
|
|
|
|
/**
|
|
* Follow one Session log from its opening or resume cursor.
|
|
* @param request - durable address and last committed sequence already held by the caller.
|
|
* @param signal - cancellation owned by the Remote stream carrier.
|
|
* @returns a complete opening snapshot followed by gap-free durable event
|
|
* frames and optional cursorless assistant-stream frames.
|
|
*/
|
|
@Remote({ mode: 'stream' })
|
|
follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
|
|
return this.history.follow(request, signal)
|
|
}
|
|
|
|
/**
|
|
* Read all registered projections without activating an Agent.
|
|
* @param request - Session whose current values are required.
|
|
* @param signal - cancellation for the Session observation.
|
|
* @returns complete baseline, or null when the Session does not exist.
|
|
*/
|
|
@Remote('projections')
|
|
async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise<SessionProjectionsValue> {
|
|
const { sessionId } = request
|
|
if (sessionId.length === 0) {
|
|
throw new RemoteError('gateway/bad-request', 'sessionId must not be empty', {})
|
|
}
|
|
try {
|
|
using observation = await this.ctx.sessionQuery.observeSession(sessionId, { signal })
|
|
const projections = observation.projections
|
|
if (projections === undefined) {
|
|
throw new RemoteError('session/projections-unavailable', 'Session projections are unavailable', {})
|
|
}
|
|
return { asOfSeq: projections.asOfSeq, values: projections.values as SessionProjectionValues }
|
|
} catch (error: unknown) {
|
|
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') return null
|
|
if (signal.aborted
|
|
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
|
|
throw new RemoteError('gateway/cancelled', 'Session projection read was cancelled', {}, { cause: error })
|
|
}
|
|
if (error instanceof RemoteError) throw error
|
|
throw new RemoteError('gateway/internal', 'Session projection read failed', {}, { cause: error })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stream a complete live-control baseline followed by replacement frames.
|
|
* @param signal - cancellation owned by the Remote stream carrier.
|
|
* @returns one complete baseline followed by live replacement frames.
|
|
*/
|
|
@Remote({ mode: 'stream' })
|
|
control(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
|
|
return this.controlState.control(signal)
|
|
}
|
|
|
|
|
|
}
|
|
|
|
export { buildModelCatalog }
|
|
export default SessionController
|