feat(agent-core-v2): add scope-typed IScopeHandle aliases

- make IScopeHandle generic over LifecycleScope and add IAppScopeHandle /
  ISessionScopeHandle / IAgentScopeHandle so session and agent handles cannot
  be passed to each other's parameters at compile time
- thread the aliases through IAgentLifecycleService / ISessionLifecycleService
  and their consumers (sessionLegacy, messageLegacy, server-v2 mainAgent and
  skills), narrowing at the lifecycle boundary
- dep-graph viewer: render scope-mismatch (token registered at a scope the
  caller cannot see) distinctly from a genuinely missing implementation
This commit is contained in:
haozhe.yang 2026-07-01 22:45:48 +08:00
parent fff2babe42
commit d4a6a20db8
14 changed files with 544 additions and 165 deletions

View file

@ -12,12 +12,18 @@ import {
type Viewport,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useMemo } from 'react';
import { Fragment, useMemo, useState } from 'react';
import type { Edge, EdgeKind, Graph, ServiceNode } from '../../analyzer/types';
import type { Edge, EdgeKind, EdgeRef, Graph, ServiceNode } from '../../analyzer/types';
import type { FilterState } from './Filters';
import { layoutDagre } from './layout-dagre';
import { EDGE_STYLE, SCOPE_STYLE } from './style';
import {
EDGE_STYLE,
SCOPE_MISMATCH_COLOR,
SCOPE_STYLE,
UNRESOLVED_COLOR,
} from './style';
import { tagColor, type TagMap } from './tags';
/** Fixed node width so port rows have a stable horizontal box. */
const NODE_WIDTH = 300;
@ -27,6 +33,8 @@ const HEADER_HEIGHT = 68;
const PORT_ROW_HEIGHT = 18;
/** Vertical padding between the header divider and the first port row. */
const PORTS_PAD_TOP = 4;
/** Height reserved for the tag chip row when a node carries at least one tag. */
const TAGS_ROW_HEIGHT = 20;
/**
* Per-node method port lists. `outPorts` are methods on this service that
@ -52,6 +60,10 @@ interface GraphViewProps {
/** Selected `ServiceNode.id`. */
selectedId?: string;
onSelect: (id?: string) => void;
/** User-authored tags, keyed by `ServiceNode.id`. */
tags: TagMap;
/** Replace the full tag list for a node (empty list clears the entry). */
onEditTags: (nodeId: string, tags: string[]) => void;
}
interface ServiceNodeData extends Record<string, unknown> {
@ -65,6 +77,8 @@ interface ServiceNodeData extends Record<string, unknown> {
matched: boolean;
dim: boolean;
ports: ServicePortsInfo;
/** Tags attached to this node, in entry order. */
tags: string[];
}
const EVENT_KINDS: Set<EdgeKind> = new Set(['publish', 'subscribe', 'emit', 'on']);
@ -137,30 +151,35 @@ function computeServicePorts(
return result;
}
function nodeHeight(ports: ServicePortsInfo): number {
function nodeHeight(ports: ServicePortsInfo, hasTags: boolean): number {
const rows = Math.max(ports.inPorts.length, ports.outPorts.length);
if (rows === 0) return HEADER_HEIGHT;
return HEADER_HEIGHT + PORTS_PAD_TOP + rows * PORT_ROW_HEIGHT + PORTS_PAD_TOP;
const base = rows === 0 ? HEADER_HEIGHT : HEADER_HEIGHT + PORTS_PAD_TOP + rows * PORT_ROW_HEIGHT + PORTS_PAD_TOP;
return hasTags ? base + TAGS_ROW_HEIGHT : base;
}
function ServiceNodeView({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Element {
const { service, selected, matched, dim, ports } = data;
const { service, selected, matched, dim, ports, tags } = data;
const bg = SCOPE_STYLE[service.scope].color;
const rowCount = Math.max(ports.inPorts.length, ports.outPorts.length);
// Interface-only node: the token is referenced but has no registered impl.
// Flagged with a dashed warning border so missing bindings stand out from
// concrete services at a glance. Selection / search-match still win so the
// active node stays unambiguous.
// active node stays unambiguous. Scope-mismatch nodes (token registered, but
// at a scope the caller can't see) get a distinct amber dashed border.
const isUnresolved = service.unresolved === true;
const isScopeMismatch = service.scopeMismatch === true;
const specialBorder = isUnresolved || isScopeMismatch;
const borderColor = selected
? '#ffdf5d'
: matched
? '#79c0ff'
: isUnresolved
? '#f85149'
: 'rgba(0,0,0,0.4)';
const borderWidth = selected || matched || isUnresolved ? 2 : 1;
const borderStyle = isUnresolved && !selected && !matched ? 'dashed' : 'solid';
? UNRESOLVED_COLOR
: isScopeMismatch
? SCOPE_MISMATCH_COLOR
: 'rgba(0,0,0,0.4)';
const borderWidth = selected || matched || specialBorder ? 2 : 1;
const borderStyle = specialBorder && !selected && !matched ? 'dashed' : 'solid';
const glow = selected
? '0 0 0 3px rgba(255,223,93,0.25)'
: matched
@ -224,11 +243,17 @@ function ServiceNodeView({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Elemen
</span>
</div>
<div style={{ fontSize: 10, opacity: 0.65, marginTop: 2, fontStyle: 'italic' }}>
{isUnresolved ? 'no implementation registered' : service.token}
{isUnresolved
? 'no implementation registered'
: isScopeMismatch
? `registered at ${service.scope} · cross-scope ref`
: service.token}
</div>
<div style={{ fontSize: 10, opacity: 0.75, marginTop: 2 }}>{service.domain}</div>
</div>
{tags.length > 0 && <TagChips tags={tags} />}
{rowCount > 0 && (
<div
style={{
@ -351,6 +376,187 @@ function BandLabelView({ data }: NodeProps<Node<{ scope: string; width: number }
const nodeTypes = { service: ServiceNodeView, band: BandLabelView };
/** Non-interactive row of tag chips, used inside a graph node. */
function TagChips({ tags }: { tags: string[] }): JSX.Element {
return (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 3,
padding: '0 8px 5px',
}}
>
{tags.map((tag) => (
<TagChip key={tag} tag={tag} />
))}
</div>
);
}
interface TagChipProps {
tag: string;
/** When provided, renders a remove affordance. */
onRemove?: () => void;
}
function TagChip({ tag, onRemove }: TagChipProps): JSX.Element {
const { color, bg } = tagColor(tag);
return (
<span
title={tag}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 3,
maxWidth: onRemove ? 150 : 120,
padding: '1px 5px',
fontSize: 9,
lineHeight: '14px',
color,
background: bg,
border: `1px solid ${color}`,
borderRadius: 8,
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{tag}
</span>
{onRemove && (
<button
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
aria-label={`remove tag ${tag}`}
style={{
background: 'transparent',
border: 'none',
color,
cursor: 'pointer',
padding: 0,
fontSize: 11,
lineHeight: 1,
opacity: 0.8,
}}
>
×
</button>
)}
</span>
);
}
interface TagEditorProps {
tags: string[];
/** Known tags across the graph, offered as input suggestions. */
allTags: string[];
onChange: (next: string[]) => void;
}
/**
* Per-node tag editor rendered in the side panel. Chips remove on click; the
* input adds on Enter or the add button, normalising whitespace and refusing
* duplicates. `allTags` feeds a `<datalist>` so existing tags are one keystroke
* away keeps spelling consistent so grouping actually groups.
*/
function TagEditor({ tags, allTags, onChange }: TagEditorProps): JSX.Element {
const [draft, setDraft] = useState('');
const listId = 'tag-suggestions';
function commit(raw: string): void {
const tag = raw.trim();
if (!tag || tags.includes(tag)) {
setDraft('');
return;
}
onChange([...tags, tag]);
setDraft('');
}
return (
<div style={{ marginTop: 12 }}>
<div
style={{
fontSize: 10,
fontWeight: 600,
textTransform: 'uppercase',
color: '#7d8590',
marginBottom: 4,
letterSpacing: 0.5,
}}
>
tags
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
{tags.length === 0 ? (
<span style={{ color: '#6e7681', fontSize: 11 }}>no tags</span>
) : (
tags.map((tag) => (
<TagChip
key={tag}
tag={tag}
onRemove={() => onChange(tags.filter((t) => t !== tag))}
/>
))
)}
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input
value={draft}
list={listId}
placeholder="add tag…"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commit(draft);
}
}}
style={{
flex: 1,
minWidth: 0,
padding: '4px 7px',
background: '#0e1116',
color: '#e6edf3',
border: '1px solid #30363d',
borderRadius: 4,
fontSize: 11,
}}
/>
<button
onClick={() => commit(draft)}
style={{
padding: '4px 10px',
background: '#21262d',
color: '#e6edf3',
border: '1px solid #30363d',
borderRadius: 4,
cursor: 'pointer',
fontSize: 11,
}}
>
add
</button>
<datalist id={listId}>
{allTags
.filter((t) => !tags.includes(t))
.map((t) => (
<option key={t} value={t} />
))}
</datalist>
</div>
</div>
);
}
/**
* Persist the pan/zoom viewport across dev-server reloads so a source-code
* edit (which triggers a `full-reload` from the `virtual:dep-graph` plugin)
@ -416,6 +622,8 @@ export function GraphView({
filters,
selectedId,
onSelect,
tags,
onEditTags,
}: GraphViewProps): JSX.Element {
// Compute once at mount so a re-render that adds nodes doesn't yank the
// viewport back to the stored value while the user is panning.
@ -451,10 +659,12 @@ export function GraphView({
// dead weight on the node, so we compute after filter+visibility.
const ports = computeServicePorts(visibleServices, finalEdges);
// Compute the two focus drivers:
// Compute the three focus drivers:
// • `selectedId` — the click-selected node (0 or 1 at a time).
// • `matched` — every node whose identity or public surface hits
// the current search string.
// • `tagMatched` — every node carrying at least one active tag
// (the "group by tag" view).
// Their neighbours (nodes touched by any surviving edge) are folded in
// so the graph keeps enough context around a hit to be readable —
// this is the "act like a click" behaviour: nothing disappears, just
@ -467,6 +677,16 @@ export function GraphView({
}
}
// Tag focus: every visible node carrying at least one active tag seeds
// the focus set, so the graph reads as "the group(s) these tags pick out".
const tagMatched = new Set<string>();
if (filters.activeTags.size > 0) {
for (const s of visibleServices) {
const st = tags[s.id];
if (st && st.some((t) => filters.activeTags.has(t))) tagMatched.add(s.id);
}
}
const focused = new Set<string>();
const seedFocus = (id: string): void => {
focused.add(id);
@ -477,14 +697,21 @@ export function GraphView({
};
if (selectedId !== undefined) seedFocus(selectedId);
for (const id of matched) seedFocus(id);
for (const id of tagMatched) seedFocus(id);
const focusActive = selectedId !== undefined || matched.size > 0;
const focusActive =
selectedId !== undefined || matched.size > 0 || tagMatched.size > 0;
const layout = layoutDagre(visibleServices, finalEdges, {
groupByScope: filters.groupByScope,
nodeSize: (id) => {
const p = ports.get(id);
return { width: NODE_WIDTH, height: p ? nodeHeight(p) : HEADER_HEIGHT };
const p = ports.get(id) ?? {
inPorts: [],
outPorts: [],
connectedIn: new Set<string>(),
};
const hasTags = (tags[id]?.length ?? 0) > 0;
return { width: NODE_WIDTH, height: nodeHeight(p, hasTags) };
},
});
const pos = layout.positions;
@ -504,6 +731,7 @@ export function GraphView({
outPorts: [],
connectedIn: new Set<string>(),
},
tags: tags[service.id] ?? [],
},
}),
);
@ -574,7 +802,7 @@ export function GraphView({
: [];
return { nodes: rfNodes, edges: rfEdges, selectedService, selectedEdges };
}, [graph, filters, selectedId]);
}, [graph, filters, selectedId, tags]);
return (
<>
@ -608,7 +836,11 @@ export function GraphView({
if (n.id.startsWith('band::')) return 'transparent';
const service = (n.data as ServiceNodeData | undefined)?.service;
if (!service) return '#7d8590';
return service.unresolved ? '#f85149' : SCOPE_STYLE[service.scope].color;
return service.unresolved
? UNRESOLVED_COLOR
: service.scopeMismatch
? SCOPE_MISMATCH_COLOR
: SCOPE_STYLE[service.scope].color;
}}
/>
<Controls showInteractive={false} style={{ background: '#151b23' }} />
@ -619,6 +851,8 @@ export function GraphView({
graph={graph}
edges={selectedEdges}
onClose={() => onSelect(undefined)}
tags={tags}
onEditTags={onEditTags}
/>
)}
</>
@ -630,19 +864,33 @@ interface ServicePanelProps {
graph: Graph;
edges: Edge[];
onClose: () => void;
tags: TagMap;
onEditTags: (nodeId: string, tags: string[]) => void;
}
function ServicePanel({ service, graph, edges, onClose }: ServicePanelProps): JSX.Element {
function ServicePanel({
service,
graph,
edges,
onClose,
tags,
onEditTags,
}: ServicePanelProps): JSX.Element {
const outgoing = edges.filter((e) => e.from === service.id);
const incoming = edges.filter((e) => e.to === service.id && e.from !== service.id);
const byId = new Map(graph.services.map((s) => [s.id, s]));
const nodeTags = tags[service.id] ?? [];
const allTags = useMemo(
() => [...new Set(Object.values(tags).flat())].sort(),
[tags],
);
return (
<div
style={{
position: 'absolute',
top: 12,
right: 12,
width: 360,
width: 420,
maxHeight: 'calc(100vh - 24px)',
overflowY: 'auto',
background: 'rgba(21,27,35,0.96)',
@ -657,16 +905,20 @@ function ServicePanel({ service, graph, edges, onClose }: ServicePanelProps): JS
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{service.impl}</div>
{service.unresolved ? (
<div style={{ color: '#f85149', fontSize: 11, marginTop: 2 }}>
<div style={{ color: UNRESOLVED_COLOR, fontSize: 11, marginTop: 2 }}>
No implementation registered
</div>
) : service.scopeMismatch ? (
<div style={{ color: SCOPE_MISMATCH_COLOR, fontSize: 11, marginTop: 2 }}>
Registered at {service.scope} not visible from the caller&apos;s scope
</div>
) : (
<div style={{ color: '#a5b0bc', fontSize: 11 }}>{service.token}</div>
)}
<div style={{ color: '#7d8590', fontSize: 11 }}>
<b>{service.scope}</b> · {service.domain}
</div>
{!service.unresolved && (
{!service.unresolved && !service.scopeMismatch && (
<div style={{ color: '#7d8590', fontSize: 10, marginTop: 4, wordBreak: 'break-all' }}>
{service.file}:{service.line}
</div>
@ -687,6 +939,14 @@ function ServicePanel({ service, graph, edges, onClose }: ServicePanelProps): JS
</button>
</div>
<TagEditor
tags={nodeTags}
allTags={allTags}
onChange={(next) => {
onEditTags(service.id, next);
}}
/>
<EdgeList
title={`out (${outgoing.length})`}
edges={outgoing}
@ -710,7 +970,44 @@ interface EdgeListProps {
byId: Map<string, ServiceNode>;
}
interface EdgeGroup {
edge: Edge;
peerLabel: string;
peerToken?: string;
/** Refs that have at least one attributed method — one table row each. */
methodRefs: EdgeRef[];
/** Refs with neither `fromMethod` nor `toMethod` (ctor param decls etc.). */
unattributedCount: number;
}
function buildEdgeGroups(
edges: Edge[],
direction: 'in' | 'out',
byId: Map<string, ServiceNode>,
): EdgeGroup[] {
return edges.map((e) => {
const peerId = direction === 'out' ? e.to : e.from;
const peer = byId.get(peerId);
const peerLabel = peer ? peer.impl : peerId;
const peerToken = peer?.token;
const methodRefs = e.refs.filter(
(r) => r.toMethod !== undefined || r.fromMethod !== undefined,
);
const unattributedCount = e.refs.length - methodRefs.length;
return { edge: e, peerLabel, peerToken, methodRefs, unattributedCount };
});
}
/**
* Right-panel table of edges touching the selected service. One row per
* attributed call ref; consecutive rows belonging to the same edge share
* `kind` / `peer` cells via `rowSpan` so the grouping is visible without
* repeating them. The self-side method column is bold so the direction of
* each call reads at a glance (out `from` bold, in `to` bold).
*/
function EdgeList({ title, edges, direction, byId }: EdgeListProps): JSX.Element {
const groups = buildEdgeGroups(edges, direction, byId);
const selfIsFrom = direction === 'out';
return (
<div style={{ marginTop: 12 }}>
<div
@ -725,84 +1022,163 @@ function EdgeList({ title, edges, direction, byId }: EdgeListProps): JSX.Element
>
{title}
</div>
{edges.length === 0 && <div style={{ color: '#7d8590', fontSize: 11 }}></div>}
{edges.map((e) => {
const peerId = direction === 'out' ? e.to : e.from;
const peer = byId.get(peerId);
const label = peer ? `${peer.impl} (${peer.token})` : peerId;
const methodRefs = e.refs.filter((r) => r.toMethod !== undefined || r.fromMethod !== undefined);
return (
<div key={`${e.from}::${e.kind}::${e.to}`} style={{ padding: '3px 0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
display: 'inline-block',
width: 10,
height: 3,
borderTop: `${EDGE_STYLE[e.kind].dashed ? '2px dashed' : '2px solid'} ${
EDGE_STYLE[e.kind].color
}`,
}}
/>
<span style={{ color: '#7d8590', fontSize: 10, minWidth: 62 }}>{e.kind}</span>
<span
style={{
fontFamily: 'ui-monospace, monospace',
fontSize: 11,
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1,
}}
>
{label}
</span>
<span style={{ color: '#7d8590', fontSize: 10 }}>×{e.refs.length}</span>
</div>
{methodRefs.length > 0 && (
<details style={{ marginLeft: 20, marginTop: 2 }}>
<summary
style={{
fontSize: 10,
color: '#8b949e',
cursor: 'pointer',
listStyle: 'revert',
}}
>
{methodRefs.length} call{methodRefs.length === 1 ? '' : 's'}
</summary>
<div style={{ marginTop: 3 }}>
{methodRefs.map((r, i) => (
<div
key={`${r.file}:${r.line}:${i}`}
{groups.length === 0 ? (
<div style={{ color: '#7d8590', fontSize: 11 }}></div>
) : (
<table style={tableStyle}>
<colgroup>
<col style={{ width: 72 }} />
<col style={{ width: 128 }} />
<col />
<col style={{ width: 40 }} />
</colgroup>
<thead>
<tr>
<th style={thStyle}>kind</th>
<th style={thStyle}>peer</th>
<th style={thStyle}>from to</th>
<th style={{ ...thStyle, textAlign: 'right' }}>line</th>
</tr>
</thead>
<tbody>
{groups.map((g) => {
const kindStyle = EDGE_STYLE[g.edge.kind];
const kindCell = (
<div style={cellClipStyle}>
<span
style={{
display: 'inline-block',
width: 10,
height: 3,
borderTop: `${kindStyle.dashed ? '2px dashed' : '2px solid'} ${kindStyle.color}`,
marginRight: 4,
verticalAlign: 'middle',
}}
/>
<span style={{ color: '#a5b0bc' }}>{g.edge.kind}</span>
</div>
);
const peerCell = (
<div style={cellClipStyle} title={g.peerToken}>
{g.peerLabel}
</div>
);
const groupKey = `${g.edge.from}::${g.edge.kind}::${g.edge.to}`;
if (g.methodRefs.length === 0) {
return (
<tr key={groupKey} style={groupBorderStyle}>
<td style={tdStyle}>{kindCell}</td>
<td style={tdStyle}>{peerCell}</td>
<td
colSpan={2}
style={{
fontFamily: 'ui-monospace, monospace',
fontSize: 10,
color: '#a5b0bc',
padding: '1px 0',
...tdStyle,
color: '#6e7681',
fontStyle: 'italic',
}}
>
{direction === 'out' ? (
<>
<span>{r.fromMethod ?? '?'}</span>
<span style={{ color: '#6e7681' }}>{' → '}</span>
<span style={{ color: '#e6edf3' }}>{r.toMethod ?? '?'}</span>
</>
) : (
<>
<span style={{ color: '#e6edf3' }}>{r.fromMethod ?? '?'}</span>
<span style={{ color: '#6e7681' }}>{' → '}</span>
<span>{r.toMethod ?? '?'}</span>
</>
)}
<span style={{ color: '#6e7681' }}>{` (:${r.line})`}</span>
</div>
))}
</div>
</details>
)}
</div>
);
})}
×{g.edge.refs.length}
</td>
</tr>
);
}
return (
<Fragment key={groupKey}>
{g.methodRefs.map((r, i) => {
const isFirst = i === 0;
return (
<tr
key={`${groupKey}::${r.file}:${r.line}:${i}`}
style={isFirst ? groupBorderStyle : undefined}
>
{isFirst && (
<>
<td rowSpan={g.methodRefs.length} style={tdStyle}>
{kindCell}
</td>
<td rowSpan={g.methodRefs.length} style={tdStyle}>
{peerCell}
</td>
</>
)}
<td style={tdCallStyle} title={`${r.fromMethod ?? '?'}${r.toMethod ?? '?'}`}>
<span
style={{
fontWeight: selfIsFrom ? 600 : 400,
color: selfIsFrom ? '#e6edf3' : '#a5b0bc',
}}
>
{r.fromMethod ?? '?'}
</span>
<span style={{ color: '#6e7681', margin: '0 4px' }}></span>
<span
style={{
fontWeight: !selfIsFrom ? 600 : 400,
color: !selfIsFrom ? '#e6edf3' : '#a5b0bc',
}}
>
{r.toMethod ?? '?'}
</span>
</td>
<td style={tdLineStyle}>:{r.line}</td>
</tr>
);
})}
</Fragment>
);
})}
</tbody>
</table>
)}
</div>
);
}
const tableStyle: React.CSSProperties = {
width: '100%',
borderCollapse: 'collapse',
tableLayout: 'fixed',
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
fontSize: 10.5,
};
const thStyle: React.CSSProperties = {
textAlign: 'left',
fontWeight: 600,
color: '#7d8590',
fontSize: 9,
textTransform: 'uppercase',
letterSpacing: 0.5,
padding: '3px 6px',
borderBottom: '1px solid #30363d',
};
const tdStyle: React.CSSProperties = {
padding: '3px 6px',
verticalAlign: 'top',
};
const tdCallStyle: React.CSSProperties = {
...tdStyle,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
};
const tdLineStyle: React.CSSProperties = {
...tdStyle,
textAlign: 'right',
color: '#6e7681',
whiteSpace: 'nowrap',
};
const cellClipStyle: React.CSSProperties = {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
const groupBorderStyle: React.CSSProperties = {
borderTop: '1px solid #21262d',
};

View file

@ -22,4 +22,9 @@ export const SCOPE_STYLE: Record<ServiceScope, { color: string; badge: string }>
Agent: { color: '#2f8a4d', badge: 'Agt' },
};
/** Border / minimap color for scope-mismatch nodes (token registered elsewhere). */
export const SCOPE_MISMATCH_COLOR = '#f0883e';
/** Border / minimap color for unresolved nodes (token registered nowhere). */
export const UNRESOLVED_COLOR = '#f85149';
export const EDGE_KINDS: EdgeKind[] = ['ctor', 'accessor', 'publish', 'subscribe', 'emit', 'on'];

View file

@ -63,13 +63,20 @@ export interface ScopeOptions {
readonly extra?: ScopeSeed;
}
export interface IScopeHandle {
export interface IScopeHandle<K extends LifecycleScope = LifecycleScope> {
readonly id: string;
readonly kind: LifecycleScope;
readonly kind: K;
readonly accessor: ServicesAccessor;
dispose(): void;
}
/** Handle to the process-root App scope. */
export type IAppScopeHandle = IScopeHandle<LifecycleScope.App>;
/** Handle to a Session scope (child of App). */
export type ISessionScopeHandle = IScopeHandle<LifecycleScope.Session>;
/** Handle to an Agent scope (child of Session). */
export type IAgentScopeHandle = IScopeHandle<LifecycleScope.Agent>;
function buildCollection(kind: LifecycleScope, extra?: ScopeSeed): ServiceCollection {
const collection = new ServiceCollection();
for (const entry of _scopedRegistry) {

View file

@ -14,7 +14,7 @@
import type { Message, PageResponse } from '@moonshot-ai/protocol';
import { InstantiationType } from '#/_base/di/extensions';
import { type IScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import {
IAgentContextMemoryService,
@ -125,7 +125,7 @@ export class MessageLegacyService implements IMessageLegacyService {
* Returns `undefined` only when a cold session's workspace is gone and the
* session directory cannot be reconstructed (mirrors the `fork` limitation).
*/
private async resolveMainAgent(sessionId: string): Promise<IScopeHandle | undefined> {
private async resolveMainAgent(sessionId: string): Promise<IAgentScopeHandle | undefined> {
const session = await this.lifecycle.resume(sessionId);
if (session === undefined) return undefined;
const agents = session.accessor.get(IAgentLifecycleService);

View file

@ -13,7 +13,7 @@
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { IScopeHandle } from '#/_base/di/scope';
import type { ISessionScopeHandle } from '#/_base/di/scope';
import type { Event } from '#/_base/event';
export interface CreateSessionOptions {
@ -32,7 +32,7 @@ export interface ForkSessionOptions {
export interface SessionCreatedEvent {
readonly sessionId: string;
readonly handle: IScopeHandle;
readonly handle: ISessionScopeHandle;
}
export interface SessionClosedEvent {
@ -46,7 +46,7 @@ export interface SessionArchivedEvent {
export interface SessionForkedEvent {
readonly sourceSessionId: string;
readonly sessionId: string;
readonly handle: IScopeHandle;
readonly handle: ISessionScopeHandle;
}
export interface ISessionLifecycleService {
@ -55,9 +55,9 @@ export interface ISessionLifecycleService {
readonly onDidCloseSession: Event<SessionClosedEvent>;
readonly onDidArchiveSession: Event<SessionArchivedEvent>;
readonly onDidForkSession: Event<SessionForkedEvent>;
create(opts: CreateSessionOptions): Promise<IScopeHandle>;
get(sessionId: string): IScopeHandle | undefined;
list(): readonly IScopeHandle[];
create(opts: CreateSessionOptions): Promise<ISessionScopeHandle>;
get(sessionId: string): ISessionScopeHandle | undefined;
list(): readonly ISessionScopeHandle[];
/**
* Load a persisted session into the live scope tree and restore its main
* agent from the persisted wire log. Returns the existing handle when the
@ -70,10 +70,10 @@ export interface ISessionLifecycleService {
* a previous process or by v1 without requiring a prior `create` in this
* process. Restores only the main agent; sub-agents are materialized lazily.
*/
resume(sessionId: string): Promise<IScopeHandle | undefined>;
resume(sessionId: string): Promise<ISessionScopeHandle | undefined>;
close(sessionId: string): Promise<void>;
archive(sessionId: string): Promise<void>;
fork(opts: ForkSessionOptions): Promise<IScopeHandle>;
fork(opts: ForkSessionOptions): Promise<ISessionScopeHandle>;
}
export const ISessionLifecycleService: ServiceIdentifier<ISessionLifecycleService> =

View file

@ -15,7 +15,7 @@ import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
import {
createScopedChildHandle,
type IScopeHandle,
type ISessionScopeHandle,
LifecycleScope,
registerScopedService,
} from '#/_base/di/scope';
@ -55,7 +55,7 @@ import {
export class SessionLifecycleService extends Disposable implements ISessionLifecycleService {
declare readonly _serviceBrand: undefined;
private readonly sessions = new Map<string, IScopeHandle>();
private readonly sessions = new Map<string, ISessionScopeHandle>();
private readonly _onDidCreateSession = this._register(new Emitter<SessionCreatedEvent>());
readonly onDidCreateSession: Event<SessionCreatedEvent> = this._onDidCreateSession.event;
private readonly _onDidCloseSession = this._register(new Emitter<SessionClosedEvent>());
@ -67,7 +67,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
/** In-flight `resume` promises, keyed by session id de-dupes concurrent
* cold loads so a hot read path (e.g. snapshot retry) cannot materialize
* the same session twice and leak a handle. */
private readonly resuming = new Map<string, Promise<IScopeHandle | undefined>>();
private readonly resuming = new Map<string, Promise<ISessionScopeHandle | undefined>>();
constructor(
@IInstantiationService private readonly instantiation: IInstantiationService,
@ -81,7 +81,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
super();
}
async create(opts: CreateSessionOptions): Promise<IScopeHandle> {
async create(opts: CreateSessionOptions): Promise<ISessionScopeHandle> {
const workspaceId = encodeWorkDirKey(opts.workDir);
const sessionDir = join(this.bootstrap.sessionsDir, workspaceId, opts.sessionId);
// Metadata lives at `<sessionDir>/state.json` (shared with v1's layout; the
@ -112,7 +112,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
...execContextSeed(execCtx),
],
},
);
) as ISessionScopeHandle;
this.sessions.set(opts.sessionId, handle);
await handle.accessor.get(ISessionMetadata).ready;
void handle.accessor.get(ISessionSkillCatalog).load();
@ -120,11 +120,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
return handle;
}
get(sessionId: string): IScopeHandle | undefined {
get(sessionId: string): ISessionScopeHandle | undefined {
return this.sessions.get(sessionId);
}
resume(sessionId: string): Promise<IScopeHandle | undefined> {
resume(sessionId: string): Promise<ISessionScopeHandle | undefined> {
const live = this.sessions.get(sessionId);
if (live !== undefined) return Promise.resolve(live);
const inflight = this.resuming.get(sessionId);
@ -134,7 +134,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
return promise;
}
private async doResume(sessionId: string): Promise<IScopeHandle | undefined> {
private async doResume(sessionId: string): Promise<ISessionScopeHandle | undefined> {
// Re-check after the serialized entry: a prior `resume` for the same id may
// have already materialized the session while this call was queued.
const live = this.sessions.get(sessionId);
@ -158,7 +158,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
return handle;
}
list(): readonly IScopeHandle[] {
list(): readonly ISessionScopeHandle[] {
return [...this.sessions.values()];
}
@ -179,7 +179,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
this._onDidArchiveSession.fire({ sessionId });
}
async fork(opts: ForkSessionOptions): Promise<IScopeHandle> {
async fork(opts: ForkSessionOptions): Promise<ISessionScopeHandle> {
const sourceId = opts.sourceSessionId;
// 1. Resolve the source: prefer a live handle, otherwise fall back to the
@ -283,7 +283,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
* sources (flush then read) and closed sources (read the persisted log).
*/
private async copyAgentWire(args: {
readonly sourceHandle: IScopeHandle | undefined;
readonly sourceHandle: ISessionScopeHandle | undefined;
readonly sourceHomedir: string;
readonly agentId: string;
readonly targetSessionDir: string;

View file

@ -10,9 +10,8 @@
*/
import { InstantiationType } from '#/_base/di/extensions';
import { type IScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { IAuthSummaryService } from '#/app/auth';
import { IAgentContextMemoryService, toProtocolMessage, type ContextMessage } from '#/agent/contextMemory';
import { IAgentContextSizeService } from '#/agent/contextSize';
import { ErrorCodes, isKimiError, KimiError } from '#/errors';
@ -37,7 +36,6 @@ import type {
ForkSessionRequest,
SessionAbortResponse,
SessionStatusResponse,
StartBtwSessionResponse,
UndoSessionRequest,
UndoSessionResponse,
} from '@moonshot-ai/protocol';
@ -73,7 +71,6 @@ export class SessionLegacyService implements ISessionLegacyService {
@ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService,
@ISessionIndex private readonly index: ISessionIndex,
@IWorkspaceRegistry private readonly workspaceRegistry: IWorkspaceRegistry,
@IAuthSummaryService private readonly auth: IAuthSummaryService,
) {}
async fork(sessionId: string, body: ForkSessionRequest): Promise<SessionWireFields> {
@ -222,16 +219,6 @@ export class SessionLegacyService implements ISessionLegacyService {
return { aborted: true };
}
async startBtw(sessionId: string): Promise<StartBtwSessionResponse> {
if (this.lifecycle.get(sessionId) === undefined) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
await this.auth.ensureReady();
const agent = await this.resolveMainAgent(sessionId);
const agentId = await agent.accessor.get(IAgentRPCService).startBtw({});
return { agent_id: agentId };
}
async archive(sessionId: string): Promise<ArchiveSessionResponse> {
// Native `ISessionLifecycleService.archive` is a no-op for sessions that
// are not live, so gate on the live handle (matches the previous route
@ -278,7 +265,7 @@ export class SessionLegacyService implements ISessionLegacyService {
* Resolve the session's main agent, creating it on demand (mirrors v1's
* `resumeSession` + the server-v2 `ensureMainAgent` helper).
*/
private async resolveMainAgent(sessionId: string): Promise<IScopeHandle> {
private async resolveMainAgent(sessionId: string): Promise<IAgentScopeHandle> {
const session = this.lifecycle.get(sessionId);
if (session === undefined) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
@ -289,7 +276,7 @@ export class SessionLegacyService implements ISessionLegacyService {
return agents.createMain();
}
private async assembleStatus(sessionId: string, agent: IScopeHandle): Promise<SessionStatusResponse> {
private async assembleStatus(sessionId: string, agent: IAgentScopeHandle): Promise<SessionStatusResponse> {
const session = this.lifecycle.get(sessionId);
const profile = agent.accessor.get(IAgentProfileService);
const contextSize = agent.accessor.get(IAgentContextSizeService);

View file

@ -8,7 +8,7 @@
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { IScopeHandle } from '#/_base/di/scope';
import type { IAgentScopeHandle } from '#/_base/di/scope';
import type { Event } from '#/_base/event';
export interface CreateAgentOptions {
@ -22,15 +22,15 @@ export interface CreateAgentOptions {
export interface IAgentLifecycleService {
readonly _serviceBrand: undefined;
/** Fires after an agent is created and registered, with its scope handle. */
readonly onDidCreate: Event<IScopeHandle>;
readonly onDidCreate: Event<IAgentScopeHandle>;
/** Fires after an agent is removed, with its agent id. */
readonly onDidDispose: Event<string>;
create(opts: CreateAgentOptions): Promise<IScopeHandle>;
createMain(): Promise<IScopeHandle>;
create(opts: CreateAgentOptions): Promise<IAgentScopeHandle>;
createMain(): Promise<IAgentScopeHandle>;
/** Create a child agent that inherits the parent's profile and context history. */
fork(parentAgentId: string): Promise<IScopeHandle>;
getHandle(agentId: string): IScopeHandle | undefined;
list(): readonly IScopeHandle[];
fork(parentAgentId: string): Promise<IAgentScopeHandle>;
getHandle(agentId: string): IAgentScopeHandle | undefined;
list(): readonly IAgentScopeHandle[];
remove(agentId: string): Promise<void>;
}

View file

@ -15,7 +15,7 @@ import { Emitter } from '#/_base/event';
import { SyncDescriptor } from '#/_base/di/descriptors';
import {
createScopedChildHandle,
type IScopeHandle,
type IAgentScopeHandle,
LifecycleScope,
registerScopedService,
} from '#/_base/di/scope';
@ -48,8 +48,8 @@ let nextAgentId = 0;
export class AgentLifecycleService extends Disposable implements IAgentLifecycleService {
declare readonly _serviceBrand: undefined;
private readonly handles = new Map<string, IScopeHandle>();
private readonly onDidCreateEmitter = this._register(new Emitter<IScopeHandle>());
private readonly handles = new Map<string, IAgentScopeHandle>();
private readonly onDidCreateEmitter = this._register(new Emitter<IAgentScopeHandle>());
private readonly onDidDisposeEmitter = this._register(new Emitter<string>());
private mcpManager: McpConnectionManager | undefined;
@ -72,7 +72,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
super();
}
async create(opts: CreateAgentOptions): Promise<IScopeHandle> {
async create(opts: CreateAgentOptions): Promise<IAgentScopeHandle> {
const agentId = opts.agentId ?? `agent-${nextAgentId++}`;
// Per-agent homedir → the wire-record persistence key (`hashKey(homedir)`).
// Co-located under the session dir, mirroring v1's `<sessionDir>/agents/<id>`.
@ -95,7 +95,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
[IAgentExternalHooksService, new SyncDescriptor(AgentExternalHooksService, [{}], true)],
],
},
);
) as IAgentScopeHandle;
this.handles.set(agentId, handle);
// Record the agent in the session registry so a closed-session fork can
// enumerate every agent and relocate its wire log.
@ -114,7 +114,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
return handle;
}
async createMain(): Promise<IScopeHandle> {
async createMain(): Promise<IAgentScopeHandle> {
const handle = await this.create({ agentId: 'main' });
// Force-instantiate the plugin session-start injector so it registers its
// turn-cadence injection before the first turn. Main-agent only, matching
@ -123,7 +123,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
return handle;
}
async fork(parentAgentId: string): Promise<IScopeHandle> {
async fork(parentAgentId: string): Promise<IAgentScopeHandle> {
const parent =
this.handles.get(parentAgentId) ??
(parentAgentId === 'main' ? await this.createMain() : undefined);
@ -173,11 +173,11 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
await manager.connectAll(servers);
}
getHandle(agentId: string): IScopeHandle | undefined {
getHandle(agentId: string): IAgentScopeHandle | undefined {
return this.handles.get(agentId);
}
list(): readonly IScopeHandle[] {
list(): readonly IAgentScopeHandle[] {
return [...this.handles.values()];
}

View file

@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation';
import { DisposableStore } from '#/_base/di/lifecycle';
import { type IScopeHandle, LifecycleScope } from '#/_base/di/scope';
import { type IAgentScopeHandle, type ISessionScopeHandle, LifecycleScope } from '#/_base/di/scope';
import { TestInstantiationService } from '#/_base/di/test';
import { IAgentLifecycleService } from '#/session/agent-lifecycle/agentLifecycle';
import type { ContextMessage } from '#/agent/contextMemory';
@ -59,7 +59,7 @@ describe('RestGateway', () => {
clear: () => {},
};
const agentHandle: IScopeHandle = {
const agentHandle: IAgentScopeHandle = {
id: 'main',
kind: LifecycleScope.Agent,
accessor: makeAccessor([
@ -79,7 +79,7 @@ describe('RestGateway', () => {
list: () => [agentHandle],
remove: () => Promise.resolve(),
};
const sessionHandle: IScopeHandle = {
const sessionHandle: ISessionScopeHandle = {
id: 's1',
kind: LifecycleScope.Session,
accessor: makeAccessor([[IAgentLifecycleService, agents]]),

View file

@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation';
import { DisposableStore } from '#/_base/di/lifecycle';
import { type IScopeHandle, LifecycleScope } from '#/_base/di/scope';
import { type IAgentScopeHandle, LifecycleScope } from '#/_base/di/scope';
import { TestInstantiationService } from '#/_base/di/test';
import { IAgentLifecycleService } from '#/session/agent-lifecycle/agentLifecycle';
import { ISessionInteractionService, type Interaction, type InteractionKind } from '#/session/interaction';
@ -41,7 +41,7 @@ function makeAccessor(turn: IAgentTurnService): ServicesAccessor {
};
}
function handle(id: string, active: boolean): IScopeHandle {
function handle(id: string, active: boolean): IAgentScopeHandle {
const turn = makeTurnService(active);
return {
id,
@ -51,7 +51,7 @@ function handle(id: string, active: boolean): IScopeHandle {
};
}
function lifecycle(handles: readonly IScopeHandle[]): IAgentLifecycleService {
function lifecycle(handles: readonly IAgentScopeHandle[]): IAgentLifecycleService {
return {
_serviceBrand: undefined,
onDidCreate: () => ({ dispose: () => {} }),

View file

@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { ServicesAccessor } from '#/_base/di/instantiation';
import { DisposableStore } from '#/_base/di/lifecycle';
import { type IScopeHandle, LifecycleScope } from '#/_base/di/scope';
import { type IAgentScopeHandle, LifecycleScope } from '#/_base/di/scope';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { IAgentLifecycleService } from '#/session/agent-lifecycle/agentLifecycle';
import { IEventService } from '#/app/event';
@ -11,7 +11,7 @@ import { SessionService } from '#/session/session/sessionService';
import { ISessionContext } from '#/session/session-context';
import { ISessionMetadata } from '#/session/session-metadata';
const handle: IScopeHandle = {
const handle: IAgentScopeHandle = {
id: 'main',
kind: LifecycleScope.Agent,
accessor: { get: () => ({}) } as unknown as ServicesAccessor,

View file

@ -52,7 +52,7 @@ import {
ISessionLifecycleService,
ISessionSkillCatalog,
isKimiError,
type IScopeHandle,
type ISessionScopeHandle,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import {
@ -98,7 +98,7 @@ const skillTailParamsSchema = z.object({
});
type ResolvedSession =
| { readonly handle: IScopeHandle }
| { readonly handle: ISessionScopeHandle }
| { readonly envelope: ReturnType<typeof errEnvelope> };
/**

View file

@ -7,7 +7,11 @@
* missing main agent is created instead of reported as `agent.not_found`.
*/
import { IAgentLifecycleService, type IScopeHandle } from '@moonshot-ai/agent-core-v2';
import {
IAgentLifecycleService,
type IAgentScopeHandle,
type ISessionScopeHandle,
} from '@moonshot-ai/agent-core-v2';
export const MAIN_AGENT_ID = 'main';
@ -15,7 +19,7 @@ export const MAIN_AGENT_ID = 'main';
* Return the session's main agent, creating it on demand when it does not
* exist yet.
*/
export async function ensureMainAgent(session: IScopeHandle): Promise<IScopeHandle> {
export async function ensureMainAgent(session: ISessionScopeHandle): Promise<IAgentScopeHandle> {
const agents = session.accessor.get(IAgentLifecycleService);
const existing = agents.getHandle(MAIN_AGENT_ID);
if (existing !== undefined) return existing;