feat(agent-core-v2): add workspace runtime manager, session facade, and v1 create adapter

Implement the M3 milestone:

- Add app/workspaceRegistration: a long-lived workspace runtime manager
  (idempotent ensureRegistered with inflight coalescing, unregister that
  blocks new leases then closes the runtime without deleting data) and
  the internal IWorkspaceSessionService facade that delegates every
  operation to the already-registered runtime's sessions manager.
- Add a remote workspace runtime test harness fake with offline
  semantics (leases fail on disconnect, resume returns
  session.runtime_unavailable, no local fallback) and capability gating.
- Route POST /api/v1/sessions through a v1 compatibility adapter that
  resolves/touches the workspace registration, reuses the registered
  LocalWorkspaceRuntime, persists via runtime.sessions.create, and
  activates through the existing lifecycle service so hooks, telemetry,
  rollback, and the wire response stay byte-identical.
This commit is contained in:
haozhe.yang 2026-07-28 22:48:31 +08:00
parent 355372a5e9
commit 32a7e35429
14 changed files with 1658 additions and 60 deletions

View file

@ -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']),
],
]);

View file

@ -31,7 +31,9 @@ import type { ISessionManager } from '#/app/sessionHostRuntime/sessionManager';
*/
export type WorkspaceCapability =
/** Sessions live in the local `sessions/<wd_id>/<sessionId>` 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

View file

@ -0,0 +1,4 @@
export * from './workspaceRuntimeManager';
export * from './workspaceRuntimeManagerService';
export * from './workspaceSessionService';
export * from './workspaceSessionServiceImpl';

View file

@ -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<IWorkspaceRuntime>;
/**
* 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<void>;
list(): readonly WorkspaceRuntimeRegistrationSummary[];
}
export const IWorkspaceRuntimeManager: ServiceIdentifier<IWorkspaceRuntimeManager> =
createDecorator<IWorkspaceRuntimeManager>('workspaceRuntimeManager');

View file

@ -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<string, IWorkspaceProvider>();
private readonly registrations = new Map<string, RegisteredWorkspaceRuntime>();
/** In-flight `ensureRegistered` opens, folded per workspace. */
private readonly inflight = new Map<string, Promise<IWorkspaceRuntime>>();
/** In-flight `unregister` teardowns; a re-register waits for them. */
private readonly draining = new Map<string, Promise<void>>();
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<IWorkspaceRuntime> {
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<IWorkspaceRuntime> {
// 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<void> {
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',
);

View file

@ -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<SessionDescriptor>;
list(workspaceId: string, query?: SessionListQuery): Promise<SessionPage>;
get(workspaceId: string, sessionId: string): Promise<SessionDescriptor | undefined>;
update(
workspaceId: string,
sessionId: string,
patch: UpdateSessionPatch,
): Promise<SessionDescriptor>;
delete(workspaceId: string, sessionId: string, options?: DeleteSessionOptions): Promise<void>;
open(workspaceId: string, sessionId: string, options: OpenSessionOptions): Promise<ISessionHandle>;
resume(
workspaceId: string,
sessionId: string,
options: ResumeSessionOptions,
): Promise<ISessionHandle>;
/** Same-runtime fork only; the target descriptor shares the workspace runtime id. */
fork(
workspaceId: string,
sourceSessionId: string,
input: SameRuntimeForkInput,
): Promise<SessionDescriptor>;
}
export const IWorkspaceSessionService: ServiceIdentifier<IWorkspaceSessionService> =
createDecorator<IWorkspaceSessionService>('workspaceSessionService');

View file

@ -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<SessionDescriptor> {
return this.manager.requireRuntime(workspaceId).sessions.create(input);
}
async list(workspaceId: string, query?: SessionListQuery): Promise<SessionPage> {
return this.manager.requireRuntime(workspaceId).sessions.list(query);
}
async get(workspaceId: string, sessionId: string): Promise<SessionDescriptor | undefined> {
return this.manager.requireRuntime(workspaceId).sessions.get(sessionId);
}
async update(
workspaceId: string,
sessionId: string,
patch: UpdateSessionPatch,
): Promise<SessionDescriptor> {
return this.manager.requireRuntime(workspaceId).sessions.update(sessionId, patch);
}
async delete(
workspaceId: string,
sessionId: string,
options?: DeleteSessionOptions,
): Promise<void> {
await this.manager.requireRuntime(workspaceId).sessions.delete(sessionId, options);
}
async open(
workspaceId: string,
sessionId: string,
options: OpenSessionOptions,
): Promise<ISessionHandle> {
const context = await this.manager.requireRuntime(workspaceId).sessions.open(sessionId, options);
return handleOf(context);
}
async resume(
workspaceId: string,
sessionId: string,
options: ResumeSessionOptions,
): Promise<ISessionHandle> {
const context = await this.manager
.requireRuntime(workspaceId)
.sessions.resume(sessionId, options);
return handleOf(context);
}
async fork(
workspaceId: string,
sourceSessionId: string,
input: SameRuntimeForkInput,
): Promise<SessionDescriptor> {
return this.manager.requireRuntime(workspaceId).sessions.fork(sourceSessionId, input);
}
}
registerScopedService(
LifecycleScope.App,
IWorkspaceSessionService,
WorkspaceSessionServiceImpl,
ScopeActivation.OnDemand,
'workspaceRegistration',
);

View file

@ -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';

View file

@ -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<T>(iterable: AsyncIterable<T>): Promise<T[]> {
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<void> }> {
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<FakeLease> {
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<SessionExportEntry> {
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',
});
});
});

View file

@ -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<Env> {
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);
});
});

View file

@ -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<SessionRuntimeCapability> = new Set([
'os.filesystem',
'os.process',
'artifact.model_read',
'session.cold_read',
'session.export',
'session.import',
'session.fork',
]);
const DEFAULT_REMOTE_WORKSPACE_CAPABILITIES: ReadonlySet<WorkspaceCapability> = 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_<workspaceId>` (deterministic re-open identity). */
readonly runtimeId?: string;
readonly connection?: FakeRemoteConnection;
readonly capabilities?: ReadonlySet<SessionRuntimeCapability>;
readonly workspaceCapabilities?: ReadonlySet<WorkspaceCapability>;
/** 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<WorkspaceCapability>;
/** 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<SessionRuntimeCapability>;
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<SessionRuntimeCapability> {
return this.caps;
}
async close(_reason: RuntimeCloseReason): Promise<void> {
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 = <T extends { readonly requires: readonly SessionRuntimeCapability[] }>(
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<ISessionManager['create']>[0]) {
return this.delegate.create(input);
}
list(query?: Parameters<ISessionManager['list']>[0]) {
return this.delegate.list(query);
}
get(sessionId: string) {
return this.delegate.get(sessionId);
}
update(sessionId: string, patch: Parameters<ISessionManager['update']>[1]) {
return this.delegate.update(sessionId, patch);
}
delete(sessionId: string, options?: Parameters<ISessionManager['delete']>[1]) {
return this.delegate.delete(sessionId, options);
}
fork(sourceSessionId: string, input: Parameters<ISessionManager['fork']>[1]) {
return this.delegate.fork(sourceSessionId, input);
}
coldRead(sessionId: string) {
return this.delegate.coldRead(sessionId);
}
export(sessionId: string, options?: Parameters<ISessionManager['export']>[1]) {
return this.delegate.export(sessionId, options);
}
import(input: Parameters<ISessionManager['import']>[0]) {
return this.delegate.import(input);
}
async open(
sessionId: string,
options: OpenSessionOptions,
): Promise<ISessionRuntimeContext> {
const context = await this.delegate.open(sessionId, options);
return this.gate(context);
}
async resume(
sessionId: string,
options: ResumeSessionOptions,
): Promise<ISessionRuntimeContext> {
const context = await this.delegate.resume(sessionId, options);
return this.gate(context);
}
}
export interface FakeRemoteWorkspaceProviderOptions {
readonly capabilities?: ReadonlySet<SessionRuntimeCapability>;
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<IWorkspaceRuntimeRegistration> {
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'),
});
}
}

View file

@ -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_<uuid>` 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<typeof errEnvelope>
| ReturnType<typeof buildValidationEnvelope>;
/**
* 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<V1CreateSessionOutcome> {
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,
};
}

View file

@ -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({

View file

@ -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<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
expect(first.body.code).toBe(0);
const second = await postJson<SessionWire>('/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<RuntimeSummary[]>(
'/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_<workspaceId> 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<SessionWire>('/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<PageWire>('/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<SessionWire>(`/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<SessionWire>('/api/v1/sessions', { metadata: { cwd } });