mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-25 16:46:17 +00:00
refactor(services,daemon,agent-core): align service registry bootstrap and expand DI lifecycle primitives
- remove defaultServicesModule() and services/src/module.ts; consume getSingletonServiceDescriptors() directly\n- update daemon service registrations and bootstrap to use registry descriptors\n- add DisposableMap, DisposableSet, disposable tracking, and disposeOnReturn to agent-core DI\n- update AGENTS.md with new registration patterns
This commit is contained in:
parent
d4eacc7e7d
commit
18224d47ff
31 changed files with 1059 additions and 1049 deletions
6
.changeset/align-service-registry-bootstrap.md
Normal file
6
.changeset/align-service-registry-bootstrap.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@moonshot-ai/services": patch
|
||||
"@moonshot-ai/daemon": patch
|
||||
---
|
||||
|
||||
Use singleton service descriptors directly for daemon service bootstrap.
|
||||
|
|
@ -29,11 +29,22 @@ export { InstantiationService } from './instantiationService';
|
|||
export {
|
||||
Disposable,
|
||||
DisposableStore,
|
||||
DisposableMap,
|
||||
DisposableSet,
|
||||
MutableDisposable,
|
||||
DisposableTracker,
|
||||
combinedDisposable,
|
||||
toDisposable,
|
||||
dispose,
|
||||
disposeIfDisposable,
|
||||
disposeOnReturn,
|
||||
isDisposable,
|
||||
markAsSingleton,
|
||||
setDisposableTracker,
|
||||
trackDisposable,
|
||||
markAsDisposed,
|
||||
} from './lifecycle';
|
||||
export type { IDisposable } from './lifecycle';
|
||||
export type { IDisposable, IDisposableTracker } from './lifecycle';
|
||||
export { CyclicDependencyError } from './errors';
|
||||
export {
|
||||
InstantiationType,
|
||||
|
|
|
|||
|
|
@ -1,68 +1,435 @@
|
|||
/**
|
||||
* Lifecycle primitives for DI-managed services: `IDisposable` interface, a
|
||||
* `Disposable` base class, plus the helper primitives (`DisposableStore`,
|
||||
* `MutableDisposable`, `toDisposable`, `combinedDisposable`, `Disposable.None`)
|
||||
* that `Event<T>` / `Emitter<T>` (in `base/common/event.ts`) build on top of.
|
||||
* `MutableDisposable`, `DisposableMap`, `DisposableSet`, `toDisposable`,
|
||||
* `combinedDisposable`, `dispose`, `disposeOnReturn`, `Disposable.None`) that
|
||||
* `Event<T>` / `Emitter<T>` (in `base/common/event.ts`) build on top of.
|
||||
*
|
||||
* Modelled after VSCode's `base/common/lifecycle.ts`. Children disposed by
|
||||
* `Disposable` / `DisposableStore` are torn down in reverse register order
|
||||
* (LIFO) and each `dispose()` is wrapped in try/catch so one failing child
|
||||
* does not skip its siblings.
|
||||
* Modelled after VSCode's `base/common/lifecycle.ts`. Two intentional
|
||||
* deviations from upstream:
|
||||
*
|
||||
* 1. **Error policy** — VSCode's iterable `dispose(...)` collects per-child
|
||||
* errors and throws an `AggregateError` at the end. Here every dispose
|
||||
* path routes failures through `onUnexpectedError` and continues, so a
|
||||
* single misbehaving child cannot abort sibling teardown. Same policy
|
||||
* `Emitter.fire()` uses; keep it consistent.
|
||||
* 2. **Disposable tracker** — `IDisposableTracker` interface and the
|
||||
* plumbing (`trackDisposable` / `markAsDisposed` / `setParentOfDisposable`)
|
||||
* are present, but `disposableTracker` defaults to `null` so there is
|
||||
* zero overhead. Install a `DisposableTracker` in test setup (or a custom
|
||||
* one in a debug build) when leak hunting. We do not ship the
|
||||
* `GCBasedDisposableTracker` variant — `FinalizationRegistry` timing is
|
||||
* non-deterministic enough to produce noisy reports.
|
||||
*
|
||||
* Sibling teardown order is **insertion order** (`Set` iteration). Subclasses
|
||||
* that need a specific order must sequence teardown explicitly inside
|
||||
* `override dispose()` before calling `super.dispose()`.
|
||||
*/
|
||||
|
||||
import { onUnexpectedError } from '../errors/unexpectedError';
|
||||
|
||||
// #region Disposable Tracking
|
||||
|
||||
/**
|
||||
* Hook surface for tracking living disposables, parentage, and lifecycle
|
||||
* transitions. Install via `setDisposableTracker`. Defaults to `null`
|
||||
* (zero overhead).
|
||||
*/
|
||||
export interface IDisposableTracker {
|
||||
/** Called on construction of every disposable. */
|
||||
trackDisposable(disposable: IDisposable): void;
|
||||
/**
|
||||
* Called when a disposable is registered as child of another. If `parent`
|
||||
* is `null`, the disposable was detached from its former parent (e.g. by
|
||||
* `DisposableStore.deleteAndLeak`).
|
||||
*/
|
||||
setParent(child: IDisposable, parent: IDisposable | null): void;
|
||||
/** Called after a disposable's `dispose()` runs. */
|
||||
markAsDisposed(disposable: IDisposable): void;
|
||||
/**
|
||||
* Mark a disposable as a singleton (lives for the lifetime of the process)
|
||||
* so it isn't reported as a leak.
|
||||
*/
|
||||
markAsSingleton(disposable: IDisposable): void;
|
||||
}
|
||||
|
||||
interface DisposableInfo {
|
||||
value: IDisposable;
|
||||
source: string | null;
|
||||
parent: IDisposable | null;
|
||||
isSingleton: boolean;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default tracker for test / dev use. Records a constructor stack on each
|
||||
* `trackDisposable` call and removes the entry on `markAsDisposed`. After a
|
||||
* suspected leak window (e.g. an `afterAll` hook), call
|
||||
* `getTrackedDisposables()` to inspect what's still alive.
|
||||
*
|
||||
* Roots whose ancestor was marked as a singleton are filtered out (they
|
||||
* intentionally live for the process).
|
||||
*/
|
||||
export class DisposableTracker implements IDisposableTracker {
|
||||
private static idx = 0;
|
||||
private readonly livingDisposables = new Map<IDisposable, DisposableInfo>();
|
||||
|
||||
private getDisposableData(d: IDisposable): DisposableInfo {
|
||||
let val = this.livingDisposables.get(d);
|
||||
if (!val) {
|
||||
val = {
|
||||
parent: null,
|
||||
source: null,
|
||||
isSingleton: false,
|
||||
value: d,
|
||||
idx: DisposableTracker.idx++,
|
||||
};
|
||||
this.livingDisposables.set(d, val);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
trackDisposable(d: IDisposable): void {
|
||||
const data = this.getDisposableData(d);
|
||||
if (!data.source) {
|
||||
data.source = new Error().stack ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
setParent(child: IDisposable, parent: IDisposable | null): void {
|
||||
this.getDisposableData(child).parent = parent;
|
||||
}
|
||||
|
||||
markAsDisposed(x: IDisposable): void {
|
||||
this.livingDisposables.delete(x);
|
||||
}
|
||||
|
||||
markAsSingleton(d: IDisposable): void {
|
||||
this.getDisposableData(d).isSingleton = true;
|
||||
}
|
||||
|
||||
private getRootParent(
|
||||
data: DisposableInfo,
|
||||
cache: Map<DisposableInfo, DisposableInfo>,
|
||||
): DisposableInfo {
|
||||
const cached = cache.get(data);
|
||||
if (cached) return cached;
|
||||
const result = data.parent
|
||||
? this.getRootParent(this.getDisposableData(data.parent), cache)
|
||||
: data;
|
||||
cache.set(data, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* All currently-living disposables whose root ancestor is NOT a singleton.
|
||||
* Use as the post-condition assertion in test teardown.
|
||||
*/
|
||||
getTrackedDisposables(): IDisposable[] {
|
||||
const cache = new Map<DisposableInfo, DisposableInfo>();
|
||||
return [...this.livingDisposables.entries()]
|
||||
.filter(
|
||||
([, v]) => v.source !== null && !this.getRootParent(v, cache).isSingleton,
|
||||
)
|
||||
.map(([k]) => k);
|
||||
}
|
||||
}
|
||||
|
||||
let disposableTracker: IDisposableTracker | null = null;
|
||||
|
||||
export function setDisposableTracker(tracker: IDisposableTracker | null): void {
|
||||
disposableTracker = tracker;
|
||||
}
|
||||
|
||||
export function trackDisposable<T extends IDisposable>(x: T): T {
|
||||
disposableTracker?.trackDisposable(x);
|
||||
return x;
|
||||
}
|
||||
|
||||
export function markAsDisposed(disposable: IDisposable): void {
|
||||
disposableTracker?.markAsDisposed(disposable);
|
||||
}
|
||||
|
||||
function setParentOfDisposable(
|
||||
child: IDisposable,
|
||||
parent: IDisposable | null,
|
||||
): void {
|
||||
disposableTracker?.setParent(child, parent);
|
||||
}
|
||||
|
||||
function setParentOfDisposables(
|
||||
children: IDisposable[],
|
||||
parent: IDisposable | null,
|
||||
): void {
|
||||
if (!disposableTracker) return;
|
||||
for (const child of children) {
|
||||
disposableTracker.setParent(child, parent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that the given object lives for the lifetime of the process so
|
||||
* the tracker should not report it as a leak.
|
||||
*/
|
||||
export function markAsSingleton<T extends IDisposable>(singleton: T): T {
|
||||
disposableTracker?.markAsSingleton(singleton);
|
||||
return singleton;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
export interface IDisposable {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for services that own other disposables. Subclasses call
|
||||
* `this._register(child)` to take ownership; `dispose()` tears children down
|
||||
* in reverse register order (LIFO) and is idempotent.
|
||||
* Type guard for heterogeneous collections. Matches VSCode `isDisposable`:
|
||||
* accepts any object with a zero-arg `dispose()` method.
|
||||
*/
|
||||
export abstract class Disposable implements IDisposable {
|
||||
private _disposed = false;
|
||||
protected _toDispose: IDisposable[] = [];
|
||||
export function isDisposable<E>(thing: E): thing is E & IDisposable {
|
||||
return (
|
||||
typeof thing === 'object' &&
|
||||
thing !== null &&
|
||||
typeof (thing as unknown as IDisposable).dispose === 'function' &&
|
||||
(thing as unknown as IDisposable).dispose.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take ownership of a child disposable. Returns the child for ergonomic
|
||||
* one-liner chaining (`const x = this._register(new Foo())`).
|
||||
*/
|
||||
protected _register<T extends IDisposable>(d: T): T {
|
||||
if (this._disposed) {
|
||||
// Don't silently hold a reference after disposal; tear down immediately
|
||||
// so we don't leak the child if someone calls `_register` post-dispose.
|
||||
/**
|
||||
* Dispose one or many `IDisposable`s. Per-child errors are routed through
|
||||
* `onUnexpectedError` and do not abort the loop (kimi-code policy — VSCode
|
||||
* collects errors into an `AggregateError` and throws; we do not, to keep
|
||||
* a single misbehaving child from breaking sibling teardown).
|
||||
*
|
||||
* Overloads mirror VSCode for ergonomic call sites.
|
||||
*/
|
||||
export function dispose<T extends IDisposable>(disposable: T): T;
|
||||
export function dispose<T extends IDisposable>(
|
||||
disposable: T | undefined,
|
||||
): T | undefined;
|
||||
export function dispose<T extends IDisposable, A extends Iterable<T> = Iterable<T>>(
|
||||
disposables: A,
|
||||
): A;
|
||||
export function dispose<T extends IDisposable>(disposables: Array<T>): Array<T>;
|
||||
export function dispose<T extends IDisposable>(
|
||||
disposables: ReadonlyArray<T>,
|
||||
): ReadonlyArray<T>;
|
||||
export function dispose<T extends IDisposable>(
|
||||
arg: T | Iterable<T> | undefined,
|
||||
): unknown {
|
||||
if (arg === undefined || arg === null) return arg;
|
||||
if (isIterable<T>(arg)) {
|
||||
for (const d of arg) {
|
||||
if (d) {
|
||||
try {
|
||||
d.dispose();
|
||||
} catch (err) {
|
||||
onUnexpectedError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.isArray(arg) ? [] : arg;
|
||||
}
|
||||
try {
|
||||
(arg as T).dispose();
|
||||
} catch (err) {
|
||||
onUnexpectedError(err);
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
|
||||
function isIterable<T>(arg: unknown): arg is Iterable<T> {
|
||||
return (
|
||||
typeof arg === 'object' &&
|
||||
arg !== null &&
|
||||
typeof (arg as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose only the entries in `disposables` that pass the `isDisposable`
|
||||
* type guard. Mirrors VSCode helper of the same name; useful when holding
|
||||
* mixed collections (e.g. legacy code that may or may not implement the
|
||||
* interface).
|
||||
*/
|
||||
export function disposeIfDisposable<T extends IDisposable | object>(
|
||||
disposables: Array<T>,
|
||||
): Array<T> {
|
||||
for (const d of disposables) {
|
||||
if (isDisposable(d)) {
|
||||
try {
|
||||
d.dispose();
|
||||
} catch {
|
||||
// Swallow: dispose() must be idempotent / forgiving.
|
||||
} catch (err) {
|
||||
onUnexpectedError(err);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
this._toDispose.push(d);
|
||||
return d;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a function as an `IDisposable`. The returned object's `dispose()`
|
||||
* invokes `fn` at most once — repeated calls are a no-op (idempotent).
|
||||
*
|
||||
* Implemented as a class so the returned object has a stable shape for
|
||||
* debuggers / V8 hidden-class optimisation, and so the tracker can record
|
||||
* a construction stack.
|
||||
*/
|
||||
class FunctionDisposable implements IDisposable {
|
||||
private _isDisposed = false;
|
||||
private readonly _fn: () => void;
|
||||
|
||||
constructor(fn: () => void) {
|
||||
this._fn = fn;
|
||||
trackDisposable(this);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this._disposed) {
|
||||
return;
|
||||
if (this._isDisposed) return;
|
||||
this._isDisposed = true;
|
||||
markAsDisposed(this);
|
||||
try {
|
||||
this._fn();
|
||||
} catch (err) {
|
||||
onUnexpectedError(err);
|
||||
}
|
||||
this._disposed = true;
|
||||
// Reverse order: most-recently-registered tears down first (LIFO).
|
||||
while (this._toDispose.length > 0) {
|
||||
const child = this._toDispose.pop();
|
||||
if (!child) continue;
|
||||
}
|
||||
}
|
||||
|
||||
export function toDisposable(fn: () => void): IDisposable {
|
||||
return new FunctionDisposable(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate multiple disposables into a single `IDisposable`. Children are
|
||||
* disposed in insertion order via the iterable `dispose(...)` helper, so
|
||||
* one throwing child does not skip its siblings.
|
||||
*/
|
||||
export function combinedDisposable(...disposables: IDisposable[]): IDisposable {
|
||||
const parent = toDisposable(() => dispose(disposables));
|
||||
setParentOfDisposables(disposables, parent);
|
||||
return parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container that owns multiple `IDisposable`s. Iteration / disposal order is
|
||||
* **insertion order** (`Set` semantics). Mirrors VSCode
|
||||
* `base/common/lifecycle.ts DisposableStore`.
|
||||
*/
|
||||
export class DisposableStore implements IDisposable {
|
||||
private readonly _toDispose = new Set<IDisposable>();
|
||||
private _isDisposed = false;
|
||||
|
||||
constructor() {
|
||||
trackDisposable(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take ownership of `d`. Returns `d` for ergonomic chaining
|
||||
* (`const x = store.add(new Foo())`). After the store has been disposed,
|
||||
* `add` disposes the incoming child immediately and still returns it.
|
||||
* Adding the store to itself throws.
|
||||
*/
|
||||
add<T extends IDisposable>(d: T): T {
|
||||
if ((d as unknown as DisposableStore) === this) {
|
||||
throw new Error('Cannot register a disposable on itself!');
|
||||
}
|
||||
setParentOfDisposable(d, this);
|
||||
if (this._isDisposed) {
|
||||
try {
|
||||
child.dispose();
|
||||
} catch {
|
||||
// Continue tearing down siblings even if one throws.
|
||||
d.dispose();
|
||||
} catch (err) {
|
||||
onUnexpectedError(err);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
this._toDispose.add(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove `d` from the store AND dispose it. Matches VSCode
|
||||
* `DisposableStore.delete`. Use `deleteAndLeak` to detach without
|
||||
* disposing.
|
||||
*/
|
||||
delete<T extends IDisposable>(d: T): void {
|
||||
if (this._isDisposed) return;
|
||||
if ((d as unknown as DisposableStore) === this) {
|
||||
throw new Error('Cannot dispose a disposable on itself!');
|
||||
}
|
||||
this._toDispose.delete(d);
|
||||
try {
|
||||
d.dispose();
|
||||
} catch (err) {
|
||||
onUnexpectedError(err);
|
||||
}
|
||||
}
|
||||
|
||||
protected get _isDisposed(): boolean {
|
||||
return this._disposed;
|
||||
/**
|
||||
* Remove `d` from the store WITHOUT disposing. Caller takes ownership of
|
||||
* `d`'s lifetime. Matches VSCode `DisposableStore.deleteAndLeak`.
|
||||
*/
|
||||
deleteAndLeak<T extends IDisposable>(d: T): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._toDispose.delete(d)) {
|
||||
setParentOfDisposable(d, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose every currently-held child but keep the store usable.
|
||||
*/
|
||||
clear(): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._toDispose.size === 0) return;
|
||||
const items = Array.from(this._toDispose);
|
||||
this._toDispose.clear();
|
||||
dispose(items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose every currently-held child and mark the store as disposed.
|
||||
* Idempotent.
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
this._isDisposed = true;
|
||||
markAsDisposed(this);
|
||||
const items = Array.from(this._toDispose);
|
||||
this._toDispose.clear();
|
||||
dispose(items);
|
||||
}
|
||||
|
||||
get isDisposed(): boolean {
|
||||
return this._isDisposed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for services that own other disposables. Subclasses call
|
||||
* `this._register(child)` to take ownership; `dispose()` tears children down
|
||||
* in **insertion order** (matching VSCode) and is idempotent.
|
||||
*
|
||||
* Subclasses inspect "have I been disposed yet?" via `this._store.isDisposed`.
|
||||
*/
|
||||
export abstract class Disposable implements IDisposable {
|
||||
protected readonly _store = new DisposableStore();
|
||||
|
||||
constructor() {
|
||||
trackDisposable(this);
|
||||
setParentOfDisposable(this._store, this);
|
||||
}
|
||||
|
||||
protected _register<T extends IDisposable>(d: T): T {
|
||||
if ((d as unknown as Disposable) === this) {
|
||||
throw new Error('Cannot register a disposable on itself!');
|
||||
}
|
||||
return this._store.add(d);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
markAsDisposed(this);
|
||||
this._store.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +437,10 @@ export abstract class Disposable implements IDisposable {
|
|||
* Static zero-value disposable. `Disposable.None.dispose()` is a no-op and
|
||||
* is safe to call repeatedly. The object is frozen so callers can't mutate
|
||||
* the shared instance. Modelled after VSCode `base/common/lifecycle.ts`.
|
||||
*
|
||||
* Declared as a namespace merger rather than a static class property so we
|
||||
* don't pull `DisposableStore` allocation into module load just to read
|
||||
* `Disposable.None`.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace Disposable {
|
||||
|
|
@ -80,52 +451,11 @@ export namespace Disposable {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a function as an `IDisposable`. The returned object's `dispose()`
|
||||
* invokes `fn` at most once — repeated calls are a no-op (idempotent).
|
||||
*/
|
||||
export function toDisposable(fn: () => void): IDisposable {
|
||||
let called = false;
|
||||
return {
|
||||
dispose(): void {
|
||||
if (called) return;
|
||||
called = true;
|
||||
fn();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate multiple disposables into a single `IDisposable`. The returned
|
||||
* object's `dispose()` invokes each child's `dispose()` — each call wrapped
|
||||
* in try/catch so one throwing child does not skip its siblings (mirrors
|
||||
* `Disposable.dispose()` semantics above). Idempotent: a second `dispose()`
|
||||
* is a no-op.
|
||||
*/
|
||||
export function combinedDisposable(...disposables: IDisposable[]): IDisposable {
|
||||
let disposed = false;
|
||||
return {
|
||||
dispose(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
for (const child of disposables) {
|
||||
try {
|
||||
child.dispose();
|
||||
} catch (err) {
|
||||
// Route to onUnexpectedError so the failure is visible, but keep
|
||||
// iterating siblings (consistent with Disposable.dispose()).
|
||||
onUnexpectedError(err);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable slot that owns a single `IDisposable`. Assigning a new value
|
||||
* disposes the previous one; assigning `undefined` disposes the current
|
||||
* value. After this store has itself been disposed any subsequent value
|
||||
* is disposed immediately on assignment (mirrors `Disposable._register`).
|
||||
* is disposed immediately on assignment.
|
||||
*
|
||||
* Mirrors VSCode `base/common/lifecycle.ts MutableDisposable`.
|
||||
*/
|
||||
|
|
@ -133,14 +463,16 @@ export class MutableDisposable<T extends IDisposable> implements IDisposable {
|
|||
private _value: T | undefined;
|
||||
private _isDisposed = false;
|
||||
|
||||
constructor() {
|
||||
trackDisposable(this);
|
||||
}
|
||||
|
||||
get value(): T | undefined {
|
||||
return this._isDisposed ? undefined : this._value;
|
||||
}
|
||||
|
||||
set value(value: T | undefined) {
|
||||
if (this._isDisposed) {
|
||||
// Once disposed, the slot can no longer hold a reference — tear
|
||||
// down the new value immediately and keep the slot empty.
|
||||
if (value !== undefined) {
|
||||
try {
|
||||
value.dispose();
|
||||
|
|
@ -150,11 +482,10 @@ export class MutableDisposable<T extends IDisposable> implements IDisposable {
|
|||
}
|
||||
return;
|
||||
}
|
||||
if (this._value === value) {
|
||||
return;
|
||||
}
|
||||
if (this._value === value) return;
|
||||
const prev = this._value;
|
||||
this._value = value;
|
||||
if (value) setParentOfDisposable(value, this);
|
||||
if (prev !== undefined) {
|
||||
try {
|
||||
prev.dispose();
|
||||
|
|
@ -164,14 +495,10 @@ export class MutableDisposable<T extends IDisposable> implements IDisposable {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the held value (if any) and mark the slot as disposed.
|
||||
* Subsequent `value = ...` assignments dispose the incoming value
|
||||
* immediately. Idempotent.
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
this._isDisposed = true;
|
||||
markAsDisposed(this);
|
||||
const prev = this._value;
|
||||
this._value = undefined;
|
||||
if (prev !== undefined) {
|
||||
|
|
@ -199,87 +526,240 @@ export class MutableDisposable<T extends IDisposable> implements IDisposable {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the slot WITHOUT disposing the current value; returns the old
|
||||
* value. Caller takes ownership of its lifetime.
|
||||
*/
|
||||
clearAndLeak(): T | undefined {
|
||||
if (this._isDisposed) return undefined;
|
||||
const prev = this._value;
|
||||
this._value = undefined;
|
||||
if (prev !== undefined) setParentOfDisposable(prev, null);
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Container that owns multiple `IDisposable`s. `add` returns the child for
|
||||
* chaining; `delete` removes (without disposing); `clear` disposes every
|
||||
* currently-held child; `dispose` disposes every child and marks the store
|
||||
* as disposed. Once disposed, subsequent `add(child)` calls dispose the
|
||||
* incoming child immediately (mirrors `Disposable._register`).
|
||||
* Map whose values are `IDisposable`. Overwriting a key disposes the previous
|
||||
* value; `deleteAndDispose(key)` removes and disposes; `dispose()` disposes
|
||||
* every value and marks the map as disposed. Mirrors VSCode
|
||||
* `base/common/lifecycle.ts DisposableMap`.
|
||||
*
|
||||
* Mirrors VSCode `base/common/lifecycle.ts DisposableStore`.
|
||||
* Use this to collapse the "Map of per-entity state + manual teardown loop in
|
||||
* `override dispose()`" pattern that recurs across daemon services.
|
||||
*/
|
||||
export class DisposableStore implements IDisposable {
|
||||
private _toDispose = new Set<IDisposable>();
|
||||
export class DisposableMap<K, V extends IDisposable = IDisposable>
|
||||
implements IDisposable
|
||||
{
|
||||
private readonly _store: Map<K, V>;
|
||||
private _isDisposed = false;
|
||||
|
||||
/**
|
||||
* Take ownership of `d`. Returns `d` for ergonomic chaining
|
||||
* (`const x = store.add(new Foo())`). After the store has been disposed,
|
||||
* `add` disposes the incoming child immediately and still returns it.
|
||||
*/
|
||||
add<T extends IDisposable>(d: T): T {
|
||||
if (this._isDisposed) {
|
||||
try {
|
||||
d.dispose();
|
||||
} catch (err) {
|
||||
onUnexpectedError(err);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
this._toDispose.add(d);
|
||||
return d;
|
||||
constructor(store: Map<K, V> = new Map<K, V>()) {
|
||||
this._store = store;
|
||||
trackDisposable(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove `d` from the store WITHOUT disposing it. No-op if `d` is not
|
||||
* currently tracked.
|
||||
*/
|
||||
delete<T extends IDisposable>(d: T): void {
|
||||
if (this._isDisposed) return;
|
||||
this._toDispose.delete(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose every currently-held child but keep the store usable
|
||||
* (subsequent `add` calls still work).
|
||||
*/
|
||||
clear(): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._toDispose.size === 0) return;
|
||||
const items = Array.from(this._toDispose);
|
||||
this._toDispose.clear();
|
||||
// Iterate in reverse so most-recently-added tears down first (LIFO),
|
||||
// matching `Disposable.dispose()` semantics.
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
items[i]!.dispose();
|
||||
} catch (err) {
|
||||
onUnexpectedError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose every currently-held child and mark the store as disposed.
|
||||
* Idempotent.
|
||||
* Dispose every stored value and mark this object as disposed. Subsequent
|
||||
* mutation (`set`) is a no-op + warning.
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
this._isDisposed = true;
|
||||
const items = Array.from(this._toDispose);
|
||||
this._toDispose.clear();
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
markAsDisposed(this);
|
||||
this.clearAndDisposeAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose every stored value and clear the map, but DO NOT mark the map
|
||||
* itself as disposed (subsequent `set` calls still work).
|
||||
*/
|
||||
clearAndDisposeAll(): void {
|
||||
if (this._store.size === 0) return;
|
||||
try {
|
||||
dispose(this._store.values());
|
||||
} finally {
|
||||
this._store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this._store.has(key);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this._store.size;
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
return this._store.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert `value` at `key`. If `key` already has a value, that previous
|
||||
* value is disposed unless `skipDisposeOnOverwrite` is set.
|
||||
*/
|
||||
set(key: K, value: V, skipDisposeOnOverwrite = false): void {
|
||||
if (this._isDisposed) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
new Error(
|
||||
'Trying to add a disposable to a DisposableMap that has already been disposed of. The added object will be leaked!',
|
||||
).stack,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!skipDisposeOnOverwrite) {
|
||||
const prev = this._store.get(key);
|
||||
if (prev !== undefined && prev !== value) {
|
||||
try {
|
||||
prev.dispose();
|
||||
} catch (err) {
|
||||
onUnexpectedError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
this._store.set(key, value);
|
||||
setParentOfDisposable(value, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the value stored for `key` AND dispose it.
|
||||
*/
|
||||
deleteAndDispose(key: K): void {
|
||||
const value = this._store.get(key);
|
||||
if (value !== undefined) {
|
||||
try {
|
||||
items[i]!.dispose();
|
||||
value.dispose();
|
||||
} catch (err) {
|
||||
onUnexpectedError(err);
|
||||
}
|
||||
}
|
||||
this._store.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the value stored for `key` and return it. Caller takes
|
||||
* ownership of the lifetime.
|
||||
*/
|
||||
deleteAndLeak(key: K): V | undefined {
|
||||
const value = this._store.get(key);
|
||||
if (value !== undefined) setParentOfDisposable(value, null);
|
||||
this._store.delete(key);
|
||||
return value;
|
||||
}
|
||||
|
||||
keys(): IterableIterator<K> {
|
||||
return this._store.keys();
|
||||
}
|
||||
|
||||
values(): IterableIterator<V> {
|
||||
return this._store.values();
|
||||
}
|
||||
|
||||
[Symbol.iterator](): IterableIterator<[K, V]> {
|
||||
return this._store[Symbol.iterator]();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whose values are `IDisposable`. `add(v)` takes ownership;
|
||||
* `deleteAndDispose(v)` removes and disposes; `dispose()` disposes every
|
||||
* value. Mirrors VSCode `base/common/lifecycle.ts DisposableSet`.
|
||||
*/
|
||||
export class DisposableSet<V extends IDisposable = IDisposable>
|
||||
implements IDisposable
|
||||
{
|
||||
private readonly _store: Set<V>;
|
||||
private _isDisposed = false;
|
||||
|
||||
constructor(store: Set<V> = new Set<V>()) {
|
||||
this._store = store;
|
||||
trackDisposable(this);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
this._isDisposed = true;
|
||||
markAsDisposed(this);
|
||||
this.clearAndDisposeAll();
|
||||
}
|
||||
|
||||
clearAndDisposeAll(): void {
|
||||
if (this._store.size === 0) return;
|
||||
try {
|
||||
dispose(this._store.values());
|
||||
} finally {
|
||||
this._store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
has(value: V): boolean {
|
||||
return this._store.has(value);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this._store.size;
|
||||
}
|
||||
|
||||
add(value: V): void {
|
||||
if (this._isDisposed) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
new Error(
|
||||
'Trying to add a disposable to a DisposableSet that has already been disposed of. The added object will be leaked!',
|
||||
).stack,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._store.add(value);
|
||||
setParentOfDisposable(value, this);
|
||||
}
|
||||
|
||||
deleteAndDispose(value: V): void {
|
||||
if (this._store.delete(value)) {
|
||||
try {
|
||||
value.dispose();
|
||||
} catch (err) {
|
||||
onUnexpectedError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get isDisposed(): boolean {
|
||||
return this._isDisposed;
|
||||
deleteAndLeak(value: V): V | undefined {
|
||||
if (this._store.delete(value)) {
|
||||
setParentOfDisposable(value, null);
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
values(): IterableIterator<V> {
|
||||
return this._store.values();
|
||||
}
|
||||
|
||||
[Symbol.iterator](): IterableIterator<V> {
|
||||
return this._store[Symbol.iterator]();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoped `using` helper: construct a `DisposableStore`, run `fn(store)`, then
|
||||
* tear the store down in a `finally`. Lets callers register transient
|
||||
* disposables for the duration of a function without writing
|
||||
* `try { ... } finally { store.dispose(); }` by hand.
|
||||
*
|
||||
* disposeOnReturn(store => {
|
||||
* const a = store.add(new Foo());
|
||||
* doStuff(a);
|
||||
* });
|
||||
*/
|
||||
export function disposeOnReturn(fn: (store: DisposableStore) => void): void {
|
||||
const store = new DisposableStore();
|
||||
try {
|
||||
fn(store);
|
||||
} finally {
|
||||
store.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -292,7 +292,7 @@ describe('InstantiationService.createChild', () => {
|
|||
});
|
||||
|
||||
describe('Disposable base class', () => {
|
||||
it('reverse register order on dispose', () => {
|
||||
it('insertion order on dispose', () => {
|
||||
const events: string[] = [];
|
||||
class Child implements IDisposable {
|
||||
constructor(public readonly label: string) {}
|
||||
|
|
@ -310,7 +310,7 @@ describe('Disposable base class', () => {
|
|||
}
|
||||
const o = new Owner();
|
||||
o.dispose();
|
||||
expect(events).toEqual(['disposed third', 'disposed second', 'disposed first']);
|
||||
expect(events).toEqual(['disposed first', 'disposed second', 'disposed third']);
|
||||
});
|
||||
|
||||
it('idempotent dispose on the base class', () => {
|
||||
|
|
@ -372,7 +372,8 @@ describe('Disposable base class', () => {
|
|||
}
|
||||
const o = new Owner();
|
||||
expect(() => o.dispose()).not.toThrow();
|
||||
// BadChild is registered last so it tears down first (LIFO).
|
||||
expect(events).toEqual(['bad-attempted', 'good']);
|
||||
// GoodChild is registered first, so it tears down first (insertion order);
|
||||
// BadChild throws but the loop continues.
|
||||
expect(events).toEqual(['good', 'bad-attempted']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ describe('toDisposable', () => {
|
|||
});
|
||||
|
||||
describe('combinedDisposable', () => {
|
||||
it('disposes all children', () => {
|
||||
it('disposes all children in insertion order', () => {
|
||||
const order: string[] = [];
|
||||
const d = combinedDisposable(
|
||||
makeRecorder('a', order),
|
||||
|
|
@ -142,14 +142,14 @@ describe('DisposableStore', () => {
|
|||
store.dispose();
|
||||
});
|
||||
|
||||
it('dispose tears down children in LIFO order', () => {
|
||||
it('dispose tears down children in insertion order', () => {
|
||||
const order: string[] = [];
|
||||
const store = new DisposableStore();
|
||||
store.add(makeRecorder('a', order));
|
||||
store.add(makeRecorder('b', order));
|
||||
store.add(makeRecorder('c', order));
|
||||
store.dispose();
|
||||
expect(order).toEqual(['c', 'b', 'a']);
|
||||
expect(order).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('clear disposes children but keeps the store usable', () => {
|
||||
|
|
@ -158,18 +158,30 @@ describe('DisposableStore', () => {
|
|||
store.add(makeRecorder('a', order));
|
||||
store.add(makeRecorder('b', order));
|
||||
store.clear();
|
||||
expect(order).toEqual(['b', 'a']);
|
||||
expect(order).toEqual(['a', 'b']);
|
||||
store.add(makeRecorder('c', order));
|
||||
store.dispose();
|
||||
expect(order).toEqual(['b', 'a', 'c']);
|
||||
expect(order).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('delete removes a child without disposing it', () => {
|
||||
it('delete removes a child AND disposes it', () => {
|
||||
const order: string[] = [];
|
||||
const store = new DisposableStore();
|
||||
const rec = makeRecorder('a', order);
|
||||
store.add(rec);
|
||||
store.delete(rec);
|
||||
expect(order).toEqual(['a']);
|
||||
// No second dispose when the store itself is later torn down.
|
||||
store.dispose();
|
||||
expect(order).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('deleteAndLeak removes a child WITHOUT disposing it', () => {
|
||||
const order: string[] = [];
|
||||
const store = new DisposableStore();
|
||||
const rec = makeRecorder('a', order);
|
||||
store.add(rec);
|
||||
store.deleteAndLeak(rec);
|
||||
store.dispose();
|
||||
expect(order).toEqual([]);
|
||||
});
|
||||
|
|
@ -205,16 +217,16 @@ describe('DisposableStore', () => {
|
|||
});
|
||||
store.add(makeRecorder('c', order));
|
||||
store.dispose();
|
||||
// LIFO: c, throwing-child, a
|
||||
expect(order).toEqual(['c', 'a']);
|
||||
// Insertion order: a, throwing-child, c
|
||||
expect(order).toEqual(['a', 'c']);
|
||||
expect(captured).toHaveLength(1);
|
||||
expect((captured[0] as Error).message).toBe('store-child-boom');
|
||||
resetUnexpectedErrorHandler();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disposable base class (unchanged behaviour)', () => {
|
||||
it('LIFO teardown still holds after the lifecycle expansion', () => {
|
||||
describe('Disposable base class', () => {
|
||||
it('insertion-order teardown', () => {
|
||||
const order: string[] = [];
|
||||
class Owner extends Disposable {
|
||||
add(label: string): void {
|
||||
|
|
@ -226,6 +238,19 @@ describe('Disposable base class (unchanged behaviour)', () => {
|
|||
owner.add('b');
|
||||
owner.add('c');
|
||||
owner.dispose();
|
||||
expect(order).toEqual(['c', 'b', 'a']);
|
||||
expect(order).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('registering self throws', () => {
|
||||
class Owner extends Disposable {
|
||||
registerSelf(): void {
|
||||
this._register(this);
|
||||
}
|
||||
}
|
||||
const owner = new Owner();
|
||||
expect(() => owner.registerSelf()).toThrow(
|
||||
/Cannot register a disposable on itself/,
|
||||
);
|
||||
owner.dispose();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
import { Disposable } from '@moonshot-ai/agent-core';
|
||||
import { Disposable, DisposableMap, type IDisposable } from '@moonshot-ai/agent-core';
|
||||
import type {
|
||||
ApprovalRequest as ProtocolApprovalRequest,
|
||||
Event,
|
||||
|
|
@ -86,23 +86,64 @@ export class ApprovalExpiredError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
interface PendingApproval {
|
||||
readonly approvalId: string;
|
||||
readonly sessionId: string;
|
||||
readonly toolCallId: string;
|
||||
readonly createdAt: string;
|
||||
readonly expiresAt: string;
|
||||
readonly protocolRequest: ProtocolApprovalRequest;
|
||||
resolve: (r: ApprovalResponse) => void;
|
||||
reject: (e: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
/**
|
||||
* One pending approval. Owns the 60s timer + promise callbacks. Two ways out:
|
||||
* - `markSettled()` — the happy path called by resolve/expire AFTER they've
|
||||
* decided the outcome; just stops the timer.
|
||||
* - `dispose()` — the shutdown fallback. If the entry hasn't been settled
|
||||
* yet, clears the timer AND rejects with "daemon shutting down" so the
|
||||
* awaiter doesn't dangle.
|
||||
*
|
||||
* Stored in `ApprovalService._pending` (`DisposableMap`) so service-wide
|
||||
* teardown via `super.dispose()` triggers each entry's `dispose()`
|
||||
* automatically.
|
||||
*/
|
||||
class PendingApproval implements IDisposable {
|
||||
private _settled = false;
|
||||
|
||||
constructor(
|
||||
readonly approvalId: string,
|
||||
readonly sessionId: string,
|
||||
readonly toolCallId: string,
|
||||
readonly createdAt: string,
|
||||
readonly expiresAt: string,
|
||||
readonly protocolRequest: ProtocolApprovalRequest,
|
||||
private readonly _resolveFn: (r: ApprovalResponse) => void,
|
||||
private readonly _rejectFn: (e: Error) => void,
|
||||
private readonly _timer: NodeJS.Timeout,
|
||||
) {}
|
||||
|
||||
markSettled(): void {
|
||||
if (this._settled) return;
|
||||
this._settled = true;
|
||||
clearTimeout(this._timer);
|
||||
}
|
||||
|
||||
resolve(r: ApprovalResponse): void {
|
||||
this._resolveFn(r);
|
||||
}
|
||||
|
||||
reject(e: Error): void {
|
||||
this._rejectFn(e);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this._settled) return;
|
||||
this._settled = true;
|
||||
clearTimeout(this._timer);
|
||||
try {
|
||||
this._rejectFn(new Error('daemon shutting down'));
|
||||
} catch {
|
||||
// awaiter may not have a catch handler attached yet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ApprovalService extends Disposable implements IApprovalService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** Indexed by daemon-minted `approval_id` (REST path key). */
|
||||
private readonly _pending = new Map<string, PendingApproval>();
|
||||
private readonly _pending: DisposableMap<string, PendingApproval>;
|
||||
/** Reverse lookup for `toolCallId` (legacy in-process interface compatibility). */
|
||||
private readonly _byToolCallId = new Map<string, string>();
|
||||
/**
|
||||
|
|
@ -119,12 +160,13 @@ export class ApprovalService extends Disposable implements IApprovalService {
|
|||
@IEventService private readonly eventService: IEventService,
|
||||
) {
|
||||
super();
|
||||
this._pending = this._register(new DisposableMap<string, PendingApproval>());
|
||||
}
|
||||
|
||||
async request(
|
||||
req: ApprovalRequest & { sessionId: string; agentId: string },
|
||||
): Promise<ApprovalResponse> {
|
||||
if (this._isDisposed) {
|
||||
if (this._store.isDisposed) {
|
||||
throw new Error('approval service disposed');
|
||||
}
|
||||
|
||||
|
|
@ -169,17 +211,20 @@ export class ApprovalService extends Disposable implements IApprovalService {
|
|||
return await new Promise<ApprovalResponse>((resolve, reject) => {
|
||||
const timer = setTimeout(() => this._expire(approvalId), this._timeoutMs);
|
||||
timer.unref?.();
|
||||
this._pending.set(approvalId, {
|
||||
this._pending.set(
|
||||
approvalId,
|
||||
sessionId: req.sessionId,
|
||||
toolCallId: req.toolCallId,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
protocolRequest,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
new PendingApproval(
|
||||
approvalId,
|
||||
req.sessionId,
|
||||
req.toolCallId,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
protocolRequest,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
),
|
||||
);
|
||||
this._byToolCallId.set(req.toolCallId, approvalId);
|
||||
});
|
||||
}
|
||||
|
|
@ -194,8 +239,8 @@ export class ApprovalService extends Disposable implements IApprovalService {
|
|||
resolve(id: string, response: ApprovalResponse): void {
|
||||
const p = this._pending.get(id);
|
||||
if (!p) return;
|
||||
clearTimeout(p.timer);
|
||||
this._pending.delete(id);
|
||||
p.markSettled();
|
||||
this._pending.deleteAndLeak(id);
|
||||
this._byToolCallId.delete(p.toolCallId);
|
||||
this.markResolved(p.approvalId);
|
||||
|
||||
|
|
@ -275,7 +320,8 @@ export class ApprovalService extends Disposable implements IApprovalService {
|
|||
private _expire(approvalId: string): void {
|
||||
const p = this._pending.get(approvalId);
|
||||
if (!p) return;
|
||||
this._pending.delete(approvalId);
|
||||
p.markSettled();
|
||||
this._pending.deleteAndLeak(approvalId);
|
||||
this._byToolCallId.delete(p.toolCallId);
|
||||
// Mark as resolved-style for idempotency — a late REST resolve on this id
|
||||
// gets 40902 rather than 40404 (matches "expired ≈ already_resolved" UX).
|
||||
|
|
@ -293,16 +339,7 @@ export class ApprovalService extends Disposable implements IApprovalService {
|
|||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
for (const [, p] of this._pending) {
|
||||
clearTimeout(p.timer);
|
||||
try {
|
||||
p.reject(new Error('daemon shutting down'));
|
||||
} catch {
|
||||
// ignore — the awaiter may not have a catch handler attached yet.
|
||||
}
|
||||
}
|
||||
this._pending.clear();
|
||||
if (this._store.isDisposed) return;
|
||||
this._byToolCallId.clear();
|
||||
this._recentlyResolved.clear();
|
||||
super.dispose();
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ export class FileStore extends Disposable implements IFileStore {
|
|||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._store.isDisposed) return;
|
||||
this.indexCache = undefined;
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import nodePath from 'node:path';
|
|||
|
||||
import { FSWatcher } from 'chokidar';
|
||||
|
||||
import { Disposable } from '@moonshot-ai/agent-core';
|
||||
import { Disposable, DisposableMap, type IDisposable } from '@moonshot-ai/agent-core';
|
||||
import { ISessionService } from '@moonshot-ai/services';
|
||||
|
||||
import type { FsChangeEntry, FsChangeAction, FsChangeKind } from '@moonshot-ai/protocol';
|
||||
|
|
@ -42,25 +42,51 @@ interface PendingChange {
|
|||
kind: FsChangeKind;
|
||||
}
|
||||
|
||||
interface SessionEntry {
|
||||
/** Live chokidar watcher; closed + dropped on last unref. */
|
||||
watcher: FSWatcher;
|
||||
/** `sessionId.cwd` (absolute, post-realpath) for relative-path mapping. */
|
||||
cwd: string;
|
||||
/**
|
||||
* Per-session watcher state. Owns the chokidar `FSWatcher` + active debounce
|
||||
* timer; `dispose()` clears the timer and fire-and-forgets `watcher.close()`.
|
||||
* Stored in `FsWatcherService.sessions` (a `DisposableMap`) so removal via
|
||||
* `sessions.deleteAndDispose(sessionId)` and service-wide teardown via
|
||||
* `super.dispose()` both flow through `dispose()` automatically.
|
||||
*/
|
||||
class SessionEntry implements IDisposable {
|
||||
/** `absPath → refCount` across all connections subscribed to this session. */
|
||||
pathRefs: Map<string, number>;
|
||||
readonly pathRefs = new Map<string, number>();
|
||||
/** `connectionId → Set<absPath>` for overlap filtering on emit. */
|
||||
connectionPaths: Map<string, Set<string>>;
|
||||
readonly connectionPaths = new Map<string, Set<string>>();
|
||||
/** Accumulating changes for the current 200ms window. */
|
||||
pendingChanges: PendingChange[];
|
||||
pendingChanges: PendingChange[] = [];
|
||||
/** Raw event count (used for `truncated.count`). */
|
||||
pendingRawCount: number;
|
||||
pendingRawCount = 0;
|
||||
/** True once `pendingChanges.length > maxChangesPerWindow`. */
|
||||
truncated: boolean;
|
||||
truncated = false;
|
||||
/** Timer for the active debounce window; `undefined` between windows. */
|
||||
debounceTimer: NodeJS.Timeout | undefined;
|
||||
debounceTimer: NodeJS.Timeout | undefined = undefined;
|
||||
/** Per-session seq counter, monotonic, starts at 1. */
|
||||
seq: number;
|
||||
seq = 0;
|
||||
private _disposed = false;
|
||||
|
||||
constructor(
|
||||
public readonly sessionId: string,
|
||||
public readonly watcher: FSWatcher,
|
||||
public cwd: string,
|
||||
private readonly logger: ILogService,
|
||||
) {}
|
||||
|
||||
dispose(): void {
|
||||
if (this._disposed) return;
|
||||
this._disposed = true;
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = undefined;
|
||||
}
|
||||
void this.watcher.close().catch((err) => {
|
||||
this.logger.warn(
|
||||
{ sessionId: this.sessionId, err: String(err) },
|
||||
'fs-watcher close failed',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class FsWatcherService extends Disposable implements IFsWatcher {
|
||||
|
|
@ -70,7 +96,7 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
|
|||
private readonly maxChangesPerWindow: number;
|
||||
private readonly maxPathsPerConnection: number;
|
||||
private readonly makeWatcher: () => FSWatcher;
|
||||
private readonly sessions = new Map<string, SessionEntry>();
|
||||
private readonly sessions: DisposableMap<string, SessionEntry>;
|
||||
/** `connectionId → Map<sessionId, Set<absPath>>`. */
|
||||
private readonly connections = new Map<string, Map<string, Set<string>>>();
|
||||
|
||||
|
|
@ -89,6 +115,7 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
|
|||
@ISessionService _sessionService: ISessionService,
|
||||
) {
|
||||
super();
|
||||
this.sessions = this._register(new DisposableMap<string, SessionEntry>());
|
||||
this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
||||
this.maxChangesPerWindow =
|
||||
options.maxChangesPerWindow ?? DEFAULT_MAX_CHANGES_PER_WINDOW;
|
||||
|
|
@ -111,7 +138,7 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
|
|||
connectionId: string,
|
||||
absPaths: readonly string[],
|
||||
): readonly string[] {
|
||||
if (this._isDisposed) return [];
|
||||
if (this._store.isDisposed) return [];
|
||||
|
||||
// Project the new total for this connection (assuming all `absPaths` are
|
||||
// additions). Dedup against existing first.
|
||||
|
|
@ -184,7 +211,7 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
|
|||
connectionId: string,
|
||||
absPaths: readonly string[],
|
||||
): readonly string[] {
|
||||
if (this._isDisposed) return [];
|
||||
if (this._store.isDisposed) return [];
|
||||
const entry = this.sessions.get(sessionId);
|
||||
if (!entry) return [];
|
||||
const connSessions = this.connections.get(connectionId);
|
||||
|
|
@ -218,7 +245,7 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
|
|||
}
|
||||
// Per-session cleanup: if no path references remain, close the watcher.
|
||||
if (entry.pathRefs.size === 0) {
|
||||
this.disposeSessionEntry(sessionId, entry);
|
||||
this.sessions.deleteAndDispose(sessionId);
|
||||
}
|
||||
return connSessionPaths ? Array.from(connSessionPaths) : [];
|
||||
}
|
||||
|
|
@ -285,17 +312,7 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
|
|||
|
||||
private createSessionEntry(sessionId: string, cwd: string): SessionEntry {
|
||||
const watcher = this.makeWatcher();
|
||||
const entry: SessionEntry = {
|
||||
watcher,
|
||||
cwd,
|
||||
pathRefs: new Map(),
|
||||
connectionPaths: new Map(),
|
||||
pendingChanges: [],
|
||||
pendingRawCount: 0,
|
||||
truncated: false,
|
||||
debounceTimer: undefined,
|
||||
seq: 0,
|
||||
};
|
||||
const entry = new SessionEntry(sessionId, watcher, cwd, this.logger);
|
||||
watcher.on(
|
||||
'all',
|
||||
(eventName: string, absPath: string) => {
|
||||
|
|
@ -311,27 +328,13 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
|
|||
return entry;
|
||||
}
|
||||
|
||||
private disposeSessionEntry(sessionId: string, entry: SessionEntry): void {
|
||||
if (entry.debounceTimer) {
|
||||
clearTimeout(entry.debounceTimer);
|
||||
entry.debounceTimer = undefined;
|
||||
}
|
||||
void entry.watcher.close().catch((err) => {
|
||||
this.logger.warn(
|
||||
{ sessionId, err: String(err) },
|
||||
'fs-watcher close failed',
|
||||
);
|
||||
});
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
private onRawChange(
|
||||
sessionId: string,
|
||||
entry: SessionEntry,
|
||||
eventName: string,
|
||||
absPath: string,
|
||||
): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._store.isDisposed) return;
|
||||
const action = mapChokidarEventToAction(eventName);
|
||||
if (action === undefined) return; // 'ready', 'raw', 'all', 'error'
|
||||
const kind = mapChokidarEventToKind(eventName);
|
||||
|
|
@ -414,11 +417,7 @@ export class FsWatcherService extends Disposable implements IFsWatcher {
|
|||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
const entries = Array.from(this.sessions.entries());
|
||||
for (const [sid, e] of entries) {
|
||||
this.disposeSessionEntry(sid, e);
|
||||
}
|
||||
if (this._store.isDisposed) return;
|
||||
this.connections.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export class ConnectionRegistry extends Disposable implements IConnectionRegistr
|
|||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._store.isDisposed) return;
|
||||
// Belt-and-suspenders: WSGateway.dispose() already called closeAll().
|
||||
// Idempotent.
|
||||
this.closeAll();
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export class FastifyRestGateway extends Disposable implements IRestGateway {
|
|||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._store.isDisposed) return;
|
||||
// Fire-and-forget — Fastify's close is async but the DI dispose contract is sync.
|
||||
// The daemon's RunningDaemon.close() awaits `app.close()` explicitly before
|
||||
// calling ix.dispose(), so by the time we get here the listener is already
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export class SessionClientsService extends Disposable implements ISessionClients
|
|||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._store.isDisposed) return;
|
||||
this._bySession.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export class WSBroadcastService extends Disposable implements IWSBroadcastServic
|
|||
}
|
||||
|
||||
private _onEvent(event: Event): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._store.isDisposed) return;
|
||||
const sid = extractSessionId(event);
|
||||
const evType = (event as { type?: string }).type ?? '<no-type>';
|
||||
if (!sid) {
|
||||
|
|
@ -157,7 +157,7 @@ export class WSBroadcastService extends Disposable implements IWSBroadcastServic
|
|||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._store.isDisposed) return;
|
||||
this._sessions.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export class WSGateway extends Disposable implements IWSGateway {
|
|||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._store.isDisposed) return;
|
||||
// 1. Close every attached connection (WS code 1001 = going away).
|
||||
try {
|
||||
this.registry.closeAll('daemon shutting down');
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
import { Disposable } from '@moonshot-ai/agent-core';
|
||||
import { Disposable, DisposableMap, type IDisposable } from '@moonshot-ai/agent-core';
|
||||
import type {
|
||||
Event,
|
||||
QuestionRequest as ProtocolQuestionRequest,
|
||||
|
|
@ -66,23 +66,67 @@ export class QuestionExpiredError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
interface PendingQuestion {
|
||||
readonly questionId: string;
|
||||
readonly sessionId: string;
|
||||
readonly toolCallId: string | undefined;
|
||||
readonly createdAt: string;
|
||||
readonly expiresAt: string;
|
||||
readonly protocolRequest: ProtocolQuestionRequest;
|
||||
resolve: (r: QuestionResult) => void;
|
||||
reject: (e: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
/**
|
||||
* One pending question. Owns the 60s timer + promise callbacks. Two ways out:
|
||||
* - `markSettled()` — the happy path called by resolve/dismiss/expire AFTER
|
||||
* they've decided the outcome; just stops the timer.
|
||||
* - `dispose()` — the shutdown fallback. If the entry hasn't been settled
|
||||
* yet, clears the timer AND rejects with "daemon shutting down" so the
|
||||
* awaiter doesn't dangle.
|
||||
*
|
||||
* Stored in `QuestionService._pending` (`DisposableMap`) so service-wide
|
||||
* teardown via `super.dispose()` triggers each entry's `dispose()`
|
||||
* automatically, replacing the old hand-written for-loop in `override
|
||||
* dispose()`.
|
||||
*/
|
||||
class PendingQuestion implements IDisposable {
|
||||
private _settled = false;
|
||||
|
||||
constructor(
|
||||
readonly questionId: string,
|
||||
readonly sessionId: string,
|
||||
readonly toolCallId: string | undefined,
|
||||
readonly createdAt: string,
|
||||
readonly expiresAt: string,
|
||||
readonly protocolRequest: ProtocolQuestionRequest,
|
||||
private readonly _resolveFn: (r: QuestionResult) => void,
|
||||
private readonly _rejectFn: (e: Error) => void,
|
||||
private readonly _timer: NodeJS.Timeout,
|
||||
) {}
|
||||
|
||||
/** Happy-path settle (resolve/dismiss/expire decided the outcome). */
|
||||
markSettled(): void {
|
||||
if (this._settled) return;
|
||||
this._settled = true;
|
||||
clearTimeout(this._timer);
|
||||
}
|
||||
|
||||
resolve(r: QuestionResult): void {
|
||||
this._resolveFn(r);
|
||||
}
|
||||
|
||||
reject(e: Error): void {
|
||||
this._rejectFn(e);
|
||||
}
|
||||
|
||||
/** Shutdown fallback: reject any unsettled awaiter. Idempotent. */
|
||||
dispose(): void {
|
||||
if (this._settled) return;
|
||||
this._settled = true;
|
||||
clearTimeout(this._timer);
|
||||
try {
|
||||
this._rejectFn(new Error('daemon shutting down'));
|
||||
} catch {
|
||||
// swallow — the awaiter side may have already gone away
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class QuestionService extends Disposable implements IQuestionService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** Indexed by daemon-minted `question_id` (REST path key). */
|
||||
private readonly _pending = new Map<string, PendingQuestion>();
|
||||
private readonly _pending: DisposableMap<string, PendingQuestion>;
|
||||
/** Bounded set of resolved/dismissed ids for idempotency. */
|
||||
private readonly _recentlyResolved = new Set<string>();
|
||||
private _timeoutMs = QUESTION_DEFAULT_TIMEOUT_MS;
|
||||
|
|
@ -93,12 +137,13 @@ export class QuestionService extends Disposable implements IQuestionService {
|
|||
@IEventService private readonly eventService: IEventService,
|
||||
) {
|
||||
super();
|
||||
this._pending = this._register(new DisposableMap<string, PendingQuestion>());
|
||||
}
|
||||
|
||||
async request(
|
||||
req: QuestionRequest & { sessionId: string; agentId: string },
|
||||
): Promise<QuestionResult> {
|
||||
if (this._isDisposed) {
|
||||
if (this._store.isDisposed) {
|
||||
throw new Error('question service disposed');
|
||||
}
|
||||
|
||||
|
|
@ -135,17 +180,20 @@ export class QuestionService extends Disposable implements IQuestionService {
|
|||
return await new Promise<QuestionResult>((resolve, reject) => {
|
||||
const timer = setTimeout(() => this._expire(questionId), this._timeoutMs);
|
||||
timer.unref?.();
|
||||
this._pending.set(questionId, {
|
||||
this._pending.set(
|
||||
questionId,
|
||||
sessionId: req.sessionId,
|
||||
toolCallId: req.toolCallId,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
protocolRequest,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
new PendingQuestion(
|
||||
questionId,
|
||||
req.sessionId,
|
||||
req.toolCallId,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
protocolRequest,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -157,8 +205,8 @@ export class QuestionService extends Disposable implements IQuestionService {
|
|||
resolve(id: string, response: QuestionResult): void {
|
||||
const p = this._pending.get(id);
|
||||
if (!p) return;
|
||||
clearTimeout(p.timer);
|
||||
this._pending.delete(id);
|
||||
p.markSettled();
|
||||
this._pending.deleteAndLeak(id);
|
||||
this.markResolved(p.questionId);
|
||||
|
||||
const resolvedAt = new Date().toISOString();
|
||||
|
|
@ -187,8 +235,8 @@ export class QuestionService extends Disposable implements IQuestionService {
|
|||
dismiss(id: string): void {
|
||||
const p = this._pending.get(id);
|
||||
if (!p) return;
|
||||
clearTimeout(p.timer);
|
||||
this._pending.delete(id);
|
||||
p.markSettled();
|
||||
this._pending.deleteAndLeak(id);
|
||||
this.markResolved(p.questionId);
|
||||
|
||||
const dismissedAt = new Date().toISOString();
|
||||
|
|
@ -253,7 +301,8 @@ export class QuestionService extends Disposable implements IQuestionService {
|
|||
private _expire(questionId: string): void {
|
||||
const p = this._pending.get(questionId);
|
||||
if (!p) return;
|
||||
this._pending.delete(questionId);
|
||||
p.markSettled();
|
||||
this._pending.deleteAndLeak(questionId);
|
||||
this.markResolved(p.questionId);
|
||||
|
||||
const expiredEvent: Event = {
|
||||
|
|
@ -268,16 +317,7 @@ export class QuestionService extends Disposable implements IQuestionService {
|
|||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
for (const [, p] of this._pending) {
|
||||
clearTimeout(p.timer);
|
||||
try {
|
||||
p.reject(new Error('daemon shutting down'));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this._pending.clear();
|
||||
if (this._store.isDisposed) return;
|
||||
this._recentlyResolved.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +1,9 @@
|
|||
/**
|
||||
* `createDaemonServiceCollection` — central wiring for the daemon's DI graph.
|
||||
* Mirrors the VSCode `electron-main/main.ts:162-233`
|
||||
* pattern: hybrid `ServiceCollection`, with:
|
||||
*
|
||||
* - **Prebuilt** `services.set(I, new C(...))` for services that capture
|
||||
* runtime handles or closures the container can't synthesize
|
||||
* (`PinoLogger` wraps the Fastify-shared `pino.Logger`;
|
||||
* `FastifyRestGateway` wraps the `FastifyLike` instance;
|
||||
* `IEnvironmentService` carries CLI-resolved `homeDir` / `configPath`).
|
||||
* - **Descriptor** `services.set(I, new SyncDescriptor(C, [], false))` for
|
||||
* services whose ctor is pure `@I…` injection. The container drives
|
||||
* construction via `_createAndCacheServiceInstance` so decorators auto-inject.
|
||||
* A handful of services still take a leading options bag (e.g.
|
||||
* `CoreProcessService` with `coreProcessOptions`, `WSGateway` with
|
||||
* `wsGatewayOptions`); those use `new SyncDescriptor(C, [options], false)`.
|
||||
*
|
||||
* `supportsDelayedInstantiation = false` for every descriptor here to preserve
|
||||
* the `_constructionOrder` discipline that powers reverse-dispose order. The
|
||||
* `a.get(IX)` touch sequence in `start.ts` still pins the ordering.
|
||||
*
|
||||
* # Why a helper (not inline in start.ts)?
|
||||
*
|
||||
* Centralizing all `services.set(... new SyncDescriptor(...))` in one place
|
||||
* keeps the wiring shape auditable in a single file while `start.ts` retains
|
||||
* the construction-order touch list + post-collection adapters
|
||||
* (`IFsWatcher` closure construction,
|
||||
* `setUnexpectedErrorHandler`, WS abort + fs-watch handler wiring).
|
||||
*
|
||||
* # Why `IFsWatcher` stays in start.ts
|
||||
*
|
||||
* `FsWatcherService` ctor takes a `connection-lookup` closure built from
|
||||
* `IConnectionRegistry.get` at runtime. That closure isn't serializable
|
||||
* into a `SyncDescriptor` static-arg slot, so we keep its construction
|
||||
* inside the `ix.invokeFunction` block in `start.ts` post-collection.
|
||||
*/
|
||||
|
||||
import {
|
||||
getSingletonServiceDescriptors,
|
||||
ServiceCollection,
|
||||
SyncDescriptor,
|
||||
} from '@moonshot-ai/agent-core';
|
||||
import {
|
||||
AuthSummaryService,
|
||||
CoreProcessService,
|
||||
defaultServicesModule,
|
||||
EventService,
|
||||
IApprovalService,
|
||||
IAuthSummaryService,
|
||||
IEnvironmentService,
|
||||
IEventService,
|
||||
ICoreProcessService,
|
||||
IMcpService,
|
||||
IMessageService,
|
||||
IOAuthService,
|
||||
IPromptService,
|
||||
IQuestionService,
|
||||
ISessionService,
|
||||
ITaskService,
|
||||
IToolService,
|
||||
McpService,
|
||||
MessageService,
|
||||
OAuthService,
|
||||
PromptService,
|
||||
SessionService,
|
||||
TaskService,
|
||||
ToolService,
|
||||
} from '@moonshot-ai/services';
|
||||
import * as Services from '@moonshot-ai/services';
|
||||
import type { Logger as PinoLogger } from 'pino';
|
||||
|
||||
import type { FastifyLike } from '#/services/gateway/restGateway';
|
||||
|
|
@ -100,66 +39,43 @@ import { IWSBroadcastService } from '#/services/gateway/wsBroadcast';
|
|||
import { WSBroadcastService } from '#/services/gateway/wsBroadcastService';
|
||||
|
||||
export interface DaemonServiceCollectionOptions {
|
||||
/** Original `startDaemon` options bag — carries the per-service tunables. */
|
||||
readonly daemon: DaemonStartOptions;
|
||||
/** Resolved Fastify instance (`app`) — needed by `FastifyRestGateway`. */
|
||||
readonly app: FastifyLike;
|
||||
/** Fastify-shared pino logger — wrapped by `PinoLoggerAdapter`. */
|
||||
readonly pinoLogger: PinoLogger;
|
||||
/** Pre-resolved environment paths (homeDir / configPath). */
|
||||
readonly envService: IEnvironmentService;
|
||||
readonly envService: Services.IEnvironmentService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the daemon's `ServiceCollection`. The returned collection has
|
||||
* EVERY singleton seeded — either as a prebuilt instance (runtime-handle
|
||||
* services) or as a `SyncDescriptor` (descriptor-first singletons).
|
||||
*
|
||||
* One singleton NOT registered here, by design:
|
||||
* - `IFsWatcher` — needs a closure over `IConnectionRegistry.get` at
|
||||
* construction time; built inline in `start.ts` (see file header).
|
||||
*/
|
||||
export function createDaemonServiceCollection(
|
||||
input: DaemonServiceCollectionOptions,
|
||||
): ServiceCollection {
|
||||
const { daemon, app, pinoLogger, envService } = input;
|
||||
|
||||
const services = new ServiceCollection(
|
||||
// Registry entries from `@moonshot-ai/services` (self-registered by each
|
||||
// impl file at module-load time). These supply the default descriptors
|
||||
// for services whose ctor is pure `@I…` injection.
|
||||
...defaultServicesModule(),
|
||||
// Daemon-only services not shipped by `@moonshot-ai/services`.
|
||||
...getSingletonServiceDescriptors(),
|
||||
[IConnectionRegistry, new SyncDescriptor(ConnectionRegistry, [], false)],
|
||||
[ISessionClientsService, new SyncDescriptor(SessionClientsService, [], false)],
|
||||
[IWSBroadcastService, new SyncDescriptor(WSBroadcastService, [], false)],
|
||||
[IApprovalService, new SyncDescriptor(ApprovalService, [], false)],
|
||||
[IQuestionService, new SyncDescriptor(QuestionService, [], false)],
|
||||
[Services.IApprovalService, new SyncDescriptor(ApprovalService, [], false)],
|
||||
[Services.IQuestionService, new SyncDescriptor(QuestionService, [], false)],
|
||||
[IFsService, new SyncDescriptor(FsService, [], false)],
|
||||
[IFsSearchService, new SyncDescriptor(FsSearchService, [], false)],
|
||||
[IFsGitService, new SyncDescriptor(FsGitService, [], false)],
|
||||
[IWorkspaceFsService, new SyncDescriptor(WorkspaceFsService, [], false)],
|
||||
);
|
||||
|
||||
// -- Prebuilt: services that need runtime handles / external closures ------
|
||||
services.set(ILogService, new PinoLoggerAdapter(pinoLogger));
|
||||
services.set(IRestGateway, new FastifyRestGateway(app));
|
||||
services.set(IEnvironmentService, envService);
|
||||
services.set(Services.IEnvironmentService, envService);
|
||||
|
||||
// -- Override registry entries with runtime static args --------------------
|
||||
services.set(
|
||||
IWSGateway,
|
||||
new SyncDescriptor(WSGateway, [daemon.wsGatewayOptions ?? {}], false),
|
||||
);
|
||||
services.set(
|
||||
ICoreProcessService,
|
||||
new SyncDescriptor(CoreProcessService, [daemon.coreProcessOptions ?? {}], false),
|
||||
Services.ICoreProcessService,
|
||||
new SyncDescriptor(Services.CoreProcessService, [daemon.coreProcessOptions ?? {}], false),
|
||||
);
|
||||
|
||||
// `IFileStore` + `IWorkspaceRegistry` derive their on-disk base from
|
||||
// `IEnvironmentService.homeDir` (which itself reads
|
||||
// `opts.coreProcessOptions?.homeDir`). No static args here — the impls
|
||||
// inject `IEnvironmentService` directly.
|
||||
services.set(IFileStore, new SyncDescriptor(FileStore, [], false));
|
||||
services.set(IWorkspaceRegistry, new SyncDescriptor(WorkspaceRegistryService, [], false));
|
||||
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export class WorkspaceFsService extends Disposable implements IWorkspaceFsServic
|
|||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._store.isDisposed) return;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
|
|||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._store.isDisposed) return;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,13 +96,13 @@ no new suffixes get reintroduced.
|
|||
|
||||
Adding a new service: create the folder + contracts + impl pair, add a
|
||||
bottom-of-file `registerSingleton(IXxxService, XxxService,
|
||||
InstantiationType.Delayed)` in the impl, add the corresponding side-effect
|
||||
import to `module.ts`, re-export from `index.ts`. The daemon's `start.ts`
|
||||
consumes `defaultServicesModule()` for descriptor-only services; only override
|
||||
the registry entry (via `services.set(I, prebuiltInstance)` or
|
||||
`services.set(I, new SyncDescriptor(C, [runtimeArgs], false))`) when the
|
||||
service needs an external handle or runtime static args that the registry
|
||||
can't supply.
|
||||
InstantiationType.Delayed)` in the impl, then re-export the contracts and impl
|
||||
from `index.ts` so importing `@moonshot-ai/services` runs the registration
|
||||
side effect. Daemon bootstrap consumes `getSingletonServiceDescriptors()` for
|
||||
descriptor-only services; only override the registry entry (via
|
||||
`services.set(I, prebuiltInstance)` or `services.set(I, new SyncDescriptor(C,
|
||||
[runtimeArgs], false))`) when the service needs an external handle or runtime
|
||||
static args that the registry can't supply.
|
||||
|
||||
## Service registration (normative)
|
||||
|
||||
|
|
@ -127,11 +127,9 @@ can't supply.
|
|||
`CoreProcessService`'s `options`), fall back to the descriptor overload:
|
||||
`registerSingleton(IXxxService, new SyncDescriptor(XxxService, [optionsBag]))`.
|
||||
|
||||
2. **`defaultServicesModule()` is a thin projection** of
|
||||
`getSingletonServiceDescriptors()`. It does NOT maintain a separate
|
||||
list — `module.ts`'s only responsibility is the side-effect import
|
||||
list that populates the registry plus the
|
||||
`InstantiationType.Delayed | Eager` projection.
|
||||
2. **Consumers seed from `getSingletonServiceDescriptors()` directly**.
|
||||
Importing `@moonshot-ai/services` loads the package barrel, whose impl
|
||||
re-exports run the `registerSingleton(...)` side effects.
|
||||
|
||||
3. **Daemon-side `services.set(...)` may override** the registry-derived
|
||||
entry for services that need runtime static args (e.g.
|
||||
|
|
@ -142,9 +140,9 @@ can't supply.
|
|||
from `registerSingleton` (plan §158); the later registration wins at
|
||||
every layer.
|
||||
|
||||
The legacy "hand-built array in `module.ts`" pattern that lived here
|
||||
through Phase 2 is gone. Do NOT reintroduce it — extending the array in
|
||||
`module.ts` no longer has any effect on what the daemon resolves.
|
||||
The legacy "hand-built array" and `defaultServicesModule()` wrapper patterns
|
||||
are gone. Do NOT reintroduce them — the registry is the source of truth, and
|
||||
bootstrap code should read it with `getSingletonServiceDescriptors()`.
|
||||
|
||||
## Comments (normative)
|
||||
|
||||
|
|
@ -178,4 +176,3 @@ Existing files in this package over-comment by historical accident.
|
|||
file, prefer leaving the surrounding comments alone — large comment
|
||||
deletions belong in their own dedicated cleanup pass, not bundled into
|
||||
behavior changes.
|
||||
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export class AuthSummaryService
|
|||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._store.isDisposed) return;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic
|
|||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._store.isDisposed) return;
|
||||
// KimiCore does not currently expose a dispose() — when it does, we'll
|
||||
// await/call it here BEFORE super.dispose(). For now, disposing the
|
||||
// service flips _disposed, which makes future rpc.* invocations reject
|
||||
|
|
@ -152,7 +152,7 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic
|
|||
|
||||
private _buildRpcProxy(): CoreRPC {
|
||||
const rpcPromise = this._coreRpcPromise;
|
||||
const isDisposedRef = () => this._isDisposed;
|
||||
const isDisposedRef = () => this._store.isDisposed;
|
||||
|
||||
// We don't know the concrete method set at compile time here (CoreAPI is
|
||||
// a structural interface; `RPCMethods<CoreAPI>` is a mapped type).
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export class EventService extends Disposable implements IEventService {
|
|||
readonly onDidPublish = this._onDidPublish.event;
|
||||
|
||||
publish(event: ProtocolEvent): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._store.isDisposed) return;
|
||||
this._onDidPublish.fire(event);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,3 @@
|
|||
/**
|
||||
* `@moonshot-ai/services` — in-process service container for the kimi-code
|
||||
* daemon. Houses every `IXxxService` decorator (per-domain folder), the
|
||||
* `CoreProcessService` that owns the in-process `KimiCore` instance, and the
|
||||
* adapters that translate `KimiCore` shapes into protocol-shaped data.
|
||||
*
|
||||
* Naming convention is encoded in `packages/services/AGENTS.md` — every
|
||||
* injectable uses the `Service` suffix, contracts live in `<domain>.ts`,
|
||||
* impl lives in `<domain>Service.ts`, folder names are camelCase.
|
||||
*
|
||||
* Per-domain layout:
|
||||
* coreProcess/coreProcess.ts — ICoreProcessService + CoreProcessServiceOptions
|
||||
* coreProcess/coreProcessService.ts — CoreProcessService (self-registers via registerSingleton)
|
||||
* coreProcess/coreProcessClient.ts — BridgeClientAPI (SDK-side of the RPC pair)
|
||||
* event/event.ts — IEventService
|
||||
* event/eventService.ts — EventService (pure in-process Emitter wrapper)
|
||||
* approval/approval.ts — IApprovalService + protocol adapter
|
||||
* question/question.ts — IQuestionService + protocol adapter
|
||||
* environment/environment.ts — IEnvironmentService
|
||||
* session/session.ts — ISessionService + toProtocolSession
|
||||
* session/sessionService.ts — SessionService
|
||||
* message/message.ts — IMessageService + toProtocolMessage
|
||||
* message/messageService.ts — MessageService
|
||||
* prompt/prompt.ts — IPromptService + SyntheticPrompt* events
|
||||
* prompt/promptService.ts — PromptService
|
||||
* tool/tool.ts — IToolService + toProtocolTool
|
||||
* tool/toolService.ts — ToolService
|
||||
* mcp/mcp.ts — IMcpService + toProtocolMcpServer
|
||||
* mcp/mcpService.ts — McpService
|
||||
* task/task.ts — ITaskService + toProtocolTask
|
||||
* task/taskService.ts — TaskService
|
||||
* oauth/oauth.ts — IOAuthService
|
||||
* oauth/oauthService.ts — OAuthService
|
||||
* authSummary/authSummary.ts — IAuthSummaryService + sentinel errors
|
||||
* authSummary/authSummaryService.ts — AuthSummaryService
|
||||
* modelCatalog/modelCatalog.ts — IModelCatalogService + config adapters
|
||||
* modelCatalog/modelCatalogService.ts — ModelCatalogService
|
||||
*/
|
||||
|
||||
export { BridgeClientAPI } from './coreProcess/coreProcessClient';
|
||||
export type { CoreProcessClientDeps } from './coreProcess/coreProcessClient';
|
||||
export {
|
||||
|
|
@ -44,18 +5,10 @@ export {
|
|||
type CoreProcessServiceOptions,
|
||||
} from './coreProcess/coreProcess';
|
||||
export { CoreProcessService } from './coreProcess/coreProcessService';
|
||||
export {
|
||||
defaultServicesModule,
|
||||
type ServiceModuleEntry,
|
||||
} from './module';
|
||||
|
||||
// --- per-domain exports ---------------------------------------------------
|
||||
|
||||
// event service
|
||||
export { IEventService } from './event/event';
|
||||
export { EventService } from './event/eventService';
|
||||
|
||||
// approval service + adapter
|
||||
export { IApprovalService } from './approval/approval';
|
||||
export type { ApprovalRequest, ApprovalResponse } from './approval/approval';
|
||||
export {
|
||||
|
|
@ -64,7 +17,6 @@ export {
|
|||
type ToBrokerRequestParams as ApprovalToBrokerRequestParams,
|
||||
} from './approval/approval';
|
||||
|
||||
// question service + adapter
|
||||
export { IQuestionService } from './question/question';
|
||||
export type { QuestionRequest, QuestionResult } from './question/question';
|
||||
export {
|
||||
|
|
@ -74,10 +26,8 @@ export {
|
|||
type QuestionToBrokerRequestParams,
|
||||
} from './question/question';
|
||||
|
||||
// environment service
|
||||
export { IEnvironmentService } from './environment/environment';
|
||||
|
||||
// authSummary service
|
||||
export {
|
||||
IAuthSummaryService,
|
||||
AuthProvisioningRequiredError,
|
||||
|
|
@ -87,11 +37,9 @@ export {
|
|||
} from './authSummary/authSummary';
|
||||
export { AuthSummaryService } from './authSummary/authSummaryService';
|
||||
|
||||
// oauth service
|
||||
export { IOAuthService } from './oauth/oauth';
|
||||
export { OAuthService } from './oauth/oauthService';
|
||||
|
||||
// model catalog service + adapter
|
||||
export {
|
||||
IModelCatalogService,
|
||||
ModelNotFoundError,
|
||||
|
|
@ -103,7 +51,6 @@ export {
|
|||
export type { ProviderCredentialState } from './modelCatalog/modelCatalog';
|
||||
export { ModelCatalogService } from './modelCatalog/modelCatalogService';
|
||||
|
||||
// session service + adapter
|
||||
export {
|
||||
ISessionService,
|
||||
SessionNotFoundError,
|
||||
|
|
@ -113,7 +60,6 @@ export {
|
|||
export type { SessionListQuery } from './session/session';
|
||||
export { SessionService } from './session/sessionService';
|
||||
|
||||
// message service + adapter
|
||||
export {
|
||||
IMessageService,
|
||||
MessageNotFoundError,
|
||||
|
|
@ -124,7 +70,6 @@ export {
|
|||
export type { MessageListQuery } from './message/message';
|
||||
export { MessageService } from './message/messageService';
|
||||
|
||||
// prompt service
|
||||
export {
|
||||
IPromptService,
|
||||
PromptAlreadyCompletedError,
|
||||
|
|
@ -141,7 +86,6 @@ export type {
|
|||
} from './prompt/prompt';
|
||||
export { PromptService } from './prompt/promptService';
|
||||
|
||||
// tool service + adapter
|
||||
export {
|
||||
IToolService,
|
||||
toProtocolTool,
|
||||
|
|
@ -149,7 +93,6 @@ export {
|
|||
} from './tool/tool';
|
||||
export { ToolService } from './tool/toolService';
|
||||
|
||||
// mcp service + adapter
|
||||
export {
|
||||
IMcpService,
|
||||
McpServerNotFoundError,
|
||||
|
|
@ -157,7 +100,6 @@ export {
|
|||
} from './mcp/mcp';
|
||||
export { McpService } from './mcp/mcpService';
|
||||
|
||||
// task service + adapter
|
||||
export {
|
||||
ITaskService,
|
||||
TaskAlreadyFinishedError,
|
||||
|
|
@ -167,10 +109,3 @@ export {
|
|||
} from './task/task';
|
||||
export type { TaskListQuery } from './task/task';
|
||||
export { TaskService } from './task/taskService';
|
||||
|
||||
// NOTE: every `<X>Service.ts` impl file self-registers at the bottom via
|
||||
// `registerSingleton(IXxx, XxxService, InstantiationType.Delayed)` (or the
|
||||
// descriptor overload when a leading options bag is required, e.g.
|
||||
// `CoreProcessService`). `defaultServicesModule()` is a thin projection of
|
||||
// that global registry. Consumers override entries with `services.set(...)`
|
||||
// for runtime static args or prebuilt instances.
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
/**
|
||||
* `defaultServicesModule()` — DI entries shipped by `@moonshot-ai/services`.
|
||||
*
|
||||
* Thin projection of the global singleton registry maintained by
|
||||
* `@moonshot-ai/agent-core`. Each service impl file self-registers at
|
||||
* module-load time via `registerSingleton` (ctor overload for pure `@I…`
|
||||
* injection, descriptor overload when a leading options bag is required).
|
||||
* Importing this `module.ts` triggers the side-effect imports below, which
|
||||
* populate the registry; `defaultServicesModule()` snapshots the registry
|
||||
* via `getSingletonServiceDescriptors()`.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* const services = new ServiceCollection(...defaultServicesModule());
|
||||
*
|
||||
* Each entry is `[ServiceIdentifier, SyncDescriptor]`. The descriptor carries
|
||||
* the `supportsDelayedInstantiation` flag; no extra projection is needed.
|
||||
*
|
||||
* Consumers (e.g. the daemon) override entries with `services.set(...)` for
|
||||
* runtime static args (`CoreProcessService` with real `coreProcessOptions`)
|
||||
* or prebuilt instances (`PinoLogger`). Later registrations win.
|
||||
*
|
||||
* Per-domain layout: see `packages/services/AGENTS.md`. Classes live in
|
||||
* per-domain folders (`session/`, `message/`, …) with one `<domain>.ts`
|
||||
* contracts file and one `<domain>Service.ts` impl file each.
|
||||
*/
|
||||
|
||||
import {
|
||||
getSingletonServiceDescriptors,
|
||||
SyncDescriptor,
|
||||
type ServiceIdentifier,
|
||||
} from '@moonshot-ai/agent-core';
|
||||
|
||||
// Side-effect imports — each impl file calls `registerSingleton(...)` at
|
||||
// file bottom. Ordering matters: it determines the order entries surface from
|
||||
// `getSingletonServiceDescriptors()`, which the daemon's reverse-dispose
|
||||
// semantics piggy-back on. CoreProcessService MUST register first — the
|
||||
// existing `defaultServicesModule()` test
|
||||
// (`packages/services/test/coreProcessService.test.ts:315`) asserts it
|
||||
// sits at index 0, and downstream `a.get(...)` "touch" ordering in
|
||||
// `packages/daemon/src/start.ts` assumes the bridge is the first
|
||||
// service-package entry into the construction-order list.
|
||||
import './coreProcess/coreProcessService';
|
||||
import './event/eventService';
|
||||
import './session/sessionService';
|
||||
import './message/messageService';
|
||||
import './prompt/promptService';
|
||||
import './tool/toolService';
|
||||
import './mcp/mcpService';
|
||||
import './task/taskService';
|
||||
import './authSummary/authSummaryService';
|
||||
import './oauth/oauthService';
|
||||
import './modelCatalog/modelCatalogService';
|
||||
|
||||
export type ServiceModuleEntry = readonly [
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ServiceIdentifier<any>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
SyncDescriptor<any>,
|
||||
];
|
||||
|
||||
export function defaultServicesModule(): ReadonlyArray<ServiceModuleEntry> {
|
||||
return getSingletonServiceDescriptors().map(
|
||||
([id, descriptor]) => [id, descriptor] as const,
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* `OAuthService` — implementation of `IOAuthService`.
|
||||
*/
|
||||
|
||||
import { Disposable, InstantiationType, registerSingleton } from '@moonshot-ai/agent-core';
|
||||
import { Disposable, DisposableMap, InstantiationType, registerSingleton, type IDisposable } from '@moonshot-ai/agent-core';
|
||||
import {
|
||||
DeviceCodeTimeoutError,
|
||||
KIMI_CODE_PROVIDER_NAME,
|
||||
|
|
@ -22,19 +22,52 @@ import { ulid } from 'ulid';
|
|||
import { IEnvironmentService } from '../environment/environment';
|
||||
import { IOAuthService } from './oauth';
|
||||
|
||||
interface FlowState {
|
||||
readonly flowId: string;
|
||||
readonly provider: string;
|
||||
readonly deviceAuth: DeviceAuthorization;
|
||||
/** Resolved seconds-until-expiry (may differ from `deviceAuth.expiresIn` if that was null). */
|
||||
readonly expiresInSec: number;
|
||||
readonly startedAt: number;
|
||||
readonly expiresAt: number;
|
||||
status: OAuthFlowStatus;
|
||||
readonly controller: AbortController;
|
||||
/**
|
||||
* One in-flight (or recently-completed) device-code flow. Stored in
|
||||
* `OAuthService._flows` (a `DisposableMap`) so:
|
||||
* - `_flows.set(provider, newState)` auto-disposes the supersedee
|
||||
* - `_flows.deleteAndDispose(provider)` (called from the GC timer) tears
|
||||
* down a terminal entry's leftover state
|
||||
* - service-wide `super.dispose()` walks every entry's `dispose()`
|
||||
* instead of the old hand-written for-loop in `override dispose()`.
|
||||
*
|
||||
* `dispose()` is idempotent and aborts the controller only when the flow is
|
||||
* still pending — terminal flows have already returned from their underlying
|
||||
* promise, so a second abort would be a noisy no-op.
|
||||
*/
|
||||
class FlowState implements IDisposable {
|
||||
status: OAuthFlowStatus = 'pending';
|
||||
resolvedAt?: number;
|
||||
errorMessage?: string;
|
||||
gcTimer?: NodeJS.Timeout;
|
||||
private _disposed = false;
|
||||
|
||||
constructor(
|
||||
readonly flowId: string,
|
||||
readonly provider: string,
|
||||
readonly deviceAuth: DeviceAuthorization,
|
||||
/** Resolved seconds-until-expiry (may differ from `deviceAuth.expiresIn` if that was null). */
|
||||
readonly expiresInSec: number,
|
||||
readonly startedAt: number,
|
||||
readonly expiresAt: number,
|
||||
readonly controller: AbortController,
|
||||
) {}
|
||||
|
||||
dispose(): void {
|
||||
if (this._disposed) return;
|
||||
this._disposed = true;
|
||||
if (this.gcTimer !== undefined) {
|
||||
clearTimeout(this.gcTimer);
|
||||
this.gcTimer = undefined;
|
||||
}
|
||||
if (this.status === 'pending') {
|
||||
try {
|
||||
this.controller.abort();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Terminal flows live this long after resolution before GC. */
|
||||
|
|
@ -44,10 +77,11 @@ export class OAuthService extends Disposable implements IOAuthService {
|
|||
readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly _authFacade: KimiAuthFacade;
|
||||
private readonly _flows = new Map<string, FlowState>();
|
||||
private readonly _flows: DisposableMap<string, FlowState>;
|
||||
|
||||
constructor(@IEnvironmentService private readonly env: IEnvironmentService) {
|
||||
super();
|
||||
this._flows = this._register(new DisposableMap<string, FlowState>());
|
||||
this._authFacade = new KimiAuthFacade({
|
||||
homeDir: env.homeDir,
|
||||
configPath: env.configPath,
|
||||
|
|
@ -117,16 +151,15 @@ export class OAuthService extends Disposable implements IOAuthService {
|
|||
// `OAuthManager.login`, so the `expires_at` we surface to clients is
|
||||
// never further out than the deadline that's actually being enforced.
|
||||
const expiresInSec = deviceAuth.expiresIn ?? 15 * 60;
|
||||
const state: FlowState = {
|
||||
const state = new FlowState(
|
||||
flowId,
|
||||
provider: name,
|
||||
name,
|
||||
deviceAuth,
|
||||
expiresInSec,
|
||||
startedAt,
|
||||
expiresAt: startedAt + expiresInSec * 1000,
|
||||
status: 'pending',
|
||||
startedAt + expiresInSec * 1000,
|
||||
controller,
|
||||
};
|
||||
);
|
||||
this._flows.set(name, state);
|
||||
|
||||
// Wire the background promise's terminal transition. We branch on error
|
||||
|
|
@ -185,18 +218,7 @@ export class OAuthService extends Disposable implements IOAuthService {
|
|||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
for (const state of this._flows.values()) {
|
||||
if (state.gcTimer !== undefined) clearTimeout(state.gcTimer);
|
||||
if (state.status === 'pending') {
|
||||
try {
|
||||
state.controller.abort();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
this._flows.clear();
|
||||
if (this._store.isDisposed) return;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -220,14 +242,18 @@ export class OAuthService extends Disposable implements IOAuthService {
|
|||
if (state.status === status) return;
|
||||
state.status = status;
|
||||
state.resolvedAt = Date.now();
|
||||
// Schedule GC. If a new flow supersedes this entry first, the new flow
|
||||
// replaces the map entry and this timer just no-ops on the stale state.
|
||||
// Schedule GC. If a new flow supersedes this entry first, the supersede
|
||||
// path runs `_flows.set(name, newState)` which auto-disposes this entry —
|
||||
// its `dispose()` clears `gcTimer` before the timer can fire, so the
|
||||
// callback can rely on "this state is still the current map entry"
|
||||
// without the equality check needing a stale-guard.
|
||||
if (state.gcTimer !== undefined) clearTimeout(state.gcTimer);
|
||||
state.gcTimer = setTimeout(() => {
|
||||
const current = this._flows.get(state.provider);
|
||||
// Only GC if this state IS the current map entry. A newer flow may
|
||||
// have already overwritten the slot.
|
||||
if (current === state) this._flows.delete(state.provider);
|
||||
// Belt-and-suspenders: even though dispose() clears the timer on
|
||||
// overwrite, keep the identity check in case the timer was queued
|
||||
// before the clearTimeout took effect.
|
||||
if (current === state) this._flows.deleteAndDispose(state.provider);
|
||||
}, TERMINAL_RETENTION_MS);
|
||||
// Don't keep the process alive solely for GC.
|
||||
state.gcTimer.unref?.();
|
||||
|
|
|
|||
|
|
@ -819,7 +819,7 @@ export class PromptService
|
|||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._store.isDisposed) return;
|
||||
this._active.clear();
|
||||
this._queued.clear();
|
||||
this._agentState.clear();
|
||||
|
|
|
|||
|
|
@ -1,39 +1,3 @@
|
|||
/**
|
||||
* `ISessionService` — daemon-facing session CRUD interface.
|
||||
*
|
||||
* Wraps `ICoreProcessService.rpc.{createSession, listSessions, closeSession,
|
||||
* updateSessionMetadata}` and adapts agent-core's camelCase + number
|
||||
* timestamps to the protocol's snake_case + ISO 8601 `Z` shape (see SCHEMAS.md
|
||||
* §2). Other services in `@moonshot-ai/services` (messages, prompts, ...)
|
||||
* inherit this camelCase ↔ snake_case + number ↔ ISO pattern.
|
||||
*
|
||||
* **Why a service layer**: REST handlers in `@moonshot-ai/daemon` are
|
||||
* disallowed from importing `@moonshot-ai/kimi-code-sdk` (anti-corruption
|
||||
* test). Routes call `accessor.get(ISessionService).<method>(...)`; the
|
||||
* adapter is here.
|
||||
*
|
||||
* **CoreAPI shape gap**: agent-core does NOT expose `getSession(id)` returning
|
||||
* a full `SessionSummary` — `getSessionMetadata` returns the smaller
|
||||
* `SessionMeta` shape. `get(id)` is implemented via `listSessions({})` +
|
||||
* filter, throwing `SessionNotFoundError` (→ 40401) when the id is absent.
|
||||
* See `SessionService` for details + the gap documentation.
|
||||
*
|
||||
* **Adapter helpers**: `toProtocolSession` is co-located here.
|
||||
*
|
||||
* **DI wiring**: this class takes `ICoreProcessService` via ctor positional
|
||||
* arg. `defaultServicesModule()` adds a `SyncDescriptor(SessionService)`
|
||||
* entry, but the container has no ctor-arg DI, so the daemon's `start.ts`
|
||||
* wires it via
|
||||
* `ix.createInstance(SessionService, a.get(ICoreProcessService))` then
|
||||
* `services.set(ISessionService, instance)` — same pattern as
|
||||
* `CoreProcessService` itself. The descriptor entry is the canonical
|
||||
* declaration; the daemon's manual wiring is the runtime path.
|
||||
*
|
||||
* **Anti-corruption**: this file imports from `@moonshot-ai/agent-core` only
|
||||
* for type-only `SessionSummary` / `SessionMeta`. Runtime calls go through
|
||||
* `ICoreProcessService.rpc.<method>`, not direct CoreAPI consumption.
|
||||
*/
|
||||
|
||||
import { createDecorator } from '@moonshot-ai/agent-core';
|
||||
import { encodeWorkDirKey } from '@moonshot-ai/agent-core/session/store';
|
||||
import type { Event } from '@moonshot-ai/agent-core/base/common/event';
|
||||
|
|
@ -54,116 +18,41 @@ import {
|
|||
type UndoSessionResponse,
|
||||
} from '@moonshot-ai/protocol';
|
||||
|
||||
/**
|
||||
* Listing query — `before_id`/`after_id` + `page_size` mutual exclusivity is
|
||||
* already enforced by `cursorQuerySchema`. The service layer adds an optional
|
||||
* status filter the daemon layer parses out of the REST query string, and an
|
||||
* optional `workDir` filter for the `?workspace_id=` fast path (the daemon
|
||||
* route layer resolves `workspace_id → workspace.root` and sets `workDir`).
|
||||
*/
|
||||
export interface SessionListQuery extends CursorQuery {
|
||||
status?: import('@moonshot-ai/protocol').SessionStatus;
|
||||
/**
|
||||
* When set, the underlying `core.rpc.listSessions({workDir})` path uses
|
||||
* agent-core's `listWorkDir` (readdir-based) instead of a full `listAll`.
|
||||
* Daemon-route caller is responsible for resolving the workspace_id to its
|
||||
* registered root before populating this.
|
||||
*/
|
||||
workDir?: string;
|
||||
}
|
||||
|
||||
export interface ISessionService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/**
|
||||
* `POST /v1/sessions` — create a new session. Requires `metadata.cwd`
|
||||
* (agent-core's `createSession` calls `requiredWorkDir`; missing cwd ⇒ throw).
|
||||
*/
|
||||
create(input: SessionCreate): Promise<Session>;
|
||||
|
||||
/**
|
||||
* `GET /v1/sessions` — list sessions. Cursor pagination is applied
|
||||
* client-side over `core.rpc.listSessions({})` (the CoreAPI surface
|
||||
* doesn't take a cursor today). Default
|
||||
* `page_size = 20` per REST.md §1.6 is applied at the route layer, not here.
|
||||
*/
|
||||
list(query: SessionListQuery): Promise<PageResponse<Session>>;
|
||||
|
||||
/**
|
||||
* `GET /v1/sessions/{id}` and `GET /v1/sessions/{id}/profile` — single
|
||||
* session by id. Implemented as `listSessions({}) + .find(id)`; throws
|
||||
* `SessionNotFoundError` (→ 40401) when not found.
|
||||
*/
|
||||
get(id: string): Promise<Session>;
|
||||
|
||||
/**
|
||||
* `POST /v1/sessions/{id}/profile` — update session mutable properties.
|
||||
* Backed by `updateSessionMetadata` for metadata changes; `title` writes
|
||||
* through the same path (mapped onto agent-core's `SessionMeta.title`).
|
||||
* `agent_config.model` is dispatched to `core.rpc.setModel` when present.
|
||||
* Returns the post-update Session.
|
||||
*/
|
||||
update(id: string, input: SessionUpdate): Promise<Session>;
|
||||
|
||||
/**
|
||||
* `POST /v1/sessions/{id}:fork` — create a new persisted session from an
|
||||
* idle source session and return the fork.
|
||||
*/
|
||||
fork(id: string, input: SessionFork): Promise<Session>;
|
||||
|
||||
/**
|
||||
* `GET /v1/sessions/{id}/children` — list direct child sessions whose
|
||||
* metadata points at the parent session.
|
||||
*/
|
||||
listChildren(id: string, query: SessionListQuery): Promise<PageResponse<Session>>;
|
||||
|
||||
/**
|
||||
* `POST /v1/sessions/{id}/children` — create a persisted child session from
|
||||
* the parent session and return the child.
|
||||
*/
|
||||
createChild(id: string, input: SessionChildCreate): Promise<Session>;
|
||||
|
||||
/**
|
||||
* `DELETE /v1/sessions/{id}` — close (= soft-delete in v1) the session.
|
||||
* Backed by `bridge.rpc.closeSession({sessionId})`. CoreAPI does not
|
||||
* surface a hard delete; the daemon currently conflates close == delete.
|
||||
*
|
||||
* Returns `{ deleted: true }` envelope shape per REST §3.3.
|
||||
*/
|
||||
getStatus(id: string): Promise<SessionStatusResponse>;
|
||||
|
||||
compact(id: string, input: CompactSessionRequest): Promise<CompactSessionResponse>;
|
||||
|
||||
undo(id: string, input: UndoSessionRequest): Promise<UndoSessionResponse>;
|
||||
|
||||
/**
|
||||
* `DELETE /v1/sessions/{id}` — close (= soft-delete in v1) the session.
|
||||
* Backed by `bridge.rpc.closeSession({sessionId})`. CoreAPI does not
|
||||
* surface a hard delete; the daemon currently conflates close == delete.
|
||||
*
|
||||
* Returns `{ deleted: true }` envelope shape per REST §3.3.
|
||||
*/
|
||||
delete(id: string): Promise<{ deleted: true }>;
|
||||
|
||||
/**
|
||||
* VSCode-style accessor for session-creation events. The listener fires
|
||||
* synchronously after the bridge RPC returns a new `Session`.
|
||||
*
|
||||
* Subscribing returns an `IDisposable`. Owners stash it via
|
||||
* `Disposable._register(svc.onDidCreate(handler))` so it tears down
|
||||
* with the owning service.
|
||||
*/
|
||||
readonly onDidCreate: Event<{ session: Session }>;
|
||||
|
||||
/**
|
||||
* VSCode-style accessor for session-close events. The listener fires
|
||||
* synchronously after `bridge.rpc.closeSession` resolves. Same
|
||||
* `IDisposable` contract as `onDidCreate`.
|
||||
*/
|
||||
readonly onDidClose: Event<{ sessionId: string }>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const ISessionService = createDecorator<ISessionService>('sessionService');
|
||||
|
||||
export class SessionUndoUnavailableError extends Error {
|
||||
|
|
@ -175,11 +64,6 @@ export class SessionUndoUnavailableError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel error class — daemon's route layer catches this and maps to
|
||||
* `code: 40401` (session.not_found). Other errors fall through to
|
||||
* `installErrorHandler` (→ 50001 internal).
|
||||
*/
|
||||
export class SessionNotFoundError extends Error {
|
||||
readonly sessionId: string;
|
||||
constructor(sessionId: string) {
|
||||
|
|
@ -189,27 +73,6 @@ export class SessionNotFoundError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert agent-core's `SessionSummary` + optional `SessionMeta` into the
|
||||
* protocol-level `Session` shape. The optional `meta` argument is the result
|
||||
* of `getSessionMetadata` — when present, its `title` / `custom` enrich the
|
||||
* baseline summary; when absent, defaults are used.
|
||||
*
|
||||
* `cwd` overrides apply in this priority order:
|
||||
* 1. `meta.custom.cwd` (set by daemon when update wrote a new cwd).
|
||||
* 2. `summary.metadata.cwd` (when caller-supplied during create).
|
||||
* 3. `summary.workDir` (agent-core canonical field).
|
||||
*
|
||||
* `workspace_id` is ALWAYS derived from `summary.workDir` via
|
||||
* `encodeWorkDirKey`, so every session round-trips to a stable workspace
|
||||
* key. If the daemon has never seen a `POST /workspaces` for that wd-key
|
||||
* the id simply won't appear in the workspaces list; the session still has
|
||||
* an id the front-end can group on.
|
||||
*
|
||||
* The merged `Session.metadata` keeps `cwd` plus persistent custom metadata
|
||||
* from the summary and live `meta.custom` when available (excluding
|
||||
* daemon-internal `goal` plumbing — that's not protocol surface).
|
||||
*/
|
||||
export function toProtocolSession(
|
||||
summary: SessionSummary,
|
||||
meta?: SessionMeta | undefined,
|
||||
|
|
@ -221,8 +84,6 @@ export function toProtocolSession(
|
|||
(typeof summaryMetadata['cwd'] === 'string' && summaryMetadata['cwd']) ||
|
||||
summary.workDir;
|
||||
|
||||
// Strip the internal "goal" key — that's daemon-side runtime state, not
|
||||
// protocol surface (SCHEMAS §2 doesn't expose it).
|
||||
const { goal: _dropSummaryGoal, ...summaryWithoutGoal } = summaryMetadata;
|
||||
const { goal: _dropCustomGoal, ...customWithoutGoal } = customMetadata;
|
||||
|
||||
|
|
@ -244,10 +105,6 @@ export function toProtocolSession(
|
|||
status: 'idle',
|
||||
metadata: mergedMetadata,
|
||||
agent_config: {
|
||||
// CoreAPI doesn't surface a session's effective model on the listSessions
|
||||
// path; we leave it empty because there is no current source for the
|
||||
// effective model on this path. Empty string keeps the schema valid for
|
||||
// consumers that only inspect known keys.
|
||||
model: '',
|
||||
},
|
||||
usage: emptySessionUsage(),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
/**
|
||||
* `SessionService` — implementation of `ISessionService`.
|
||||
*/
|
||||
|
||||
import {
|
||||
Disposable,
|
||||
Emitter,
|
||||
|
|
@ -50,13 +46,6 @@ const DEFAULT_UNDO_MESSAGE_PAGE_SIZE = 50;
|
|||
const MAX_UNDO_MESSAGE_PAGE_SIZE = 100;
|
||||
const CHILD_SESSION_KIND = 'child';
|
||||
|
||||
/**
|
||||
* Treat the incoming `metadata` object — schema-validated by zod as
|
||||
* `{cwd: string}` plus arbitrary `unknown` keys — as a JSON-safe object for
|
||||
* agent-core's `JsonObject` slot. We don't deep-validate here; clients can
|
||||
* send non-JSON-serializable values and agent-core will reject at the RPC
|
||||
* boundary. This cast keeps the adapter narrow and the wire stable.
|
||||
*/
|
||||
function asJsonObject(value: Record<string, unknown>): JsonObject {
|
||||
return value as unknown as JsonObject;
|
||||
}
|
||||
|
|
@ -111,17 +100,8 @@ function pageContextMessages(
|
|||
export class SessionService extends Disposable implements ISessionService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/**
|
||||
* VSCode-style Emitter for session-creation events. Listener exceptions
|
||||
* route to `onUnexpectedError` inside `Emitter.fire()`. Owned via
|
||||
* `_register(...)` so it disposes when the service is torn down.
|
||||
*/
|
||||
private readonly _onDidCreate = this._register(new Emitter<{ session: Session }>());
|
||||
readonly onDidCreate = this._onDidCreate.event;
|
||||
/**
|
||||
* VSCode-style Emitter for session-close events. Same ownership +
|
||||
* exception-routing semantics as `_onDidCreate`.
|
||||
*/
|
||||
private readonly _onDidClose = this._register(new Emitter<{ sessionId: string }>());
|
||||
readonly onDidClose = this._onDidClose.event;
|
||||
|
||||
|
|
@ -134,13 +114,6 @@ export class SessionService extends Disposable implements ISessionService {
|
|||
}
|
||||
|
||||
async create(input: SessionCreate): Promise<Session> {
|
||||
// The protocol schema now allows `metadata` to be omitted (so callers can
|
||||
// send `{ workspace_id }` only). The daemon route layer is responsible
|
||||
// for resolving workspace_id → workspace.root → metadata.cwd BEFORE
|
||||
// calling this method; by the time we land here `metadata.cwd` must be
|
||||
// set. agent-core's `createSession` calls `requiredWorkDir(...)` and
|
||||
// throws if cwd is missing, so a missing-cwd bug surfaces as a
|
||||
// descriptive error rather than silently picking the daemon's cwd.
|
||||
if (input.metadata === undefined || typeof input.metadata.cwd !== 'string') {
|
||||
throw new Error('SessionService.create: metadata.cwd is required');
|
||||
}
|
||||
|
|
@ -150,39 +123,25 @@ export class SessionService extends Disposable implements ISessionService {
|
|||
metadata: metadataForCore,
|
||||
...(input.agent_config?.model !== undefined ? { model: input.agent_config.model } : {}),
|
||||
});
|
||||
// agent-core's createSession ignores any caller-supplied title — newly
|
||||
// created sessions get the default `SessionMeta.title = 'New Session'`.
|
||||
// When the caller supplied a title we apply it via `renameSession` so the
|
||||
// post-create get reflects it.
|
||||
if (input.title !== undefined) {
|
||||
try {
|
||||
await this.core.rpc.renameSession({ sessionId: summary.id, title: input.title });
|
||||
} catch {
|
||||
// If rename fails (e.g. session closed/race), continue with the
|
||||
// default — the response shape is unchanged.
|
||||
}
|
||||
}
|
||||
const meta = await this.tryGetMeta(summary.id);
|
||||
const session = toProtocolSession(summary, meta);
|
||||
// Fire onDidCreate listeners after the core RPC resolves.
|
||||
this._onDidCreate.fire({ session });
|
||||
return session;
|
||||
}
|
||||
|
||||
async list(query: SessionListQuery): Promise<PageResponse<Session>> {
|
||||
// Fast path: when caller supplies a workDir (typically from `?workspace_id=`
|
||||
// resolving to a workspace.root), agent-core's `listSessions({workDir})`
|
||||
// walks a single wd-key bucket via readdir instead of scanning every
|
||||
// workdir. Otherwise we list everything and apply downstream filters.
|
||||
const all =
|
||||
query.workDir !== undefined
|
||||
? await this.core.rpc.listSessions({ workDir: query.workDir })
|
||||
: await this.core.rpc.listSessions({});
|
||||
// Sort by createdAt desc per REST §1.6 "最近 N 条(按 created_at desc)".
|
||||
const sorted = [...all].sort((a, b) => b.createdAt - a.createdAt);
|
||||
const sorted = all.toSorted((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
// Cursor: anchor on id. before_id = older than that id; after_id = newer.
|
||||
// Because the underlying list is desc, "older" = AFTER in the array.
|
||||
let pivotIndex = -1;
|
||||
if (query.before_id !== undefined) {
|
||||
pivotIndex = sorted.findIndex((s) => s.id === query.before_id);
|
||||
|
|
@ -192,10 +151,8 @@ export class SessionService extends Disposable implements ISessionService {
|
|||
|
||||
let slice: typeof sorted;
|
||||
if (query.before_id !== undefined && pivotIndex >= 0) {
|
||||
// before_id = older entries → tail of the desc array, exclusive of pivot
|
||||
slice = sorted.slice(pivotIndex + 1);
|
||||
} else if (query.after_id !== undefined && pivotIndex >= 0) {
|
||||
// after_id = newer entries → head of the desc array, exclusive of pivot
|
||||
slice = sorted.slice(0, pivotIndex);
|
||||
} else {
|
||||
slice = sorted;
|
||||
|
|
@ -206,16 +163,10 @@ export class SessionService extends Disposable implements ISessionService {
|
|||
const pageSummaries = slice.slice(0, pageSize);
|
||||
const hasMore = slice.length > pageSize;
|
||||
|
||||
// Hydrate each summary with its metadata. We do these in parallel —
|
||||
// `getSessionMetadata` is in-memory once the session is loaded, so the
|
||||
// round-trip count is what matters, not bandwidth.
|
||||
const items = await Promise.all(
|
||||
pageSummaries.map(async (s) => toProtocolSession(s, await this.tryGetMeta(s.id))),
|
||||
);
|
||||
|
||||
// Apply post-hydration status filter if requested. Today all sessions
|
||||
// are mapped to 'idle' (see header note); the filter is wired now so the
|
||||
// wire contract is stable when agent-core surfaces a real status enum.
|
||||
const filtered =
|
||||
query.status !== undefined ? items.filter((s) => s.status === query.status) : items;
|
||||
|
||||
|
|
@ -233,21 +184,16 @@ export class SessionService extends Disposable implements ISessionService {
|
|||
}
|
||||
|
||||
async update(id: string, input: SessionUpdate): Promise<Session> {
|
||||
// Existence check first — gives a deterministic 40401 if the id is wrong.
|
||||
const all = await this.core.rpc.listSessions({});
|
||||
const summary = all.find((s) => s.id === id);
|
||||
if (summary === undefined) {
|
||||
throw new SessionNotFoundError(id);
|
||||
}
|
||||
|
||||
// 1) title goes through renameSession.
|
||||
if (input.title !== undefined) {
|
||||
await this.core.rpc.renameSession({ sessionId: id, title: input.title });
|
||||
}
|
||||
|
||||
// 2) metadata patches go through updateSessionMetadata. agent-core's
|
||||
// SessionMeta has top-level `title` + `custom`; we route protocol's
|
||||
// `metadata` (catchall) into `custom` so it round-trips on the next get.
|
||||
const metadataPatch = input.metadata;
|
||||
if (metadataPatch !== undefined && Object.keys(metadataPatch).length > 0) {
|
||||
await this.core.rpc.updateSessionMetadata({
|
||||
|
|
@ -256,19 +202,9 @@ export class SessionService extends Disposable implements ISessionService {
|
|||
});
|
||||
}
|
||||
|
||||
// 3) agent_config runtime controls — route the four fields (model,
|
||||
// thinking, permission_mode, plan_mode) through the per-session
|
||||
// shadow on `IPromptService.applyAgentState`. The helper diff-
|
||||
// dispatches against the shadow and writes a dispatch-log entry
|
||||
// with `source='meta'` for any setter that actually fires.
|
||||
// Resolution is lazy via `IInstantiationService` to break the
|
||||
// ctor cycle (`PromptService` already injects `ISessionService`).
|
||||
const ac = input.agent_config;
|
||||
if (ac !== undefined) {
|
||||
const patch: AgentStatePatch = {};
|
||||
// SessionService.update has long accepted `agent_config.model` and
|
||||
// treated empty string as "no change" — we preserve that quirk by
|
||||
// dropping the empty case before forwarding to the shadow.
|
||||
if (ac.model !== undefined && ac.model !== '') patch.model = ac.model;
|
||||
if (ac.thinking !== undefined) patch.thinking = ac.thinking;
|
||||
if (ac.permission_mode !== undefined) patch.permission_mode = ac.permission_mode;
|
||||
|
|
@ -286,10 +222,6 @@ export class SessionService extends Disposable implements ISessionService {
|
|||
}
|
||||
}
|
||||
|
||||
// 4) permission_rules: no CoreAPI surface yet — we accept the input
|
||||
// (schema-validated) but no CoreAPI surface exists to persist them yet.
|
||||
|
||||
// Re-fetch to return the post-update Session.
|
||||
const allAfter = await this.core.rpc.listSessions({});
|
||||
const summaryAfter = allAfter.find((s) => s.id === id) ?? summary;
|
||||
const meta = await this.tryGetMeta(id);
|
||||
|
|
@ -374,7 +306,6 @@ export class SessionService extends Disposable implements ISessionService {
|
|||
}
|
||||
|
||||
async getStatus(id: string): Promise<SessionStatusResponse> {
|
||||
// Existence check — same pattern as get() / update() / delete().
|
||||
const all = await this.core.rpc.listSessions({});
|
||||
const summary = all.find((s) => s.id === id);
|
||||
if (summary === undefined) {
|
||||
|
|
@ -448,14 +379,12 @@ export class SessionService extends Disposable implements ISessionService {
|
|||
}
|
||||
|
||||
async delete(id: string): Promise<{ deleted: true }> {
|
||||
// Existence check — deterministic 40401 even on close.
|
||||
const all = await this.core.rpc.listSessions({});
|
||||
const summary = all.find((s) => s.id === id);
|
||||
if (summary === undefined) {
|
||||
throw new SessionNotFoundError(id);
|
||||
}
|
||||
await this.core.rpc.closeSession({ sessionId: id });
|
||||
// Fire onDidClose listeners after the core RPC resolves.
|
||||
this._onDidClose.fire({ sessionId: id });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
|
@ -469,11 +398,6 @@ export class SessionService extends Disposable implements ISessionService {
|
|||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a session's metadata; swallow errors (session may not be loaded into
|
||||
* the active session map yet, in which case `sessionApi(id)` throws). The
|
||||
* caller falls back to defaults from the summary alone.
|
||||
*/
|
||||
private async tryGetMeta(id: string): Promise<SessionMeta | undefined> {
|
||||
try {
|
||||
const meta = await this.core.rpc.getSessionMetadata({ sessionId: id });
|
||||
|
|
@ -483,24 +407,10 @@ export class SessionService extends Disposable implements ISessionService {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Per-domain event accessors -------------------------------------------
|
||||
//
|
||||
// `onDidCreate` / `onDidClose` are declared above as
|
||||
// `Emitter<T>.event` getters; consumers subscribe via
|
||||
// `svc.onDidCreate(handler)` (returns IDisposable) and own the
|
||||
// detach lifetime through `Disposable._register(...)`.
|
||||
|
||||
override dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
// `_onDidCreate` and `_onDidClose` are registered via `this._register(...)`,
|
||||
// so `super.dispose()` flushes their listeners.
|
||||
if (this._store.isDisposed) return;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Self-register under the global singleton registry. Daemon-side bootstrap
|
||||
// projects this through `defaultServicesModule()` /
|
||||
// `getSingletonServiceDescriptors()`. All ctor deps are `@I…`-injected, so
|
||||
// `staticArguments` is `[]`. `supportsDelayedInstantiation = false` preserves
|
||||
// current reverse-dispose semantics.
|
||||
registerSingleton(ISessionService, SessionService, InstantiationType.Delayed);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,3 @@
|
|||
/**
|
||||
* Acceptance: `CoreProcessService` wires peer services + KimiCore + RPC pair;
|
||||
* `ready()` settles; `dispose()` short-circuits RPC; `defaultServicesModule()`
|
||||
* composes with the DI container.
|
||||
*
|
||||
* Hermetic strategy: KimiCore wants a real HOME dir / config / Git Bash. We
|
||||
* point it at an isolated tmp dir per test so it doesn't touch the user's
|
||||
* `~/.kimi`. The `rpc` smoke uses a single round-trip (`getCoreInfo`) that
|
||||
* doesn't require any external state — exercises the full RPC plumbing (core
|
||||
* ← createRPC → BridgeClientAPI binding) without touching session/plugin/MCP
|
||||
* code paths. createSession() smoke is harder to make hermetic because it
|
||||
* spins up Kaos, hooks, and plugin discovery — we leave that to integration
|
||||
* suites with daemon-side mocks.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
|
@ -20,16 +5,16 @@ import { join } from 'node:path';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
InstantiationService,
|
||||
ServiceCollection,
|
||||
SyncDescriptor,
|
||||
Emitter,
|
||||
getSingletonServiceDescriptors,
|
||||
type ApprovalRequest,
|
||||
type ApprovalResponse,
|
||||
type Event,
|
||||
type QuestionRequest,
|
||||
type QuestionResult,
|
||||
} from '@moonshot-ai/agent-core';
|
||||
import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test';
|
||||
|
||||
import {
|
||||
BridgeClientAPI,
|
||||
|
|
@ -39,11 +24,8 @@ import {
|
|||
IEventService,
|
||||
ICoreProcessService,
|
||||
IQuestionService,
|
||||
defaultServicesModule,
|
||||
} from '../src';
|
||||
|
||||
// --- Mock peer-service impls (per-test fresh instances) ----------------------
|
||||
|
||||
class RecordingEventService implements IEventService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
|
|
@ -92,8 +74,6 @@ class RecordingQuestionService implements IQuestionService {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Sandbox HOME setup ------------------------------------------------------
|
||||
|
||||
let tmpHome: string;
|
||||
let prevHome: string | undefined;
|
||||
|
||||
|
|
@ -112,7 +92,6 @@ afterEach(() => {
|
|||
try {
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup; tmp dirs are auto-pruned.
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -132,8 +111,6 @@ function makeEnv(homeDir: string): IEnvironmentService {
|
|||
};
|
||||
}
|
||||
|
||||
// --- Tests -------------------------------------------------------------------
|
||||
|
||||
describe('BridgeClientAPI', () => {
|
||||
it('routes emitEvent / requestApproval / requestQuestion / toolCall to peer services', async () => {
|
||||
const { eventService, approvalService, questionService } = makePeers();
|
||||
|
|
@ -191,7 +168,6 @@ describe('CoreProcessService direct construction', () => {
|
|||
questionService,
|
||||
);
|
||||
try {
|
||||
// ready() resolves once the SDK side of the RPC pair has bound.
|
||||
await expect(core.ready()).resolves.toBeUndefined();
|
||||
expect(typeof core.rpc.getCoreInfo).toBe('function');
|
||||
} finally {
|
||||
|
|
@ -210,9 +186,6 @@ describe('CoreProcessService direct construction', () => {
|
|||
);
|
||||
try {
|
||||
await core.ready();
|
||||
// getCoreInfo is a pure read on KimiCore (no session/plugin state). It
|
||||
// round-trips through the full createRPC pair (serialize → core →
|
||||
// serialize back) — that's the in-process adapter smoke we care about.
|
||||
const info = await core.rpc.getCoreInfo({});
|
||||
expect(info).toHaveProperty('version');
|
||||
expect(typeof info.version).toBe('string');
|
||||
|
|
@ -232,40 +205,19 @@ describe('CoreProcessService direct construction', () => {
|
|||
);
|
||||
await core.ready();
|
||||
core.dispose();
|
||||
core.dispose(); // second call must be a no-op
|
||||
core.dispose();
|
||||
|
||||
await expect(core.rpc.getCoreInfo({})).rejects.toThrow(/disposed/);
|
||||
});
|
||||
|
||||
// Regression: prior to the BLOCKER fix the in-process adapter never
|
||||
// forwarded a `resolveOAuthTokenProvider` into KimiCore.
|
||||
// ProviderManager.resolveAuth then synthesized a closure that ALWAYS
|
||||
// threw `auth.login_required` even after a successful device-code login.
|
||||
// The daemon's `/auth` readiness probe (file-existence check) still said
|
||||
// `ready:true`, so the failure only surfaced inside the prompt turn.
|
||||
// Lock down that the adapter default-wires a resolver from the same
|
||||
// home/config paths KimiCore consumes.
|
||||
it('default-wires a resolveOAuthTokenProvider when caller omits one', () => {
|
||||
const resolver = CoreProcessService._defaultOAuthTokenResolver(tmpHome, join(tmpHome, 'config.toml'));
|
||||
expect(typeof resolver).toBe('function');
|
||||
// Calling the resolver with the managed-kimi-code provider name must
|
||||
// return an object exposing `getAccessToken`. We don't invoke it —
|
||||
// there's no token on disk in this hermetic test — but the shape is
|
||||
// sufficient to prove the adapter wired a real BearerTokenProvider
|
||||
// factory (not the always-throw sentinel).
|
||||
const tokenProvider = resolver('managed:kimi-code');
|
||||
expect(tokenProvider).toBeDefined();
|
||||
expect(typeof tokenProvider?.getAccessToken).toBe('function');
|
||||
});
|
||||
|
||||
// Regression: prior to the identity-wiring fix the adapter never
|
||||
// forwarded `kimiRequestHeaders` into KimiCore — the daemon-hosted
|
||||
// KimiCore made upstream fetches with the Node default User-Agent, and
|
||||
// the managed Kimi-for-Coding endpoint rejected with 40340 ("only
|
||||
// available for Coding Agents such as Kimi CLI, …"). The in-process TUI
|
||||
// path (`createKimiHarness`) was unaffected because `SDKRpcClient`
|
||||
// already built these headers from `identity`. Lock down that the
|
||||
// adapter does the same when given an `identity`.
|
||||
it('default-wires kimiRequestHeaders from identity when caller omits headers', () => {
|
||||
const headers = CoreProcessService._defaultKimiRequestHeaders(
|
||||
tmpHome,
|
||||
|
|
@ -275,10 +227,6 @@ describe('CoreProcessService direct construction', () => {
|
|||
expect(headers!['User-Agent']).toMatch(/^kimi-code-cli\/9\.9\.9/);
|
||||
expect(headers!['X-Msh-Platform']).toBe('kimi_code_cli');
|
||||
expect(headers!['X-Msh-Version']).toBe('9.9.9');
|
||||
// `createKimiDeviceId` mints + caches a per-machine UUID under
|
||||
// `<homeDir>/device_id`. Assert the header exists (UUID shape, not a
|
||||
// literal value — we tmp-isolate the home, so the value differs every
|
||||
// run).
|
||||
expect(headers!['X-Msh-Device-Id']).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
|
|
@ -290,10 +238,6 @@ describe('CoreProcessService direct construction', () => {
|
|||
});
|
||||
|
||||
it('caller-supplied kimiRequestHeaders win over identity-derived defaults', () => {
|
||||
// Sanity: when both are present the ctor takes `options.kimiRequestHeaders`
|
||||
// first. We can't observe the KimiCore ctor arg directly without exposing
|
||||
// it, so we just lock down the helper's precedence contract — the ctor's
|
||||
// `??` chain depends on it.
|
||||
const explicit = { 'User-Agent': 'override/1.0' };
|
||||
const picked =
|
||||
explicit ?? CoreProcessService._defaultKimiRequestHeaders(
|
||||
|
|
@ -304,33 +248,24 @@ describe('CoreProcessService direct construction', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('defaultServicesModule() composition', () => {
|
||||
describe('singleton registry composition', () => {
|
||||
it('returns a CoreProcessService descriptor that composes with the DI container', async () => {
|
||||
const { eventService, approvalService, questionService } = makePeers();
|
||||
const moduleEntries = defaultServicesModule();
|
||||
// ICoreProcessService is the FIRST entry (its position matters because
|
||||
// the daemon's start.ts uses createInstance + services.set on top of the
|
||||
// descriptor — the order documents the canonical construction sequence).
|
||||
const moduleEntries = getSingletonServiceDescriptors();
|
||||
expect(moduleEntries.length).toBeGreaterThanOrEqual(1);
|
||||
expect(moduleEntries[0]![0]).toBe(ICoreProcessService);
|
||||
expect(moduleEntries[0]![1]).toBeInstanceOf(SyncDescriptor);
|
||||
|
||||
const services = new ServiceCollection(
|
||||
// Spread module entries first so the test's explicit per-decorator
|
||||
// overrides below win (last-write-wins in ServiceCollection). The
|
||||
// module now self-registers `IEventService` too, so we need the
|
||||
// fake `eventService` to land AFTER that descriptor entry.
|
||||
...moduleEntries.map(([id, desc]) => [id, desc] as const),
|
||||
[IEventService, eventService],
|
||||
[IApprovalService, approvalService],
|
||||
[IQuestionService, questionService],
|
||||
[IEnvironmentService, makeEnv(tmpHome)],
|
||||
);
|
||||
const ix = new InstantiationService(services);
|
||||
const ix = new TestInstantiationService();
|
||||
for (const [id, desc] of moduleEntries) {
|
||||
ix.set(id, desc);
|
||||
}
|
||||
ix.stub(IEventService, eventService);
|
||||
ix.stub(IApprovalService, approvalService);
|
||||
ix.stub(IQuestionService, questionService);
|
||||
ix.stub(IEnvironmentService, makeEnv(tmpHome));
|
||||
|
||||
try {
|
||||
// createInstance with static options prefix; @IEnvironmentService and
|
||||
// the three peer services auto-inject from the container.
|
||||
const core = ix.createInstance(CoreProcessService, {});
|
||||
try {
|
||||
await core.ready();
|
||||
|
|
|
|||
|
|
@ -1,18 +1,9 @@
|
|||
/**
|
||||
* Acceptance: the three peer-service decorators (`IEventService`,
|
||||
* `IApprovalService`, `IQuestionService`) are typed correctly, can be
|
||||
* registered in a `ServiceCollection`, resolved through
|
||||
* `InstantiationService`, and surface their diagnostic names in
|
||||
* not-registered errors.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
InstantiationService,
|
||||
ServiceCollection,
|
||||
Emitter,
|
||||
} from '@moonshot-ai/agent-core';
|
||||
import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test';
|
||||
import type { ApprovalRequest, Event, QuestionRequest } from '@moonshot-ai/agent-core';
|
||||
|
||||
import {
|
||||
|
|
@ -72,7 +63,6 @@ class FakeQuestionService implements IQuestionService {
|
|||
}
|
||||
|
||||
function makeFakeEvent(): Event {
|
||||
// Minimal AgentStatusUpdatedEvent shape — the union narrows by `type`.
|
||||
return {
|
||||
type: 'agent_status_updated',
|
||||
sessionId: 'sess-1',
|
||||
|
|
@ -106,117 +96,81 @@ function makeFakeQuestion(): QuestionRequest & { sessionId: string; agentId: str
|
|||
}
|
||||
|
||||
describe('@moonshot-ai/services · interfaces', () => {
|
||||
it('registers all three peer services in a ServiceCollection and resolves them through InstantiationService', () => {
|
||||
it('registers all three peer services in a test instantiation service', () => {
|
||||
const events = new FakeEventService();
|
||||
const approvals = new FakeApprovalService();
|
||||
const questions = new FakeQuestionService();
|
||||
|
||||
const services = new ServiceCollection(
|
||||
[IEventService, events],
|
||||
[IApprovalService, approvals],
|
||||
[IQuestionService, questions],
|
||||
);
|
||||
const ix = new InstantiationService(services);
|
||||
const ix = new TestInstantiationService();
|
||||
ix.stub(IEventService, events);
|
||||
ix.stub(IApprovalService, approvals);
|
||||
ix.stub(IQuestionService, questions);
|
||||
|
||||
try {
|
||||
ix.invokeFunction((accessor) => {
|
||||
expect(accessor.get(IEventService)).toBe(events);
|
||||
expect(accessor.get(IApprovalService)).toBe(approvals);
|
||||
expect(accessor.get(IQuestionService)).toBe(questions);
|
||||
});
|
||||
} finally {
|
||||
ix.dispose();
|
||||
}
|
||||
expect(ix.get(IEventService)).toBe(events);
|
||||
expect(ix.get(IApprovalService)).toBe(approvals);
|
||||
expect(ix.get(IQuestionService)).toBe(questions);
|
||||
});
|
||||
|
||||
it('end-to-end smoke: invokes service methods via the accessor', async () => {
|
||||
it('end-to-end smoke: invokes service methods through the test container', async () => {
|
||||
const events = new FakeEventService();
|
||||
const approvals = new FakeApprovalService();
|
||||
const questions = new FakeQuestionService();
|
||||
|
||||
const services = new ServiceCollection(
|
||||
[IEventService, events],
|
||||
[IApprovalService, approvals],
|
||||
[IQuestionService, questions],
|
||||
);
|
||||
const ix = new InstantiationService(services);
|
||||
const ix = new TestInstantiationService();
|
||||
ix.stub(IEventService, events);
|
||||
ix.stub(IApprovalService, approvals);
|
||||
ix.stub(IQuestionService, questions);
|
||||
|
||||
try {
|
||||
const event = makeFakeEvent();
|
||||
ix.invokeFunction((a) => a.get(IEventService).publish(event));
|
||||
expect(events.events).toEqual([event]);
|
||||
const event = makeFakeEvent();
|
||||
ix.get(IEventService).publish(event);
|
||||
expect(events.events).toEqual([event]);
|
||||
|
||||
const approval = makeFakeApproval();
|
||||
const approvalResp = await ix.invokeFunction((a) =>
|
||||
a.get(IApprovalService).request(approval),
|
||||
);
|
||||
expect(approvalResp).toEqual({ decision: 'approved' });
|
||||
expect(approvals.received).toHaveLength(1);
|
||||
const approval = makeFakeApproval();
|
||||
const approvalResp = await ix.get(IApprovalService).request(approval);
|
||||
expect(approvalResp).toEqual({ decision: 'approved' });
|
||||
expect(approvals.received).toHaveLength(1);
|
||||
|
||||
const question = makeFakeQuestion();
|
||||
const questionResp = await ix.invokeFunction((a) =>
|
||||
a.get(IQuestionService).request(question),
|
||||
);
|
||||
expect(questionResp).toBeNull();
|
||||
expect(questions.received).toHaveLength(1);
|
||||
} finally {
|
||||
ix.dispose();
|
||||
}
|
||||
const question = makeFakeQuestion();
|
||||
const questionResp = await ix.get(IQuestionService).request(question);
|
||||
expect(questionResp).toBeNull();
|
||||
expect(questions.received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('resolve/dismiss service methods are wired through the same DI value', () => {
|
||||
const approvals = new FakeApprovalService();
|
||||
const questions = new FakeQuestionService();
|
||||
|
||||
const services = new ServiceCollection(
|
||||
[IApprovalService, approvals],
|
||||
[IQuestionService, questions],
|
||||
);
|
||||
const ix = new InstantiationService(services);
|
||||
const ix = new TestInstantiationService();
|
||||
ix.stub(IApprovalService, approvals);
|
||||
ix.stub(IQuestionService, questions);
|
||||
|
||||
try {
|
||||
ix.invokeFunction((a) => {
|
||||
a.get(IApprovalService).resolve('tc-1', { decision: 'rejected', feedback: 'no' });
|
||||
a.get(IQuestionService).resolve('q-1', { answers: { q_1: 'A' } });
|
||||
a.get(IQuestionService).dismiss('q-2');
|
||||
});
|
||||
ix.get(IApprovalService).resolve('tc-1', { decision: 'rejected', feedback: 'no' });
|
||||
ix.get(IQuestionService).resolve('q-1', { answers: { q_1: 'A' } });
|
||||
ix.get(IQuestionService).dismiss('q-2');
|
||||
|
||||
expect(approvals.resolveCalls).toEqual([
|
||||
{ id: 'tc-1', response: { decision: 'rejected', feedback: 'no' } },
|
||||
]);
|
||||
expect(questions.resolveCalls).toEqual([
|
||||
{ id: 'q-1', response: { answers: { q_1: 'A' } } },
|
||||
]);
|
||||
expect(questions.dismissCalls).toEqual(['q-2']);
|
||||
} finally {
|
||||
ix.dispose();
|
||||
}
|
||||
expect(approvals.resolveCalls).toEqual([
|
||||
{ id: 'tc-1', response: { decision: 'rejected', feedback: 'no' } },
|
||||
]);
|
||||
expect(questions.resolveCalls).toEqual([
|
||||
{ id: 'q-1', response: { answers: { q_1: 'A' } } },
|
||||
]);
|
||||
expect(questions.dismissCalls).toEqual(['q-2']);
|
||||
});
|
||||
|
||||
it('looking up an unregistered service returns undefined in non-strict mode', () => {
|
||||
const ix = new InstantiationService(new ServiceCollection());
|
||||
try {
|
||||
expect(ix.invokeFunction((a) => a.get(IEventService))).toBeUndefined();
|
||||
expect(ix.invokeFunction((a) => a.get(IApprovalService))).toBeUndefined();
|
||||
expect(ix.invokeFunction((a) => a.get(IQuestionService))).toBeUndefined();
|
||||
} finally {
|
||||
ix.dispose();
|
||||
}
|
||||
const ix = new TestInstantiationService();
|
||||
expect(ix.get(IEventService)).toBeUndefined();
|
||||
expect(ix.get(IApprovalService)).toBeUndefined();
|
||||
expect(ix.get(IQuestionService)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('IEventService / IApprovalService / IQuestionService are callable ServiceIdentifiers (compile-time guard)', () => {
|
||||
// The const half of the dual export must be usable as a ServiceCollection
|
||||
// key and as a `createDecorator` brand value. We exercise both at runtime
|
||||
// to also catch any accidental swap of the value with the type.
|
||||
expect(typeof IEventService).toBe('function');
|
||||
expect(typeof IApprovalService).toBe('function');
|
||||
expect(typeof IQuestionService).toBe('function');
|
||||
|
||||
// Avoid an unused-import warning on the type-only re-export.
|
||||
const _typeProbe: ApprovalResponse | QuestionResult = null;
|
||||
void _typeProbe;
|
||||
// And use vi to keep the import surface (helpful when running with strict
|
||||
// unused-imports lints in the future).
|
||||
vi.fn();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,19 +1,3 @@
|
|||
/**
|
||||
* `SessionService` (Chain 2 / P1.2) unit tests.
|
||||
*
|
||||
* Hermetic: we mock `ICoreProcessService` with an in-memory `rpc` proxy whose
|
||||
* methods return controllable promises. No KimiCore, no agent-core RPC pair
|
||||
* — the adapter is exercised against a fake bridge.
|
||||
*
|
||||
* Test cases cover:
|
||||
* - create → toProtocolSession (camelCase ↔ snake_case + number → ISO)
|
||||
* - list pagination (default/before_id/after_id/page_size; has_more)
|
||||
* - get + SessionNotFoundError → 40401 mapping at the daemon layer
|
||||
* - update (title-only / metadata-only / both / empty)
|
||||
* - delete returning {deleted: true}
|
||||
* - toProtocolSession field defaults for fields agent-core doesn't surface
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
|
|
@ -23,15 +7,13 @@ import {
|
|||
type CreateSessionPayload,
|
||||
Emitter,
|
||||
type ForkSessionPayload,
|
||||
type IInstantiationService,
|
||||
type RenameSessionPayload,
|
||||
type ResumeSessionResult,
|
||||
type ServiceIdentifier,
|
||||
type ServicesAccessor,
|
||||
type SessionMeta,
|
||||
type SessionSummary,
|
||||
type UpdateSessionMetadataPayload,
|
||||
} from '@moonshot-ai/agent-core';
|
||||
import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test';
|
||||
import { emptySessionUsage, type Session } from '@moonshot-ai/protocol';
|
||||
|
||||
import {
|
||||
|
|
@ -63,11 +45,6 @@ interface FakeBridgeState {
|
|||
postUndoContexts: Map<string, AgentContextData>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a tiny fake `ICoreProcessService` whose `rpc` proxy implements just the
|
||||
* five session methods the impl uses. Each method delegates to an in-memory
|
||||
* state object the test owns.
|
||||
*/
|
||||
function makeFakeBridge(state: FakeBridgeState): ICoreProcessService {
|
||||
const rpc: Partial<CoreRPC> = {
|
||||
createSession: vi
|
||||
|
|
@ -152,8 +129,6 @@ function makeFakeBridge(state: FakeBridgeState): ICoreProcessService {
|
|||
.fn()
|
||||
.mockImplementation(async (payload: WithSessionId<RenameSessionPayload>) => {
|
||||
state.renamedTitles.set(payload.sessionId, payload.title);
|
||||
// Reflect into the metadata map so subsequent `getSessionMetadata`
|
||||
// returns the updated title (mirrors real KimiCore behavior).
|
||||
const existing = state.metas.get(payload.sessionId);
|
||||
if (existing !== undefined) {
|
||||
state.metas.set(payload.sessionId, { ...existing, title: payload.title });
|
||||
|
|
@ -257,15 +232,8 @@ function textMessage(
|
|||
let state: FakeBridgeState;
|
||||
let svc: SessionService;
|
||||
let promptStub: ReturnType<typeof makePromptServiceStub>;
|
||||
let instantiation: TestInstantiationService;
|
||||
|
||||
/**
|
||||
* Stub `IPromptService` for hermetic SessionService tests. Records every
|
||||
* `applyAgentState(sid, patch, source)` call so tests can assert that
|
||||
* `SessionService.update` forwards `agent_config` runtime fields through
|
||||
* the shared shadow-aware helper rather than dispatching `core.rpc.*`
|
||||
* directly. The other `IPromptService` methods aren't reachable from
|
||||
* SessionService and are stubbed to throw on access.
|
||||
*/
|
||||
function makePromptServiceStub(): {
|
||||
promptService: IPromptService;
|
||||
calls: Array<{ sid: string; patch: Record<string, unknown>; source: string; promptId: string | undefined }>;
|
||||
|
|
@ -290,45 +258,27 @@ function makePromptServiceStub(): {
|
|||
return { promptService, calls };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake `IInstantiationService` that only resolves the one service
|
||||
* `SessionService.update` reaches for (`IPromptService`). Other lookups
|
||||
* throw — they would indicate an unintended dependency creeping in.
|
||||
*/
|
||||
function makeFakeInstantiation(stubs: {
|
||||
function makeTestInstantiation(stubs: {
|
||||
promptService: IPromptService;
|
||||
}): IInstantiationService {
|
||||
const accessor: ServicesAccessor = {
|
||||
get: <T,>(id: ServiceIdentifier<T>): T => {
|
||||
if ((id as unknown) === (IPromptService as unknown)) {
|
||||
return stubs.promptService as unknown as T;
|
||||
}
|
||||
throw new Error(`unexpected service lookup: ${String((id as unknown as { toString(): string }).toString())}`);
|
||||
},
|
||||
};
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
invokeFunction: <R,>(fn: (a: ServicesAccessor) => R): R => fn(accessor),
|
||||
createInstance: (() => {
|
||||
throw new Error('createInstance not supported in this test stub');
|
||||
}) as IInstantiationService['createInstance'],
|
||||
createChild: () => {
|
||||
throw new Error('createChild not supported in this test stub');
|
||||
},
|
||||
} as unknown as IInstantiationService;
|
||||
}): TestInstantiationService {
|
||||
const ix = new TestInstantiationService(undefined, true);
|
||||
ix.stub(IPromptService, stubs.promptService);
|
||||
return ix;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state = freshState();
|
||||
promptStub = makePromptServiceStub();
|
||||
instantiation = makeTestInstantiation({ promptService: promptStub.promptService });
|
||||
svc = new SessionService(
|
||||
makeFakeBridge(state),
|
||||
makeFakeInstantiation({ promptService: promptStub.promptService }),
|
||||
instantiation,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
svc.dispose();
|
||||
instantiation.dispose();
|
||||
});
|
||||
|
||||
describe('toProtocolSession adapter', () => {
|
||||
|
|
@ -458,7 +408,6 @@ describe('SessionService.create', () => {
|
|||
expect(state.sessions).toHaveLength(1);
|
||||
expect(state.sessions[0]!.workDir).toBe('/tmp/foo');
|
||||
expect(session.metadata.cwd).toBe('/tmp/foo');
|
||||
// title is echoed back even when CoreAPI doesn't reflect it (gap doc).
|
||||
expect(session.title).toBe('My session');
|
||||
expect(session.created_at.endsWith('Z')).toBe(true);
|
||||
});
|
||||
|
|
@ -468,9 +417,7 @@ describe('SessionService.create', () => {
|
|||
metadata: { cwd: '/tmp/x' },
|
||||
agent_config: { model: 'moonshot-v1-128k' },
|
||||
});
|
||||
const created = state.sessions[0]!;
|
||||
expect((state.sessions as SessionSummary[])[0]!.metadata?.['cwd']).toBe('/tmp/x');
|
||||
void created;
|
||||
expect(state.sessions[0]!.metadata?.['cwd']).toBe('/tmp/x');
|
||||
});
|
||||
|
||||
it('rejects when metadata.cwd is absent (daemon route must pre-resolve workspace_id → cwd)', async () => {
|
||||
|
|
@ -482,7 +429,6 @@ describe('SessionService.create', () => {
|
|||
|
||||
describe('SessionService.list', () => {
|
||||
beforeEach(async () => {
|
||||
// Seed 3 sessions in increasing createdAt order.
|
||||
await svc.create({ metadata: { cwd: '/tmp/a' } });
|
||||
await svc.create({ metadata: { cwd: '/tmp/b' } });
|
||||
await svc.create({ metadata: { cwd: '/tmp/c' } });
|
||||
|
|
@ -504,20 +450,19 @@ describe('SessionService.list', () => {
|
|||
|
||||
it('before_id returns older sessions only', async () => {
|
||||
const all = await svc.list({});
|
||||
const pivotId = all.items[0]!.id; // newest
|
||||
const pivotId = all.items[0]!.id;
|
||||
const olderPage = await svc.list({ before_id: pivotId });
|
||||
expect(olderPage.items.map((s) => s.metadata.cwd)).toEqual(['/tmp/b', '/tmp/a']);
|
||||
});
|
||||
|
||||
it('after_id returns newer sessions only', async () => {
|
||||
const all = await svc.list({});
|
||||
const pivotId = all.items[2]!.id; // oldest
|
||||
const pivotId = all.items[2]!.id;
|
||||
const newerPage = await svc.list({ after_id: pivotId });
|
||||
expect(newerPage.items.map((s) => s.metadata.cwd)).toEqual(['/tmp/c', '/tmp/b']);
|
||||
});
|
||||
|
||||
it('status filter applies post-hydration', async () => {
|
||||
// Today everything maps to 'idle'; non-matching filter returns []
|
||||
const empty = await svc.list({ status: 'running' });
|
||||
expect(empty.items).toEqual([]);
|
||||
const idle = await svc.list({ status: 'idle' });
|
||||
|
|
@ -569,7 +514,6 @@ describe('SessionService.update', () => {
|
|||
it('routes title through bridge.rpc.renameSession', async () => {
|
||||
await svc.update(created.id, { title: 'Renamed' });
|
||||
expect(state.renamedTitles.get(created.id)).toBe('Renamed');
|
||||
// Title is reflected via the next get (impl re-fetches metadata).
|
||||
expect(state.metadataPatches.has(created.id)).toBe(false);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,6 @@ export default defineConfig({
|
|||
plugins: [rawTextPlugin()],
|
||||
resolve: {
|
||||
alias: [
|
||||
// Order matters — list MORE specific entries first so prefix matching
|
||||
// doesn't route them through the bare `@moonshot-ai/agent-core` alias
|
||||
// (which points at agent-core/src/index.ts, breaking subpath imports).
|
||||
{
|
||||
find: /^@moonshot-ai\/agent-core\/session\/store$/,
|
||||
replacement: fileURLToPath(
|
||||
|
|
@ -23,6 +20,12 @@ export default defineConfig({
|
|||
new URL('../agent-core/src/base/common/event.ts', import.meta.url),
|
||||
),
|
||||
},
|
||||
{
|
||||
find: /^@moonshot-ai\/agent-core\/di\/test$/,
|
||||
replacement: fileURLToPath(
|
||||
new URL('../agent-core/src/di/test.ts', import.meta.url),
|
||||
),
|
||||
},
|
||||
{
|
||||
find: '@moonshot-ai/kimi-code-sdk',
|
||||
replacement: fileURLToPath(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue