mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-26 17:45:08 +00:00
refactor(agent-core-v2): split kaos into host/agent filesystem, process and storage domains
- introduce storage domain (IStorageService/IRecordStore/IConfigStore) as the program-side persistence substrate over raw node fs - add hostFs (local real-filesystem primitives reused by persistence and the local agent backend) - add agentFs (IAgentFileSystem + IFileSystemBackend) and process (IProcessRunner + IProcessBackend) with local/ssh backends for the Agent execution environment - add workspaceContext (session workspace root + path access), workspaceRegistry (process-wide catalog) and hostFolderBrowser (host-side folder picker) - remove kaos/fs/terminal/filestore/workspace domains - route persistence through storage (sessionMetaStore -> IConfigStore) and hostFs (sessionStore); route plan file IO through agentFs - collect app paths in IEnvironmentService; register new domains in DOMAIN_LAYER and refresh the dependency map
This commit is contained in:
parent
6185ea8e89
commit
db02c18ddc
77 changed files with 1894 additions and 2175 deletions
|
|
@ -23,7 +23,9 @@ package "Core scope (process-wide)" #EAF3FB {
|
|||
rectangle "<b>telemetry</b>\n<size:9><i>Core</i></size>\n ITelemetryService" as telemetry #D6EAF8
|
||||
rectangle "<b>event</b>\n<size:9><i>Core</i></size>\n IEventService" as event #D6EAF8
|
||||
rectangle "<b>gateway</b>\n<size:9><i>Core</i></size>\n IScopeRegistry\n IRestGateway\n IWSGateway\n IWSBroadcastService" as gateway #D6EAF8
|
||||
rectangle "<b>filestore</b>\n<size:9><i>Core</i></size>\n IFileStore" as filestore #D6EAF8
|
||||
rectangle "<b>hostFs</b>\n<size:9><i>Core</i></size>\n IHostFileSystem" as hostFs #D6EAF8
|
||||
rectangle "<b>workspaceRegistry</b>\n<size:9><i>Core</i></size>\n IWorkspaceRegistry" as workspaceRegistry #D6EAF8
|
||||
rectangle "<b>hostFolderBrowser</b>\n<size:9><i>Core</i></size>\n IHostFolderBrowser" as hostFolderBrowser #D6EAF8
|
||||
rectangle "<b>auth</b>\n<size:9><i>Core</i></size>\n IOAuthService\n IAuthSummaryService" as auth #D6EAF8
|
||||
rectangle "<b>provider</b>\n<size:9><i>Core</i></size>\n IProviderService" as provider #D6EAF8
|
||||
rectangle "<b>flag</b>\n<size:9><i>Core</i></size>\n IFlagService" as flag #D6EAF8
|
||||
|
|
@ -31,12 +33,13 @@ package "Core scope (process-wide)" #EAF3FB {
|
|||
|
||||
package "Session scope (per session)" #EAFAF1 {
|
||||
rectangle "<b>session</b>\n<size:9><i>Session</i></size>\n ISessionService" as session #D5F5E3
|
||||
rectangle "<b>fs</b>\n<size:9><i>Session</i></size>\n IFsService\n IFsSearchService\n IFsGitService\n IFsWatcher" as fs #D5F5E3
|
||||
rectangle "<b>workspaceContext</b>\n<size:9><i>Session</i></size>\n IWorkspaceContext" as workspaceContext #D5F5E3
|
||||
rectangle "<b>agentFs</b>\n<size:9><i>Session</i></size>\n IAgentFileSystem\n IFileSystemBackend" as agentFs #D5F5E3
|
||||
rectangle "<b>approval</b>\n<size:9><i>Session</i></size>\n IApprovalService" as approval #D5F5E3
|
||||
rectangle "<b>question</b>\n<size:9><i>Session</i></size>\n IQuestionService" as question #D5F5E3
|
||||
rectangle "<b>session-activity</b>\n<size:9><i>Session</i></size>\n ISessionActivity" as session_activity #D5F5E3
|
||||
rectangle "<b>subagentHost</b>\n<size:9><i>Session</i></size>\n ISubagentHost" as subagentHost #D5F5E3
|
||||
rectangle "<b>terminal</b>\n<size:9><i>Session</i></size>\n ITerminalService" as terminal #D5F5E3
|
||||
rectangle "<b>process</b>\n<size:9><i>Session</i></size>\n IProcessRunner\n IProcessBackend" as process #D5F5E3
|
||||
rectangle "<b>agent-lifecycle</b>\n<size:9><i>Session</i></size>\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
|
||||
rectangle "<b>session-context</b>\n<size:9><i>Session</i></size>\n ISessionContext (seed)" as session_context #D5F5E3
|
||||
}
|
||||
|
|
@ -86,8 +89,6 @@ package "Multi-scope" #F5EEF8 {
|
|||
rectangle "<b>kosong</b>\n<size:9><i>Multi</i></size>\n IProtocolHandlerRegistry(C)\n IProviderManager(S, seed)" as kosong #E8DAEF
|
||||
rectangle "<b>records</b>\n<size:9><i>Multi</i></size>\n ISessionStore(C)\n ISessionMetaStore(S)\n IAgentRecords(A)" as records #E8DAEF
|
||||
rectangle "<b>tool</b>\n<size:9><i>Multi</i></size>\n IToolDefinitionRegistry(C)\n IToolService(A)" as tool #E8DAEF
|
||||
rectangle "<b>kaos</b>\n<size:9><i>Multi</i></size>\n IKaosFactory(C)\n ISessionKaosService(S)\n IKaosService / IAgentKaos(A)" as kaos #E8DAEF
|
||||
rectangle "<b>workspace</b>\n<size:9><i>Multi</i></size>\n IWorkspaceRegistry(C)\n IWorkspaceFsService(C)\n ISessionWorkspaceService(S)" as workspace #E8DAEF
|
||||
}
|
||||
|
||||
package "WIP (contract only)" #FBFCFC {
|
||||
|
|
@ -98,12 +99,10 @@ package "WIP (contract only)" #FBFCFC {
|
|||
|
||||
' ---- DI injection (solid) ----
|
||||
gateway --> event #34495E
|
||||
workspace --> environment #34495E
|
||||
workspace --> event #34495E
|
||||
workspace --> records #34495E
|
||||
workspace --> log #34495E
|
||||
workspace --> kaos #34495E
|
||||
filestore --> kaos #34495E
|
||||
agentFs --> workspaceContext #34495E
|
||||
agentFs --> hostFs #34495E
|
||||
process --> workspaceContext #34495E
|
||||
hostFolderBrowser --> hostFs #34495E
|
||||
auth --> provider #34495E
|
||||
auth --> environment #34495E
|
||||
auth --> telemetry #34495E
|
||||
|
|
@ -113,26 +112,19 @@ flag --> config #34495E
|
|||
config --> environment #34495E
|
||||
config --> log #34495E
|
||||
config --> records #34495E
|
||||
config --> kaos #34495E
|
||||
kosong --> config #34495E
|
||||
records --> kaos #34495E
|
||||
records --> hostFs #34495E
|
||||
records --> storage #34495E
|
||||
records --> log #34495E
|
||||
tool --> config #34495E
|
||||
tool --> records #34495E
|
||||
tool --> kaos #34495E
|
||||
tool --> permission #34495E
|
||||
tool --> kosong #34495E
|
||||
kaos --> environment #34495E
|
||||
kaos --> log #34495E
|
||||
session --> records #34495E
|
||||
session --> agent_lifecycle #34495E
|
||||
session --> session_activity #34495E
|
||||
session --> event #34495E
|
||||
fs --> kaos #34495E
|
||||
fs --> log #34495E
|
||||
session_activity --> agent_lifecycle #34495E
|
||||
terminal --> log #34495E
|
||||
terminal --> kaos #34495E
|
||||
agent_lifecycle --> session_context #34495E
|
||||
agent_lifecycle --> records #34495E
|
||||
permissionMode --> wireRecord #34495E
|
||||
|
|
@ -278,7 +270,7 @@ turn --> tool #34495E
|
|||
plan --> contextMemory #34495E
|
||||
plan --> wireRecord #34495E
|
||||
plan --> eventBus #34495E
|
||||
plan --> kaos #34495E
|
||||
plan --> agentFs #34495E
|
||||
plan --> profile #34495E
|
||||
plan --> replayBuilder #34495E
|
||||
plan --> toolRegistry #34495E
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
52
packages/agent-core-v2/src/agentFs/agentFs.ts
Normal file
52
packages/agent-core-v2/src/agentFs/agentFs.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* `agentFs` domain (L1) — the Agent's filesystem and its pluggable backend.
|
||||
*
|
||||
* Defines the `IAgentFileSystem` that business code injects to read and write
|
||||
* files inside the Agent's execution environment, plus the internal
|
||||
* `IFileSystemBackend` provider that hides the local/ssh/container split.
|
||||
* Session-scoped. Business code depends on `IAgentFileSystem` only; the
|
||||
* backend is wired through the scope registry.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export interface AgentFileStat {
|
||||
readonly isFile: boolean;
|
||||
readonly isDirectory: boolean;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
export interface IAgentFileSystem {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
readonly cwd: string;
|
||||
|
||||
readText(path: string): Promise<string>;
|
||||
writeText(path: string, data: string): Promise<void>;
|
||||
readBytes(path: string): Promise<Uint8Array>;
|
||||
writeBytes(path: string, data: Uint8Array): Promise<void>;
|
||||
stat(path: string): Promise<AgentFileStat>;
|
||||
readdir(path: string): Promise<readonly string[]>;
|
||||
glob(pattern: string): Promise<readonly string[]>;
|
||||
mkdir(path: string): Promise<void>;
|
||||
withCwd(cwd: string): IAgentFileSystem;
|
||||
}
|
||||
|
||||
export const IAgentFileSystem: ServiceIdentifier<IAgentFileSystem> =
|
||||
createDecorator<IAgentFileSystem>('agentFileSystem');
|
||||
|
||||
export interface IFileSystemBackend {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
readText(absPath: string): Promise<string>;
|
||||
writeText(absPath: string, data: string): Promise<void>;
|
||||
readBytes(absPath: string): Promise<Uint8Array>;
|
||||
writeBytes(absPath: string, data: Uint8Array): Promise<void>;
|
||||
stat(absPath: string): Promise<AgentFileStat>;
|
||||
readdir(absPath: string): Promise<readonly string[]>;
|
||||
glob(absDir: string, pattern: string): Promise<readonly string[]>;
|
||||
mkdir(absPath: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const IFileSystemBackend: ServiceIdentifier<IFileSystemBackend> =
|
||||
createDecorator<IFileSystemBackend>('fileSystemBackend');
|
||||
77
packages/agent-core-v2/src/agentFs/agentFsService.ts
Normal file
77
packages/agent-core-v2/src/agentFs/agentFsService.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* `agentFs` domain (L1) — `IAgentFileSystem` implementation.
|
||||
*
|
||||
* Resolves paths against the session workspace and delegates IO to the
|
||||
* injected `IFileSystemBackend`; reads the work directory through
|
||||
* `workspaceContext`. Bound at Session scope.
|
||||
*/
|
||||
|
||||
import { isAbsolute, resolve } from 'node:path';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { NotImplementedError } from '#/_base/errors';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IWorkspaceContext } from '#/workspaceContext';
|
||||
|
||||
import { type AgentFileStat, IAgentFileSystem, IFileSystemBackend } from './agentFs';
|
||||
|
||||
export class AgentFileSystem implements IAgentFileSystem {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(
|
||||
@IFileSystemBackend private readonly backend: IFileSystemBackend,
|
||||
@IWorkspaceContext private readonly workspace: IWorkspaceContext,
|
||||
) {}
|
||||
|
||||
get cwd(): string {
|
||||
return this.workspace.workDir;
|
||||
}
|
||||
|
||||
private abs(path: string): string {
|
||||
return isAbsolute(path) ? path : resolve(this.cwd, path);
|
||||
}
|
||||
|
||||
readText(path: string): Promise<string> {
|
||||
return this.backend.readText(this.abs(path));
|
||||
}
|
||||
|
||||
writeText(path: string, data: string): Promise<void> {
|
||||
return this.backend.writeText(this.abs(path), data);
|
||||
}
|
||||
|
||||
readBytes(path: string): Promise<Uint8Array> {
|
||||
return this.backend.readBytes(this.abs(path));
|
||||
}
|
||||
|
||||
writeBytes(path: string, data: Uint8Array): Promise<void> {
|
||||
return this.backend.writeBytes(this.abs(path), data);
|
||||
}
|
||||
|
||||
stat(path: string): Promise<AgentFileStat> {
|
||||
return this.backend.stat(this.abs(path));
|
||||
}
|
||||
|
||||
readdir(path: string): Promise<readonly string[]> {
|
||||
return this.backend.readdir(this.abs(path));
|
||||
}
|
||||
|
||||
glob(pattern: string): Promise<readonly string[]> {
|
||||
return this.backend.glob(this.cwd, pattern);
|
||||
}
|
||||
|
||||
mkdir(path: string): Promise<void> {
|
||||
return this.backend.mkdir(this.abs(path));
|
||||
}
|
||||
|
||||
withCwd(_cwd: string): IAgentFileSystem {
|
||||
throw new NotImplementedError('agentFs.withCwd');
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
IAgentFileSystem,
|
||||
AgentFileSystem,
|
||||
InstantiationType.Delayed,
|
||||
'agentFs',
|
||||
);
|
||||
12
packages/agent-core-v2/src/agentFs/index.ts
Normal file
12
packages/agent-core-v2/src/agentFs/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* `agentFs` domain barrel — re-exports the agent-filesystem contract
|
||||
* (`agentFs`), its scoped service (`agentFsService`), and the backend
|
||||
* implementations (`localFileSystemBackend`, `sshFileSystemBackend`).
|
||||
* Importing this barrel registers the `IAgentFileSystem` and default local
|
||||
* `IFileSystemBackend` bindings into the scope registry.
|
||||
*/
|
||||
|
||||
export * from './agentFs';
|
||||
export * from './agentFsService';
|
||||
export * from './localFileSystemBackend';
|
||||
export * from './sshFileSystemBackend';
|
||||
63
packages/agent-core-v2/src/agentFs/localFileSystemBackend.ts
Normal file
63
packages/agent-core-v2/src/agentFs/localFileSystemBackend.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* `agentFs` domain (L1) — local `IFileSystemBackend` implementation.
|
||||
*
|
||||
* Backs the Agent filesystem with the real local disk by delegating to the
|
||||
* program-side `hostFs` primitives. Registered as the default
|
||||
* `IFileSystemBackend` at Session scope; remote backends override it via the
|
||||
* scope registry.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { NotImplementedError } from '#/_base/errors';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IHostFileSystem } from '#/hostFs';
|
||||
|
||||
import { type AgentFileStat, IFileSystemBackend } from './agentFs';
|
||||
|
||||
export class LocalFileSystemBackend implements IFileSystemBackend {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(@IHostFileSystem private readonly hostFs: IHostFileSystem) {}
|
||||
|
||||
readText(absPath: string): Promise<string> {
|
||||
return this.hostFs.readText(absPath);
|
||||
}
|
||||
|
||||
writeText(absPath: string, data: string): Promise<void> {
|
||||
return this.hostFs.writeText(absPath, data);
|
||||
}
|
||||
|
||||
readBytes(absPath: string): Promise<Uint8Array> {
|
||||
return this.hostFs.readBytes(absPath);
|
||||
}
|
||||
|
||||
writeBytes(absPath: string, data: Uint8Array): Promise<void> {
|
||||
return this.hostFs.writeBytes(absPath, data);
|
||||
}
|
||||
|
||||
async stat(absPath: string): Promise<AgentFileStat> {
|
||||
const s = await this.hostFs.stat(absPath);
|
||||
return { isFile: s.isFile, isDirectory: s.isDirectory, size: s.size };
|
||||
}
|
||||
|
||||
async readdir(absPath: string): Promise<readonly string[]> {
|
||||
const entries = await this.hostFs.readdir(absPath);
|
||||
return entries.map((e) => e.name);
|
||||
}
|
||||
|
||||
glob(_absDir: string, _pattern: string): Promise<readonly string[]> {
|
||||
throw new NotImplementedError('localFileSystemBackend.glob');
|
||||
}
|
||||
|
||||
mkdir(absPath: string): Promise<void> {
|
||||
return this.hostFs.mkdir(absPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
IFileSystemBackend,
|
||||
LocalFileSystemBackend,
|
||||
InstantiationType.Delayed,
|
||||
'agentFs',
|
||||
);
|
||||
47
packages/agent-core-v2/src/agentFs/sshFileSystemBackend.ts
Normal file
47
packages/agent-core-v2/src/agentFs/sshFileSystemBackend.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* `agentFs` domain (L1) — ssh `IFileSystemBackend` stub.
|
||||
*
|
||||
* Placeholder for the remote backend; not registered into the scope registry
|
||||
* yet. A composition root that needs ssh supplies it through
|
||||
* `ScopeOptions.extra` to override the local backend.
|
||||
*/
|
||||
|
||||
import { NotImplementedError } from '#/_base/errors';
|
||||
|
||||
import { type AgentFileStat, IFileSystemBackend } from './agentFs';
|
||||
|
||||
export class SshFileSystemBackend implements IFileSystemBackend {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
readText(_absPath: string): Promise<string> {
|
||||
throw new NotImplementedError('sshFileSystemBackend');
|
||||
}
|
||||
|
||||
writeText(_absPath: string, _data: string): Promise<void> {
|
||||
throw new NotImplementedError('sshFileSystemBackend');
|
||||
}
|
||||
|
||||
readBytes(_absPath: string): Promise<Uint8Array> {
|
||||
throw new NotImplementedError('sshFileSystemBackend');
|
||||
}
|
||||
|
||||
writeBytes(_absPath: string, _data: Uint8Array): Promise<void> {
|
||||
throw new NotImplementedError('sshFileSystemBackend');
|
||||
}
|
||||
|
||||
stat(_absPath: string): Promise<AgentFileStat> {
|
||||
throw new NotImplementedError('sshFileSystemBackend');
|
||||
}
|
||||
|
||||
readdir(_absPath: string): Promise<readonly string[]> {
|
||||
throw new NotImplementedError('sshFileSystemBackend');
|
||||
}
|
||||
|
||||
glob(_absDir: string, _pattern: string): Promise<readonly string[]> {
|
||||
throw new NotImplementedError('sshFileSystemBackend');
|
||||
}
|
||||
|
||||
mkdir(_absPath: string): Promise<void> {
|
||||
throw new NotImplementedError('sshFileSystemBackend');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
/**
|
||||
* `environment` domain (L1) — resolved environment paths and OS probe.
|
||||
*
|
||||
* Defines the public contract of the environment: the resolved paths
|
||||
* (`homeDir`, `configPath`) and the `IEnvironmentService` used by other
|
||||
* domains to locate config and detect the host `Environment`, plus the
|
||||
* Core-scope `environmentSeed`. Core-scoped — one shared instance.
|
||||
* Defines the public contract of the environment: the resolved paths the app
|
||||
* writes to (`homeDir`, `configPath`, `sessionsDir`, `blobsDir`, `storeDir`,
|
||||
* `cacheDir`, `logsDir`) and the `IEnvironmentService` used by other domains
|
||||
* to locate those paths and detect the host `Environment`, plus the Core-scope
|
||||
* `environmentSeed`. Pure configuration — it performs no IO. Core-scoped.
|
||||
*/
|
||||
|
||||
import type { Environment } from '@moonshot-ai/kaos';
|
||||
|
|
@ -23,6 +24,11 @@ export interface IEnvironmentService {
|
|||
readonly _serviceBrand: undefined;
|
||||
readonly homeDir: string;
|
||||
readonly configPath: string;
|
||||
readonly sessionsDir: string;
|
||||
readonly blobsDir: string;
|
||||
readonly storeDir: string;
|
||||
readonly cacheDir: string;
|
||||
readonly logsDir: string;
|
||||
detect(): Promise<Environment>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,11 +21,21 @@ export class EnvironmentService implements IEnvironmentService {
|
|||
declare readonly _serviceBrand: undefined;
|
||||
readonly homeDir: string;
|
||||
readonly configPath: string;
|
||||
readonly sessionsDir: string;
|
||||
readonly blobsDir: string;
|
||||
readonly storeDir: string;
|
||||
readonly cacheDir: string;
|
||||
readonly logsDir: string;
|
||||
private detected?: Promise<Environment>;
|
||||
|
||||
constructor(@IEnvironmentOptions options: IEnvironmentOptions) {
|
||||
this.homeDir = options.homeDir;
|
||||
this.configPath = join(options.homeDir, 'config.toml');
|
||||
this.sessionsDir = join(options.homeDir, 'sessions');
|
||||
this.blobsDir = join(options.homeDir, 'blobs');
|
||||
this.storeDir = join(options.homeDir, 'store');
|
||||
this.cacheDir = join(options.homeDir, 'cache');
|
||||
this.logsDir = join(options.homeDir, 'logs');
|
||||
}
|
||||
|
||||
detect(): Promise<Environment> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
/**
|
||||
* `filestore` domain (cross-cutting) — `IFileStore` implementation.
|
||||
*
|
||||
* Stores and retrieves blobs keyed by string; uses the execution environment
|
||||
* through `kaos`. Bound at Core scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IKaosFactory } from '#/kaos';
|
||||
|
||||
import { IFileStore } from './filestore';
|
||||
|
||||
export class FileStore implements IFileStore {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly blobs = new Map<string, Uint8Array>();
|
||||
|
||||
constructor(@IKaosFactory _kaosFactory: IKaosFactory) {}
|
||||
|
||||
put(key: string, data: Uint8Array): Promise<void> {
|
||||
this.blobs.set(key, data);
|
||||
return Promise.resolve();
|
||||
}
|
||||
get(key: string): Promise<Uint8Array | undefined> {
|
||||
return Promise.resolve(this.blobs.get(key));
|
||||
}
|
||||
delete(key: string): Promise<void> {
|
||||
this.blobs.delete(key);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(LifecycleScope.Core, IFileStore, FileStore, InstantiationType.Delayed, 'filestore');
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
/**
|
||||
* `filestore` domain (cross-cutting) — core-scope blob/file store.
|
||||
*
|
||||
* Defines the public contract of the file store: the `IFileStore` used by
|
||||
* other domains to store and retrieve opaque blobs by key. Core-scoped — one
|
||||
* shared instance.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export interface IFileStore {
|
||||
readonly _serviceBrand: undefined;
|
||||
put(key: string, data: Uint8Array): Promise<void>;
|
||||
get(key: string): Promise<Uint8Array | undefined>;
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const IFileStore: ServiceIdentifier<IFileStore> =
|
||||
createDecorator<IFileStore>('fileStore');
|
||||
|
|
@ -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';
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
/**
|
||||
* `fs` domain (cross-cutting) — session-scope filesystem services.
|
||||
*
|
||||
* Defines the public contracts of filesystem access: the `IFsService`,
|
||||
* `IFsSearchService`, `IFsGitService`, and `IFsWatcher` used by tools to read
|
||||
* and write files, search, inspect git state, and watch paths. Session-scoped
|
||||
* — one set of services per session.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export interface IFsService {
|
||||
readonly _serviceBrand: undefined;
|
||||
read(path: string): Promise<string>;
|
||||
write(path: string, content: string): Promise<void>;
|
||||
stat(path: string): Promise<unknown>;
|
||||
mkdir(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const IFsService: ServiceIdentifier<IFsService> =
|
||||
createDecorator<IFsService>('fsService');
|
||||
|
||||
export interface IFsSearchService {
|
||||
readonly _serviceBrand: undefined;
|
||||
grep(pattern: string, path: string): Promise<readonly unknown[]>;
|
||||
glob(pattern: string): Promise<readonly string[]>;
|
||||
}
|
||||
|
||||
export const IFsSearchService: ServiceIdentifier<IFsSearchService> =
|
||||
createDecorator<IFsSearchService>('fsSearchService');
|
||||
|
||||
export interface IFsGitService {
|
||||
readonly _serviceBrand: undefined;
|
||||
status(cwd: string): Promise<string>;
|
||||
diff(cwd: string): Promise<string>;
|
||||
log(cwd: string): Promise<readonly string[]>;
|
||||
}
|
||||
|
||||
export const IFsGitService: ServiceIdentifier<IFsGitService> =
|
||||
createDecorator<IFsGitService>('fsGitService');
|
||||
|
||||
export interface IFsWatcher {
|
||||
readonly _serviceBrand: undefined;
|
||||
watch(path: string): void;
|
||||
unwatch(path: string): void;
|
||||
}
|
||||
|
||||
export const IFsWatcher: ServiceIdentifier<IFsWatcher> =
|
||||
createDecorator<IFsWatcher>('fsWatcher');
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
/**
|
||||
* `fs` domain (cross-cutting) — `IFsService` / `IFsSearchService` /
|
||||
* `IFsGitService` / `IFsWatcher` implementation.
|
||||
*
|
||||
* Owns file I/O, search, git inspection, and path watching; accesses the
|
||||
* filesystem through `kaos` and logs through `log`. Bound at Session scope.
|
||||
*/
|
||||
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import type { Kaos } from '@moonshot-ai/kaos';
|
||||
import { ISessionKaosService } from '#/kaos';
|
||||
import { ILogService } from '#/log';
|
||||
|
||||
import {
|
||||
IFsGitService,
|
||||
IFsSearchService,
|
||||
IFsService,
|
||||
IFsWatcher,
|
||||
} from './fs';
|
||||
|
||||
function readAll(stream: Readable): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
stream.setEncoding('utf8');
|
||||
stream.on('data', (chunk: string) => { data += chunk; });
|
||||
stream.on('end', () => resolve(data));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export class FsService implements IFsService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(
|
||||
@ISessionKaosService private readonly sessionKaos: ISessionKaosService,
|
||||
@ILogService _log: ILogService,
|
||||
) {}
|
||||
|
||||
private get kaos(): Kaos {
|
||||
return this.sessionKaos.toolKaos;
|
||||
}
|
||||
|
||||
read(path: string): Promise<string> {
|
||||
return this.kaos.readText(path);
|
||||
}
|
||||
write(path: string, content: string): Promise<void> {
|
||||
return this.kaos.writeText(path, content).then(() => undefined);
|
||||
}
|
||||
stat(path: string): Promise<unknown> {
|
||||
return this.kaos.stat(path);
|
||||
}
|
||||
async mkdir(path: string): Promise<void> {
|
||||
await this.kaos.mkdir(path, { parents: true, existOk: true });
|
||||
}
|
||||
}
|
||||
|
||||
export class FsSearchService implements IFsSearchService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(
|
||||
@ISessionKaosService private readonly sessionKaos: ISessionKaosService,
|
||||
@ILogService _log: ILogService,
|
||||
) {}
|
||||
|
||||
private get kaos(): Kaos {
|
||||
return this.sessionKaos.toolKaos;
|
||||
}
|
||||
|
||||
async grep(pattern: string, path: string): Promise<readonly unknown[]> {
|
||||
const proc = await this.kaos.exec('grep', '-r', '-n', pattern, path);
|
||||
const out = await readAll(proc.stdout);
|
||||
await proc.wait();
|
||||
return out.split('\n').filter((l) => l.length > 0);
|
||||
}
|
||||
|
||||
async glob(pattern: string): Promise<readonly string[]> {
|
||||
const proc = await this.kaos.exec('find', '.', '-name', pattern);
|
||||
const out = await readAll(proc.stdout);
|
||||
await proc.wait();
|
||||
return out.split('\n').filter((l) => l.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
export class FsGitService implements IFsGitService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(
|
||||
@ISessionKaosService private readonly sessionKaos: ISessionKaosService,
|
||||
@ILogService _log: ILogService,
|
||||
) {}
|
||||
|
||||
private get kaos(): Kaos {
|
||||
return this.sessionKaos.toolKaos;
|
||||
}
|
||||
|
||||
private async git(...args: string[]): Promise<string> {
|
||||
const proc = await this.kaos.exec('git', ...args);
|
||||
const out = await readAll(proc.stdout);
|
||||
await proc.wait();
|
||||
return out;
|
||||
}
|
||||
|
||||
status(_cwd: string): Promise<string> {
|
||||
return this.git('status', '--short');
|
||||
}
|
||||
diff(_cwd: string): Promise<string> {
|
||||
return this.git('diff');
|
||||
}
|
||||
async log(_cwd: string): Promise<readonly string[]> {
|
||||
const out = await this.git('log', '--oneline', '-n', '20');
|
||||
return out.split('\n').filter((l) => l.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
export class FsWatcher extends Disposable implements IFsWatcher {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly watched = new Set<string>();
|
||||
|
||||
constructor(
|
||||
@ISessionKaosService _sessionKaos: ISessionKaosService,
|
||||
@ILogService _log: ILogService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
watch(path: string): void {
|
||||
this.watched.add(path);
|
||||
}
|
||||
unwatch(path: string): void {
|
||||
this.watched.delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(LifecycleScope.Session, IFsService, FsService, InstantiationType.Delayed, 'fs');
|
||||
registerScopedService(LifecycleScope.Session, IFsSearchService, FsSearchService, InstantiationType.Delayed, 'fs');
|
||||
registerScopedService(LifecycleScope.Session, IFsGitService, FsGitService, InstantiationType.Delayed, 'fs');
|
||||
registerScopedService(LifecycleScope.Session, IFsWatcher, FsWatcher, InstantiationType.Delayed, 'fs');
|
||||
|
|
@ -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';
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* `hostFolderBrowser` domain (L1) — host-side folder picker.
|
||||
*
|
||||
* Defines the `IHostFolderBrowser` used by the program side (TUI / server) to
|
||||
* let the user browse the real local filesystem when choosing a workspace
|
||||
* folder. Distinct from the Agent-side `agentFs`, which is sandboxed and may
|
||||
* be remote. Core-scoped.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import type { HostDirEntry } from '#/hostFs';
|
||||
|
||||
export interface FsBrowseResponse {
|
||||
readonly path: string;
|
||||
readonly entries: readonly HostDirEntry[];
|
||||
}
|
||||
|
||||
export interface IHostFolderBrowser {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
browse(absPath?: string): Promise<FsBrowseResponse>;
|
||||
home(): Promise<string>;
|
||||
}
|
||||
|
||||
export const IHostFolderBrowser: ServiceIdentifier<IHostFolderBrowser> =
|
||||
createDecorator<IHostFolderBrowser>('hostFolderBrowser');
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* `hostFolderBrowser` domain (L1) — `IHostFolderBrowser` implementation.
|
||||
*
|
||||
* Browses the real local filesystem through the program-side `hostFs`
|
||||
* primitives. Bound at Core scope.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IHostFileSystem } from '#/hostFs';
|
||||
|
||||
import { type FsBrowseResponse, IHostFolderBrowser } from './hostFolderBrowser';
|
||||
|
||||
export class HostFolderBrowser implements IHostFolderBrowser {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(@IHostFileSystem private readonly hostFs: IHostFileSystem) {}
|
||||
|
||||
async browse(absPath?: string): Promise<FsBrowseResponse> {
|
||||
const path = resolve(absPath ?? homedir());
|
||||
const entries = await this.hostFs.readdir(path);
|
||||
return { path, entries };
|
||||
}
|
||||
|
||||
home(): Promise<string> {
|
||||
return Promise.resolve(homedir());
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Core,
|
||||
IHostFolderBrowser,
|
||||
HostFolderBrowser,
|
||||
InstantiationType.Delayed,
|
||||
'hostFolderBrowser',
|
||||
);
|
||||
9
packages/agent-core-v2/src/hostFolderBrowser/index.ts
Normal file
9
packages/agent-core-v2/src/hostFolderBrowser/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* `hostFolderBrowser` domain barrel — re-exports the host-folder-browser
|
||||
* contract (`hostFolderBrowser`) and its scoped service
|
||||
* (`hostFolderBrowserService`). Importing this barrel registers the
|
||||
* `IHostFolderBrowser` binding into the scope registry.
|
||||
*/
|
||||
|
||||
export * from './hostFolderBrowser';
|
||||
export * from './hostFolderBrowserService';
|
||||
39
packages/agent-core-v2/src/hostFs/hostFs.ts
Normal file
39
packages/agent-core-v2/src/hostFs/hostFs.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* `hostFs` domain (L1) — local real-filesystem primitives.
|
||||
*
|
||||
* Defines the `IHostFileSystem` used by the program side (persistence, skill
|
||||
* loading, workspace registry) to read and write the app's own files on the
|
||||
* real local disk, plus the stat/entry models. Core-scoped — one shared
|
||||
* instance. The Agent side never injects this directly; its local backend
|
||||
* reuses it internally.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export interface HostFileStat {
|
||||
readonly isFile: boolean;
|
||||
readonly isDirectory: boolean;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
export interface HostDirEntry {
|
||||
readonly name: string;
|
||||
readonly isFile: boolean;
|
||||
readonly isDirectory: boolean;
|
||||
}
|
||||
|
||||
export interface IHostFileSystem {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
readText(path: string): Promise<string>;
|
||||
writeText(path: string, data: string): Promise<void>;
|
||||
readBytes(path: string): Promise<Uint8Array>;
|
||||
writeBytes(path: string, data: Uint8Array): Promise<void>;
|
||||
stat(path: string): Promise<HostFileStat>;
|
||||
readdir(path: string): Promise<readonly HostDirEntry[]>;
|
||||
mkdir(path: string, options?: { readonly recursive?: boolean }): Promise<void>;
|
||||
remove(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const IHostFileSystem: ServiceIdentifier<IHostFileSystem> =
|
||||
createDecorator<IHostFileSystem>('hostFileSystem');
|
||||
64
packages/agent-core-v2/src/hostFs/hostFsService.ts
Normal file
64
packages/agent-core-v2/src/hostFs/hostFsService.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* `hostFs` domain (L1) — `IHostFileSystem` implementation.
|
||||
*
|
||||
* Reads and writes the app's own files on the real local disk through
|
||||
* `node:fs/promises`. Bound at Core scope.
|
||||
*/
|
||||
|
||||
import { readFile, readdir, stat, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import { type HostDirEntry, type HostFileStat, IHostFileSystem } from './hostFs';
|
||||
|
||||
export class HostFileSystem implements IHostFileSystem {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
async readText(path: string): Promise<string> {
|
||||
return readFile(path, 'utf8');
|
||||
}
|
||||
|
||||
async writeText(path: string, data: string): Promise<void> {
|
||||
await writeFile(path, data, 'utf8');
|
||||
}
|
||||
|
||||
async readBytes(path: string): Promise<Uint8Array> {
|
||||
const buf = await readFile(path);
|
||||
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
}
|
||||
|
||||
async writeBytes(path: string, data: Uint8Array): Promise<void> {
|
||||
await writeFile(path, data);
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<HostFileStat> {
|
||||
const s = await stat(path);
|
||||
return { isFile: s.isFile(), isDirectory: s.isDirectory(), size: s.size };
|
||||
}
|
||||
|
||||
async readdir(path: string): Promise<readonly HostDirEntry[]> {
|
||||
const entries = await readdir(path, { withFileTypes: true });
|
||||
return entries.map((d) => ({
|
||||
name: d.name,
|
||||
isFile: d.isFile(),
|
||||
isDirectory: d.isDirectory(),
|
||||
}));
|
||||
}
|
||||
|
||||
async mkdir(path: string, options?: { readonly recursive?: boolean }): Promise<void> {
|
||||
await mkdir(path, { recursive: options?.recursive ?? false });
|
||||
}
|
||||
|
||||
async remove(path: string): Promise<void> {
|
||||
await rm(path, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Core,
|
||||
IHostFileSystem,
|
||||
HostFileSystem,
|
||||
InstantiationType.Delayed,
|
||||
'hostFs',
|
||||
);
|
||||
8
packages/agent-core-v2/src/hostFs/index.ts
Normal file
8
packages/agent-core-v2/src/hostFs/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* `hostFs` domain barrel — re-exports the host-filesystem contract
|
||||
* (`hostFs`) and its scoped service (`hostFsService`). Importing this barrel
|
||||
* registers the `IHostFileSystem` binding into the scope registry.
|
||||
*/
|
||||
|
||||
export * from './hostFs';
|
||||
export * from './hostFsService';
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
/**
|
||||
* `kaos` domain (L1) — `IKaosService` implementation.
|
||||
*
|
||||
* Exposes the agent's active `Kaos` instance and working directory, and
|
||||
* switches the working directory on `chdir`. Bound at Agent scope.
|
||||
*/
|
||||
|
||||
import type { Kaos } from '@moonshot-ai/kaos';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import { IKaosService, ISessionKaosService } from './kaos';
|
||||
|
||||
export class AgentKaos implements IKaosService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private _kaos: Kaos;
|
||||
|
||||
constructor(@ISessionKaosService sessionKaos: ISessionKaosService) {
|
||||
this._kaos = sessionKaos.toolKaos;
|
||||
}
|
||||
|
||||
get kaos(): Kaos {
|
||||
return this._kaos;
|
||||
}
|
||||
|
||||
get cwd(): string {
|
||||
return this._kaos.getcwd();
|
||||
}
|
||||
|
||||
chdir(cwd: string): Promise<void> {
|
||||
this._kaos = this._kaos.withCwd(cwd);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IKaosService,
|
||||
AgentKaos,
|
||||
InstantiationType.Delayed,
|
||||
'kaos',
|
||||
);
|
||||
|
|
@ -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';
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import type { Kaos } from '@moonshot-ai/kaos';
|
||||
|
||||
import { createDecorator } from '#/_base/di/instantiation';
|
||||
|
||||
export interface IKaosService {
|
||||
readonly _serviceBrand: undefined;
|
||||
readonly kaos: Kaos | undefined;
|
||||
readonly cwd: string;
|
||||
chdir(cwd: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const IKaosService = createDecorator<IKaosService>('agentKaosService');
|
||||
|
||||
export type KaosFactoryOptions =
|
||||
| { readonly kind: 'local'; readonly cwd?: string }
|
||||
| { readonly kind: 'ssh'; readonly host: string; readonly cwd?: string };
|
||||
|
||||
export interface IKaosFactory {
|
||||
readonly _serviceBrand: undefined;
|
||||
create(options: KaosFactoryOptions): Promise<Kaos>;
|
||||
}
|
||||
|
||||
export const IKaosFactory = createDecorator<IKaosFactory>('kaosFactory');
|
||||
|
||||
export interface ISessionKaosService {
|
||||
readonly _serviceBrand: undefined;
|
||||
readonly toolKaos: Kaos;
|
||||
readonly persistenceKaos: Kaos;
|
||||
readonly systemContextKaos: Kaos;
|
||||
readonly additionalDirs: readonly string[];
|
||||
setToolKaos(kaos: Kaos): void;
|
||||
setPersistenceKaos(kaos: Kaos): void;
|
||||
addAdditionalDir(dir: string): void;
|
||||
removeAdditionalDir(dir: string): void;
|
||||
}
|
||||
|
||||
export const ISessionKaosService =
|
||||
createDecorator<ISessionKaosService>('sessionKaosService');
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
/**
|
||||
* `kaos` domain (L1) — `IKaosFactory` implementation.
|
||||
*
|
||||
* Creates `Kaos` instances for the requested kind; resolves paths through
|
||||
* `environment` and logs through `log`. Bound at Core scope.
|
||||
*/
|
||||
|
||||
import { type Kaos, LocalKaos } from '@moonshot-ai/kaos';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IEnvironmentService } from '#/environment/environment';
|
||||
import { ILogService } from '#/log/log';
|
||||
|
||||
import { type KaosFactoryOptions, IKaosFactory } from './kaos';
|
||||
|
||||
export class KaosFactory implements IKaosFactory {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(
|
||||
@IEnvironmentService _env: IEnvironmentService,
|
||||
@ILogService _log: ILogService,
|
||||
) {}
|
||||
|
||||
async create(options: KaosFactoryOptions): Promise<Kaos> {
|
||||
if (options.kind === 'ssh') {
|
||||
throw new Error('TODO: KaosFactory.create ssh');
|
||||
}
|
||||
const base = await LocalKaos.create();
|
||||
return options.cwd !== undefined ? base.withCwd(options.cwd) : base;
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Core,
|
||||
IKaosFactory,
|
||||
KaosFactory,
|
||||
InstantiationType.Delayed,
|
||||
'kaos',
|
||||
);
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import type { Kaos } from '@moonshot-ai/kaos';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import { IKaosService } from './kaos';
|
||||
|
||||
export interface KaosServiceOptions {
|
||||
readonly kaos?: Kaos;
|
||||
}
|
||||
|
||||
export class KaosService implements IKaosService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(private readonly options: KaosServiceOptions = {}) {}
|
||||
|
||||
get kaos(): Kaos | undefined {
|
||||
return this.options.kaos;
|
||||
}
|
||||
|
||||
get cwd(): string {
|
||||
const kaos = this.options.kaos;
|
||||
if (kaos === undefined) {
|
||||
throw new Error('KaosService.cwd accessed before kaos was provided');
|
||||
}
|
||||
return kaos.getcwd();
|
||||
}
|
||||
|
||||
chdir(): Promise<void> {
|
||||
return Promise.reject(new Error('KaosService.chdir is not supported'));
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IKaosService,
|
||||
KaosService,
|
||||
InstantiationType.Delayed,
|
||||
'kaos',
|
||||
);
|
||||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -16,7 +16,7 @@ import { generateHeroSlug } from "#/_base/utils/hero-slug";
|
|||
import { IContextMemory, type ContextMessage } from '#/contextMemory';
|
||||
import { IContextInjector } from '../contextInjector';
|
||||
import { IEventSink } from '../eventSink';
|
||||
import { IKaosService } from '#/kaos';
|
||||
import { IAgentFileSystem } from '#/agentFs';
|
||||
import { IProfileService } from '#/profile';
|
||||
import { IReplayBuilderService } from '#/replayBuilder';
|
||||
import { ITelemetryService } from '#/telemetry';
|
||||
|
|
@ -53,7 +53,7 @@ export class PlanService extends Disposable implements IPlanService {
|
|||
@IContextMemory private readonly context: IContextMemory,
|
||||
@IWireRecord private readonly wireRecord: IWireRecord,
|
||||
@IEventSink private readonly events: IEventSink,
|
||||
@IKaosService private readonly kaosService: IKaosService,
|
||||
@IAgentFileSystem private readonly agentFs: IAgentFileSystem,
|
||||
@IProfileService private readonly profile: IProfileService,
|
||||
@IReplayBuilderService private readonly replayBuilder: IReplayBuilderService,
|
||||
@IToolRegistry toolRegistry: IToolRegistry,
|
||||
|
|
@ -200,11 +200,9 @@ export class PlanService extends Disposable implements IPlanService {
|
|||
|
||||
async status(): Promise<PlanData> {
|
||||
if (this.planId === null || this._planFilePath === null) return null;
|
||||
const kaos = this.kaosService.kaos;
|
||||
if (kaos === undefined) return null;
|
||||
let content = '';
|
||||
try {
|
||||
content = await kaos.readText(this._planFilePath);
|
||||
content = await this.agentFs.readText(this._planFilePath);
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) throw error;
|
||||
}
|
||||
|
|
@ -362,14 +360,11 @@ export class PlanService extends Disposable implements IPlanService {
|
|||
|
||||
private async writeEmptyPlanFile(path: string): Promise<void> {
|
||||
await this.ensurePlanDirectory(path);
|
||||
await this.kaosService.kaos?.writeText(path, '');
|
||||
await this.agentFs.writeText(path, '');
|
||||
}
|
||||
|
||||
private async ensurePlanDirectory(path: string): Promise<void> {
|
||||
await this.kaosService.kaos?.mkdir(dirname(path), {
|
||||
parents: true,
|
||||
existOk: true,
|
||||
});
|
||||
await this.agentFs.mkdir(dirname(path));
|
||||
}
|
||||
|
||||
private currentCwd(): string {
|
||||
|
|
|
|||
12
packages/agent-core-v2/src/process/index.ts
Normal file
12
packages/agent-core-v2/src/process/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* `process` domain barrel — re-exports the process contract (`process`), its
|
||||
* scoped service (`processRunnerService`), and the backend implementations
|
||||
* (`localProcessBackend`, `sshProcessBackend`). Importing this barrel
|
||||
* registers the `IProcessRunner` and default local `IProcessBackend` bindings
|
||||
* into the scope registry.
|
||||
*/
|
||||
|
||||
export * from './process';
|
||||
export * from './processRunnerService';
|
||||
export * from './localProcessBackend';
|
||||
export * from './sshProcessBackend';
|
||||
72
packages/agent-core-v2/src/process/localProcessBackend.ts
Normal file
72
packages/agent-core-v2/src/process/localProcessBackend.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* `process` domain (L1) — local `IProcessBackend` implementation.
|
||||
*
|
||||
* Spawns real child processes on the host through `node:child_process`.
|
||||
* Registered as the default `IProcessBackend` at Session scope; remote
|
||||
* backends override it via the scope registry.
|
||||
*/
|
||||
|
||||
import { type ChildProcess, spawn } from 'node:child_process';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import { type IProcess, IProcessBackend } from './process';
|
||||
|
||||
class LocalProcess implements IProcess {
|
||||
readonly stdin: Writable;
|
||||
readonly stdout: Readable;
|
||||
readonly stderr: Readable;
|
||||
readonly pid: number;
|
||||
|
||||
constructor(private readonly child: ChildProcess) {
|
||||
if (child.stdin === null || child.stdout === null || child.stderr === null) {
|
||||
throw new Error('LocalProcess: child must be spawned with piped stdio.');
|
||||
}
|
||||
this.stdin = child.stdin;
|
||||
this.stdout = child.stdout;
|
||||
this.stderr = child.stderr;
|
||||
this.pid = child.pid ?? -1;
|
||||
}
|
||||
|
||||
wait(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.child.once('exit', (code) => resolve(code ?? -1));
|
||||
this.child.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
kill(signal?: NodeJS.Signals): Promise<void> {
|
||||
this.child.kill(signal);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProcessBackend implements IProcessBackend {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
spawn(
|
||||
args: readonly string[],
|
||||
options: { readonly cwd: string; readonly env?: Record<string, string> },
|
||||
): Promise<IProcess> {
|
||||
const [command, ...rest] = args;
|
||||
if (command === undefined) {
|
||||
return Promise.reject(new Error('LocalProcessBackend.spawn: command is required.'));
|
||||
}
|
||||
const child = spawn(command, rest, {
|
||||
cwd: options.cwd,
|
||||
env: options.env === undefined ? process.env : { ...process.env, ...options.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
return Promise.resolve(new LocalProcess(child));
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
IProcessBackend,
|
||||
LocalProcessBackend,
|
||||
InstantiationType.Delayed,
|
||||
'process',
|
||||
);
|
||||
48
packages/agent-core-v2/src/process/process.ts
Normal file
48
packages/agent-core-v2/src/process/process.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* `process` domain (L1) — the Agent's process runner and its pluggable backend.
|
||||
*
|
||||
* Defines the `IProcessRunner` that business code injects to spawn processes
|
||||
* inside the Agent's execution environment, the `IProcess` handle it returns,
|
||||
* and the internal `IProcessBackend` provider that hides the
|
||||
* local/ssh/container split. Session-scoped. Business code depends on
|
||||
* `IProcessRunner` only; the backend is wired through the scope registry.
|
||||
*/
|
||||
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export interface IProcess {
|
||||
readonly stdin: Writable;
|
||||
readonly stdout: Readable;
|
||||
readonly stderr: Readable;
|
||||
readonly pid: number;
|
||||
wait(): Promise<number>;
|
||||
kill(signal?: NodeJS.Signals): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ProcessExecOptions {
|
||||
readonly cwd?: string;
|
||||
readonly env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IProcessRunner {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
exec(args: readonly string[], options?: ProcessExecOptions): Promise<IProcess>;
|
||||
}
|
||||
|
||||
export const IProcessRunner: ServiceIdentifier<IProcessRunner> =
|
||||
createDecorator<IProcessRunner>('processRunner');
|
||||
|
||||
export interface IProcessBackend {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
spawn(
|
||||
args: readonly string[],
|
||||
options: { readonly cwd: string; readonly env?: Record<string, string> },
|
||||
): Promise<IProcess>;
|
||||
}
|
||||
|
||||
export const IProcessBackend: ServiceIdentifier<IProcessBackend> =
|
||||
createDecorator<IProcessBackend>('processBackend');
|
||||
41
packages/agent-core-v2/src/process/processRunnerService.ts
Normal file
41
packages/agent-core-v2/src/process/processRunnerService.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* `process` domain (L1) — `IProcessRunner` implementation.
|
||||
*
|
||||
* Resolves the working directory through `workspaceContext` and delegates
|
||||
* spawning to the injected `IProcessBackend`. Bound at Session scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IWorkspaceContext } from '#/workspaceContext';
|
||||
|
||||
import {
|
||||
type IProcess,
|
||||
IProcessBackend,
|
||||
IProcessRunner,
|
||||
type ProcessExecOptions,
|
||||
} from './process';
|
||||
|
||||
export class ProcessRunner implements IProcessRunner {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(
|
||||
@IProcessBackend private readonly backend: IProcessBackend,
|
||||
@IWorkspaceContext private readonly workspace: IWorkspaceContext,
|
||||
) {}
|
||||
|
||||
exec(args: readonly string[], options?: ProcessExecOptions): Promise<IProcess> {
|
||||
return this.backend.spawn(args, {
|
||||
cwd: options?.cwd ?? this.workspace.workDir,
|
||||
env: options?.env,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
IProcessRunner,
|
||||
ProcessRunner,
|
||||
InstantiationType.Delayed,
|
||||
'process',
|
||||
);
|
||||
22
packages/agent-core-v2/src/process/sshProcessBackend.ts
Normal file
22
packages/agent-core-v2/src/process/sshProcessBackend.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* `process` domain (L1) — ssh `IProcessBackend` stub.
|
||||
*
|
||||
* Placeholder for the remote backend; not registered into the scope registry
|
||||
* yet. A composition root that needs ssh supplies it through
|
||||
* `ScopeOptions.extra` to override the local backend.
|
||||
*/
|
||||
|
||||
import { NotImplementedError } from '#/_base/errors';
|
||||
|
||||
import { type IProcess, IProcessBackend } from './process';
|
||||
|
||||
export class SshProcessBackend implements IProcessBackend {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
spawn(
|
||||
_args: readonly string[],
|
||||
_options: { readonly cwd: string; readonly env?: Record<string, string> },
|
||||
): Promise<IProcess> {
|
||||
throw new NotImplementedError('sshProcessBackend');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +1,37 @@
|
|||
/**
|
||||
* `sessionMetaStore` — session-scope metadata persistence.
|
||||
* `sessionMetaStore` domain (L2) — `ISessionMetaStore` implementation.
|
||||
*
|
||||
* Persists session metadata as a single atomic document through the program
|
||||
* side `storage` (`IConfigStore`). Bound at Session scope.
|
||||
*/
|
||||
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { ISessionKaosService } from '#/kaos';
|
||||
import { ILogService } from '#/log';
|
||||
import { IConfigStore } from '#/storage';
|
||||
|
||||
import { ISessionMetaStore } from './sessionMetaStore';
|
||||
|
||||
const SCOPE = 'session-meta';
|
||||
|
||||
export class SessionMetaStore extends Disposable implements ISessionMetaStore {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private data: Record<string, unknown> = {};
|
||||
private readonly path: string;
|
||||
private readonly key: string;
|
||||
|
||||
constructor(
|
||||
@ISessionKaosService private readonly sessionKaos: ISessionKaosService,
|
||||
@IConfigStore private readonly configStore: IConfigStore,
|
||||
@ILogService _log: ILogService,
|
||||
path: string = 'state.json',
|
||||
key: string = 'state.json',
|
||||
) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
async read(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const text = await this.sessionKaos.persistenceKaos.readText(this.path);
|
||||
this.data = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
this.data = {};
|
||||
}
|
||||
this.data =
|
||||
(await this.configStore.get<Record<string, unknown>>(SCOPE, this.key)) ?? {};
|
||||
return this.data;
|
||||
}
|
||||
|
||||
|
|
@ -40,10 +41,7 @@ export class SessionMetaStore extends Disposable implements ISessionMetaStore {
|
|||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.sessionKaos.persistenceKaos.writeText(
|
||||
this.path,
|
||||
JSON.stringify(this.data, null, 2),
|
||||
);
|
||||
await this.configStore.set(SCOPE, this.key, this.data);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
/**
|
||||
* `sessionStore` — core-scope session directory store.
|
||||
* `sessionStore` domain (L2) — `ISessionStore` implementation.
|
||||
*
|
||||
* Enumerates session directories on the real local disk through the program
|
||||
* side `hostFs` primitives. Bound at Core scope.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { slugifyWorkDirName } from '#/_base/utils/workdir-slug';
|
||||
import { IKaosFactory } from '#/kaos';
|
||||
import { IHostFileSystem } from '#/hostFs';
|
||||
|
||||
import { ISessionStore } from './sessionStore';
|
||||
|
||||
|
|
@ -26,7 +28,8 @@ export function encodeWorkDirKey(workDir: string): string {
|
|||
|
||||
export class SessionStore implements ISessionStore {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
constructor(@IKaosFactory _kaosFactory: IKaosFactory) {}
|
||||
|
||||
constructor(@IHostFileSystem private readonly hostFs: IHostFileSystem) {}
|
||||
|
||||
sessionDir(sessionsRoot: string, workDir: string, sessionId: string): string {
|
||||
return `${sessionsRoot}/${encodeWorkDirKey(workDir)}/${sessionId}`;
|
||||
|
|
@ -38,37 +41,34 @@ export class SessionStore implements ISessionStore {
|
|||
|
||||
async countActiveSessions(sessionsRoot: string, workDir: string): Promise<number> {
|
||||
const dir = join(sessionsRoot, encodeWorkDirKey(workDir));
|
||||
let dirents;
|
||||
let entries;
|
||||
try {
|
||||
dirents = await fsp.readdir(dir, { withFileTypes: true });
|
||||
entries = await this.hostFs.readdir(dir);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') return 0;
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0;
|
||||
throw err;
|
||||
}
|
||||
let count = 0;
|
||||
for (const d of dirents) {
|
||||
if (!d.isDirectory()) continue;
|
||||
if (await isSessionArchived(join(dir, d.name))) continue;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory) continue;
|
||||
if (await this.isSessionArchived(join(dir, entry.name))) continue;
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
async function isSessionArchived(sessionDir: string): Promise<boolean> {
|
||||
try {
|
||||
const raw = await fsp.readFile(join(sessionDir, 'state.json'), 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
(parsed as { archived?: boolean }).archived === true
|
||||
);
|
||||
} catch {
|
||||
// Treat unreadable/missing state.json as non-archived so the directory still
|
||||
// counts as a session (matches the session store's own loading behavior).
|
||||
return false;
|
||||
private async isSessionArchived(sessionDir: string): Promise<boolean> {
|
||||
try {
|
||||
const raw = await this.hostFs.readText(join(sessionDir, 'state.json'));
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
(parsed as { archived?: boolean }).archived === true
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
74
packages/agent-core-v2/src/storage/configStore.ts
Normal file
74
packages/agent-core-v2/src/storage/configStore.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* `IConfigStore` / `ConfigStore` — the typed atomic-document service.
|
||||
*
|
||||
* Sits on top of `IStorageService` and stores one typed JSON value per
|
||||
* `(scope, key)`, replaced atomically on every write. This is the `Config`
|
||||
* access pattern: `state.json`, `upcoming-goals.json`, per-id cron/background
|
||||
* records, etc.
|
||||
*
|
||||
* It is a DI service: domains inject `IConfigStore` and call `get/set` with
|
||||
* the scope they own — they do not construct stores themselves. JSON
|
||||
* (de)serialization and atomic replacement are centralized here so domains
|
||||
* do not reimplement them.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import { IStorageService } from './storageService';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export interface IConfigStore {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** Read the value at `(scope, key)`, or `undefined` when absent. */
|
||||
get<T>(scope: string, key: string): Promise<T | undefined>;
|
||||
|
||||
/** Atomically replace the value at `(scope, key)`. */
|
||||
set<T>(scope: string, key: string, value: T): Promise<void>;
|
||||
|
||||
/** Delete `(scope, key)`. Missing keys are not an error. */
|
||||
delete(scope: string, key: string): Promise<void>;
|
||||
|
||||
/** List the keys under `scope`, optionally filtered by `prefix`. */
|
||||
list(scope: string, prefix?: string): Promise<readonly string[]>;
|
||||
}
|
||||
|
||||
export const IConfigStore: ServiceIdentifier<IConfigStore> =
|
||||
createDecorator<IConfigStore>('configStore');
|
||||
|
||||
export class ConfigStore implements IConfigStore {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(@IStorageService private readonly storage: IStorageService) {}
|
||||
|
||||
async get<T>(scope: string, key: string): Promise<T | undefined> {
|
||||
const bytes = await this.storage.read(scope, key);
|
||||
return bytes === undefined ? undefined : (JSON.parse(textDecoder.decode(bytes)) as T);
|
||||
}
|
||||
|
||||
async set<T>(scope: string, key: string, value: T): Promise<void> {
|
||||
await this.storage.write(scope, key, textEncoder.encode(JSON.stringify(value)), {
|
||||
atomic: true,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(scope: string, key: string): Promise<void> {
|
||||
await this.storage.delete(scope, key);
|
||||
}
|
||||
|
||||
async list(scope: string, prefix?: string): Promise<readonly string[]> {
|
||||
return this.storage.list(scope, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
IConfigStore,
|
||||
ConfigStore,
|
||||
InstantiationType.Delayed,
|
||||
'storage',
|
||||
);
|
||||
125
packages/agent-core-v2/src/storage/fileStorageService.ts
Normal file
125
packages/agent-core-v2/src/storage/fileStorageService.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* `FileStorageService` — `IStorageService` backed by the local filesystem.
|
||||
*
|
||||
* Layout: a value addressed by `(scope, key)` lives at
|
||||
* `<baseDir>/<scope>/<key>`. `scope` may contain slashes to form nested
|
||||
* directories (e.g. `"agents/main"`).
|
||||
*
|
||||
* Primitives:
|
||||
* - `write` → `atomicWrite` (tmp + fsync + rename) followed by a directory
|
||||
* fsync, so the replacement is both atomic and durable.
|
||||
* - `append` → `open('a')` + write + `fh.sync()` (when `durable`), plus a
|
||||
* one-time directory fsync per scope.
|
||||
*
|
||||
* It uses raw `node:fs` (like `wireRecord/persistence.ts` and
|
||||
* `blobStoreService.ts`) rather than `kaos`: the storage kernel needs direct
|
||||
* control over append offsets, fsync, atomic rename and (future) streaming,
|
||||
* which the agent-execution-environment abstraction does not expose.
|
||||
*/
|
||||
|
||||
import { mkdir, open, readFile, readdir, unlink } from 'node:fs/promises';
|
||||
import { dirname, join } from 'pathe';
|
||||
|
||||
import { atomicWrite, syncDir } from '#/_base/utils/fs';
|
||||
|
||||
import type {
|
||||
IStorageService,
|
||||
StorageAppendOptions,
|
||||
StorageWriteOptions,
|
||||
} from './storageService';
|
||||
|
||||
function isEnoent(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException).code === 'ENOENT';
|
||||
}
|
||||
|
||||
export class FileStorageService implements IStorageService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly syncedDirs = new Set<string>();
|
||||
|
||||
constructor(private readonly baseDir: string) {}
|
||||
|
||||
async read(scope: string, key: string): Promise<Uint8Array | undefined> {
|
||||
try {
|
||||
return await readFile(this.path(scope, key));
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async write(
|
||||
scope: string,
|
||||
key: string,
|
||||
data: Uint8Array,
|
||||
_options: StorageWriteOptions = {},
|
||||
): Promise<void> {
|
||||
const filePath = this.path(scope, key);
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await atomicWrite(filePath, data);
|
||||
await this.syncDirOnce(dirname(filePath));
|
||||
}
|
||||
|
||||
async append(
|
||||
scope: string,
|
||||
key: string,
|
||||
data: Uint8Array,
|
||||
options: StorageAppendOptions = {},
|
||||
): Promise<void> {
|
||||
const filePath = this.path(scope, key);
|
||||
const dir = dirname(filePath);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const fh = await open(filePath, 'a');
|
||||
try {
|
||||
if (data.byteLength > 0) {
|
||||
await fh.writeFile(data);
|
||||
}
|
||||
if (options.durable !== false) {
|
||||
await fh.sync();
|
||||
}
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
await this.syncDirOnce(dir);
|
||||
}
|
||||
|
||||
async list(scope: string, prefix?: string): Promise<readonly string[]> {
|
||||
let entries: readonly string[];
|
||||
try {
|
||||
entries = await readdir(this.scopePath(scope));
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
return prefix === undefined ? entries : entries.filter((entry) => entry.startsWith(prefix));
|
||||
}
|
||||
|
||||
async delete(scope: string, key: string): Promise<void> {
|
||||
try {
|
||||
await unlink(this.path(scope, key));
|
||||
} catch (error) {
|
||||
if (!isEnoent(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
// Writes resolve only after the bytes are durable; nothing is buffered.
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
|
||||
private path(scope: string, key: string): string {
|
||||
return join(this.baseDir, scope, key);
|
||||
}
|
||||
|
||||
private scopePath(scope: string): string {
|
||||
return join(this.baseDir, scope);
|
||||
}
|
||||
|
||||
private async syncDirOnce(dir: string): Promise<void> {
|
||||
if (this.syncedDirs.has(dir)) return;
|
||||
await syncDir(dir);
|
||||
this.syncedDirs.add(dir);
|
||||
}
|
||||
}
|
||||
91
packages/agent-core-v2/src/storage/inMemoryStorageService.ts
Normal file
91
packages/agent-core-v2/src/storage/inMemoryStorageService.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* `InMemoryStorageService` — `IStorageService` backed by in-memory maps.
|
||||
*
|
||||
* Registered as the default `IStorageService` so scopes and tests work out of
|
||||
* the box. For durable production storage, the composition root seeds a
|
||||
* `FileStorageService` (rooted at the session directory) into the scope via
|
||||
* `ScopeOptions.extra`, overriding this default — the same pattern
|
||||
* `blobStoreService` uses.
|
||||
*
|
||||
* `append` concatenates into the same key slot `write` replaces, mirroring the
|
||||
* file implementation's single-namespace semantics so the two are
|
||||
* interchangeable for the facades above.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import {
|
||||
IStorageService,
|
||||
type StorageAppendOptions,
|
||||
type StorageWriteOptions,
|
||||
} from './storageService';
|
||||
|
||||
export class InMemoryStorageService implements IStorageService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly scopes = new Map<string, Map<string, Uint8Array>>();
|
||||
|
||||
async read(scope: string, key: string): Promise<Uint8Array | undefined> {
|
||||
return this.scopes.get(scope)?.get(key);
|
||||
}
|
||||
|
||||
async write(
|
||||
scope: string,
|
||||
key: string,
|
||||
data: Uint8Array,
|
||||
_options: StorageWriteOptions = {},
|
||||
): Promise<void> {
|
||||
this.bucket(scope).set(key, data);
|
||||
}
|
||||
|
||||
async append(
|
||||
scope: string,
|
||||
key: string,
|
||||
data: Uint8Array,
|
||||
_options: StorageAppendOptions = {},
|
||||
): Promise<void> {
|
||||
const bucket = this.bucket(scope);
|
||||
const existing = bucket.get(key);
|
||||
if (existing === undefined) {
|
||||
bucket.set(key, data);
|
||||
return;
|
||||
}
|
||||
const merged = new Uint8Array(existing.byteLength + data.byteLength);
|
||||
merged.set(existing, 0);
|
||||
merged.set(data, existing.byteLength);
|
||||
bucket.set(key, merged);
|
||||
}
|
||||
|
||||
async list(scope: string, prefix?: string): Promise<readonly string[]> {
|
||||
const bucket = this.scopes.get(scope);
|
||||
if (bucket === undefined) return [];
|
||||
const keys = [...bucket.keys()];
|
||||
return prefix === undefined ? keys : keys.filter((key) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
async delete(scope: string, key: string): Promise<void> {
|
||||
this.scopes.get(scope)?.delete(key);
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
|
||||
private bucket(scope: string): Map<string, Uint8Array> {
|
||||
let bucket = this.scopes.get(scope);
|
||||
if (bucket === undefined) {
|
||||
bucket = new Map();
|
||||
this.scopes.set(scope, bucket);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
IStorageService,
|
||||
InMemoryStorageService,
|
||||
InstantiationType.Delayed,
|
||||
'storage',
|
||||
);
|
||||
6
packages/agent-core-v2/src/storage/index.ts
Normal file
6
packages/agent-core-v2/src/storage/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from './storageService';
|
||||
export * from './fileStorageService';
|
||||
export * from './inMemoryStorageService';
|
||||
export * from './recordStore';
|
||||
export * from './configStore';
|
||||
export * from './queryStore';
|
||||
114
packages/agent-core-v2/src/storage/queryStore.ts
Normal file
114
packages/agent-core-v2/src/storage/queryStore.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* `IQueryStore` — the indexed, queryable read-model facade.
|
||||
*
|
||||
* A peer of `IRecordStore` and `IConfigStore`. Where `IRecordStore` is the
|
||||
* authoritative append-only write model and `IConfigStore` holds atomic
|
||||
* documents, `IQueryStore` serves fast, indexed, paginated reads over a
|
||||
* *derived* dataset — typically materialized from a record log by a projector.
|
||||
*
|
||||
* This file intentionally ships the interface only. A concrete implementation
|
||||
* (e.g. backed by `minidb`) and the projector that feeds it are a follow-up;
|
||||
* the contract is fixed here so domains can depend on it without coupling to
|
||||
* any specific engine.
|
||||
*
|
||||
* `collection` is a logical table (an engine may encode it as a key prefix).
|
||||
* Values are plain JSON-shaped objects; indexes are declared over their fields.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export type SortDir = 'asc' | 'desc';
|
||||
|
||||
export interface Page<T> {
|
||||
readonly items: readonly T[];
|
||||
/** Opaque token for the next page, or `undefined` when exhausted. */
|
||||
readonly nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface ComparisonOp {
|
||||
readonly $eq?: unknown;
|
||||
readonly $ne?: unknown;
|
||||
readonly $gt?: number | string;
|
||||
readonly $gte?: number | string;
|
||||
readonly $lt?: number | string;
|
||||
readonly $lte?: number | string;
|
||||
readonly $in?: readonly unknown[];
|
||||
readonly $nin?: readonly unknown[];
|
||||
readonly $exists?: boolean;
|
||||
}
|
||||
|
||||
/** A field mapped to a scalar (shorthand for `$eq`) or a set of operators. */
|
||||
export type QueryFilter = {
|
||||
readonly [field: string]: unknown | ComparisonOp;
|
||||
};
|
||||
|
||||
export interface IQuery<T> {
|
||||
where(filter: QueryFilter): IQuery<T>;
|
||||
orderBy(field: string, dir?: SortDir): IQuery<T>;
|
||||
limit(n: number): IQuery<T>;
|
||||
cursor(cursor: string | undefined): IQuery<T>;
|
||||
execute(): Promise<Page<T>>;
|
||||
}
|
||||
|
||||
export interface ValueIndexDef {
|
||||
readonly kind: 'value';
|
||||
readonly name: string;
|
||||
/** Dot/bracket path into the value, e.g. `"model"` or `"meta.user"`. */
|
||||
readonly field: string;
|
||||
readonly unique?: boolean;
|
||||
}
|
||||
|
||||
export interface CompoundIndexDef {
|
||||
readonly kind: 'compound';
|
||||
readonly name: string;
|
||||
readonly groupBy: string;
|
||||
readonly orderBy: string;
|
||||
}
|
||||
|
||||
export interface TextIndexDef {
|
||||
readonly kind: 'text';
|
||||
readonly name: string;
|
||||
/** Fields to tokenize; empty/omitted means the whole value. */
|
||||
readonly fields?: readonly string[];
|
||||
}
|
||||
|
||||
export type IndexDef = ValueIndexDef | CompoundIndexDef | TextIndexDef;
|
||||
|
||||
export type WriteOp =
|
||||
| { readonly kind: 'put'; readonly collection: string; readonly key: string; readonly value: unknown }
|
||||
| { readonly kind: 'delete'; readonly collection: string; readonly key: string };
|
||||
|
||||
/** Position a projector has reached in its source log. */
|
||||
export interface Checkpoint {
|
||||
readonly seq: number;
|
||||
}
|
||||
|
||||
export interface IQueryStore {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** Upsert a value (projector write path). */
|
||||
put<T>(collection: string, key: string, value: T): Promise<void>;
|
||||
|
||||
/** Apply several writes atomically. */
|
||||
batch(ops: readonly WriteOp[]): Promise<void>;
|
||||
|
||||
delete(collection: string, key: string): Promise<void>;
|
||||
|
||||
get<T>(collection: string, key: string): Promise<T | undefined>;
|
||||
|
||||
/** Start a query against a collection (read path). */
|
||||
query<T>(collection: string): IQuery<T>;
|
||||
|
||||
/** Declare an index. Idempotent — re-declaring an existing index is a no-op. */
|
||||
ensureIndex(collection: string, def: IndexDef): Promise<void>;
|
||||
|
||||
/** Read how far a projector has indexed a given source log. */
|
||||
getCheckpoint(source: string): Promise<Checkpoint | undefined>;
|
||||
|
||||
/** Persist a projector's progress for a given source log. */
|
||||
setCheckpoint(source: string, checkpoint: Checkpoint): Promise<void>;
|
||||
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export const IQueryStore: ServiceIdentifier<IQueryStore> = createDecorator<IQueryStore>('queryStore');
|
||||
190
packages/agent-core-v2/src/storage/recordStore.ts
Normal file
190
packages/agent-core-v2/src/storage/recordStore.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* `IRecordStore` / `RecordStore` — the typed append-log service.
|
||||
*
|
||||
* Sits on top of `IStorageService` and turns a byte stream into an ordered
|
||||
* sequence of typed JSON records. Owns the concerns the storage service
|
||||
* deliberately ignores: line framing (one JSON value per line, a.k.a. JSONL),
|
||||
* batching of appends into a single durable `append`, and crash-tolerant
|
||||
* decoding (a torn final line is dropped; corruption anywhere else throws).
|
||||
*
|
||||
* It is a DI service: domains inject `IRecordStore` and call
|
||||
* `append/read/rewrite` with the `(scope, key)` of the log they own. Buffering
|
||||
* is kept per log inside the service, so many appends within a synchronous
|
||||
* block collapse into one durable write.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import { IStorageService } from './storageService';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export class RecordCorruptedError extends Error {
|
||||
constructor(
|
||||
readonly scope: string,
|
||||
readonly key: string,
|
||||
readonly lineNumber: number,
|
||||
cause: unknown,
|
||||
) {
|
||||
super(`record log ${scope}/${key}: corrupted line ${lineNumber}: ${String(cause)}`);
|
||||
this.name = 'RecordCorruptedError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface IRecordStore {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** Buffer a record for the next durable append. Resolves immediately. */
|
||||
append<R>(scope: string, key: string, record: R): void;
|
||||
|
||||
/**
|
||||
* Replay the log in order. Flushes pending appends first. A torn final line
|
||||
* (crash mid-flush) is dropped; any other corruption throws.
|
||||
*/
|
||||
read<R>(scope: string, key: string): AsyncIterable<R>;
|
||||
|
||||
/** Atomically replace the whole log with `records` (used after migration). */
|
||||
rewrite<R>(scope: string, key: string, records: readonly R[]): Promise<void>;
|
||||
|
||||
/** Durable-write every buffered record across all logs. */
|
||||
flush(): Promise<void>;
|
||||
|
||||
/** Flush and release resources. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export const IRecordStore: ServiceIdentifier<IRecordStore> =
|
||||
createDecorator<IRecordStore>('recordStore');
|
||||
|
||||
interface LogState {
|
||||
pending: unknown[];
|
||||
flushPromise: Promise<void> | undefined;
|
||||
flushScheduled: boolean;
|
||||
}
|
||||
|
||||
export class RecordStore implements IRecordStore {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly logs = new Map<string, LogState>();
|
||||
|
||||
constructor(@IStorageService private readonly storage: IStorageService) {}
|
||||
|
||||
append<R>(scope: string, key: string, record: R): void {
|
||||
const state = this.state(scope, key);
|
||||
state.pending.push(record);
|
||||
this.scheduleFlush(scope, key, state);
|
||||
}
|
||||
|
||||
async *read<R>(scope: string, key: string): AsyncIterable<R> {
|
||||
await this.flushLog(scope, key);
|
||||
const bytes = await this.storage.read(scope, key);
|
||||
if (bytes === undefined) return;
|
||||
|
||||
const lines = textDecoder.decode(bytes).split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i]!;
|
||||
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
||||
if (line.length === 0) continue;
|
||||
const isLast = i === lines.length - 1;
|
||||
try {
|
||||
yield JSON.parse(line) as R;
|
||||
} catch (error) {
|
||||
// A crash can leave a half-written last line; drop it. Corruption
|
||||
// anywhere before the end is real and must surface.
|
||||
if (isLast) return;
|
||||
throw new RecordCorruptedError(scope, key, i + 1, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async rewrite<R>(scope: string, key: string, records: readonly R[]): Promise<void> {
|
||||
// Persist anything already buffered, then atomically replace the log.
|
||||
await this.flushLog(scope, key);
|
||||
await this.storage.write(scope, key, encodeBatch(records), { atomic: true });
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
const inFlight = [...this.logs.keys()].map((id) => {
|
||||
const { scope, key } = fromLogId(id);
|
||||
return this.flushLog(scope, key);
|
||||
});
|
||||
await Promise.all(inFlight);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
private state(scope: string, key: string): LogState {
|
||||
const id = logId(scope, key);
|
||||
let state = this.logs.get(id);
|
||||
if (state === undefined) {
|
||||
state = { pending: [], flushPromise: undefined, flushScheduled: false };
|
||||
this.logs.set(id, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defer the drain to the next microtask so records appended within the same
|
||||
* synchronous block accumulate into a single durable `IStorageService.append`.
|
||||
*/
|
||||
private scheduleFlush(scope: string, key: string, state: LogState): void {
|
||||
if (state.flushScheduled || state.flushPromise !== undefined) return;
|
||||
state.flushScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
state.flushScheduled = false;
|
||||
void this.flushLog(scope, key);
|
||||
});
|
||||
}
|
||||
|
||||
private flushLog(scope: string, key: string): Promise<void> {
|
||||
const state = this.state(scope, key);
|
||||
if (state.flushPromise !== undefined) return state.flushPromise;
|
||||
|
||||
const promise = this.drain(scope, key, state).finally(() => {
|
||||
if (state.flushPromise === promise) {
|
||||
state.flushPromise = undefined;
|
||||
}
|
||||
// Records appended during the drain must be drained too.
|
||||
if (state.pending.length > 0) {
|
||||
void this.flushLog(scope, key);
|
||||
}
|
||||
});
|
||||
state.flushPromise = promise;
|
||||
return promise;
|
||||
}
|
||||
|
||||
private async drain(scope: string, key: string, state: LogState): Promise<void> {
|
||||
while (state.pending.length > 0) {
|
||||
const batch = state.pending.splice(0);
|
||||
await this.storage.append(scope, key, encodeBatch(batch), { durable: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logId(scope: string, key: string): string {
|
||||
return `${scope}\n${key}`;
|
||||
}
|
||||
|
||||
function fromLogId(id: string): { scope: string; key: string } {
|
||||
const index = id.indexOf('\n');
|
||||
return { scope: id.slice(0, index), key: id.slice(index + 1) };
|
||||
}
|
||||
|
||||
function encodeBatch(records: readonly unknown[]): Uint8Array {
|
||||
if (records.length === 0) return new Uint8Array(0);
|
||||
const content = records.map((record) => JSON.stringify(record) + '\n').join('');
|
||||
return textEncoder.encode(content);
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
IRecordStore,
|
||||
RecordStore,
|
||||
InstantiationType.Delayed,
|
||||
'storage',
|
||||
);
|
||||
71
packages/agent-core-v2/src/storage/storageService.ts
Normal file
71
packages/agent-core-v2/src/storage/storageService.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* `storage` domain — the bottom-most persistence substrate.
|
||||
*
|
||||
* `IStorageService` is the single, backend-pluggable byte store that every
|
||||
* other local-persistence abstraction is built on. It exposes the two
|
||||
* irreducible durable primitives side by side:
|
||||
*
|
||||
* - `write` — atomic whole-value replacement (the `Config` access pattern).
|
||||
* - `append` — ordered, durable byte extension (the `Record` access pattern).
|
||||
*
|
||||
* They are not interchangeable: building `append` on top of `write` is O(n)
|
||||
* per append, and building `write` on top of `append` yields awkward "read
|
||||
* the last value" semantics. Keeping both as first-class primitives lets each
|
||||
* implementation implement them optimally (file: `open('a')` vs tmp+rename;
|
||||
* db: `INSERT` vs `UPSERT`).
|
||||
*
|
||||
* The service is intentionally byte-oriented and scope/key-addressed: it knows
|
||||
* nothing about JSON, records, configs, versions or framing. Those concerns
|
||||
* live in the typed facades above it (`IRecordStore`, `IConfigStore`).
|
||||
*
|
||||
* `scope`/`key` are trusted internal path segments for the file implementation
|
||||
* (e.g. scope `"agents/main"`, key `"wire.jsonl"`); they are not user input.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export interface StorageWriteOptions {
|
||||
/**
|
||||
* Replace atomically so readers never observe a half-written value. The file
|
||||
* implementation always writes atomically; the option is reserved for
|
||||
* implementations where atomicity is opt-in. Defaults to `true`.
|
||||
*/
|
||||
readonly atomic?: boolean;
|
||||
}
|
||||
|
||||
export interface StorageAppendOptions {
|
||||
/**
|
||||
* When `true` (default) the append is fsync'd before the call resolves so
|
||||
* the bytes are durable across a crash. Set to `false` only for rebuildable
|
||||
* data (caches/indexes) where throughput matters more than durability.
|
||||
*/
|
||||
readonly durable?: boolean;
|
||||
}
|
||||
|
||||
export interface IStorageService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** Read the whole value, or `undefined` when the key does not exist. */
|
||||
read(scope: string, key: string): Promise<Uint8Array | undefined>;
|
||||
|
||||
/** Atomically replace the whole value. */
|
||||
write(scope: string, key: string, data: Uint8Array, options?: StorageWriteOptions): Promise<void>;
|
||||
|
||||
/** Durable byte extension (ordered). */
|
||||
append(scope: string, key: string, data: Uint8Array, options?: StorageAppendOptions): Promise<void>;
|
||||
|
||||
/** List the keys under `scope`, optionally filtered by `prefix`. */
|
||||
list(scope: string, prefix?: string): Promise<readonly string[]>;
|
||||
|
||||
/** Delete a key. Missing keys are not an error. */
|
||||
delete(scope: string, key: string): Promise<void>;
|
||||
|
||||
/** Flush any buffered writes to the durable medium. */
|
||||
flush(): Promise<void>;
|
||||
|
||||
/** Release implementation resources (file handles, timers, connections). */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export const IStorageService: ServiceIdentifier<IStorageService> =
|
||||
createDecorator<IStorageService>('storageService');
|
||||
|
|
@ -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);
|
||||
|
|
@ -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';
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
/**
|
||||
* `terminal` domain (cross-cutting) — session-scope terminal service.
|
||||
*
|
||||
* Defines the public contract of terminal management: the `TerminalHandle`
|
||||
* model and the `ITerminalService` used to spawn processes, write to stdin,
|
||||
* and kill terminals. Session-scoped — one service per session.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export interface TerminalHandle {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export interface ITerminalService {
|
||||
readonly _serviceBrand: undefined;
|
||||
spawn(cmd: string, args: readonly string[]): Promise<TerminalHandle>;
|
||||
write(id: string, data: string): void;
|
||||
kill(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const ITerminalService: ServiceIdentifier<ITerminalService> =
|
||||
createDecorator<ITerminalService>('terminalService');
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
/**
|
||||
* `terminal` domain (cross-cutting) — `ITerminalService` implementation.
|
||||
*
|
||||
* Owns the spawned terminal processes and their lifecycle; runs processes
|
||||
* through `kaos` and logs through `log`. Bound at Session scope.
|
||||
*/
|
||||
|
||||
import type { KaosProcess } from '@moonshot-ai/kaos';
|
||||
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { ISessionKaosService } from '#/kaos';
|
||||
import { ILogService } from '#/log';
|
||||
|
||||
import { type TerminalHandle, ITerminalService } from './terminal';
|
||||
|
||||
export class TerminalService extends Disposable implements ITerminalService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly processes = new Map<string, KaosProcess>();
|
||||
|
||||
constructor(
|
||||
@ILogService _log: ILogService,
|
||||
@ISessionKaosService private readonly sessionKaos: ISessionKaosService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async spawn(cmd: string, args: readonly string[]): Promise<TerminalHandle> {
|
||||
const proc = await this.sessionKaos.toolKaos.exec(cmd, ...args);
|
||||
const id = String(proc.pid);
|
||||
this.processes.set(id, proc);
|
||||
return { id };
|
||||
}
|
||||
|
||||
write(id: string, data: string): void {
|
||||
this.processes.get(id)?.stdin.write(data);
|
||||
}
|
||||
|
||||
async kill(id: string): Promise<void> {
|
||||
const proc = this.processes.get(id);
|
||||
if (proc === undefined) return;
|
||||
await proc.kill();
|
||||
this.processes.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(LifecycleScope.Session, ITerminalService, TerminalService, InstantiationType.Delayed, 'terminal');
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
/**
|
||||
* `workspace` domain error codes and error classes.
|
||||
*
|
||||
* Codes are sourced from the protocol `KimiErrorCode` set so the wire shape
|
||||
* matches the existing REST routes (`workspace.not_found`, `fs.path_not_found`,
|
||||
* `fs.permission_denied`, `validation.failed`). A single `ErrorDomain` owns all
|
||||
* codes so a shared code (e.g. `fs.path_not_found`, used by both the registry
|
||||
* and the fs browser) is registered exactly once.
|
||||
*/
|
||||
|
||||
import { KimiError, registerErrorDomain, type ErrorDomain } from '#/_base/errors';
|
||||
|
||||
export const WorkspaceErrors = {
|
||||
codes: {
|
||||
WORKSPACE_NOT_FOUND: 'workspace.not_found',
|
||||
PATH_NOT_ABSOLUTE: 'validation.failed',
|
||||
PATH_NOT_FOUND: 'fs.path_not_found',
|
||||
PERMISSION_DENIED: 'fs.permission_denied',
|
||||
},
|
||||
info: {
|
||||
'workspace.not_found': {
|
||||
title: 'Workspace not found',
|
||||
retryable: false,
|
||||
public: true,
|
||||
},
|
||||
'validation.failed': {
|
||||
title: 'Path must be absolute',
|
||||
retryable: false,
|
||||
public: true,
|
||||
},
|
||||
'fs.path_not_found': {
|
||||
title: 'Path not found',
|
||||
retryable: false,
|
||||
public: true,
|
||||
},
|
||||
'fs.permission_denied': {
|
||||
title: 'Permission denied',
|
||||
retryable: false,
|
||||
public: true,
|
||||
},
|
||||
},
|
||||
} as const satisfies ErrorDomain;
|
||||
|
||||
registerErrorDomain(WorkspaceErrors);
|
||||
|
||||
export type WorkspaceErrorCode = (typeof WorkspaceErrors.codes)[keyof typeof WorkspaceErrors.codes];
|
||||
|
||||
export class WorkspaceError extends KimiError {
|
||||
constructor(code: WorkspaceErrorCode, message: string, details?: Record<string, unknown>) {
|
||||
super(code, message, { details });
|
||||
this.name = 'WorkspaceError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceFsError extends KimiError {
|
||||
constructor(code: WorkspaceErrorCode, message: string, details?: Record<string, unknown>) {
|
||||
super(code, message, { details });
|
||||
this.name = 'WorkspaceFsError';
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/**
|
||||
* `workspace` domain (cross-cutting) — session-scope workspace service.
|
||||
*
|
||||
* Defines the public contract of the current session's workspace: the primary
|
||||
* `workDir` plus any `additionalDirs`, and the path helpers built on top of
|
||||
* them (`resolve` / `isWithin` / `assertAllowed`). Session-scoped — one
|
||||
* instance per session — so consumers never thread a `workspaceId` around.
|
||||
*
|
||||
* This is the semantic layer above `kaos`: `ISessionKaosService` owns the raw
|
||||
* `Kaos` environments and additional roots, while `ISessionWorkspaceService` turns
|
||||
* them into workspace-relative path operations. Dependency direction is
|
||||
* `workspace → kaos`, never the reverse (see `docs/service-design.md` §5).
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import type { PathAccessOperation } from '#/_base/tools/policies/path-access';
|
||||
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
|
||||
|
||||
export interface ISessionWorkspaceService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** Primary workspace directory (absolute). Reflects the session kaos cwd. */
|
||||
readonly workDir: string;
|
||||
/** Extra allowed roots (e.g. `--add-dir` CLI flag). */
|
||||
readonly additionalDirs: readonly string[];
|
||||
|
||||
/**
|
||||
* Resolve `rel` to an absolute, canonical path. Relative paths are resolved
|
||||
* against `workDir`; absolute paths are normalized as-is. Lexical only — no
|
||||
* filesystem I/O.
|
||||
*/
|
||||
resolve(rel: string): string;
|
||||
|
||||
/** True iff `path` (resolved against `workDir`) sits inside `workDir` or any `additionalDirs`. */
|
||||
isWithin(path: string): boolean;
|
||||
|
||||
/**
|
||||
* Throw if `path` escapes the workspace for `op` (or matches a sensitive
|
||||
* file); returns the canonical absolute path when it passes.
|
||||
*/
|
||||
assertAllowed(path: string, op: PathAccessOperation): string;
|
||||
|
||||
/** Snapshot as a `WorkspaceConfig` value object for the path-access helpers. */
|
||||
toConfig(): WorkspaceConfig;
|
||||
|
||||
/** Add an extra allowed root for this session (no-op if already present). */
|
||||
addAdditionalDir(dir: string): void;
|
||||
/** Remove an extra allowed root from this session. */
|
||||
removeAdditionalDir(dir: string): void;
|
||||
}
|
||||
|
||||
export const ISessionWorkspaceService: ServiceIdentifier<ISessionWorkspaceService> =
|
||||
createDecorator<ISessionWorkspaceService>('sessionWorkspaceService');
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
/**
|
||||
* `workspace` domain (cross-cutting) — core-scope folder-picker contract.
|
||||
*
|
||||
* Defines the public contract of the host-filesystem browser
|
||||
* (`IWorkspaceFsService`) backing the workspace folder picker: listing a
|
||||
* directory's subfolders (`browse`) and the picker landing payload (`home`).
|
||||
* Core-scoped and host-fs based — it operates on arbitrary absolute paths of
|
||||
* the host, deliberately distinct from the session-scoped, kaos-sandboxed `fs`
|
||||
* domain used by tools.
|
||||
*/
|
||||
|
||||
import type { FsBrowseResponse, FsHomeResponse } from '@moonshot-ai/protocol';
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export interface IWorkspaceFsService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** List subdirectories of `absPath` (defaults to `$HOME`), annotated with git state. */
|
||||
browse(absPath?: string): Promise<FsBrowseResponse>;
|
||||
|
||||
/** Folder-picker landing payload: `$HOME` plus recently-opened workspace roots. */
|
||||
home(): Promise<FsHomeResponse>;
|
||||
}
|
||||
|
||||
export const IWorkspaceFsService: ServiceIdentifier<IWorkspaceFsService> =
|
||||
createDecorator<IWorkspaceFsService>('workspaceFsService');
|
||||
|
||||
export const RECENT_ROOTS_LIMIT = 8;
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
/**
|
||||
* `workspace` domain (cross-cutting) — `IWorkspaceFsService` implementation.
|
||||
*
|
||||
* Browses the host filesystem for the workspace folder picker and derives the
|
||||
* recent-roots list from `IWorkspaceRegistry`. Bound at Core scope.
|
||||
*/
|
||||
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { dirname, isAbsolute, join } from 'node:path';
|
||||
|
||||
import type { FsBrowseEntry, FsBrowseResponse, FsHomeResponse } from '@moonshot-ai/protocol';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import { WorkspaceFsError, WorkspaceErrors } from './errors';
|
||||
import { IWorkspaceRegistry } from './workspaceRegistry';
|
||||
import { detectGit } from './workspaceRegistryService';
|
||||
import { IWorkspaceFsService, RECENT_ROOTS_LIMIT } from './workspaceFs';
|
||||
|
||||
export class WorkspaceFsService extends Disposable implements IWorkspaceFsService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(@IWorkspaceRegistry private readonly registry: IWorkspaceRegistry) {
|
||||
super();
|
||||
}
|
||||
|
||||
async browse(absPath?: string): Promise<FsBrowseResponse> {
|
||||
const target = absPath ?? os.homedir();
|
||||
if (!isAbsolute(target)) {
|
||||
throw new WorkspaceFsError(WorkspaceErrors.codes.PATH_NOT_ABSOLUTE, `path must be absolute: ${target}`, {
|
||||
path: target,
|
||||
});
|
||||
}
|
||||
let realTarget: string;
|
||||
try {
|
||||
realTarget = await fsp.realpath(target);
|
||||
} catch (err) {
|
||||
throw mapFsError(err, target);
|
||||
}
|
||||
let dirents;
|
||||
try {
|
||||
dirents = await fsp.readdir(realTarget, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
throw mapFsError(err, realTarget);
|
||||
}
|
||||
const dirOnly = dirents.filter((d) => d.isDirectory());
|
||||
|
||||
const entries: FsBrowseEntry[] = await Promise.all(
|
||||
dirOnly.map(async (d) => {
|
||||
const childAbs = join(realTarget, d.name);
|
||||
const git = await detectGit(childAbs);
|
||||
const base: FsBrowseEntry = {
|
||||
name: d.name,
|
||||
path: childAbs,
|
||||
is_dir: true,
|
||||
is_git_repo: git.is_git_repo,
|
||||
};
|
||||
if (git.branch !== null) {
|
||||
return { ...base, branch: git.branch };
|
||||
}
|
||||
return base;
|
||||
}),
|
||||
);
|
||||
|
||||
entries.sort(compareBrowseEntries);
|
||||
|
||||
const parent = dirname(realTarget);
|
||||
return {
|
||||
path: realTarget,
|
||||
parent: parent === realTarget ? null : parent,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
async home(): Promise<FsHomeResponse> {
|
||||
const home = os.homedir();
|
||||
const workspaces = await this.registry.list();
|
||||
const recent_roots = workspaces.slice(0, RECENT_ROOTS_LIMIT).map((w) => w.root);
|
||||
return { home, recent_roots };
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._store.isDisposed) return;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function mapFsError(err: unknown, path: string): Error {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
return new WorkspaceFsError(WorkspaceErrors.codes.PATH_NOT_FOUND, `path not found: ${path}`, { path });
|
||||
}
|
||||
if (code === 'EACCES' || code === 'EPERM') {
|
||||
return new WorkspaceFsError(WorkspaceErrors.codes.PERMISSION_DENIED, `permission denied: ${path}`, { path });
|
||||
}
|
||||
return err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
|
||||
function compareBrowseEntries(a: FsBrowseEntry, b: FsBrowseEntry): number {
|
||||
const aDot = a.name.startsWith('.');
|
||||
const bDot = b.name.startsWith('.');
|
||||
if (aDot !== bDot) return aDot ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Core,
|
||||
IWorkspaceFsService,
|
||||
WorkspaceFsService,
|
||||
InstantiationType.Delayed,
|
||||
'workspace',
|
||||
);
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
/**
|
||||
* `workspace` domain (cross-cutting) — core-scope workspace registry contract.
|
||||
*
|
||||
* Defines the public contract of the persistent, process-wide catalog of known
|
||||
* workspaces (`IWorkspaceRegistry`): the `Workspace` records keyed by a stable
|
||||
* `wd_<slug>_<hash>` id, plus the `WorkspacePatch` used to rename one. Core-
|
||||
* scoped — one shared registry for the whole process. Backed by
|
||||
* `<homeDir>/workspaces.json`; session counts are delegated to `ISessionStore`
|
||||
* so this domain never reaches into the session store's on-disk layout.
|
||||
*/
|
||||
|
||||
import type { Workspace } from '@moonshot-ai/protocol';
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export interface WorkspacePatch {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface IWorkspaceRegistry {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** List every registered workspace, most-recently-opened first. */
|
||||
list(): Promise<Workspace[]>;
|
||||
|
||||
/** Fetch a single workspace by id; throws `WorkspaceError` (workspace.not_found) if absent. */
|
||||
get(workspaceId: string): Promise<Workspace>;
|
||||
|
||||
/**
|
||||
* Register `root` (idempotent): creates the entry on first sight, otherwise
|
||||
* bumps `last_opened_at`. Throws `WorkspaceError` (fs.path_not_found) when
|
||||
* `root` does not exist on disk.
|
||||
*/
|
||||
createOrTouch(root: string, name?: string): Promise<Workspace>;
|
||||
|
||||
/** Rename a workspace (display name only). */
|
||||
update(workspaceId: string, patch: WorkspacePatch): Promise<Workspace>;
|
||||
|
||||
/** Unregister a workspace (does not remove on-disk content). */
|
||||
delete(workspaceId: string): Promise<void>;
|
||||
|
||||
/** Resolve a workspace id back to its absolute root path. */
|
||||
resolveRoot(workspaceId: string): Promise<string>;
|
||||
}
|
||||
|
||||
export const IWorkspaceRegistry: ServiceIdentifier<IWorkspaceRegistry> =
|
||||
createDecorator<IWorkspaceRegistry>('workspaceRegistry');
|
||||
|
|
@ -1,328 +0,0 @@
|
|||
/**
|
||||
* `workspace` domain (cross-cutting) — `IWorkspaceRegistry` implementation.
|
||||
*
|
||||
* Persists the process-wide workspace catalog to `<homeDir>/workspaces.json`
|
||||
* and hydrates each entry with live git state and an active-session count.
|
||||
* Bound at Core scope — one shared registry.
|
||||
*/
|
||||
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import type { Stats } from 'node:fs';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
|
||||
import type { Workspace } from '@moonshot-ai/protocol';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IEnvironmentService } from '#/environment/environment';
|
||||
import { IEventService } from '#/event/event';
|
||||
import { ILogService } from '#/log/log';
|
||||
import { ISessionStore } from '#/sessionStore/sessionStore';
|
||||
|
||||
import { WorkspaceError, WorkspaceErrors } from './errors';
|
||||
import { IWorkspaceRegistry, type WorkspacePatch } from './workspaceRegistry';
|
||||
|
||||
const WORKSPACE_REGISTRY_FILE = 'workspaces.json';
|
||||
const WORKSPACE_REGISTRY_VERSION = 1;
|
||||
|
||||
interface WorkspaceRegistryEntry {
|
||||
root: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_opened_at: string;
|
||||
}
|
||||
|
||||
interface WorkspaceRegistryFile {
|
||||
version: number;
|
||||
workspaces: Record<string, WorkspaceRegistryEntry>;
|
||||
}
|
||||
|
||||
export class WorkspaceRegistryService extends Disposable implements IWorkspaceRegistry {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly sessionsDir: string;
|
||||
private readonly registryPath: string;
|
||||
private opQueue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
@IEnvironmentService env: IEnvironmentService,
|
||||
@ILogService private readonly logger: ILogService,
|
||||
@IEventService private readonly eventService: IEventService,
|
||||
@ISessionStore private readonly sessionStore: ISessionStore,
|
||||
) {
|
||||
super();
|
||||
this.sessionsDir = join(env.homeDir, 'sessions');
|
||||
this.registryPath = join(env.homeDir, WORKSPACE_REGISTRY_FILE);
|
||||
}
|
||||
|
||||
async list(): Promise<Workspace[]> {
|
||||
const file = await this.runExclusive(() => this.readRegistry());
|
||||
const hydrated = await Promise.all(
|
||||
Object.entries(file.workspaces).map(([workspaceId, entry]) => this.hydrate(workspaceId, entry)),
|
||||
);
|
||||
return hydrated.sort((a, b) => (b.last_opened_at < a.last_opened_at ? -1 : 1));
|
||||
}
|
||||
|
||||
async get(workspaceId: string): Promise<Workspace> {
|
||||
const entry = await this.runExclusive(async () => {
|
||||
const file = await this.readRegistry();
|
||||
return file.workspaces[workspaceId] ?? null;
|
||||
});
|
||||
if (entry === null) {
|
||||
throw new WorkspaceError(WorkspaceErrors.codes.WORKSPACE_NOT_FOUND, `workspace not found: ${workspaceId}`, {
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
return this.hydrate(workspaceId, entry);
|
||||
}
|
||||
|
||||
async createOrTouch(root: string, name?: string): Promise<Workspace> {
|
||||
let realRoot: string;
|
||||
try {
|
||||
realRoot = await fsp.realpath(root);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw new WorkspaceError(WorkspaceErrors.codes.PATH_NOT_FOUND, `workspace root does not exist: ${root}`, {
|
||||
root,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const workspaceId = this.sessionStore.workspaceIdFor(realRoot);
|
||||
await fsp.mkdir(join(this.sessionsDir, workspaceId), { recursive: true, mode: 0o700 });
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const { entry, created } = await this.runExclusive(async () => {
|
||||
const file = await this.readRegistry();
|
||||
const existing = file.workspaces[workspaceId];
|
||||
const next: WorkspaceRegistryEntry =
|
||||
existing !== undefined
|
||||
? { ...existing, last_opened_at: now }
|
||||
: {
|
||||
root: realRoot,
|
||||
name: name ?? basename(realRoot),
|
||||
created_at: now,
|
||||
last_opened_at: now,
|
||||
};
|
||||
file.workspaces[workspaceId] = next;
|
||||
await this.writeRegistry(file);
|
||||
return { entry: next, created: existing === undefined };
|
||||
});
|
||||
const workspace = await this.hydrate(workspaceId, entry);
|
||||
if (created) {
|
||||
this.eventService.publish({ type: 'event.workspace.created', payload: { workspace } });
|
||||
}
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async update(workspaceId: string, patch: WorkspacePatch): Promise<Workspace> {
|
||||
const entry = await this.runExclusive(async () => {
|
||||
const file = await this.readRegistry();
|
||||
const existing = file.workspaces[workspaceId];
|
||||
if (existing === undefined) {
|
||||
throw new WorkspaceError(WorkspaceErrors.codes.WORKSPACE_NOT_FOUND, `workspace not found: ${workspaceId}`, {
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
const next: WorkspaceRegistryEntry = {
|
||||
...existing,
|
||||
...(patch.name !== undefined ? { name: patch.name } : {}),
|
||||
};
|
||||
file.workspaces[workspaceId] = next;
|
||||
await this.writeRegistry(file);
|
||||
return next;
|
||||
});
|
||||
const workspace = await this.hydrate(workspaceId, entry);
|
||||
this.eventService.publish({ type: 'event.workspace.updated', payload: { workspace } });
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async delete(workspaceId: string): Promise<void> {
|
||||
const root = await this.runExclusive(async () => {
|
||||
const file = await this.readRegistry();
|
||||
const existing = file.workspaces[workspaceId];
|
||||
if (existing === undefined) {
|
||||
throw new WorkspaceError(WorkspaceErrors.codes.WORKSPACE_NOT_FOUND, `workspace not found: ${workspaceId}`, {
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
delete file.workspaces[workspaceId];
|
||||
await this.writeRegistry(file);
|
||||
return existing.root;
|
||||
});
|
||||
this.eventService.publish({ type: 'event.workspace.deleted', payload: { workspace_id: workspaceId, root } });
|
||||
}
|
||||
|
||||
async resolveRoot(workspaceId: string): Promise<string> {
|
||||
const entry = await this.runExclusive(async () => {
|
||||
const file = await this.readRegistry();
|
||||
return file.workspaces[workspaceId] ?? null;
|
||||
});
|
||||
if (entry === null) {
|
||||
throw new WorkspaceError(WorkspaceErrors.codes.WORKSPACE_NOT_FOUND, `workspace not found: ${workspaceId}`, {
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
return entry.root;
|
||||
}
|
||||
|
||||
private async hydrate(workspaceId: string, entry: WorkspaceRegistryEntry): Promise<Workspace> {
|
||||
const [{ is_git_repo, branch }, session_count] = await Promise.all([
|
||||
detectGit(entry.root),
|
||||
this.sessionStore.countActiveSessions(this.sessionsDir, entry.root),
|
||||
]);
|
||||
return {
|
||||
id: workspaceId,
|
||||
root: entry.root,
|
||||
name: entry.name,
|
||||
is_git_repo,
|
||||
branch,
|
||||
created_at: entry.created_at,
|
||||
last_opened_at: entry.last_opened_at,
|
||||
session_count,
|
||||
};
|
||||
}
|
||||
|
||||
private async readRegistry(): Promise<WorkspaceRegistryFile> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fsp.readFile(this.registryPath, 'utf8');
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {} };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
this.logger.warn('workspaces.json malformed; treating as empty', {
|
||||
path: this.registryPath,
|
||||
err: String(err),
|
||||
});
|
||||
return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {} };
|
||||
}
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
typeof (parsed as { workspaces?: unknown }).workspaces !== 'object' ||
|
||||
(parsed as { workspaces?: unknown }).workspaces === null
|
||||
) {
|
||||
this.logger.warn('workspaces.json missing required keys; treating as empty', {
|
||||
path: this.registryPath,
|
||||
});
|
||||
return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {} };
|
||||
}
|
||||
const rawWorkspaces = (parsed as { workspaces: Record<string, unknown> }).workspaces;
|
||||
const workspaces: Record<string, WorkspaceRegistryEntry> = {};
|
||||
for (const [id, value] of Object.entries(rawWorkspaces)) {
|
||||
const entry = this.sanitizeEntry(value);
|
||||
if (entry !== null) {
|
||||
workspaces[id] = entry;
|
||||
}
|
||||
}
|
||||
const version =
|
||||
typeof (parsed as { version?: unknown }).version === 'number'
|
||||
? (parsed as { version: number }).version
|
||||
: WORKSPACE_REGISTRY_VERSION;
|
||||
return { version, workspaces };
|
||||
}
|
||||
|
||||
private sanitizeEntry(value: unknown): WorkspaceRegistryEntry | null {
|
||||
if (typeof value !== 'object' || value === null) return null;
|
||||
const v = value as Partial<WorkspaceRegistryEntry>;
|
||||
if (
|
||||
typeof v.root !== 'string' ||
|
||||
typeof v.name !== 'string' ||
|
||||
typeof v.created_at !== 'string' ||
|
||||
typeof v.last_opened_at !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
root: v.root,
|
||||
name: v.name,
|
||||
created_at: v.created_at,
|
||||
last_opened_at: v.last_opened_at,
|
||||
};
|
||||
}
|
||||
|
||||
private async writeRegistry(file: WorkspaceRegistryFile): Promise<void> {
|
||||
await fsp.mkdir(dirname(this.registryPath), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${this.registryPath}.tmp`;
|
||||
await fsp.writeFile(tmp, JSON.stringify(file, null, 2), 'utf8');
|
||||
await fsp.rename(tmp, this.registryPath);
|
||||
}
|
||||
|
||||
private runExclusive<T>(op: () => Promise<T>): Promise<T> {
|
||||
const next = this.opQueue.then(op, op);
|
||||
this.opQueue = next.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._store.isDisposed) return;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export interface GitInfo {
|
||||
is_git_repo: boolean;
|
||||
branch: string | null;
|
||||
}
|
||||
|
||||
export async function detectGit(root: string): Promise<GitInfo> {
|
||||
let dotGit: Stats;
|
||||
try {
|
||||
dotGit = await fsp.lstat(join(root, '.git'));
|
||||
} catch {
|
||||
return { is_git_repo: false, branch: null };
|
||||
}
|
||||
|
||||
let gitDir: string;
|
||||
if (dotGit.isDirectory()) {
|
||||
gitDir = join(root, '.git');
|
||||
} else if (dotGit.isFile()) {
|
||||
let text: string;
|
||||
try {
|
||||
text = await fsp.readFile(join(root, '.git'), 'utf8');
|
||||
} catch {
|
||||
return { is_git_repo: false, branch: null };
|
||||
}
|
||||
const m = /^gitdir:\s*(.+)$/m.exec(text);
|
||||
if (m === null) return { is_git_repo: false, branch: null };
|
||||
const ref = m[1] ?? '';
|
||||
if (ref === '') return { is_git_repo: false, branch: null };
|
||||
gitDir = ref.trim();
|
||||
|
||||
if (!gitDir.startsWith('/')) {
|
||||
gitDir = join(root, gitDir);
|
||||
}
|
||||
} else {
|
||||
return { is_git_repo: false, branch: null };
|
||||
}
|
||||
|
||||
let head: string;
|
||||
try {
|
||||
head = (await fsp.readFile(join(gitDir, 'HEAD'), 'utf8')).trim();
|
||||
} catch {
|
||||
return { is_git_repo: true, branch: null };
|
||||
}
|
||||
const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
|
||||
return { is_git_repo: true, branch: ref ? (ref[1] ?? null) : null };
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Core,
|
||||
IWorkspaceRegistry,
|
||||
WorkspaceRegistryService,
|
||||
InstantiationType.Delayed,
|
||||
'workspace',
|
||||
);
|
||||
|
|
@ -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',
|
||||
);
|
||||
9
packages/agent-core-v2/src/workspaceContext/index.ts
Normal file
9
packages/agent-core-v2/src/workspaceContext/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* `workspaceContext` domain barrel — re-exports the workspace-context
|
||||
* contract (`workspaceContext`) and its scoped service
|
||||
* (`workspaceContextService`). Importing this barrel registers the
|
||||
* `IWorkspaceContext` binding into the scope registry.
|
||||
*/
|
||||
|
||||
export * from './workspaceContext';
|
||||
export * from './workspaceContextService';
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* `workspaceContext` domain (L1) — session workspace root and path access.
|
||||
*
|
||||
* Defines the `IWorkspaceContext` used by the Agent side to resolve relative
|
||||
* paths against the session work directory and to enforce that file/process
|
||||
* operations stay within the workspace (plus any additional dirs). Pure
|
||||
* configuration + boundary — it performs no IO. Session-scoped.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export type PathAccessOperation = 'read' | 'write' | 'execute';
|
||||
|
||||
export interface IWorkspaceContext {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
readonly workDir: string;
|
||||
readonly additionalDirs: readonly string[];
|
||||
|
||||
setWorkDir(workDir: string): void;
|
||||
resolve(rel: string): string;
|
||||
isWithin(absPath: string): boolean;
|
||||
assertAllowed(absPath: string, op: PathAccessOperation): string;
|
||||
addAdditionalDir(dir: string): void;
|
||||
removeAdditionalDir(dir: string): void;
|
||||
}
|
||||
|
||||
export const IWorkspaceContext: ServiceIdentifier<IWorkspaceContext> =
|
||||
createDecorator<IWorkspaceContext>('workspaceContext');
|
||||
|
|
@ -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',
|
||||
);
|
||||
9
packages/agent-core-v2/src/workspaceRegistry/index.ts
Normal file
9
packages/agent-core-v2/src/workspaceRegistry/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* `workspaceRegistry` domain barrel — re-exports the workspace-registry
|
||||
* contract (`workspaceRegistry`) and its scoped service
|
||||
* (`workspaceRegistryService`). Importing this barrel registers the
|
||||
* `IWorkspaceRegistry` binding into the scope registry.
|
||||
*/
|
||||
|
||||
export * from './workspaceRegistry';
|
||||
export * from './workspaceRegistryService';
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* `workspaceRegistry` domain (L1) — process-wide catalog of known workspaces.
|
||||
*
|
||||
* Defines the `IWorkspaceRegistry` used by the program side to remember the
|
||||
* folders the user has opened (backed by the app's own persistence). This is
|
||||
* a host-side catalog, distinct from the session-scoped `workspaceContext`
|
||||
* that describes one Agent's active work directory. Core-scoped.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export interface Workspace {
|
||||
readonly id: string;
|
||||
readonly root: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface IWorkspaceRegistry {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
list(): Promise<readonly Workspace[]>;
|
||||
get(id: string): Promise<Workspace | undefined>;
|
||||
createOrTouch(root: string, name?: string): Promise<Workspace>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const IWorkspaceRegistry: ServiceIdentifier<IWorkspaceRegistry> =
|
||||
createDecorator<IWorkspaceRegistry>('workspaceRegistry');
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* `workspaceRegistry` domain (L1) — `IWorkspaceRegistry` implementation.
|
||||
*
|
||||
* In-memory skeleton of the known-workspaces catalog; persistence through
|
||||
* `IConfigStore` will replace the map in a later phase. Bound at Core scope.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import { IWorkspaceRegistry, type Workspace } from './workspaceRegistry';
|
||||
|
||||
export class WorkspaceRegistryService implements IWorkspaceRegistry {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly workspaces = new Map<string, Workspace>();
|
||||
|
||||
list(): Promise<readonly Workspace[]> {
|
||||
return Promise.resolve([...this.workspaces.values()]);
|
||||
}
|
||||
|
||||
get(id: string): Promise<Workspace | undefined> {
|
||||
return Promise.resolve(this.workspaces.get(id));
|
||||
}
|
||||
|
||||
createOrTouch(root: string, name?: string): Promise<Workspace> {
|
||||
const id = createHash('sha256').update(root).digest('hex').slice(0, 12);
|
||||
const existing = this.workspaces.get(id);
|
||||
if (existing !== undefined) return Promise.resolve(existing);
|
||||
const ws: Workspace = { id, root, name: name ?? root.split('/').pop() ?? root };
|
||||
this.workspaces.set(id, ws);
|
||||
return Promise.resolve(ws);
|
||||
}
|
||||
|
||||
delete(id: string): Promise<void> {
|
||||
this.workspaces.delete(id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Core,
|
||||
IWorkspaceRegistry,
|
||||
WorkspaceRegistryService,
|
||||
InstantiationType.Delayed,
|
||||
'workspaceRegistry',
|
||||
);
|
||||
|
|
@ -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')),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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' }));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
81
packages/agent-core-v2/test/storage/configStore.test.ts
Normal file
81
packages/agent-core-v2/test/storage/configStore.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { SyncDescriptor } from '#/_base/di/descriptors';
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { TestInstantiationService } from '#/_base/di/test';
|
||||
import { IConfigStore, IStorageService } from '#/storage';
|
||||
import { ConfigStore } from '#/storage/configStore';
|
||||
import { InMemoryStorageService } from '#/storage/inMemoryStorageService';
|
||||
|
||||
interface State {
|
||||
readonly title?: string;
|
||||
readonly count?: number;
|
||||
}
|
||||
|
||||
describe('ConfigStore', () => {
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
let storage: InMemoryStorageService;
|
||||
let config: IConfigStore;
|
||||
|
||||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
ix = disposables.add(new TestInstantiationService());
|
||||
storage = new InMemoryStorageService();
|
||||
ix.stub(IStorageService, storage);
|
||||
ix.set(IConfigStore, new SyncDescriptor(ConfigStore));
|
||||
config = ix.get(IConfigStore);
|
||||
});
|
||||
|
||||
afterEach(() => disposables.dispose());
|
||||
|
||||
it('get returns undefined for a missing key', async () => {
|
||||
expect(await config.get('session', 'state.json')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('set + get round-trips a value', async () => {
|
||||
await config.set<State>('session', 'state.json', { title: 'hello', count: 1 });
|
||||
expect(await config.get<State>('session', 'state.json')).toEqual({ title: 'hello', count: 1 });
|
||||
});
|
||||
|
||||
it('set atomically replaces the previous value', async () => {
|
||||
await config.set<State>('session', 'state.json', { title: 'old' });
|
||||
await config.set<State>('session', 'state.json', { title: 'new', count: 2 });
|
||||
expect(await config.get<State>('session', 'state.json')).toEqual({ title: 'new', count: 2 });
|
||||
});
|
||||
|
||||
it('keys are independent', async () => {
|
||||
await config.set<State>('session', 'a.json', { title: 'A' });
|
||||
await config.set<State>('session', 'b.json', { title: 'B' });
|
||||
expect(await config.get<State>('session', 'a.json')).toEqual({ title: 'A' });
|
||||
expect(await config.get<State>('session', 'b.json')).toEqual({ title: 'B' });
|
||||
});
|
||||
|
||||
it('scopes are isolated', async () => {
|
||||
await config.set<State>('scope-a', 'k', { title: 'A' });
|
||||
await config.set<State>('scope-b', 'k', { title: 'B' });
|
||||
expect(await config.get<State>('scope-a', 'k')).toEqual({ title: 'A' });
|
||||
expect(await config.get<State>('scope-b', 'k')).toEqual({ title: 'B' });
|
||||
});
|
||||
|
||||
it('delete removes a key; missing delete is a no-op', async () => {
|
||||
await config.set<State>('session', 'state.json', { title: 'x' });
|
||||
await config.delete('session', 'state.json');
|
||||
expect(await config.get('session', 'state.json')).toBeUndefined();
|
||||
await expect(config.delete('session', 'state.json')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('list returns keys, optionally filtered by prefix', async () => {
|
||||
await config.set('session', 'job-1', {});
|
||||
await config.set('session', 'job-2', {});
|
||||
await config.set('session', 'state.json', {});
|
||||
expect((await config.list('session')).toSorted()).toEqual(['job-1', 'job-2', 'state.json']);
|
||||
expect((await config.list('session', 'job-')).toSorted()).toEqual(['job-1', 'job-2']);
|
||||
});
|
||||
|
||||
it('value is persisted through the underlying IStorageService', async () => {
|
||||
await config.set<State>('session', 'state.json', { title: 'x' });
|
||||
const raw = new TextDecoder().decode(await storage.read('session', 'state.json'));
|
||||
expect(JSON.parse(raw)).toEqual({ title: 'x' });
|
||||
});
|
||||
});
|
||||
100
packages/agent-core-v2/test/storage/recordStore.test.ts
Normal file
100
packages/agent-core-v2/test/storage/recordStore.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { SyncDescriptor } from '#/_base/di/descriptors';
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { TestInstantiationService } from '#/_base/di/test';
|
||||
import { IRecordStore, IStorageService, RecordCorruptedError } from '#/storage';
|
||||
import { InMemoryStorageService } from '#/storage/inMemoryStorageService';
|
||||
import { RecordStore } from '#/storage/recordStore';
|
||||
|
||||
const enc = new TextEncoder();
|
||||
|
||||
interface Rec {
|
||||
readonly n: number;
|
||||
}
|
||||
|
||||
const SCOPE = 'agents/main';
|
||||
const KEY = 'wire.jsonl';
|
||||
|
||||
describe('RecordStore', () => {
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
let storage: InMemoryStorageService;
|
||||
let record: IRecordStore;
|
||||
|
||||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
ix = disposables.add(new TestInstantiationService());
|
||||
storage = new InMemoryStorageService();
|
||||
ix.stub(IStorageService, storage);
|
||||
ix.set(IRecordStore, new SyncDescriptor(RecordStore));
|
||||
record = ix.get(IRecordStore);
|
||||
});
|
||||
|
||||
afterEach(() => disposables.dispose());
|
||||
|
||||
async function collect<R>(scope: string, key: string): Promise<readonly R[]> {
|
||||
const out: R[] = [];
|
||||
for await (const r of record.read<R>(scope, key)) {
|
||||
out.push(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it('reads nothing from an empty log', async () => {
|
||||
expect(await collect<Rec>(SCOPE, KEY)).toEqual([]);
|
||||
});
|
||||
|
||||
it('append + read round-trips records in order', async () => {
|
||||
record.append<Rec>(SCOPE, KEY, { n: 1 });
|
||||
record.append<Rec>(SCOPE, KEY, { n: 2 });
|
||||
record.append<Rec>(SCOPE, KEY, { n: 3 });
|
||||
expect(await collect<Rec>(SCOPE, KEY)).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]);
|
||||
});
|
||||
|
||||
it('batches many appends into a single durable append', async () => {
|
||||
const spy = { count: 0 };
|
||||
const original = storage.append.bind(storage);
|
||||
storage.append = async (...args) => {
|
||||
spy.count++;
|
||||
return original(...args);
|
||||
};
|
||||
|
||||
for (let n = 0; n < 10; n++) record.append<Rec>(SCOPE, KEY, { n });
|
||||
await record.flush();
|
||||
|
||||
expect(await collect<Rec>(SCOPE, KEY)).toHaveLength(10);
|
||||
expect(spy.count).toBe(1);
|
||||
});
|
||||
|
||||
it('rewrite atomically replaces the whole log', async () => {
|
||||
record.append<Rec>(SCOPE, KEY, { n: 1 });
|
||||
record.append<Rec>(SCOPE, KEY, { n: 2 });
|
||||
await record.flush();
|
||||
|
||||
await record.rewrite<Rec>(SCOPE, KEY, [{ n: 9 }, { n: 8 }]);
|
||||
expect(await collect<Rec>(SCOPE, KEY)).toEqual([{ n: 9 }, { n: 8 }]);
|
||||
});
|
||||
|
||||
it('logs addressed by different scope/key are independent', async () => {
|
||||
record.append<Rec>('a', 'l', { n: 1 });
|
||||
record.append<Rec>('b', 'l', { n: 2 });
|
||||
expect(await collect<Rec>('a', 'l')).toEqual([{ n: 1 }]);
|
||||
expect(await collect<Rec>('b', 'l')).toEqual([{ n: 2 }]);
|
||||
});
|
||||
|
||||
it('drops a torn final line (crash mid-flush)', async () => {
|
||||
// One complete record + a half-written trailing record with no newline.
|
||||
const raw = `${JSON.stringify({ n: 1 })}\n${JSON.stringify({ n: 2 }).slice(0, 4)}`;
|
||||
await storage.append(SCOPE, KEY, enc.encode(raw));
|
||||
|
||||
expect(await collect<Rec>(SCOPE, KEY)).toEqual([{ n: 1 }]);
|
||||
});
|
||||
|
||||
it('throws RecordCorruptedError on a corrupted middle line', async () => {
|
||||
const raw = `${JSON.stringify({ n: 1 })}\nGARBAGE\n${JSON.stringify({ n: 3 })}\n`;
|
||||
await storage.append(SCOPE, KEY, enc.encode(raw));
|
||||
|
||||
await expect(collect<Rec>(SCOPE, KEY)).rejects.toBeInstanceOf(RecordCorruptedError);
|
||||
});
|
||||
});
|
||||
101
packages/agent-core-v2/test/storage/storageService.test.ts
Normal file
101
packages/agent-core-v2/test/storage/storageService.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { IStorageService } from '#/storage';
|
||||
import { FileStorageService } from '#/storage/fileStorageService';
|
||||
import { InMemoryStorageService } from '#/storage/inMemoryStorageService';
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
|
||||
interface ServiceHandle {
|
||||
readonly service: IStorageService;
|
||||
readonly cleanup?: () => Promise<void>;
|
||||
}
|
||||
|
||||
function storageServiceSuite(
|
||||
name: string,
|
||||
setup: () => Promise<ServiceHandle>,
|
||||
): void {
|
||||
describe(name, () => {
|
||||
let service: IStorageService;
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
const handle = await setup();
|
||||
service = handle.service;
|
||||
cleanup = handle.cleanup;
|
||||
});
|
||||
afterEach(async () => {
|
||||
await cleanup?.();
|
||||
});
|
||||
|
||||
it('read returns undefined for a missing key', async () => {
|
||||
expect(await service.read('s', 'missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('write then read round-trips bytes', async () => {
|
||||
await service.write('s', 'k', enc.encode('hello'));
|
||||
expect(dec.decode(await service.read('s', 'k'))).toBe('hello');
|
||||
});
|
||||
|
||||
it('write replaces the whole value (not append)', async () => {
|
||||
await service.write('s', 'k', enc.encode('first'));
|
||||
await service.write('s', 'k', enc.encode('second'));
|
||||
expect(dec.decode(await service.read('s', 'k'))).toBe('second');
|
||||
});
|
||||
|
||||
it('append extends bytes in order', async () => {
|
||||
await service.append('s', 'k', enc.encode('a'));
|
||||
await service.append('s', 'k', enc.encode('b'));
|
||||
await service.append('s', 'k', enc.encode('c'));
|
||||
expect(dec.decode(await service.read('s', 'k'))).toBe('abc');
|
||||
});
|
||||
|
||||
it('append on a missing key creates it', async () => {
|
||||
await service.append('s', 'k', enc.encode('created'));
|
||||
expect(dec.decode(await service.read('s', 'k'))).toBe('created');
|
||||
});
|
||||
|
||||
it('scopes are isolated', async () => {
|
||||
await service.write('scope-a', 'k', enc.encode('A'));
|
||||
await service.write('scope-b', 'k', enc.encode('B'));
|
||||
expect(dec.decode(await service.read('scope-a', 'k'))).toBe('A');
|
||||
expect(dec.decode(await service.read('scope-b', 'k'))).toBe('B');
|
||||
});
|
||||
|
||||
it('list returns keys, optionally filtered by prefix', async () => {
|
||||
await service.write('s', 'alpha', enc.encode('1'));
|
||||
await service.write('s', 'beta', enc.encode('2'));
|
||||
await service.write('s', 'alphabet', enc.encode('3'));
|
||||
expect((await service.list('s')).toSorted()).toEqual(['alpha', 'alphabet', 'beta']);
|
||||
expect((await service.list('s', 'alpha')).toSorted()).toEqual(['alpha', 'alphabet']);
|
||||
});
|
||||
|
||||
it('list on a missing scope returns []', async () => {
|
||||
expect(await service.list('nope')).toEqual([]);
|
||||
});
|
||||
|
||||
it('delete removes a key; deleting a missing key is a no-op', async () => {
|
||||
await service.write('s', 'k', enc.encode('x'));
|
||||
await service.delete('s', 'k');
|
||||
expect(await service.read('s', 'k')).toBeUndefined();
|
||||
await expect(service.delete('s', 'k')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
storageServiceSuite('InMemoryStorageService', async () => ({
|
||||
service: new InMemoryStorageService(),
|
||||
}));
|
||||
|
||||
storageServiceSuite('FileStorageService', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'storage-service-test-'));
|
||||
return {
|
||||
service: new FileStorageService(dir),
|
||||
cleanup: () => rm(dir, { recursive: true, force: true }),
|
||||
};
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue