feat(web): add workspace-aware session flow
This commit is contained in:
@@ -5,16 +5,22 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceNameConflictError,
|
||||
} from '@deepseek-ai/dsh-workspace'
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
|
||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
|
||||
@@ -172,12 +178,14 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-level default agent routing: provider/model from the gateway config, cwd from the host process. */
|
||||
/** Resolved Host routing and project-directory defaults consumed by the API implementation. */
|
||||
export interface ApiProxyDefaults {
|
||||
provider: string
|
||||
model: string
|
||||
/** Default project directory for new sessions whose create request carries no cwd. */
|
||||
cwd: string
|
||||
/** Parent directory for name-created workspaces. */
|
||||
workspaceRoot: string
|
||||
}
|
||||
|
||||
/** The tool/call payload fields the presenter path reads. */
|
||||
@@ -273,17 +281,62 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
|
||||
*/
|
||||
class SessionNotFound extends Error {}
|
||||
|
||||
/** Requested identity already belongs to a session with another project cwd. */
|
||||
class SessionCwdConflict extends Error {
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
readonly requestedCwd: string,
|
||||
readonly existingCwd: string | undefined,
|
||||
) {
|
||||
super(
|
||||
`session "${sessionId}" already exists with cwd ${JSON.stringify(existingCwd)}; `
|
||||
+ `requested ${JSON.stringify(requestedCwd)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Host failed before the registry could adopt a name-created directory. */
|
||||
class WorkspaceDirectoryCreationError extends Error {}
|
||||
|
||||
/** Wire projection of one workspace entity (the workspace.* value row). */
|
||||
function workspaceView(workspace: Workspace): WorkspaceView {
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
path: workspace.path,
|
||||
title: workspace.title,
|
||||
sessionIds: [...workspace.sessionIds],
|
||||
createdAt: workspace.createdAt,
|
||||
updatedAt: workspace.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire projection of the durable record carried by `domain/changed`. */
|
||||
function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView {
|
||||
const record: WorkspaceRecord = workspaceRecord.parse(value)
|
||||
return {
|
||||
workspaceId: workspaceId as WorkspaceId,
|
||||
path: record.path,
|
||||
title: record.title,
|
||||
sessionIds: [...record.sessionIds],
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implement ApiProxy over a composed host context.
|
||||
* @param ctx - a context with the host spine mounted (sessions/agents/tools/userInteraction services).
|
||||
* @param defaults - host-level default provider/model: injected as
|
||||
* agentOptions on create/resume, reported by describe from the same source.
|
||||
* @param ctx - a context with the Host spine and Workspace registry mounted.
|
||||
* @param defaults - host routing and project-directory defaults.
|
||||
* @returns the ApiProxy implementation.
|
||||
*/
|
||||
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
||||
const agentOptions = { provider: defaults.provider, model: defaults.model }
|
||||
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
|
||||
const resumes = new Map<SessionId, Promise<Agent>>()
|
||||
/** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
|
||||
const sessionCreations = new Map<SessionId, Promise<Agent>>()
|
||||
/** Serializes path ownership checks with record creation across spellings. */
|
||||
let workspaceCreationChain = Promise.resolve()
|
||||
const pendingQuestions = new Map<RpcId, PendingQuestion>()
|
||||
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
|
||||
|
||||
@@ -384,6 +437,78 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve one requested identity to a live agent, creating or resuming it once. */
|
||||
async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> {
|
||||
let creation = sessionCreations.get(sessionId)
|
||||
if (creation === undefined) {
|
||||
creation = (async () => {
|
||||
const live = ctx.agents.get(sessionId)
|
||||
if (live !== undefined) return live
|
||||
|
||||
const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined
|
||||
const stored = persistence === undefined
|
||||
? undefined
|
||||
: (await persistence.list()).find(header => header.id === sessionId)
|
||||
if (stored !== undefined) {
|
||||
if (stored.cwd !== cwd) {
|
||||
throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
|
||||
}
|
||||
return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })).agent
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdir(cwd, { recursive: true })
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
|
||||
}
|
||||
return (await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })).agent
|
||||
})().catch((error: unknown) => {
|
||||
// Another Host entry path may have published the same identity while
|
||||
// this operation crossed an asynchronous persistence/filesystem step.
|
||||
const live = ctx.agents.get(sessionId)
|
||||
if (live !== undefined) return live
|
||||
throw error
|
||||
}).finally(() => {
|
||||
sessionCreations.delete(sessionId)
|
||||
})
|
||||
sessionCreations.set(sessionId, creation)
|
||||
}
|
||||
const agent = await creation
|
||||
if (agent.session.header.cwd !== cwd) {
|
||||
throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
/** Resolve or create one path while holding the Host's workspace-create chain. */
|
||||
function ensureWorkspace(
|
||||
path: string,
|
||||
title: string | undefined,
|
||||
rejectExistingName = false,
|
||||
createDirectory = false,
|
||||
): Promise<{ workspace: Workspace; created: boolean }> {
|
||||
const operation = workspaceCreationChain.then(async () => {
|
||||
if (rejectExistingName && title !== undefined
|
||||
&& ctx.workspace.list().some(workspace => workspace.title === title)) {
|
||||
throw new WorkspaceNameConflictError(title)
|
||||
}
|
||||
if (createDirectory) {
|
||||
try {
|
||||
await mkdir(path, { recursive: true })
|
||||
} catch (error: unknown) {
|
||||
throw new WorkspaceDirectoryCreationError(
|
||||
`failed to create workspace directory "${path}": ${String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
const existing = await ctx.workspace.resolveByPath(path)
|
||||
if (existing !== undefined) return { workspace: existing, created: false }
|
||||
return { workspace: await ctx.workspace.create(path, title), created: true }
|
||||
})
|
||||
workspaceCreationChain = operation.then(() => undefined, () => undefined)
|
||||
return operation
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: {
|
||||
// Attached sessions summarize from memory; persisted-but-unattached (cold)
|
||||
@@ -406,23 +531,51 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
|
||||
async create(request) {
|
||||
const sessionId = `session-${randomUUID()}` as SessionId
|
||||
// A session's cwd is its project path. When the creator does not choose
|
||||
// one, the default project is the host-level default (the host process
|
||||
// working directory unless boot overrides it). Ensure the directory
|
||||
// exists so Create-workspace and typed paths land on a real folder.
|
||||
const cwd = request.payload.cwd ?? defaults.cwd
|
||||
const sessionId = request.payload.sessionId ?? `session-${randomUUID()}` as SessionId
|
||||
let workspace: Workspace | undefined
|
||||
if (request.payload.workspaceId !== undefined) {
|
||||
workspace = ctx.workspace.get(brandWorkspaceId(request.payload.workspaceId))
|
||||
if (workspace === undefined) {
|
||||
return err(request, {
|
||||
code: 'workspace-not-found',
|
||||
message: `workspace "${request.payload.workspaceId}" not found`,
|
||||
details: { workspaceId: request.payload.workspaceId },
|
||||
})
|
||||
}
|
||||
}
|
||||
const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd
|
||||
try {
|
||||
await mkdir(cwd, { recursive: true })
|
||||
await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SessionCwdConflict) {
|
||||
return err(request, {
|
||||
code: 'session-conflict',
|
||||
message: error.message,
|
||||
details: {
|
||||
sessionId: error.sessionId,
|
||||
requestedCwd: error.requestedCwd,
|
||||
...error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd },
|
||||
},
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `failed to ensure project directory "${cwd}": ${String(error)}`,
|
||||
message: `failed to create session "${sessionId}": ${String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })
|
||||
return ok(request, { sessionId: handle.agent.id })
|
||||
if (workspace !== undefined) {
|
||||
try {
|
||||
await workspace.attachSession(sessionId)
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'workspace-attach-failed',
|
||||
message: `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`,
|
||||
details: { sessionId, workspaceId: workspace.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
return ok(request, { sessionId })
|
||||
},
|
||||
|
||||
async history(request) {
|
||||
@@ -472,12 +625,71 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
},
|
||||
|
||||
workspace: {
|
||||
list(request) {
|
||||
return Promise.resolve(ok(request, { items: ctx.workspace.list().map(workspaceView) }))
|
||||
},
|
||||
|
||||
// Exactly one of path/name arrives (schema refine). Existing-folder
|
||||
// adoption reuses its canonical path; create-by-name rejects a name
|
||||
// already present in the registry.
|
||||
async create(request) {
|
||||
const { payload } = request
|
||||
let path: string
|
||||
if (payload.name !== undefined) {
|
||||
const name = payload.name.trim()
|
||||
if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
|
||||
return err(request, {
|
||||
code: 'workspace-invalid-path',
|
||||
message: `workspace name must be one non-empty path segment, got "${payload.name}"`,
|
||||
details: { path: payload.name },
|
||||
})
|
||||
}
|
||||
path = join(defaults.workspaceRoot, name)
|
||||
} else {
|
||||
path = payload.path as string
|
||||
}
|
||||
try {
|
||||
const name = payload.name?.trim()
|
||||
const { workspace, created } = await ensureWorkspace(
|
||||
path,
|
||||
name,
|
||||
name !== undefined,
|
||||
name !== undefined,
|
||||
)
|
||||
return ok(request, { workspace: workspaceView(workspace), created })
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkspaceNameConflictError) {
|
||||
return err(request, {
|
||||
code: 'workspace-name-conflict',
|
||||
message: error.message,
|
||||
details: { name: error.workspaceName },
|
||||
})
|
||||
}
|
||||
if (error instanceof WorkspaceDirectoryCreationError) {
|
||||
return err(request, { code: 'internal', message: error.message, details: {} })
|
||||
}
|
||||
// The registry rejects a path that does not resolve to an existing
|
||||
// directory (realpath ENOENT / not-a-directory) — the business
|
||||
// error of the typed-path flow, surfaced as a validation failure.
|
||||
return err(request, {
|
||||
code: 'workspace-invalid-path',
|
||||
message: `cannot create a workspace at "${path}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
details: { path },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
host: {
|
||||
describe(request) {
|
||||
// TODO(step2): version should read apps/cli's package.json; placeholder for now.
|
||||
return Promise.resolve(ok(request, {
|
||||
version: '0.0.1',
|
||||
cwd: process.cwd(),
|
||||
// Same source as session.create's fallback: the UI's default project
|
||||
// must match where an unspecified-cwd session actually lands.
|
||||
cwd: defaults.cwd,
|
||||
provider: defaults.provider,
|
||||
model: defaults.model,
|
||||
attachedSessions: ctx.agents.list().length,
|
||||
@@ -542,12 +754,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
|
||||
host(_request, signal) {
|
||||
const queue = new FrameQueue<RpcRequest<HostFrame>>()
|
||||
const committedWorkspaceIds = new Set(
|
||||
ctx.workspace.list().map(workspace => String(workspace.id)),
|
||||
)
|
||||
const disposers = [
|
||||
ctx.on('session/created', (session: Session) => {
|
||||
queue.push(frame({
|
||||
type: 'host/session-added',
|
||||
sessionId: session.id,
|
||||
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
|
||||
// cwd rides the frame so the client list needs no refresh to group the new session.
|
||||
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
|
||||
}))
|
||||
}),
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
@@ -560,6 +777,29 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: Error) => {
|
||||
queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) }))
|
||||
}),
|
||||
ctx.on('domain/changed', (change) => {
|
||||
if (change.domain !== 'workspace' || change.operation !== 'put') return
|
||||
if (change.table === '') {
|
||||
const state = workspaceDomainState.parse(change.value)
|
||||
for (const workspaceId of state.workspaceIds) {
|
||||
if (committedWorkspaceIds.has(workspaceId)) continue
|
||||
const workspace = ctx.workspace.get(workspaceId)
|
||||
if (workspace === undefined) {
|
||||
throw new Error(`committed workspace registry references missing workspace "${workspaceId}"`)
|
||||
}
|
||||
committedWorkspaceIds.add(workspaceId)
|
||||
queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) }))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (change.table !== 'workspaces' || !committedWorkspaceIds.has(change.key)) return
|
||||
// Existing-entity table writes are complete attach/touch commits.
|
||||
// A new entity's first put waits for the global registry write above.
|
||||
queue.push(frame({
|
||||
type: 'host/workspace-changed',
|
||||
workspace: changedWorkspaceView(change.key, change.value),
|
||||
}))
|
||||
}),
|
||||
]
|
||||
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { Wire } from './rpc.schema.ts'
|
||||
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
|
||||
import { approvalRequestIdSchema } from './approvals.schema.ts'
|
||||
import { sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
|
||||
import { workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
/** Question shape validated strictly against core dsh-user-interaction. */
|
||||
export const askUserQuestionItemSchema = z.object({
|
||||
@@ -39,9 +40,10 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
|
||||
/** HostFrame union (payload slot of a host-stream ServerRequest). */
|
||||
export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, parentSessionId: sessionIdSchema.optional() }),
|
||||
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional() }),
|
||||
z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }),
|
||||
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
|
||||
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
|
||||
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
|
||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||
]) as unknown as z.ZodType<HostFrame>
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
|
||||
import type { WorkspaceView } from './workspace.ts'
|
||||
|
||||
// Client-side consumers take the render-intent vocabulary from the contract;
|
||||
// dsh-tools remains its owner.
|
||||
@@ -62,10 +63,18 @@ export type MuxFrame =
|
||||
| { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
|
||||
| { type: 'stream/error'; error: RpcError }
|
||||
|
||||
/** Host stream frames. session-added carries the lineage anchor; agent-error is the only outlet for live failures with no turn position. */
|
||||
/**
|
||||
* Host stream frames. session-added carries the lineage anchor and the
|
||||
* project cwd (the list-summary fields a client cannot wait for a refresh to
|
||||
* learn); agent-error is the only outlet for live failures with no turn
|
||||
* position; workspace-changed pushes the full new snapshot after every
|
||||
* durable workspace mutation (create/attach/order change — the client
|
||||
* upserts, while `workspace.list` provides the reconnect baseline).
|
||||
*/
|
||||
export type HostFrame =
|
||||
| { type: 'host/session-added'; sessionId: SessionId; parentSessionId?: SessionId }
|
||||
| { type: 'host/session-added'; sessionId: SessionId; parentSessionId?: SessionId; cwd?: string }
|
||||
| { type: 'host/session-removed'; sessionId: SessionId }
|
||||
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
|
||||
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
|
||||
| { type: 'host/workspace-changed'; workspace: WorkspaceView }
|
||||
| { type: 'stream/error'; error: RpcError }
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type { SessionsApi } from './sessions.ts'
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { EventsApi } from './events.ts'
|
||||
import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
|
||||
@@ -13,6 +14,7 @@ import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
export interface ApiProxy {
|
||||
sessions: SessionsApi
|
||||
host: HostApi
|
||||
workspace: WorkspaceApi
|
||||
events: EventsApi
|
||||
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
|
||||
respond(message: ClientResponse): Promise<RpcReceipt>
|
||||
@@ -21,6 +23,7 @@ export interface ApiProxy {
|
||||
// ---- Domain interfaces and payload entities ----
|
||||
export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts'
|
||||
export type { HostApi } from './host.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type { SessionsApi } from './sessions.ts'
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { RpcResponse } from './rpc.ts'
|
||||
|
||||
/** Method name → method signature. Signatures are the single source of truth; payload/value types are always derived from here. */
|
||||
@@ -16,6 +17,8 @@ export interface RpcMethodMap {
|
||||
'session.prompt': SessionsApi['prompt']
|
||||
'session.cancel': SessionsApi['cancel']
|
||||
'host.describe': HostApi['describe']
|
||||
'workspace.list': WorkspaceApi['list']
|
||||
'workspace.create': WorkspaceApi['create']
|
||||
}
|
||||
|
||||
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */
|
||||
|
||||
@@ -35,6 +35,11 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
|
||||
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('session-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedCwd: z.string(), existingCwd: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('workspace-attach-failed'), message: z.string(), details: z.object({ sessionId: z.string(), workspaceId: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
||||
]) as unknown as z.ZodType<RpcError>
|
||||
|
||||
@@ -32,6 +32,11 @@ export interface RpcErrorDetailsMap {
|
||||
'bad-request': { issues: ZodIssue[] }
|
||||
'cancelled': {}
|
||||
'session-not-found': { sessionId: SessionId }
|
||||
'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string }
|
||||
'workspace-attach-failed': { sessionId: SessionId; workspaceId: string }
|
||||
'workspace-not-found': { workspaceId: string }
|
||||
'workspace-invalid-path': { path: string }
|
||||
'workspace-name-conflict': { name: string }
|
||||
'agent-busy': { reason: string }
|
||||
'internal': {}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,19 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { HistoryEntry, SessionSummary } from './sessions.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
|
||||
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
|
||||
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
|
||||
|
||||
/**
|
||||
* WorkspaceId: the workspace domain's one brand cast. Hosted here rather
|
||||
* than in workspace.schema because session.create references it while
|
||||
* workspace.schema references sessionIdSchema — schema modules must stay a
|
||||
* DAG (both casts used at module top level; a cycle is a load-time TDZ).
|
||||
*/
|
||||
export const workspaceIdSchema = z.string().min(1) as unknown as z.ZodType<WorkspaceId>
|
||||
|
||||
/** SessionEvent passthrough: strict envelope, wide data (the client fold handles unknown types via its documented default). */
|
||||
export const sessionEventSchema = z.object({
|
||||
type: z.string(),
|
||||
@@ -44,10 +53,15 @@ export const sessionListValueSchema = z.object({
|
||||
items: z.array(sessionSummarySchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.list'>>>
|
||||
|
||||
/** session.create request payload. */
|
||||
/** session.create request payload (at most one of workspaceId / cwd). */
|
||||
export const sessionCreateRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema.optional(),
|
||||
cwd: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.create'>>>
|
||||
sessionId: sessionIdSchema.optional(),
|
||||
}).refine(
|
||||
payload => payload.workspaceId === undefined || payload.cwd === undefined,
|
||||
{ message: 'session.create accepts workspaceId or cwd, not both' },
|
||||
) satisfies z.ZodType<Wire<RequestPayload<'session.create'>>>
|
||||
|
||||
/** session.create response value. */
|
||||
export const sessionCreateValueSchema = z.object({
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
@@ -49,8 +50,16 @@ export interface SessionsApi {
|
||||
/** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */
|
||||
list(request: RpcRequest<{ cursor?: string }>): Promise<RpcResponse<{ items: SessionSummary[] }>>
|
||||
|
||||
/** Creates a new session (and its agent, idle and standing by). */
|
||||
create(request: RpcRequest<{ cwd?: string }>): Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
/**
|
||||
* Creates a real session and its idle agent. At most one of `workspaceId` /
|
||||
* `cwd` is accepted; an omitted project uses the Host cwd. A caller may
|
||||
* preallocate `sessionId`: retries with the same id and cwd return the same
|
||||
* session, while a different cwd fails with `session-conflict`.
|
||||
* Workspace creation attaches the session after publication; an attach
|
||||
* failure returns `workspace-attach-failed` with the published session id.
|
||||
*/
|
||||
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/**
|
||||
* Reads a window of history events; page boundaries align to message boundaries: one page =
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* workspace domain zod schemas (names derived from map keys). The
|
||||
* WorkspaceId brand cast lives in sessions.schema (see the note there) and
|
||||
* is re-exported here as the domain-local name.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { WorkspaceView } from './workspace.ts'
|
||||
import { sessionIdSchema, workspaceIdSchema } from './sessions.schema.ts'
|
||||
|
||||
export { workspaceIdSchema } from './sessions.schema.ts'
|
||||
|
||||
/** WorkspaceView row of every workspace.* response. */
|
||||
export const workspaceViewSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
path: z.string(),
|
||||
title: z.string(),
|
||||
sessionIds: z.array(sessionIdSchema),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
}) satisfies z.ZodType<Wire<WorkspaceView>>
|
||||
|
||||
/** workspace.list request payload (empty object literal). */
|
||||
export const workspaceListRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'workspace.list'>>>
|
||||
|
||||
/** workspace.list response value. */
|
||||
export const workspaceListValueSchema = z.object({
|
||||
items: z.array(workspaceViewSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.list'>>>
|
||||
|
||||
/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */
|
||||
export const workspaceCreateRequestSchema = z.object({
|
||||
path: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
}).refine(
|
||||
payload => (payload.path === undefined) !== (payload.name === undefined),
|
||||
{ message: 'workspace.create requires exactly one of path / name' },
|
||||
) satisfies z.ZodType<Wire<RequestPayload<'workspace.create'>>>
|
||||
|
||||
/** workspace.create response value. */
|
||||
export const workspaceCreateValueSchema = z.object({
|
||||
workspace: workspaceViewSchema,
|
||||
created: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.create'>>>
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* workspace domain contract. Wire projection of the host-side workspace
|
||||
* entity (@deepseek-ai/dsh-workspace): a stable id over a directory path,
|
||||
* a display title, and the ordered session account. Method signatures are the
|
||||
* source of truth, same as the sessions domain.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/**
|
||||
* Wire-side workspace id brand. Deliberately re-declared here rather than
|
||||
* imported from dsh-workspace: api/ must stay browser-importable with zero
|
||||
* host-package dependencies, and the brand string matches, so both sides
|
||||
* agree structurally.
|
||||
*/
|
||||
export type WorkspaceId = Branded<'WorkspaceId'>
|
||||
|
||||
/** One workspace row: the record projection every workspace.* value carries. */
|
||||
export interface WorkspaceView {
|
||||
workspaceId: WorkspaceId
|
||||
/** Canonical directory path (host-side realpath canon). */
|
||||
path: string
|
||||
/** Unique display title (defaults to the path basename at create). */
|
||||
title: string
|
||||
/** Sessions accounted under this workspace, newest-first for display. */
|
||||
sessionIds: SessionId[]
|
||||
/** ISO-8601 creation instant. */
|
||||
createdAt: string
|
||||
/** ISO-8601 last-mutation instant. */
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** Workspace-domain unary methods (the map keys workspace.* of RpcMethodMap). */
|
||||
export interface WorkspaceApi {
|
||||
/** Lists all workspaces in the registry's durable display order. */
|
||||
list(request: RpcRequest<{}>): Promise<RpcResponse<{ items: WorkspaceView[] }>>
|
||||
|
||||
/**
|
||||
* Creates (or idempotently resolves) a workspace. Exactly one of `path` /
|
||||
* `name` (schema-enforced): `path` registers an EXISTING directory (no
|
||||
* mkdir — a missing or non-directory path fails with `workspace-invalid-path`);
|
||||
* `name` is a single path segment the host mkdirs under its default project
|
||||
* root before registering. Either spelling resolving to a directory already
|
||||
* owned by a workspace returns that workspace (`created: false`) for the
|
||||
* existing-folder spelling. Create-by-name rejects an existing title with
|
||||
* `workspace-name-conflict`; a new path whose basename duplicates another
|
||||
* Workspace title is rejected by the registry with the same code.
|
||||
* A new name-created workspace uses `name` as both directory name and title;
|
||||
* a path-created workspace uses the registry's basename title default.
|
||||
*/
|
||||
create(request: RpcRequest<{ path?: string; name?: string }>):
|
||||
Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>>
|
||||
}
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
sessionListValueSchema,
|
||||
sessionPromptValueSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
import {
|
||||
workspaceCreateValueSchema,
|
||||
workspaceListValueSchema,
|
||||
} from '../api/workspace.schema.ts'
|
||||
|
||||
/**
|
||||
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
|
||||
@@ -48,6 +52,10 @@ export interface IApiClient {
|
||||
host: {
|
||||
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
|
||||
}
|
||||
workspace: {
|
||||
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
|
||||
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
|
||||
}
|
||||
events: {
|
||||
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
|
||||
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
|
||||
@@ -67,6 +75,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'session.prompt': sessionPromptValueSchema,
|
||||
'session.cancel': sessionCancelValueSchema,
|
||||
'host.describe': hostDescribeValueSchema,
|
||||
'workspace.list': workspaceListValueSchema,
|
||||
'workspace.create': workspaceCreateValueSchema,
|
||||
}
|
||||
|
||||
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
|
||||
@@ -253,6 +263,11 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
|
||||
}
|
||||
|
||||
readonly workspace: IApiClient['workspace'] = {
|
||||
list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
|
||||
create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
|
||||
}
|
||||
|
||||
readonly events: IApiClient['events'] = {
|
||||
mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen),
|
||||
host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen),
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
sessionPromptRequestSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
|
||||
import {
|
||||
workspaceCreateRequestSchema,
|
||||
workspaceListRequestSchema,
|
||||
} from '../api/workspace.schema.ts'
|
||||
|
||||
/**
|
||||
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
|
||||
@@ -44,6 +48,8 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
|
||||
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
|
||||
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
|
||||
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
|
||||
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
|
||||
}
|
||||
|
||||
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* routes — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { ApiProxy } from './api/index.ts'
|
||||
@@ -28,37 +29,47 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Gateway plugin config: the host-level default agent routing. */
|
||||
/** Gateway plugin config: host-level agent routing and Workspace creation root. */
|
||||
export interface Config {
|
||||
/** Default provider route for created/resumed agents. */
|
||||
provider: string
|
||||
/** Default model id. */
|
||||
model: string
|
||||
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
|
||||
workspaceRoot?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The API gateway service: implements the ApiProxy contract over the composed
|
||||
* host context and provides it as `ctx.apiProxy`. The default project
|
||||
* directory for new sessions is the host process working directory (not a
|
||||
* config field this round).
|
||||
* host context and provides it as `ctx.apiProxy`. The Host cwd is the default
|
||||
* project directory and the fallback parent for name-created Workspaces.
|
||||
*/
|
||||
export class ApiProxyService extends Service implements ApiProxy {
|
||||
static inject = ['agents', 'sessions', 'tools', 'userInteraction']
|
||||
static inject = ['agents', 'sessions', 'tools', 'userInteraction', 'workspace']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
workspaceRoot: z.string(),
|
||||
})
|
||||
|
||||
readonly sessions: ApiProxy['sessions']
|
||||
readonly workspace: ApiProxy['workspace']
|
||||
readonly host: ApiProxy['host']
|
||||
readonly events: ApiProxy['events']
|
||||
readonly respond: ApiProxy['respond']
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'apiProxy')
|
||||
const api = createApiProxy(ctx, { provider: config.provider, model: config.model, cwd: process.cwd() })
|
||||
const cwd = process.cwd()
|
||||
const api = createApiProxy(ctx, {
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
cwd,
|
||||
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
|
||||
})
|
||||
this.sessions = api.sessions
|
||||
this.workspace = api.workspace
|
||||
this.host = api.host
|
||||
this.events = api.events
|
||||
// createApiProxy returns closures (no `this` capture); bind only satisfies
|
||||
|
||||
Reference in New Issue
Block a user