refactor(agent-core-v2): split kaos into host/agent filesystem, process and storage domains

- introduce storage domain (IStorageService/IRecordStore/IConfigStore) as the program-side persistence substrate over raw node fs
- add hostFs (local real-filesystem primitives reused by persistence and the local agent backend)
- add agentFs (IAgentFileSystem + IFileSystemBackend) and process (IProcessRunner + IProcessBackend) with local/ssh backends for the Agent execution environment
- add workspaceContext (session workspace root + path access), workspaceRegistry (process-wide catalog) and hostFolderBrowser (host-side folder picker)
- remove kaos/fs/terminal/filestore/workspace domains
- route persistence through storage (sessionMetaStore -> IConfigStore) and hostFs (sessionStore); route plan file IO through agentFs
- collect app paths in IEnvironmentService; register new domains in DOMAIN_LAYER and refresh the dependency map
This commit is contained in:
haozhe.yang 2026-06-27 09:53:27 +08:00
parent 6185ea8e89
commit db02c18ddc
77 changed files with 1894 additions and 2175 deletions

View file

@ -23,7 +23,9 @@ package "Core scope (process-wide)" #EAF3FB {
rectangle "<b>telemetry</b>\n<size:9><i>Core</i></size>\n ITelemetryService" as telemetry #D6EAF8
rectangle "<b>event</b>\n<size:9><i>Core</i></size>\n IEventService" as event #D6EAF8
rectangle "<b>gateway</b>\n<size:9><i>Core</i></size>\n IScopeRegistry\n IRestGateway\n IWSGateway\n IWSBroadcastService" as gateway #D6EAF8
rectangle "<b>filestore</b>\n<size:9><i>Core</i></size>\n IFileStore" as filestore #D6EAF8
rectangle "<b>hostFs</b>\n<size:9><i>Core</i></size>\n IHostFileSystem" as hostFs #D6EAF8
rectangle "<b>workspaceRegistry</b>\n<size:9><i>Core</i></size>\n IWorkspaceRegistry" as workspaceRegistry #D6EAF8
rectangle "<b>hostFolderBrowser</b>\n<size:9><i>Core</i></size>\n IHostFolderBrowser" as hostFolderBrowser #D6EAF8
rectangle "<b>auth</b>\n<size:9><i>Core</i></size>\n IOAuthService\n IAuthSummaryService" as auth #D6EAF8
rectangle "<b>provider</b>\n<size:9><i>Core</i></size>\n IProviderService" as provider #D6EAF8
rectangle "<b>flag</b>\n<size:9><i>Core</i></size>\n IFlagService" as flag #D6EAF8
@ -31,12 +33,13 @@ package "Core scope (process-wide)" #EAF3FB {
package "Session scope (per session)" #EAFAF1 {
rectangle "<b>session</b>\n<size:9><i>Session</i></size>\n ISessionService" as session #D5F5E3
rectangle "<b>fs</b>\n<size:9><i>Session</i></size>\n IFsService\n IFsSearchService\n IFsGitService\n IFsWatcher" as fs #D5F5E3
rectangle "<b>workspaceContext</b>\n<size:9><i>Session</i></size>\n IWorkspaceContext" as workspaceContext #D5F5E3
rectangle "<b>agentFs</b>\n<size:9><i>Session</i></size>\n IAgentFileSystem\n IFileSystemBackend" as agentFs #D5F5E3
rectangle "<b>approval</b>\n<size:9><i>Session</i></size>\n IApprovalService" as approval #D5F5E3
rectangle "<b>question</b>\n<size:9><i>Session</i></size>\n IQuestionService" as question #D5F5E3
rectangle "<b>session-activity</b>\n<size:9><i>Session</i></size>\n ISessionActivity" as session_activity #D5F5E3
rectangle "<b>subagentHost</b>\n<size:9><i>Session</i></size>\n ISubagentHost" as subagentHost #D5F5E3
rectangle "<b>terminal</b>\n<size:9><i>Session</i></size>\n ITerminalService" as terminal #D5F5E3
rectangle "<b>process</b>\n<size:9><i>Session</i></size>\n IProcessRunner\n IProcessBackend" as process #D5F5E3
rectangle "<b>agent-lifecycle</b>\n<size:9><i>Session</i></size>\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
rectangle "<b>session-context</b>\n<size:9><i>Session</i></size>\n ISessionContext (seed)" as session_context #D5F5E3
}
@ -86,8 +89,6 @@ package "Multi-scope" #F5EEF8 {
rectangle "<b>kosong</b>\n<size:9><i>Multi</i></size>\n IProtocolHandlerRegistry(C)\n IProviderManager(S, seed)" as kosong #E8DAEF
rectangle "<b>records</b>\n<size:9><i>Multi</i></size>\n ISessionStore(C)\n ISessionMetaStore(S)\n IAgentRecords(A)" as records #E8DAEF
rectangle "<b>tool</b>\n<size:9><i>Multi</i></size>\n IToolDefinitionRegistry(C)\n IToolService(A)" as tool #E8DAEF
rectangle "<b>kaos</b>\n<size:9><i>Multi</i></size>\n IKaosFactory(C)\n ISessionKaosService(S)\n IKaosService / IAgentKaos(A)" as kaos #E8DAEF
rectangle "<b>workspace</b>\n<size:9><i>Multi</i></size>\n IWorkspaceRegistry(C)\n IWorkspaceFsService(C)\n ISessionWorkspaceService(S)" as workspace #E8DAEF
}
package "WIP (contract only)" #FBFCFC {
@ -98,12 +99,10 @@ package "WIP (contract only)" #FBFCFC {
' ---- DI injection (solid) ----
gateway --> event #34495E
workspace --> environment #34495E
workspace --> event #34495E
workspace --> records #34495E
workspace --> log #34495E
workspace --> kaos #34495E
filestore --> kaos #34495E
agentFs --> workspaceContext #34495E
agentFs --> hostFs #34495E
process --> workspaceContext #34495E
hostFolderBrowser --> hostFs #34495E
auth --> provider #34495E
auth --> environment #34495E
auth --> telemetry #34495E
@ -113,26 +112,19 @@ flag --> config #34495E
config --> environment #34495E
config --> log #34495E
config --> records #34495E
config --> kaos #34495E
kosong --> config #34495E
records --> kaos #34495E
records --> hostFs #34495E
records --> storage #34495E
records --> log #34495E
tool --> config #34495E
tool --> records #34495E
tool --> kaos #34495E
tool --> permission #34495E
tool --> kosong #34495E
kaos --> environment #34495E
kaos --> log #34495E
session --> records #34495E
session --> agent_lifecycle #34495E
session --> session_activity #34495E
session --> event #34495E
fs --> kaos #34495E
fs --> log #34495E
session_activity --> agent_lifecycle #34495E
terminal --> log #34495E
terminal --> kaos #34495E
agent_lifecycle --> session_context #34495E
agent_lifecycle --> records #34495E
permissionMode --> wireRecord #34495E
@ -278,7 +270,7 @@ turn --> tool #34495E
plan --> contextMemory #34495E
plan --> wireRecord #34495E
plan --> eventBus #34495E
plan --> kaos #34495E
plan --> agentFs #34495E
plan --> profile #34495E
plan --> replayBuilder #34495E
plan --> toolRegistry #34495E

View file

@ -42,7 +42,8 @@ const DOMAIN_LAYER = new Map([
['log', 1],
['telemetry', 1],
['environment', 1],
['kaos', 1],
['hostFs', 1],
['workspaceContext', 1],
['kosong', 1],
['hooks', 1],
['storage', 1],
@ -51,10 +52,10 @@ const DOMAIN_LAYER = new Map([
['wireRecord', 2],
['blobStore', 2],
['config', 2],
['terminal', 2],
['fs', 2],
['workspace', 2],
['filestore', 2],
['agentFs', 2],
['process', 2],
['workspaceRegistry', 2],
['hostFolderBrowser', 2],
['auth', 2],
['provider', 2],
['sessionMetaStore', 2],
@ -160,7 +161,6 @@ const ALLOWED_EXCEPTIONS = new Set([
'cron>session-context',
'cron>session-activity',
'session>event',
'workspace>event',
'wireRecord>hooks',
// L3/L4 type-sharing introduced by the rebase-v2 restructuring.
'config>externalHooks',

View file

@ -0,0 +1,52 @@
/**
* `agentFs` domain (L1) the Agent's filesystem and its pluggable backend.
*
* Defines the `IAgentFileSystem` that business code injects to read and write
* files inside the Agent's execution environment, plus the internal
* `IFileSystemBackend` provider that hides the local/ssh/container split.
* Session-scoped. Business code depends on `IAgentFileSystem` only; the
* backend is wired through the scope registry.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface AgentFileStat {
readonly isFile: boolean;
readonly isDirectory: boolean;
readonly size: number;
}
export interface IAgentFileSystem {
readonly _serviceBrand: undefined;
readonly cwd: string;
readText(path: string): Promise<string>;
writeText(path: string, data: string): Promise<void>;
readBytes(path: string): Promise<Uint8Array>;
writeBytes(path: string, data: Uint8Array): Promise<void>;
stat(path: string): Promise<AgentFileStat>;
readdir(path: string): Promise<readonly string[]>;
glob(pattern: string): Promise<readonly string[]>;
mkdir(path: string): Promise<void>;
withCwd(cwd: string): IAgentFileSystem;
}
export const IAgentFileSystem: ServiceIdentifier<IAgentFileSystem> =
createDecorator<IAgentFileSystem>('agentFileSystem');
export interface IFileSystemBackend {
readonly _serviceBrand: undefined;
readText(absPath: string): Promise<string>;
writeText(absPath: string, data: string): Promise<void>;
readBytes(absPath: string): Promise<Uint8Array>;
writeBytes(absPath: string, data: Uint8Array): Promise<void>;
stat(absPath: string): Promise<AgentFileStat>;
readdir(absPath: string): Promise<readonly string[]>;
glob(absDir: string, pattern: string): Promise<readonly string[]>;
mkdir(absPath: string): Promise<void>;
}
export const IFileSystemBackend: ServiceIdentifier<IFileSystemBackend> =
createDecorator<IFileSystemBackend>('fileSystemBackend');

View file

@ -0,0 +1,77 @@
/**
* `agentFs` domain (L1) `IAgentFileSystem` implementation.
*
* Resolves paths against the session workspace and delegates IO to the
* injected `IFileSystemBackend`; reads the work directory through
* `workspaceContext`. Bound at Session scope.
*/
import { isAbsolute, resolve } from 'node:path';
import { InstantiationType } from '#/_base/di/extensions';
import { NotImplementedError } from '#/_base/errors';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IWorkspaceContext } from '#/workspaceContext';
import { type AgentFileStat, IAgentFileSystem, IFileSystemBackend } from './agentFs';
export class AgentFileSystem implements IAgentFileSystem {
declare readonly _serviceBrand: undefined;
constructor(
@IFileSystemBackend private readonly backend: IFileSystemBackend,
@IWorkspaceContext private readonly workspace: IWorkspaceContext,
) {}
get cwd(): string {
return this.workspace.workDir;
}
private abs(path: string): string {
return isAbsolute(path) ? path : resolve(this.cwd, path);
}
readText(path: string): Promise<string> {
return this.backend.readText(this.abs(path));
}
writeText(path: string, data: string): Promise<void> {
return this.backend.writeText(this.abs(path), data);
}
readBytes(path: string): Promise<Uint8Array> {
return this.backend.readBytes(this.abs(path));
}
writeBytes(path: string, data: Uint8Array): Promise<void> {
return this.backend.writeBytes(this.abs(path), data);
}
stat(path: string): Promise<AgentFileStat> {
return this.backend.stat(this.abs(path));
}
readdir(path: string): Promise<readonly string[]> {
return this.backend.readdir(this.abs(path));
}
glob(pattern: string): Promise<readonly string[]> {
return this.backend.glob(this.cwd, pattern);
}
mkdir(path: string): Promise<void> {
return this.backend.mkdir(this.abs(path));
}
withCwd(_cwd: string): IAgentFileSystem {
throw new NotImplementedError('agentFs.withCwd');
}
}
registerScopedService(
LifecycleScope.Session,
IAgentFileSystem,
AgentFileSystem,
InstantiationType.Delayed,
'agentFs',
);

View file

@ -0,0 +1,12 @@
/**
* `agentFs` domain barrel re-exports the agent-filesystem contract
* (`agentFs`), its scoped service (`agentFsService`), and the backend
* implementations (`localFileSystemBackend`, `sshFileSystemBackend`).
* Importing this barrel registers the `IAgentFileSystem` and default local
* `IFileSystemBackend` bindings into the scope registry.
*/
export * from './agentFs';
export * from './agentFsService';
export * from './localFileSystemBackend';
export * from './sshFileSystemBackend';

View file

@ -0,0 +1,63 @@
/**
* `agentFs` domain (L1) local `IFileSystemBackend` implementation.
*
* Backs the Agent filesystem with the real local disk by delegating to the
* program-side `hostFs` primitives. Registered as the default
* `IFileSystemBackend` at Session scope; remote backends override it via the
* scope registry.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { NotImplementedError } from '#/_base/errors';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IHostFileSystem } from '#/hostFs';
import { type AgentFileStat, IFileSystemBackend } from './agentFs';
export class LocalFileSystemBackend implements IFileSystemBackend {
declare readonly _serviceBrand: undefined;
constructor(@IHostFileSystem private readonly hostFs: IHostFileSystem) {}
readText(absPath: string): Promise<string> {
return this.hostFs.readText(absPath);
}
writeText(absPath: string, data: string): Promise<void> {
return this.hostFs.writeText(absPath, data);
}
readBytes(absPath: string): Promise<Uint8Array> {
return this.hostFs.readBytes(absPath);
}
writeBytes(absPath: string, data: Uint8Array): Promise<void> {
return this.hostFs.writeBytes(absPath, data);
}
async stat(absPath: string): Promise<AgentFileStat> {
const s = await this.hostFs.stat(absPath);
return { isFile: s.isFile, isDirectory: s.isDirectory, size: s.size };
}
async readdir(absPath: string): Promise<readonly string[]> {
const entries = await this.hostFs.readdir(absPath);
return entries.map((e) => e.name);
}
glob(_absDir: string, _pattern: string): Promise<readonly string[]> {
throw new NotImplementedError('localFileSystemBackend.glob');
}
mkdir(absPath: string): Promise<void> {
return this.hostFs.mkdir(absPath, { recursive: true });
}
}
registerScopedService(
LifecycleScope.Session,
IFileSystemBackend,
LocalFileSystemBackend,
InstantiationType.Delayed,
'agentFs',
);

View file

@ -0,0 +1,47 @@
/**
* `agentFs` domain (L1) ssh `IFileSystemBackend` stub.
*
* Placeholder for the remote backend; not registered into the scope registry
* yet. A composition root that needs ssh supplies it through
* `ScopeOptions.extra` to override the local backend.
*/
import { NotImplementedError } from '#/_base/errors';
import { type AgentFileStat, IFileSystemBackend } from './agentFs';
export class SshFileSystemBackend implements IFileSystemBackend {
declare readonly _serviceBrand: undefined;
readText(_absPath: string): Promise<string> {
throw new NotImplementedError('sshFileSystemBackend');
}
writeText(_absPath: string, _data: string): Promise<void> {
throw new NotImplementedError('sshFileSystemBackend');
}
readBytes(_absPath: string): Promise<Uint8Array> {
throw new NotImplementedError('sshFileSystemBackend');
}
writeBytes(_absPath: string, _data: Uint8Array): Promise<void> {
throw new NotImplementedError('sshFileSystemBackend');
}
stat(_absPath: string): Promise<AgentFileStat> {
throw new NotImplementedError('sshFileSystemBackend');
}
readdir(_absPath: string): Promise<readonly string[]> {
throw new NotImplementedError('sshFileSystemBackend');
}
glob(_absDir: string, _pattern: string): Promise<readonly string[]> {
throw new NotImplementedError('sshFileSystemBackend');
}
mkdir(_absPath: string): Promise<void> {
throw new NotImplementedError('sshFileSystemBackend');
}
}

View file

@ -1,10 +1,11 @@
/**
* `environment` domain (L1) resolved environment paths and OS probe.
*
* Defines the public contract of the environment: the resolved paths
* (`homeDir`, `configPath`) and the `IEnvironmentService` used by other
* domains to locate config and detect the host `Environment`, plus the
* Core-scope `environmentSeed`. Core-scoped one shared instance.
* Defines the public contract of the environment: the resolved paths the app
* writes to (`homeDir`, `configPath`, `sessionsDir`, `blobsDir`, `storeDir`,
* `cacheDir`, `logsDir`) and the `IEnvironmentService` used by other domains
* to locate those paths and detect the host `Environment`, plus the Core-scope
* `environmentSeed`. Pure configuration it performs no IO. Core-scoped.
*/
import type { Environment } from '@moonshot-ai/kaos';
@ -23,6 +24,11 @@ export interface IEnvironmentService {
readonly _serviceBrand: undefined;
readonly homeDir: string;
readonly configPath: string;
readonly sessionsDir: string;
readonly blobsDir: string;
readonly storeDir: string;
readonly cacheDir: string;
readonly logsDir: string;
detect(): Promise<Environment>;
}

View file

@ -21,11 +21,21 @@ export class EnvironmentService implements IEnvironmentService {
declare readonly _serviceBrand: undefined;
readonly homeDir: string;
readonly configPath: string;
readonly sessionsDir: string;
readonly blobsDir: string;
readonly storeDir: string;
readonly cacheDir: string;
readonly logsDir: string;
private detected?: Promise<Environment>;
constructor(@IEnvironmentOptions options: IEnvironmentOptions) {
this.homeDir = options.homeDir;
this.configPath = join(options.homeDir, 'config.toml');
this.sessionsDir = join(options.homeDir, 'sessions');
this.blobsDir = join(options.homeDir, 'blobs');
this.storeDir = join(options.homeDir, 'store');
this.cacheDir = join(options.homeDir, 'cache');
this.logsDir = join(options.homeDir, 'logs');
}
detect(): Promise<Environment> {

View file

@ -22,7 +22,6 @@ import { ProfileErrors } from '#/profile/errors';
import { PromptErrors } from '#/prompt/errors';
import { SessionErrors } from '#/session/errors';
import { SkillErrors } from '#/skill/errors';
import { TerminalErrors } from '#/terminal/errors';
import { TurnErrors } from '#/turn/errors';
import { WireRecordErrors } from '#/wireRecord/errors';
@ -41,7 +40,6 @@ export { ProfileErrors } from '#/profile/errors';
export { PromptErrors } from '#/prompt/errors';
export { SessionErrors } from '#/session/errors';
export { SkillErrors } from '#/skill/errors';
export { TerminalErrors } from '#/terminal/errors';
export { TurnErrors } from '#/turn/errors';
export { WireRecordErrors } from '#/wireRecord/errors';
@ -61,7 +59,6 @@ export const ErrorCodes = {
...PromptErrors.codes,
...SessionErrors.codes,
...SkillErrors.codes,
...TerminalErrors.codes,
...TurnErrors.codes,
...WireRecordErrors.codes,
} as const;

View file

@ -1,33 +0,0 @@
/**
* `filestore` domain (cross-cutting) `IFileStore` implementation.
*
* Stores and retrieves blobs keyed by string; uses the execution environment
* through `kaos`. Bound at Core scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IKaosFactory } from '#/kaos';
import { IFileStore } from './filestore';
export class FileStore implements IFileStore {
declare readonly _serviceBrand: undefined;
private readonly blobs = new Map<string, Uint8Array>();
constructor(@IKaosFactory _kaosFactory: IKaosFactory) {}
put(key: string, data: Uint8Array): Promise<void> {
this.blobs.set(key, data);
return Promise.resolve();
}
get(key: string): Promise<Uint8Array | undefined> {
return Promise.resolve(this.blobs.get(key));
}
delete(key: string): Promise<void> {
this.blobs.delete(key);
return Promise.resolve();
}
}
registerScopedService(LifecycleScope.Core, IFileStore, FileStore, InstantiationType.Delayed, 'filestore');

View file

@ -1,19 +0,0 @@
/**
* `filestore` domain (cross-cutting) core-scope blob/file store.
*
* Defines the public contract of the file store: the `IFileStore` used by
* other domains to store and retrieve opaque blobs by key. Core-scoped one
* shared instance.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IFileStore {
readonly _serviceBrand: undefined;
put(key: string, data: Uint8Array): Promise<void>;
get(key: string): Promise<Uint8Array | undefined>;
delete(key: string): Promise<void>;
}
export const IFileStore: ServiceIdentifier<IFileStore> =
createDecorator<IFileStore>('fileStore');

View file

@ -1,8 +0,0 @@
/**
* `filestore` domain barrel re-exports the `filestore` contract and its
* scoped service (`fileStoreService`). Importing this barrel registers the
* `IFileStore` binding into the scope registry.
*/
export * from './filestore';
export * from './fileStoreService';

View file

@ -1,49 +0,0 @@
/**
* `fs` domain (cross-cutting) session-scope filesystem services.
*
* Defines the public contracts of filesystem access: the `IFsService`,
* `IFsSearchService`, `IFsGitService`, and `IFsWatcher` used by tools to read
* and write files, search, inspect git state, and watch paths. Session-scoped
* one set of services per session.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IFsService {
readonly _serviceBrand: undefined;
read(path: string): Promise<string>;
write(path: string, content: string): Promise<void>;
stat(path: string): Promise<unknown>;
mkdir(path: string): Promise<void>;
}
export const IFsService: ServiceIdentifier<IFsService> =
createDecorator<IFsService>('fsService');
export interface IFsSearchService {
readonly _serviceBrand: undefined;
grep(pattern: string, path: string): Promise<readonly unknown[]>;
glob(pattern: string): Promise<readonly string[]>;
}
export const IFsSearchService: ServiceIdentifier<IFsSearchService> =
createDecorator<IFsSearchService>('fsSearchService');
export interface IFsGitService {
readonly _serviceBrand: undefined;
status(cwd: string): Promise<string>;
diff(cwd: string): Promise<string>;
log(cwd: string): Promise<readonly string[]>;
}
export const IFsGitService: ServiceIdentifier<IFsGitService> =
createDecorator<IFsGitService>('fsGitService');
export interface IFsWatcher {
readonly _serviceBrand: undefined;
watch(path: string): void;
unwatch(path: string): void;
}
export const IFsWatcher: ServiceIdentifier<IFsWatcher> =
createDecorator<IFsWatcher>('fsWatcher');

View file

@ -1,141 +0,0 @@
/**
* `fs` domain (cross-cutting) `IFsService` / `IFsSearchService` /
* `IFsGitService` / `IFsWatcher` implementation.
*
* Owns file I/O, search, git inspection, and path watching; accesses the
* filesystem through `kaos` and logs through `log`. Bound at Session scope.
*/
import type { Readable } from 'node:stream';
import { Disposable } from '#/_base/di/lifecycle';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type { Kaos } from '@moonshot-ai/kaos';
import { ISessionKaosService } from '#/kaos';
import { ILogService } from '#/log';
import {
IFsGitService,
IFsSearchService,
IFsService,
IFsWatcher,
} from './fs';
function readAll(stream: Readable): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
stream.setEncoding('utf8');
stream.on('data', (chunk: string) => { data += chunk; });
stream.on('end', () => resolve(data));
stream.on('error', reject);
});
}
export class FsService implements IFsService {
declare readonly _serviceBrand: undefined;
constructor(
@ISessionKaosService private readonly sessionKaos: ISessionKaosService,
@ILogService _log: ILogService,
) {}
private get kaos(): Kaos {
return this.sessionKaos.toolKaos;
}
read(path: string): Promise<string> {
return this.kaos.readText(path);
}
write(path: string, content: string): Promise<void> {
return this.kaos.writeText(path, content).then(() => undefined);
}
stat(path: string): Promise<unknown> {
return this.kaos.stat(path);
}
async mkdir(path: string): Promise<void> {
await this.kaos.mkdir(path, { parents: true, existOk: true });
}
}
export class FsSearchService implements IFsSearchService {
declare readonly _serviceBrand: undefined;
constructor(
@ISessionKaosService private readonly sessionKaos: ISessionKaosService,
@ILogService _log: ILogService,
) {}
private get kaos(): Kaos {
return this.sessionKaos.toolKaos;
}
async grep(pattern: string, path: string): Promise<readonly unknown[]> {
const proc = await this.kaos.exec('grep', '-r', '-n', pattern, path);
const out = await readAll(proc.stdout);
await proc.wait();
return out.split('\n').filter((l) => l.length > 0);
}
async glob(pattern: string): Promise<readonly string[]> {
const proc = await this.kaos.exec('find', '.', '-name', pattern);
const out = await readAll(proc.stdout);
await proc.wait();
return out.split('\n').filter((l) => l.length > 0);
}
}
export class FsGitService implements IFsGitService {
declare readonly _serviceBrand: undefined;
constructor(
@ISessionKaosService private readonly sessionKaos: ISessionKaosService,
@ILogService _log: ILogService,
) {}
private get kaos(): Kaos {
return this.sessionKaos.toolKaos;
}
private async git(...args: string[]): Promise<string> {
const proc = await this.kaos.exec('git', ...args);
const out = await readAll(proc.stdout);
await proc.wait();
return out;
}
status(_cwd: string): Promise<string> {
return this.git('status', '--short');
}
diff(_cwd: string): Promise<string> {
return this.git('diff');
}
async log(_cwd: string): Promise<readonly string[]> {
const out = await this.git('log', '--oneline', '-n', '20');
return out.split('\n').filter((l) => l.length > 0);
}
}
export class FsWatcher extends Disposable implements IFsWatcher {
declare readonly _serviceBrand: undefined;
private readonly watched = new Set<string>();
constructor(
@ISessionKaosService _sessionKaos: ISessionKaosService,
@ILogService _log: ILogService,
) {
super();
}
watch(path: string): void {
this.watched.add(path);
}
unwatch(path: string): void {
this.watched.delete(path);
}
}
registerScopedService(LifecycleScope.Session, IFsService, FsService, InstantiationType.Delayed, 'fs');
registerScopedService(LifecycleScope.Session, IFsSearchService, FsSearchService, InstantiationType.Delayed, 'fs');
registerScopedService(LifecycleScope.Session, IFsGitService, FsGitService, InstantiationType.Delayed, 'fs');
registerScopedService(LifecycleScope.Session, IFsWatcher, FsWatcher, InstantiationType.Delayed, 'fs');

View file

@ -1,8 +0,0 @@
/**
* `fs` domain barrel re-exports the filesystem contract (`fs`) and its scoped
* services (`fsService`). Importing this barrel registers the `IFsService`,
* `IFsSearchService`, and `IFsGitService` bindings into the scope registry.
*/
export * from './fs';
export * from './fsService';

View file

@ -0,0 +1,26 @@
/**
* `hostFolderBrowser` domain (L1) host-side folder picker.
*
* Defines the `IHostFolderBrowser` used by the program side (TUI / server) to
* let the user browse the real local filesystem when choosing a workspace
* folder. Distinct from the Agent-side `agentFs`, which is sandboxed and may
* be remote. Core-scoped.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { HostDirEntry } from '#/hostFs';
export interface FsBrowseResponse {
readonly path: string;
readonly entries: readonly HostDirEntry[];
}
export interface IHostFolderBrowser {
readonly _serviceBrand: undefined;
browse(absPath?: string): Promise<FsBrowseResponse>;
home(): Promise<string>;
}
export const IHostFolderBrowser: ServiceIdentifier<IHostFolderBrowser> =
createDecorator<IHostFolderBrowser>('hostFolderBrowser');

View file

@ -0,0 +1,39 @@
/**
* `hostFolderBrowser` domain (L1) `IHostFolderBrowser` implementation.
*
* Browses the real local filesystem through the program-side `hostFs`
* primitives. Bound at Core scope.
*/
import { homedir } from 'node:os';
import { resolve } from 'node:path';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IHostFileSystem } from '#/hostFs';
import { type FsBrowseResponse, IHostFolderBrowser } from './hostFolderBrowser';
export class HostFolderBrowser implements IHostFolderBrowser {
declare readonly _serviceBrand: undefined;
constructor(@IHostFileSystem private readonly hostFs: IHostFileSystem) {}
async browse(absPath?: string): Promise<FsBrowseResponse> {
const path = resolve(absPath ?? homedir());
const entries = await this.hostFs.readdir(path);
return { path, entries };
}
home(): Promise<string> {
return Promise.resolve(homedir());
}
}
registerScopedService(
LifecycleScope.Core,
IHostFolderBrowser,
HostFolderBrowser,
InstantiationType.Delayed,
'hostFolderBrowser',
);

View file

@ -0,0 +1,9 @@
/**
* `hostFolderBrowser` domain barrel re-exports the host-folder-browser
* contract (`hostFolderBrowser`) and its scoped service
* (`hostFolderBrowserService`). Importing this barrel registers the
* `IHostFolderBrowser` binding into the scope registry.
*/
export * from './hostFolderBrowser';
export * from './hostFolderBrowserService';

View file

@ -0,0 +1,39 @@
/**
* `hostFs` domain (L1) local real-filesystem primitives.
*
* Defines the `IHostFileSystem` used by the program side (persistence, skill
* loading, workspace registry) to read and write the app's own files on the
* real local disk, plus the stat/entry models. Core-scoped one shared
* instance. The Agent side never injects this directly; its local backend
* reuses it internally.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface HostFileStat {
readonly isFile: boolean;
readonly isDirectory: boolean;
readonly size: number;
}
export interface HostDirEntry {
readonly name: string;
readonly isFile: boolean;
readonly isDirectory: boolean;
}
export interface IHostFileSystem {
readonly _serviceBrand: undefined;
readText(path: string): Promise<string>;
writeText(path: string, data: string): Promise<void>;
readBytes(path: string): Promise<Uint8Array>;
writeBytes(path: string, data: Uint8Array): Promise<void>;
stat(path: string): Promise<HostFileStat>;
readdir(path: string): Promise<readonly HostDirEntry[]>;
mkdir(path: string, options?: { readonly recursive?: boolean }): Promise<void>;
remove(path: string): Promise<void>;
}
export const IHostFileSystem: ServiceIdentifier<IHostFileSystem> =
createDecorator<IHostFileSystem>('hostFileSystem');

View file

@ -0,0 +1,64 @@
/**
* `hostFs` domain (L1) `IHostFileSystem` implementation.
*
* Reads and writes the app's own files on the real local disk through
* `node:fs/promises`. Bound at Core scope.
*/
import { readFile, readdir, stat, mkdir, rm, writeFile } from 'node:fs/promises';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { type HostDirEntry, type HostFileStat, IHostFileSystem } from './hostFs';
export class HostFileSystem implements IHostFileSystem {
declare readonly _serviceBrand: undefined;
async readText(path: string): Promise<string> {
return readFile(path, 'utf8');
}
async writeText(path: string, data: string): Promise<void> {
await writeFile(path, data, 'utf8');
}
async readBytes(path: string): Promise<Uint8Array> {
const buf = await readFile(path);
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
}
async writeBytes(path: string, data: Uint8Array): Promise<void> {
await writeFile(path, data);
}
async stat(path: string): Promise<HostFileStat> {
const s = await stat(path);
return { isFile: s.isFile(), isDirectory: s.isDirectory(), size: s.size };
}
async readdir(path: string): Promise<readonly HostDirEntry[]> {
const entries = await readdir(path, { withFileTypes: true });
return entries.map((d) => ({
name: d.name,
isFile: d.isFile(),
isDirectory: d.isDirectory(),
}));
}
async mkdir(path: string, options?: { readonly recursive?: boolean }): Promise<void> {
await mkdir(path, { recursive: options?.recursive ?? false });
}
async remove(path: string): Promise<void> {
await rm(path, { recursive: true, force: true });
}
}
registerScopedService(
LifecycleScope.Core,
IHostFileSystem,
HostFileSystem,
InstantiationType.Delayed,
'hostFs',
);

View file

@ -0,0 +1,8 @@
/**
* `hostFs` domain barrel re-exports the host-filesystem contract
* (`hostFs`) and its scoped service (`hostFsService`). Importing this barrel
* registers the `IHostFileSystem` binding into the scope registry.
*/
export * from './hostFs';
export * from './hostFsService';

View file

@ -9,7 +9,7 @@ export * from './errors';
export * from './log/index';
export * from './telemetry/index';
export * from './environment/index';
export * from './kaos/index';
export * from './hostFs/index';
export * from './kosong/index';
export * from './sessionStore/index';
@ -42,10 +42,12 @@ import './approval/index';
export * from './question/index';
export * from './gateway/index';
export * from './terminal/index';
export * from './fs/index';
export * from './workspace/index';
export * from './filestore/index';
export * from './workspaceContext/index';
export * from './workspaceRegistry/index';
export * from './hostFolderBrowser/index';
export * from './agentFs/index';
export * from './process/index';
export * from './storage/index';
export * from './auth/index';
// Ported agent services. These keep the current service boundaries during the migration.

View file

@ -1,43 +0,0 @@
/**
* `kaos` domain (L1) `IKaosService` implementation.
*
* Exposes the agent's active `Kaos` instance and working directory, and
* switches the working directory on `chdir`. Bound at Agent scope.
*/
import type { Kaos } from '@moonshot-ai/kaos';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IKaosService, ISessionKaosService } from './kaos';
export class AgentKaos implements IKaosService {
declare readonly _serviceBrand: undefined;
private _kaos: Kaos;
constructor(@ISessionKaosService sessionKaos: ISessionKaosService) {
this._kaos = sessionKaos.toolKaos;
}
get kaos(): Kaos {
return this._kaos;
}
get cwd(): string {
return this._kaos.getcwd();
}
chdir(cwd: string): Promise<void> {
this._kaos = this._kaos.withCwd(cwd);
return Promise.resolve();
}
}
registerScopedService(
LifecycleScope.Agent,
IKaosService,
AgentKaos,
InstantiationType.Delayed,
'kaos',
);

View file

@ -1,11 +0,0 @@
/**
* `kaos` domain barrel re-exports the `kaos` contract and its scoped
* services (`kaosFactory`, `sessionKaosService`, `agentKaos`). Importing this
* barrel registers the `IKaosFactory`, `ISessionKaosService`, and `IKaosService`
* bindings into the scope registry.
*/
export * from './kaos';
export * from './kaosFactory';
export * from './sessionKaosService';
export * from './agentKaos';

View file

@ -1,38 +0,0 @@
import type { Kaos } from '@moonshot-ai/kaos';
import { createDecorator } from '#/_base/di/instantiation';
export interface IKaosService {
readonly _serviceBrand: undefined;
readonly kaos: Kaos | undefined;
readonly cwd: string;
chdir(cwd: string): Promise<void>;
}
export const IKaosService = createDecorator<IKaosService>('agentKaosService');
export type KaosFactoryOptions =
| { readonly kind: 'local'; readonly cwd?: string }
| { readonly kind: 'ssh'; readonly host: string; readonly cwd?: string };
export interface IKaosFactory {
readonly _serviceBrand: undefined;
create(options: KaosFactoryOptions): Promise<Kaos>;
}
export const IKaosFactory = createDecorator<IKaosFactory>('kaosFactory');
export interface ISessionKaosService {
readonly _serviceBrand: undefined;
readonly toolKaos: Kaos;
readonly persistenceKaos: Kaos;
readonly systemContextKaos: Kaos;
readonly additionalDirs: readonly string[];
setToolKaos(kaos: Kaos): void;
setPersistenceKaos(kaos: Kaos): void;
addAdditionalDir(dir: string): void;
removeAdditionalDir(dir: string): void;
}
export const ISessionKaosService =
createDecorator<ISessionKaosService>('sessionKaosService');

View file

@ -1,40 +0,0 @@
/**
* `kaos` domain (L1) `IKaosFactory` implementation.
*
* Creates `Kaos` instances for the requested kind; resolves paths through
* `environment` and logs through `log`. Bound at Core scope.
*/
import { type Kaos, LocalKaos } from '@moonshot-ai/kaos';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IEnvironmentService } from '#/environment/environment';
import { ILogService } from '#/log/log';
import { type KaosFactoryOptions, IKaosFactory } from './kaos';
export class KaosFactory implements IKaosFactory {
declare readonly _serviceBrand: undefined;
constructor(
@IEnvironmentService _env: IEnvironmentService,
@ILogService _log: ILogService,
) {}
async create(options: KaosFactoryOptions): Promise<Kaos> {
if (options.kind === 'ssh') {
throw new Error('TODO: KaosFactory.create ssh');
}
const base = await LocalKaos.create();
return options.cwd !== undefined ? base.withCwd(options.cwd) : base;
}
}
registerScopedService(
LifecycleScope.Core,
IKaosFactory,
KaosFactory,
InstantiationType.Delayed,
'kaos',
);

View file

@ -1,39 +0,0 @@
import type { Kaos } from '@moonshot-ai/kaos';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IKaosService } from './kaos';
export interface KaosServiceOptions {
readonly kaos?: Kaos;
}
export class KaosService implements IKaosService {
declare readonly _serviceBrand: undefined;
constructor(private readonly options: KaosServiceOptions = {}) {}
get kaos(): Kaos | undefined {
return this.options.kaos;
}
get cwd(): string {
const kaos = this.options.kaos;
if (kaos === undefined) {
throw new Error('KaosService.cwd accessed before kaos was provided');
}
return kaos.getcwd();
}
chdir(): Promise<void> {
return Promise.reject(new Error('KaosService.chdir is not supported'));
}
}
registerScopedService(
LifecycleScope.Agent,
IKaosService,
KaosService,
InstantiationType.Delayed,
'kaos',
);

View file

@ -1,75 +0,0 @@
/**
* `kaos` domain (L1) `ISessionKaosService` implementation.
*
* Holds the session's tool, persistence, and system-context `Kaos`
* environments plus additional search directories; logs through `log`. Bound
* at Session scope.
*/
import type { Kaos } from '@moonshot-ai/kaos';
import { Disposable } from '#/_base/di/lifecycle';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ILogService } from '#/log/log';
import { ISessionKaosService } from './kaos';
export class SessionKaosService extends Disposable implements ISessionKaosService {
declare readonly _serviceBrand: undefined;
private _toolKaos: Kaos | undefined;
private _persistenceKaos: Kaos | undefined;
private _additionalDirs: string[] = [];
constructor(@ILogService _log: ILogService) {
super();
}
get toolKaos(): Kaos {
if (this._toolKaos === undefined) {
throw new Error('SessionKaosService.toolKaos accessed before setToolKaos');
}
return this._toolKaos;
}
get persistenceKaos(): Kaos {
return this._persistenceKaos ?? this.toolKaos;
}
get systemContextKaos(): Kaos {
return this.persistenceKaos.withCwd(this.toolKaos.getcwd());
}
get additionalDirs(): readonly string[] {
return this._additionalDirs;
}
setToolKaos(kaos: Kaos): void {
this._toolKaos = kaos;
if (this._persistenceKaos === undefined) {
this._persistenceKaos = kaos;
}
}
setPersistenceKaos(kaos: Kaos): void {
this._persistenceKaos = kaos;
}
addAdditionalDir(dir: string): void {
if (!this._additionalDirs.includes(dir)) {
this._additionalDirs.push(dir);
}
}
removeAdditionalDir(dir: string): void {
this._additionalDirs = this._additionalDirs.filter((d) => d !== dir);
}
}
registerScopedService(
LifecycleScope.Session,
ISessionKaosService,
SessionKaosService,
InstantiationType.Delayed,
'kaos',
);

View file

@ -16,7 +16,7 @@ import { generateHeroSlug } from "#/_base/utils/hero-slug";
import { IContextMemory, type ContextMessage } from '#/contextMemory';
import { IContextInjector } from '../contextInjector';
import { IEventSink } from '../eventSink';
import { IKaosService } from '#/kaos';
import { IAgentFileSystem } from '#/agentFs';
import { IProfileService } from '#/profile';
import { IReplayBuilderService } from '#/replayBuilder';
import { ITelemetryService } from '#/telemetry';
@ -53,7 +53,7 @@ export class PlanService extends Disposable implements IPlanService {
@IContextMemory private readonly context: IContextMemory,
@IWireRecord private readonly wireRecord: IWireRecord,
@IEventSink private readonly events: IEventSink,
@IKaosService private readonly kaosService: IKaosService,
@IAgentFileSystem private readonly agentFs: IAgentFileSystem,
@IProfileService private readonly profile: IProfileService,
@IReplayBuilderService private readonly replayBuilder: IReplayBuilderService,
@IToolRegistry toolRegistry: IToolRegistry,
@ -200,11 +200,9 @@ export class PlanService extends Disposable implements IPlanService {
async status(): Promise<PlanData> {
if (this.planId === null || this._planFilePath === null) return null;
const kaos = this.kaosService.kaos;
if (kaos === undefined) return null;
let content = '';
try {
content = await kaos.readText(this._planFilePath);
content = await this.agentFs.readText(this._planFilePath);
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
@ -362,14 +360,11 @@ export class PlanService extends Disposable implements IPlanService {
private async writeEmptyPlanFile(path: string): Promise<void> {
await this.ensurePlanDirectory(path);
await this.kaosService.kaos?.writeText(path, '');
await this.agentFs.writeText(path, '');
}
private async ensurePlanDirectory(path: string): Promise<void> {
await this.kaosService.kaos?.mkdir(dirname(path), {
parents: true,
existOk: true,
});
await this.agentFs.mkdir(dirname(path));
}
private currentCwd(): string {

View file

@ -0,0 +1,12 @@
/**
* `process` domain barrel re-exports the process contract (`process`), its
* scoped service (`processRunnerService`), and the backend implementations
* (`localProcessBackend`, `sshProcessBackend`). Importing this barrel
* registers the `IProcessRunner` and default local `IProcessBackend` bindings
* into the scope registry.
*/
export * from './process';
export * from './processRunnerService';
export * from './localProcessBackend';
export * from './sshProcessBackend';

View file

@ -0,0 +1,72 @@
/**
* `process` domain (L1) local `IProcessBackend` implementation.
*
* Spawns real child processes on the host through `node:child_process`.
* Registered as the default `IProcessBackend` at Session scope; remote
* backends override it via the scope registry.
*/
import { type ChildProcess, spawn } from 'node:child_process';
import type { Readable, Writable } from 'node:stream';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { type IProcess, IProcessBackend } from './process';
class LocalProcess implements IProcess {
readonly stdin: Writable;
readonly stdout: Readable;
readonly stderr: Readable;
readonly pid: number;
constructor(private readonly child: ChildProcess) {
if (child.stdin === null || child.stdout === null || child.stderr === null) {
throw new Error('LocalProcess: child must be spawned with piped stdio.');
}
this.stdin = child.stdin;
this.stdout = child.stdout;
this.stderr = child.stderr;
this.pid = child.pid ?? -1;
}
wait(): Promise<number> {
return new Promise((resolve, reject) => {
this.child.once('exit', (code) => resolve(code ?? -1));
this.child.once('error', reject);
});
}
kill(signal?: NodeJS.Signals): Promise<void> {
this.child.kill(signal);
return Promise.resolve();
}
}
export class LocalProcessBackend implements IProcessBackend {
declare readonly _serviceBrand: undefined;
spawn(
args: readonly string[],
options: { readonly cwd: string; readonly env?: Record<string, string> },
): Promise<IProcess> {
const [command, ...rest] = args;
if (command === undefined) {
return Promise.reject(new Error('LocalProcessBackend.spawn: command is required.'));
}
const child = spawn(command, rest, {
cwd: options.cwd,
env: options.env === undefined ? process.env : { ...process.env, ...options.env },
stdio: ['pipe', 'pipe', 'pipe'],
});
return Promise.resolve(new LocalProcess(child));
}
}
registerScopedService(
LifecycleScope.Session,
IProcessBackend,
LocalProcessBackend,
InstantiationType.Delayed,
'process',
);

View file

@ -0,0 +1,48 @@
/**
* `process` domain (L1) the Agent's process runner and its pluggable backend.
*
* Defines the `IProcessRunner` that business code injects to spawn processes
* inside the Agent's execution environment, the `IProcess` handle it returns,
* and the internal `IProcessBackend` provider that hides the
* local/ssh/container split. Session-scoped. Business code depends on
* `IProcessRunner` only; the backend is wired through the scope registry.
*/
import type { Readable, Writable } from 'node:stream';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IProcess {
readonly stdin: Writable;
readonly stdout: Readable;
readonly stderr: Readable;
readonly pid: number;
wait(): Promise<number>;
kill(signal?: NodeJS.Signals): Promise<void>;
}
export interface ProcessExecOptions {
readonly cwd?: string;
readonly env?: Record<string, string>;
}
export interface IProcessRunner {
readonly _serviceBrand: undefined;
exec(args: readonly string[], options?: ProcessExecOptions): Promise<IProcess>;
}
export const IProcessRunner: ServiceIdentifier<IProcessRunner> =
createDecorator<IProcessRunner>('processRunner');
export interface IProcessBackend {
readonly _serviceBrand: undefined;
spawn(
args: readonly string[],
options: { readonly cwd: string; readonly env?: Record<string, string> },
): Promise<IProcess>;
}
export const IProcessBackend: ServiceIdentifier<IProcessBackend> =
createDecorator<IProcessBackend>('processBackend');

View file

@ -0,0 +1,41 @@
/**
* `process` domain (L1) `IProcessRunner` implementation.
*
* Resolves the working directory through `workspaceContext` and delegates
* spawning to the injected `IProcessBackend`. Bound at Session scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IWorkspaceContext } from '#/workspaceContext';
import {
type IProcess,
IProcessBackend,
IProcessRunner,
type ProcessExecOptions,
} from './process';
export class ProcessRunner implements IProcessRunner {
declare readonly _serviceBrand: undefined;
constructor(
@IProcessBackend private readonly backend: IProcessBackend,
@IWorkspaceContext private readonly workspace: IWorkspaceContext,
) {}
exec(args: readonly string[], options?: ProcessExecOptions): Promise<IProcess> {
return this.backend.spawn(args, {
cwd: options?.cwd ?? this.workspace.workDir,
env: options?.env,
});
}
}
registerScopedService(
LifecycleScope.Session,
IProcessRunner,
ProcessRunner,
InstantiationType.Delayed,
'process',
);

View file

@ -0,0 +1,22 @@
/**
* `process` domain (L1) ssh `IProcessBackend` stub.
*
* Placeholder for the remote backend; not registered into the scope registry
* yet. A composition root that needs ssh supplies it through
* `ScopeOptions.extra` to override the local backend.
*/
import { NotImplementedError } from '#/_base/errors';
import { type IProcess, IProcessBackend } from './process';
export class SshProcessBackend implements IProcessBackend {
declare readonly _serviceBrand: undefined;
spawn(
_args: readonly string[],
_options: { readonly cwd: string; readonly env?: Record<string, string> },
): Promise<IProcess> {
throw new NotImplementedError('sshProcessBackend');
}
}

View file

@ -1,36 +1,37 @@
/**
* `sessionMetaStore` session-scope metadata persistence.
* `sessionMetaStore` domain (L2) `ISessionMetaStore` implementation.
*
* Persists session metadata as a single atomic document through the program
* side `storage` (`IConfigStore`). Bound at Session scope.
*/
import { Disposable } from '#/_base/di/lifecycle';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ISessionKaosService } from '#/kaos';
import { ILogService } from '#/log';
import { IConfigStore } from '#/storage';
import { ISessionMetaStore } from './sessionMetaStore';
const SCOPE = 'session-meta';
export class SessionMetaStore extends Disposable implements ISessionMetaStore {
declare readonly _serviceBrand: undefined;
private data: Record<string, unknown> = {};
private readonly path: string;
private readonly key: string;
constructor(
@ISessionKaosService private readonly sessionKaos: ISessionKaosService,
@IConfigStore private readonly configStore: IConfigStore,
@ILogService _log: ILogService,
path: string = 'state.json',
key: string = 'state.json',
) {
super();
this.path = path;
this.key = key;
}
async read(): Promise<Record<string, unknown>> {
try {
const text = await this.sessionKaos.persistenceKaos.readText(this.path);
this.data = JSON.parse(text) as Record<string, unknown>;
} catch {
this.data = {};
}
this.data =
(await this.configStore.get<Record<string, unknown>>(SCOPE, this.key)) ?? {};
return this.data;
}
@ -40,10 +41,7 @@ export class SessionMetaStore extends Disposable implements ISessionMetaStore {
}
async flush(): Promise<void> {
await this.sessionKaos.persistenceKaos.writeText(
this.path,
JSON.stringify(this.data, null, 2),
);
await this.configStore.set(SCOPE, this.key, this.data);
}
}

View file

@ -1,15 +1,17 @@
/**
* `sessionStore` core-scope session directory store.
* `sessionStore` domain (L2) `ISessionStore` implementation.
*
* Enumerates session directories on the real local disk through the program
* side `hostFs` primitives. Bound at Core scope.
*/
import { createHash } from 'node:crypto';
import { promises as fsp } from 'node:fs';
import { join } from 'node:path';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { slugifyWorkDirName } from '#/_base/utils/workdir-slug';
import { IKaosFactory } from '#/kaos';
import { IHostFileSystem } from '#/hostFs';
import { ISessionStore } from './sessionStore';
@ -26,7 +28,8 @@ export function encodeWorkDirKey(workDir: string): string {
export class SessionStore implements ISessionStore {
declare readonly _serviceBrand: undefined;
constructor(@IKaosFactory _kaosFactory: IKaosFactory) {}
constructor(@IHostFileSystem private readonly hostFs: IHostFileSystem) {}
sessionDir(sessionsRoot: string, workDir: string, sessionId: string): string {
return `${sessionsRoot}/${encodeWorkDirKey(workDir)}/${sessionId}`;
@ -38,37 +41,34 @@ export class SessionStore implements ISessionStore {
async countActiveSessions(sessionsRoot: string, workDir: string): Promise<number> {
const dir = join(sessionsRoot, encodeWorkDirKey(workDir));
let dirents;
let entries;
try {
dirents = await fsp.readdir(dir, { withFileTypes: true });
entries = await this.hostFs.readdir(dir);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') return 0;
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0;
throw err;
}
let count = 0;
for (const d of dirents) {
if (!d.isDirectory()) continue;
if (await isSessionArchived(join(dir, d.name))) continue;
for (const entry of entries) {
if (!entry.isDirectory) continue;
if (await this.isSessionArchived(join(dir, entry.name))) continue;
count += 1;
}
return count;
}
}
async function isSessionArchived(sessionDir: string): Promise<boolean> {
try {
const raw = await fsp.readFile(join(sessionDir, 'state.json'), 'utf8');
const parsed = JSON.parse(raw) as unknown;
return (
typeof parsed === 'object' &&
parsed !== null &&
(parsed as { archived?: boolean }).archived === true
);
} catch {
// Treat unreadable/missing state.json as non-archived so the directory still
// counts as a session (matches the session store's own loading behavior).
return false;
private async isSessionArchived(sessionDir: string): Promise<boolean> {
try {
const raw = await this.hostFs.readText(join(sessionDir, 'state.json'));
const parsed = JSON.parse(raw) as unknown;
return (
typeof parsed === 'object' &&
parsed !== null &&
(parsed as { archived?: boolean }).archived === true
);
} catch {
return false;
}
}
}

View file

@ -0,0 +1,74 @@
/**
* `IConfigStore` / `ConfigStore` the typed atomic-document service.
*
* Sits on top of `IStorageService` and stores one typed JSON value per
* `(scope, key)`, replaced atomically on every write. This is the `Config`
* access pattern: `state.json`, `upcoming-goals.json`, per-id cron/background
* records, etc.
*
* It is a DI service: domains inject `IConfigStore` and call `get/set` with
* the scope they own they do not construct stores themselves. JSON
* (de)serialization and atomic replacement are centralized here so domains
* do not reimplement them.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IStorageService } from './storageService';
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
export interface IConfigStore {
readonly _serviceBrand: undefined;
/** Read the value at `(scope, key)`, or `undefined` when absent. */
get<T>(scope: string, key: string): Promise<T | undefined>;
/** Atomically replace the value at `(scope, key)`. */
set<T>(scope: string, key: string, value: T): Promise<void>;
/** Delete `(scope, key)`. Missing keys are not an error. */
delete(scope: string, key: string): Promise<void>;
/** List the keys under `scope`, optionally filtered by `prefix`. */
list(scope: string, prefix?: string): Promise<readonly string[]>;
}
export const IConfigStore: ServiceIdentifier<IConfigStore> =
createDecorator<IConfigStore>('configStore');
export class ConfigStore implements IConfigStore {
declare readonly _serviceBrand: undefined;
constructor(@IStorageService private readonly storage: IStorageService) {}
async get<T>(scope: string, key: string): Promise<T | undefined> {
const bytes = await this.storage.read(scope, key);
return bytes === undefined ? undefined : (JSON.parse(textDecoder.decode(bytes)) as T);
}
async set<T>(scope: string, key: string, value: T): Promise<void> {
await this.storage.write(scope, key, textEncoder.encode(JSON.stringify(value)), {
atomic: true,
});
}
async delete(scope: string, key: string): Promise<void> {
await this.storage.delete(scope, key);
}
async list(scope: string, prefix?: string): Promise<readonly string[]> {
return this.storage.list(scope, prefix);
}
}
registerScopedService(
LifecycleScope.Session,
IConfigStore,
ConfigStore,
InstantiationType.Delayed,
'storage',
);

View file

@ -0,0 +1,125 @@
/**
* `FileStorageService` `IStorageService` backed by the local filesystem.
*
* Layout: a value addressed by `(scope, key)` lives at
* `<baseDir>/<scope>/<key>`. `scope` may contain slashes to form nested
* directories (e.g. `"agents/main"`).
*
* Primitives:
* - `write` `atomicWrite` (tmp + fsync + rename) followed by a directory
* fsync, so the replacement is both atomic and durable.
* - `append` `open('a')` + write + `fh.sync()` (when `durable`), plus a
* one-time directory fsync per scope.
*
* It uses raw `node:fs` (like `wireRecord/persistence.ts` and
* `blobStoreService.ts`) rather than `kaos`: the storage kernel needs direct
* control over append offsets, fsync, atomic rename and (future) streaming,
* which the agent-execution-environment abstraction does not expose.
*/
import { mkdir, open, readFile, readdir, unlink } from 'node:fs/promises';
import { dirname, join } from 'pathe';
import { atomicWrite, syncDir } from '#/_base/utils/fs';
import type {
IStorageService,
StorageAppendOptions,
StorageWriteOptions,
} from './storageService';
function isEnoent(error: unknown): boolean {
return (error as NodeJS.ErrnoException).code === 'ENOENT';
}
export class FileStorageService implements IStorageService {
declare readonly _serviceBrand: undefined;
private readonly syncedDirs = new Set<string>();
constructor(private readonly baseDir: string) {}
async read(scope: string, key: string): Promise<Uint8Array | undefined> {
try {
return await readFile(this.path(scope, key));
} catch (error) {
if (isEnoent(error)) return undefined;
throw error;
}
}
async write(
scope: string,
key: string,
data: Uint8Array,
_options: StorageWriteOptions = {},
): Promise<void> {
const filePath = this.path(scope, key);
await mkdir(dirname(filePath), { recursive: true });
await atomicWrite(filePath, data);
await this.syncDirOnce(dirname(filePath));
}
async append(
scope: string,
key: string,
data: Uint8Array,
options: StorageAppendOptions = {},
): Promise<void> {
const filePath = this.path(scope, key);
const dir = dirname(filePath);
await mkdir(dir, { recursive: true });
const fh = await open(filePath, 'a');
try {
if (data.byteLength > 0) {
await fh.writeFile(data);
}
if (options.durable !== false) {
await fh.sync();
}
} finally {
await fh.close();
}
await this.syncDirOnce(dir);
}
async list(scope: string, prefix?: string): Promise<readonly string[]> {
let entries: readonly string[];
try {
entries = await readdir(this.scopePath(scope));
} catch (error) {
if (isEnoent(error)) return [];
throw error;
}
return prefix === undefined ? entries : entries.filter((entry) => entry.startsWith(prefix));
}
async delete(scope: string, key: string): Promise<void> {
try {
await unlink(this.path(scope, key));
} catch (error) {
if (!isEnoent(error)) throw error;
}
}
async flush(): Promise<void> {
// Writes resolve only after the bytes are durable; nothing is buffered.
}
async close(): Promise<void> {}
private path(scope: string, key: string): string {
return join(this.baseDir, scope, key);
}
private scopePath(scope: string): string {
return join(this.baseDir, scope);
}
private async syncDirOnce(dir: string): Promise<void> {
if (this.syncedDirs.has(dir)) return;
await syncDir(dir);
this.syncedDirs.add(dir);
}
}

View file

@ -0,0 +1,91 @@
/**
* `InMemoryStorageService` `IStorageService` backed by in-memory maps.
*
* Registered as the default `IStorageService` so scopes and tests work out of
* the box. For durable production storage, the composition root seeds a
* `FileStorageService` (rooted at the session directory) into the scope via
* `ScopeOptions.extra`, overriding this default the same pattern
* `blobStoreService` uses.
*
* `append` concatenates into the same key slot `write` replaces, mirroring the
* file implementation's single-namespace semantics so the two are
* interchangeable for the facades above.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import {
IStorageService,
type StorageAppendOptions,
type StorageWriteOptions,
} from './storageService';
export class InMemoryStorageService implements IStorageService {
declare readonly _serviceBrand: undefined;
private readonly scopes = new Map<string, Map<string, Uint8Array>>();
async read(scope: string, key: string): Promise<Uint8Array | undefined> {
return this.scopes.get(scope)?.get(key);
}
async write(
scope: string,
key: string,
data: Uint8Array,
_options: StorageWriteOptions = {},
): Promise<void> {
this.bucket(scope).set(key, data);
}
async append(
scope: string,
key: string,
data: Uint8Array,
_options: StorageAppendOptions = {},
): Promise<void> {
const bucket = this.bucket(scope);
const existing = bucket.get(key);
if (existing === undefined) {
bucket.set(key, data);
return;
}
const merged = new Uint8Array(existing.byteLength + data.byteLength);
merged.set(existing, 0);
merged.set(data, existing.byteLength);
bucket.set(key, merged);
}
async list(scope: string, prefix?: string): Promise<readonly string[]> {
const bucket = this.scopes.get(scope);
if (bucket === undefined) return [];
const keys = [...bucket.keys()];
return prefix === undefined ? keys : keys.filter((key) => key.startsWith(prefix));
}
async delete(scope: string, key: string): Promise<void> {
this.scopes.get(scope)?.delete(key);
}
async flush(): Promise<void> {}
async close(): Promise<void> {}
private bucket(scope: string): Map<string, Uint8Array> {
let bucket = this.scopes.get(scope);
if (bucket === undefined) {
bucket = new Map();
this.scopes.set(scope, bucket);
}
return bucket;
}
}
registerScopedService(
LifecycleScope.Session,
IStorageService,
InMemoryStorageService,
InstantiationType.Delayed,
'storage',
);

View file

@ -0,0 +1,6 @@
export * from './storageService';
export * from './fileStorageService';
export * from './inMemoryStorageService';
export * from './recordStore';
export * from './configStore';
export * from './queryStore';

View file

@ -0,0 +1,114 @@
/**
* `IQueryStore` the indexed, queryable read-model facade.
*
* A peer of `IRecordStore` and `IConfigStore`. Where `IRecordStore` is the
* authoritative append-only write model and `IConfigStore` holds atomic
* documents, `IQueryStore` serves fast, indexed, paginated reads over a
* *derived* dataset typically materialized from a record log by a projector.
*
* This file intentionally ships the interface only. A concrete implementation
* (e.g. backed by `minidb`) and the projector that feeds it are a follow-up;
* the contract is fixed here so domains can depend on it without coupling to
* any specific engine.
*
* `collection` is a logical table (an engine may encode it as a key prefix).
* Values are plain JSON-shaped objects; indexes are declared over their fields.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export type SortDir = 'asc' | 'desc';
export interface Page<T> {
readonly items: readonly T[];
/** Opaque token for the next page, or `undefined` when exhausted. */
readonly nextCursor?: string;
}
export interface ComparisonOp {
readonly $eq?: unknown;
readonly $ne?: unknown;
readonly $gt?: number | string;
readonly $gte?: number | string;
readonly $lt?: number | string;
readonly $lte?: number | string;
readonly $in?: readonly unknown[];
readonly $nin?: readonly unknown[];
readonly $exists?: boolean;
}
/** A field mapped to a scalar (shorthand for `$eq`) or a set of operators. */
export type QueryFilter = {
readonly [field: string]: unknown | ComparisonOp;
};
export interface IQuery<T> {
where(filter: QueryFilter): IQuery<T>;
orderBy(field: string, dir?: SortDir): IQuery<T>;
limit(n: number): IQuery<T>;
cursor(cursor: string | undefined): IQuery<T>;
execute(): Promise<Page<T>>;
}
export interface ValueIndexDef {
readonly kind: 'value';
readonly name: string;
/** Dot/bracket path into the value, e.g. `"model"` or `"meta.user"`. */
readonly field: string;
readonly unique?: boolean;
}
export interface CompoundIndexDef {
readonly kind: 'compound';
readonly name: string;
readonly groupBy: string;
readonly orderBy: string;
}
export interface TextIndexDef {
readonly kind: 'text';
readonly name: string;
/** Fields to tokenize; empty/omitted means the whole value. */
readonly fields?: readonly string[];
}
export type IndexDef = ValueIndexDef | CompoundIndexDef | TextIndexDef;
export type WriteOp =
| { readonly kind: 'put'; readonly collection: string; readonly key: string; readonly value: unknown }
| { readonly kind: 'delete'; readonly collection: string; readonly key: string };
/** Position a projector has reached in its source log. */
export interface Checkpoint {
readonly seq: number;
}
export interface IQueryStore {
readonly _serviceBrand: undefined;
/** Upsert a value (projector write path). */
put<T>(collection: string, key: string, value: T): Promise<void>;
/** Apply several writes atomically. */
batch(ops: readonly WriteOp[]): Promise<void>;
delete(collection: string, key: string): Promise<void>;
get<T>(collection: string, key: string): Promise<T | undefined>;
/** Start a query against a collection (read path). */
query<T>(collection: string): IQuery<T>;
/** Declare an index. Idempotent — re-declaring an existing index is a no-op. */
ensureIndex(collection: string, def: IndexDef): Promise<void>;
/** Read how far a projector has indexed a given source log. */
getCheckpoint(source: string): Promise<Checkpoint | undefined>;
/** Persist a projector's progress for a given source log. */
setCheckpoint(source: string, checkpoint: Checkpoint): Promise<void>;
close(): Promise<void>;
}
export const IQueryStore: ServiceIdentifier<IQueryStore> = createDecorator<IQueryStore>('queryStore');

View file

@ -0,0 +1,190 @@
/**
* `IRecordStore` / `RecordStore` the typed append-log service.
*
* Sits on top of `IStorageService` and turns a byte stream into an ordered
* sequence of typed JSON records. Owns the concerns the storage service
* deliberately ignores: line framing (one JSON value per line, a.k.a. JSONL),
* batching of appends into a single durable `append`, and crash-tolerant
* decoding (a torn final line is dropped; corruption anywhere else throws).
*
* It is a DI service: domains inject `IRecordStore` and call
* `append/read/rewrite` with the `(scope, key)` of the log they own. Buffering
* is kept per log inside the service, so many appends within a synchronous
* block collapse into one durable write.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IStorageService } from './storageService';
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
export class RecordCorruptedError extends Error {
constructor(
readonly scope: string,
readonly key: string,
readonly lineNumber: number,
cause: unknown,
) {
super(`record log ${scope}/${key}: corrupted line ${lineNumber}: ${String(cause)}`);
this.name = 'RecordCorruptedError';
}
}
export interface IRecordStore {
readonly _serviceBrand: undefined;
/** Buffer a record for the next durable append. Resolves immediately. */
append<R>(scope: string, key: string, record: R): void;
/**
* Replay the log in order. Flushes pending appends first. A torn final line
* (crash mid-flush) is dropped; any other corruption throws.
*/
read<R>(scope: string, key: string): AsyncIterable<R>;
/** Atomically replace the whole log with `records` (used after migration). */
rewrite<R>(scope: string, key: string, records: readonly R[]): Promise<void>;
/** Durable-write every buffered record across all logs. */
flush(): Promise<void>;
/** Flush and release resources. */
close(): Promise<void>;
}
export const IRecordStore: ServiceIdentifier<IRecordStore> =
createDecorator<IRecordStore>('recordStore');
interface LogState {
pending: unknown[];
flushPromise: Promise<void> | undefined;
flushScheduled: boolean;
}
export class RecordStore implements IRecordStore {
declare readonly _serviceBrand: undefined;
private readonly logs = new Map<string, LogState>();
constructor(@IStorageService private readonly storage: IStorageService) {}
append<R>(scope: string, key: string, record: R): void {
const state = this.state(scope, key);
state.pending.push(record);
this.scheduleFlush(scope, key, state);
}
async *read<R>(scope: string, key: string): AsyncIterable<R> {
await this.flushLog(scope, key);
const bytes = await this.storage.read(scope, key);
if (bytes === undefined) return;
const lines = textDecoder.decode(bytes).split('\n');
for (let i = 0; i < lines.length; i++) {
const raw = lines[i]!;
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
if (line.length === 0) continue;
const isLast = i === lines.length - 1;
try {
yield JSON.parse(line) as R;
} catch (error) {
// A crash can leave a half-written last line; drop it. Corruption
// anywhere before the end is real and must surface.
if (isLast) return;
throw new RecordCorruptedError(scope, key, i + 1, error);
}
}
}
async rewrite<R>(scope: string, key: string, records: readonly R[]): Promise<void> {
// Persist anything already buffered, then atomically replace the log.
await this.flushLog(scope, key);
await this.storage.write(scope, key, encodeBatch(records), { atomic: true });
}
async flush(): Promise<void> {
const inFlight = [...this.logs.keys()].map((id) => {
const { scope, key } = fromLogId(id);
return this.flushLog(scope, key);
});
await Promise.all(inFlight);
}
async close(): Promise<void> {
await this.flush();
}
private state(scope: string, key: string): LogState {
const id = logId(scope, key);
let state = this.logs.get(id);
if (state === undefined) {
state = { pending: [], flushPromise: undefined, flushScheduled: false };
this.logs.set(id, state);
}
return state;
}
/**
* Defer the drain to the next microtask so records appended within the same
* synchronous block accumulate into a single durable `IStorageService.append`.
*/
private scheduleFlush(scope: string, key: string, state: LogState): void {
if (state.flushScheduled || state.flushPromise !== undefined) return;
state.flushScheduled = true;
queueMicrotask(() => {
state.flushScheduled = false;
void this.flushLog(scope, key);
});
}
private flushLog(scope: string, key: string): Promise<void> {
const state = this.state(scope, key);
if (state.flushPromise !== undefined) return state.flushPromise;
const promise = this.drain(scope, key, state).finally(() => {
if (state.flushPromise === promise) {
state.flushPromise = undefined;
}
// Records appended during the drain must be drained too.
if (state.pending.length > 0) {
void this.flushLog(scope, key);
}
});
state.flushPromise = promise;
return promise;
}
private async drain(scope: string, key: string, state: LogState): Promise<void> {
while (state.pending.length > 0) {
const batch = state.pending.splice(0);
await this.storage.append(scope, key, encodeBatch(batch), { durable: true });
}
}
}
function logId(scope: string, key: string): string {
return `${scope}\n${key}`;
}
function fromLogId(id: string): { scope: string; key: string } {
const index = id.indexOf('\n');
return { scope: id.slice(0, index), key: id.slice(index + 1) };
}
function encodeBatch(records: readonly unknown[]): Uint8Array {
if (records.length === 0) return new Uint8Array(0);
const content = records.map((record) => JSON.stringify(record) + '\n').join('');
return textEncoder.encode(content);
}
registerScopedService(
LifecycleScope.Session,
IRecordStore,
RecordStore,
InstantiationType.Delayed,
'storage',
);

View file

@ -0,0 +1,71 @@
/**
* `storage` domain the bottom-most persistence substrate.
*
* `IStorageService` is the single, backend-pluggable byte store that every
* other local-persistence abstraction is built on. It exposes the two
* irreducible durable primitives side by side:
*
* - `write` atomic whole-value replacement (the `Config` access pattern).
* - `append` ordered, durable byte extension (the `Record` access pattern).
*
* They are not interchangeable: building `append` on top of `write` is O(n)
* per append, and building `write` on top of `append` yields awkward "read
* the last value" semantics. Keeping both as first-class primitives lets each
* implementation implement them optimally (file: `open('a')` vs tmp+rename;
* db: `INSERT` vs `UPSERT`).
*
* The service is intentionally byte-oriented and scope/key-addressed: it knows
* nothing about JSON, records, configs, versions or framing. Those concerns
* live in the typed facades above it (`IRecordStore`, `IConfigStore`).
*
* `scope`/`key` are trusted internal path segments for the file implementation
* (e.g. scope `"agents/main"`, key `"wire.jsonl"`); they are not user input.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface StorageWriteOptions {
/**
* Replace atomically so readers never observe a half-written value. The file
* implementation always writes atomically; the option is reserved for
* implementations where atomicity is opt-in. Defaults to `true`.
*/
readonly atomic?: boolean;
}
export interface StorageAppendOptions {
/**
* When `true` (default) the append is fsync'd before the call resolves so
* the bytes are durable across a crash. Set to `false` only for rebuildable
* data (caches/indexes) where throughput matters more than durability.
*/
readonly durable?: boolean;
}
export interface IStorageService {
readonly _serviceBrand: undefined;
/** Read the whole value, or `undefined` when the key does not exist. */
read(scope: string, key: string): Promise<Uint8Array | undefined>;
/** Atomically replace the whole value. */
write(scope: string, key: string, data: Uint8Array, options?: StorageWriteOptions): Promise<void>;
/** Durable byte extension (ordered). */
append(scope: string, key: string, data: Uint8Array, options?: StorageAppendOptions): Promise<void>;
/** List the keys under `scope`, optionally filtered by `prefix`. */
list(scope: string, prefix?: string): Promise<readonly string[]>;
/** Delete a key. Missing keys are not an error. */
delete(scope: string, key: string): Promise<void>;
/** Flush any buffered writes to the durable medium. */
flush(): Promise<void>;
/** Release implementation resources (file handles, timers, connections). */
close(): Promise<void>;
}
export const IStorageService: ServiceIdentifier<IStorageService> =
createDecorator<IStorageService>('storageService');

View file

@ -1,13 +0,0 @@
/**
* `terminal` domain error codes.
*/
import { registerErrorDomain, type ErrorDomain } from '#/_base/errors';
export const TerminalErrors = {
codes: {
SHELL_GIT_BASH_NOT_FOUND: 'shell.git_bash_not_found',
},
} as const satisfies ErrorDomain;
registerErrorDomain(TerminalErrors);

View file

@ -1,8 +0,0 @@
/**
* `terminal` domain barrel re-exports the terminal contract (`terminal`) and
* its scoped service (`terminalService`). Importing this barrel registers the
* `ITerminalService` binding into the scope registry.
*/
export * from './terminal';
export * from './terminalService';

View file

@ -1,23 +0,0 @@
/**
* `terminal` domain (cross-cutting) session-scope terminal service.
*
* Defines the public contract of terminal management: the `TerminalHandle`
* model and the `ITerminalService` used to spawn processes, write to stdin,
* and kill terminals. Session-scoped one service per session.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface TerminalHandle {
readonly id: string;
}
export interface ITerminalService {
readonly _serviceBrand: undefined;
spawn(cmd: string, args: readonly string[]): Promise<TerminalHandle>;
write(id: string, data: string): void;
kill(id: string): Promise<void>;
}
export const ITerminalService: ServiceIdentifier<ITerminalService> =
createDecorator<ITerminalService>('terminalService');

View file

@ -1,48 +0,0 @@
/**
* `terminal` domain (cross-cutting) `ITerminalService` implementation.
*
* Owns the spawned terminal processes and their lifecycle; runs processes
* through `kaos` and logs through `log`. Bound at Session scope.
*/
import type { KaosProcess } from '@moonshot-ai/kaos';
import { Disposable } from '#/_base/di/lifecycle';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ISessionKaosService } from '#/kaos';
import { ILogService } from '#/log';
import { type TerminalHandle, ITerminalService } from './terminal';
export class TerminalService extends Disposable implements ITerminalService {
declare readonly _serviceBrand: undefined;
private readonly processes = new Map<string, KaosProcess>();
constructor(
@ILogService _log: ILogService,
@ISessionKaosService private readonly sessionKaos: ISessionKaosService,
) {
super();
}
async spawn(cmd: string, args: readonly string[]): Promise<TerminalHandle> {
const proc = await this.sessionKaos.toolKaos.exec(cmd, ...args);
const id = String(proc.pid);
this.processes.set(id, proc);
return { id };
}
write(id: string, data: string): void {
this.processes.get(id)?.stdin.write(data);
}
async kill(id: string): Promise<void> {
const proc = this.processes.get(id);
if (proc === undefined) return;
await proc.kill();
this.processes.delete(id);
}
}
registerScopedService(LifecycleScope.Session, ITerminalService, TerminalService, InstantiationType.Delayed, 'terminal');

View file

@ -1,60 +0,0 @@
/**
* `workspace` domain error codes and error classes.
*
* Codes are sourced from the protocol `KimiErrorCode` set so the wire shape
* matches the existing REST routes (`workspace.not_found`, `fs.path_not_found`,
* `fs.permission_denied`, `validation.failed`). A single `ErrorDomain` owns all
* codes so a shared code (e.g. `fs.path_not_found`, used by both the registry
* and the fs browser) is registered exactly once.
*/
import { KimiError, registerErrorDomain, type ErrorDomain } from '#/_base/errors';
export const WorkspaceErrors = {
codes: {
WORKSPACE_NOT_FOUND: 'workspace.not_found',
PATH_NOT_ABSOLUTE: 'validation.failed',
PATH_NOT_FOUND: 'fs.path_not_found',
PERMISSION_DENIED: 'fs.permission_denied',
},
info: {
'workspace.not_found': {
title: 'Workspace not found',
retryable: false,
public: true,
},
'validation.failed': {
title: 'Path must be absolute',
retryable: false,
public: true,
},
'fs.path_not_found': {
title: 'Path not found',
retryable: false,
public: true,
},
'fs.permission_denied': {
title: 'Permission denied',
retryable: false,
public: true,
},
},
} as const satisfies ErrorDomain;
registerErrorDomain(WorkspaceErrors);
export type WorkspaceErrorCode = (typeof WorkspaceErrors.codes)[keyof typeof WorkspaceErrors.codes];
export class WorkspaceError extends KimiError {
constructor(code: WorkspaceErrorCode, message: string, details?: Record<string, unknown>) {
super(code, message, { details });
this.name = 'WorkspaceError';
}
}
export class WorkspaceFsError extends KimiError {
constructor(code: WorkspaceErrorCode, message: string, details?: Record<string, unknown>) {
super(code, message, { details });
this.name = 'WorkspaceFsError';
}
}

View file

@ -1,14 +0,0 @@
/**
* `workspace` domain barrel re-exports the workspace contracts and their
* scoped services. Importing this barrel registers every workspace binding
* (`IWorkspaceRegistry`, `IWorkspaceFsService`, `ISessionWorkspaceService`)
* and the domain's error codes into the scope registry.
*/
export * from './errors';
export * from './workspace';
export * from './workspaceService';
export * from './workspaceRegistry';
export * from './workspaceRegistryService';
export * from './workspaceFs';
export * from './workspaceFsService';

View file

@ -1,53 +0,0 @@
/**
* `workspace` domain (cross-cutting) session-scope workspace service.
*
* Defines the public contract of the current session's workspace: the primary
* `workDir` plus any `additionalDirs`, and the path helpers built on top of
* them (`resolve` / `isWithin` / `assertAllowed`). Session-scoped one
* instance per session so consumers never thread a `workspaceId` around.
*
* This is the semantic layer above `kaos`: `ISessionKaosService` owns the raw
* `Kaos` environments and additional roots, while `ISessionWorkspaceService` turns
* them into workspace-relative path operations. Dependency direction is
* `workspace → kaos`, never the reverse (see `docs/service-design.md` §5).
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { PathAccessOperation } from '#/_base/tools/policies/path-access';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
export interface ISessionWorkspaceService {
readonly _serviceBrand: undefined;
/** Primary workspace directory (absolute). Reflects the session kaos cwd. */
readonly workDir: string;
/** Extra allowed roots (e.g. `--add-dir` CLI flag). */
readonly additionalDirs: readonly string[];
/**
* Resolve `rel` to an absolute, canonical path. Relative paths are resolved
* against `workDir`; absolute paths are normalized as-is. Lexical only no
* filesystem I/O.
*/
resolve(rel: string): string;
/** True iff `path` (resolved against `workDir`) sits inside `workDir` or any `additionalDirs`. */
isWithin(path: string): boolean;
/**
* Throw if `path` escapes the workspace for `op` (or matches a sensitive
* file); returns the canonical absolute path when it passes.
*/
assertAllowed(path: string, op: PathAccessOperation): string;
/** Snapshot as a `WorkspaceConfig` value object for the path-access helpers. */
toConfig(): WorkspaceConfig;
/** Add an extra allowed root for this session (no-op if already present). */
addAdditionalDir(dir: string): void;
/** Remove an extra allowed root from this session. */
removeAdditionalDir(dir: string): void;
}
export const ISessionWorkspaceService: ServiceIdentifier<ISessionWorkspaceService> =
createDecorator<ISessionWorkspaceService>('sessionWorkspaceService');

View file

@ -1,29 +0,0 @@
/**
* `workspace` domain (cross-cutting) core-scope folder-picker contract.
*
* Defines the public contract of the host-filesystem browser
* (`IWorkspaceFsService`) backing the workspace folder picker: listing a
* directory's subfolders (`browse`) and the picker landing payload (`home`).
* Core-scoped and host-fs based it operates on arbitrary absolute paths of
* the host, deliberately distinct from the session-scoped, kaos-sandboxed `fs`
* domain used by tools.
*/
import type { FsBrowseResponse, FsHomeResponse } from '@moonshot-ai/protocol';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IWorkspaceFsService {
readonly _serviceBrand: undefined;
/** List subdirectories of `absPath` (defaults to `$HOME`), annotated with git state. */
browse(absPath?: string): Promise<FsBrowseResponse>;
/** Folder-picker landing payload: `$HOME` plus recently-opened workspace roots. */
home(): Promise<FsHomeResponse>;
}
export const IWorkspaceFsService: ServiceIdentifier<IWorkspaceFsService> =
createDecorator<IWorkspaceFsService>('workspaceFsService');
export const RECENT_ROOTS_LIMIT = 8;

View file

@ -1,115 +0,0 @@
/**
* `workspace` domain (cross-cutting) `IWorkspaceFsService` implementation.
*
* Browses the host filesystem for the workspace folder picker and derives the
* recent-roots list from `IWorkspaceRegistry`. Bound at Core scope.
*/
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import { dirname, isAbsolute, join } from 'node:path';
import type { FsBrowseEntry, FsBrowseResponse, FsHomeResponse } from '@moonshot-ai/protocol';
import { InstantiationType } from '#/_base/di/extensions';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { WorkspaceFsError, WorkspaceErrors } from './errors';
import { IWorkspaceRegistry } from './workspaceRegistry';
import { detectGit } from './workspaceRegistryService';
import { IWorkspaceFsService, RECENT_ROOTS_LIMIT } from './workspaceFs';
export class WorkspaceFsService extends Disposable implements IWorkspaceFsService {
declare readonly _serviceBrand: undefined;
constructor(@IWorkspaceRegistry private readonly registry: IWorkspaceRegistry) {
super();
}
async browse(absPath?: string): Promise<FsBrowseResponse> {
const target = absPath ?? os.homedir();
if (!isAbsolute(target)) {
throw new WorkspaceFsError(WorkspaceErrors.codes.PATH_NOT_ABSOLUTE, `path must be absolute: ${target}`, {
path: target,
});
}
let realTarget: string;
try {
realTarget = await fsp.realpath(target);
} catch (err) {
throw mapFsError(err, target);
}
let dirents;
try {
dirents = await fsp.readdir(realTarget, { withFileTypes: true });
} catch (err) {
throw mapFsError(err, realTarget);
}
const dirOnly = dirents.filter((d) => d.isDirectory());
const entries: FsBrowseEntry[] = await Promise.all(
dirOnly.map(async (d) => {
const childAbs = join(realTarget, d.name);
const git = await detectGit(childAbs);
const base: FsBrowseEntry = {
name: d.name,
path: childAbs,
is_dir: true,
is_git_repo: git.is_git_repo,
};
if (git.branch !== null) {
return { ...base, branch: git.branch };
}
return base;
}),
);
entries.sort(compareBrowseEntries);
const parent = dirname(realTarget);
return {
path: realTarget,
parent: parent === realTarget ? null : parent,
entries,
};
}
async home(): Promise<FsHomeResponse> {
const home = os.homedir();
const workspaces = await this.registry.list();
const recent_roots = workspaces.slice(0, RECENT_ROOTS_LIMIT).map((w) => w.root);
return { home, recent_roots };
}
override dispose(): void {
if (this._store.isDisposed) return;
super.dispose();
}
}
function mapFsError(err: unknown, path: string): Error {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') {
return new WorkspaceFsError(WorkspaceErrors.codes.PATH_NOT_FOUND, `path not found: ${path}`, { path });
}
if (code === 'EACCES' || code === 'EPERM') {
return new WorkspaceFsError(WorkspaceErrors.codes.PERMISSION_DENIED, `permission denied: ${path}`, { path });
}
return err instanceof Error ? err : new Error(String(err));
}
function compareBrowseEntries(a: FsBrowseEntry, b: FsBrowseEntry): number {
const aDot = a.name.startsWith('.');
const bDot = b.name.startsWith('.');
if (aDot !== bDot) return aDot ? 1 : -1;
return a.name.localeCompare(b.name);
}
registerScopedService(
LifecycleScope.Core,
IWorkspaceFsService,
WorkspaceFsService,
InstantiationType.Delayed,
'workspace',
);

View file

@ -1,47 +0,0 @@
/**
* `workspace` domain (cross-cutting) core-scope workspace registry contract.
*
* Defines the public contract of the persistent, process-wide catalog of known
* workspaces (`IWorkspaceRegistry`): the `Workspace` records keyed by a stable
* `wd_<slug>_<hash>` id, plus the `WorkspacePatch` used to rename one. Core-
* scoped one shared registry for the whole process. Backed by
* `<homeDir>/workspaces.json`; session counts are delegated to `ISessionStore`
* so this domain never reaches into the session store's on-disk layout.
*/
import type { Workspace } from '@moonshot-ai/protocol';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface WorkspacePatch {
name?: string;
}
export interface IWorkspaceRegistry {
readonly _serviceBrand: undefined;
/** List every registered workspace, most-recently-opened first. */
list(): Promise<Workspace[]>;
/** Fetch a single workspace by id; throws `WorkspaceError` (workspace.not_found) if absent. */
get(workspaceId: string): Promise<Workspace>;
/**
* Register `root` (idempotent): creates the entry on first sight, otherwise
* bumps `last_opened_at`. Throws `WorkspaceError` (fs.path_not_found) when
* `root` does not exist on disk.
*/
createOrTouch(root: string, name?: string): Promise<Workspace>;
/** Rename a workspace (display name only). */
update(workspaceId: string, patch: WorkspacePatch): Promise<Workspace>;
/** Unregister a workspace (does not remove on-disk content). */
delete(workspaceId: string): Promise<void>;
/** Resolve a workspace id back to its absolute root path. */
resolveRoot(workspaceId: string): Promise<string>;
}
export const IWorkspaceRegistry: ServiceIdentifier<IWorkspaceRegistry> =
createDecorator<IWorkspaceRegistry>('workspaceRegistry');

View file

@ -1,328 +0,0 @@
/**
* `workspace` domain (cross-cutting) `IWorkspaceRegistry` implementation.
*
* Persists the process-wide workspace catalog to `<homeDir>/workspaces.json`
* and hydrates each entry with live git state and an active-session count.
* Bound at Core scope one shared registry.
*/
import { promises as fsp } from 'node:fs';
import type { Stats } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import type { Workspace } from '@moonshot-ai/protocol';
import { InstantiationType } from '#/_base/di/extensions';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IEnvironmentService } from '#/environment/environment';
import { IEventService } from '#/event/event';
import { ILogService } from '#/log/log';
import { ISessionStore } from '#/sessionStore/sessionStore';
import { WorkspaceError, WorkspaceErrors } from './errors';
import { IWorkspaceRegistry, type WorkspacePatch } from './workspaceRegistry';
const WORKSPACE_REGISTRY_FILE = 'workspaces.json';
const WORKSPACE_REGISTRY_VERSION = 1;
interface WorkspaceRegistryEntry {
root: string;
name: string;
created_at: string;
last_opened_at: string;
}
interface WorkspaceRegistryFile {
version: number;
workspaces: Record<string, WorkspaceRegistryEntry>;
}
export class WorkspaceRegistryService extends Disposable implements IWorkspaceRegistry {
declare readonly _serviceBrand: undefined;
private readonly sessionsDir: string;
private readonly registryPath: string;
private opQueue: Promise<unknown> = Promise.resolve();
constructor(
@IEnvironmentService env: IEnvironmentService,
@ILogService private readonly logger: ILogService,
@IEventService private readonly eventService: IEventService,
@ISessionStore private readonly sessionStore: ISessionStore,
) {
super();
this.sessionsDir = join(env.homeDir, 'sessions');
this.registryPath = join(env.homeDir, WORKSPACE_REGISTRY_FILE);
}
async list(): Promise<Workspace[]> {
const file = await this.runExclusive(() => this.readRegistry());
const hydrated = await Promise.all(
Object.entries(file.workspaces).map(([workspaceId, entry]) => this.hydrate(workspaceId, entry)),
);
return hydrated.sort((a, b) => (b.last_opened_at < a.last_opened_at ? -1 : 1));
}
async get(workspaceId: string): Promise<Workspace> {
const entry = await this.runExclusive(async () => {
const file = await this.readRegistry();
return file.workspaces[workspaceId] ?? null;
});
if (entry === null) {
throw new WorkspaceError(WorkspaceErrors.codes.WORKSPACE_NOT_FOUND, `workspace not found: ${workspaceId}`, {
workspaceId,
});
}
return this.hydrate(workspaceId, entry);
}
async createOrTouch(root: string, name?: string): Promise<Workspace> {
let realRoot: string;
try {
realRoot = await fsp.realpath(root);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw new WorkspaceError(WorkspaceErrors.codes.PATH_NOT_FOUND, `workspace root does not exist: ${root}`, {
root,
});
}
throw err;
}
const workspaceId = this.sessionStore.workspaceIdFor(realRoot);
await fsp.mkdir(join(this.sessionsDir, workspaceId), { recursive: true, mode: 0o700 });
const now = new Date().toISOString();
const { entry, created } = await this.runExclusive(async () => {
const file = await this.readRegistry();
const existing = file.workspaces[workspaceId];
const next: WorkspaceRegistryEntry =
existing !== undefined
? { ...existing, last_opened_at: now }
: {
root: realRoot,
name: name ?? basename(realRoot),
created_at: now,
last_opened_at: now,
};
file.workspaces[workspaceId] = next;
await this.writeRegistry(file);
return { entry: next, created: existing === undefined };
});
const workspace = await this.hydrate(workspaceId, entry);
if (created) {
this.eventService.publish({ type: 'event.workspace.created', payload: { workspace } });
}
return workspace;
}
async update(workspaceId: string, patch: WorkspacePatch): Promise<Workspace> {
const entry = await this.runExclusive(async () => {
const file = await this.readRegistry();
const existing = file.workspaces[workspaceId];
if (existing === undefined) {
throw new WorkspaceError(WorkspaceErrors.codes.WORKSPACE_NOT_FOUND, `workspace not found: ${workspaceId}`, {
workspaceId,
});
}
const next: WorkspaceRegistryEntry = {
...existing,
...(patch.name !== undefined ? { name: patch.name } : {}),
};
file.workspaces[workspaceId] = next;
await this.writeRegistry(file);
return next;
});
const workspace = await this.hydrate(workspaceId, entry);
this.eventService.publish({ type: 'event.workspace.updated', payload: { workspace } });
return workspace;
}
async delete(workspaceId: string): Promise<void> {
const root = await this.runExclusive(async () => {
const file = await this.readRegistry();
const existing = file.workspaces[workspaceId];
if (existing === undefined) {
throw new WorkspaceError(WorkspaceErrors.codes.WORKSPACE_NOT_FOUND, `workspace not found: ${workspaceId}`, {
workspaceId,
});
}
delete file.workspaces[workspaceId];
await this.writeRegistry(file);
return existing.root;
});
this.eventService.publish({ type: 'event.workspace.deleted', payload: { workspace_id: workspaceId, root } });
}
async resolveRoot(workspaceId: string): Promise<string> {
const entry = await this.runExclusive(async () => {
const file = await this.readRegistry();
return file.workspaces[workspaceId] ?? null;
});
if (entry === null) {
throw new WorkspaceError(WorkspaceErrors.codes.WORKSPACE_NOT_FOUND, `workspace not found: ${workspaceId}`, {
workspaceId,
});
}
return entry.root;
}
private async hydrate(workspaceId: string, entry: WorkspaceRegistryEntry): Promise<Workspace> {
const [{ is_git_repo, branch }, session_count] = await Promise.all([
detectGit(entry.root),
this.sessionStore.countActiveSessions(this.sessionsDir, entry.root),
]);
return {
id: workspaceId,
root: entry.root,
name: entry.name,
is_git_repo,
branch,
created_at: entry.created_at,
last_opened_at: entry.last_opened_at,
session_count,
};
}
private async readRegistry(): Promise<WorkspaceRegistryFile> {
let raw: string;
try {
raw = await fsp.readFile(this.registryPath, 'utf8');
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') {
return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {} };
}
throw err;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
this.logger.warn('workspaces.json malformed; treating as empty', {
path: this.registryPath,
err: String(err),
});
return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {} };
}
if (
typeof parsed !== 'object' ||
parsed === null ||
typeof (parsed as { workspaces?: unknown }).workspaces !== 'object' ||
(parsed as { workspaces?: unknown }).workspaces === null
) {
this.logger.warn('workspaces.json missing required keys; treating as empty', {
path: this.registryPath,
});
return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {} };
}
const rawWorkspaces = (parsed as { workspaces: Record<string, unknown> }).workspaces;
const workspaces: Record<string, WorkspaceRegistryEntry> = {};
for (const [id, value] of Object.entries(rawWorkspaces)) {
const entry = this.sanitizeEntry(value);
if (entry !== null) {
workspaces[id] = entry;
}
}
const version =
typeof (parsed as { version?: unknown }).version === 'number'
? (parsed as { version: number }).version
: WORKSPACE_REGISTRY_VERSION;
return { version, workspaces };
}
private sanitizeEntry(value: unknown): WorkspaceRegistryEntry | null {
if (typeof value !== 'object' || value === null) return null;
const v = value as Partial<WorkspaceRegistryEntry>;
if (
typeof v.root !== 'string' ||
typeof v.name !== 'string' ||
typeof v.created_at !== 'string' ||
typeof v.last_opened_at !== 'string'
) {
return null;
}
return {
root: v.root,
name: v.name,
created_at: v.created_at,
last_opened_at: v.last_opened_at,
};
}
private async writeRegistry(file: WorkspaceRegistryFile): Promise<void> {
await fsp.mkdir(dirname(this.registryPath), { recursive: true, mode: 0o700 });
const tmp = `${this.registryPath}.tmp`;
await fsp.writeFile(tmp, JSON.stringify(file, null, 2), 'utf8');
await fsp.rename(tmp, this.registryPath);
}
private runExclusive<T>(op: () => Promise<T>): Promise<T> {
const next = this.opQueue.then(op, op);
this.opQueue = next.then(
() => {},
() => {},
);
return next;
}
override dispose(): void {
if (this._store.isDisposed) return;
super.dispose();
}
}
export interface GitInfo {
is_git_repo: boolean;
branch: string | null;
}
export async function detectGit(root: string): Promise<GitInfo> {
let dotGit: Stats;
try {
dotGit = await fsp.lstat(join(root, '.git'));
} catch {
return { is_git_repo: false, branch: null };
}
let gitDir: string;
if (dotGit.isDirectory()) {
gitDir = join(root, '.git');
} else if (dotGit.isFile()) {
let text: string;
try {
text = await fsp.readFile(join(root, '.git'), 'utf8');
} catch {
return { is_git_repo: false, branch: null };
}
const m = /^gitdir:\s*(.+)$/m.exec(text);
if (m === null) return { is_git_repo: false, branch: null };
const ref = m[1] ?? '';
if (ref === '') return { is_git_repo: false, branch: null };
gitDir = ref.trim();
if (!gitDir.startsWith('/')) {
gitDir = join(root, gitDir);
}
} else {
return { is_git_repo: false, branch: null };
}
let head: string;
try {
head = (await fsp.readFile(join(gitDir, 'HEAD'), 'utf8')).trim();
} catch {
return { is_git_repo: true, branch: null };
}
const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
return { is_git_repo: true, branch: ref ? (ref[1] ?? null) : null };
}
registerScopedService(
LifecycleScope.Core,
IWorkspaceRegistry,
WorkspaceRegistryService,
InstantiationType.Delayed,
'workspace',
);

View file

@ -1,87 +0,0 @@
/**
* `workspace` domain (cross-cutting) `ISessionWorkspaceService` implementation.
*
* Turns the raw `Kaos` environments and additional roots owned by
* `ISessionKaosService` into workspace-relative path operations. Bound at
* Session scope one instance per session.
*/
import type { Kaos } from '@moonshot-ai/kaos';
import { InstantiationType } from '#/_base/di/extensions';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import {
type PathAccessOperation,
type PathClass,
assertPathAllowed,
canonicalizePath,
isWithinWorkspace,
} from '#/_base/tools/policies/path-access';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import { ISessionKaosService } from '#/kaos/kaos';
import { ILogService } from '#/log/log';
import { ISessionWorkspaceService } from './workspace';
export class SessionWorkspaceService extends Disposable implements ISessionWorkspaceService {
declare readonly _serviceBrand: undefined;
constructor(
@ISessionKaosService private readonly sessionKaos: ISessionKaosService,
@ILogService private readonly _log: ILogService,
) {
super();
}
private get kaos(): Kaos {
return this.sessionKaos.toolKaos;
}
private get pathClass(): PathClass {
return this.kaos.pathClass();
}
get workDir(): string {
return this.kaos.getcwd();
}
get additionalDirs(): readonly string[] {
return this.sessionKaos.additionalDirs;
}
resolve(rel: string): string {
return canonicalizePath(rel, this.workDir, this.pathClass);
}
isWithin(path: string): boolean {
return isWithinWorkspace(this.resolve(path), this.toConfig(), this.pathClass);
}
assertAllowed(path: string, op: PathAccessOperation): string {
return assertPathAllowed(path, this.workDir, this.toConfig(), {
mode: op,
pathClass: this.pathClass,
});
}
toConfig(): WorkspaceConfig {
return { workspaceDir: this.workDir, additionalDirs: this.additionalDirs };
}
addAdditionalDir(dir: string): void {
this.sessionKaos.addAdditionalDir(dir);
}
removeAdditionalDir(dir: string): void {
this.sessionKaos.removeAdditionalDir(dir);
}
}
registerScopedService(
LifecycleScope.Session,
ISessionWorkspaceService,
SessionWorkspaceService,
InstantiationType.Delayed,
'workspace',
);

View file

@ -0,0 +1,9 @@
/**
* `workspaceContext` domain barrel re-exports the workspace-context
* contract (`workspaceContext`) and its scoped service
* (`workspaceContextService`). Importing this barrel registers the
* `IWorkspaceContext` binding into the scope registry.
*/
export * from './workspaceContext';
export * from './workspaceContextService';

View file

@ -0,0 +1,29 @@
/**
* `workspaceContext` domain (L1) session workspace root and path access.
*
* Defines the `IWorkspaceContext` used by the Agent side to resolve relative
* paths against the session work directory and to enforce that file/process
* operations stay within the workspace (plus any additional dirs). Pure
* configuration + boundary it performs no IO. Session-scoped.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export type PathAccessOperation = 'read' | 'write' | 'execute';
export interface IWorkspaceContext {
readonly _serviceBrand: undefined;
readonly workDir: string;
readonly additionalDirs: readonly string[];
setWorkDir(workDir: string): void;
resolve(rel: string): string;
isWithin(absPath: string): boolean;
assertAllowed(absPath: string, op: PathAccessOperation): string;
addAdditionalDir(dir: string): void;
removeAdditionalDir(dir: string): void;
}
export const IWorkspaceContext: ServiceIdentifier<IWorkspaceContext> =
createDecorator<IWorkspaceContext>('workspaceContext');

View file

@ -0,0 +1,77 @@
/**
* `workspaceContext` domain (L1) `IWorkspaceContext` implementation.
*
* Holds the session work directory and additional dirs, resolves relative
* paths, and checks whether a path falls within the workspace. Bound at
* Session scope.
*/
import { isAbsolute, relative, resolve } from 'node:path';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IWorkspaceContext, type PathAccessOperation } from './workspaceContext';
export class WorkspaceContextService implements IWorkspaceContext {
declare readonly _serviceBrand: undefined;
private _workDir: string;
private _additionalDirs: string[] = [];
constructor(workDir: string = process.cwd()) {
this._workDir = resolve(workDir);
}
get workDir(): string {
return this._workDir;
}
get additionalDirs(): readonly string[] {
return this._additionalDirs;
}
setWorkDir(workDir: string): void {
this._workDir = resolve(workDir);
}
resolve(rel: string): string {
return isAbsolute(rel) ? resolve(rel) : resolve(this._workDir, rel);
}
isWithin(absPath: string): boolean {
const target = resolve(absPath);
if (target === this._workDir) return true;
const rel = relative(this._workDir, target);
if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)) return true;
return this._additionalDirs.some((dir) => {
const r = relative(dir, target);
return r === '' || (!r.startsWith('..') && !isAbsolute(r));
});
}
assertAllowed(absPath: string, op: PathAccessOperation): string {
const target = this.resolve(absPath);
if (!this.isWithin(target)) {
throw new Error(`Path outside workspace (${op}): ${target}`);
}
return target;
}
addAdditionalDir(dir: string): void {
const d = resolve(dir);
if (!this._additionalDirs.includes(d)) this._additionalDirs.push(d);
}
removeAdditionalDir(dir: string): void {
const d = resolve(dir);
this._additionalDirs = this._additionalDirs.filter((x) => x !== d);
}
}
registerScopedService(
LifecycleScope.Session,
IWorkspaceContext,
WorkspaceContextService,
InstantiationType.Delayed,
'workspaceContext',
);

View file

@ -0,0 +1,9 @@
/**
* `workspaceRegistry` domain barrel re-exports the workspace-registry
* contract (`workspaceRegistry`) and its scoped service
* (`workspaceRegistryService`). Importing this barrel registers the
* `IWorkspaceRegistry` binding into the scope registry.
*/
export * from './workspaceRegistry';
export * from './workspaceRegistryService';

View file

@ -0,0 +1,28 @@
/**
* `workspaceRegistry` domain (L1) process-wide catalog of known workspaces.
*
* Defines the `IWorkspaceRegistry` used by the program side to remember the
* folders the user has opened (backed by the app's own persistence). This is
* a host-side catalog, distinct from the session-scoped `workspaceContext`
* that describes one Agent's active work directory. Core-scoped.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface Workspace {
readonly id: string;
readonly root: string;
readonly name: string;
}
export interface IWorkspaceRegistry {
readonly _serviceBrand: undefined;
list(): Promise<readonly Workspace[]>;
get(id: string): Promise<Workspace | undefined>;
createOrTouch(root: string, name?: string): Promise<Workspace>;
delete(id: string): Promise<void>;
}
export const IWorkspaceRegistry: ServiceIdentifier<IWorkspaceRegistry> =
createDecorator<IWorkspaceRegistry>('workspaceRegistry');

View file

@ -0,0 +1,48 @@
/**
* `workspaceRegistry` domain (L1) `IWorkspaceRegistry` implementation.
*
* In-memory skeleton of the known-workspaces catalog; persistence through
* `IConfigStore` will replace the map in a later phase. Bound at Core scope.
*/
import { createHash } from 'node:crypto';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IWorkspaceRegistry, type Workspace } from './workspaceRegistry';
export class WorkspaceRegistryService implements IWorkspaceRegistry {
declare readonly _serviceBrand: undefined;
private readonly workspaces = new Map<string, Workspace>();
list(): Promise<readonly Workspace[]> {
return Promise.resolve([...this.workspaces.values()]);
}
get(id: string): Promise<Workspace | undefined> {
return Promise.resolve(this.workspaces.get(id));
}
createOrTouch(root: string, name?: string): Promise<Workspace> {
const id = createHash('sha256').update(root).digest('hex').slice(0, 12);
const existing = this.workspaces.get(id);
if (existing !== undefined) return Promise.resolve(existing);
const ws: Workspace = { id, root, name: name ?? root.split('/').pop() ?? root };
this.workspaces.set(id, ws);
return Promise.resolve(ws);
}
delete(id: string): Promise<void> {
this.workspaces.delete(id);
return Promise.resolve();
}
}
registerScopedService(
LifecycleScope.Core,
IWorkspaceRegistry,
WorkspaceRegistryService,
InstantiationType.Delayed,
'workspaceRegistry',
);

View file

@ -19,6 +19,11 @@ export function stubEnvironment(homeDir = '/tmp/kimi-home'): IEnvironmentService
_serviceBrand: undefined,
homeDir,
configPath: `${homeDir}/config.toml`,
sessionsDir: `${homeDir}/sessions`,
blobsDir: `${homeDir}/blobs`,
storeDir: `${homeDir}/store`,
cacheDir: `${homeDir}/cache`,
logsDir: `${homeDir}/logs`,
detect: () => Promise.reject(new Error('unused in test')),
};
}

View file

@ -1,30 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IFileStore } from '#/filestore';
import { FileStore } from '#/filestore/fileStoreService';
import { IKaosFactory } from '#/kaos';
describe('FileStore', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.stub(IKaosFactory, { _serviceBrand: undefined });
ix.set(IFileStore, new SyncDescriptor(FileStore));
});
afterEach(() => disposables.dispose());
it('put / get / delete', async () => {
const store = ix.get(IFileStore);
const data = new TextEncoder().encode('hello');
await store.put('k1', data);
expect(await store.get('k1')).toEqual(data);
await store.delete('k1');
expect(await store.get('k1')).toBeUndefined();
});
});

View file

@ -1,53 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LocalKaos } from '@moonshot-ai/kaos';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IFsService } from '#/fs';
import { FsService } from '#/fs/fsService';
import { ISessionKaosService } from '#/kaos';
import { SessionKaosService } from '#/kaos/sessionKaosService';
import { ILogService } from '#/log';
import { stubLog } from '../log/stubs';
describe('FsService', () => {
let dir: string;
let disposables: DisposableStore;
let ix: TestInstantiationService;
let fs: IFsService;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'fs-test-'));
const base = await LocalKaos.create();
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.stub(ILogService, stubLog());
ix.set(ISessionKaosService, new SyncDescriptor(SessionKaosService));
ix.set(IFsService, new SyncDescriptor(FsService));
const sessionKaos = ix.get(ISessionKaosService);
sessionKaos.setToolKaos(base.withCwd(dir));
fs = ix.get(IFsService);
});
afterEach(async () => {
disposables.dispose();
await rm(dir, { recursive: true, force: true });
});
it('write then read round-trips', async () => {
await fs.write('hello.txt', 'world');
expect(await fs.read('hello.txt')).toBe('world');
});
it('mkdir creates a directory', async () => {
await fs.mkdir('sub/deep');
const st = (await fs.stat('sub/deep')) as { isDirectory?: () => boolean };
expect(typeof st).toBe('object');
});
});

View file

@ -1,54 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { type Kaos, LocalKaos } from '@moonshot-ai/kaos';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { AgentKaos } from '#/kaos/agentKaos';
import { IKaosService, ISessionKaosService } from '#/kaos';
function stubSessionKaos(toolKaos: Kaos): ISessionKaosService {
return {
_serviceBrand: undefined,
toolKaos,
persistenceKaos: toolKaos,
systemContextKaos: toolKaos,
additionalDirs: [],
setToolKaos: () => {},
setPersistenceKaos: () => {},
addAdditionalDir: () => {},
removeAdditionalDir: () => {},
};
}
describe('AgentKaos', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
});
afterEach(() => disposables.dispose());
it('exposes kaos and cwd from the session toolKaos', async () => {
const base = await LocalKaos.create();
ix.set(ISessionKaosService, stubSessionKaos(base.withCwd('/tmp/tool')));
ix.set(IKaosService, new SyncDescriptor(AgentKaos));
const agentKaos = ix.get(IKaosService);
expect(agentKaos.kaos?.getcwd()).toBe('/tmp/tool');
expect(agentKaos.cwd).toBe('/tmp/tool');
});
it('chdir switches the active cwd', async () => {
const base = await LocalKaos.create();
ix.set(ISessionKaosService, stubSessionKaos(base.withCwd('/tmp/a')));
ix.set(IKaosService, new SyncDescriptor(AgentKaos));
const agentKaos = ix.get(IKaosService);
await agentKaos.chdir('/tmp/b');
expect(agentKaos.cwd).toBe('/tmp/b');
});
});

View file

@ -1,130 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LocalKaos } from '@moonshot-ai/kaos';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IEnvironmentService } from '#/environment';
import { ILogService } from '#/log';
import { stubLog } from '../log/stubs';
import { IKaosService, IKaosFactory, ISessionKaosService } from '#/kaos';
import { AgentKaos } from '#/kaos/agentKaos';
import { KaosFactory } from '#/kaos/kaosFactory';
import { SessionKaosService } from '#/kaos/sessionKaosService';
describe('KaosFactory', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.stub(IEnvironmentService, {});
ix.stub(ILogService, stubLog());
ix.set(IKaosFactory, new SyncDescriptor(KaosFactory));
});
afterEach(() => disposables.dispose());
it('creates a local kaos', async () => {
const factory = ix.get(IKaosFactory);
const kaos = await factory.create({ kind: 'local' });
expect(typeof kaos.getcwd()).toBe('string');
});
it('pins to the requested cwd', async () => {
const factory = ix.get(IKaosFactory);
const base = await LocalKaos.create();
const target = base.getcwd();
const kaos = await factory.create({ kind: 'local', cwd: target });
expect(kaos.getcwd()).toBe(target);
});
it('throws TODO for ssh', async () => {
const factory = ix.get(IKaosFactory);
await expect(factory.create({ kind: 'ssh', host: 'h' })).rejects.toThrow(/TODO/);
});
});
describe('SessionKaosService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.stub(ILogService, stubLog());
ix.set(ISessionKaosService, new SyncDescriptor(SessionKaosService));
});
afterEach(() => disposables.dispose());
async function make(): Promise<{ svc: ISessionKaosService; kaos: LocalKaos }> {
const svc = ix.get(ISessionKaosService);
const kaos = await LocalKaos.create();
svc.setToolKaos(kaos);
return { svc, kaos };
}
it('throws before setToolKaos', () => {
const svc = ix.get(ISessionKaosService);
expect(() => svc.toolKaos).toThrow(/before setToolKaos/);
});
it('persistenceKaos defaults to toolKaos', async () => {
const { svc, kaos } = await make();
expect(svc.persistenceKaos).toBe(kaos);
});
it('setPersistenceKaos overrides the default', async () => {
const { svc } = await make();
const other = await LocalKaos.create();
svc.setPersistenceKaos(other);
expect(svc.persistenceKaos).toBe(other);
});
it('additionalDirs add / dedupe / remove', async () => {
const { svc } = await make();
svc.addAdditionalDir('/a');
svc.addAdditionalDir('/b');
svc.addAdditionalDir('/a');
expect(svc.additionalDirs).toEqual(['/a', '/b']);
svc.removeAdditionalDir('/a');
expect(svc.additionalDirs).toEqual(['/b']);
});
it('systemContextKaos is pinned to the tool cwd', async () => {
const { svc } = await make();
const sys = svc.systemContextKaos;
expect(sys.getcwd()).toBe(svc.toolKaos.getcwd());
expect(sys).not.toBe(svc.toolKaos);
});
});
describe('AgentKaos', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.stub(ILogService, stubLog());
ix.set(ISessionKaosService, new SyncDescriptor(SessionKaosService));
ix.set(IKaosService, new SyncDescriptor(AgentKaos));
});
afterEach(() => disposables.dispose());
it('derives cwd from the session kaos and isolates chdir', async () => {
const session = ix.get(ISessionKaosService);
const base = await LocalKaos.create();
session.setToolKaos(base);
const agent = ix.get(IKaosService);
expect(agent.cwd).toBe(base.getcwd());
const next = base.withCwd('/').getcwd();
await agent.chdir('/');
expect(agent.cwd).toBe(next);
expect(session.toolKaos.getcwd()).toBe(base.getcwd());
});
});

View file

@ -6,7 +6,7 @@ import { TestInstantiationService } from '#/_base/di/test';
import { IContextMemory } from '#/contextMemory';
import { IContextInjector } from '../../src/contextInjector';
import { IEventSink } from '../../src/eventSink';
import { IKaosService } from '#/kaos';
import { IAgentFileSystem } from '#/agentFs';
import { IPlanService } from '#/plan';
import { PlanService } from '#/plan/planService';
import { IProfileService } from '#/profile';
@ -39,8 +39,12 @@ describe('PlanService', () => {
ix.stub(ITelemetryService, { track() {} });
ix.stub(IToolRegistry, { register: () => ({ dispose() {} }) });
ix.stub(IContextInjector, { register: () => ({ dispose() {} }) });
// kaos undefined → filesystem access short-circuits via optional chaining.
ix.stub(IKaosService, { kaos: undefined });
// Agent filesystem no-op — plan file IO is not exercised by these tests.
ix.stub(IAgentFileSystem, {
readText: () => Promise.resolve(''),
writeText: () => Promise.resolve(),
mkdir: () => Promise.resolve(),
});
// PlanService.currentCwd() reads profile.data().cwd.
ix.stub(IProfileService, 'data', () => ({ cwd: '/tmp' }));

View file

@ -1,44 +1,33 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LocalKaos } from '@moonshot-ai/kaos';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { ISessionKaosService } from '#/kaos';
import { SessionKaosService } from '#/kaos/sessionKaosService';
import { ILogService } from '#/log';
import { stubLog } from '../log/stubs';
import { ISessionMetaStore } from '#/sessionMetaStore';
import { SessionMetaStore } from '#/sessionMetaStore/sessionMetaStoreService';
import { ConfigStore, IConfigStore, IStorageService, InMemoryStorageService } from '#/storage';
import { stubLog } from '../log/stubs';
describe('SessionMetaStore', () => {
let dir: string;
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'session-meta-test-'));
const base = await LocalKaos.create();
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.stub(ILogService, stubLog());
ix.set(ISessionKaosService, new SyncDescriptor(SessionKaosService));
ix.set(IStorageService, new SyncDescriptor(InMemoryStorageService));
ix.set(IConfigStore, new SyncDescriptor(ConfigStore));
ix.set(ISessionMetaStore, new SyncDescriptor(SessionMetaStore));
const sessionKaos = ix.get(ISessionKaosService);
sessionKaos.setPersistenceKaos(base.withCwd(dir));
});
afterEach(async () => {
disposables.dispose();
await rm(dir, { recursive: true, force: true });
});
it('read returns {} when state.json is absent', async () => {
it('read returns {} when no document is stored yet', async () => {
const meta = ix.get(ISessionMetaStore);
expect(await meta.read()).toEqual({});
});
@ -48,8 +37,6 @@ describe('SessionMetaStore', () => {
await meta.write({ title: 'hello' });
await meta.write({ count: 1 });
// read() goes straight to disk, so even the same instance reflects the
// persisted state (no in-memory cache to mask a failed flush).
const fresh = ix.get(ISessionMetaStore);
expect(await fresh.read()).toEqual({ title: 'hello', count: 1 });
});

View file

@ -7,7 +7,7 @@ import { join } from 'node:path';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { IKaosFactory } from '#/kaos';
import { HostFileSystem, IHostFileSystem } from '#/hostFs';
import { ISessionStore } from '#/sessionStore/sessionStore';
import { SessionStore, encodeWorkDirKey } from '#/sessionStore/sessionStoreService';
@ -39,7 +39,7 @@ describe('SessionStore workspace helpers', () => {
});
function build(): ISessionStore {
const host = createScopedTestHost([stubPair(IKaosFactory, { _serviceBrand: undefined } as unknown as IKaosFactory)]);
const host = createScopedTestHost([stubPair(IHostFileSystem, new HostFileSystem())]);
disposeHost = () => host.dispose();
return host.core.accessor.get(ISessionStore);
}

View file

@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IConfigStore, IStorageService } from '#/storage';
import { ConfigStore } from '#/storage/configStore';
import { InMemoryStorageService } from '#/storage/inMemoryStorageService';
interface State {
readonly title?: string;
readonly count?: number;
}
describe('ConfigStore', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let storage: InMemoryStorageService;
let config: IConfigStore;
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
storage = new InMemoryStorageService();
ix.stub(IStorageService, storage);
ix.set(IConfigStore, new SyncDescriptor(ConfigStore));
config = ix.get(IConfigStore);
});
afterEach(() => disposables.dispose());
it('get returns undefined for a missing key', async () => {
expect(await config.get('session', 'state.json')).toBeUndefined();
});
it('set + get round-trips a value', async () => {
await config.set<State>('session', 'state.json', { title: 'hello', count: 1 });
expect(await config.get<State>('session', 'state.json')).toEqual({ title: 'hello', count: 1 });
});
it('set atomically replaces the previous value', async () => {
await config.set<State>('session', 'state.json', { title: 'old' });
await config.set<State>('session', 'state.json', { title: 'new', count: 2 });
expect(await config.get<State>('session', 'state.json')).toEqual({ title: 'new', count: 2 });
});
it('keys are independent', async () => {
await config.set<State>('session', 'a.json', { title: 'A' });
await config.set<State>('session', 'b.json', { title: 'B' });
expect(await config.get<State>('session', 'a.json')).toEqual({ title: 'A' });
expect(await config.get<State>('session', 'b.json')).toEqual({ title: 'B' });
});
it('scopes are isolated', async () => {
await config.set<State>('scope-a', 'k', { title: 'A' });
await config.set<State>('scope-b', 'k', { title: 'B' });
expect(await config.get<State>('scope-a', 'k')).toEqual({ title: 'A' });
expect(await config.get<State>('scope-b', 'k')).toEqual({ title: 'B' });
});
it('delete removes a key; missing delete is a no-op', async () => {
await config.set<State>('session', 'state.json', { title: 'x' });
await config.delete('session', 'state.json');
expect(await config.get('session', 'state.json')).toBeUndefined();
await expect(config.delete('session', 'state.json')).resolves.toBeUndefined();
});
it('list returns keys, optionally filtered by prefix', async () => {
await config.set('session', 'job-1', {});
await config.set('session', 'job-2', {});
await config.set('session', 'state.json', {});
expect((await config.list('session')).toSorted()).toEqual(['job-1', 'job-2', 'state.json']);
expect((await config.list('session', 'job-')).toSorted()).toEqual(['job-1', 'job-2']);
});
it('value is persisted through the underlying IStorageService', async () => {
await config.set<State>('session', 'state.json', { title: 'x' });
const raw = new TextDecoder().decode(await storage.read('session', 'state.json'));
expect(JSON.parse(raw)).toEqual({ title: 'x' });
});
});

View file

@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IRecordStore, IStorageService, RecordCorruptedError } from '#/storage';
import { InMemoryStorageService } from '#/storage/inMemoryStorageService';
import { RecordStore } from '#/storage/recordStore';
const enc = new TextEncoder();
interface Rec {
readonly n: number;
}
const SCOPE = 'agents/main';
const KEY = 'wire.jsonl';
describe('RecordStore', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let storage: InMemoryStorageService;
let record: IRecordStore;
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
storage = new InMemoryStorageService();
ix.stub(IStorageService, storage);
ix.set(IRecordStore, new SyncDescriptor(RecordStore));
record = ix.get(IRecordStore);
});
afterEach(() => disposables.dispose());
async function collect<R>(scope: string, key: string): Promise<readonly R[]> {
const out: R[] = [];
for await (const r of record.read<R>(scope, key)) {
out.push(r);
}
return out;
}
it('reads nothing from an empty log', async () => {
expect(await collect<Rec>(SCOPE, KEY)).toEqual([]);
});
it('append + read round-trips records in order', async () => {
record.append<Rec>(SCOPE, KEY, { n: 1 });
record.append<Rec>(SCOPE, KEY, { n: 2 });
record.append<Rec>(SCOPE, KEY, { n: 3 });
expect(await collect<Rec>(SCOPE, KEY)).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]);
});
it('batches many appends into a single durable append', async () => {
const spy = { count: 0 };
const original = storage.append.bind(storage);
storage.append = async (...args) => {
spy.count++;
return original(...args);
};
for (let n = 0; n < 10; n++) record.append<Rec>(SCOPE, KEY, { n });
await record.flush();
expect(await collect<Rec>(SCOPE, KEY)).toHaveLength(10);
expect(spy.count).toBe(1);
});
it('rewrite atomically replaces the whole log', async () => {
record.append<Rec>(SCOPE, KEY, { n: 1 });
record.append<Rec>(SCOPE, KEY, { n: 2 });
await record.flush();
await record.rewrite<Rec>(SCOPE, KEY, [{ n: 9 }, { n: 8 }]);
expect(await collect<Rec>(SCOPE, KEY)).toEqual([{ n: 9 }, { n: 8 }]);
});
it('logs addressed by different scope/key are independent', async () => {
record.append<Rec>('a', 'l', { n: 1 });
record.append<Rec>('b', 'l', { n: 2 });
expect(await collect<Rec>('a', 'l')).toEqual([{ n: 1 }]);
expect(await collect<Rec>('b', 'l')).toEqual([{ n: 2 }]);
});
it('drops a torn final line (crash mid-flush)', async () => {
// One complete record + a half-written trailing record with no newline.
const raw = `${JSON.stringify({ n: 1 })}\n${JSON.stringify({ n: 2 }).slice(0, 4)}`;
await storage.append(SCOPE, KEY, enc.encode(raw));
expect(await collect<Rec>(SCOPE, KEY)).toEqual([{ n: 1 }]);
});
it('throws RecordCorruptedError on a corrupted middle line', async () => {
const raw = `${JSON.stringify({ n: 1 })}\nGARBAGE\n${JSON.stringify({ n: 3 })}\n`;
await storage.append(SCOPE, KEY, enc.encode(raw));
await expect(collect<Rec>(SCOPE, KEY)).rejects.toBeInstanceOf(RecordCorruptedError);
});
});

View file

@ -0,0 +1,101 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { IStorageService } from '#/storage';
import { FileStorageService } from '#/storage/fileStorageService';
import { InMemoryStorageService } from '#/storage/inMemoryStorageService';
const enc = new TextEncoder();
const dec = new TextDecoder();
interface ServiceHandle {
readonly service: IStorageService;
readonly cleanup?: () => Promise<void>;
}
function storageServiceSuite(
name: string,
setup: () => Promise<ServiceHandle>,
): void {
describe(name, () => {
let service: IStorageService;
let cleanup: (() => Promise<void>) | undefined;
beforeEach(async () => {
const handle = await setup();
service = handle.service;
cleanup = handle.cleanup;
});
afterEach(async () => {
await cleanup?.();
});
it('read returns undefined for a missing key', async () => {
expect(await service.read('s', 'missing')).toBeUndefined();
});
it('write then read round-trips bytes', async () => {
await service.write('s', 'k', enc.encode('hello'));
expect(dec.decode(await service.read('s', 'k'))).toBe('hello');
});
it('write replaces the whole value (not append)', async () => {
await service.write('s', 'k', enc.encode('first'));
await service.write('s', 'k', enc.encode('second'));
expect(dec.decode(await service.read('s', 'k'))).toBe('second');
});
it('append extends bytes in order', async () => {
await service.append('s', 'k', enc.encode('a'));
await service.append('s', 'k', enc.encode('b'));
await service.append('s', 'k', enc.encode('c'));
expect(dec.decode(await service.read('s', 'k'))).toBe('abc');
});
it('append on a missing key creates it', async () => {
await service.append('s', 'k', enc.encode('created'));
expect(dec.decode(await service.read('s', 'k'))).toBe('created');
});
it('scopes are isolated', async () => {
await service.write('scope-a', 'k', enc.encode('A'));
await service.write('scope-b', 'k', enc.encode('B'));
expect(dec.decode(await service.read('scope-a', 'k'))).toBe('A');
expect(dec.decode(await service.read('scope-b', 'k'))).toBe('B');
});
it('list returns keys, optionally filtered by prefix', async () => {
await service.write('s', 'alpha', enc.encode('1'));
await service.write('s', 'beta', enc.encode('2'));
await service.write('s', 'alphabet', enc.encode('3'));
expect((await service.list('s')).toSorted()).toEqual(['alpha', 'alphabet', 'beta']);
expect((await service.list('s', 'alpha')).toSorted()).toEqual(['alpha', 'alphabet']);
});
it('list on a missing scope returns []', async () => {
expect(await service.list('nope')).toEqual([]);
});
it('delete removes a key; deleting a missing key is a no-op', async () => {
await service.write('s', 'k', enc.encode('x'));
await service.delete('s', 'k');
expect(await service.read('s', 'k')).toBeUndefined();
await expect(service.delete('s', 'k')).resolves.toBeUndefined();
});
});
}
storageServiceSuite('InMemoryStorageService', async () => ({
service: new InMemoryStorageService(),
}));
storageServiceSuite('FileStorageService', async () => {
const dir = await mkdtemp(join(tmpdir(), 'storage-service-test-'));
return {
service: new FileStorageService(dir),
cleanup: () => rm(dir, { recursive: true, force: true }),
};
});

View file

@ -1,48 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LocalKaos } from '@moonshot-ai/kaos';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { ISessionKaosService } from '#/kaos';
import { SessionKaosService } from '#/kaos/sessionKaosService';
import { ILogService } from '#/log';
import { stubLog } from '../log/stubs';
import { ITerminalService } from '#/terminal';
import { TerminalService } from '#/terminal/terminalService';
describe('TerminalService', () => {
let dir: string;
let disposables: DisposableStore;
let ix: TestInstantiationService;
let terminal: ITerminalService;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'term-test-'));
const base = await LocalKaos.create();
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.stub(ILogService, stubLog());
ix.set(ISessionKaosService, new SyncDescriptor(SessionKaosService));
ix.set(ITerminalService, new SyncDescriptor(TerminalService));
const sessionKaos = ix.get(ISessionKaosService);
sessionKaos.setToolKaos(base.withCwd(dir));
terminal = ix.get(ITerminalService);
});
afterEach(async () => {
disposables.dispose();
await rm(dir, { recursive: true, force: true });
});
it('spawn returns a handle and kill terminates the process', async () => {
const handle = await terminal.spawn('sleep', ['10']);
expect(typeof handle.id).toBe('string');
await terminal.kill(handle.id);
});
});

View file

@ -1,131 +0,0 @@
import type { Kaos } from '@moonshot-ai/kaos';
import { beforeEach, describe, expect, it } from 'vitest';
import { InstantiationType } from '#/_base/di/extensions';
import {
LifecycleScope,
_clearScopedRegistryForTests,
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { PathSecurityError } from '#/_base/tools/policies/path-access';
import { ISessionKaosService } from '#/kaos/kaos';
import { ILogService } from '#/log/log';
import { ISessionWorkspaceService } from '#/workspace/workspace';
import { SessionWorkspaceService } from '#/workspace/workspaceService';
import { stubLog } from '../log/stubs';
function fakeKaos(cwd: string): Kaos {
return {
pathClass: () => 'posix',
getcwd: () => cwd,
gethome: () => '/home/user',
} as unknown as Kaos;
}
function stubSessionKaos(cwd: string, initialAdditional: readonly string[] = []): ISessionKaosService {
const kaos = fakeKaos(cwd);
const additional = [...initialAdditional];
return {
_serviceBrand: undefined,
toolKaos: kaos,
persistenceKaos: kaos,
systemContextKaos: kaos,
get additionalDirs() {
return additional;
},
setToolKaos: () => {},
setPersistenceKaos: () => {},
addAdditionalDir: (dir) => {
if (!additional.includes(dir)) additional.push(dir);
},
removeAdditionalDir: (dir) => {
const idx = additional.indexOf(dir);
if (idx >= 0) additional.splice(idx, 1);
},
};
}
describe('SessionWorkspaceService', () => {
beforeEach(() => {
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.Session,
ISessionWorkspaceService,
SessionWorkspaceService,
InstantiationType.Delayed,
'workspace',
);
});
function build(sessionKaos: ISessionKaosService): ISessionWorkspaceService {
const host = createScopedTestHost([stubPair(ILogService, stubLog())]);
const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionKaosService, sessionKaos)]);
return session.accessor.get(ISessionWorkspaceService);
}
it('reflects the session kaos cwd and additional dirs', () => {
const ws = build(stubSessionKaos('/repo', ['/extra']));
expect(ws.workDir).toBe('/repo');
expect(ws.additionalDirs).toEqual(['/extra']);
});
it('resolves a relative path against workDir', () => {
const ws = build(stubSessionKaos('/repo'));
expect(ws.resolve('src/index.ts')).toBe('/repo/src/index.ts');
});
it('normalizes an absolute path', () => {
const ws = build(stubSessionKaos('/repo'));
expect(ws.resolve('/repo/a/../b')).toBe('/repo/b');
});
it('checks whether a path is within the workspace', () => {
const ws = build(stubSessionKaos('/repo', ['/extra']));
expect(ws.isWithin('src/index.ts')).toBe(true);
expect(ws.isWithin('/repo/sub/file')).toBe(true);
expect(ws.isWithin('/extra/file')).toBe(true);
expect(ws.isWithin('/elsewhere/file')).toBe(false);
});
it('allows a path inside the workspace', () => {
const ws = build(stubSessionKaos('/repo'));
expect(ws.assertAllowed('src/index.ts', 'read')).toBe('/repo/src/index.ts');
});
it('rejects a relative path that escapes the workspace', () => {
const ws = build(stubSessionKaos('/repo'));
expect(() => ws.assertAllowed('../outside', 'read')).toThrowError(PathSecurityError);
try {
ws.assertAllowed('../outside', 'read');
} catch (error) {
expect((error as PathSecurityError).code).toBe('PATH_OUTSIDE_WORKSPACE');
}
});
it('rejects a sensitive file', () => {
const ws = build(stubSessionKaos('/repo'));
expect(() => ws.assertAllowed('.env', 'read')).toThrowError(PathSecurityError);
try {
ws.assertAllowed('.env', 'read');
} catch (error) {
expect((error as PathSecurityError).code).toBe('PATH_SENSITIVE');
}
});
it('snapshots a WorkspaceConfig value object', () => {
const ws = build(stubSessionKaos('/repo', ['/extra']));
expect(ws.toConfig()).toEqual({ workspaceDir: '/repo', additionalDirs: ['/extra'] });
});
it('delegates additional-dir mutation to the session kaos', () => {
const ws = build(stubSessionKaos('/repo'));
ws.addAdditionalDir('/extra');
expect(ws.additionalDirs).toEqual(['/extra']);
ws.addAdditionalDir('/extra'); // idempotent
expect(ws.additionalDirs).toEqual(['/extra']);
ws.removeAdditionalDir('/extra');
expect(ws.additionalDirs).toEqual([]);
});
});

View file

@ -1,113 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import { join } from 'node:path';
import type { Workspace } from '@moonshot-ai/protocol';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { WorkspaceErrors } from '#/workspace/errors';
import { IWorkspaceRegistry } from '#/workspace/workspaceRegistry';
import { IWorkspaceFsService } from '#/workspace/workspaceFs';
import { WorkspaceFsService } from '#/workspace/workspaceFsService';
function stubRegistry(workspaces: Workspace[]): IWorkspaceRegistry {
return {
_serviceBrand: undefined,
list: async () => workspaces,
get: async () => {
throw new Error('not implemented in test');
},
createOrTouch: async () => {
throw new Error('not implemented in test');
},
update: async () => {
throw new Error('not implemented in test');
},
delete: async () => {},
resolveRoot: async () => {
throw new Error('not implemented in test');
},
};
}
function fakeWorkspace(root: string): Workspace {
return {
id: 'wd_fake_000000000000',
root,
name: root.split('/').pop() ?? root,
is_git_repo: false,
branch: null,
created_at: new Date(0).toISOString(),
last_opened_at: new Date(0).toISOString(),
session_count: 0,
};
}
describe('WorkspaceFsService', () => {
let tmpDir: string;
let disposeHost: (() => void) | undefined;
beforeEach(async () => {
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.Core,
IWorkspaceFsService,
WorkspaceFsService,
InstantiationType.Delayed,
'workspace',
);
// Canonicalize: browse() realpaths the target, so assertions must compare
// against the real path (macOS maps /var/folders -> /private/var/folders).
tmpDir = await fsp.realpath(await fsp.mkdtemp(join(os.tmpdir(), 'ws-fs-')));
});
afterEach(async () => {
disposeHost?.();
disposeHost = undefined;
await fsp.rm(tmpDir, { recursive: true, force: true });
});
function build(workspaces: Workspace[] = []): IWorkspaceFsService {
const host = createScopedTestHost([stubPair(IWorkspaceRegistry, stubRegistry(workspaces))]);
disposeHost = () => host.dispose();
return host.core.accessor.get(IWorkspaceFsService);
}
it('lists subdirectories of an absolute path', async () => {
await fsp.mkdir(join(tmpDir, 'alpha'));
await fsp.mkdir(join(tmpDir, 'beta'));
await fsp.writeFile(join(tmpDir, 'not-a-dir.txt'), 'x');
const fs = build();
const result = await fs.browse(tmpDir);
expect(result.path).toBe(tmpDir);
expect(result.entries.map((e) => e.name).sort()).toEqual(['alpha', 'beta']);
expect(result.entries.every((e) => e.is_dir)).toBe(true);
});
it('throws validation.failed for a relative path', async () => {
const fs = build();
await expect(fs.browse('relative/path')).rejects.toMatchObject({
code: WorkspaceErrors.codes.PATH_NOT_ABSOLUTE,
});
});
it('throws fs.path_not_found for a missing path', async () => {
const fs = build();
await expect(fs.browse(join(tmpDir, 'missing'))).rejects.toMatchObject({
code: WorkspaceErrors.codes.PATH_NOT_FOUND,
});
});
it('returns $HOME plus recent roots from the registry on home()', async () => {
const roots = ['/repo/one', '/repo/two', '/repo/three'];
const fs = build(roots.map(fakeWorkspace));
const result = await fs.home();
expect(result.home).toBe(os.homedir());
expect(result.recent_roots).toEqual(roots);
});
});

View file

@ -1,173 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import { join } from 'node:path';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { IEnvironmentService } from '#/environment/environment';
import { IEventService, type ProtocolEvent } from '#/event/event';
import { ILogService } from '#/log/log';
import { ISessionStore } from '#/sessionStore/sessionStore';
import { WorkspaceError, WorkspaceErrors } from '#/workspace/errors';
import { IWorkspaceRegistry } from '#/workspace/workspaceRegistry';
import { WorkspaceRegistryService } from '#/workspace/workspaceRegistryService';
import { stubLog } from '../log/stubs';
function stubEnv(homeDir: string): IEnvironmentService {
return {
_serviceBrand: undefined,
homeDir,
configPath: join(homeDir, 'config.toml'),
detect: async () => {
throw new Error('not implemented in test');
},
};
}
function stubEventBus(): { service: IEventService; events: ProtocolEvent[] } {
const events: ProtocolEvent[] = [];
return {
events,
service: {
_serviceBrand: undefined,
publish: (e) => {
events.push(e);
},
subscribe: () => ({ dispose() {} }),
},
};
}
function stubSessionStore(sessionCount: () => number): ISessionStore {
return {
_serviceBrand: undefined,
sessionDir: (root, workDir, sessionId) => join(root, workDir.replace(/[^a-z0-9]/gi, '_'), sessionId),
workspaceIdFor: (workDir) => `wd_${workDir.replace(/[^a-z0-9]/gi, '_').slice(0, 24)}`,
countActiveSessions: async () => sessionCount(),
};
}
describe('WorkspaceRegistryService', () => {
let homeDir: string;
let rootDir: string;
let disposeHost: (() => void) | undefined;
beforeEach(async () => {
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.Core,
IWorkspaceRegistry,
WorkspaceRegistryService,
InstantiationType.Delayed,
'workspace',
);
homeDir = await fsp.mkdtemp(join(os.tmpdir(), 'ws-reg-home-'));
// Canonicalize: createOrTouch realpaths the root, so assertions must compare
// against the real path (macOS maps /var/folders -> /private/var/folders).
rootDir = await fsp.realpath(await fsp.mkdtemp(join(os.tmpdir(), 'ws-reg-root-')));
});
afterEach(async () => {
disposeHost?.();
disposeHost = undefined;
await fsp.rm(homeDir, { recursive: true, force: true });
await fsp.rm(rootDir, { recursive: true, force: true });
});
function build(sessionCount: () => number = () => 0) {
const eventStub = stubEventBus();
const host = createScopedTestHost([
stubPair(IEnvironmentService, stubEnv(homeDir)),
stubPair(ILogService, stubLog()),
stubPair(IEventService, eventStub.service),
stubPair(ISessionStore, stubSessionStore(sessionCount)),
]);
disposeHost = () => host.dispose();
return { registry: host.core.accessor.get(IWorkspaceRegistry), events: eventStub.events };
}
it('creates a new workspace and lists it', async () => {
const { registry } = build();
const ws = await registry.createOrTouch(rootDir, 'my-repo');
expect(ws.name).toBe('my-repo');
expect(ws.root).toBe(rootDir);
expect(ws.is_git_repo).toBe(false);
const all = await registry.list();
expect(all).toHaveLength(1);
expect(all[0]!.id).toBe(ws.id);
});
it('is idempotent on the same root and bumps last_opened_at', async () => {
const { registry } = build();
const first = await registry.createOrTouch(rootDir);
const second = await registry.createOrTouch(rootDir);
expect(second.id).toBe(first.id);
expect(new Date(second.last_opened_at).getTime()).toBeGreaterThanOrEqual(
new Date(first.last_opened_at).getTime(),
);
expect(await registry.list()).toHaveLength(1);
});
it('throws fs.path_not_found when root does not exist', async () => {
const { registry } = build();
const missing = join(rootDir, 'does-not-exist');
await expect(registry.createOrTouch(missing)).rejects.toMatchObject({
code: WorkspaceErrors.codes.PATH_NOT_FOUND,
});
});
it('renames a workspace', async () => {
const { registry } = build();
const ws = await registry.createOrTouch(rootDir, 'old');
const updated = await registry.update(ws.id, { name: 'new' });
expect(updated.name).toBe('new');
expect((await registry.get(ws.id)).name).toBe('new');
});
it('deletes a workspace', async () => {
const { registry } = build();
const ws = await registry.createOrTouch(rootDir);
await registry.delete(ws.id);
expect(await registry.list()).toHaveLength(0);
await expect(registry.get(ws.id)).rejects.toMatchObject({
code: WorkspaceErrors.codes.WORKSPACE_NOT_FOUND,
});
});
it('resolves a workspace id back to its root', async () => {
const { registry } = build();
const ws = await registry.createOrTouch(rootDir);
expect(await registry.resolveRoot(ws.id)).toBe(rootDir);
});
it('throws workspace.not_found for an unknown id', async () => {
const { registry } = build();
await expect(registry.get('wd_unknown_000000000000')).rejects.toBeInstanceOf(WorkspaceError);
await expect(registry.get('wd_unknown_000000000000')).rejects.toMatchObject({
code: WorkspaceErrors.codes.WORKSPACE_NOT_FOUND,
});
});
it('publishes a created event only on first registration', async () => {
const { registry, events } = build();
await registry.createOrTouch(rootDir);
await registry.createOrTouch(rootDir);
const created = events.filter((e) => e.type === 'event.workspace.created');
expect(created).toHaveLength(1);
expect((created[0]!.payload as { workspace: { root: string } }).workspace.root).toBe(rootDir);
});
it('reports session_count from the session store', async () => {
let count = 3;
const { registry } = build(() => count);
const ws = await registry.createOrTouch(rootDir);
expect(ws.session_count).toBe(3);
count = 7;
expect((await registry.get(ws.id)).session_count).toBe(7);
});
});