diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 85c744736..8a29ac83f 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -263,6 +263,15 @@ const DOMAIN_LAYER = new Map([ // coordination domains. It is also guarded target-side: only the domain // itself may import it (TARGET_IMPORT_ALLOWLIST, plan §10.1). ['localWorkspaceRuntime', 6], + // `workspaceRegistration` (M3, plan §4.2/§7.7) owns the long-lived + // workspace-runtime registration leases: the runtime manager opens a + // workspace runtime ONCE via the sanctioned provider (it is the allowed + // importer of `localWorkspaceRuntime` beside the domain itself, see + // TARGET_IMPORT_ALLOWLIST) and keeps it registered; the Workspace Session + // facade delegates to the already-registered `runtime.sessions`. It builds + // on the L2 host-runtime + workspace contracts and the L6 local runtime, + // so it sits at L6 beside the other coordination domains. + ['workspaceRegistration', 6], ['interaction', 6], ['sessionMetadata', 6], // `undo` owns the undo pipeline (quiesce → context.undo → reconcile): it @@ -444,7 +453,11 @@ const DOMAIN_IMPORT_BANS = new Map([ const TARGET_IMPORT_ALLOWLIST = new Map([ [ 'localWorkspaceRuntime', - new Set(['localWorkspaceRuntime']), + // `workspaceRegistration` (M3) is the sanctioned registration/runtime + // management importer (plan §7.7): it instantiates the Local provider to + // open long-lived workspace runtimes. Everyone else keeps talking to the + // generic contracts. + new Set(['localWorkspaceRuntime', 'workspaceRegistration']), ], ]); diff --git a/packages/agent-core-v2/src/app/workspace/workspaceRuntime.ts b/packages/agent-core-v2/src/app/workspace/workspaceRuntime.ts index 8ae9d1199..c2a07f2d6 100644 --- a/packages/agent-core-v2/src/app/workspace/workspaceRuntime.ts +++ b/packages/agent-core-v2/src/app/workspace/workspaceRuntime.ts @@ -31,7 +31,9 @@ import type { ISessionManager } from '#/app/sessionHostRuntime/sessionManager'; */ export type WorkspaceCapability = /** Sessions live in the local `sessions//` layout. */ - | 'workspace.local'; + | 'workspace.local' + /** Sessions are hosted behind a shared remote connection (plan §4.4). */ + | 'workspace.remote'; /** * A long-lived host runtime belonging to the Workspace domain. Everything the diff --git a/packages/agent-core-v2/src/app/workspaceRegistration/index.ts b/packages/agent-core-v2/src/app/workspaceRegistration/index.ts new file mode 100644 index 000000000..120d402e4 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceRegistration/index.ts @@ -0,0 +1,4 @@ +export * from './workspaceRuntimeManager'; +export * from './workspaceRuntimeManagerService'; +export * from './workspaceSessionService'; +export * from './workspaceSessionServiceImpl'; diff --git a/packages/agent-core-v2/src/app/workspaceRegistration/workspaceRuntimeManager.ts b/packages/agent-core-v2/src/app/workspaceRegistration/workspaceRuntimeManager.ts new file mode 100644 index 000000000..9f421d1f9 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceRegistration/workspaceRuntimeManager.ts @@ -0,0 +1,100 @@ +/** + * `workspaceRegistration` domain (L6) — the Workspace registration/runtime + * manager contract (plan §4.1/§7.7). + * + * `IWorkspaceRuntimeManager` owns the LONG-LIVED `IWorkspaceRuntimeRegistration` + * leases: it opens a workspace runtime ONCE through the matching + * `IWorkspaceProvider`, registers it into `ISessionHostRuntimeRegistry` and + * keeps the pair (provider registration + registry lease) for the runtime's + * whole life. Session create/CRUD never goes through this manager's + * `ensureRegistered` — ordinary callers use `getRuntime`/`requireRuntime` and + * reuse the already-registered runtime (plan §9.4: two internal creates must + * NOT re-open the provider). The ONE allowed registration path beside + * composition is the kap-server v1 create compatibility adapter's + * createOrTouch flow (plan §4.2/§6.2). + * + * Unregister follows plan §7.7 and never deletes session data: + * + * 1. the workspace is detached from the manager first, so no NEW child + * lease is handed out through the manager/facade; + * 2. the provider registration is disposed — `runtime.close(...)` flips the + * runtime offline (every subsequent `sessions.*` call fails with + * `session.runtime_unavailable`, which blocks new child leases at the + * runtime boundary too) and closes the live session leases + * (`runtime_lost`); + * 3. the registry lease is disposed, removing routing only — the offline + * registry-entry semantics of `ISessionHostRuntimeRegistry` mean refs + * keep failing accurately, and re-registering under the SAME runtime id + * later revives them (plan §9.2). + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { + IWorkspaceProvider, + IWorkspaceRuntime, +} from '#/app/workspace/workspaceRuntime'; + +/** The workspace facts a registration is keyed by (catalog-resolved). */ +export interface WorkspaceRuntimeRef { + /** The canonical (alias-folded) workspace id — determines the runtime id. */ + readonly workspaceId: string; + /** The canonical workspace root (the v1 `metadata.cwd` fact). */ + readonly root: string; +} + +export interface WorkspaceRuntimeRegistrationSummary { + readonly workspaceId: string; + readonly runtimeId: string; + readonly kind: string; +} + +export interface IWorkspaceRuntimeManager { + readonly _serviceBrand: undefined; + + /** + * Register an additional provider under a workspace kind (composition roots + * and test harnesses; the `'local'` provider is built in). Providers are + * ONLY invoked by `ensureRegistered` — never by session CRUD. + */ + registerProvider(kind: string, provider: IWorkspaceProvider): void; + + /** + * Resolve the already-registered runtime of a workspace, or `undefined`. + * This is the ordinary two-step lookup (plan §4.2 step 1): it never opens a + * provider and never registers a runtime. + */ + getRuntime(workspaceId: string): IWorkspaceRuntime | undefined; + + /** + * Like `getRuntime`, but throws `session.runtime_not_found` when the + * workspace has no registered runtime (never registered or unregistered). + */ + requireRuntime(workspaceId: string): IWorkspaceRuntime; + + /** + * Return the registered runtime of a workspace, opening and registering it + * through the workspace-kind provider when absent. Idempotent: an existing + * registration is reused as-is (deterministic provider runtime ids make a + * same-workspace re-open collapse onto the same identity; the registry's + * `session.runtime_id_conflict` stands as the sentinel against duplicate + * instances). Concurrent calls for one workspace fold onto one open. + */ + ensureRegistered( + workspace: WorkspaceRuntimeRef, + options?: { readonly kind?: string }, + ): Promise; + + /** + * Detach the workspace's runtime: block new child leases, close live + * session leases and the runtime, then drop registry routing. Session data + * is retained — re-registering the same workspace revives it. A no-op for + * a workspace with no registration. + */ + unregister(workspaceId: string): Promise; + + list(): readonly WorkspaceRuntimeRegistrationSummary[]; +} + +export const IWorkspaceRuntimeManager: ServiceIdentifier = + createDecorator('workspaceRuntimeManager'); diff --git a/packages/agent-core-v2/src/app/workspaceRegistration/workspaceRuntimeManagerService.ts b/packages/agent-core-v2/src/app/workspaceRegistration/workspaceRuntimeManagerService.ts new file mode 100644 index 000000000..44510087a --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceRegistration/workspaceRuntimeManagerService.ts @@ -0,0 +1,182 @@ +/** + * `workspaceRegistration` domain (L6) — `IWorkspaceRuntimeManager` implementation. + * + * The manager is the ONLY owner of workspace-runtime registration leases + * (plan §7.7). It builds the `'local'` provider in-process (the Local branch + * of `IWorkspaceProvider`, imported here under the plan §10.1 target-side + * allowlist — registration management and composition are the sanctioned + * importers of the legacy-layout runtime) and accepts further providers via + * `registerProvider`. + * + * Bound at App scope, activated on demand: compositions that never touch the + * Workspace domain (standalone memory/server hosts, plan §0.3) never + * instantiate it. + */ + +import { type IDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +import { LocalWorkspaceProvider } from '#/app/localWorkspaceRuntime/localWorkspaceProvider'; +import { + SessionHostRuntimeError, + SessionHostRuntimeErrors, +} from '#/app/sessionHostRuntime/errors'; +import { ISessionHostRuntimeRegistry } from '#/app/sessionHostRuntime/sessionHostRuntimeRegistry'; +import type { + IWorkspaceProvider, + IWorkspaceRuntime, + IWorkspaceRuntimeRegistration, +} from '#/app/workspace/workspaceRuntime'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { ErrorCodes, Error2 } from '#/errors'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import { + IWorkspaceRuntimeManager, + type WorkspaceRuntimeRef, + type WorkspaceRuntimeRegistrationSummary, +} from './workspaceRuntimeManager'; + +interface RegisteredWorkspaceRuntime { + readonly workspaceId: string; + readonly runtime: IWorkspaceRuntime; + readonly registration: IWorkspaceRuntimeRegistration; + readonly registryLease: IDisposable; +} + +export class WorkspaceRuntimeManagerService implements IWorkspaceRuntimeManager { + declare readonly _serviceBrand: undefined; + + private readonly providers = new Map(); + private readonly registrations = new Map(); + /** In-flight `ensureRegistered` opens, folded per workspace. */ + private readonly inflight = new Map>(); + /** In-flight `unregister` teardowns; a re-register waits for them. */ + private readonly draining = new Map>(); + + constructor( + @ISessionHostRuntimeRegistry private readonly registry: ISessionHostRuntimeRegistry, + @IBootstrapService bootstrap: IBootstrapService, + @IFileSystemStorageService storage: IFileSystemStorageService, + ) { + this.providers.set( + 'local', + new LocalWorkspaceProvider({ homeDir: bootstrap.homeDir, storage }), + ); + } + + registerProvider(kind: string, provider: IWorkspaceProvider): void { + if (this.providers.has(kind)) { + throw new Error2( + ErrorCodes.VALIDATION_FAILED, + `workspace provider kind '${kind}' is already registered`, + ); + } + this.providers.set(kind, provider); + } + + getRuntime(workspaceId: string): IWorkspaceRuntime | undefined { + return this.registrations.get(workspaceId)?.runtime; + } + + requireRuntime(workspaceId: string): IWorkspaceRuntime { + const runtime = this.getRuntime(workspaceId); + if (runtime === undefined) { + throw new SessionHostRuntimeError( + SessionHostRuntimeErrors.codes.SESSION_RUNTIME_NOT_FOUND, + `workspace '${workspaceId}' has no registered runtime`, + { details: { workspaceId } }, + ); + } + return runtime; + } + + ensureRegistered( + workspace: WorkspaceRuntimeRef, + options?: { readonly kind?: string }, + ): Promise { + const existing = this.registrations.get(workspace.workspaceId); + if (existing !== undefined) return Promise.resolve(existing.runtime); + const pending = this.inflight.get(workspace.workspaceId); + if (pending !== undefined) return pending; + const promise = this.doEnsureRegistered(workspace, options?.kind ?? 'local').finally(() => + this.inflight.delete(workspace.workspaceId), + ); + this.inflight.set(workspace.workspaceId, promise); + return promise; + } + + private async doEnsureRegistered( + workspace: WorkspaceRuntimeRef, + kind: string, + ): Promise { + // A concurrent unregister may still be tearing the previous runtime down: + // wait it out so the fresh registration never collides with a stale + // registry lease under the same runtime id. + await this.draining.get(workspace.workspaceId); + const existing = this.registrations.get(workspace.workspaceId); + if (existing !== undefined) return existing.runtime; + + const provider = this.providers.get(kind); + if (provider === undefined) { + throw new Error2( + ErrorCodes.VALIDATION_FAILED, + `no workspace provider registered for kind '${kind}'`, + ); + } + const registration = await provider.open({ + root: workspace.root, + workspaceId: workspace.workspaceId, + }); + let registryLease: IDisposable; + try { + // The registry is the sentinel: a second live instance under this + // runtime id fails with `session.runtime_id_conflict`. + registryLease = this.registry.register(registration.runtime); + } catch (error) { + await registration.dispose().catch(() => {}); + throw error; + } + this.registrations.set(workspace.workspaceId, { + workspaceId: workspace.workspaceId, + runtime: registration.runtime, + registration, + registryLease, + }); + return registration.runtime; + } + + async unregister(workspaceId: string): Promise { + const entry = this.registrations.get(workspaceId); + if (entry === undefined) return; + // 1. Detach first: the manager/facade hands out no new runtime reference, + // so no new child lease is opened through them (plan §7.7 step 1). + this.registrations.delete(workspaceId); + const teardown = (async () => { + // 2. Close the runtime: flips it offline (blocking new child leases at + // the runtime boundary with `session.runtime_unavailable`) and + // closes the live session leases (`runtime_lost`). Data is retained. + await entry.registration.dispose(); + // 3. Drop routing only; re-registering under the same id revives refs. + entry.registryLease.dispose(); + })().finally(() => this.draining.delete(workspaceId)); + this.draining.set(workspaceId, teardown); + await teardown; + } + + list(): readonly WorkspaceRuntimeRegistrationSummary[] { + return [...this.registrations.values()].map((entry) => ({ + workspaceId: entry.workspaceId, + runtimeId: entry.runtime.id, + kind: entry.runtime.kind, + })); + } +} + +registerScopedService( + LifecycleScope.App, + IWorkspaceRuntimeManager, + WorkspaceRuntimeManagerService, + ScopeActivation.OnDemand, + 'workspaceRegistration', +); diff --git a/packages/agent-core-v2/src/app/workspaceRegistration/workspaceSessionService.ts b/packages/agent-core-v2/src/app/workspaceRegistration/workspaceSessionService.ts new file mode 100644 index 000000000..bdd6c4b43 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceRegistration/workspaceSessionService.ts @@ -0,0 +1,64 @@ +/** + * `workspaceRegistration` domain (L6) — the Workspace Session facade contract + * (plan §4.2). + * + * `IWorkspaceSessionService` is the Workspace domain's INTERNAL application + * service for session operations: every method performs exactly two steps — + * + * 1. resolve the ALREADY-REGISTERED `IWorkspaceRuntime` of the workspace + * from the registration manager (`requireRuntime`); + * 2. delegate to that runtime's `runtime.sessions.*`. + * + * The facade maintains no second session catalog, copies no metadata, issues + * no extra owner records and creates no transient runtimes. It NEVER calls + * `IWorkspaceProvider.open` / `ensureRegistered` — ordinary CRUD reuses the + * registered runtime, and the single sanctioned createOrTouch registration + * path is the kap-server v1 create compatibility adapter (plan §4.2/§6.2, + * §9.4). Milestone 1 maps it to NO HTTP/RPC/WebSocket route. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { + CreateSessionInput, + DeleteSessionOptions, + OpenSessionOptions, + ResumeSessionOptions, + SameRuntimeForkInput, + SessionListQuery, + SessionPage, + UpdateSessionPatch, +} from '#/app/sessionHostRuntime/sessionManager'; +import type { ISessionHandle } from '#/app/sessionHostRuntime/sessionService'; +import type { SessionDescriptor } from '#/app/sessionHostRuntime/sessionRuntimeContext'; + +export interface IWorkspaceSessionService { + readonly _serviceBrand: undefined; + + create(workspaceId: string, input: CreateSessionInput): Promise; + list(workspaceId: string, query?: SessionListQuery): Promise; + get(workspaceId: string, sessionId: string): Promise; + update( + workspaceId: string, + sessionId: string, + patch: UpdateSessionPatch, + ): Promise; + delete(workspaceId: string, sessionId: string, options?: DeleteSessionOptions): Promise; + + open(workspaceId: string, sessionId: string, options: OpenSessionOptions): Promise; + resume( + workspaceId: string, + sessionId: string, + options: ResumeSessionOptions, + ): Promise; + + /** Same-runtime fork only; the target descriptor shares the workspace runtime id. */ + fork( + workspaceId: string, + sourceSessionId: string, + input: SameRuntimeForkInput, + ): Promise; +} + +export const IWorkspaceSessionService: ServiceIdentifier = + createDecorator('workspaceSessionService'); diff --git a/packages/agent-core-v2/src/app/workspaceRegistration/workspaceSessionServiceImpl.ts b/packages/agent-core-v2/src/app/workspaceRegistration/workspaceSessionServiceImpl.ts new file mode 100644 index 000000000..0a2e63e64 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceRegistration/workspaceSessionServiceImpl.ts @@ -0,0 +1,113 @@ +/** + * `workspaceRegistration` domain (L6) — `IWorkspaceSessionService` implementation. + * + * Every method is the plan §4.2 two-step: `requireRuntime(workspaceId)` from + * the registration manager (throwing `session.runtime_not_found` when the + * workspace has no live registration), then a straight delegate into + * `runtime.sessions.*`. Nothing here opens a provider, registers a runtime, + * or keeps any session state of its own. Bound at App scope, activated on + * demand. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +import type { + CreateSessionInput, + DeleteSessionOptions, + OpenSessionOptions, + ResumeSessionOptions, + SameRuntimeForkInput, + SessionListQuery, + SessionPage, + UpdateSessionPatch, +} from '#/app/sessionHostRuntime/sessionManager'; +import type { ISessionHandle } from '#/app/sessionHostRuntime/sessionService'; +import type { SessionRef } from '#/app/sessionHostRuntime/sessionRef'; +import type { + ISessionRuntimeContext, + SessionCloseReason, + SessionDescriptor, +} from '#/app/sessionHostRuntime/sessionRuntimeContext'; + +import { IWorkspaceRuntimeManager } from './workspaceRuntimeManager'; +import { IWorkspaceSessionService } from './workspaceSessionService'; + +/** The lease-backed handle (plan §3.4/§5.4): closing it closes only the child lease. */ +function handleOf(context: ISessionRuntimeContext): ISessionHandle { + const ref: SessionRef = context.ref; + return { + ref, + context, + close: (reason: SessionCloseReason) => context.close(reason), + }; +} + +export class WorkspaceSessionServiceImpl implements IWorkspaceSessionService { + declare readonly _serviceBrand: undefined; + + constructor(@IWorkspaceRuntimeManager private readonly manager: IWorkspaceRuntimeManager) {} + + async create(workspaceId: string, input: CreateSessionInput): Promise { + return this.manager.requireRuntime(workspaceId).sessions.create(input); + } + + async list(workspaceId: string, query?: SessionListQuery): Promise { + return this.manager.requireRuntime(workspaceId).sessions.list(query); + } + + async get(workspaceId: string, sessionId: string): Promise { + return this.manager.requireRuntime(workspaceId).sessions.get(sessionId); + } + + async update( + workspaceId: string, + sessionId: string, + patch: UpdateSessionPatch, + ): Promise { + return this.manager.requireRuntime(workspaceId).sessions.update(sessionId, patch); + } + + async delete( + workspaceId: string, + sessionId: string, + options?: DeleteSessionOptions, + ): Promise { + await this.manager.requireRuntime(workspaceId).sessions.delete(sessionId, options); + } + + async open( + workspaceId: string, + sessionId: string, + options: OpenSessionOptions, + ): Promise { + const context = await this.manager.requireRuntime(workspaceId).sessions.open(sessionId, options); + return handleOf(context); + } + + async resume( + workspaceId: string, + sessionId: string, + options: ResumeSessionOptions, + ): Promise { + const context = await this.manager + .requireRuntime(workspaceId) + .sessions.resume(sessionId, options); + return handleOf(context); + } + + async fork( + workspaceId: string, + sourceSessionId: string, + input: SameRuntimeForkInput, + ): Promise { + return this.manager.requireRuntime(workspaceId).sessions.fork(sourceSessionId, input); + } +} + +registerScopedService( + LifecycleScope.App, + IWorkspaceSessionService, + WorkspaceSessionServiceImpl, + ScopeActivation.OnDemand, + 'workspaceRegistration', +); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 3f667afe1..b9e22e6d0 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -368,6 +368,10 @@ export * from '#/app/workspaceAliases/workspaceAliases'; import '#/app/workspaceAliases/workspaceAliasesService'; export * from '#/app/workspaceSessions/workspaceSessions'; import '#/app/workspaceSessions/workspaceSessionsService'; +export * from '#/app/workspaceRegistration/workspaceRuntimeManager'; +import '#/app/workspaceRegistration/workspaceRuntimeManagerService'; +export * from '#/app/workspaceRegistration/workspaceSessionService'; +import '#/app/workspaceRegistration/workspaceSessionServiceImpl'; import '#/app/git/gitService'; export * from '#/app/bashParser/bashParser'; import '#/app/bashParser/bashParserService'; diff --git a/packages/agent-core-v2/test/app/workspaceRegistration/remoteWorkspaceRuntime.test.ts b/packages/agent-core-v2/test/app/workspaceRegistration/remoteWorkspaceRuntime.test.ts new file mode 100644 index 000000000..1b456502e --- /dev/null +++ b/packages/agent-core-v2/test/app/workspaceRegistration/remoteWorkspaceRuntime.test.ts @@ -0,0 +1,271 @@ +/** + * M3 tests for the Remote Workspace Runtime fake (plan §4.4) — the Remote + * branch of `IWorkspaceProvider`. + * + * Covers the Remote-fake column of the plan §9.1 contract matrix (multi-session + * create/CRUD/open/resume/close with namespace and lock isolation, same-runtime + * fork, cold read, logical export/import) plus the Remote-specific semantics + * the matrix calls out: one shared connection identity across session leases, + * capability-gated contributions excluded at lease assembly, network-cut + * behavior (live leases suspended with `runtime_lost`, every new call fails + * with `session.runtime_unavailable`, cold read included), reconnect revival + * under the same runtime id, and the absence of any Local/App fallback. + * Run with `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/app/workspaceRegistration/remoteWorkspaceRuntime.test.ts`. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import type { ISessionRuntimeContext } from '#/app/sessionHostRuntime/sessionRuntimeContext'; +import type { SessionExportEntry } from '#/app/sessionHostRuntime/sessionManager'; +import { jsonDocumentCodec } from '#/persistence/backends/node-fs/atomicDocumentStore'; + +import { + FakeRemoteWorkspaceProvider, + FakeRemoteWorkspaceRuntime, +} from '../../harness/remoteWorkspaceRuntime'; + +const DESCRIPTOR = { root: '/remote/project', workspaceId: 'wd_remote' } as const; + +async function collect(iterable: AsyncIterable): Promise { + const out: T[] = []; + for await (const item of iterable) out.push(item); + return out; +} + +async function openRuntime( + provider = new FakeRemoteWorkspaceProvider(), +): Promise<{ runtime: FakeRemoteWorkspaceRuntime; dispose: () => Promise }> { + const registration = await provider.open(DESCRIPTOR); + return { + runtime: registration.runtime as FakeRemoteWorkspaceRuntime, + dispose: () => registration.dispose(), + }; +} + +type FakeLease = ISessionRuntimeContext & { readonly closedLease: boolean }; + +async function openLease( + runtime: FakeRemoteWorkspaceRuntime, + sessionId: string, +): Promise { + return (await runtime.sessions.open(sessionId, {})) as FakeLease; +} + +describe('FakeRemoteWorkspaceProvider open (plan §4.1/§4.4)', () => { + it('returns the complete long-lived registration in one shot', async () => { + const provider = new FakeRemoteWorkspaceProvider(); + const registration = await provider.open(DESCRIPTOR); + expect(provider.openCalls).toBe(1); + expect(registration.workspaceId).toBe('wd_remote'); + const runtime = registration.runtime as FakeRemoteWorkspaceRuntime; + expect(runtime.id).toBe('remote-workspace_wd_remote'); + expect(runtime.kind).toBe('remote-workspace'); + expect(runtime.workspaceCapabilities).toEqual(new Set(['workspace.remote'])); + expect(runtime.status()).toBe('online'); + expect(registration.runtime.sessions).toBeDefined(); + + await registration.dispose(); + expect(runtime.status()).toBe('offline'); + await expect(runtime.sessions.list()).rejects.toMatchObject({ + code: 'session.runtime_unavailable', + }); + }); +}); + +describe('multi-session hosting over one shared connection (plan §9.1)', () => { + it('creates many sessions sharing the runtime id with isolated state and locks', async () => { + const { runtime } = await openRuntime(); + const a = await runtime.sessions.create({ sessionId: 'A' }); + const b = await runtime.sessions.create({ sessionId: 'B' }); + expect(a.ref.runtimeId).toBe(runtime.id); + expect(b.ref.runtimeId).toBe(runtime.id); + + // Namespace isolation: A's document is invisible to B. + const leaseA = await openLease(runtime, 'A'); + const leaseB = await openLease(runtime, 'B'); + const nsA = leaseA.persistence.sessionNamespace(); + const nsB = leaseB.persistence.sessionNamespace(); + await leaseA.persistence + .documents(nsA, jsonDocumentCodec) + .set(nsA, 'note.json', { who: 'A' }); + expect( + await leaseB.persistence.documents(nsB, jsonDocumentCodec).get(nsB, 'note.json'), + ).toBeUndefined(); + + // Lock isolation: B's lease does not block A's session roster, but a second + // writer on A conflicts. + await expect(runtime.sessions.open('A', {})).rejects.toMatchObject({ + code: 'session.lease_conflict', + }); + + // Every lease points at the SAME shared connection identity. + expect(leaseA.os?.['remoteConnectionId']).toBe(runtime.connection.id); + expect(leaseB.os?.['remoteConnectionId']).toBe(runtime.connection.id); + + // Closing one session leaves the other fully usable; closing the LAST one + // still keeps the runtime online (plan §5.4). + await leaseA.close('explicit'); + await leaseB.persistence.documents(nsB, jsonDocumentCodec).set(nsB, 'note.json', { who: 'B' }); + await leaseB.close('explicit'); + expect(runtime.status()).toBe('online'); + await runtime.sessions.create({ sessionId: 'C' }); + expect((await runtime.sessions.list()).items.map((d) => d.ref.sessionId).sort()).toEqual([ + 'A', + 'B', + 'C', + ]); + }); + + it('round-trips update/delete, same-runtime fork, cold read and export/import', async () => { + const { runtime } = await openRuntime(); + await runtime.sessions.create({ sessionId: 'src', metadata: { title: 'original' } }); + + const updated = await runtime.sessions.update('src', { metadata: { title: 'renamed' } }); + expect(updated.metadata['title']).toBe('renamed'); + + const forked = await runtime.sessions.fork('src', { sessionId: 'forked' }); + expect(forked.ref.runtimeId).toBe(runtime.id); + expect((await runtime.sessions.get('forked'))?.metadata['title']).toBe('renamed'); + + const cold = await runtime.sessions.coldRead('src'); + expect((await cold.descriptor()).ref.sessionId).toBe('src'); + + const exported = await collect(runtime.sessions.export('src')); + expect(exported.length).toBeGreaterThan(0); + const imported = await runtime.sessions.import({ + sessionId: 'imported', + entries: (async function* (): AsyncIterable { + yield* exported; + })(), + }); + expect(imported.ref).toEqual({ runtimeId: runtime.id, sessionId: 'imported' }); + + await runtime.sessions.delete('imported'); + expect(await runtime.sessions.get('imported')).toBeUndefined(); + }); + + it('writes and reads artifacts per owner through the remote lease', async () => { + const { runtime } = await openRuntime(); + await runtime.sessions.create({ sessionId: 'art' }); + const lease = await openLease(runtime, 'art'); + const ref = await lease.artifacts.write( + { kind: 'agent', agentId: 'main' }, + 'result.bin', + (async function* () { + yield new TextEncoder().encode('remote-bytes'); + })(), + ); + expect(ref.runtimeId).toBe(runtime.id); + expect(ref.sessionId).toBe('art'); + + const stream = await lease.coldReader.readArtifact(ref, { + range: { start: 0, end: 6 }, + }); + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value !== undefined) chunks.push(value); + } + expect(Buffer.concat(chunks).toString('utf8')).toBe('remote'); + + // Owner validation stays the runtime's job (plan §3.6/§9.7). + await expect( + lease.coldReader.readArtifact({ ...ref, owner: { kind: 'session' } }), + ).rejects.toMatchObject({ code: 'artifact.owner_mismatch' }); + await lease.close('explicit'); + }); +}); + +describe('capability-gated contributions (plan §4.4/§7.4)', () => { + it('excludes contributions whose required capability the runtime does not project', async () => { + const provider = new FakeRemoteWorkspaceProvider({ + capabilities: new Set(['os.filesystem', 'session.cold_read']), + contributions: { + sessionServices: [ + { id: 'svc-fs', requires: ['os.filesystem'] }, + { id: 'svc-terminal', requires: ['os.terminal'] }, + { id: 'svc-none', requires: [] }, + ], + agentServices: [{ id: 'agent-proc', requires: ['os.process'] }], + tools: [ + { name: 'read_file', requires: ['os.filesystem'] }, + { name: 'run_pty', requires: ['os.terminal'] }, + ], + }, + }); + const { runtime } = await openRuntime(provider); + await runtime.sessions.create({ sessionId: 'gated' }); + const lease = await openLease(runtime, 'gated'); + + expect(lease.contributions.sessionServices.map((s) => s.id)).toEqual(['svc-fs', 'svc-none']); + expect(lease.contributions.agentServices).toEqual([]); + expect(lease.contributions.tools.map((t) => t.name)).toEqual(['read_file']); + expect(lease.capabilities.has('os.terminal')).toBe(false); + await lease.close('explicit'); + }); +}); + +describe('network cut and reconnect (plan §4.4/§9.2)', () => { + it('suspends live leases and fails every new call while offline, with no fallback', async () => { + const { runtime } = await openRuntime(); + await runtime.sessions.create({ sessionId: 'live-a' }); + await runtime.sessions.create({ sessionId: 'live-b' }); + const leaseA = await openLease(runtime, 'live-a'); + const leaseB = await openLease(runtime, 'live-b'); + const nsA = leaseA.persistence.sessionNamespace(); + await leaseA.persistence.documents(nsA, jsonDocumentCodec).set(nsA, 'note.json', { ok: 1 }); + await leaseA.flush(); + + runtime.connection.disconnect(); + + // Existing child leases enter the suspended/failed state (`runtime_lost`). + await vi.waitFor(() => { + expect(leaseA.closedLease).toBe(true); + expect(leaseB.closedLease).toBe(true); + }); + expect(runtime.status()).toBe('offline'); + + // Every new operation fails with session.runtime_unavailable — including a + // fresh resume and cold read — and never falls back to Local/App storage. + const unavailable = { code: 'session.runtime_unavailable' }; + await expect(runtime.sessions.create({})).rejects.toMatchObject(unavailable); + await expect(runtime.sessions.list()).rejects.toMatchObject(unavailable); + await expect(runtime.sessions.get('live-a')).rejects.toMatchObject(unavailable); + await expect(runtime.sessions.open('live-a', {})).rejects.toMatchObject(unavailable); + await expect(runtime.sessions.resume('live-a', {})).rejects.toMatchObject(unavailable); + await expect(runtime.sessions.coldRead('live-a')).rejects.toMatchObject(unavailable); + await expect(collect(runtime.sessions.export('live-a'))).rejects.toMatchObject(unavailable); + + // Reconnect under the SAME runtime id: the runtime comes back online, the + // suspended leases stay closed (a re-open is a fresh lease), and the data + // written before the cut is intact. + runtime.connection.reconnect(); + expect(runtime.status()).toBe('online'); + expect(runtime.id).toBe('remote-workspace_wd_remote'); + const revived = await openLease(runtime, 'live-a'); + expect(revived).not.toBe(leaseA); + expect( + await revived.persistence + .documents(nsA, jsonDocumentCodec) + .get(nsA, 'note.json'), + ).toEqual({ ok: 1 }); + await revived.close('explicit'); + }); + + it('keeps a disposed (unregistered) runtime down across a reconnect', async () => { + const provider = new FakeRemoteWorkspaceProvider(); + const registration = await provider.open(DESCRIPTOR); + const runtime = registration.runtime as FakeRemoteWorkspaceRuntime; + await registration.dispose(); + expect(runtime.status()).toBe('offline'); + + runtime.connection.reconnect(); + expect(runtime.status()).toBe('offline'); + await expect(runtime.sessions.list()).rejects.toMatchObject({ + code: 'session.runtime_unavailable', + }); + }); +}); diff --git a/packages/agent-core-v2/test/app/workspaceRegistration/workspaceRegistration.test.ts b/packages/agent-core-v2/test/app/workspaceRegistration/workspaceRegistration.test.ts new file mode 100644 index 000000000..57f7c4c29 --- /dev/null +++ b/packages/agent-core-v2/test/app/workspaceRegistration/workspaceRegistration.test.ts @@ -0,0 +1,319 @@ +/** + * M3 tests for the Workspace registration/runtime manager + * (`IWorkspaceRuntimeManager`, plan §7.7) and the Workspace Session facade + * (`IWorkspaceSessionService`, plan §4.2). + * + * Covers plan §9.4: the facade delegates every operation to the SAME + * registered runtime (two internal creates share the runtime id and never + * re-open the provider — spy proof included), ordinary CRUD performs no + * provider.open/register, closing or deleting a session never unregisters the + * runtime, unregister blocks new leases and drops routing while retaining + * session data (a same-id re-registration revives it, plan §9.2), and Local + * A / Local B / Remote C coexist in one process with accurate routing. + * Run with `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/app/workspaceRegistration/workspaceRegistration.test.ts`. + */ + +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import type { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { SessionHostRuntimeRegistry } from '#/app/sessionHostRuntime/sessionHostRuntimeRegistry'; +import type { WorkspaceRuntimeRef } from '#/app/workspaceRegistration/workspaceRuntimeManager'; +import { WorkspaceRuntimeManagerService } from '#/app/workspaceRegistration/workspaceRuntimeManagerService'; +import { WorkspaceSessionServiceImpl } from '#/app/workspaceRegistration/workspaceSessionServiceImpl'; +import { jsonDocumentCodec } from '#/persistence/backends/node-fs/atomicDocumentStore'; +import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; + +import { + FakeRemoteWorkspaceProvider, + FakeRemoteWorkspaceRuntime, +} from '../../harness/remoteWorkspaceRuntime'; + +interface Env { + readonly homeDir: string; + readonly registry: SessionHostRuntimeRegistry; + readonly manager: WorkspaceRuntimeManagerService; + readonly facade: WorkspaceSessionServiceImpl; + readonly remoteProvider: FakeRemoteWorkspaceProvider; + /** Two local workspaces (real temp roots) and one remote workspace ref. */ + readonly A: WorkspaceRuntimeRef; + readonly B: WorkspaceRuntimeRef; + readonly C: WorkspaceRuntimeRef; +} + +async function makeEnv(): Promise { + const homeDir = await mkdtemp(join(tmpdir(), 'wsreg-home-')); + const rootA = await mkdtemp(join(tmpdir(), 'wsreg-a-')); + const rootB = await mkdtemp(join(tmpdir(), 'wsreg-b-')); + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }); + await rm(rootA, { recursive: true, force: true }); + await rm(rootB, { recursive: true, force: true }); + }); + const registry = new SessionHostRuntimeRegistry(); + const manager = new WorkspaceRuntimeManagerService( + registry, + { homeDir } as IBootstrapService, + new FileStorageService(homeDir, 0o700, 0o600), + ); + const facade = new WorkspaceSessionServiceImpl(manager); + const remoteProvider = new FakeRemoteWorkspaceProvider(); + manager.registerProvider('remote', remoteProvider); + return { + homeDir, + registry, + manager, + facade, + remoteProvider, + A: { workspaceId: 'wd_a', root: rootA }, + B: { workspaceId: 'wd_b', root: rootB }, + C: { workspaceId: 'wd_c', root: '/remote/c' }, + }; +} + +describe('WorkspaceRuntimeManagerService registration (plan §7.7)', () => { + it('opens the runtime once, registers it, and reuses the registration on later ensures', async () => { + const env = await makeEnv(); + const first = await env.manager.ensureRegistered(env.A); + expect(first.id).toBe('local-workspace_wd_a'); + expect(env.registry.get('local-workspace_wd_a')).toBe(first); + expect(env.manager.getRuntime(env.A.workspaceId)).toBe(first); + + // The second ensure is a pure reuse: same instance, still one registry entry. + const second = await env.manager.ensureRegistered(env.A); + expect(second).toBe(first); + expect(env.registry.list()).toHaveLength(1); + expect(env.manager.list()).toEqual([ + { workspaceId: 'wd_a', runtimeId: 'local-workspace_wd_a', kind: 'local-workspace' }, + ]); + }); + + it('folds concurrent ensures of one workspace onto a single provider open', async () => { + const env = await makeEnv(); + const [r1, r2, r3] = await Promise.all([ + env.manager.ensureRegistered(env.A), + env.manager.ensureRegistered(env.A), + env.manager.ensureRegistered(env.A), + ]); + expect(r1).toBe(r2); + expect(r2).toBe(r3); + expect(env.registry.list()).toHaveLength(1); + }); + + it('throws session.runtime_not_found from requireRuntime for unknown workspaces', async () => { + const env = await makeEnv(); + expect(() => env.manager.requireRuntime('wd_missing')).toThrowError( + expect.objectContaining({ code: 'session.runtime_not_found' }), + ); + expect(env.manager.getRuntime('wd_missing')).toBeUndefined(); + }); + + it('rejects an unknown provider kind and duplicate provider kinds', async () => { + const env = await makeEnv(); + await expect(env.manager.ensureRegistered(env.A, { kind: 'bogus' })).rejects.toMatchObject({ + code: 'validation.failed', + }); + expect(() => + env.manager.registerProvider('remote', new FakeRemoteWorkspaceProvider()), + ).toThrowError(expect.objectContaining({ code: 'validation.failed' })); + }); + + it('unregister blocks new leases, closes live ones, drops routing and retains data', async () => { + const env = await makeEnv(); + const runtime = await env.manager.ensureRegistered(env.A); + await env.facade.create(env.A.workspaceId, { sessionId: 's1' }); + const lease = await env.facade.open(env.A.workspaceId, 's1', {}); + + await env.manager.unregister(env.A.workspaceId); + + // Routing is gone from both the manager and the host-runtime registry. + expect(env.manager.getRuntime(env.A.workspaceId)).toBeUndefined(); + expect(env.registry.get(runtime.id)).toBeUndefined(); + expect(runtime.status()).toBe('offline'); + // The live lease was closed with `runtime_lost` (plan §7.7 step 2). + expect((lease.context as { closedLease?: boolean }).closedLease).toBe(true); + // New facade calls fail accurately instead of recreating anything. + await expect(env.facade.get(env.A.workspaceId, 's1')).rejects.toMatchObject({ + code: 'session.runtime_not_found', + }); + // Session data is retained on disk (unregister never deletes, plan §3.1). + expect(await readdir(join(env.homeDir, 'sessions', 'wd_a', 's1'))).toEqual(['state.json']); + + // Re-registering the same workspace revives the SAME runtime id, and the + // retained session opens again (plan §9.2/§9.4). + const revived = await env.manager.ensureRegistered(env.A); + expect(revived.id).toBe('local-workspace_wd_a'); + expect(revived).not.toBe(runtime); + expect(revived.status()).toBe('online'); + const descriptor = await env.facade.get(env.A.workspaceId, 's1'); + expect(descriptor?.ref).toEqual({ runtimeId: 'local-workspace_wd_a', sessionId: 's1' }); + const reopened = await env.facade.open(env.A.workspaceId, 's1', {}); + await reopened.close('explicit'); + }); + + it('hosts Local A, Local B and Remote C side by side with accurate routing', async () => { + const env = await makeEnv(); + const localA = await env.manager.ensureRegistered(env.A); + const localB = await env.manager.ensureRegistered(env.B); + const remoteC = await env.manager.ensureRegistered(env.C, { kind: 'remote' }); + expect(remoteC).toBeInstanceOf(FakeRemoteWorkspaceRuntime); + + expect(env.registry.list().map((r) => r.id).sort()).toEqual([ + 'local-workspace_wd_a', + 'local-workspace_wd_b', + 'remote-workspace_wd_c', + ]); + + // Each runtime hosts multiple sessions; ids collide across runtimes on + // purpose — routing by workspace resolves each one correctly. + await env.facade.create(env.A.workspaceId, { sessionId: 'same' }); + await env.facade.create(env.A.workspaceId, { sessionId: 'a2' }); + await env.facade.create(env.B.workspaceId, { sessionId: 'same' }); + await env.facade.create(env.C.workspaceId, { sessionId: 'same' }); + await env.facade.create(env.C.workspaceId, { sessionId: 'c2' }); + + const listA = await env.facade.list(env.A.workspaceId); + expect(listA.items.map((d) => d.ref.sessionId).sort()).toEqual(['a2', 'same']); + expect(listA.items.every((d) => d.ref.runtimeId === localA.id)).toBe(true); + const listB = await env.facade.list(env.B.workspaceId); + expect(listB.items.map((d) => d.ref.sessionId)).toEqual(['same']); + expect(listB.items[0]?.ref.runtimeId).toBe(localB.id); + const listC = await env.facade.list(env.C.workspaceId); + expect(listC.items.map((d) => d.ref.sessionId).sort()).toEqual(['c2', 'same']); + expect(listC.items.every((d) => d.ref.runtimeId === remoteC.id)).toBe(true); + + // The same-named sessions stay isolated across runtimes. + await env.facade.update(env.A.workspaceId, 'same', { metadata: { title: 'A session' } }); + expect((await env.facade.get(env.B.workspaceId, 'same'))?.metadata['title']).toBeUndefined(); + expect((await env.facade.get(env.A.workspaceId, 'same'))?.metadata['title']).toBe('A session'); + }); +}); + +describe('WorkspaceSessionServiceImpl facade (plan §4.2/§9.4)', () => { + it('delegates the full CRUD/open/resume/fork surface to the registered runtime', async () => { + const env = await makeEnv(); + const runtime = await env.manager.ensureRegistered(env.A); + + const one = await env.facade.create(env.A.workspaceId, { sessionId: 'one' }); + const two = await env.facade.create(env.A.workspaceId, { sessionId: 'two' }); + // Two internal creates share the runtime id (plan §9.4). + expect(one.ref.runtimeId).toBe(runtime.id); + expect(two.ref.runtimeId).toBe(runtime.id); + + expect((await env.facade.get(env.A.workspaceId, 'one'))?.ref.sessionId).toBe('one'); + expect((await env.facade.list(env.A.workspaceId)).items).toHaveLength(2); + + const updated = await env.facade.update(env.A.workspaceId, 'one', { + metadata: { title: 'renamed' }, + }); + expect(updated.metadata['title']).toBe('renamed'); + expect((await env.facade.get(env.A.workspaceId, 'one'))?.metadata['title']).toBe('renamed'); + + const forked = await env.facade.fork(env.A.workspaceId, 'one', { sessionId: 'three' }); + expect(forked.ref.runtimeId).toBe(runtime.id); + expect((await env.facade.list(env.A.workspaceId)).items).toHaveLength(3); + + const lease = await env.facade.open(env.A.workspaceId, 'two', {}); + expect(lease.ref).toEqual({ runtimeId: runtime.id, sessionId: 'two' }); + // A live lease blocks a second writer (isolation stays the runtime's). + await expect(env.facade.resume(env.A.workspaceId, 'two', {})).rejects.toMatchObject({ + code: 'session.lease_conflict', + }); + await lease.close('explicit'); + const resumed = await env.facade.resume(env.A.workspaceId, 'two', {}); + await resumed.close('explicit'); + + await env.facade.delete(env.A.workspaceId, 'three'); + expect(await env.facade.get(env.A.workspaceId, 'three')).toBeUndefined(); + }); + + it('never opens a provider for ordinary CRUD (spy proof, plan §9.4)', async () => { + const env = await makeEnv(); + // One sanctioned registration (the v1-create-adapter shape); everything + // after it must be pure reuse. + await env.manager.ensureRegistered(env.C, { kind: 'remote' }); + expect(env.remoteProvider.openCalls).toBe(1); + + await env.facade.create(env.C.workspaceId, { sessionId: 's1' }); + await env.facade.create(env.C.workspaceId, { sessionId: 's2' }); + await env.facade.list(env.C.workspaceId); + await env.facade.get(env.C.workspaceId, 's1'); + await env.facade.update(env.C.workspaceId, 's1', { metadata: { title: 'x' } }); + await env.facade.fork(env.C.workspaceId, 's1', { sessionId: 's3' }); + const lease = await env.facade.open(env.C.workspaceId, 's2', {}); + await lease.close('explicit'); + await env.facade.delete(env.C.workspaceId, 's3'); + + // No ordinary operation re-opened the provider or re-registered a runtime. + expect(env.remoteProvider.openCalls).toBe(1); + expect(env.registry.list()).toHaveLength(1); + }); + + it('keeps the runtime registered and online when sessions close or are deleted', async () => { + const env = await makeEnv(); + const runtime = await env.manager.ensureRegistered(env.A); + await env.facade.create(env.A.workspaceId, { sessionId: 'keep1' }); + await env.facade.create(env.A.workspaceId, { sessionId: 'keep2' }); + + const lease = await env.facade.open(env.A.workspaceId, 'keep1', {}); + await lease.close('explicit'); + await env.facade.delete(env.A.workspaceId, 'keep2'); + + // Neither closing nor deleting a session touches the registration. + expect(env.manager.getRuntime(env.A.workspaceId)).toBe(runtime); + expect(env.registry.get(runtime.id)).toBe(runtime); + expect(runtime.status()).toBe('online'); + // … and the runtime keeps hosting sessions afterwards. + await env.facade.create(env.A.workspaceId, { sessionId: 'keep3' }); + expect( + (await env.facade.list(env.A.workspaceId)).items.map((d) => d.ref.sessionId).sort(), + ).toEqual(['keep1', 'keep3']); + }); + + it('fails facade calls with session.runtime_not_found for unregistered workspaces', async () => { + const env = await makeEnv(); + await expect(env.facade.create('wd_never', {})).rejects.toMatchObject({ + code: 'session.runtime_not_found', + }); + await expect(env.facade.list('wd_never')).rejects.toMatchObject({ + code: 'session.runtime_not_found', + }); + }); + + it('isolates facade-opened leases per session while sharing the runtime (plan §9.3)', async () => { + const env = await makeEnv(); + await env.manager.ensureRegistered(env.A); + await env.facade.create(env.A.workspaceId, { sessionId: 'la' }); + await env.facade.create(env.A.workspaceId, { sessionId: 'lb' }); + + const leaseA = await env.facade.open(env.A.workspaceId, 'la', {}); + const leaseB = await env.facade.open(env.A.workspaceId, 'lb', {}); + const nsA = leaseA.context.persistence.agentNamespace('main'); + const nsB = leaseB.context.persistence.agentNamespace('main'); + leaseA.context.persistence + .logs(nsA, jsonDocumentCodec) + .append(nsA, 'wire.jsonl', { type: 'wire.test', who: 'A' }); + leaseB.context.persistence + .logs(nsB, jsonDocumentCodec) + .append(nsB, 'wire.jsonl', { type: 'wire.test', who: 'B' }); + await leaseA.context.flush(); + await leaseA.close('explicit'); + + // Closing A leaves B fully writable; the runtime stays up either way. + expect(env.manager.getRuntime(env.A.workspaceId)?.status()).toBe('online'); + leaseB.context.persistence + .logs(nsB, jsonDocumentCodec) + .append(nsB, 'wire.jsonl', { type: 'wire.test', who: 'B2' }); + await leaseB.context.flush(); + await leaseB.close('explicit'); + + const coldB = await env.manager.requireRuntime(env.A.workspaceId).sessions.coldRead('lb'); + const records: unknown[] = []; + for await (const record of coldB.readRecords({ agentId: 'main' })) records.push(record); + expect(records).toHaveLength(2); + }); +}); diff --git a/packages/agent-core-v2/test/harness/remoteWorkspaceRuntime.ts b/packages/agent-core-v2/test/harness/remoteWorkspaceRuntime.ts new file mode 100644 index 000000000..170b5e805 --- /dev/null +++ b/packages/agent-core-v2/test/harness/remoteWorkspaceRuntime.ts @@ -0,0 +1,325 @@ +/** + * Test harness — `FakeRemoteWorkspaceProvider` / `FakeRemoteWorkspaceRuntime`, + * the Remote branch of `IWorkspaceProvider` (plan §4.4). + * + * The fake models a LONG-LIVED remote workspace runtime hosting any number of + * sessions behind ONE shared remote connection: + * + * - `runtime.sessions` implements the full CRUD / open / lease / cold / + * artifact / export / import surface on top of an in-memory "provider + * backend" (the standalone memory session manager stands in for the + * remote store); sessions share the connection identity while their + * namespaces, locks and state stay isolated; + * - OS capabilities surface as an opaque remote handle on the lease (`os`), + * carrying the shared connection id every session lease points at; + * - contributions are gated at lease-assembly time: a contribution whose + * `requires` names a capability the runtime does not project is excluded + * before the Session scope would ever see it (plan §4.4/§7.4); + * - `connection.disconnect()` simulates a network cut: the runtime flips + * offline, every live child lease is closed with `runtime_lost` (the + * suspended/failed signal), and new open/resume (and every other manager + * call) fails with `session.runtime_unavailable` until + * `connection.reconnect()` flips the same runtime id back online — + * previously suspended leases do NOT revive. There is no Local/App + * fallback path at all. + * + * It doubles as the skeleton a real Remote provider grows from: swap the + * in-memory delegate for an RPC-bound session manager and keep the + * connection/lease/capability semantics. + */ + +import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; + +import type { + ISessionHostRuntime, + RuntimeCloseReason, + SessionRuntimeCapability, + SessionRuntimeStatus, +} from '#/app/sessionHostRuntime/sessionHostRuntime'; +import type { + ISessionManager, + OpenSessionOptions, + ResumeSessionOptions, +} from '#/app/sessionHostRuntime/sessionManager'; +import type { + ISessionRuntimeContext, + SessionRuntimeContributions, +} from '#/app/sessionHostRuntime/sessionRuntimeContext'; +import { StandaloneMemorySessionManager } from '#/app/standaloneMemoryRuntime/standaloneMemoryHostRuntime'; +import type { + IWorkspaceProvider, + IWorkspaceRuntime, + IWorkspaceRuntimeRegistration, + WorkspaceCapability, + WorkspaceDescriptor, +} from '#/app/workspace/workspaceRuntime'; + +const DEFAULT_REMOTE_CAPABILITIES: ReadonlySet = new Set([ + 'os.filesystem', + 'os.process', + 'artifact.model_read', + 'session.cold_read', + 'session.export', + 'session.import', + 'session.fork', +]); + +const DEFAULT_REMOTE_WORKSPACE_CAPABILITIES: ReadonlySet = new Set([ + 'workspace.remote', +]); + +const NO_CONTRIBUTIONS: SessionRuntimeContributions = { + sessionServices: [], + agentServices: [], + tools: [], +}; + +/** + * The shared remote connection (plan §4.4: sessions share the connection and + * its auth identity). `disconnect`/`reconnect` drive the runtime's online + * status; listeners let the owning runtime suspend leases on a cut. + */ +export class FakeRemoteConnection { + readonly id: string; + private connected = true; + private readonly disconnectListeners = new Set<() => void>(); + private readonly reconnectListeners = new Set<() => void>(); + + constructor(id: string) { + this.id = id; + } + + isConnected(): boolean { + return this.connected; + } + + disconnect(): void { + if (!this.connected) return; + this.connected = false; + for (const listener of this.disconnectListeners) listener(); + } + + reconnect(): void { + if (this.connected) return; + this.connected = true; + for (const listener of this.reconnectListeners) listener(); + } + + onDisconnect(listener: () => void): () => void { + this.disconnectListeners.add(listener); + return () => this.disconnectListeners.delete(listener); + } + + onReconnect(listener: () => void): () => void { + this.reconnectListeners.add(listener); + return () => this.reconnectListeners.delete(listener); + } +} + +export interface FakeRemoteWorkspaceRuntimeOptions { + readonly workspaceId: string; + /** Defaults to `remote-workspace_` (deterministic re-open identity). */ + readonly runtimeId?: string; + readonly connection?: FakeRemoteConnection; + readonly capabilities?: ReadonlySet; + readonly workspaceCapabilities?: ReadonlySet; + /** Ungated contribution set; the lease view filters it by capability (plan §7.4). */ + readonly contributions?: SessionRuntimeContributions; +} + +export class FakeRemoteWorkspaceRuntime implements IWorkspaceRuntime { + readonly id: string; + readonly kind = 'remote-workspace'; + readonly sessions: ISessionManager; + readonly workspaceCapabilities: ReadonlySet; + /** The shared connection every session lease of this runtime points at. */ + readonly connection: FakeRemoteConnection; + + private readonly delegate: StandaloneMemorySessionManager; + private currentStatus: SessionRuntimeStatus = 'online'; + private disposed = false; + private readonly caps: ReadonlySet; + private readonly ungatedContributions: SessionRuntimeContributions; + + constructor(options: FakeRemoteWorkspaceRuntimeOptions) { + this.id = options.runtimeId ?? `remote-workspace_${options.workspaceId}`; + this.caps = options.capabilities ?? DEFAULT_REMOTE_CAPABILITIES; + this.workspaceCapabilities = + options.workspaceCapabilities ?? DEFAULT_REMOTE_WORKSPACE_CAPABILITIES; + this.ungatedContributions = options.contributions ?? NO_CONTRIBUTIONS; + this.connection = + options.connection ?? new FakeRemoteConnection(`${this.id}/connection`); + this.delegate = new StandaloneMemorySessionManager( + this.id, + () => this.currentStatus, + (status) => { + this.currentStatus = status; + }, + this.caps, + this.ungatedContributions, + ); + this.sessions = new FakeRemoteSessionManager(this.delegate, (context) => + this.gateContext(context), + ); + this.connection.onDisconnect(() => { + // Network cut: flip offline and suspend every live child lease with + // `runtime_lost` — never a Local/App-scope fallback (plan §4.4). + void this.delegate.closeRuntime(); + }); + this.connection.onReconnect(() => { + // The same runtime id comes back online; suspended leases stay closed + // and must be re-opened. A disposed (unregistered) runtime stays down. + if (!this.disposed) { + this.currentStatus = 'online'; + } + }); + } + + status(): SessionRuntimeStatus { + return this.currentStatus; + } + + capabilities(): ReadonlySet { + return this.caps; + } + + async close(_reason: RuntimeCloseReason): Promise { + this.disposed = true; + await this.delegate.closeRuntime(); + } + + /** + * Lease assembly (plan §3.3/§4.4): contributions whose `requires` names a + * capability this runtime does not project are excluded BEFORE the Session + * scope would be built, and the lease exposes the shared remote connection + * as its OS capability handle. The wrapper forwards the underlying lease's + * `closedLease` flag so tests can observe a suspended lease directly. + */ + private gateContext(context: ISessionRuntimeContext): ISessionRuntimeContext { + const caps = this.caps; + const gate = ( + entries: readonly T[], + ): readonly T[] => entries.filter((entry) => entry.requires.every((r) => caps.has(r))); + const wrapped: ISessionRuntimeContext & { readonly closedLease: boolean } = { + ref: context.ref, + descriptor: context.descriptor, + persistence: context.persistence, + artifacts: context.artifacts, + coldReader: context.coldReader, + capabilities: context.capabilities, + contributions: { + sessionServices: gate(context.contributions.sessionServices), + agentServices: gate(context.contributions.agentServices), + tools: gate(context.contributions.tools), + }, + os: { remoteConnectionId: this.connection.id }, + get closedLease() { + return (context as { readonly closedLease?: boolean }).closedLease ?? false; + }, + flush: () => context.flush(), + close: (reason) => context.close(reason), + }; + return wrapped; + } +} + +/** + * Delegating manager: every method reaches the "remote backend" through the + * shared connection — while offline the delegate itself fails every call with + * `session.runtime_unavailable`. open/resume additionally run the fake's + * lease-assembly gate (contributions + OS handle). + */ +class FakeRemoteSessionManager implements ISessionManager { + constructor( + private readonly delegate: StandaloneMemorySessionManager, + private readonly gate: (context: ISessionRuntimeContext) => ISessionRuntimeContext, + ) {} + + create(input: Parameters[0]) { + return this.delegate.create(input); + } + + list(query?: Parameters[0]) { + return this.delegate.list(query); + } + + get(sessionId: string) { + return this.delegate.get(sessionId); + } + + update(sessionId: string, patch: Parameters[1]) { + return this.delegate.update(sessionId, patch); + } + + delete(sessionId: string, options?: Parameters[1]) { + return this.delegate.delete(sessionId, options); + } + + fork(sourceSessionId: string, input: Parameters[1]) { + return this.delegate.fork(sourceSessionId, input); + } + + coldRead(sessionId: string) { + return this.delegate.coldRead(sessionId); + } + + export(sessionId: string, options?: Parameters[1]) { + return this.delegate.export(sessionId, options); + } + + import(input: Parameters[0]) { + return this.delegate.import(input); + } + + async open( + sessionId: string, + options: OpenSessionOptions, + ): Promise { + const context = await this.delegate.open(sessionId, options); + return this.gate(context); + } + + async resume( + sessionId: string, + options: ResumeSessionOptions, + ): Promise { + const context = await this.delegate.resume(sessionId, options); + return this.gate(context); + } +} + +export interface FakeRemoteWorkspaceProviderOptions { + readonly capabilities?: ReadonlySet; + readonly contributions?: SessionRuntimeContributions; +} + +/** + * The Remote branch of `IWorkspaceProvider` (plan §4.4): `open` establishes + * the shared remote connection and returns the complete long-lived runtime in + * one shot. `openCalls` is the spy counter proving ordinary session CRUD never + * re-opens the provider (plan §9.4). + */ +export class FakeRemoteWorkspaceProvider implements IWorkspaceProvider { + private openCount = 0; + + constructor(private readonly options: FakeRemoteWorkspaceProviderOptions = {}) {} + + get openCalls(): number { + return this.openCount; + } + + open(descriptor: WorkspaceDescriptor): Promise { + this.openCount += 1; + const workspaceId = descriptor.workspaceId ?? encodeWorkDirKey(descriptor.root); + const runtime = new FakeRemoteWorkspaceRuntime({ + workspaceId, + capabilities: this.options.capabilities, + contributions: this.options.contributions, + }); + return Promise.resolve({ + workspaceId, + runtime, + dispose: () => runtime.close('unregistered'), + }); + } +} diff --git a/packages/kap-server/src/app/v1Compatibility/v1WorkspaceSessionAdapter.ts b/packages/kap-server/src/app/v1Compatibility/v1WorkspaceSessionAdapter.ts new file mode 100644 index 000000000..9c82ffcf9 --- /dev/null +++ b/packages/kap-server/src/app/v1Compatibility/v1WorkspaceSessionAdapter.ts @@ -0,0 +1,180 @@ +/** + * v1 Workspace create compatibility adapter (multi-runtime refactor, plan + * §4.2/§5.1/§5.2/§6.2). + * + * This adapter owns the `POST /api/v1/sessions` create flow's Workspace side: + * it resolves `workspace_id` / `metadata.cwd` with the CURRENT rules + * (`IWorkspaceService.get` for an explicit id — unknown id is the wire 40410; + * `createOrTouch` for the registration — a missing/unusable root is the wire + * 40409 through the route's `Error2` mapping), then obtains the workspace's + * ALREADY-REGISTERED `IWorkspaceRuntime` from the registration manager + * (`ensureRegistered` opens and registers it exactly once; every later create + * for the same workspace reuses that instance — the registry's + * `session.runtime_id_conflict` stands as the duplicate sentinel). + * + * Runtime create/register happens ONLY here: the generic + * `ISessionService.create` and Session Core never open a provider, and no + * per-session runtime is ever created (plan §6.2). + * + * Session creation itself keeps today's observable behavior exactly: + * + * 1. `runtime.sessions.create({ sessionId })` persists the session under the + * owner runtime (the M2 byte-identical `state.json` + + * `session_index.jsonl` layout — the persistence owner); + * 2. `ISessionLifecycleService.create({ workDir, sessionId })` then builds + * the live Session scope through the EXISTING engine path: its metadata + * store tolerant-reads the state.json written in step 1 (same document + * version/shape, zero rewrite), and everything else about today's create + * — scope materialization, main-agent/plan-mode handling, lifecycle + * hooks (`SessionStart` with source `startup`), telemetry + * (`session_started { resumed: false }`), the session_index discovery + * line and the failure rollback — is preserved verbatim. The session id + * keeps the current globally-random `session_` strategy, so the + * bare-id v1 wire never changes shape. + * + * The wire surface stays frozen: the route keeps its path/method/schema, its + * numeric error codes and envelope, and projects the outcome with the same + * `toWireSession` + `event.session.created` publication as before. + */ + +import { randomUUID } from 'node:crypto'; + +import { + ISessionLifecycleService, + ISessionMetadata, + IWorkspaceRuntimeManager, + IWorkspaceService, + type Scope, + type SessionMeta, + type Workspace, +} from '@moonshot-ai/agent-core-v2'; + +import { errEnvelope } from '../../envelope'; +import { ErrorCode } from '../../protocol/error-codes'; +import type { CreateSessionRequest } from '../../protocol/rest-session'; + +/** The adapter outcome the route projects; error envelopes are wire-frozen. */ +export type V1CreateSessionOutcome = + | { + readonly kind: 'created'; + /** Fresh metadata read from the live session (title already applied). */ + readonly meta: SessionMeta; + /** The catalog entry the create resolved/touched (id + root). */ + readonly workspace: Workspace; + } + | { readonly kind: 'error'; readonly envelope: V1CreateErrorEnvelope }; + +type V1CreateErrorEnvelope = + | ReturnType + | ReturnType; + +/** + * Resolve the create's workspace inputs with the current rules, create the + * session through the registered workspace runtime, and activate it through + * the existing lifecycle. Validation failures come back as frozen error + * envelopes; `Error2` failures (`fs.path_not_found`, lifecycle errors) + * propagate to the route's existing `sendMappedError`. + */ +export async function createV1WorkspaceSession( + core: Scope, + body: CreateSessionRequest, + requestId: string, +): Promise { + const callerCwd = typeof body.metadata?.cwd === 'string' ? body.metadata.cwd : undefined; + const workspaceId = body.workspace_id; + if (workspaceId === undefined && callerCwd === undefined) { + return { + kind: 'error', + envelope: buildValidationEnvelope( + [{ path: 'metadata.cwd', message: 'either workspace_id or metadata.cwd is required' }], + requestId, + ), + }; + } + + const workspaces = core.accessor.get(IWorkspaceService); + let workDir: string; + if (workspaceId !== undefined) { + const workspace = await workspaces.get(workspaceId); + if (workspace === undefined) { + return { + kind: 'error', + envelope: errEnvelope( + ErrorCode.WORKSPACE_NOT_FOUND, + `workspace ${workspaceId} does not exist`, + requestId, + ), + }; + } + if (callerCwd !== undefined && callerCwd !== workspace.root) { + return { + kind: 'error', + envelope: buildValidationEnvelope( + [ + { + path: 'metadata.cwd', + message: `metadata.cwd (${callerCwd}) must equal workspace root (${workspace.root})`, + }, + ], + requestId, + ), + }; + } + workDir = workspace.root; + } else { + workDir = callerCwd as string; + } + + // Ensure the workspace is registered so `metadata.cwd` is resolvable on + // read (gap G3 — v2 does not store workDir on the session). Throws + // `fs.path_not_found` for a missing/unusable root (the wire 40409). + const touched = await workspaces.createOrTouch(workDir); + + // Resolve the workspace's long-lived runtime, opening + registering it only + // when absent; an existing registration is reused as-is (plan §9.4: two + // creates must share one runtime). Runtime create/register lives ONLY in + // this adapter — Session Core and the generic session service never see it. + const runtime = await core.accessor.get(IWorkspaceRuntimeManager).ensureRegistered({ + workspaceId: touched.id, + root: touched.root, + }); + + // The owner runtime persists the session (M2 byte-identical layout); the + // existing lifecycle path then activates it live with today's full behavior. + const sessionId = `session_${randomUUID()}`; + await runtime.sessions.create({ sessionId }); + const handle = await core.accessor.get(ISessionLifecycleService).create({ workDir, sessionId }); + + if (typeof body.title === 'string') { + await handle.accessor.get(ISessionMetadata).setTitle(body.title); + } + const meta = await handle.accessor.get(ISessionMetadata).read(); + return { kind: 'created', meta, workspace: touched }; +} + +/** The v1 validation-envelope builder (same shape as the route's local one). */ +function buildValidationEnvelope( + details: { path: string; message: string }[], + requestId: string, +): { + code: number; + msg: string; + data: null; + request_id: string; + details: { path: string; message: string }[]; +} { + const first = details[0]; + const msg = + first === undefined + ? 'validation failed' + : first.path === '' + ? first.message + : `${first.path}: ${first.message}`; + return { + code: ErrorCode.VALIDATION_FAILED, + msg, + data: null, + request_id: requestId, + details, + }; +} diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 1623004e0..34d4de824 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -127,6 +127,7 @@ import { import { workspaceIdSchema } from '../protocol/workspace'; import { z } from 'zod'; +import { createV1WorkspaceSession } from '../app/v1Compatibility/v1WorkspaceSessionAdapter'; import { errEnvelope, okEnvelope } from '../envelope'; import { requestLog } from '../lib/requestLog'; import { defineRoute } from '../middleware/defineRoute'; @@ -263,67 +264,20 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void tags: ['sessions'], }, async (req, reply) => { - const body = req.body; - const callerCwd = typeof body.metadata?.cwd === 'string' ? body.metadata.cwd : undefined; - const workspaceId = body.workspace_id; - if (workspaceId === undefined && callerCwd === undefined) { - reply.send( - buildValidationEnvelope( - [{ path: 'metadata.cwd', message: 'either workspace_id or metadata.cwd is required' }], - req.id, - ), - ); - return; - } - - const registry = core.accessor.get(IWorkspaceService); - let workDir: string; - if (workspaceId !== undefined) { - const workspace = await registry.get(workspaceId); - if (workspace === undefined) { - reply.send( - errEnvelope( - ErrorCode.WORKSPACE_NOT_FOUND, - `workspace ${workspaceId} does not exist`, - req.id, - ), - ); - return; - } - if (callerCwd !== undefined && callerCwd !== workspace.root) { - reply.send( - buildValidationEnvelope( - [ - { - path: 'metadata.cwd', - message: `metadata.cwd (${callerCwd}) must equal workspace root (${workspace.root})`, - }, - ], - req.id, - ), - ); - return; - } - workDir = workspace.root; - } else { - workDir = callerCwd as string; - } - - // Ensure the workspace is registered so `metadata.cwd` is resolvable on - // read (gap G3 — v2 does not store workDir on the session). try { - const touched = await registry.createOrTouch(workDir); - - const handle = await core.accessor.get(ISessionLifecycleService).create({ - workDir, - }); - if (typeof body.title === 'string') { - await handle.accessor.get(ISessionMetadata).setTitle(body.title); + // The Workspace side of create (workspace_id/cwd resolution, + // createOrTouch registration, runtime resolution/reuse and the + // runtime-owned persistence create) lives in the v1 compatibility + // adapter (plan §6.2); the projection and `event.session.created` + // publication below keep today's wire behavior verbatim. + const outcome = await createV1WorkspaceSession(core, req.body, req.id); + if (outcome.kind === 'error') { + reply.send(outcome.envelope); + return; } - const meta = await handle.accessor.get(ISessionMetadata).read(); const session = toWireSession( - { ...meta, workspaceId: touched.id }, - touched.root, + { ...outcome.meta, workspaceId: outcome.workspace.id }, + outcome.workspace.root, { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, ); core.accessor.get(IEventService).publish({ diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index b6a2db27d..255e09334 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -391,6 +391,73 @@ describe('server-v2 /api/v1/sessions', () => { expect(body.details?.[0]?.path).toBe('metadata.cwd'); }); + it('reuses ONE registered workspace runtime across cwd/workspace_id creates and session teardown', async () => { + const cwd = home as string; + const first = await postJson('/api/v1/sessions', { metadata: { cwd } }); + expect(first.body.code).toBe(0); + const second = await postJson('/api/v1/sessions', { + workspace_id: first.body.data.workspace_id, + }); + expect(second.body.code).toBe(0); + // Session teardown (archive) must not unregister the runtime. + const archived = await postJson(`/api/v1/sessions/${first.body.data.id}:archive`, {}); + expect(archived.body.code).toBe(0); + + interface RuntimeSummary { + id: string; + kind: string; + status: string; + } + const runtimes = await getJson( + '/api/v1/debug/sessionHostRuntimeRegistry/list', + ); + expect(runtimes.body.code).toBe(0); + const localRuntimes = runtimes.body.data.filter((r) => r.kind === 'local-workspace'); + // Two creates (plus an archive) produced exactly one long-lived runtime, + // keyed by the deterministic local-workspace_ id. + expect(localRuntimes).toHaveLength(1); + expect(localRuntimes[0]?.id).toBe(`local-workspace_${first.body.data.workspace_id}`); + expect(localRuntimes[0]?.status).toBe('online'); + }); + + it('keeps adapter-created sessions listable/gettable/resumable after a server restart', async () => { + const cwd = home as string; + const created = await postJson('/api/v1/sessions', { + title: 'before restart', + metadata: { cwd }, + }); + expect(created.body.code).toBe(0); + const sessionId = created.body.data.id; + const workspaceId = created.body.data.workspace_id; + + // Cold restart on the same home dir: the M2 byte layout (state.json + + // session_index.jsonl) is the only persistence involved. + await server?.close(); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home as string, + logLevel: 'silent', + debugEndpoints: true, + }); + base = `http://127.0.0.1:${server.port}`; + + const listed = await getJson('/api/v1/sessions'); + const found = listed.body.data.items.find((s) => s.id === sessionId); + expect(found?.title).toBe('before restart'); + expect(found?.workspace_id).toBe(workspaceId); + expect(found?.metadata.cwd).toBe(cwd); + + const got = await getJson(`/api/v1/sessions/${sessionId}`); + expect(got.body.code).toBe(0); + expect(got.body.data.metadata.cwd).toBe(cwd); + + // The existing cold-load (resume) path still serves the session. + const status = await getJson(`/api/v1/sessions/${sessionId}/status`); + expect(status.body.code).toBe(0); + }); + + it('lists created sessions', async () => { const cwd = home as string; const created = await postJson('/api/v1/sessions', { metadata: { cwd } });