diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml index e0220b822..a7e6f3f7d 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.puml +++ b/packages/agent-core-v2/docs/di-scope-domains.puml @@ -23,7 +23,9 @@ package "Core scope (process-wide)" #EAF3FB { rectangle "telemetry\nCore\n ITelemetryService" as telemetry #D6EAF8 rectangle "event\nCore\n IEventService" as event #D6EAF8 rectangle "gateway\nCore\n IScopeRegistry\n IRestGateway\n IWSGateway\n IWSBroadcastService" as gateway #D6EAF8 - rectangle "filestore\nCore\n IFileStore" as filestore #D6EAF8 + rectangle "hostFs\nCore\n IHostFileSystem" as hostFs #D6EAF8 + rectangle "workspaceRegistry\nCore\n IWorkspaceRegistry" as workspaceRegistry #D6EAF8 + rectangle "hostFolderBrowser\nCore\n IHostFolderBrowser" as hostFolderBrowser #D6EAF8 rectangle "auth\nCore\n IOAuthService\n IAuthSummaryService" as auth #D6EAF8 rectangle "provider\nCore\n IProviderService" as provider #D6EAF8 rectangle "flag\nCore\n IFlagService" as flag #D6EAF8 @@ -31,12 +33,13 @@ package "Core scope (process-wide)" #EAF3FB { package "Session scope (per session)" #EAFAF1 { rectangle "session\nSession\n ISessionService" as session #D5F5E3 - rectangle "fs\nSession\n IFsService\n IFsSearchService\n IFsGitService\n IFsWatcher" as fs #D5F5E3 + rectangle "workspaceContext\nSession\n IWorkspaceContext" as workspaceContext #D5F5E3 + rectangle "agentFs\nSession\n IAgentFileSystem\n IFileSystemBackend" as agentFs #D5F5E3 rectangle "approval\nSession\n IApprovalService" as approval #D5F5E3 rectangle "question\nSession\n IQuestionService" as question #D5F5E3 rectangle "session-activity\nSession\n ISessionActivity" as session_activity #D5F5E3 rectangle "subagentHost\nSession\n ISubagentHost" as subagentHost #D5F5E3 - rectangle "terminal\nSession\n ITerminalService" as terminal #D5F5E3 + rectangle "process\nSession\n IProcessRunner\n IProcessBackend" as process #D5F5E3 rectangle "agent-lifecycle\nSession\n IAgentLifecycleService" as agent_lifecycle #D5F5E3 rectangle "session-context\nSession\n ISessionContext (seed)" as session_context #D5F5E3 } @@ -86,8 +89,6 @@ package "Multi-scope" #F5EEF8 { rectangle "kosong\nMulti\n IProtocolHandlerRegistry(C)\n IProviderManager(S, seed)" as kosong #E8DAEF rectangle "records\nMulti\n ISessionStore(C)\n ISessionMetaStore(S)\n IAgentRecords(A)" as records #E8DAEF rectangle "tool\nMulti\n IToolDefinitionRegistry(C)\n IToolService(A)" as tool #E8DAEF - rectangle "kaos\nMulti\n IKaosFactory(C)\n ISessionKaosService(S)\n IKaosService / IAgentKaos(A)" as kaos #E8DAEF - rectangle "workspace\nMulti\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 diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index dd36a4508..03f7c9cb5 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -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', diff --git a/packages/agent-core-v2/src/agentFs/agentFs.ts b/packages/agent-core-v2/src/agentFs/agentFs.ts new file mode 100644 index 000000000..8fc7cd06c --- /dev/null +++ b/packages/agent-core-v2/src/agentFs/agentFs.ts @@ -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; + writeText(path: string, data: string): Promise; + readBytes(path: string): Promise; + writeBytes(path: string, data: Uint8Array): Promise; + stat(path: string): Promise; + readdir(path: string): Promise; + glob(pattern: string): Promise; + mkdir(path: string): Promise; + withCwd(cwd: string): IAgentFileSystem; +} + +export const IAgentFileSystem: ServiceIdentifier = + createDecorator('agentFileSystem'); + +export interface IFileSystemBackend { + readonly _serviceBrand: undefined; + + readText(absPath: string): Promise; + writeText(absPath: string, data: string): Promise; + readBytes(absPath: string): Promise; + writeBytes(absPath: string, data: Uint8Array): Promise; + stat(absPath: string): Promise; + readdir(absPath: string): Promise; + glob(absDir: string, pattern: string): Promise; + mkdir(absPath: string): Promise; +} + +export const IFileSystemBackend: ServiceIdentifier = + createDecorator('fileSystemBackend'); diff --git a/packages/agent-core-v2/src/agentFs/agentFsService.ts b/packages/agent-core-v2/src/agentFs/agentFsService.ts new file mode 100644 index 000000000..09b2dcab9 --- /dev/null +++ b/packages/agent-core-v2/src/agentFs/agentFsService.ts @@ -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 { + return this.backend.readText(this.abs(path)); + } + + writeText(path: string, data: string): Promise { + return this.backend.writeText(this.abs(path), data); + } + + readBytes(path: string): Promise { + return this.backend.readBytes(this.abs(path)); + } + + writeBytes(path: string, data: Uint8Array): Promise { + return this.backend.writeBytes(this.abs(path), data); + } + + stat(path: string): Promise { + return this.backend.stat(this.abs(path)); + } + + readdir(path: string): Promise { + return this.backend.readdir(this.abs(path)); + } + + glob(pattern: string): Promise { + return this.backend.glob(this.cwd, pattern); + } + + mkdir(path: string): Promise { + return this.backend.mkdir(this.abs(path)); + } + + withCwd(_cwd: string): IAgentFileSystem { + throw new NotImplementedError('agentFs.withCwd'); + } +} + +registerScopedService( + LifecycleScope.Session, + IAgentFileSystem, + AgentFileSystem, + InstantiationType.Delayed, + 'agentFs', +); diff --git a/packages/agent-core-v2/src/agentFs/index.ts b/packages/agent-core-v2/src/agentFs/index.ts new file mode 100644 index 000000000..77946c5b6 --- /dev/null +++ b/packages/agent-core-v2/src/agentFs/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/agentFs/localFileSystemBackend.ts b/packages/agent-core-v2/src/agentFs/localFileSystemBackend.ts new file mode 100644 index 000000000..eda7c773e --- /dev/null +++ b/packages/agent-core-v2/src/agentFs/localFileSystemBackend.ts @@ -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 { + return this.hostFs.readText(absPath); + } + + writeText(absPath: string, data: string): Promise { + return this.hostFs.writeText(absPath, data); + } + + readBytes(absPath: string): Promise { + return this.hostFs.readBytes(absPath); + } + + writeBytes(absPath: string, data: Uint8Array): Promise { + return this.hostFs.writeBytes(absPath, data); + } + + async stat(absPath: string): Promise { + const s = await this.hostFs.stat(absPath); + return { isFile: s.isFile, isDirectory: s.isDirectory, size: s.size }; + } + + async readdir(absPath: string): Promise { + const entries = await this.hostFs.readdir(absPath); + return entries.map((e) => e.name); + } + + glob(_absDir: string, _pattern: string): Promise { + throw new NotImplementedError('localFileSystemBackend.glob'); + } + + mkdir(absPath: string): Promise { + return this.hostFs.mkdir(absPath, { recursive: true }); + } +} + +registerScopedService( + LifecycleScope.Session, + IFileSystemBackend, + LocalFileSystemBackend, + InstantiationType.Delayed, + 'agentFs', +); diff --git a/packages/agent-core-v2/src/agentFs/sshFileSystemBackend.ts b/packages/agent-core-v2/src/agentFs/sshFileSystemBackend.ts new file mode 100644 index 000000000..7fbba92a8 --- /dev/null +++ b/packages/agent-core-v2/src/agentFs/sshFileSystemBackend.ts @@ -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 { + throw new NotImplementedError('sshFileSystemBackend'); + } + + writeText(_absPath: string, _data: string): Promise { + throw new NotImplementedError('sshFileSystemBackend'); + } + + readBytes(_absPath: string): Promise { + throw new NotImplementedError('sshFileSystemBackend'); + } + + writeBytes(_absPath: string, _data: Uint8Array): Promise { + throw new NotImplementedError('sshFileSystemBackend'); + } + + stat(_absPath: string): Promise { + throw new NotImplementedError('sshFileSystemBackend'); + } + + readdir(_absPath: string): Promise { + throw new NotImplementedError('sshFileSystemBackend'); + } + + glob(_absDir: string, _pattern: string): Promise { + throw new NotImplementedError('sshFileSystemBackend'); + } + + mkdir(_absPath: string): Promise { + throw new NotImplementedError('sshFileSystemBackend'); + } +} diff --git a/packages/agent-core-v2/src/environment/environment.ts b/packages/agent-core-v2/src/environment/environment.ts index a48f090a2..ef6f2b537 100644 --- a/packages/agent-core-v2/src/environment/environment.ts +++ b/packages/agent-core-v2/src/environment/environment.ts @@ -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; } diff --git a/packages/agent-core-v2/src/environment/environmentService.ts b/packages/agent-core-v2/src/environment/environmentService.ts index cdd041ddc..7f0458dd8 100644 --- a/packages/agent-core-v2/src/environment/environmentService.ts +++ b/packages/agent-core-v2/src/environment/environmentService.ts @@ -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; 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 { diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index 4af35fba1..deb9fc327 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -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; diff --git a/packages/agent-core-v2/src/filestore/fileStoreService.ts b/packages/agent-core-v2/src/filestore/fileStoreService.ts deleted file mode 100644 index 5935ded78..000000000 --- a/packages/agent-core-v2/src/filestore/fileStoreService.ts +++ /dev/null @@ -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(); - - constructor(@IKaosFactory _kaosFactory: IKaosFactory) {} - - put(key: string, data: Uint8Array): Promise { - this.blobs.set(key, data); - return Promise.resolve(); - } - get(key: string): Promise { - return Promise.resolve(this.blobs.get(key)); - } - delete(key: string): Promise { - this.blobs.delete(key); - return Promise.resolve(); - } -} - -registerScopedService(LifecycleScope.Core, IFileStore, FileStore, InstantiationType.Delayed, 'filestore'); diff --git a/packages/agent-core-v2/src/filestore/filestore.ts b/packages/agent-core-v2/src/filestore/filestore.ts deleted file mode 100644 index f5b1e7f9e..000000000 --- a/packages/agent-core-v2/src/filestore/filestore.ts +++ /dev/null @@ -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; - get(key: string): Promise; - delete(key: string): Promise; -} - -export const IFileStore: ServiceIdentifier = - createDecorator('fileStore'); diff --git a/packages/agent-core-v2/src/filestore/index.ts b/packages/agent-core-v2/src/filestore/index.ts deleted file mode 100644 index c8ff6d2fb..000000000 --- a/packages/agent-core-v2/src/filestore/index.ts +++ /dev/null @@ -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'; diff --git a/packages/agent-core-v2/src/fs/fs.ts b/packages/agent-core-v2/src/fs/fs.ts deleted file mode 100644 index 9955cf932..000000000 --- a/packages/agent-core-v2/src/fs/fs.ts +++ /dev/null @@ -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; - write(path: string, content: string): Promise; - stat(path: string): Promise; - mkdir(path: string): Promise; -} - -export const IFsService: ServiceIdentifier = - createDecorator('fsService'); - -export interface IFsSearchService { - readonly _serviceBrand: undefined; - grep(pattern: string, path: string): Promise; - glob(pattern: string): Promise; -} - -export const IFsSearchService: ServiceIdentifier = - createDecorator('fsSearchService'); - -export interface IFsGitService { - readonly _serviceBrand: undefined; - status(cwd: string): Promise; - diff(cwd: string): Promise; - log(cwd: string): Promise; -} - -export const IFsGitService: ServiceIdentifier = - createDecorator('fsGitService'); - -export interface IFsWatcher { - readonly _serviceBrand: undefined; - watch(path: string): void; - unwatch(path: string): void; -} - -export const IFsWatcher: ServiceIdentifier = - createDecorator('fsWatcher'); diff --git a/packages/agent-core-v2/src/fs/fsService.ts b/packages/agent-core-v2/src/fs/fsService.ts deleted file mode 100644 index 6be6a0af6..000000000 --- a/packages/agent-core-v2/src/fs/fsService.ts +++ /dev/null @@ -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 { - 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 { - return this.kaos.readText(path); - } - write(path: string, content: string): Promise { - return this.kaos.writeText(path, content).then(() => undefined); - } - stat(path: string): Promise { - return this.kaos.stat(path); - } - async mkdir(path: string): Promise { - 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 { - 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 { - 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 { - const proc = await this.kaos.exec('git', ...args); - const out = await readAll(proc.stdout); - await proc.wait(); - return out; - } - - status(_cwd: string): Promise { - return this.git('status', '--short'); - } - diff(_cwd: string): Promise { - return this.git('diff'); - } - async log(_cwd: string): Promise { - 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(); - - 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'); diff --git a/packages/agent-core-v2/src/fs/index.ts b/packages/agent-core-v2/src/fs/index.ts deleted file mode 100644 index 442b55805..000000000 --- a/packages/agent-core-v2/src/fs/index.ts +++ /dev/null @@ -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'; diff --git a/packages/agent-core-v2/src/hostFolderBrowser/hostFolderBrowser.ts b/packages/agent-core-v2/src/hostFolderBrowser/hostFolderBrowser.ts new file mode 100644 index 000000000..75a6c3f52 --- /dev/null +++ b/packages/agent-core-v2/src/hostFolderBrowser/hostFolderBrowser.ts @@ -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; + home(): Promise; +} + +export const IHostFolderBrowser: ServiceIdentifier = + createDecorator('hostFolderBrowser'); diff --git a/packages/agent-core-v2/src/hostFolderBrowser/hostFolderBrowserService.ts b/packages/agent-core-v2/src/hostFolderBrowser/hostFolderBrowserService.ts new file mode 100644 index 000000000..5c0a0b6d9 --- /dev/null +++ b/packages/agent-core-v2/src/hostFolderBrowser/hostFolderBrowserService.ts @@ -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 { + const path = resolve(absPath ?? homedir()); + const entries = await this.hostFs.readdir(path); + return { path, entries }; + } + + home(): Promise { + return Promise.resolve(homedir()); + } +} + +registerScopedService( + LifecycleScope.Core, + IHostFolderBrowser, + HostFolderBrowser, + InstantiationType.Delayed, + 'hostFolderBrowser', +); diff --git a/packages/agent-core-v2/src/hostFolderBrowser/index.ts b/packages/agent-core-v2/src/hostFolderBrowser/index.ts new file mode 100644 index 000000000..c0f38282a --- /dev/null +++ b/packages/agent-core-v2/src/hostFolderBrowser/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/hostFs/hostFs.ts b/packages/agent-core-v2/src/hostFs/hostFs.ts new file mode 100644 index 000000000..28674df5e --- /dev/null +++ b/packages/agent-core-v2/src/hostFs/hostFs.ts @@ -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; + writeText(path: string, data: string): Promise; + readBytes(path: string): Promise; + writeBytes(path: string, data: Uint8Array): Promise; + stat(path: string): Promise; + readdir(path: string): Promise; + mkdir(path: string, options?: { readonly recursive?: boolean }): Promise; + remove(path: string): Promise; +} + +export const IHostFileSystem: ServiceIdentifier = + createDecorator('hostFileSystem'); diff --git a/packages/agent-core-v2/src/hostFs/hostFsService.ts b/packages/agent-core-v2/src/hostFs/hostFsService.ts new file mode 100644 index 000000000..3df079c58 --- /dev/null +++ b/packages/agent-core-v2/src/hostFs/hostFsService.ts @@ -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 { + return readFile(path, 'utf8'); + } + + async writeText(path: string, data: string): Promise { + await writeFile(path, data, 'utf8'); + } + + async readBytes(path: string): Promise { + const buf = await readFile(path); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } + + async writeBytes(path: string, data: Uint8Array): Promise { + await writeFile(path, data); + } + + async stat(path: string): Promise { + const s = await stat(path); + return { isFile: s.isFile(), isDirectory: s.isDirectory(), size: s.size }; + } + + async readdir(path: string): Promise { + 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 { + await mkdir(path, { recursive: options?.recursive ?? false }); + } + + async remove(path: string): Promise { + await rm(path, { recursive: true, force: true }); + } +} + +registerScopedService( + LifecycleScope.Core, + IHostFileSystem, + HostFileSystem, + InstantiationType.Delayed, + 'hostFs', +); diff --git a/packages/agent-core-v2/src/hostFs/index.ts b/packages/agent-core-v2/src/hostFs/index.ts new file mode 100644 index 000000000..24b22c394 --- /dev/null +++ b/packages/agent-core-v2/src/hostFs/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 71750db62..20b4e7018 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -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. diff --git a/packages/agent-core-v2/src/kaos/agentKaos.ts b/packages/agent-core-v2/src/kaos/agentKaos.ts deleted file mode 100644 index 2d07919ee..000000000 --- a/packages/agent-core-v2/src/kaos/agentKaos.ts +++ /dev/null @@ -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 { - this._kaos = this._kaos.withCwd(cwd); - return Promise.resolve(); - } -} - -registerScopedService( - LifecycleScope.Agent, - IKaosService, - AgentKaos, - InstantiationType.Delayed, - 'kaos', -); diff --git a/packages/agent-core-v2/src/kaos/index.ts b/packages/agent-core-v2/src/kaos/index.ts deleted file mode 100644 index 9b0562ce0..000000000 --- a/packages/agent-core-v2/src/kaos/index.ts +++ /dev/null @@ -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'; diff --git a/packages/agent-core-v2/src/kaos/kaos.ts b/packages/agent-core-v2/src/kaos/kaos.ts deleted file mode 100644 index bc48bbb34..000000000 --- a/packages/agent-core-v2/src/kaos/kaos.ts +++ /dev/null @@ -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; -} - -export const IKaosService = createDecorator('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; -} - -export const IKaosFactory = createDecorator('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('sessionKaosService'); diff --git a/packages/agent-core-v2/src/kaos/kaosFactory.ts b/packages/agent-core-v2/src/kaos/kaosFactory.ts deleted file mode 100644 index 43d30ae8c..000000000 --- a/packages/agent-core-v2/src/kaos/kaosFactory.ts +++ /dev/null @@ -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 { - 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', -); diff --git a/packages/agent-core-v2/src/kaos/kaosService.ts b/packages/agent-core-v2/src/kaos/kaosService.ts deleted file mode 100644 index 6064b6d2f..000000000 --- a/packages/agent-core-v2/src/kaos/kaosService.ts +++ /dev/null @@ -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 { - return Promise.reject(new Error('KaosService.chdir is not supported')); - } -} - -registerScopedService( - LifecycleScope.Agent, - IKaosService, - KaosService, - InstantiationType.Delayed, - 'kaos', -); diff --git a/packages/agent-core-v2/src/kaos/sessionKaosService.ts b/packages/agent-core-v2/src/kaos/sessionKaosService.ts deleted file mode 100644 index 6c5571f14..000000000 --- a/packages/agent-core-v2/src/kaos/sessionKaosService.ts +++ /dev/null @@ -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', -); diff --git a/packages/agent-core-v2/src/plan/planService.ts b/packages/agent-core-v2/src/plan/planService.ts index d1b64470b..7fdbcad29 100644 --- a/packages/agent-core-v2/src/plan/planService.ts +++ b/packages/agent-core-v2/src/plan/planService.ts @@ -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 { 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 { await this.ensurePlanDirectory(path); - await this.kaosService.kaos?.writeText(path, ''); + await this.agentFs.writeText(path, ''); } private async ensurePlanDirectory(path: string): Promise { - await this.kaosService.kaos?.mkdir(dirname(path), { - parents: true, - existOk: true, - }); + await this.agentFs.mkdir(dirname(path)); } private currentCwd(): string { diff --git a/packages/agent-core-v2/src/process/index.ts b/packages/agent-core-v2/src/process/index.ts new file mode 100644 index 000000000..3b8ab1f1e --- /dev/null +++ b/packages/agent-core-v2/src/process/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/process/localProcessBackend.ts b/packages/agent-core-v2/src/process/localProcessBackend.ts new file mode 100644 index 000000000..2727e2dcb --- /dev/null +++ b/packages/agent-core-v2/src/process/localProcessBackend.ts @@ -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 { + return new Promise((resolve, reject) => { + this.child.once('exit', (code) => resolve(code ?? -1)); + this.child.once('error', reject); + }); + } + + kill(signal?: NodeJS.Signals): Promise { + 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 }, + ): Promise { + 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', +); diff --git a/packages/agent-core-v2/src/process/process.ts b/packages/agent-core-v2/src/process/process.ts new file mode 100644 index 000000000..8e5470e40 --- /dev/null +++ b/packages/agent-core-v2/src/process/process.ts @@ -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; + kill(signal?: NodeJS.Signals): Promise; +} + +export interface ProcessExecOptions { + readonly cwd?: string; + readonly env?: Record; +} + +export interface IProcessRunner { + readonly _serviceBrand: undefined; + + exec(args: readonly string[], options?: ProcessExecOptions): Promise; +} + +export const IProcessRunner: ServiceIdentifier = + createDecorator('processRunner'); + +export interface IProcessBackend { + readonly _serviceBrand: undefined; + + spawn( + args: readonly string[], + options: { readonly cwd: string; readonly env?: Record }, + ): Promise; +} + +export const IProcessBackend: ServiceIdentifier = + createDecorator('processBackend'); diff --git a/packages/agent-core-v2/src/process/processRunnerService.ts b/packages/agent-core-v2/src/process/processRunnerService.ts new file mode 100644 index 000000000..375e95514 --- /dev/null +++ b/packages/agent-core-v2/src/process/processRunnerService.ts @@ -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 { + return this.backend.spawn(args, { + cwd: options?.cwd ?? this.workspace.workDir, + env: options?.env, + }); + } +} + +registerScopedService( + LifecycleScope.Session, + IProcessRunner, + ProcessRunner, + InstantiationType.Delayed, + 'process', +); diff --git a/packages/agent-core-v2/src/process/sshProcessBackend.ts b/packages/agent-core-v2/src/process/sshProcessBackend.ts new file mode 100644 index 000000000..063c18dce --- /dev/null +++ b/packages/agent-core-v2/src/process/sshProcessBackend.ts @@ -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 }, + ): Promise { + throw new NotImplementedError('sshProcessBackend'); + } +} diff --git a/packages/agent-core-v2/src/sessionMetaStore/sessionMetaStoreService.ts b/packages/agent-core-v2/src/sessionMetaStore/sessionMetaStoreService.ts index 283232d08..325fcbc24 100644 --- a/packages/agent-core-v2/src/sessionMetaStore/sessionMetaStoreService.ts +++ b/packages/agent-core-v2/src/sessionMetaStore/sessionMetaStoreService.ts @@ -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 = {}; - 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> { - try { - const text = await this.sessionKaos.persistenceKaos.readText(this.path); - this.data = JSON.parse(text) as Record; - } catch { - this.data = {}; - } + this.data = + (await this.configStore.get>(SCOPE, this.key)) ?? {}; return this.data; } @@ -40,10 +41,7 @@ export class SessionMetaStore extends Disposable implements ISessionMetaStore { } async flush(): Promise { - await this.sessionKaos.persistenceKaos.writeText( - this.path, - JSON.stringify(this.data, null, 2), - ); + await this.configStore.set(SCOPE, this.key, this.data); } } diff --git a/packages/agent-core-v2/src/sessionStore/sessionStoreService.ts b/packages/agent-core-v2/src/sessionStore/sessionStoreService.ts index 14d389bfd..ba83ebd53 100644 --- a/packages/agent-core-v2/src/sessionStore/sessionStoreService.ts +++ b/packages/agent-core-v2/src/sessionStore/sessionStoreService.ts @@ -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 { 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 { - 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 { + 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; + } } } diff --git a/packages/agent-core-v2/src/storage/configStore.ts b/packages/agent-core-v2/src/storage/configStore.ts new file mode 100644 index 000000000..d96a8ab1d --- /dev/null +++ b/packages/agent-core-v2/src/storage/configStore.ts @@ -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(scope: string, key: string): Promise; + + /** Atomically replace the value at `(scope, key)`. */ + set(scope: string, key: string, value: T): Promise; + + /** Delete `(scope, key)`. Missing keys are not an error. */ + delete(scope: string, key: string): Promise; + + /** List the keys under `scope`, optionally filtered by `prefix`. */ + list(scope: string, prefix?: string): Promise; +} + +export const IConfigStore: ServiceIdentifier = + createDecorator('configStore'); + +export class ConfigStore implements IConfigStore { + declare readonly _serviceBrand: undefined; + + constructor(@IStorageService private readonly storage: IStorageService) {} + + async get(scope: string, key: string): Promise { + const bytes = await this.storage.read(scope, key); + return bytes === undefined ? undefined : (JSON.parse(textDecoder.decode(bytes)) as T); + } + + async set(scope: string, key: string, value: T): Promise { + await this.storage.write(scope, key, textEncoder.encode(JSON.stringify(value)), { + atomic: true, + }); + } + + async delete(scope: string, key: string): Promise { + await this.storage.delete(scope, key); + } + + async list(scope: string, prefix?: string): Promise { + return this.storage.list(scope, prefix); + } +} + +registerScopedService( + LifecycleScope.Session, + IConfigStore, + ConfigStore, + InstantiationType.Delayed, + 'storage', +); diff --git a/packages/agent-core-v2/src/storage/fileStorageService.ts b/packages/agent-core-v2/src/storage/fileStorageService.ts new file mode 100644 index 000000000..11c34dbd8 --- /dev/null +++ b/packages/agent-core-v2/src/storage/fileStorageService.ts @@ -0,0 +1,125 @@ +/** + * `FileStorageService` — `IStorageService` backed by the local filesystem. + * + * Layout: a value addressed by `(scope, key)` lives at + * `//`. `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(); + + constructor(private readonly baseDir: string) {} + + async read(scope: string, key: string): Promise { + 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 { + 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 { + 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 { + 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 { + try { + await unlink(this.path(scope, key)); + } catch (error) { + if (!isEnoent(error)) throw error; + } + } + + async flush(): Promise { + // Writes resolve only after the bytes are durable; nothing is buffered. + } + + async close(): Promise {} + + 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 { + if (this.syncedDirs.has(dir)) return; + await syncDir(dir); + this.syncedDirs.add(dir); + } +} diff --git a/packages/agent-core-v2/src/storage/inMemoryStorageService.ts b/packages/agent-core-v2/src/storage/inMemoryStorageService.ts new file mode 100644 index 000000000..3c351d817 --- /dev/null +++ b/packages/agent-core-v2/src/storage/inMemoryStorageService.ts @@ -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>(); + + async read(scope: string, key: string): Promise { + return this.scopes.get(scope)?.get(key); + } + + async write( + scope: string, + key: string, + data: Uint8Array, + _options: StorageWriteOptions = {}, + ): Promise { + this.bucket(scope).set(key, data); + } + + async append( + scope: string, + key: string, + data: Uint8Array, + _options: StorageAppendOptions = {}, + ): Promise { + 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 { + 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 { + this.scopes.get(scope)?.delete(key); + } + + async flush(): Promise {} + + async close(): Promise {} + + private bucket(scope: string): Map { + 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', +); diff --git a/packages/agent-core-v2/src/storage/index.ts b/packages/agent-core-v2/src/storage/index.ts new file mode 100644 index 000000000..10e101d40 --- /dev/null +++ b/packages/agent-core-v2/src/storage/index.ts @@ -0,0 +1,6 @@ +export * from './storageService'; +export * from './fileStorageService'; +export * from './inMemoryStorageService'; +export * from './recordStore'; +export * from './configStore'; +export * from './queryStore'; diff --git a/packages/agent-core-v2/src/storage/queryStore.ts b/packages/agent-core-v2/src/storage/queryStore.ts new file mode 100644 index 000000000..7bf04802a --- /dev/null +++ b/packages/agent-core-v2/src/storage/queryStore.ts @@ -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 { + 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 { + where(filter: QueryFilter): IQuery; + orderBy(field: string, dir?: SortDir): IQuery; + limit(n: number): IQuery; + cursor(cursor: string | undefined): IQuery; + execute(): Promise>; +} + +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(collection: string, key: string, value: T): Promise; + + /** Apply several writes atomically. */ + batch(ops: readonly WriteOp[]): Promise; + + delete(collection: string, key: string): Promise; + + get(collection: string, key: string): Promise; + + /** Start a query against a collection (read path). */ + query(collection: string): IQuery; + + /** Declare an index. Idempotent — re-declaring an existing index is a no-op. */ + ensureIndex(collection: string, def: IndexDef): Promise; + + /** Read how far a projector has indexed a given source log. */ + getCheckpoint(source: string): Promise; + + /** Persist a projector's progress for a given source log. */ + setCheckpoint(source: string, checkpoint: Checkpoint): Promise; + + close(): Promise; +} + +export const IQueryStore: ServiceIdentifier = createDecorator('queryStore'); diff --git a/packages/agent-core-v2/src/storage/recordStore.ts b/packages/agent-core-v2/src/storage/recordStore.ts new file mode 100644 index 000000000..bd80c8dc0 --- /dev/null +++ b/packages/agent-core-v2/src/storage/recordStore.ts @@ -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(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(scope: string, key: string): AsyncIterable; + + /** Atomically replace the whole log with `records` (used after migration). */ + rewrite(scope: string, key: string, records: readonly R[]): Promise; + + /** Durable-write every buffered record across all logs. */ + flush(): Promise; + + /** Flush and release resources. */ + close(): Promise; +} + +export const IRecordStore: ServiceIdentifier = + createDecorator('recordStore'); + +interface LogState { + pending: unknown[]; + flushPromise: Promise | undefined; + flushScheduled: boolean; +} + +export class RecordStore implements IRecordStore { + declare readonly _serviceBrand: undefined; + + private readonly logs = new Map(); + + constructor(@IStorageService private readonly storage: IStorageService) {} + + append(scope: string, key: string, record: R): void { + const state = this.state(scope, key); + state.pending.push(record); + this.scheduleFlush(scope, key, state); + } + + async *read(scope: string, key: string): AsyncIterable { + 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(scope: string, key: string, records: readonly R[]): Promise { + // 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 { + const inFlight = [...this.logs.keys()].map((id) => { + const { scope, key } = fromLogId(id); + return this.flushLog(scope, key); + }); + await Promise.all(inFlight); + } + + async close(): Promise { + 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 { + 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 { + 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', +); diff --git a/packages/agent-core-v2/src/storage/storageService.ts b/packages/agent-core-v2/src/storage/storageService.ts new file mode 100644 index 000000000..5ff7cf230 --- /dev/null +++ b/packages/agent-core-v2/src/storage/storageService.ts @@ -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; + + /** Atomically replace the whole value. */ + write(scope: string, key: string, data: Uint8Array, options?: StorageWriteOptions): Promise; + + /** Durable byte extension (ordered). */ + append(scope: string, key: string, data: Uint8Array, options?: StorageAppendOptions): Promise; + + /** List the keys under `scope`, optionally filtered by `prefix`. */ + list(scope: string, prefix?: string): Promise; + + /** Delete a key. Missing keys are not an error. */ + delete(scope: string, key: string): Promise; + + /** Flush any buffered writes to the durable medium. */ + flush(): Promise; + + /** Release implementation resources (file handles, timers, connections). */ + close(): Promise; +} + +export const IStorageService: ServiceIdentifier = + createDecorator('storageService'); diff --git a/packages/agent-core-v2/src/terminal/errors.ts b/packages/agent-core-v2/src/terminal/errors.ts deleted file mode 100644 index 44cc0431c..000000000 --- a/packages/agent-core-v2/src/terminal/errors.ts +++ /dev/null @@ -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); diff --git a/packages/agent-core-v2/src/terminal/index.ts b/packages/agent-core-v2/src/terminal/index.ts deleted file mode 100644 index b4de489fa..000000000 --- a/packages/agent-core-v2/src/terminal/index.ts +++ /dev/null @@ -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'; diff --git a/packages/agent-core-v2/src/terminal/terminal.ts b/packages/agent-core-v2/src/terminal/terminal.ts deleted file mode 100644 index 179461dce..000000000 --- a/packages/agent-core-v2/src/terminal/terminal.ts +++ /dev/null @@ -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; - write(id: string, data: string): void; - kill(id: string): Promise; -} - -export const ITerminalService: ServiceIdentifier = - createDecorator('terminalService'); diff --git a/packages/agent-core-v2/src/terminal/terminalService.ts b/packages/agent-core-v2/src/terminal/terminalService.ts deleted file mode 100644 index e18fd3a90..000000000 --- a/packages/agent-core-v2/src/terminal/terminalService.ts +++ /dev/null @@ -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(); - - constructor( - @ILogService _log: ILogService, - @ISessionKaosService private readonly sessionKaos: ISessionKaosService, - ) { - super(); - } - - async spawn(cmd: string, args: readonly string[]): Promise { - 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 { - 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'); diff --git a/packages/agent-core-v2/src/workspace/errors.ts b/packages/agent-core-v2/src/workspace/errors.ts deleted file mode 100644 index 288f0025c..000000000 --- a/packages/agent-core-v2/src/workspace/errors.ts +++ /dev/null @@ -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) { - super(code, message, { details }); - this.name = 'WorkspaceError'; - } -} - -export class WorkspaceFsError extends KimiError { - constructor(code: WorkspaceErrorCode, message: string, details?: Record) { - super(code, message, { details }); - this.name = 'WorkspaceFsError'; - } -} diff --git a/packages/agent-core-v2/src/workspace/index.ts b/packages/agent-core-v2/src/workspace/index.ts deleted file mode 100644 index 157a650a0..000000000 --- a/packages/agent-core-v2/src/workspace/index.ts +++ /dev/null @@ -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'; diff --git a/packages/agent-core-v2/src/workspace/workspace.ts b/packages/agent-core-v2/src/workspace/workspace.ts deleted file mode 100644 index 33efbae39..000000000 --- a/packages/agent-core-v2/src/workspace/workspace.ts +++ /dev/null @@ -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 = - createDecorator('sessionWorkspaceService'); diff --git a/packages/agent-core-v2/src/workspace/workspaceFs.ts b/packages/agent-core-v2/src/workspace/workspaceFs.ts deleted file mode 100644 index 243253b9b..000000000 --- a/packages/agent-core-v2/src/workspace/workspaceFs.ts +++ /dev/null @@ -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; - - /** Folder-picker landing payload: `$HOME` plus recently-opened workspace roots. */ - home(): Promise; -} - -export const IWorkspaceFsService: ServiceIdentifier = - createDecorator('workspaceFsService'); - -export const RECENT_ROOTS_LIMIT = 8; diff --git a/packages/agent-core-v2/src/workspace/workspaceFsService.ts b/packages/agent-core-v2/src/workspace/workspaceFsService.ts deleted file mode 100644 index c0642295c..000000000 --- a/packages/agent-core-v2/src/workspace/workspaceFsService.ts +++ /dev/null @@ -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 { - 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 { - 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', -); diff --git a/packages/agent-core-v2/src/workspace/workspaceRegistry.ts b/packages/agent-core-v2/src/workspace/workspaceRegistry.ts deleted file mode 100644 index f2b57a0be..000000000 --- a/packages/agent-core-v2/src/workspace/workspaceRegistry.ts +++ /dev/null @@ -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__` id, plus the `WorkspacePatch` used to rename one. Core- - * scoped — one shared registry for the whole process. Backed by - * `/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; - - /** Fetch a single workspace by id; throws `WorkspaceError` (workspace.not_found) if absent. */ - get(workspaceId: string): Promise; - - /** - * 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; - - /** Rename a workspace (display name only). */ - update(workspaceId: string, patch: WorkspacePatch): Promise; - - /** Unregister a workspace (does not remove on-disk content). */ - delete(workspaceId: string): Promise; - - /** Resolve a workspace id back to its absolute root path. */ - resolveRoot(workspaceId: string): Promise; -} - -export const IWorkspaceRegistry: ServiceIdentifier = - createDecorator('workspaceRegistry'); diff --git a/packages/agent-core-v2/src/workspace/workspaceRegistryService.ts b/packages/agent-core-v2/src/workspace/workspaceRegistryService.ts deleted file mode 100644 index adef305f6..000000000 --- a/packages/agent-core-v2/src/workspace/workspaceRegistryService.ts +++ /dev/null @@ -1,328 +0,0 @@ -/** - * `workspace` domain (cross-cutting) — `IWorkspaceRegistry` implementation. - * - * Persists the process-wide workspace catalog to `/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; -} - -export class WorkspaceRegistryService extends Disposable implements IWorkspaceRegistry { - declare readonly _serviceBrand: undefined; - - private readonly sessionsDir: string; - private readonly registryPath: string; - private opQueue: Promise = 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 }).workspaces; - const workspaces: Record = {}; - 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; - 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 { - 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(op: () => Promise): Promise { - 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 { - 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', -); diff --git a/packages/agent-core-v2/src/workspace/workspaceService.ts b/packages/agent-core-v2/src/workspace/workspaceService.ts deleted file mode 100644 index 89c3e91f2..000000000 --- a/packages/agent-core-v2/src/workspace/workspaceService.ts +++ /dev/null @@ -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', -); diff --git a/packages/agent-core-v2/src/workspaceContext/index.ts b/packages/agent-core-v2/src/workspaceContext/index.ts new file mode 100644 index 000000000..63c36545e --- /dev/null +++ b/packages/agent-core-v2/src/workspaceContext/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/workspaceContext/workspaceContext.ts b/packages/agent-core-v2/src/workspaceContext/workspaceContext.ts new file mode 100644 index 000000000..29041e954 --- /dev/null +++ b/packages/agent-core-v2/src/workspaceContext/workspaceContext.ts @@ -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 = + createDecorator('workspaceContext'); diff --git a/packages/agent-core-v2/src/workspaceContext/workspaceContextService.ts b/packages/agent-core-v2/src/workspaceContext/workspaceContextService.ts new file mode 100644 index 000000000..4f7b5fc86 --- /dev/null +++ b/packages/agent-core-v2/src/workspaceContext/workspaceContextService.ts @@ -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', +); diff --git a/packages/agent-core-v2/src/workspaceRegistry/index.ts b/packages/agent-core-v2/src/workspaceRegistry/index.ts new file mode 100644 index 000000000..002626a2e --- /dev/null +++ b/packages/agent-core-v2/src/workspaceRegistry/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/workspaceRegistry/workspaceRegistry.ts b/packages/agent-core-v2/src/workspaceRegistry/workspaceRegistry.ts new file mode 100644 index 000000000..551ee9a32 --- /dev/null +++ b/packages/agent-core-v2/src/workspaceRegistry/workspaceRegistry.ts @@ -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; + get(id: string): Promise; + createOrTouch(root: string, name?: string): Promise; + delete(id: string): Promise; +} + +export const IWorkspaceRegistry: ServiceIdentifier = + createDecorator('workspaceRegistry'); diff --git a/packages/agent-core-v2/src/workspaceRegistry/workspaceRegistryService.ts b/packages/agent-core-v2/src/workspaceRegistry/workspaceRegistryService.ts new file mode 100644 index 000000000..d5d2ef2e2 --- /dev/null +++ b/packages/agent-core-v2/src/workspaceRegistry/workspaceRegistryService.ts @@ -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(); + + list(): Promise { + return Promise.resolve([...this.workspaces.values()]); + } + + get(id: string): Promise { + return Promise.resolve(this.workspaces.get(id)); + } + + createOrTouch(root: string, name?: string): Promise { + 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 { + this.workspaces.delete(id); + return Promise.resolve(); + } +} + +registerScopedService( + LifecycleScope.Core, + IWorkspaceRegistry, + WorkspaceRegistryService, + InstantiationType.Delayed, + 'workspaceRegistry', +); diff --git a/packages/agent-core-v2/test/environment/stubs.ts b/packages/agent-core-v2/test/environment/stubs.ts index dbb77a900..807046261 100644 --- a/packages/agent-core-v2/test/environment/stubs.ts +++ b/packages/agent-core-v2/test/environment/stubs.ts @@ -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')), }; } diff --git a/packages/agent-core-v2/test/filestore/filestore.test.ts b/packages/agent-core-v2/test/filestore/filestore.test.ts deleted file mode 100644 index 52af07c86..000000000 --- a/packages/agent-core-v2/test/filestore/filestore.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/agent-core-v2/test/fs/fs.test.ts b/packages/agent-core-v2/test/fs/fs.test.ts deleted file mode 100644 index 89fe3893b..000000000 --- a/packages/agent-core-v2/test/fs/fs.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/packages/agent-core-v2/test/kaos/agentKaos.test.ts b/packages/agent-core-v2/test/kaos/agentKaos.test.ts deleted file mode 100644 index 61dc5aa48..000000000 --- a/packages/agent-core-v2/test/kaos/agentKaos.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/packages/agent-core-v2/test/kaos/kaos.test.ts b/packages/agent-core-v2/test/kaos/kaos.test.ts deleted file mode 100644 index 4989cd459..000000000 --- a/packages/agent-core-v2/test/kaos/kaos.test.ts +++ /dev/null @@ -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()); - }); -}); diff --git a/packages/agent-core-v2/test/plan/plan.test.ts b/packages/agent-core-v2/test/plan/plan.test.ts index f38a80fec..a377fa144 100644 --- a/packages/agent-core-v2/test/plan/plan.test.ts +++ b/packages/agent-core-v2/test/plan/plan.test.ts @@ -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' })); diff --git a/packages/agent-core-v2/test/sessionMetaStore/sessionMetaStore.test.ts b/packages/agent-core-v2/test/sessionMetaStore/sessionMetaStore.test.ts index 628b2ca9b..8a895b0fe 100644 --- a/packages/agent-core-v2/test/sessionMetaStore/sessionMetaStore.test.ts +++ b/packages/agent-core-v2/test/sessionMetaStore/sessionMetaStore.test.ts @@ -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 }); }); diff --git a/packages/agent-core-v2/test/sessionStore/sessionStore.test.ts b/packages/agent-core-v2/test/sessionStore/sessionStore.test.ts index 057027a7a..5c4839d73 100644 --- a/packages/agent-core-v2/test/sessionStore/sessionStore.test.ts +++ b/packages/agent-core-v2/test/sessionStore/sessionStore.test.ts @@ -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); } diff --git a/packages/agent-core-v2/test/storage/configStore.test.ts b/packages/agent-core-v2/test/storage/configStore.test.ts new file mode 100644 index 000000000..37d509183 --- /dev/null +++ b/packages/agent-core-v2/test/storage/configStore.test.ts @@ -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('session', 'state.json', { title: 'hello', count: 1 }); + expect(await config.get('session', 'state.json')).toEqual({ title: 'hello', count: 1 }); + }); + + it('set atomically replaces the previous value', async () => { + await config.set('session', 'state.json', { title: 'old' }); + await config.set('session', 'state.json', { title: 'new', count: 2 }); + expect(await config.get('session', 'state.json')).toEqual({ title: 'new', count: 2 }); + }); + + it('keys are independent', async () => { + await config.set('session', 'a.json', { title: 'A' }); + await config.set('session', 'b.json', { title: 'B' }); + expect(await config.get('session', 'a.json')).toEqual({ title: 'A' }); + expect(await config.get('session', 'b.json')).toEqual({ title: 'B' }); + }); + + it('scopes are isolated', async () => { + await config.set('scope-a', 'k', { title: 'A' }); + await config.set('scope-b', 'k', { title: 'B' }); + expect(await config.get('scope-a', 'k')).toEqual({ title: 'A' }); + expect(await config.get('scope-b', 'k')).toEqual({ title: 'B' }); + }); + + it('delete removes a key; missing delete is a no-op', async () => { + await config.set('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('session', 'state.json', { title: 'x' }); + const raw = new TextDecoder().decode(await storage.read('session', 'state.json')); + expect(JSON.parse(raw)).toEqual({ title: 'x' }); + }); +}); diff --git a/packages/agent-core-v2/test/storage/recordStore.test.ts b/packages/agent-core-v2/test/storage/recordStore.test.ts new file mode 100644 index 000000000..94463b46d --- /dev/null +++ b/packages/agent-core-v2/test/storage/recordStore.test.ts @@ -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(scope: string, key: string): Promise { + const out: R[] = []; + for await (const r of record.read(scope, key)) { + out.push(r); + } + return out; + } + + it('reads nothing from an empty log', async () => { + expect(await collect(SCOPE, KEY)).toEqual([]); + }); + + it('append + read round-trips records in order', async () => { + record.append(SCOPE, KEY, { n: 1 }); + record.append(SCOPE, KEY, { n: 2 }); + record.append(SCOPE, KEY, { n: 3 }); + expect(await collect(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(SCOPE, KEY, { n }); + await record.flush(); + + expect(await collect(SCOPE, KEY)).toHaveLength(10); + expect(spy.count).toBe(1); + }); + + it('rewrite atomically replaces the whole log', async () => { + record.append(SCOPE, KEY, { n: 1 }); + record.append(SCOPE, KEY, { n: 2 }); + await record.flush(); + + await record.rewrite(SCOPE, KEY, [{ n: 9 }, { n: 8 }]); + expect(await collect(SCOPE, KEY)).toEqual([{ n: 9 }, { n: 8 }]); + }); + + it('logs addressed by different scope/key are independent', async () => { + record.append('a', 'l', { n: 1 }); + record.append('b', 'l', { n: 2 }); + expect(await collect('a', 'l')).toEqual([{ n: 1 }]); + expect(await collect('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(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(SCOPE, KEY)).rejects.toBeInstanceOf(RecordCorruptedError); + }); +}); diff --git a/packages/agent-core-v2/test/storage/storageService.test.ts b/packages/agent-core-v2/test/storage/storageService.test.ts new file mode 100644 index 000000000..a8ae8d475 --- /dev/null +++ b/packages/agent-core-v2/test/storage/storageService.test.ts @@ -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; +} + +function storageServiceSuite( + name: string, + setup: () => Promise, +): void { + describe(name, () => { + let service: IStorageService; + let cleanup: (() => Promise) | 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 }), + }; +}); diff --git a/packages/agent-core-v2/test/terminal/terminal.test.ts b/packages/agent-core-v2/test/terminal/terminal.test.ts deleted file mode 100644 index b1c97a072..000000000 --- a/packages/agent-core-v2/test/terminal/terminal.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/agent-core-v2/test/workspace/workspace.test.ts b/packages/agent-core-v2/test/workspace/workspace.test.ts deleted file mode 100644 index 6b86e0170..000000000 --- a/packages/agent-core-v2/test/workspace/workspace.test.ts +++ /dev/null @@ -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([]); - }); -}); diff --git a/packages/agent-core-v2/test/workspace/workspaceFs.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs.test.ts deleted file mode 100644 index 9c1ccf263..000000000 --- a/packages/agent-core-v2/test/workspace/workspaceFs.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/agent-core-v2/test/workspace/workspaceRegistry.test.ts b/packages/agent-core-v2/test/workspace/workspaceRegistry.test.ts deleted file mode 100644 index 53fd7cd1e..000000000 --- a/packages/agent-core-v2/test/workspace/workspaceRegistry.test.ts +++ /dev/null @@ -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); - }); -});