Merge remote-tracking branch 'origin/master' into fix/pty-handoff-grace
This commit is contained in:
@@ -750,6 +750,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
}
|
||||
return ok(request, { workspace: { ...workspace } })
|
||||
},
|
||||
delete: (request) => {
|
||||
const { workspaceId } = request.payload
|
||||
const index = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId)
|
||||
if (index === -1) {
|
||||
return err(request, {
|
||||
code: 'workspace-not-found',
|
||||
message: `no workspace ${workspaceId}`,
|
||||
details: { workspaceId },
|
||||
})
|
||||
}
|
||||
workspaces.splice(index, 1)
|
||||
emitHost({ type: 'host/workspace-removed', workspaceId })
|
||||
return ok(request, { deleted: true as const })
|
||||
},
|
||||
insertSessionBefore: (request) => {
|
||||
const { workspaceId, sessionId, beforeSessionId } = request.payload
|
||||
const workspace = workspaces.find(w => w.workspaceId === workspaceId)
|
||||
@@ -941,6 +955,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'workspace.list': return this.api.workspace.list(request)
|
||||
case 'workspace.create': return this.api.workspace.create(request)
|
||||
case 'workspace.rename': return this.api.workspace.rename(request)
|
||||
case 'workspace.delete': return this.api.workspace.delete(request)
|
||||
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
|
||||
case 'command.list': return this.api.commands.list(request)
|
||||
// The in-memory execute never blocks, so a never-aborting signal is faithful here.
|
||||
|
||||
@@ -81,6 +81,7 @@ export class FakeApiClient implements IApiClient {
|
||||
rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
}))),
|
||||
delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))),
|
||||
insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
}))),
|
||||
|
||||
@@ -380,6 +380,31 @@ describe('createFixtureApi', () => {
|
||||
expect(noop.result.value.workspace.updatedAt).toBe(before)
|
||||
})
|
||||
|
||||
it('workspace.delete removes only the Workspace row and emits the removal frame', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const seen: HostFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.host(req({}), abort.signal)) {
|
||||
seen.push(envelope.payload)
|
||||
abort.abort()
|
||||
}
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
|
||||
const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId }))
|
||||
expect(deleted.result).toEqual({ ok: true, value: { deleted: true } })
|
||||
await consuming
|
||||
expect(seen).toEqual([{ type: 'host/workspace-removed', workspaceId: 'fx-ws-fixture' }])
|
||||
const list = await api.workspace.list(req({}))
|
||||
if (!list.result.ok) throw new Error('workspace list failed')
|
||||
expect(list.result.value.items.some(workspace => workspace.workspaceId === 'fx-ws-fixture')).toBe(false)
|
||||
const sessions = await api.sessions.list(req({}))
|
||||
if (!sessions.result.ok) throw new Error('session list failed')
|
||||
expect(sessions.result.value.items.map(session => session.sessionId)).toContain('fx-alpha')
|
||||
})
|
||||
|
||||
it('session.create({workspaceId}) lands on the account and unknown ids error', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: d7b7bbc4e4e05893689a8f2dcac82763b4c67ef8
|
||||
README.zh.md: d2054170cd6f31505793812fff46cc0f2356ad75
|
||||
README.md: a434b2d5719de2f30a883ee6e0d26264b3c62f4e
|
||||
README.zh.md: a79fa99578e6bce4f0ff8e7c1f7538df8a436778
|
||||
|
||||
@@ -6,7 +6,9 @@ Client cordis boot and React-free object services: SlotsService wraps SlotCore a
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental frames arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
|
||||
Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
|
||||
|
||||
`WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears.
|
||||
|
||||
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量帧会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
|
||||
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已记账的 Session 会立即投影到 Ungrouped 下。
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
|
||||
@@ -1,590 +0,0 @@
|
||||
/**
|
||||
* SessionsService: root sessions service — list snapshot store (manager
|
||||
* projection; carries `current`, the persisted selection every
|
||||
* session-scoped surface keys off — migrated here from ui-layout per the
|
||||
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
|
||||
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
|
||||
* id), stable SessionBinding cache, ancestry walk.
|
||||
*
|
||||
* Scope lifecycle is stage-driven: a scope is minted lazily on first
|
||||
* resolution (pure — resolution has no side effects and is render-safe);
|
||||
* the event window and deferred teardown key off the STAGED session, which
|
||||
* follows `list.current` exactly. Staging is the open signal: the window
|
||||
* opens ⟺ the session is on stage (today the stage is `current`; the staged
|
||||
* state can widen to a multi-pane list later). A session leaving the list
|
||||
* tears its scope down immediately unless it is the staged one, whose scope
|
||||
* survives frozen (read-only view) until the stage moves on.
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { SessionListPhase } from './manager.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
/** Session list row projected from the host list RPC plus live stream increments. */
|
||||
export interface SessionSummary {
|
||||
id: SessionId
|
||||
/** Latest durable log-backed title, absent until the host projects one. */
|
||||
title?: string
|
||||
/** Human-facing label: durable title, project basename, then session id. */
|
||||
displayTitle: string
|
||||
cwd?: string
|
||||
parentId?: SessionId
|
||||
running: boolean
|
||||
/**
|
||||
* Empty-log bit (host summary derivation mirror). List surfaces hide blank
|
||||
* sessions; New Session reuses a blank one targeting the same workspace.
|
||||
* Filtering stays with the consumer — the store carries every row.
|
||||
*/
|
||||
blank: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Session list store shape. `current` rides the same snapshot (arbitrated:
|
||||
* the single useSessions standard hook reads list and selection together —
|
||||
* sidebar highlighting and SessionProvider share one fact source).
|
||||
*/
|
||||
export interface SessionListState {
|
||||
ids: SessionId[]
|
||||
byId: Record<SessionId, SessionSummary>
|
||||
current: SessionId | undefined
|
||||
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
|
||||
phase: SessionListPhase
|
||||
}
|
||||
|
||||
/** Structured session-create failure. */
|
||||
export class SessionCreateError extends Error {
|
||||
override readonly name = 'SessionCreateError'
|
||||
|
||||
/**
|
||||
* @param rpcError - Host business or folded transport error.
|
||||
* @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
|
||||
*/
|
||||
constructor(
|
||||
readonly rpcError: RpcError,
|
||||
readonly requestedSessionId: SessionId | undefined,
|
||||
) {
|
||||
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
|
||||
export interface SessionBinding {
|
||||
readonly sessionId: SessionId
|
||||
readonly session: Session
|
||||
readonly ctx: Context
|
||||
}
|
||||
|
||||
// Scope primitives live in ../agents/scope.ts (the client mirror of host
|
||||
// dsh-scope, keyed by Agent identity); re-exported here so existing
|
||||
// consumers keep their import site.
|
||||
export { scopeOf } from '../agents/scope.ts'
|
||||
|
||||
/**
|
||||
* Workspace display title of a session cwd: the path's last non-empty
|
||||
* segment (both separators accepted; trailing separators ignored), or ''
|
||||
* for separator-only paths — callers own their fallback (session id, raw
|
||||
* cwd, default-directory copy). The repo-wide single basename derivation —
|
||||
* every surface naming a workspace (picker rows, toggle labels, list titles)
|
||||
* calls this instead of re-splitting paths.
|
||||
* @param cwd - workspace directory path.
|
||||
* @returns basename title, or '' when no non-empty segment exists.
|
||||
*/
|
||||
export function workspaceTitleOf(cwd: string): string {
|
||||
return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Display title projection: durable title, project directory basename, then
|
||||
* the raw id.
|
||||
*/
|
||||
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
|
||||
if (title !== undefined) return title
|
||||
if (cwd !== undefined && cwd !== '') {
|
||||
const base = workspaceTitleOf(cwd)
|
||||
if (base !== '') return base
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
interface ScopeRecord {
|
||||
fiber: Fiber
|
||||
ctx: Context
|
||||
binding: SessionBinding
|
||||
/** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */
|
||||
provideInfo: SessionProvideInfo
|
||||
}
|
||||
|
||||
/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */
|
||||
export interface SessionProvideContribution {
|
||||
/** Bare observable sources, keyed by hook base name ('input' → useInput). */
|
||||
hooks?: Record<string, HostObservable<unknown>>
|
||||
/** Stable plain members (action callbacks etc.), spread into standard props verbatim. */
|
||||
props?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Static declaration plus per-session resolver for one standard-kit
|
||||
* contribution. The declared names let the renderer construct the same hook
|
||||
* and prop surface while no session is current.
|
||||
*/
|
||||
export interface SessionProvideDescriptor {
|
||||
/** Hook base names (`input` becomes `useInput`). */
|
||||
hooks?: readonly string[]
|
||||
/** Plain standard-prop names. */
|
||||
props?: readonly string[]
|
||||
/** Resolve every declared member for one definite session. */
|
||||
resolve(binding: SessionBinding): SessionProvideContribution
|
||||
}
|
||||
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
|
||||
export class SessionsService {
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
private readonly manager: SessionManager
|
||||
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
* purpose: reads go through the list snapshot; writes through {@link
|
||||
* SessionsService.open} / {@link SessionsService.clear}. Projection
|
||||
* validates it against the live list instead of destructively pruning, so a
|
||||
* selection survives transient list states (reconnect re-pull) and
|
||||
* resurfaces when its session returns.
|
||||
*/
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
|
||||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||||
/** Registered per-session standard-props providers, in registration order. */
|
||||
private readonly providers: SessionProvideDescriptor[] = []
|
||||
/** Static no-session projection, rebuilt only when the provider roster changes. */
|
||||
private maybeInfo: SessionMaybeProvideInfo
|
||||
/**
|
||||
* The staged session id — follows `list.current` exactly, holding its last
|
||||
* defined value across masked gaps (a transiently absent selection blanks
|
||||
* `current` without moving the stage, so reconnect re-pulls and removals
|
||||
* keep the staged scope's frozen view alive until the stage moves on).
|
||||
*/
|
||||
private watched: SessionId | undefined
|
||||
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
|
||||
private readonly deferredRemovals = new Set<SessionId>()
|
||||
|
||||
/**
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'pending',
|
||||
})
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
this.manager.subscribe(() => { this.projectList() })
|
||||
// Stage follower: every current write (open() and projection alike)
|
||||
// re-evaluates staging, so startup restore (persisted selection validated
|
||||
// by the projection) and reconnect resurfacing open their window with no
|
||||
// dedicated code path. Safe to run synchronously inside the store notify:
|
||||
// the follower writes no list state — session.open()'s synchronous prefix
|
||||
// touches only session-side state and its own microtask-batched notifier.
|
||||
this.list.subscribe(() => { this.followCurrent() })
|
||||
// The runtime's own contribution comes first: useSession rides the same
|
||||
// provide channel every plugin uses (no renderer special case).
|
||||
this.providers.push({
|
||||
hooks: ['session'],
|
||||
resolve: binding => ({ hooks: { session: binding.session } }),
|
||||
})
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a per-session standard-props provider: every session-scope slot
|
||||
* component receives the contributed members as standard props (`hooks`
|
||||
* sources become `use<Name>` selector hooks on the render side; `props`
|
||||
* spread verbatim). Contributions materialize lazily with the session's
|
||||
* scope record and die with it. Registration order is resolution order;
|
||||
* duplicate member names fail loud at materialization.
|
||||
* @param descriptor - static member roster plus per-session resolver.
|
||||
* @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops).
|
||||
*/
|
||||
provide(descriptor: SessionProvideDescriptor): () => void {
|
||||
this.providers.push(descriptor)
|
||||
// Scopes may already exist (boot order: the list lands and resolves
|
||||
// scopes before later plugins register) — their bundles must include
|
||||
// every provider by first render, so re-materialize on roster change.
|
||||
this.rematerializeProvideBundles()
|
||||
return () => {
|
||||
const at = this.providers.indexOf(descriptor)
|
||||
if (at >= 0) this.providers.splice(at, 1)
|
||||
this.rematerializeProvideBundles()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild every live scope's standard-props bundle after a provider roster change. */
|
||||
private rematerializeProvideBundles(): void {
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
for (const record of this.scopes.values()) {
|
||||
record.provideInfo = this.materializeProvideInfo(record.binding)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the static no-session kit and reject duplicate declared names. */
|
||||
private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
|
||||
const hooks: Record<string, undefined> = {}
|
||||
const props: Record<string, undefined> = {}
|
||||
for (const descriptor of this.providers) {
|
||||
for (const name of descriptor.hooks ?? []) {
|
||||
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
|
||||
hooks[name] = undefined
|
||||
}
|
||||
for (const name of descriptor.props ?? []) {
|
||||
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
|
||||
props[name] = undefined
|
||||
}
|
||||
}
|
||||
return { sessionId: undefined, hooks, props }
|
||||
}
|
||||
|
||||
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
|
||||
private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
|
||||
const hooks: Record<string, HostObservable<unknown>> = {}
|
||||
const props: Record<string, unknown> = {}
|
||||
for (const descriptor of this.providers) {
|
||||
const contribution = descriptor.resolve(binding)
|
||||
const contributedHooks = contribution.hooks ?? {}
|
||||
const contributedProps = contribution.props ?? {}
|
||||
for (const name of Object.keys(contributedHooks)) {
|
||||
if (!(descriptor.hooks ?? []).includes(name)) {
|
||||
throw new Error(`sessions.provide: undeclared hook "${name}"`)
|
||||
}
|
||||
}
|
||||
for (const name of Object.keys(contributedProps)) {
|
||||
if (!(descriptor.props ?? []).includes(name)) {
|
||||
throw new Error(`sessions.provide: undeclared prop "${name}"`)
|
||||
}
|
||||
}
|
||||
for (const name of descriptor.hooks ?? []) {
|
||||
const source = contributedHooks[name]
|
||||
if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
|
||||
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
|
||||
hooks[name] = source
|
||||
}
|
||||
for (const name of descriptor.props ?? []) {
|
||||
if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
|
||||
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
|
||||
props[name] = contributedProps[name]
|
||||
}
|
||||
}
|
||||
return { sessionId: binding.sessionId, hooks, props }
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a session as current. Unknown ids fail loud instead of navigating
|
||||
* nowhere.
|
||||
* @param id - session id (must exist in the list store).
|
||||
*/
|
||||
open(id: SessionId): void {
|
||||
this.manager.select(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection so the layout shows the no-session empty
|
||||
* state (new-session affordance and the workspace preselection flow).
|
||||
* Wipes the persisted selection too — a reload stays on empty until the
|
||||
* user opens or starts a session. The staged scope keeps its frozen view
|
||||
* per the masked-gap contract until the next open() moves the stage.
|
||||
*/
|
||||
clear(): void {
|
||||
this.manager.clearSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the real Session baseline, reusing an in-flight pull.
|
||||
* @returns completion of the current or newly started baseline pull.
|
||||
*/
|
||||
refresh(): Promise<void> {
|
||||
return this.manager.refreshList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a mux stream envelope into the Session object layer.
|
||||
* @param envelope - validated mux stream envelope.
|
||||
*/
|
||||
handleMuxEnvelope(envelope: Parameters<SessionManager['handleMuxEnvelope']>[0]): void {
|
||||
this.manager.handleMuxEnvelope(envelope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a Host stream envelope into the Session object layer.
|
||||
* @param envelope - validated Host stream envelope.
|
||||
*/
|
||||
handleHostEnvelope(envelope: Parameters<SessionManager['handleHostEnvelope']>[0]): void {
|
||||
this.manager.handleHostEnvelope(envelope)
|
||||
}
|
||||
|
||||
/** Rebuild the Session baseline and every opened window after connection. */
|
||||
handleConnected(): void {
|
||||
this.manager.handleConnected()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host. Resolution guarantee: by the time the
|
||||
* promise resolves, the created session is in the list store and
|
||||
* {@link SessionsService.binding} resolves it — callers (New Session
|
||||
* draft hand-off) may address the scope synchronously, without waiting a
|
||||
* notifier flush. The synchronous projection below makes this structural
|
||||
* rather than an accident of microtask ordering.
|
||||
* @param opts - target workspace or directory and an optional preallocated id.
|
||||
* @returns the new session id.
|
||||
* @throws {SessionCreateError} with the requested id.
|
||||
*/
|
||||
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
|
||||
const result = await this.manager.create(opts)
|
||||
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
|
||||
this.projectList()
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an Agent-scoped context view (use-and-discard).
|
||||
* @param id - session id (the agent identity — 1:1 same axis).
|
||||
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
scope(id: SessionId): Context | undefined {
|
||||
return this.resolve(id)?.ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Agent scope tag off a context. Service-method seam: fetch
|
||||
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
|
||||
* value import of the standalone helper would inline a second module
|
||||
* instance whose private tag Symbol never matches.
|
||||
* @param ctx - any client context.
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
scopeOf(ctx: Context): SessionId | undefined {
|
||||
return scopeTagOf(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the business Session behind an Agent-scoped context — the one
|
||||
* hop every scoped consumer (event listeners, per-session controllers)
|
||||
* takes from ctx-space into object-space (the client mirror of host
|
||||
* `agent.session`). Same service-method seam as
|
||||
* {@link SessionsService.scopeOf}.
|
||||
* @param ctx - an Agent-scoped context.
|
||||
* @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
|
||||
*/
|
||||
sessionOf(ctx: Context): Session | undefined {
|
||||
const id = scopeTagOf(ctx)
|
||||
if (id === undefined) return undefined
|
||||
return this.scopes.get(id)?.binding.session
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stable session binding (scope-addressed assembly feed). Pure
|
||||
* resolution — no staging, no window side effects.
|
||||
* @param id - session id.
|
||||
* @returns binding, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
binding(id: SessionId): SessionBinding | undefined {
|
||||
return this.resolve(id)?.binding
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the render-layer standard-props bundle (SessionProvider's feed
|
||||
* through the renderer host; ctx never enters the render layer). Pure
|
||||
* resolution — render-safe: SessionProvider calls this during render, so no
|
||||
* staging, no window side effects (StrictMode double-invokes and concurrent
|
||||
* discarded passes must stay free).
|
||||
* @param id - session id.
|
||||
* @returns the provide info, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
provideInfo(id: string): SessionProvideInfo | undefined {
|
||||
return this.resolve(id as SessionId)?.provideInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current-session-optional standard kit. Unknown or absent ids
|
||||
* return the static no-session projection rather than removing hook props.
|
||||
* @param id - current session id, when selected.
|
||||
* @returns a definite or no-session provide bundle.
|
||||
*/
|
||||
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the stage to the list's current session: sweep teardowns deferred
|
||||
* behind the previous occupant and pull the new occupant's history window.
|
||||
* Staging IS the open signal — the window opens ⟺ the session is on stage
|
||||
* — and open() is idempotent (an in-flight or completed open no-ops; a
|
||||
* failed one retries the next time current is touched).
|
||||
*/
|
||||
private followCurrent(): void {
|
||||
const snapshot = this.list.getSnapshot()
|
||||
const current = snapshot.current
|
||||
// A masked gap (current blanked while the selection's session is
|
||||
// transiently absent) holds the stage: tearing down on the gap would
|
||||
// destroy exactly the frozen scope the mask exists to preserve.
|
||||
if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return
|
||||
this.watched = current
|
||||
this.sweepDeferred()
|
||||
const record = this.resolve(current)
|
||||
/* v8 ignore next 3 -- defensive: current is always a listed id (open()
|
||||
* validates and the projection masks absent selections), so resolve
|
||||
* cannot miss; kept so a future current writer cannot crash the notify. */
|
||||
if (record !== undefined) {
|
||||
void record.binding.session.open()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb feed: walk parentId links inside the list store.
|
||||
* @param id - session id.
|
||||
* @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk).
|
||||
*/
|
||||
ancestry(id: SessionId): SessionSummary[] {
|
||||
const { byId } = this.list.getSnapshot()
|
||||
const chain: SessionSummary[] = []
|
||||
let cursor: SessionId | undefined = id
|
||||
while (cursor !== undefined) {
|
||||
const summary: SessionSummary | undefined = byId[cursor]
|
||||
if (summary === undefined || chain.includes(summary)) break
|
||||
chain.unshift(summary)
|
||||
cursor = summary.parentId
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily mint the scope + binding for an eligible session. Eligibility and
|
||||
* prune share one predicate (decision 12): listed on the host — a scope is
|
||||
* born when its session enters the client's view (list mirror row from the
|
||||
* baseline pull, a create() echo, or the session-added frame) and dies with
|
||||
* the prune when the row leaves.
|
||||
*/
|
||||
private resolve(id: SessionId): ScopeRecord | undefined {
|
||||
const existing = this.scopes.get(id)
|
||||
if (existing !== undefined) return existing
|
||||
if (!this.eligible(id)) return undefined
|
||||
const { fiber, ctx } = createScope(this.rootCtx, id)
|
||||
const session = this.manager.get(id)
|
||||
// The Session owns its scoped dispatch point (host Agent.loopCtx mirror);
|
||||
// mint and bind are one step so a live scope record implies a bound actx.
|
||||
session.bindScope(ctx)
|
||||
const binding: SessionBinding = { sessionId: id, session, ctx }
|
||||
const record: ScopeRecord = {
|
||||
fiber,
|
||||
ctx,
|
||||
binding,
|
||||
// Sources are bare observables; React binds selector hooks at its own seam.
|
||||
provideInfo: this.materializeProvideInfo(binding),
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
return record
|
||||
}
|
||||
|
||||
/** The one aliveness predicate shared by scope mint and prune: host-listed. */
|
||||
private eligible(id: SessionId): boolean {
|
||||
return this.list.getSnapshot().byId[id] !== undefined
|
||||
}
|
||||
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const { items, current, phase } = this.manager.getListSnapshot()
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const entry of items) {
|
||||
ids.push(entry.sessionId)
|
||||
byId[entry.sessionId] = {
|
||||
id: entry.sessionId,
|
||||
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
||||
running: entry.running,
|
||||
blank: entry.blank,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
}
|
||||
}
|
||||
const persisted = this.selection.getSnapshot().sessionId
|
||||
// No current (cleared, or masked gap) wipes the persisted cell — a reload
|
||||
// stays on empty; the in-memory selection still resurfaces a masked id.
|
||||
if (current === undefined) {
|
||||
if (persisted !== undefined) this.selection.set({})
|
||||
} else if (byId[current] !== undefined && persisted !== current) {
|
||||
this.selection.set({ sessionId: current })
|
||||
}
|
||||
this.list.set({ ids, byId, current, phase })
|
||||
this.pruneScopes(byId)
|
||||
}
|
||||
|
||||
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
|
||||
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
|
||||
void byId
|
||||
for (const [id, record] of this.scopes) {
|
||||
if (this.eligible(id)) continue
|
||||
if (id === this.watched) {
|
||||
this.deferredRemovals.add(id)
|
||||
continue
|
||||
}
|
||||
this.scopes.delete(id)
|
||||
this.deferredRemovals.delete(id)
|
||||
this.dropScope(id, record)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One teardown for the whole per-session axis (decision 12): the scope
|
||||
* fiber (cascading every actx-registered effect: input shell, slash
|
||||
* controller, popup, plugin stores, listeners), the session-keyed slot
|
||||
* stores, and the Session instance itself — the host session log is the
|
||||
* durable truth, a reopen lazily rebuilds and backfills via open().
|
||||
*/
|
||||
private dropScope(id: SessionId, record: ScopeRecord): void {
|
||||
void record.fiber.dispose()
|
||||
// Release the Session's dispatch point with the scope it belongs to (a
|
||||
// surviving instance — the live Intent — rebinds when resolve re-mints).
|
||||
record.binding.session.unbindScope()
|
||||
// Optional lookup: slots and sessions are sibling services with no
|
||||
// declared dependency; a slots-less boot (object-layer tests) skips.
|
||||
this.rootCtx.get('slots')?.pruneStoreScope(id)
|
||||
this.manager.drop(id)
|
||||
}
|
||||
|
||||
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
|
||||
private sweepDeferred(): void {
|
||||
for (const id of [...this.deferredRemovals]) {
|
||||
/* v8 ignore next -- defensive: only the staged id ever defers, and every
|
||||
* stage move sweeps first, so the set cannot contain the id the stage just
|
||||
* moved to; kept as a guard against future extra sweep call sites. */
|
||||
if (id === this.watched) continue
|
||||
// Eligible again? (A re-added id cancels the deferred teardown.)
|
||||
if (this.eligible(id)) {
|
||||
this.deferredRemovals.delete(id)
|
||||
continue
|
||||
}
|
||||
const record = this.scopes.get(id)
|
||||
this.deferredRemovals.delete(id)
|
||||
/* v8 ignore next -- defensive: prune deletes a scope and its deferral
|
||||
* together, so a deferred id always still owns its record; kept so a
|
||||
* future teardown path cannot double-dispose. */
|
||||
if (record !== undefined) {
|
||||
this.scopes.delete(id)
|
||||
this.dropScope(id, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ export interface WorkspaceListSnapshot {
|
||||
error: RpcError | null
|
||||
}
|
||||
|
||||
type WorkspaceDelta =
|
||||
| { type: 'upsert'; workspace: WorkspaceView }
|
||||
| { type: 'remove'; workspaceId: WorkspaceId }
|
||||
|
||||
/** Workspace object cluster driven by one list baseline and changed-frame upserts. */
|
||||
export class WorkspaceManager {
|
||||
private items: Workspace[] = []
|
||||
@@ -28,7 +32,16 @@ export class WorkspaceManager {
|
||||
private phase: WorkspaceListPhase = 'pending'
|
||||
private error: RpcError | null = null
|
||||
private inflight: Promise<void> | null = null
|
||||
private refreshFrames: WorkspaceView[] | null = null
|
||||
private refreshFrames: WorkspaceDelta[] | null = null
|
||||
/**
|
||||
* Ids this process has seen removed, kept for the connection's lifetime so
|
||||
* a late changed frame or a stale baseline row cannot resurrect a deleted
|
||||
* row. Correctness rests on Host ids never being reused (the registry mints
|
||||
* a fresh `randomUUID` per record, including when the same directory is
|
||||
* registered again) — a path-derived id scheme would turn these entries
|
||||
* into permanent blindfolds and must clear them instead.
|
||||
*/
|
||||
private readonly removedIds = new Set<WorkspaceId>()
|
||||
private snapshotCache: WorkspaceListSnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
@@ -51,7 +64,7 @@ export class WorkspaceManager {
|
||||
this.state = 'loading'
|
||||
this.error = null
|
||||
const established = this.itemViews()
|
||||
const frames: WorkspaceView[] = []
|
||||
const frames: WorkspaceDelta[] = []
|
||||
this.refreshFrames = frames
|
||||
this.notifier.markDirty()
|
||||
this.inflight = (async () => {
|
||||
@@ -61,7 +74,8 @@ export class WorkspaceManager {
|
||||
let items = this.phase === 'pending'
|
||||
? result.value.items
|
||||
: mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId)
|
||||
for (const workspace of frames) items = upsertWorkspace(items, workspace)
|
||||
items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
|
||||
for (const delta of frames) items = applyWorkspaceDelta(items, delta)
|
||||
this.installViews(items)
|
||||
this.state = 'idle'
|
||||
this.phase = 'ready'
|
||||
@@ -111,6 +125,18 @@ export class WorkspaceManager {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Workspace registration and remove its local projection from the
|
||||
* unary response without waiting for the Host frame.
|
||||
* @param workspaceId - target workspace.
|
||||
* @returns the wire result.
|
||||
*/
|
||||
async delete(workspaceId: WorkspaceId): Promise<RpcResult<{ deleted: true }>> {
|
||||
const { result } = await this.api.workspace.delete({ workspaceId })
|
||||
if (result.ok) this.remove(workspaceId, true)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a session within its Workspace's manual order, then publish the
|
||||
* returned snapshot without waiting for the changed frame.
|
||||
@@ -139,6 +165,7 @@ export class WorkspaceManager {
|
||||
*/
|
||||
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
|
||||
if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
|
||||
else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId)
|
||||
}
|
||||
|
||||
/** Re-pull the baseline after each connection generation. */
|
||||
@@ -175,7 +202,8 @@ export class WorkspaceManager {
|
||||
|
||||
/** Upsert one Host view, optionally retaining the local object that materialized it. */
|
||||
private upsert(view: WorkspaceView, identity?: Workspace): void {
|
||||
this.refreshFrames?.push(view)
|
||||
if (this.removedIds.has(view.workspaceId)) return
|
||||
this.refreshFrames?.push({ type: 'upsert', workspace: view })
|
||||
const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId)
|
||||
// Mutation responses and changed frames race (two carriers, no ordering):
|
||||
// reject a snapshot strictly older than the installed projection so a
|
||||
@@ -195,6 +223,24 @@ export class WorkspaceManager {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Remove one id idempotently and retain a tombstone against late echoes. */
|
||||
private remove(workspaceId: WorkspaceId, direct = false): void {
|
||||
this.refreshFrames?.push({ type: 'remove', workspaceId })
|
||||
this.removedIds.add(workspaceId)
|
||||
const items = this.items.filter(item =>
|
||||
item.getSnapshot().view?.workspaceId !== workspaceId)
|
||||
if (items.length === this.items.length) {
|
||||
// The Host frame may have removed the row first but left its batched
|
||||
// notification pending. A successful unary echo still flushes that
|
||||
// committed state before the user action resolves.
|
||||
if (direct) this.notifier.notifyNow()
|
||||
return
|
||||
}
|
||||
this.items = items
|
||||
if (direct) this.notifier.notifyNow()
|
||||
else this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private installViews(views: readonly WorkspaceView[]): void {
|
||||
const existing = new Map(
|
||||
this.items.flatMap((workspace) => {
|
||||
@@ -234,3 +280,10 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi
|
||||
? [workspace, ...items]
|
||||
: items.map((item, position) => position === index ? workspace : item)
|
||||
}
|
||||
|
||||
/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */
|
||||
function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] {
|
||||
return delta.type === 'upsert'
|
||||
? upsertWorkspace(items, delta.workspace)
|
||||
: items.filter(workspace => workspace.workspaceId !== delta.workspaceId)
|
||||
}
|
||||
|
||||
@@ -174,6 +174,16 @@ export class WorkspacesService {
|
||||
return result.value.workspace
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one Workspace registration. Sessions, session logs, and the
|
||||
* directory remain Host-owned outside this operation.
|
||||
* @param workspaceId - target workspace.
|
||||
*/
|
||||
async delete(workspaceId: WorkspaceId): Promise<void> {
|
||||
const result = await this.manager.delete(workspaceId)
|
||||
if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a session within its Workspace's manual order (DOM-insertBefore-like).
|
||||
* @param workspaceId - owning workspace.
|
||||
|
||||
@@ -96,6 +96,9 @@ export class FakeApiClient implements IApiClient {
|
||||
onWorkspaceRename: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
|
||||
|
||||
onWorkspaceDelete: (payload: unknown) => Promise<RpcResponse<{ deleted: true }>> =
|
||||
() => Promise.resolve(ok({ deleted: true }))
|
||||
|
||||
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
|
||||
|
||||
@@ -103,6 +106,7 @@ export class FakeApiClient implements IApiClient {
|
||||
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
|
||||
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
|
||||
rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
|
||||
delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
|
||||
insertSessionBefore: (payload: unknown) =>
|
||||
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
|
||||
}
|
||||
|
||||
@@ -76,6 +76,48 @@ describe('WorkspaceManager', () => {
|
||||
ok: false, error: { code: 'internal', message: 'create transport' },
|
||||
})
|
||||
})
|
||||
|
||||
it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
|
||||
api.onWorkspaceList = () => gate.promise
|
||||
const manager = new WorkspaceManager(api)
|
||||
const hydration = manager.refresh()
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'removed' as never,
|
||||
payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
|
||||
})
|
||||
gate.resolve(ok({ items: [workspace('gone'), workspace('kept')] as never[] }))
|
||||
await hydration
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'late-change' as never,
|
||||
payload: { type: 'host/workspace-changed', workspace: workspace('gone') },
|
||||
})
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'duplicate-remove' as never,
|
||||
payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
|
||||
})
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
|
||||
})
|
||||
|
||||
it('removes from the unary delete echo while a refresh is in flight', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('gone')] as never[] }))
|
||||
const manager = new WorkspaceManager(api)
|
||||
await manager.refresh()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
|
||||
api.onWorkspaceList = () => gate.promise
|
||||
const refresh = manager.refresh()
|
||||
|
||||
await expect(manager.delete(wid('gone'))).resolves.toMatchObject({ ok: true })
|
||||
expect(api.callsOf('workspace.delete')).toEqual([{ workspaceId: 'gone' }])
|
||||
expect(manager.getSnapshot().items).toEqual([])
|
||||
gate.resolve(ok({ items: [workspace('gone')] as never[] }))
|
||||
await refresh
|
||||
expect(manager.getSnapshot().items).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspacesService', () => {
|
||||
@@ -175,4 +217,20 @@ describe('WorkspacesService', () => {
|
||||
}))
|
||||
await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/)
|
||||
})
|
||||
|
||||
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
|
||||
await workspaces.refresh()
|
||||
await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined()
|
||||
expect(workspaces.list.getSnapshot().items).toEqual([])
|
||||
|
||||
api.onWorkspaceDelete = () => Promise.resolve(err({
|
||||
code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' },
|
||||
}))
|
||||
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: e0247b3e26f617f86e9c0094afa1cbc920f02d33
|
||||
README.zh.md: 92ef463faab4b1ccda85d7f3cec1678a338d4010
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
|
||||
README.md: b5a78c30ddae5e12612bb8cced65b5fe95f7e259
|
||||
README.zh.md: 904543a48f1609e23ba80cf240be965d0654a951
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals.
|
||||
|
||||
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization.
|
||||
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
|
||||
|
||||
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
|
||||
|
||||
@@ -18,5 +18,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No Workspace rename/delete controls** — the picker supports selection and creation only.
|
||||
- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions.
|
||||
- **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot,以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot,因此两个表层使用同一菜单和创建模态框。
|
||||
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
|
||||
|
||||
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
|
||||
|
||||
@@ -18,5 +18,5 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有 Workspace 重命名/删除控件**:选择器仅支持选择和创建。
|
||||
- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
|
||||
- **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中。
|
||||
|
||||
@@ -258,6 +258,16 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.deleteAction:not(:disabled) {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.deleteStatus {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wide {
|
||||
animation: none;
|
||||
|
||||
@@ -87,10 +87,15 @@ type SessionTreeProps = Pick<
|
||||
query: string
|
||||
/** Open the browser-owned rename dialog for a real Workspace group. */
|
||||
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
|
||||
onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
}
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) {
|
||||
function SessionTree({
|
||||
useSessions, startSession, open, workspaces, query,
|
||||
onRenameRequest, onDeleteRequest, insertSessionBefore,
|
||||
}: SessionTreeProps) {
|
||||
const list = useSessions((s) => s)
|
||||
const current = list.current
|
||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||
@@ -128,11 +133,17 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen
|
||||
onCreate={() => {
|
||||
if (group.workspaceId !== undefined) startSession(group.workspaceId)
|
||||
}}
|
||||
onRename={group.workspaceId === undefined
|
||||
actions={group.workspaceId === undefined
|
||||
? undefined
|
||||
: () => {
|
||||
/* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
||||
: {
|
||||
rename: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
||||
},
|
||||
delete: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{group.sessions.map((node, index) => {
|
||||
@@ -236,6 +247,7 @@ export function WorkspaceBrowser({
|
||||
startSession,
|
||||
open,
|
||||
renameWorkspace,
|
||||
deleteWorkspace,
|
||||
insertSessionBefore,
|
||||
createWorkspace,
|
||||
}: WorkspaceBrowserProps) {
|
||||
@@ -291,6 +303,41 @@ export function WorkspaceBrowser({
|
||||
})
|
||||
}
|
||||
|
||||
// Delete dialog is separate from the row so a successful removal can
|
||||
// unmount that row without tearing down the in-flight confirmation state.
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteCommittedId, setDeleteCommittedId] = useState<WorkspaceId | null>(null)
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (deleteCommittedId === null
|
||||
|| workspaces.some(workspace => workspace.workspaceId === deleteCommittedId)) return
|
||||
setDeleting(false)
|
||||
setDeleteCommittedId(null)
|
||||
setDeleteTarget(null)
|
||||
}, [deleteCommittedId, workspaces])
|
||||
const closeDelete = () => {
|
||||
if (deleting) return
|
||||
setDeleteTarget(null)
|
||||
setDeleteError(null)
|
||||
}
|
||||
const confirmDelete = () => {
|
||||
/* v8 ignore next -- the Modal is absent without a target and its button is disabled while deleting. */
|
||||
if (deleting || deleteTarget === null) return
|
||||
setDeleting(true)
|
||||
setDeleteCommittedId(null)
|
||||
setDeleteError(null)
|
||||
deleteWorkspace(deleteTarget.workspaceId).then(() => {
|
||||
// Keep the confirmation pending until this component has rendered the
|
||||
// committed list projection without the deleted id. Closing earlier
|
||||
// exposes one stale React frame to the next Create Workspace gesture.
|
||||
setDeleteCommittedId(deleteTarget.workspaceId)
|
||||
}).catch((reason: unknown) => {
|
||||
setDeleting(false)
|
||||
setDeleteError(reason instanceof Error ? reason.message : String(reason))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, !wide && css.rail)}>
|
||||
<div className={css.sectionHeader}>
|
||||
@@ -382,6 +429,10 @@ export function WorkspaceBrowser({
|
||||
setRenameDraft(currentTitle)
|
||||
setRenameError(null)
|
||||
}}
|
||||
onDeleteRequest={(workspaceId, title) => {
|
||||
setDeleteTarget({ workspaceId, title })
|
||||
setDeleteError(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -416,6 +467,30 @@ export function WorkspaceBrowser({
|
||||
)}
|
||||
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
|
||||
</Modal>
|
||||
<Modal
|
||||
open={deleteTarget !== null}
|
||||
onClose={closeDelete}
|
||||
title="Delete workspace"
|
||||
{...deleteTarget === null
|
||||
? {}
|
||||
: { description: `This removes “${deleteTarget.title}” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped.` }}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={css.deleteAction!}
|
||||
disabled={deleting}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
Delete workspace
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{deleting && <div className={css.deleteStatus} role="status">Deleting workspace…</div>}
|
||||
{deleteError !== null && <div className={css.renameError} role="alert">{deleteError}</div>}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function WorkspaceCreateFlow({
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [modalError, setModalError] = useState<string | null>(null)
|
||||
const normalizedWorkspaceName = workspaceName.trim()
|
||||
const duplicateWorkspaceName = normalizedWorkspaceName !== ''
|
||||
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
|
||||
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
|
||||
|
||||
const items: MenuEntry[] = [
|
||||
|
||||
@@ -32,6 +32,8 @@ export type WorkspaceBrowserInjected = {
|
||||
open: (sessionId: SessionId) => void
|
||||
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
|
||||
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
|
||||
/** Delete only a Host Workspace registration; directory and Session logs remain. */
|
||||
deleteWorkspace: (workspaceId: WorkspaceId) => Promise<void>
|
||||
/**
|
||||
* Reorder a session inside its Workspace account (DOM-insertBefore
|
||||
* semantics: omitted anchor appends to the end). The view refreshes from
|
||||
|
||||
@@ -39,6 +39,7 @@ export function apply(ctx: ClientContext): void {
|
||||
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
|
||||
open: (sessionId) => { ctx.sessions.open(sessionId) },
|
||||
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
|
||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||
},
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills
|
||||
* the sidebar shell's `sidebar.workspaces` hole (the whole browsing region),
|
||||
* and WorkspacePicker fills the conversation hero's picker hole
|
||||
* (`conversation.hero.workspace` — both hero forms). Both read real Host
|
||||
* Workspaces through the global useWorkspaces hook. Export discipline:
|
||||
* packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
|
||||
import { createWorkspaceViewStore } from './stores.ts'
|
||||
import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
|
||||
import { WorkspacePicker } from './WorkspacePicker.tsx'
|
||||
|
||||
export type {
|
||||
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slots are declared by
|
||||
* the ui-sidebar / ui-conversation applies, whose activation order relative
|
||||
* to this one is NOT constrained: dshClient.inject edges are informational
|
||||
* (loading/prefetch metadata, never apply sequencing) and neither owner
|
||||
* provides a waitable service. apply therefore registers via
|
||||
* declaration-aware deferral instead of assuming order.
|
||||
*/
|
||||
export const inject = ['slots', 'sessions', 'workspaces']
|
||||
|
||||
/**
|
||||
* Register the browser and picker once their slot declarations are on the
|
||||
* ledger. Inject factories return plain callbacks; data reads use the
|
||||
* framework's global hooks.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const browserInjected = (): WorkspaceBrowserInjected => ({
|
||||
// With a workspace: materialize (reuse-or-create the blank session) and
|
||||
// navigate. Without one: clear the selection — the layout's empty seat
|
||||
// shows the New Session pure view state and the user picks there.
|
||||
startSession: (workspaceId) => {
|
||||
if (workspaceId === undefined) {
|
||||
ctx.sessions.clear()
|
||||
return
|
||||
}
|
||||
void ctx.workspaces.connectWorkspace(workspaceId).then(
|
||||
(sessionId) => { ctx.sessions.open(sessionId) },
|
||||
(reason: unknown) => { console.warn('new session failed:', reason) },
|
||||
)
|
||||
},
|
||||
open: (sessionId) => { ctx.sessions.open(sessionId) },
|
||||
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||
},
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
})
|
||||
const pickerInjected = (): WorkspacePickerInjected => ({
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
})
|
||||
// Declaration-aware registration: each owner's declaring apply may activate
|
||||
// after this one (entry activation order is unconstrained), and a register
|
||||
// into an undeclared slot throws. Register once the declaration is on the
|
||||
// ledger; the subscription also re-registers after an HMR collapse
|
||||
// re-declares the slot (the cascade disposed our entry with it).
|
||||
ctx.effect(() => {
|
||||
const registrations = [
|
||||
{
|
||||
name: 'sidebar.workspaces' as const,
|
||||
component: WorkspaceBrowser,
|
||||
register: () => ctx.slots.register(
|
||||
{ name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected },
|
||||
WorkspaceBrowser,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'conversation.hero.workspace' as const,
|
||||
component: WorkspacePicker,
|
||||
register: () => ctx.slots.register(
|
||||
{ name: 'conversation.hero.workspace', inject: pickerInjected },
|
||||
WorkspacePicker,
|
||||
),
|
||||
},
|
||||
]
|
||||
const disposers = new Map<string, () => void>()
|
||||
const tryRegister = (entry: (typeof registrations)[number]): void => {
|
||||
if (ctx.slots.spec(entry.name) === undefined) return
|
||||
if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return
|
||||
disposers.set(entry.name, entry.register())
|
||||
}
|
||||
const unsubscribers = registrations.map(entry =>
|
||||
ctx.slots.subscribe(entry.name, () => { tryRegister(entry) }))
|
||||
for (const entry of registrations) tryRegister(entry)
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribers) unsubscribe()
|
||||
for (const dispose of disposers.values()) dispose()
|
||||
}
|
||||
}, 'ui-workspace: browser + picker registrations')
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
|
||||
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
|
||||
* except workspace Rename; the session hover card is suppressed while a menu
|
||||
* is open.
|
||||
* is open. Workspace Rename/Delete are wired; session actions remain visual-only.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -39,12 +39,12 @@ const WORKSPACE_MENU_ITEMS = [
|
||||
* @param props.onCreate - start a frontend Session inside this Workspace.
|
||||
* @returns the row element.
|
||||
*/
|
||||
export function ProjectRowItem({ group, onToggle, onCreate, onRename }: {
|
||||
export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
|
||||
group: GroupNode
|
||||
onToggle: () => void
|
||||
onCreate: () => void
|
||||
/** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */
|
||||
onRename?: (() => void) | undefined
|
||||
/** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */
|
||||
actions?: { rename: () => void; delete: () => void } | undefined
|
||||
}) {
|
||||
const row = group
|
||||
const active = group.expanded && group.containsCurrent
|
||||
@@ -68,15 +68,19 @@ export function ProjectRowItem({ group, onToggle, onCreate, onRename }: {
|
||||
<span className={css.meta}>{count}</span>
|
||||
</span>
|
||||
<span className={css.rowActions}>
|
||||
{onRename !== undefined && (
|
||||
{actions !== undefined && (
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
items={WORKSPACE_MENU_ITEMS}
|
||||
onSelect={(id) => {
|
||||
setMenuOpen(false)
|
||||
if (id === 'rename') onRename()
|
||||
// Delete is visual-only for now.
|
||||
// Unknown ids leave before the dispatch: a future menu row must
|
||||
// not inherit the destructive branch as an else fallback.
|
||||
/* v8 ignore next -- WORKSPACE_MENU_ITEMS carries exactly these two rows today. */
|
||||
if (id !== 'rename' && id !== 'delete') return
|
||||
if (id === 'rename') actions.rename()
|
||||
else actions.delete()
|
||||
}}
|
||||
portal
|
||||
closeOnPointerLeave
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
/**
|
||||
* Derives the workspace browser tree from Host Workspace order and membership.
|
||||
* Unassigned Sessions trail under Ungrouped; blank Sessions remain visible.
|
||||
*/
|
||||
import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Group key for Sessions outside every Workspace. */
|
||||
export const UNGROUPED_KEY = ''
|
||||
|
||||
/** Display label for the ungrouped bucket row. */
|
||||
export const UNGROUPED_LABEL = 'Ungrouped'
|
||||
|
||||
/** One session node of a group's visible tree (34px row; children render indented one step). */
|
||||
export interface SessionNode {
|
||||
id: SessionId
|
||||
title: string
|
||||
/** Visible children, already expansion/search-filtered (empty when folded). */
|
||||
children: readonly SessionNode[]
|
||||
/** The session HAS children in the data (the twist renders even while folded). */
|
||||
hasChildren: boolean
|
||||
expanded: boolean
|
||||
running: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** One workspace group section: header row facts + the visible session tree. */
|
||||
export interface GroupNode {
|
||||
/** Group key: the workspace id or {@link UNGROUPED_KEY}. */
|
||||
key: string
|
||||
/** Backing Workspace id; absent only for the ungrouped bucket. */
|
||||
workspaceId: WorkspaceId | undefined
|
||||
cwd: string | undefined
|
||||
label: string
|
||||
/** Total visible sessions in the group. */
|
||||
sessionCount: number
|
||||
expanded: boolean
|
||||
/** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */
|
||||
containsCurrent: boolean
|
||||
/** Visible roots (empty while the group is folded). */
|
||||
sessions: readonly SessionNode[]
|
||||
}
|
||||
|
||||
/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
|
||||
export interface TreeView {
|
||||
expandedProjects: readonly string[]
|
||||
expandedSessions: readonly string[]
|
||||
query: string
|
||||
}
|
||||
|
||||
interface Group {
|
||||
key: string
|
||||
workspaceId: WorkspaceId | undefined
|
||||
cwd: string | undefined
|
||||
label: string
|
||||
summaries: Map<SessionId, SessionSummary>
|
||||
roots: SessionId[]
|
||||
children: Map<SessionId, SessionId[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory display label: basename of the path (both separators accepted).
|
||||
* Ungrouped-bucket fallback for surfaces without a workspace title.
|
||||
* @param cwd - directory path, or undefined for the ungrouped bucket.
|
||||
* @returns basename, the raw cwd when it has no basename, or the ungrouped label.
|
||||
*/
|
||||
export function projectLabel(cwd: string | undefined): string {
|
||||
if (cwd === undefined || cwd === '') return UNGROUPED_LABEL
|
||||
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
||||
return base !== undefined && base !== '' ? base : cwd
|
||||
}
|
||||
|
||||
/** Recency comparator: newest first, id as the deterministic tiebreak (ids are unique per group). */
|
||||
function byRecency(a: SessionSummary, b: SessionSummary): number {
|
||||
if (b.updatedAt !== a.updatedAt) return b.updatedAt - a.updatedAt
|
||||
return a.id < b.id ? -1 : 1
|
||||
}
|
||||
|
||||
/** Build one group's parent/child tree from an ordered member list. */
|
||||
function buildGroup(
|
||||
key: string,
|
||||
workspaceId: WorkspaceId | undefined,
|
||||
cwd: string | undefined,
|
||||
label: string,
|
||||
members: readonly SessionSummary[],
|
||||
order: 'account' | 'recency',
|
||||
): Group {
|
||||
const summaries = new Map(members.map(m => [m.id, m]))
|
||||
const children = new Map<SessionId, SessionId[]>()
|
||||
const roots: SessionSummary[] = []
|
||||
for (const m of members) {
|
||||
// A session is a tree child only when its parent lives in the same
|
||||
// group; cross-group or unknown parents degrade to group roots.
|
||||
if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) {
|
||||
const kids = children.get(m.parentId)
|
||||
if (kids === undefined) children.set(m.parentId, [m.id])
|
||||
else kids.push(m.id)
|
||||
} else {
|
||||
roots.push(m)
|
||||
}
|
||||
}
|
||||
// Workspace order is the member iteration order (workspace.sessionIds), so
|
||||
// attached groups keep insertion order; Ungrouped sorts by recency.
|
||||
if (order === 'recency') {
|
||||
roots.sort(byRecency)
|
||||
for (const kids of children.values()) {
|
||||
kids.sort((a, b) => {
|
||||
const sa = summaries.get(a)
|
||||
const sb = summaries.get(b)
|
||||
/* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */
|
||||
if (sa === undefined || sb === undefined) return 0
|
||||
return byRecency(sa, sb)
|
||||
})
|
||||
}
|
||||
}
|
||||
const rootIds = roots.map(r => r.id)
|
||||
// parentId cycles (host bug) leave members unreachable from any root;
|
||||
// surface them as extra roots — the flatten walk's visited set stops
|
||||
// loops. Each node sits in at most one kids list and roots have no
|
||||
// in-group parent, so the scan pushes every reachable node exactly once.
|
||||
const reachable = new Set<SessionId>(rootIds)
|
||||
const stack = [...rootIds]
|
||||
while (stack.length > 0) {
|
||||
const top = stack.pop()
|
||||
/* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */
|
||||
if (top === undefined) break
|
||||
for (const kid of children.get(top) ?? []) {
|
||||
reachable.add(kid)
|
||||
stack.push(kid)
|
||||
}
|
||||
}
|
||||
for (const m of members) {
|
||||
if (!reachable.has(m.id)) rootIds.push(m.id)
|
||||
}
|
||||
return { key, workspaceId, cwd, label, summaries, roots: rootIds, children }
|
||||
}
|
||||
|
||||
/**
|
||||
* Group Sessions by Host Workspace: one group per entity in stable Host
|
||||
* order, with members resolved from sessionIds in their stored order. Sessions
|
||||
* outside every Workspace trail in the recency-ordered Ungrouped bucket.
|
||||
*/
|
||||
function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] {
|
||||
const groups: Group[] = []
|
||||
const accounted = new Set<SessionId>()
|
||||
for (const workspace of workspaces) {
|
||||
const members: SessionSummary[] = []
|
||||
for (const id of workspace.sessionIds) {
|
||||
const summary = list.byId[id]
|
||||
if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands
|
||||
accounted.add(id)
|
||||
members.push(summary)
|
||||
}
|
||||
groups.push(buildGroup(
|
||||
workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.title, members, 'account',
|
||||
))
|
||||
}
|
||||
const stray = list.ids
|
||||
.map(id => list.byId[id])
|
||||
.filter((s): s is SessionSummary => s !== undefined && !accounted.has(s.id))
|
||||
if (stray.length > 0) {
|
||||
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode {
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.displayTitle,
|
||||
children,
|
||||
hasChildren,
|
||||
expanded,
|
||||
running: s.running,
|
||||
updatedAt: s.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function buildVisible(g: Group, expandedSessions: ReadonlySet<string>): SessionNode[] {
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (id: SessionId): SessionNode | null => {
|
||||
if (visited.has(id)) return null
|
||||
visited.add(id)
|
||||
const s = g.summaries.get(id)
|
||||
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
|
||||
if (s === undefined) return null
|
||||
const kids = g.children.get(id) ?? []
|
||||
const expanded = expandedSessions.has(id)
|
||||
const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : []
|
||||
return sessionNode(s, children, kids.length > 0, expanded)
|
||||
}
|
||||
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
}
|
||||
|
||||
/** Matched sessions plus their ancestor chains (forced visible under search). */
|
||||
function searchVisible(g: Group, q: string): Set<SessionId> {
|
||||
const visible = new Set<SessionId>()
|
||||
for (const m of g.summaries.values()) {
|
||||
if (!m.displayTitle.toLowerCase().includes(q)) continue
|
||||
let cur: SessionSummary | undefined = m
|
||||
while (cur !== undefined && !visible.has(cur.id)) {
|
||||
visible.add(cur.id)
|
||||
cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (id: SessionId): SessionNode | null => {
|
||||
if (visited.has(id) || !visible.has(id)) return null
|
||||
visited.add(id)
|
||||
const s = g.summaries.get(id)
|
||||
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
|
||||
if (s === undefined) return null
|
||||
const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid))
|
||||
const children = kids.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
return sessionNode(s, children, kids.length > 0, kids.length > 0)
|
||||
}
|
||||
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the nested workspace browser group structure.
|
||||
*
|
||||
* Normal mode: every group shows; sessions populate under expanded groups,
|
||||
* descending only into expanded sessions. Search mode (non-blank query,
|
||||
* case-insensitive display-title substring): expansion state is ignored —
|
||||
* matched sessions and their ancestor chains are forced visible, groups
|
||||
* without a display-title or label hit are dropped, and a label-only hit
|
||||
* keeps the bare group header. Blank sessions are excluded everywhere.
|
||||
* @param list - sessions list snapshot (`current` feeds containsCurrent).
|
||||
* @param workspaces - real workspaces in stable Host order.
|
||||
* @param view - local expansion arrays and search query.
|
||||
* @returns group sections in render order.
|
||||
*/
|
||||
export function deriveGroups(
|
||||
list: SessionListState,
|
||||
workspaces: readonly WorkspaceView[],
|
||||
view: TreeView,
|
||||
): GroupNode[] {
|
||||
const q = view.query.trim().toLowerCase()
|
||||
const expandedProjects = new Set(view.expandedProjects)
|
||||
const expandedSessions = new Set(view.expandedSessions)
|
||||
const currentGroup = list.current === undefined
|
||||
? undefined
|
||||
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
|
||||
?? UNGROUPED_KEY
|
||||
const groups: GroupNode[] = []
|
||||
for (const g of groupByWorkspace(list, workspaces)) {
|
||||
if (q === '') {
|
||||
const expanded = expandedProjects.has(g.key)
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size,
|
||||
expanded,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: expanded ? buildVisible(g, expandedSessions) : [],
|
||||
})
|
||||
} else {
|
||||
const visible = searchVisible(g, q)
|
||||
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size,
|
||||
expanded: visible.size > 0,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: buildSearch(g, visible),
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the flat session list ("In one list" mode): every session — fork
|
||||
* children included — as a top-level row, strictly newest-first. No grouping,
|
||||
* no parent/child adjacency; rows reuse SessionNode with children always
|
||||
* empty so the renderer stays branch-free. Search mode filters by
|
||||
* case-insensitive display-title substring.
|
||||
* @param list - sessions list snapshot.
|
||||
* @param view - the search query (expansion state does not apply).
|
||||
* @returns flat rows in render order.
|
||||
*/
|
||||
export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>): SessionNode[] {
|
||||
const q = view.query.trim().toLowerCase()
|
||||
const rows: SessionSummary[] = []
|
||||
for (const id of list.ids) {
|
||||
const s = list.byId[id]
|
||||
if (s === undefined) continue
|
||||
if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue
|
||||
rows.push(s)
|
||||
}
|
||||
rows.sort(byRecency)
|
||||
return rows.map(s => sessionNode(s, [], false, false))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y").
|
||||
* @param updatedAt - epoch ms of the session's last activity.
|
||||
* @param now - current epoch ms (injected for pure rendering).
|
||||
* @returns the row's trailing time label.
|
||||
*/
|
||||
export function formatRelativeTime(updatedAt: number, now: number): string {
|
||||
const MIN = 60_000
|
||||
const HOUR = 3_600_000
|
||||
const DAY = 86_400_000
|
||||
const diff = Math.max(0, now - updatedAt)
|
||||
if (diff < MIN) return 'now'
|
||||
if (diff < HOUR) return `${Math.floor(diff / MIN)}min`
|
||||
if (diff < DAY) return `${Math.floor(diff / HOUR)}h`
|
||||
if (diff < 30 * DAY) return `${Math.floor(diff / DAY)}d`
|
||||
if (diff < 365 * DAY) return `${Math.floor(diff / (30 * DAY))}mo`
|
||||
return `${Math.floor(diff / (365 * DAY))}y`
|
||||
}
|
||||
@@ -98,12 +98,16 @@ describe('workspace browser rows', () => {
|
||||
|
||||
it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
|
||||
const onRename = vi.fn()
|
||||
const onDelete = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={vi.fn()} onRename={onRename} />)
|
||||
render(<ProjectRowItem
|
||||
group={group} onToggle={onToggle} onCreate={vi.fn()}
|
||||
actions={{ rename: onRename, delete: onDelete }}
|
||||
/>)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
// Opening the menu neither toggles the group nor renames yet.
|
||||
expect(onToggle).not.toHaveBeenCalled()
|
||||
@@ -111,11 +115,11 @@ describe('workspace browser rows', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
// Delete stays visual-only: selecting it just closes the menu.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
// Escape closes without selecting (Menu onClose path).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
@@ -54,6 +54,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
startSession: vi.fn(),
|
||||
open: vi.fn(),
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
deleteWorkspace: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
...overrides,
|
||||
@@ -457,6 +458,79 @@ describe('WorkspaceBrowser', () => {
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
|
||||
})
|
||||
|
||||
it('confirms Workspace deletion, explains retention, and blocks duplicate submission', async () => {
|
||||
let resolveDelete!: () => void
|
||||
const deleteWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveDelete = resolve }))
|
||||
const browser = mount({
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['session'], 'Alpha')])),
|
||||
deleteWorkspace,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'Delete workspace' })
|
||||
expect(dialog.textContent).toContain('removes “Alpha” from the workspace list')
|
||||
expect(dialog.textContent).toContain('folder and session logs will be kept')
|
||||
expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
|
||||
|
||||
const confirm = screen.getByRole('button', { name: 'Delete workspace' }) as HTMLButtonElement
|
||||
fireEvent.click(confirm)
|
||||
fireEvent.click(confirm)
|
||||
expect(deleteWorkspace).toHaveBeenCalledOnce()
|
||||
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
|
||||
await act(async () => { resolveDelete() })
|
||||
// RPC success alone does not close: the component waits until its
|
||||
// useWorkspaces projection has committed the removal, preventing a stale
|
||||
// duplicate-name frame from leaking into the next create gesture.
|
||||
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
|
||||
rerender(browser, { useWorkspaces: hook(workspaceState([])) })
|
||||
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the delete dialog open on failure and allows retry or cancellation', async () => {
|
||||
const deleteWorkspace = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
.mockRejectedValueOnce('denied')
|
||||
mount({
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
|
||||
deleteWorkspace,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' }))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('storage unavailable') })
|
||||
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' }))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
|
||||
})
|
||||
|
||||
it('Cancel, Escape, and Close dismiss deletion without calling the action', () => {
|
||||
const deleteWorkspace = vi.fn(async () => {})
|
||||
mount({
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
|
||||
deleteWorkspace,
|
||||
})
|
||||
const open = () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
|
||||
}
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
open()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(deleteWorkspace).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
|
||||
})
|
||||
|
||||
it('search hides drag affordances (rows are not draggable during search)', () => {
|
||||
const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })])
|
||||
mount({
|
||||
|
||||
@@ -35,18 +35,25 @@ function anchor(): { current: HTMLElement } {
|
||||
function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) {
|
||||
const onPick = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const view = render(
|
||||
const anchorRef = anchor()
|
||||
const renderPicker = (nextItems: readonly WorkspaceView[]) => (
|
||||
<WorkspacePicker
|
||||
open
|
||||
anchorRef={anchor()}
|
||||
anchorRef={anchorRef}
|
||||
useSessions={hook(sessions)}
|
||||
useWorkspaces={hook(workspaceState(items))}
|
||||
useWorkspaces={hook(workspaceState(nextItems))}
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
createWorkspace={createWorkspace}
|
||||
/>,
|
||||
/>
|
||||
)
|
||||
return { view, onPick, onClose, createWorkspace }
|
||||
const view = render(
|
||||
renderPicker(items),
|
||||
)
|
||||
return {
|
||||
view, onPick, onClose, createWorkspace,
|
||||
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
|
||||
}
|
||||
}
|
||||
|
||||
function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void {
|
||||
@@ -106,6 +113,22 @@ describe('WorkspacePicker', () => {
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not flash a duplicate alert when the successful create frame arrives before its unary response', async () => {
|
||||
let resolve!: (workspace: WorkspaceView) => void
|
||||
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
|
||||
const created = workspace('fresh', 'same-name')
|
||||
const b = mount([], vi.fn(() => pending))
|
||||
chooseCreateItem('Create a new workspace')
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
|
||||
b.rerenderItems([created])
|
||||
expect(screen.getByRole('status').textContent).toBe('Creating workspace…')
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
await act(async () => { resolve(created); await pending })
|
||||
expect(b.onPick).toHaveBeenCalledWith(created.workspaceId)
|
||||
})
|
||||
|
||||
it('exposes creation phase and error text while retaining the modal for retry', async () => {
|
||||
let reject!: (reason: unknown) => void
|
||||
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
@@ -36,12 +36,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
console.log(JSON.stringify(result))
|
||||
process.exit(0)
|
||||
`
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
|
||||
const exitCode = await new Promise<number | null>(resolve => child.on('close', resolve))
|
||||
const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], {
|
||||
cwd: pkgDir,
|
||||
stdin: 'ignore',
|
||||
timeout: 55_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
})
|
||||
|
||||
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
|
||||
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
|
||||
|
||||
@@ -950,6 +950,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'list(): Workspace[]',
|
||||
jsDoc: '/**\n * Synchronous workspace projection in durable registry order. Every\n * entity\'s `sessionIds` getter is already filtered by the startup/live\n * canonical-cwd header index; this method performs no persistence reads.\n * @returns a fresh ordered array of workspace entities.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'delete(id: WorkspaceId): Promise<boolean>',
|
||||
jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>',
|
||||
jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */',
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { promisify } from 'node:util'
|
||||
import { zstdDecompress } from 'node:zlib'
|
||||
import { execa } from 'execa'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
@@ -209,25 +210,19 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */
|
||||
function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(process.execPath, [acpBin, '--config', configArg], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
child = proc
|
||||
let stderr = ''
|
||||
proc.stderr.setEncoding('utf8')
|
||||
proc.stderr.on('data', (c: string) => { stderr += c })
|
||||
const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error(`bin did not exit within 25s. stderr:\n${stderr}`)) }, 25_000)
|
||||
proc.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stderr }) })
|
||||
proc.on('error', (err) => { clearTimeout(timer); reject(err) })
|
||||
proc.stdin.end()
|
||||
/** Spawn the built acp bin against `configArg` (stdin closed at EOF) and resolve with its exit code + stderr. */
|
||||
async function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
|
||||
const result = await execa(process.execPath, [acpBin, '--config', configArg], {
|
||||
cwd,
|
||||
env: {
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
input: '',
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
})
|
||||
if (result.timedOut) throw new Error(`bin did not exit within 25s. stderr:\n${result.stderr}`)
|
||||
return { code: result.exitCode ?? -1, stderr: result.stderr }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -6,6 +5,7 @@ import { dirname, join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { zstdDecompress } from 'node:zlib'
|
||||
import { execa } from 'execa'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
@@ -114,36 +114,34 @@ interface BinResult {
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise<BinResult> {
|
||||
return new Promise((resolveResult, reject) => {
|
||||
const child = spawn(process.execPath, [cliBin, ...args], {
|
||||
cwd,
|
||||
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
async function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise<BinResult> {
|
||||
const subprocess = execa(process.execPath, [cliBin, ...args], {
|
||||
cwd,
|
||||
env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
|
||||
stdin: 'ignore',
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
stripFinalNewline: false,
|
||||
})
|
||||
// Genuinely custom mid-stream logic: the signal cases deliver `interrupt`
|
||||
// once the first streamed chunk proves the turn is in flight.
|
||||
if (interrupt !== undefined) {
|
||||
let streamed = ''
|
||||
let interrupted = false
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
if (interrupt !== undefined && !interrupted && stdout.includes('assistant/chunk')) {
|
||||
subprocess.stdout.on('data', (chunk: Buffer) => {
|
||||
streamed += chunk.toString('utf8')
|
||||
if (!interrupted && streamed.includes('assistant/chunk')) {
|
||||
interrupted = true
|
||||
child.kill(interrupt)
|
||||
subprocess.kill(interrupt)
|
||||
}
|
||||
})
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error(`built CLI did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}, 25_000)
|
||||
child.once('error', (error) => { clearTimeout(timer); reject(error) })
|
||||
child.once('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
resolveResult({ code: code ?? -1, signal, stdout, stderr })
|
||||
})
|
||||
})
|
||||
}
|
||||
const result = await subprocess
|
||||
if (result.timedOut) {
|
||||
throw new Error(`built CLI did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
return { code: result.exitCode ?? -1, signal: result.signal ?? null, stdout: result.stdout, stderr: result.stderr }
|
||||
}
|
||||
|
||||
let consumer: string | undefined
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: 775ceec5b2171b81977650b739b5b94662902dd5
|
||||
README.zh.md: d28d6cdad43b7c944f6bdf17e80e99494b39308f
|
||||
README.md: adcffaf553bb62f77e38cf8f19f5b48e8d7881e1
|
||||
README.zh.md: 957b7967c95131f3da6cd25ce395fba8db57b020
|
||||
|
||||
@@ -12,7 +12,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
|
||||
|
||||
The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
mux 流会在每个已附加会话的订阅基线之后,以及对应的实时原始标题事件之后,立即把基于日志的最新标题投影为经过校验的 `session/title` 控制帧。该投影不会把标题加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
|
||||
|
||||
|
||||
@@ -777,6 +777,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return ok(request, { workspace: workspaceView(workspace) })
|
||||
},
|
||||
|
||||
async delete(request) {
|
||||
const { workspaceId } = request.payload
|
||||
const operation = workspaceCreationChain.then(() =>
|
||||
ctx.workspace.delete(brandWorkspaceId(workspaceId)))
|
||||
workspaceCreationChain = operation.then(() => undefined, () => undefined)
|
||||
if (!await operation) return workspaceNotFound(request, workspaceId)
|
||||
return ok(request, { deleted: true as const })
|
||||
},
|
||||
|
||||
async insertSessionBefore(request) {
|
||||
const { payload } = request
|
||||
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
|
||||
@@ -990,8 +999,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
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.domain !== 'workspace') return
|
||||
if (change.table === '') {
|
||||
if (change.operation !== 'put') return
|
||||
const state = workspaceDomainState.parse(change.value)
|
||||
for (const workspaceId of state.workspaceIds) {
|
||||
if (committedWorkspaceIds.has(workspaceId)) continue
|
||||
@@ -1004,7 +1014,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
return
|
||||
}
|
||||
if (change.table !== 'workspaces' || !committedWorkspaceIds.has(change.key)) return
|
||||
if (change.table !== 'workspaces') return
|
||||
if (change.operation === 'deleted') {
|
||||
if (!committedWorkspaceIds.delete(change.key)) return
|
||||
queue.push(frame({
|
||||
type: 'host/workspace-removed',
|
||||
workspaceId: change.key as WorkspaceId,
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (!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({
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts'
|
||||
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
|
||||
import { approvalRequestIdSchema } from './approvals.schema.ts'
|
||||
import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
|
||||
import { workspaceViewSchema } from './workspace.schema.ts'
|
||||
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
/** Question shape validated strictly against core dsh-user-interaction. */
|
||||
export const askUserQuestionItemSchema = z.object({
|
||||
@@ -47,6 +47,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
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('host/workspace-removed'), workspaceId: workspaceIdSchema }),
|
||||
z.object({ type: z.literal('host/commands-changed') }),
|
||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||
]) as unknown as z.ZodType<HostFrame>
|
||||
|
||||
@@ -85,7 +85,9 @@ export type MuxFrame =
|
||||
* 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).
|
||||
* `workspace.list` provides the reconnect baseline); workspace-removed is the
|
||||
* committed registration-deletion increment and never implies directory or
|
||||
* session-log deletion.
|
||||
*/
|
||||
export type HostFrame =
|
||||
| { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string }
|
||||
@@ -93,6 +95,7 @@ export type HostFrame =
|
||||
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
|
||||
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
|
||||
| { type: 'host/workspace-changed'; workspace: WorkspaceView }
|
||||
| { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] }
|
||||
/**
|
||||
* The command registry changed (`commands/change` passthrough). Pure
|
||||
* invalidation signal, no payload: clients refetch `command.list` in the
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface RpcMethodMap {
|
||||
'workspace.list': WorkspaceApi['list']
|
||||
'workspace.create': WorkspaceApi['create']
|
||||
'workspace.rename': WorkspaceApi['rename']
|
||||
'workspace.delete': WorkspaceApi['delete']
|
||||
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
|
||||
'command.list': CommandsApi['list']
|
||||
'command.execute': CommandsApi['execute']
|
||||
|
||||
@@ -59,6 +59,16 @@ export const workspaceRenameValueSchema = z.object({
|
||||
workspace: workspaceViewSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.rename'>>>
|
||||
|
||||
/** workspace.delete request payload. */
|
||||
export const workspaceDeleteRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.delete'>>>
|
||||
|
||||
/** workspace.delete response value. */
|
||||
export const workspaceDeleteValueSchema = z.object({
|
||||
deleted: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.delete'>>>
|
||||
|
||||
/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
|
||||
export const workspaceInsertSessionBeforeRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
|
||||
@@ -65,6 +65,14 @@ export interface WorkspaceApi {
|
||||
rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>):
|
||||
Promise<RpcResponse<{ workspace: WorkspaceView }>>
|
||||
|
||||
/**
|
||||
* Removes one Workspace registration. The directory, every user file, and
|
||||
* every session log remain untouched; those Sessions consequently become
|
||||
* ungrouped. An unknown id fails with `workspace-not-found`.
|
||||
*/
|
||||
delete(request: RpcRequest<{ workspaceId: WorkspaceId }>):
|
||||
Promise<RpcResponse<{ deleted: true }>>
|
||||
|
||||
/**
|
||||
* Moves an accounted session within its workspace's manual order,
|
||||
* DOM-insertBefore-like: with `beforeSessionId` the session is inserted
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../api/sessions.schema.ts'
|
||||
import {
|
||||
workspaceCreateValueSchema,
|
||||
workspaceDeleteValueSchema,
|
||||
workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListValueSchema,
|
||||
workspaceRenameValueSchema,
|
||||
@@ -60,6 +61,7 @@ export interface IApiClient {
|
||||
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
|
||||
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
|
||||
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
|
||||
delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.delete'>>>
|
||||
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
|
||||
}
|
||||
commands: {
|
||||
@@ -91,6 +93,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'workspace.list': workspaceListValueSchema,
|
||||
'workspace.create': workspaceCreateValueSchema,
|
||||
'workspace.rename': workspaceRenameValueSchema,
|
||||
'workspace.delete': workspaceDeleteValueSchema,
|
||||
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
|
||||
'command.list': commandListValueSchema,
|
||||
'command.execute': commandExecuteValueSchema,
|
||||
@@ -285,6 +288,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
|
||||
create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
|
||||
rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
|
||||
delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal),
|
||||
insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
|
||||
import {
|
||||
workspaceCreateRequestSchema,
|
||||
workspaceDeleteRequestSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema,
|
||||
workspaceListRequestSchema,
|
||||
workspaceRenameRequestSchema,
|
||||
@@ -57,6 +58,7 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
|
||||
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
|
||||
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
|
||||
'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) },
|
||||
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
|
||||
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
|
||||
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
|
||||
|
||||
@@ -244,4 +244,37 @@ describe('Host Workspace increments', () => {
|
||||
abort.abort()
|
||||
expect(await next).toMatchObject({ done: true })
|
||||
})
|
||||
|
||||
it('deletes the registration, keeps its session and folder, and streams one removal', async () => {
|
||||
const { api, ctx } = await harness()
|
||||
const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace
|
||||
const sessionId = SessionId('session-kept-after-workspace-delete')
|
||||
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
||||
|
||||
const abort = new AbortController()
|
||||
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
||||
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
||||
const removed = nextHostFrame(stream)
|
||||
expectOk(await api.workspace.delete(request({ workspaceId: workspace.workspaceId })))
|
||||
expect(await removed).toMatchObject({
|
||||
payload: { type: 'host/workspace-removed', workspaceId: workspace.workspaceId },
|
||||
})
|
||||
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
|
||||
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
|
||||
expect(ctx.agents.get(sessionId)).toBeDefined()
|
||||
expect(existsSync(workspace.path)).toBe(true)
|
||||
|
||||
const missing = await api.workspace.delete(request({ workspaceId: workspace.workspaceId }))
|
||||
expect(missing.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } },
|
||||
})
|
||||
|
||||
const reregistered = expectOk(await api.workspace.create(request({ path: workspace.path }))).workspace
|
||||
expect(reregistered.workspaceId).not.toBe(workspace.workspaceId)
|
||||
expect(reregistered.path).toBe(workspace.path)
|
||||
expect(reregistered.sessionIds).toEqual([])
|
||||
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
|
||||
abort.abort()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,6 +40,7 @@ function scriptedApi(overrides: {
|
||||
list: r => ok(r, { items: [] }),
|
||||
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
|
||||
rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
delete: r => ok(r, { deleted: true as const }),
|
||||
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
},
|
||||
commands: {
|
||||
@@ -76,13 +77,15 @@ describe('unary round trip', () => {
|
||||
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
|
||||
})
|
||||
|
||||
it('routes workspace rename and insertSessionBefore through the wire', async () => {
|
||||
it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => {
|
||||
const api = scriptedApi()
|
||||
const c = client(api)
|
||||
const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' })
|
||||
expect(renamed.result.ok).toBe(true)
|
||||
const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' })
|
||||
expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } })
|
||||
const deleted = await c.workspace.delete({ workspaceId: 'w1' as never })
|
||||
expect(deleted.result).toEqual({ ok: true, value: { deleted: true } })
|
||||
const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') })
|
||||
expect(anchored.result.ok).toBe(true)
|
||||
const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') })
|
||||
|
||||
@@ -64,6 +64,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
|
||||
}
|
||||
},
|
||||
async delete(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } }
|
||||
},
|
||||
async insertSessionBefore(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
|
||||
import {
|
||||
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
|
||||
workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListRequestSchema, workspaceListValueSchema,
|
||||
workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema,
|
||||
@@ -177,6 +178,13 @@ describe('workspace domain schemas', () => {
|
||||
expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
|
||||
})
|
||||
|
||||
it('validates workspace deletion payload and receipt', () => {
|
||||
expect(workspaceDeleteRequestSchema.parse({ workspaceId: 'w1' }).workspaceId).toBe('w1')
|
||||
expect(() => workspaceDeleteRequestSchema.parse({})).toThrow()
|
||||
expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true })
|
||||
expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow()
|
||||
})
|
||||
|
||||
it('insertSessionBefore accepts an anchored and an anchorless move', () => {
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2')
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined()
|
||||
@@ -273,6 +281,11 @@ describe('events frame schemas', () => {
|
||||
{ type: 'host/session-removed', sessionId: 's' },
|
||||
{ type: 'host/session-status', sessionId: 's', running: true },
|
||||
{ type: 'host/agent-error', sessionId: 's', message: 'boom' },
|
||||
{ type: 'host/workspace-changed', workspace: {
|
||||
workspaceId: 'w', path: '/w', title: 'w', sessionIds: [],
|
||||
createdAt: '0', updatedAt: '0',
|
||||
} },
|
||||
{ type: 'host/workspace-removed', workspaceId: 'w' },
|
||||
{ type: 'host/commands-changed' },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
@@ -57,12 +57,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
console.log(JSON.stringify(result))
|
||||
await ctx.fiber.dispose()
|
||||
`
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
|
||||
const exitCode = await new Promise<number | null>(resolve => child.on('close', resolve))
|
||||
const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], {
|
||||
cwd: pkgDir,
|
||||
stdin: 'ignore',
|
||||
timeout: 55_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
})
|
||||
|
||||
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
|
||||
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import SessionStore, {
|
||||
SessionId, TOOL_OUTCOME_UNKNOWN,
|
||||
type SessionEvent,
|
||||
@@ -19,22 +19,21 @@ const roots: string[] = []
|
||||
const CHILD_FAILPOINT_TIMEOUT_MS = 30_000
|
||||
|
||||
async function waitForMarker(path: string, expected: string): Promise<string> {
|
||||
const deadline = Date.now() + CHILD_FAILPOINT_TIMEOUT_MS
|
||||
for (;;) {
|
||||
try {
|
||||
const content = await readFile(path, 'utf8')
|
||||
if (content === expected) return content
|
||||
if (!expected.startsWith(content)) {
|
||||
throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// vi.waitFor retries every callback throw, so terminal states RESOLVE out
|
||||
// of the retry loop (complete marker, or content that can no longer become
|
||||
// the expected marker) and only the still-in-progress states throw-to-retry.
|
||||
const content = await vi.waitFor(async () => {
|
||||
const current = await readFile(path, 'utf8').catch((error: unknown) => {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`)
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`, { cause: error })
|
||||
})
|
||||
if (current === expected || !expected.startsWith(current)) return current
|
||||
throw new Error(`crash child has not finished publishing failpoint ${JSON.stringify(expected)}`)
|
||||
}, { interval: 10, timeout: CHILD_FAILPOINT_TIMEOUT_MS })
|
||||
if (content !== expected) {
|
||||
throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> {
|
||||
@@ -44,26 +43,24 @@ async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; marker
|
||||
// Keep the open-before-write window deterministic: readiness is marker content, not path existence.
|
||||
await writeFile(marker, '')
|
||||
const expectedMarker = mode === 'request' ? 'request-dispatched' : 'tool-side-effect'
|
||||
const child = spawn(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], {
|
||||
// The SIGKILL-at-failpoint choreography stays custom: the child must die
|
||||
// mid-write, so no timeout or graceful termination may reach it first.
|
||||
const child = execa(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
env: { TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
|
||||
stdin: 'ignore',
|
||||
stdout: 'ignore',
|
||||
reject: false,
|
||||
})
|
||||
let stderr = ''
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
try {
|
||||
const markerText = await waitForMarker(marker, expectedMarker)
|
||||
const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
||||
child.once('close', (code, signal) => { resolve({ code, signal }) })
|
||||
})
|
||||
child.kill('SIGKILL')
|
||||
const exit = await closed
|
||||
expect(exit).toEqual({ code: null, signal: 'SIGKILL' })
|
||||
const exit = await child
|
||||
expect({ code: exit.exitCode ?? null, signal: exit.signal ?? null }).toEqual({ code: null, signal: 'SIGKILL' })
|
||||
return { root, markerText }
|
||||
} catch (error: unknown) {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
|
||||
throw new Error(`crash child failed: ${stderr}`, { cause: error })
|
||||
child.kill('SIGKILL')
|
||||
throw new Error(`crash child failed: ${(await child).stderr}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: d35872e5bb06be88dc5999bfa1800083b2fbbf3c
|
||||
README.zh.md: a706b6db5408538c578cb2a1cfc3aa99804a930d
|
||||
README.md: d22e6e2d95a1ed930a7f4876daf4b06e2f761f7a
|
||||
README.zh.md: 514b7ebfe02cb34ed559633a0fd82cf6194fa4b3
|
||||
|
||||
@@ -57,7 +57,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon
|
||||
|
||||
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md).
|
||||
|
||||
Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.
|
||||
Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ defineAcpSnapshotSuite({
|
||||
|
||||
示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及每个 pin 的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。
|
||||
|
||||
约束:`suite.ts` 导入 vitest,因此包入口只能在 vitest 运行中导入(启动器、harness 和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。
|
||||
约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { existsSync, realpathSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join, delimiter } from 'node:path'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { vi } from 'vitest'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
PROTOCOL_VERSION,
|
||||
@@ -457,17 +457,25 @@ async function waitForPersistedTurnStart(
|
||||
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
|
||||
minimumTurn?: number,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (true) {
|
||||
let invalidRecord: { error: unknown } | undefined
|
||||
await vi.waitFor(async () => {
|
||||
const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
|
||||
const openTurn = log === undefined ? undefined : latestOpenTurn(log.content)
|
||||
if (openTurn !== undefined && (minimumTurn === undefined || openTurn >= minimumTurn)) return
|
||||
if (Date.now() >= deadline) {
|
||||
let openTurn: number | undefined
|
||||
try {
|
||||
openTurn = log === undefined ? undefined : latestOpenTurn(log.content)
|
||||
} catch (error) {
|
||||
// A malformed persisted record is a scenario bug, not a not-yet state:
|
||||
// vi.waitFor retries every callback throw, so capture the validation
|
||||
// failure, resolve the wait, and rethrow immediately below.
|
||||
invalidRecord = { error }
|
||||
return
|
||||
}
|
||||
if (openTurn === undefined || (minimumTurn !== undefined && openTurn < minimumTurn)) {
|
||||
const detail = minimumTurn === undefined ? 'turn/start' : `turn/start at or beyond turn ${minimumTurn}`
|
||||
throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${detail} within ${timeoutMs}ms`)
|
||||
}
|
||||
await delay(WAIT_POLL_INTERVAL_MS)
|
||||
}
|
||||
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
|
||||
if (invalidRecord !== undefined) throw invalidRecord.error
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -481,15 +489,12 @@ async function waitForPersistedTurnEnd(
|
||||
sessionId: string,
|
||||
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (true) {
|
||||
await vi.waitFor(async () => {
|
||||
const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
|
||||
if (log !== undefined && latestTurnIsClosed(log.content)) return
|
||||
if (Date.now() >= deadline) {
|
||||
if (log === undefined || !latestTurnIsClosed(log.content)) {
|
||||
throw new Error(`snapshot-harness: session "${sessionId}" did not persist turn/end within ${timeoutMs}ms`)
|
||||
}
|
||||
await delay(WAIT_POLL_INTERVAL_MS)
|
||||
}
|
||||
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
|
||||
}
|
||||
|
||||
/** Wait for a cwd-relative marker proving an external action reached readiness. */
|
||||
@@ -499,13 +504,11 @@ async function waitForWorkspaceFile(
|
||||
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
const target = join(cwd, path)
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!existsSync(target)) {
|
||||
if (Date.now() >= deadline) {
|
||||
await vi.waitFor(() => {
|
||||
if (!existsSync(target)) {
|
||||
throw new Error(`snapshot-harness: workspace file "${path}" did not appear within ${timeoutMs}ms`)
|
||||
}
|
||||
await delay(WAIT_POLL_INTERVAL_MS)
|
||||
}
|
||||
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
|
||||
}
|
||||
|
||||
/** Return whether the last complete raw-JSONL turn boundary closes its turn. */
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* @module @deepseek-ai/dsh-llm-mock-server/cli
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util'
|
||||
import { MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS } from './index.ts'
|
||||
import type {
|
||||
ConcreteMockLlmBehavior,
|
||||
@@ -63,14 +64,6 @@ Other:
|
||||
--help
|
||||
`
|
||||
|
||||
function optionValue(argv: readonly string[], index: number, option: string): string {
|
||||
const value = argv[index + 1]
|
||||
if (value === undefined || value.startsWith('--')) {
|
||||
throw new Error(`dsh-llm-mock-server: ${option} requires a value`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function numberValue(option: string, value: string): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) throw new Error(`dsh-llm-mock-server: ${option} must be a finite number`)
|
||||
@@ -122,66 +115,64 @@ function parseRandomWeights(raw: string): MockLlmRandomWeights {
|
||||
return weights
|
||||
}
|
||||
|
||||
/** parseArgs vocabulary: every documented flag; only `--repeat-last` and `--help` are boolean. */
|
||||
const CLI_OPTIONS = {
|
||||
'sequence': { type: 'string' },
|
||||
'host': { type: 'string' },
|
||||
'port': { type: 'string' },
|
||||
'api-key': { type: 'string' },
|
||||
'listen-delay-ms': { type: 'string' },
|
||||
'repeat-last': { type: 'boolean' },
|
||||
'seed': { type: 'string' },
|
||||
'random-weights': { type: 'string' },
|
||||
'success-text': { type: 'string' },
|
||||
'partial-text': { type: 'string' },
|
||||
'reasoning-text': { type: 'string' },
|
||||
'chunk-size': { type: 'string' },
|
||||
'chunk-delay-ms': { type: 'string' },
|
||||
'disconnect-delay-ms': { type: 'string' },
|
||||
'retry-after-ms': { type: 'string' },
|
||||
'request-id': { type: 'string' },
|
||||
'tool-name': { type: 'string' },
|
||||
'tool-arguments': { type: 'string' },
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Parse standalone server arguments without starting a process or listener.
|
||||
* Tokenizing rides `node:util` `parseArgs` (strict, no positionals); numeric
|
||||
* coercion, bounds, and cross-option constraints remain manual below it.
|
||||
* @param argv - arguments after the executable name.
|
||||
* @returns help or validated run configuration.
|
||||
*/
|
||||
export function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseResult {
|
||||
if (argv.includes('--help')) return { kind: 'help' }
|
||||
|
||||
let sequenceRaw: string | undefined
|
||||
let host: string | undefined
|
||||
let port = 8_000
|
||||
let apiKey: string | undefined
|
||||
let listenDelayMs: number | undefined
|
||||
let repeatLast = false
|
||||
let randomSeed: number | undefined
|
||||
let randomWeights: MockLlmRandomWeights | undefined
|
||||
let successText: string | undefined
|
||||
let partialText: string | undefined
|
||||
let reasoningText: string | undefined
|
||||
let chunkSize: number | undefined
|
||||
let chunkDelayMs: number | undefined
|
||||
let disconnectDelayMs: number | undefined
|
||||
let retryAfterMs: number | undefined
|
||||
let requestId: string | undefined
|
||||
let toolName: string | undefined
|
||||
let toolArguments: string | undefined
|
||||
const { values } = parseArgs({ args: [...argv], options: CLI_OPTIONS, strict: true, allowPositionals: false })
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const option = argv[index] as string
|
||||
if (option === '--repeat-last') {
|
||||
repeatLast = true
|
||||
continue
|
||||
}
|
||||
const value = optionValue(argv, index, option)
|
||||
index += 1
|
||||
switch (option) {
|
||||
case '--sequence': sequenceRaw = value; break
|
||||
case '--host': host = value; break
|
||||
case '--port': port = numberValue(option, value); break
|
||||
case '--api-key': apiKey = value; break
|
||||
case '--listen-delay-ms':
|
||||
listenDelayMs = boundedIntegerValue(option, value, 0, MAX_MOCK_LLM_TIMER_DELAY_MS)
|
||||
break
|
||||
case '--seed': randomSeed = numberValue(option, value); break
|
||||
case '--random-weights': randomWeights = parseRandomWeights(value); break
|
||||
case '--success-text': successText = value; break
|
||||
case '--partial-text': partialText = value; break
|
||||
case '--reasoning-text': reasoningText = value; break
|
||||
case '--chunk-size': chunkSize = numberValue(option, value); break
|
||||
case '--chunk-delay-ms': chunkDelayMs = numberValue(option, value); break
|
||||
case '--disconnect-delay-ms': disconnectDelayMs = numberValue(option, value); break
|
||||
case '--retry-after-ms': retryAfterMs = numberValue(option, value); break
|
||||
case '--request-id': requestId = value; break
|
||||
case '--tool-name': toolName = value; break
|
||||
case '--tool-arguments': toolArguments = value; break
|
||||
default: throw new Error(`dsh-llm-mock-server: unknown option ${JSON.stringify(option)}`)
|
||||
}
|
||||
}
|
||||
const host = values.host
|
||||
const port = values.port === undefined ? 8_000 : numberValue('--port', values.port)
|
||||
const apiKey = values['api-key']
|
||||
const listenDelayMs = values['listen-delay-ms'] === undefined
|
||||
? undefined
|
||||
: boundedIntegerValue('--listen-delay-ms', values['listen-delay-ms'], 0, MAX_MOCK_LLM_TIMER_DELAY_MS)
|
||||
const repeatLast = values['repeat-last'] ?? false
|
||||
const randomSeed = values.seed === undefined ? undefined : numberValue('--seed', values.seed)
|
||||
const randomWeights = values['random-weights'] === undefined ? undefined : parseRandomWeights(values['random-weights'])
|
||||
const successText = values['success-text']
|
||||
const partialText = values['partial-text']
|
||||
const reasoningText = values['reasoning-text']
|
||||
const chunkSize = values['chunk-size'] === undefined ? undefined : numberValue('--chunk-size', values['chunk-size'])
|
||||
const chunkDelayMs = values['chunk-delay-ms'] === undefined ? undefined : numberValue('--chunk-delay-ms', values['chunk-delay-ms'])
|
||||
const disconnectDelayMs = values['disconnect-delay-ms'] === undefined
|
||||
? undefined
|
||||
: numberValue('--disconnect-delay-ms', values['disconnect-delay-ms'])
|
||||
const retryAfterMs = values['retry-after-ms'] === undefined ? undefined : numberValue('--retry-after-ms', values['retry-after-ms'])
|
||||
const requestId = values['request-id']
|
||||
const toolName = values['tool-name']
|
||||
const toolArguments = values['tool-arguments']
|
||||
|
||||
if (sequenceRaw === undefined) throw new Error('dsh-llm-mock-server: --sequence is required')
|
||||
if (values.sequence === undefined) throw new Error('dsh-llm-mock-server: --sequence is required')
|
||||
const sequenceRaw = values.sequence
|
||||
const parsedSequence = parseSequence(sequenceRaw)
|
||||
if (parsedSequence.startsUnavailable && port === 0) {
|
||||
throw new Error('dsh-llm-mock-server: connection_refused requires an explicit nonzero --port')
|
||||
|
||||
@@ -101,8 +101,11 @@ describe('mock LLM server CLI parser', () => {
|
||||
|
||||
it.each([
|
||||
[[], /--sequence is required/],
|
||||
[['--wat'], /requires a value/],
|
||||
[['--wat', 'x'], /unknown option/],
|
||||
// Tokenizer-level failures carry node:util parseArgs's own messages.
|
||||
[['--wat'], /Unknown option '--wat'/],
|
||||
[['--wat', 'x'], /Unknown option '--wat'/],
|
||||
[['--port'], /Option '--port <value>' argument missing/],
|
||||
[['--sequence', 'success', 'stray'], /Unexpected argument 'stray'/],
|
||||
[['--port', 'NaN', '--sequence', 'success'], /finite number/],
|
||||
[['--sequence', 'success,'], /non-empty/],
|
||||
[['--sequence', 'success,connection_refused'], /only as the first/],
|
||||
@@ -110,7 +113,8 @@ describe('mock LLM server CLI parser', () => {
|
||||
[['--sequence', 'unknown'], /unknown behavior/],
|
||||
[['--sequence', 'connection_refused,success', '--port', '0'], /nonzero/],
|
||||
[['--sequence', 'success', '--listen-delay-ms', '5'], /requires connection_refused/],
|
||||
[['--sequence', 'connection_refused,success', '--listen-delay-ms', '-1'], /integer between 0 and 2147483647/],
|
||||
// `=` syntax: a space-separated leading-dash value is a tokenizer error, not a bounds probe.
|
||||
[['--sequence', 'connection_refused,success', '--listen-delay-ms=-1'], /integer between 0 and 2147483647/],
|
||||
[['--sequence', 'connection_refused,success', '--listen-delay-ms', '1.5'], /integer between 0 and 2147483647/],
|
||||
[['--sequence', 'connection_refused,success', '--listen-delay-ms', '2147483648'], /integer between 0 and 2147483647/],
|
||||
[['--sequence', 'success', '--seed', '1'], /require random/],
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 8e53550608037a3c9a272db825933b7224ab24db
|
||||
README.zh.md: 5310429ab59cf3cd04ac024746f5ed557e003637
|
||||
README.md: 73610ce50ebac4c6fc7bb9135f7b41b347c60685
|
||||
README.zh.md: 17f8481220136e8edf9fccd23fabfca5ccf41dfc
|
||||
|
||||
@@ -19,5 +19,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Built mode requires a prior build** — the config must also resolve every named package upward through `examples/node_modules`.
|
||||
- **Captured stdout and stderr are unbounded** — a runaway child can consume memory until the deadline kills it.
|
||||
- **Captured stdout and stderr are bounded only by execa's default 100 MB `maxBuffer`** — a runaway child is terminated at that ceiling rather than at a smoke-chosen budget.
|
||||
- **Timeout kills only the direct child** — a process tree spawned by a faulty fixture can outlive the smoke and needs external cleanup.
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
## 已知限制与待完成工作
|
||||
|
||||
- **构建 mode 需要事先构建**:配置还必须能够通过 `examples/node_modules` 向上解析每个命名包。
|
||||
- **捕获的 stdout 和 stderr 无界**:失控子进程可以消耗内存,直到 deadline 将其终止。
|
||||
- **捕获的 stdout 和 stderr 仅受 execa 默认 100 MB `maxBuffer` 约束**:失控子进程会在该上限处被终止,而不是在冒烟测试自选的预算处。
|
||||
- **超时只终止直接子进程**:故障 fixture 生成的进程树可以比冒烟测试存活更久,需要外部清理。
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"execa": "^10.0.0",
|
||||
"tsx": "^4.22.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
* @module @deepseek-ai/dsh-loader-smoke
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { execa } from 'execa'
|
||||
|
||||
const DEFAULT_PROCESS_TIMEOUT_MS = 30_000
|
||||
|
||||
@@ -171,53 +171,27 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<Loade
|
||||
tsconfigPath: options.tsconfigPath,
|
||||
env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...options.env },
|
||||
})
|
||||
const result = await new Promise<LoaderSmokeResult>((resolve, reject) => {
|
||||
const child = spawn(launch.command, launch.args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...launch.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let deferredFailure: Error | undefined
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => { stdout += chunk })
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
deferredFailure = new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)
|
||||
child.kill('SIGKILL')
|
||||
}, processTimeoutMs)
|
||||
|
||||
child.once('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (deferredFailure !== undefined) {
|
||||
reject(deferredFailure)
|
||||
} else if (code === 0) {
|
||||
resolve({ stdout, stderr })
|
||||
} else {
|
||||
reject(new Error(`${options.label} exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}
|
||||
})
|
||||
|
||||
// process.execPath and a just-created pipe make these OS-error paths
|
||||
// impractical to induce without replacing the boundary under test.
|
||||
/* v8 ignore start */
|
||||
child.once('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error(`${options.label} failed to start: ${error.message}`))
|
||||
})
|
||||
child.stdin.once('error', (error) => {
|
||||
deferredFailure ??= new Error(`${options.label} stdin failed: ${error.message}`)
|
||||
child.kill('SIGKILL')
|
||||
})
|
||||
/* v8 ignore stop */
|
||||
|
||||
child.stdin.end()
|
||||
// `input: ''` writes nothing and closes stdin — the fixture-visible
|
||||
// stdin-close contract. `reject: false` folds spawn errors, the SIGKILL
|
||||
// deadline, and nonzero exits into independent result fields, so the
|
||||
// diagnostics below embed both streams on every failure.
|
||||
const result = await execa(launch.command, launch.args, {
|
||||
cwd,
|
||||
env: launch.env,
|
||||
input: '',
|
||||
timeout: processTimeoutMs,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
stripFinalNewline: false,
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
if (result.failed) {
|
||||
throw new Error(`${options.label} exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
await options.inspect?.(cwd)
|
||||
return result
|
||||
return { stdout: result.stdout, stderr: result.stderr }
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 0d5ebabfbbb2922a369adb3a5d67ea4aafbe700f
|
||||
README.zh.md: b82e8e6138f3e97c3c047cf1812cee8e558ea29b
|
||||
# pnpm run verify-translation-pairing --write packages/workspace/README.md
|
||||
README.md: ba92e95d3cde0a95eaaeae5a9b4384c3b8c9c4b8
|
||||
README.zh.md: 8c8146bba5fa6d81c0ce5d2ed29add77b4083270
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
|
||||
The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md).
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `workspace/` | `WorkspaceRegistry` service over the storage domain form: realpath-unique paths, session-ownership accounting, entity cache | `ctx.workspace` |
|
||||
|
||||
Ownership truth lives in the workspace record's `sessionIds` (ordered), never derived from session cwd; `attachSession` verifies the session header's cwd resolves to the workspace path, so one session structurally belongs to at most one workspace. Deletion (workspace and session cascade) is deliberately absent this phase and ships with the session-side primitives.
|
||||
Ownership truth lives in the workspace record's `sessionIds` (ordered), never derived from session cwd; `attachSession` verifies the session header's cwd resolves to the workspace path, so one session structurally belongs to at most one workspace. Deleting a Workspace removes only this registry record and account: directories, user files, and session logs remain, and the Sessions become Ungrouped ([decision](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)).
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Workspace 系列拥有持久 workspace 概念:用户工作所在的目录,包含标题以及属于它的有序会话列表。设计记录:[领域 KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。
|
||||
Workspace 系列拥有持久 workspace 概念:用户工作所在的目录,包含标题以及属于它的有序会话列表。设计记录:[领域 KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)。
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| `workspace/` | 位于存储领域形式之上的 `WorkspaceRegistry` 服务:按 realpath 唯一的路径、会话所有权计数、实体缓存 | `ctx.workspace` |
|
||||
|
||||
所有权真相存在 workspace 记录的 `sessionIds`(有序)中,绝不从会话 cwd 派生;`attachSession` 会验证会话头的 cwd 解析到 workspace 路径,因此一个会话在结构上最多属于一个 workspace。本阶段有意不提供删除(workspace 与会话级联);该功能将与会话侧原语一起交付。
|
||||
所有权真相存在 workspace 记录的 `sessionIds`(有序)中,绝不从会话 cwd 派生;`attachSession` 会验证会话头的 cwd 解析到 workspace 路径,因此一个会话在结构上最多属于一个 workspace。删除 Workspace 只会移除该注册表记录及账本:目录、用户文件和会话日志都会保留,相关会话则进入 Ungrouped(参见[决策记录](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md))。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 0d395ecc58fc5e3362cb5f3c565a0539bb09c4dd
|
||||
README.zh.md: 017e1e4d3aae9f8708ead3565f8b5b59d9b249ca
|
||||
# pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md
|
||||
README.md: bee3e4fcb5dded273f30942ee2e42ee93b839e62
|
||||
README.zh.md: 7960a2d13df4f881687fd88cdb07e237b3abb7c8
|
||||
|
||||
@@ -10,6 +10,7 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n
|
||||
|
||||
- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; a different path cannot create a duplicate title.
|
||||
- `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it.
|
||||
- `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity.
|
||||
- `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry.
|
||||
- `ctx.workspace.touchSession(id)` — moves only that validated, accounted session to the front. Ungrouped or filtered sessions are no-ops, and workspace order never changes.
|
||||
- `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup.
|
||||
@@ -17,6 +18,8 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n
|
||||
|
||||
`storageDomain` and `sessionPersistence` are required startup dependencies. An unavailable peer leaves the plugin pending and cannot commit an empty initialized marker. On the first successful start, the registry calls `SessionPersistence.list()` and uses only header `id`, `cwd`, and `createdAt` to group valid historical directories and persist initial order; it never reads event bodies. The initialized marker is written last, so partial bootstrap writes are reused safely after restart. Later cwd-only sessions remain Ungrouped.
|
||||
|
||||
Create and delete persist an explicit pending-mutation marker before their record and order can diverge. Startup completes only the marked mutation, then clears the marker; an unmarked order/table mismatch remains unexplained corruption and fails loud. Deleting and re-registering the same path creates a fresh Workspace id and does not automatically re-adopt the retained Sessions.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Workspace records and session accounts
|
||||
@@ -35,5 +38,5 @@ Independent of live requests: the package never touches a request prefix, so it
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- No delete entry point in this phase — workspace deletion ships as one complete semantic together with the session-delete primitive and cascade orchestration (future-work section of the Agent Note); a half "drop the record, keep the sessions" operation is deliberately not exposed.
|
||||
- Session deletion and destructive folder removal are separate, absent capabilities; Workspace registration deletion never substitutes for either ([decision](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)).
|
||||
- The header index refreshes at startup and when attach must resolve an uncached persisted id; deletion or cwd damage performed by another process is observed after the next refresh or restart.
|
||||
|
||||
@@ -10,6 +10,7 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
|
||||
|
||||
- `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径不能创建重复标题。
|
||||
- `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它应用同一 `realpath` 规范,并会拒绝缺失路径,而不是创建路径。
|
||||
- `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话账本。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、实时会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。
|
||||
- `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。
|
||||
- `ctx.workspace.touchSession(id)`:仅将已验证、已记账的会话移到最前。未分组或被过滤的会话为空操作,workspace 顺序绝不改变。
|
||||
- `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、从两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。
|
||||
@@ -17,6 +18,8 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
|
||||
|
||||
`storageDomain` 和 `sessionPersistence` 是启动必需依赖。对等服务不可用时,插件保持待处理,且不能提交空的已初始化标记。首次成功启动时,注册表调用 `SessionPersistence.list()`,仅使用头部 `id`、`cwd` 和 `createdAt` 对有效历史目录分组并持久化初始顺序;它绝不读取事件正文。已初始化标记最后写入,因此重启后可安全复用部分启动写入。后续仅有 cwd 的会话仍属于 Ungrouped。
|
||||
|
||||
Create 与 delete 会在记录和顺序可能分叉之前,先持久化明确的待处理变更标记。启动时只补全被该标记证明的变更,随后清除标记;没有标记的顺序/表不一致仍属于来源不明的损坏,并会直接失败。删除后重新注册同一路径会生成新的 Workspace id,且不会自动重新接纳保留下来的 Session。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### Workspace 记录与会话记账
|
||||
@@ -35,5 +38,5 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
|
||||
|
||||
## 已知限制与延后工作
|
||||
|
||||
- 本阶段没有删除入口:workspace 删除将与会话删除原语和级联编排一起作为完整语义交付(参见 Agent Note 的未来工作一节);系统有意不公开「删除记录、保留会话」的半成品操作。
|
||||
- 会话删除与破坏性的文件夹移除是彼此独立且尚未提供的功能;删除 Workspace 注册记录绝不能替代二者(参见[决策记录](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md))。
|
||||
- 头部索引会在启动时刷新,也会在 attach 必须解析未缓存持久 id 时刷新;另一进程执行的删除或 cwd 破坏会在下次刷新或重启后被观测。
|
||||
|
||||
@@ -109,6 +109,7 @@ export class WorkspaceRegistry extends Service {
|
||||
this.global = domain.global
|
||||
this.state = domain.global.get()
|
||||
|
||||
await this.recoverPendingMutation()
|
||||
this.validateStoredState(this.state)
|
||||
if (!this.state.initialized) {
|
||||
const headers = await this.ctx.sessionPersistence.list()
|
||||
@@ -168,6 +169,18 @@ export class WorkspaceRegistry extends Service {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one workspace registration while retaining its directory and every
|
||||
* session log. The durable order is updated before the table deletion; a
|
||||
* failed table write restores the prior order and keeps the entity
|
||||
* published. Unknown ids are an idempotent no-op for domain callers.
|
||||
* @param id - Workspace registration to remove.
|
||||
* @returns `true` when a record was deleted, `false` when it was unknown.
|
||||
*/
|
||||
delete(id: WorkspaceId): Promise<boolean> {
|
||||
return this.enqueueOperation(() => this.deleteKnown(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve by canonical directory path without creating or mutating a
|
||||
* workspace. A missing path rejects during `realpath`; an existing unowned
|
||||
@@ -206,10 +219,28 @@ export class WorkspaceRegistry extends Service {
|
||||
}
|
||||
const entity = new WorkspaceEntity(this.host, id, record)
|
||||
this.entities.set(id, entity)
|
||||
const pendingState: WorkspaceDomainState = {
|
||||
...state,
|
||||
pendingMutation: { operation: 'create', workspaceId: id },
|
||||
}
|
||||
try {
|
||||
await this.setState(pendingState)
|
||||
} catch (error) {
|
||||
this.entities.delete(id)
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
await table.put(id, record)
|
||||
} catch (error) {
|
||||
this.entities.delete(id)
|
||||
try {
|
||||
await this.setState(state)
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
`workspace '${id}' record write and pending-marker rollback both failed`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -220,10 +251,17 @@ export class WorkspaceRegistry extends Service {
|
||||
try {
|
||||
await table.delete(id)
|
||||
} catch (rollbackError) {
|
||||
this.entities.set(id, entity)
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
`workspace '${id}' was stored but its registry order and rollback both failed`,
|
||||
`workspace '${id}' order write and record rollback both failed; the pending marker remains recoverable`,
|
||||
)
|
||||
}
|
||||
try {
|
||||
await this.setState(state)
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
`workspace '${id}' order write and pending-marker rollback both failed`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
@@ -231,6 +269,69 @@ export class WorkspaceRegistry extends Service {
|
||||
return entity
|
||||
}
|
||||
|
||||
private async deleteKnown(id: WorkspaceId): Promise<boolean> {
|
||||
const entity = this.entities.get(id)
|
||||
if (entity === undefined) return false
|
||||
const state = this.requireState()
|
||||
const nextState = {
|
||||
initialized: true,
|
||||
workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id),
|
||||
}
|
||||
await this.setState({
|
||||
...nextState,
|
||||
pendingMutation: { operation: 'delete', workspaceId: id },
|
||||
})
|
||||
this.entities.delete(id)
|
||||
try {
|
||||
await this.requireTable().delete(id)
|
||||
} catch (error) {
|
||||
this.entities.set(id, entity)
|
||||
try {
|
||||
await this.setState(state)
|
||||
} catch (rollbackError) {
|
||||
// The durable marker still says to finish deletion, so the cache must
|
||||
// agree with that recoverable direction rather than republish a row
|
||||
// absent from the persisted order.
|
||||
this.entities.delete(id)
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
`workspace '${id}' record deletion and registry-order rollback both failed`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
await this.setState(nextState)
|
||||
} catch (error) {
|
||||
// The deletion committed at the table write and was already published
|
||||
// to Host streams. Keep the durable marker for startup recovery rather
|
||||
// than reporting failure after the requested state became true.
|
||||
this.ctx.logger.warn(
|
||||
`workspace '${id}' was deleted but its pending marker could not be cleared: ${String(error)}`,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the one mutation explicitly named by durable state. Unexplained
|
||||
* order/table divergence still reaches {@link validateStoredState} and
|
||||
* fails loud; this path never infers provenance from shape alone.
|
||||
*/
|
||||
private async recoverPendingMutation(): Promise<void> {
|
||||
const state = this.requireState()
|
||||
const pending = state.pendingMutation
|
||||
if (pending === undefined) return
|
||||
if (state.workspaceIds.includes(pending.workspaceId)) {
|
||||
throw new Error(
|
||||
`workspace domain is inconsistent: pending ${pending.operation} workspace `
|
||||
+ `'${pending.workspaceId}' is still present in registry order`,
|
||||
)
|
||||
}
|
||||
await this.requireTable().delete(pending.workspaceId)
|
||||
await this.setState({ initialized: state.initialized, workspaceIds: state.workspaceIds })
|
||||
}
|
||||
|
||||
private async bootstrap(headers: readonly SessionHeader[]): Promise<void> {
|
||||
const table = this.requireTable()
|
||||
const state = this.requireState()
|
||||
@@ -454,7 +555,12 @@ export class WorkspaceRegistry extends Service {
|
||||
}
|
||||
|
||||
private enqueueOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.operationTail.then(operation)
|
||||
const result = this.operationTail.then(async () => {
|
||||
// A committed delete may leave only its marker cleanup pending. Retry
|
||||
// recovery before another create/delete can overwrite that provenance.
|
||||
await this.recoverPendingMutation()
|
||||
return await operation()
|
||||
})
|
||||
this.operationTail = result.then(() => {}, () => {})
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ export const inject = ['invariants']
|
||||
* domain's durable table. Every `domain/changed` for the `workspaces` table
|
||||
* must name a record the cache already holds an entity for (the registry
|
||||
* caches before the durable put and mutates only through cached entities).
|
||||
* A delete is valid only for create rollback, after the provisional cache
|
||||
* entry has been removed; deleting a published entity proves a bypass.
|
||||
* A delete is valid only after the registry has removed the entity from its
|
||||
* cache, whether for create rollback or an explicit registration deletion;
|
||||
* deleting while the cache still publishes the entity proves a bypass.
|
||||
*/
|
||||
const install: InvariantInstaller = Object.assign(
|
||||
(ctx: Context, fail: (message: string) => never) => {
|
||||
|
||||
@@ -29,6 +29,16 @@ export const workspaceRecord = z.object({
|
||||
/** One stored workspace record, inferred from {@link workspaceRecord}. */
|
||||
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
|
||||
/**
|
||||
* Recoverable two-write mutation marker. The marker is persisted before the
|
||||
* record/order pair can diverge, so startup can distinguish an interrupted
|
||||
* registry operation from unexplained medium corruption.
|
||||
*/
|
||||
const workspacePendingMutation = z.discriminatedUnion('operation', [
|
||||
z.object({ operation: z.literal('create'), workspaceId }),
|
||||
z.object({ operation: z.literal('delete'), workspaceId }),
|
||||
])
|
||||
|
||||
/**
|
||||
* Durable registry state. `initialized` distinguishes a valid empty registry
|
||||
* from one that still needs the header-only history bootstrap;
|
||||
@@ -37,6 +47,7 @@ export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
export const workspaceDomainState = z.object({
|
||||
initialized: z.boolean(),
|
||||
workspaceIds: z.array(workspaceId),
|
||||
pendingMutation: workspacePendingMutation.optional(),
|
||||
})
|
||||
|
||||
/** Durable registry state inferred from {@link workspaceDomainState}. */
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('workspace cache/table invariant', () => {
|
||||
.toThrow(/cache still publishes/)
|
||||
})
|
||||
|
||||
it('allows deletion only after a provisional create cache entry was removed for rollback', async () => {
|
||||
it('allows deletion after the registry removed the cache entry for rollback or explicit deletion', async () => {
|
||||
const ctx = await setup([])
|
||||
expect(() => { ctx.emit('domain/changed', deleted()) }).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -89,7 +89,7 @@ async function storageContext(pool: MemoryMediaPool, backend: StorageBackend = n
|
||||
/** Backend wrapper that injects one selected bootstrap write failure. */
|
||||
function selectiveFailureBackend(
|
||||
pool: MemoryMediaPool,
|
||||
failure: { putAt?: number; deleteAt?: number; globalAt?: number },
|
||||
failure: { putAt?: number; deleteAt?: number; globalAt?: number | readonly number[] },
|
||||
): StorageBackend {
|
||||
const inner = new MemoryStorageBackend(pool)
|
||||
let puts = 0
|
||||
@@ -113,7 +113,8 @@ function selectiveFailureBackend(
|
||||
},
|
||||
setGlobal: async (value) => {
|
||||
globals += 1
|
||||
if (globals === failure.globalAt) throw new Error('selected bootstrap marker failure')
|
||||
const failAt = Array.isArray(failure.globalAt) ? failure.globalAt : [failure.globalAt]
|
||||
if (failAt.includes(globals)) throw new Error('selected bootstrap marker failure')
|
||||
await unit.setGlobal(value)
|
||||
},
|
||||
close: () => unit.close(),
|
||||
@@ -394,19 +395,34 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
|
||||
it('rolls back the provisional cache when the record write fails', async () => {
|
||||
const dir = await makeDir('write-failure')
|
||||
const result = await harness()
|
||||
result.pool.failNextWrites = 1
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/injected/)
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { putAt: 1 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/selected bootstrap put failure/)
|
||||
expect(result.registry.list()).toEqual([])
|
||||
expect(await result.registry.create(dir)).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not publish a Workspace when its pending marker cannot be written', async () => {
|
||||
const dir = await makeDir('pending-marker-write-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 2 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/selected bootstrap marker failure/)
|
||||
expect(result.registry.list()).toEqual([])
|
||||
expect(pool.media.get('workspace')!.tables.get('workspaces')?.size ?? 0).toBe(0)
|
||||
})
|
||||
|
||||
it('rolls back a record when registry-order persistence fails', async () => {
|
||||
const dir = await makeDir('order-write-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 2 }),
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 3 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/marker failure/)
|
||||
expect(result.registry.list()).toEqual([])
|
||||
@@ -418,17 +434,129 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 2, deleteAt: 1 }),
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 3, deleteAt: 1 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1)
|
||||
})
|
||||
|
||||
it('reports a record write and pending-marker rollback failure together', async () => {
|
||||
const dir = await makeDir('record-marker-rollback-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { putAt: 1, globalAt: 3 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(storedState(pool)).toMatchObject({
|
||||
pendingMutation: { operation: 'create' },
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an order write and pending-marker rollback failure together', async () => {
|
||||
const dir = await makeDir('order-marker-rollback-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: [3, 4] }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(storedState(pool)).toMatchObject({
|
||||
pendingMutation: { operation: 'create' },
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes only the registration and leaves its directory and session headers untouched', async () => {
|
||||
const dir = await makeDir('delete-registration')
|
||||
const result = await harness({ sessions: [header('kept-session', dir)] })
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('kept-session'))
|
||||
|
||||
await expect(result.registry.delete(workspace.id)).resolves.toBe(true)
|
||||
await expect(result.registry.delete(workspace.id)).resolves.toBe(false)
|
||||
expect(result.registry.get(workspace.id)).toBeUndefined()
|
||||
expect(result.registry.list()).toEqual([])
|
||||
expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [] })
|
||||
expect(result.pool.media.get('workspace')!.tables.get('workspaces')!.has(workspace.id)).toBe(false)
|
||||
await expect(realpath(dir)).resolves.toBe(dir)
|
||||
expect(result.list).toHaveBeenCalledTimes(1)
|
||||
expect(result.load).not.toHaveBeenCalled()
|
||||
expect(result.inspect).not.toHaveBeenCalled()
|
||||
|
||||
const reregistered = await result.registry.create(dir)
|
||||
expect(reregistered.id).not.toBe(workspace.id)
|
||||
expect(reregistered.path).toBe(dir)
|
||||
expect(reregistered.sessionIds).toEqual([])
|
||||
})
|
||||
|
||||
it('rolls registry order and cache back when record deletion fails', async () => {
|
||||
const dir = await makeDir('delete-rollback')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { deleteAt: 1 }),
|
||||
})
|
||||
const workspace = await result.registry.create(dir)
|
||||
|
||||
await expect(result.registry.delete(workspace.id)).rejects.toThrow(/selected rollback delete failure/)
|
||||
expect(result.registry.get(workspace.id)).toBe(workspace)
|
||||
expect(result.registry.list()).toEqual([workspace])
|
||||
expect(storedState(pool).workspaceIds).toEqual([workspace.id])
|
||||
expect(storedRecord(pool, workspace.id)).toMatchObject({ path: dir })
|
||||
})
|
||||
|
||||
it('commits deletion and leaves a recoverable marker when marker cleanup fails', async () => {
|
||||
const dir = await makeDir('delete-marker-cleanup')
|
||||
const pool = new MemoryMediaPool()
|
||||
const first = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 5 }),
|
||||
})
|
||||
const workspace = await first.registry.create(dir)
|
||||
|
||||
await expect(first.registry.delete(workspace.id)).resolves.toBe(true)
|
||||
expect(first.registry.list()).toEqual([])
|
||||
expect(storedState(pool)).toEqual({
|
||||
initialized: true,
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'delete', workspaceId: workspace.id },
|
||||
})
|
||||
const reregistered = await first.registry.create(dir)
|
||||
expect(reregistered.id).not.toBe(workspace.id)
|
||||
expect(storedState(pool)).toEqual({
|
||||
initialized: true,
|
||||
workspaceIds: [reregistered.id],
|
||||
})
|
||||
await first.fiber.dispose()
|
||||
|
||||
const restarted = await harness({ pool })
|
||||
expect(restarted.registry.list().map(item => item.id)).toEqual([reregistered.id])
|
||||
})
|
||||
|
||||
it('keeps the failed deletion unpublished when record and order rollback both fail', async () => {
|
||||
const dir = await makeDir('delete-double-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { deleteAt: 1, globalAt: 5 }),
|
||||
})
|
||||
const workspace = await result.registry.create(dir)
|
||||
|
||||
await expect(result.registry.delete(workspace.id)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(result.registry.get(workspace.id)).toBeUndefined()
|
||||
expect(storedState(pool)).toMatchObject({
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'delete', workspaceId: workspace.id },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects table access before the registry has started', async () => {
|
||||
const dir = await makeDir('unstarted')
|
||||
const registry = new WorkspaceRegistry(new Context())
|
||||
await expect(registry.create(dir)).rejects.toThrow(/not started/)
|
||||
expect(() => registry.list()).toThrow(/not started/)
|
||||
const internals = registry as unknown as { requireTable(): unknown }
|
||||
expect(() => internals.requireTable()).toThrow(/not started/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -616,6 +744,49 @@ describe('header-validated membership projection', () => {
|
||||
internals.entities.delete(workspace.id)
|
||||
expect(() => result.registry.list()).toThrow(/references missing workspace/)
|
||||
})
|
||||
|
||||
it('recovers only an explicitly marked interrupted create or delete', async () => {
|
||||
const createDir = await makeDir('pending-create')
|
||||
const deleteDir = await makeDir('pending-delete')
|
||||
const createId = WorkspaceId('00000000-0000-4000-8000-000000000004')
|
||||
const deleteId = WorkspaceId('00000000-0000-4000-8000-000000000005')
|
||||
|
||||
const interruptedCreate = storedPool(
|
||||
[[createId, record(createDir, [])]],
|
||||
{
|
||||
initialized: true,
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'create', workspaceId: createId },
|
||||
},
|
||||
)
|
||||
const createRecovery = await harness({ pool: interruptedCreate })
|
||||
expect(createRecovery.registry.list()).toEqual([])
|
||||
expect(interruptedCreate.media.get('workspace')!.tables.get('workspaces')!.has(createId)).toBe(false)
|
||||
expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [] })
|
||||
|
||||
const interruptedDelete = storedPool(
|
||||
[[deleteId, record(deleteDir, [])]],
|
||||
{
|
||||
initialized: true,
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'delete', workspaceId: deleteId },
|
||||
},
|
||||
)
|
||||
const deleteRecovery = await harness({ pool: interruptedDelete })
|
||||
expect(deleteRecovery.registry.list()).toEqual([])
|
||||
expect(interruptedDelete.media.get('workspace')!.tables.get('workspaces')!.has(deleteId)).toBe(false)
|
||||
expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [] })
|
||||
|
||||
const corruptPending = storedPool(
|
||||
[[deleteId, record(deleteDir, [])]],
|
||||
{
|
||||
initialized: true,
|
||||
workspaceIds: [deleteId],
|
||||
pendingMutation: { operation: 'delete', workspaceId: deleteId },
|
||||
},
|
||||
)
|
||||
await expect(harness({ pool: corruptPending })).rejects.toThrow(/still present in registry order/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace mutation and status', () => {
|
||||
|
||||
Reference in New Issue
Block a user