@animalabs/agent-framework 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/src/agent.d.ts +130 -0
- package/dist/src/agent.d.ts.map +1 -0
- package/dist/src/agent.js +406 -0
- package/dist/src/agent.js.map +1 -0
- package/dist/src/api/mcp-server.d.ts +37 -0
- package/dist/src/api/mcp-server.d.ts.map +1 -0
- package/dist/src/api/mcp-server.js +598 -0
- package/dist/src/api/mcp-server.js.map +1 -0
- package/dist/src/api/server.d.ts +83 -0
- package/dist/src/api/server.d.ts.map +1 -0
- package/dist/src/api/server.js +785 -0
- package/dist/src/api/server.js.map +1 -0
- package/dist/src/api/types.d.ts +395 -0
- package/dist/src/api/types.d.ts.map +1 -0
- package/dist/src/api/types.js +5 -0
- package/dist/src/api/types.js.map +1 -0
- package/dist/src/framework.d.ts +309 -0
- package/dist/src/framework.d.ts.map +1 -0
- package/dist/src/framework.js +2309 -0
- package/dist/src/framework.js.map +1 -0
- package/dist/src/gate/event-gate.d.ts +75 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -0
- package/dist/src/gate/event-gate.js +503 -0
- package/dist/src/gate/event-gate.js.map +1 -0
- package/dist/src/gate/index.d.ts +3 -0
- package/dist/src/gate/index.d.ts.map +1 -0
- package/dist/src/gate/index.js +2 -0
- package/dist/src/gate/index.js.map +1 -0
- package/dist/src/gate/types.d.ts +82 -0
- package/dist/src/gate/types.d.ts.map +1 -0
- package/dist/src/gate/types.js +9 -0
- package/dist/src/gate/types.js.map +1 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +18 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/mcpl/channel-registry.d.ts +141 -0
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -0
- package/dist/src/mcpl/channel-registry.js +647 -0
- package/dist/src/mcpl/channel-registry.js.map +1 -0
- package/dist/src/mcpl/checkpoint-manager.d.ts +79 -0
- package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -0
- package/dist/src/mcpl/checkpoint-manager.js +430 -0
- package/dist/src/mcpl/checkpoint-manager.js.map +1 -0
- package/dist/src/mcpl/errors.d.ts +52 -0
- package/dist/src/mcpl/errors.d.ts.map +1 -0
- package/dist/src/mcpl/errors.js +93 -0
- package/dist/src/mcpl/errors.js.map +1 -0
- package/dist/src/mcpl/feature-set-manager.d.ts +91 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -0
- package/dist/src/mcpl/feature-set-manager.js +250 -0
- package/dist/src/mcpl/feature-set-manager.js.map +1 -0
- package/dist/src/mcpl/hook-orchestrator.d.ts +51 -0
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -0
- package/dist/src/mcpl/hook-orchestrator.js +217 -0
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -0
- package/dist/src/mcpl/index.d.ts +14 -0
- package/dist/src/mcpl/index.d.ts.map +1 -0
- package/dist/src/mcpl/index.js +21 -0
- package/dist/src/mcpl/index.js.map +1 -0
- package/dist/src/mcpl/inference-router.d.ts +67 -0
- package/dist/src/mcpl/inference-router.d.ts.map +1 -0
- package/dist/src/mcpl/inference-router.js +249 -0
- package/dist/src/mcpl/inference-router.js.map +1 -0
- package/dist/src/mcpl/push-handler.d.ts +60 -0
- package/dist/src/mcpl/push-handler.d.ts.map +1 -0
- package/dist/src/mcpl/push-handler.js +162 -0
- package/dist/src/mcpl/push-handler.js.map +1 -0
- package/dist/src/mcpl/scope-manager.d.ts +76 -0
- package/dist/src/mcpl/scope-manager.d.ts.map +1 -0
- package/dist/src/mcpl/scope-manager.js +213 -0
- package/dist/src/mcpl/scope-manager.js.map +1 -0
- package/dist/src/mcpl/server-connection.d.ts +174 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -0
- package/dist/src/mcpl/server-connection.js +559 -0
- package/dist/src/mcpl/server-connection.js.map +1 -0
- package/dist/src/mcpl/server-registry.d.ts +63 -0
- package/dist/src/mcpl/server-registry.d.ts.map +1 -0
- package/dist/src/mcpl/server-registry.js +119 -0
- package/dist/src/mcpl/server-registry.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +755 -0
- package/dist/src/mcpl/types.d.ts.map +1 -0
- package/dist/src/mcpl/types.js +48 -0
- package/dist/src/mcpl/types.js.map +1 -0
- package/dist/src/module-registry.d.ts +93 -0
- package/dist/src/module-registry.d.ts.map +1 -0
- package/dist/src/module-registry.js +386 -0
- package/dist/src/module-registry.js.map +1 -0
- package/dist/src/modules/api/index.d.ts +22 -0
- package/dist/src/modules/api/index.d.ts.map +1 -0
- package/dist/src/modules/api/index.js +63 -0
- package/dist/src/modules/api/index.js.map +1 -0
- package/dist/src/modules/discord/discord-js-client.d.ts +58 -0
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -0
- package/dist/src/modules/discord/discord-js-client.js +273 -0
- package/dist/src/modules/discord/discord-js-client.js.map +1 -0
- package/dist/src/modules/discord/index.d.ts +82 -0
- package/dist/src/modules/discord/index.d.ts.map +1 -0
- package/dist/src/modules/discord/index.js +959 -0
- package/dist/src/modules/discord/index.js.map +1 -0
- package/dist/src/modules/discord/types.d.ts +327 -0
- package/dist/src/modules/discord/types.d.ts.map +1 -0
- package/dist/src/modules/discord/types.js +5 -0
- package/dist/src/modules/discord/types.js.map +1 -0
- package/dist/src/modules/index.d.ts +11 -0
- package/dist/src/modules/index.d.ts.map +1 -0
- package/dist/src/modules/index.js +7 -0
- package/dist/src/modules/index.js.map +1 -0
- package/dist/src/modules/workspace/index.d.ts +59 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -0
- package/dist/src/modules/workspace/index.js +831 -0
- package/dist/src/modules/workspace/index.js.map +1 -0
- package/dist/src/modules/workspace/sync.d.ts +48 -0
- package/dist/src/modules/workspace/sync.d.ts.map +1 -0
- package/dist/src/modules/workspace/sync.js +253 -0
- package/dist/src/modules/workspace/sync.js.map +1 -0
- package/dist/src/modules/workspace/types.d.ts +166 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -0
- package/dist/src/modules/workspace/types.js +5 -0
- package/dist/src/modules/workspace/types.js.map +1 -0
- package/dist/src/modules/workspace/watcher.d.ts +43 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -0
- package/dist/src/modules/workspace/watcher.js +120 -0
- package/dist/src/modules/workspace/watcher.js.map +1 -0
- package/dist/src/queue.d.ts +50 -0
- package/dist/src/queue.d.ts.map +1 -0
- package/dist/src/queue.js +95 -0
- package/dist/src/queue.js.map +1 -0
- package/dist/src/safe-slice.d.ts +8 -0
- package/dist/src/safe-slice.d.ts.map +1 -0
- package/dist/src/safe-slice.js +18 -0
- package/dist/src/safe-slice.js.map +1 -0
- package/dist/src/types/agent.d.ts +118 -0
- package/dist/src/types/agent.d.ts.map +1 -0
- package/dist/src/types/agent.js +2 -0
- package/dist/src/types/agent.js.map +1 -0
- package/dist/src/types/events.d.ts +220 -0
- package/dist/src/types/events.d.ts.map +1 -0
- package/dist/src/types/events.js +2 -0
- package/dist/src/types/events.js.map +1 -0
- package/dist/src/types/framework.d.ts +184 -0
- package/dist/src/types/framework.d.ts.map +1 -0
- package/dist/src/types/framework.js +2 -0
- package/dist/src/types/framework.js.map +1 -0
- package/dist/src/types/index.d.ts +7 -0
- package/dist/src/types/index.d.ts.map +1 -0
- package/dist/src/types/index.js +7 -0
- package/dist/src/types/index.js.map +1 -0
- package/dist/src/types/inference-log.d.ts +64 -0
- package/dist/src/types/inference-log.d.ts.map +1 -0
- package/dist/src/types/inference-log.js +5 -0
- package/dist/src/types/inference-log.js.map +1 -0
- package/dist/src/types/module.d.ts +302 -0
- package/dist/src/types/module.d.ts.map +1 -0
- package/dist/src/types/module.js +2 -0
- package/dist/src/types/module.js.map +1 -0
- package/dist/src/types/trace.d.ts +173 -0
- package/dist/src/types/trace.d.ts.map +1 -0
- package/dist/src/types/trace.js +2 -0
- package/dist/src/types/trace.js.map +1 -0
- package/dist/test/event-gate.test.d.ts +2 -0
- package/dist/test/event-gate.test.d.ts.map +1 -0
- package/dist/test/event-gate.test.js +535 -0
- package/dist/test/event-gate.test.js.map +1 -0
- package/dist/test/framework.test.d.ts +5 -0
- package/dist/test/framework.test.d.ts.map +1 -0
- package/dist/test/framework.test.js +738 -0
- package/dist/test/framework.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +58 -0
- package/src/agent.ts +512 -0
- package/src/api/mcp-server.ts +687 -0
- package/src/api/server.ts +1023 -0
- package/src/api/types.ts +542 -0
- package/src/framework.ts +2784 -0
- package/src/gate/event-gate.ts +623 -0
- package/src/gate/index.ts +12 -0
- package/src/gate/types.ts +112 -0
- package/src/index.ts +63 -0
- package/src/mcpl/channel-registry.ts +824 -0
- package/src/mcpl/checkpoint-manager.ts +545 -0
- package/src/mcpl/errors.ts +114 -0
- package/src/mcpl/feature-set-manager.ts +333 -0
- package/src/mcpl/hook-orchestrator.ts +281 -0
- package/src/mcpl/index.ts +134 -0
- package/src/mcpl/inference-router.ts +335 -0
- package/src/mcpl/push-handler.ts +232 -0
- package/src/mcpl/scope-manager.ts +262 -0
- package/src/mcpl/server-connection.ts +694 -0
- package/src/mcpl/server-registry.ts +144 -0
- package/src/mcpl/types.ts +991 -0
- package/src/module-registry.ts +547 -0
- package/src/modules/api/index.ts +94 -0
- package/src/modules/discord/discord-js-client.ts +353 -0
- package/src/modules/discord/index.ts +1146 -0
- package/src/modules/discord/types.ts +465 -0
- package/src/modules/index.ts +43 -0
- package/src/modules/workspace/index.ts +969 -0
- package/src/modules/workspace/sync.ts +314 -0
- package/src/modules/workspace/types.ts +202 -0
- package/src/modules/workspace/watcher.ts +142 -0
- package/src/queue.ts +106 -0
- package/src/safe-slice.ts +18 -0
- package/src/types/agent.ts +119 -0
- package/src/types/events.ts +252 -0
- package/src/types/framework.ts +211 -0
- package/src/types/index.ts +6 -0
- package/src/types/inference-log.ts +64 -0
- package/src/types/module.ts +367 -0
- package/src/types/trace.ts +196 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CheckpointManager — MCPL state management with branching checkpoint trees.
|
|
3
|
+
*
|
|
4
|
+
* Manages per-(serverId, featureSet) checkpoint trees for stateful MCPL tools.
|
|
5
|
+
* Two modes:
|
|
6
|
+
* - hostState: true → host stores state data, applies JSON Patch deltas
|
|
7
|
+
* - hostState: false → host tracks opaque checkpoint IDs only
|
|
8
|
+
*
|
|
9
|
+
* Checkpoint tree metadata is persisted to Chronicle via a `mcpl/checkpoints`
|
|
10
|
+
* state slot. Host-managed state data is persisted per feature set.
|
|
11
|
+
*
|
|
12
|
+
* Spec reference: Section 8 (State Management).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { JsStore } from '@animalabs/chronicle';
|
|
16
|
+
|
|
17
|
+
import type { StateCheckpoint, JsonPatchOperation } from './types.js';
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Constants
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/** Chronicle state ID for checkpoint tree metadata. */
|
|
24
|
+
const CHECKPOINTS_STATE_ID = 'mcpl/checkpoints';
|
|
25
|
+
|
|
26
|
+
/** Prefix for per-feature-set host-managed state. */
|
|
27
|
+
const STATE_PREFIX = 'mcpl/state';
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Internal types
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
interface CheckpointNode {
|
|
34
|
+
checkpoint: string;
|
|
35
|
+
parent: string | null;
|
|
36
|
+
children: string[];
|
|
37
|
+
/** Full state data snapshot (host-managed, when server sends `data`). */
|
|
38
|
+
data?: unknown;
|
|
39
|
+
/** JSON Patch delta from parent (host-managed, when server sends `patch`). */
|
|
40
|
+
patch?: JsonPatchOperation[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface FeatureSetState {
|
|
44
|
+
hostState: boolean;
|
|
45
|
+
rollback: boolean;
|
|
46
|
+
currentCheckpoint: string | null;
|
|
47
|
+
/** Reconstructed state at currentCheckpoint (host-managed only). */
|
|
48
|
+
currentState: unknown | undefined;
|
|
49
|
+
nodes: Map<string, CheckpointNode>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Serialized form stored in Chronicle. */
|
|
53
|
+
interface SerializedTrees {
|
|
54
|
+
trees: Record<string, {
|
|
55
|
+
hostState: boolean;
|
|
56
|
+
rollback: boolean;
|
|
57
|
+
current: string | null;
|
|
58
|
+
nodes: Record<string, { parent: string | null; children: string[]; data?: unknown; patch?: JsonPatchOperation[] }>;
|
|
59
|
+
}>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// JSON Patch (RFC 6902) — minimal implementation
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a JSON Pointer (RFC 6901) into path segments.
|
|
68
|
+
* E.g. "/foo/0/bar" → ["foo", "0", "bar"]
|
|
69
|
+
*/
|
|
70
|
+
function parsePointer(pointer: string): string[] {
|
|
71
|
+
if (pointer === '') return [];
|
|
72
|
+
if (!pointer.startsWith('/')) {
|
|
73
|
+
throw new Error(`Invalid JSON Pointer: ${pointer}`);
|
|
74
|
+
}
|
|
75
|
+
return pointer.slice(1).split('/').map(
|
|
76
|
+
(s) => s.replace(/~1/g, '/').replace(/~0/g, '~'),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Navigate to a parent container and return [parent, lastKey].
|
|
82
|
+
* Throws if the path doesn't exist.
|
|
83
|
+
*/
|
|
84
|
+
function navigateTo(
|
|
85
|
+
doc: unknown,
|
|
86
|
+
segments: string[],
|
|
87
|
+
): [parent: Record<string, unknown> | unknown[], key: string] {
|
|
88
|
+
let current: unknown = doc;
|
|
89
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
90
|
+
const seg = segments[i]!;
|
|
91
|
+
if (Array.isArray(current)) {
|
|
92
|
+
current = current[Number(seg)];
|
|
93
|
+
} else if (current !== null && typeof current === 'object') {
|
|
94
|
+
current = (current as Record<string, unknown>)[seg];
|
|
95
|
+
} else {
|
|
96
|
+
throw new Error(`Cannot navigate path segment "${seg}": not an object/array`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return [current as Record<string, unknown> | unknown[], segments[segments.length - 1]!];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Apply a JSON Patch (RFC 6902) to a document.
|
|
104
|
+
* Supports: add, remove, replace, test.
|
|
105
|
+
* Operates on a deep-cloned document to avoid mutation.
|
|
106
|
+
*/
|
|
107
|
+
export function applyJsonPatch(doc: unknown, ops: JsonPatchOperation[]): unknown {
|
|
108
|
+
let result: unknown = structuredClone(doc);
|
|
109
|
+
|
|
110
|
+
for (const op of ops) {
|
|
111
|
+
const segments = parsePointer(op.path);
|
|
112
|
+
|
|
113
|
+
switch (op.op) {
|
|
114
|
+
case 'add': {
|
|
115
|
+
if (segments.length === 0) {
|
|
116
|
+
result = op.value;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
const [parent, key] = navigateTo(result, segments);
|
|
120
|
+
if (Array.isArray(parent)) {
|
|
121
|
+
const idx = key === '-' ? parent.length : Number(key);
|
|
122
|
+
parent.splice(idx, 0, op.value);
|
|
123
|
+
} else {
|
|
124
|
+
(parent as Record<string, unknown>)[key] = op.value;
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case 'remove': {
|
|
130
|
+
const [parent, key] = navigateTo(result, segments);
|
|
131
|
+
if (Array.isArray(parent)) {
|
|
132
|
+
parent.splice(Number(key), 1);
|
|
133
|
+
} else {
|
|
134
|
+
delete (parent as Record<string, unknown>)[key];
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
case 'replace': {
|
|
140
|
+
if (segments.length === 0) {
|
|
141
|
+
result = op.value;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
const [parent, key] = navigateTo(result, segments);
|
|
145
|
+
if (Array.isArray(parent)) {
|
|
146
|
+
parent[Number(key)] = op.value;
|
|
147
|
+
} else {
|
|
148
|
+
(parent as Record<string, unknown>)[key] = op.value;
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
case 'test': {
|
|
154
|
+
const [parent, key] = navigateTo(result, segments);
|
|
155
|
+
const actual = Array.isArray(parent)
|
|
156
|
+
? parent[Number(key)]
|
|
157
|
+
: (parent as Record<string, unknown>)[key];
|
|
158
|
+
if (JSON.stringify(actual) !== JSON.stringify(op.value)) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`JSON Patch test failed at "${op.path}": expected ${JSON.stringify(op.value)}, got ${JSON.stringify(actual)}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case 'move':
|
|
167
|
+
case 'copy':
|
|
168
|
+
// Deferred — not needed for MVP
|
|
169
|
+
throw new Error(`JSON Patch op "${op.op}" not yet supported`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ============================================================================
|
|
177
|
+
// CheckpointManager
|
|
178
|
+
// ============================================================================
|
|
179
|
+
|
|
180
|
+
export class CheckpointManager {
|
|
181
|
+
private store: JsStore;
|
|
182
|
+
private emitTraceFn: (event: { type: string; [key: string]: unknown }) => void;
|
|
183
|
+
private trees = new Map<string, FeatureSetState>();
|
|
184
|
+
|
|
185
|
+
constructor(
|
|
186
|
+
store: JsStore,
|
|
187
|
+
emitTraceFn: (event: { type: string; [key: string]: unknown }) => void,
|
|
188
|
+
) {
|
|
189
|
+
this.store = store;
|
|
190
|
+
this.emitTraceFn = emitTraceFn;
|
|
191
|
+
|
|
192
|
+
// Register the checkpoint metadata state slot
|
|
193
|
+
try {
|
|
194
|
+
store.registerState({ id: CHECKPOINTS_STATE_ID, strategy: 'snapshot' });
|
|
195
|
+
} catch {
|
|
196
|
+
// Already registered (e.g. framework restart)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Load existing tree from Chronicle
|
|
200
|
+
this.loadFromStore();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ==========================================================================
|
|
204
|
+
// Registration
|
|
205
|
+
// ==========================================================================
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Register a feature set as stateful.
|
|
209
|
+
* Called during initializeMcpl for feature sets with rollback or hostState.
|
|
210
|
+
*/
|
|
211
|
+
registerFeatureSet(
|
|
212
|
+
serverId: string,
|
|
213
|
+
featureSet: string,
|
|
214
|
+
opts: { hostState: boolean; rollback: boolean },
|
|
215
|
+
): void {
|
|
216
|
+
const key = this.key(serverId, featureSet);
|
|
217
|
+
if (this.trees.has(key)) return; // Already registered (e.g. from loaded state)
|
|
218
|
+
|
|
219
|
+
this.trees.set(key, {
|
|
220
|
+
hostState: opts.hostState,
|
|
221
|
+
rollback: opts.rollback,
|
|
222
|
+
currentCheckpoint: null,
|
|
223
|
+
currentState: undefined,
|
|
224
|
+
nodes: new Map(),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Register per-feature-set state slot for host-managed state
|
|
228
|
+
if (opts.hostState) {
|
|
229
|
+
const stateId = this.stateId(serverId, featureSet);
|
|
230
|
+
try {
|
|
231
|
+
this.store.registerState({ id: stateId, strategy: 'snapshot' });
|
|
232
|
+
} catch {
|
|
233
|
+
// Already registered
|
|
234
|
+
}
|
|
235
|
+
// Load existing state if any
|
|
236
|
+
const existing = this.store.getStateJson(stateId);
|
|
237
|
+
if (existing !== null && existing !== undefined) {
|
|
238
|
+
const tree = this.trees.get(key)!;
|
|
239
|
+
tree.currentState = existing;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
this.emitTraceFn({
|
|
244
|
+
type: 'mcpl:state_registered',
|
|
245
|
+
serverId,
|
|
246
|
+
featureSet,
|
|
247
|
+
hostState: opts.hostState,
|
|
248
|
+
rollback: opts.rollback,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
this.persistTree();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ==========================================================================
|
|
255
|
+
// Checkpoint recording
|
|
256
|
+
// ==========================================================================
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Record a checkpoint returned in a tool call response.
|
|
260
|
+
* Updates the tree and persists to Chronicle.
|
|
261
|
+
*/
|
|
262
|
+
recordCheckpoint(
|
|
263
|
+
serverId: string,
|
|
264
|
+
featureSet: string,
|
|
265
|
+
checkpoint: StateCheckpoint,
|
|
266
|
+
): void {
|
|
267
|
+
const key = this.key(serverId, featureSet);
|
|
268
|
+
const tree = this.trees.get(key);
|
|
269
|
+
if (!tree) return;
|
|
270
|
+
|
|
271
|
+
// Add node to tree
|
|
272
|
+
const node: CheckpointNode = {
|
|
273
|
+
checkpoint: checkpoint.checkpoint,
|
|
274
|
+
parent: checkpoint.parent,
|
|
275
|
+
children: [],
|
|
276
|
+
data: checkpoint.data,
|
|
277
|
+
patch: checkpoint.patch,
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// Link to parent
|
|
281
|
+
if (checkpoint.parent) {
|
|
282
|
+
const parentNode = tree.nodes.get(checkpoint.parent);
|
|
283
|
+
if (parentNode) {
|
|
284
|
+
parentNode.children.push(checkpoint.checkpoint);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
tree.nodes.set(checkpoint.checkpoint, node);
|
|
289
|
+
tree.currentCheckpoint = checkpoint.checkpoint;
|
|
290
|
+
|
|
291
|
+
// For host-managed state, reconstruct current state
|
|
292
|
+
if (tree.hostState) {
|
|
293
|
+
if (checkpoint.data !== undefined) {
|
|
294
|
+
// Full state provided — use directly
|
|
295
|
+
tree.currentState = structuredClone(checkpoint.data);
|
|
296
|
+
} else if (checkpoint.patch) {
|
|
297
|
+
// Delta provided — apply to current state
|
|
298
|
+
try {
|
|
299
|
+
tree.currentState = applyJsonPatch(
|
|
300
|
+
tree.currentState ?? {},
|
|
301
|
+
checkpoint.patch,
|
|
302
|
+
);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
console.error(
|
|
305
|
+
`[CheckpointManager] JSON Patch failed for ${key}:`,
|
|
306
|
+
err instanceof Error ? err.message : err,
|
|
307
|
+
);
|
|
308
|
+
// Keep existing state on patch failure
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Persist reconstructed state
|
|
313
|
+
this.store.setStateJson(this.stateId(serverId, featureSet), tree.currentState);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
this.emitTraceFn({
|
|
317
|
+
type: 'mcpl:checkpoint_recorded',
|
|
318
|
+
serverId,
|
|
319
|
+
featureSet,
|
|
320
|
+
checkpoint: checkpoint.checkpoint,
|
|
321
|
+
parent: checkpoint.parent,
|
|
322
|
+
hasData: checkpoint.data !== undefined,
|
|
323
|
+
hasPatch: checkpoint.patch !== undefined,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
this.persistTree();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ==========================================================================
|
|
330
|
+
// State access (for tools/call params)
|
|
331
|
+
// ==========================================================================
|
|
332
|
+
|
|
333
|
+
/** Get current reconstructed state for host-managed feature sets. */
|
|
334
|
+
getCurrentState(serverId: string, featureSet: string): unknown | undefined {
|
|
335
|
+
return this.trees.get(this.key(serverId, featureSet))?.currentState;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Get current checkpoint ID for server-managed feature sets. */
|
|
339
|
+
getCurrentCheckpoint(serverId: string, featureSet: string): string | null {
|
|
340
|
+
return this.trees.get(this.key(serverId, featureSet))?.currentCheckpoint ?? null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ==========================================================================
|
|
344
|
+
// Queries
|
|
345
|
+
// ==========================================================================
|
|
346
|
+
|
|
347
|
+
/** Check if a (serverId, featureSet) pair is registered as stateful. */
|
|
348
|
+
isStateful(serverId: string, featureSet: string): boolean {
|
|
349
|
+
return this.trees.has(this.key(serverId, featureSet));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Check if a (serverId, featureSet) pair uses host-managed state. */
|
|
353
|
+
isHostManaged(serverId: string, featureSet: string): boolean {
|
|
354
|
+
return this.trees.get(this.key(serverId, featureSet))?.hostState ?? false;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Get the first stateful feature set for a given server.
|
|
359
|
+
* Returns null if no stateful feature sets are registered.
|
|
360
|
+
*/
|
|
361
|
+
getStatefulFeatureSet(serverId: string): string | null {
|
|
362
|
+
const prefix = `${serverId}:`;
|
|
363
|
+
for (const [key] of this.trees) {
|
|
364
|
+
if (key.startsWith(prefix)) {
|
|
365
|
+
return key.slice(prefix.length);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ==========================================================================
|
|
372
|
+
// Rollback
|
|
373
|
+
// ==========================================================================
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Roll back to a previous checkpoint in the tree.
|
|
377
|
+
* Updates the current pointer and, for host-managed state, reconstructs
|
|
378
|
+
* the state at that checkpoint.
|
|
379
|
+
*
|
|
380
|
+
* Returns the reconstructed state (host-managed) or undefined (server-managed).
|
|
381
|
+
* Throws if the checkpoint is not found.
|
|
382
|
+
*/
|
|
383
|
+
rollbackTo(
|
|
384
|
+
serverId: string,
|
|
385
|
+
featureSet: string,
|
|
386
|
+
checkpoint: string,
|
|
387
|
+
): unknown | undefined {
|
|
388
|
+
const key = this.key(serverId, featureSet);
|
|
389
|
+
const tree = this.trees.get(key);
|
|
390
|
+
if (!tree) throw new Error(`No stateful feature set: ${key}`);
|
|
391
|
+
|
|
392
|
+
const node = tree.nodes.get(checkpoint);
|
|
393
|
+
if (!node) throw new Error(`Checkpoint not found: ${checkpoint}`);
|
|
394
|
+
|
|
395
|
+
tree.currentCheckpoint = checkpoint;
|
|
396
|
+
|
|
397
|
+
if (tree.hostState) {
|
|
398
|
+
// Reconstruct state at this checkpoint by walking from root
|
|
399
|
+
tree.currentState = this.reconstructState(tree, checkpoint);
|
|
400
|
+
this.store.setStateJson(this.stateId(serverId, featureSet), tree.currentState);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
this.emitTraceFn({
|
|
404
|
+
type: 'mcpl:state_rollback',
|
|
405
|
+
serverId,
|
|
406
|
+
featureSet,
|
|
407
|
+
checkpoint,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
this.persistTree();
|
|
411
|
+
return tree.currentState;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ==========================================================================
|
|
415
|
+
// Cleanup
|
|
416
|
+
// ==========================================================================
|
|
417
|
+
|
|
418
|
+
/** Remove all state for a server (called on disconnect). */
|
|
419
|
+
removeServer(serverId: string): void {
|
|
420
|
+
const prefix = `${serverId}:`;
|
|
421
|
+
const toRemove: string[] = [];
|
|
422
|
+
|
|
423
|
+
for (const [key] of this.trees) {
|
|
424
|
+
if (key.startsWith(prefix)) {
|
|
425
|
+
toRemove.push(key);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
for (const key of toRemove) {
|
|
430
|
+
this.trees.delete(key);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (toRemove.length > 0) {
|
|
434
|
+
this.persistTree();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ==========================================================================
|
|
439
|
+
// Private: State reconstruction
|
|
440
|
+
// ==========================================================================
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Reconstruct host-managed state at a given checkpoint by walking
|
|
444
|
+
* from the nearest ancestor with full data down to the target.
|
|
445
|
+
*/
|
|
446
|
+
private reconstructState(tree: FeatureSetState, checkpoint: string): unknown {
|
|
447
|
+
// Build path from root to checkpoint
|
|
448
|
+
const path: CheckpointNode[] = [];
|
|
449
|
+
let current: string | null = checkpoint;
|
|
450
|
+
|
|
451
|
+
while (current !== null) {
|
|
452
|
+
const node = tree.nodes.get(current);
|
|
453
|
+
if (!node) break;
|
|
454
|
+
path.unshift(node);
|
|
455
|
+
current = node.parent;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Walk forward, applying data/patches
|
|
459
|
+
let state: unknown = {};
|
|
460
|
+
|
|
461
|
+
for (const node of path) {
|
|
462
|
+
if (node.data !== undefined) {
|
|
463
|
+
// Full state snapshot — use it, discard prior state
|
|
464
|
+
state = structuredClone(node.data);
|
|
465
|
+
} else if (node.patch) {
|
|
466
|
+
// Delta — apply to current state
|
|
467
|
+
try {
|
|
468
|
+
state = applyJsonPatch(state, node.patch);
|
|
469
|
+
} catch {
|
|
470
|
+
// On patch failure, keep last known good state
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return state;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ==========================================================================
|
|
479
|
+
// Private: Persistence
|
|
480
|
+
// ==========================================================================
|
|
481
|
+
|
|
482
|
+
/** Persist checkpoint tree metadata to Chronicle. */
|
|
483
|
+
private persistTree(): void {
|
|
484
|
+
const serialized: SerializedTrees = { trees: {} };
|
|
485
|
+
|
|
486
|
+
for (const [key, tree] of this.trees) {
|
|
487
|
+
const nodes: SerializedTrees['trees'][string]['nodes'] = {};
|
|
488
|
+
for (const [id, node] of tree.nodes) {
|
|
489
|
+
nodes[id] = {
|
|
490
|
+
parent: node.parent,
|
|
491
|
+
children: node.children,
|
|
492
|
+
data: node.data,
|
|
493
|
+
patch: node.patch,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
serialized.trees[key] = {
|
|
497
|
+
hostState: tree.hostState,
|
|
498
|
+
rollback: tree.rollback,
|
|
499
|
+
current: tree.currentCheckpoint,
|
|
500
|
+
nodes,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
this.store.setStateJson(CHECKPOINTS_STATE_ID, serialized);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Load checkpoint tree from Chronicle on startup. */
|
|
508
|
+
private loadFromStore(): void {
|
|
509
|
+
const data = this.store.getStateJson(CHECKPOINTS_STATE_ID) as SerializedTrees | null;
|
|
510
|
+
if (!data?.trees) return;
|
|
511
|
+
|
|
512
|
+
for (const [key, entry] of Object.entries(data.trees)) {
|
|
513
|
+
const nodes = new Map<string, CheckpointNode>();
|
|
514
|
+
for (const [id, nodeData] of Object.entries(entry.nodes)) {
|
|
515
|
+
nodes.set(id, {
|
|
516
|
+
checkpoint: id,
|
|
517
|
+
parent: nodeData.parent,
|
|
518
|
+
children: [...nodeData.children],
|
|
519
|
+
data: nodeData.data,
|
|
520
|
+
patch: nodeData.patch,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
this.trees.set(key, {
|
|
525
|
+
hostState: entry.hostState,
|
|
526
|
+
rollback: entry.rollback,
|
|
527
|
+
currentCheckpoint: entry.current,
|
|
528
|
+
currentState: undefined, // Will be loaded per-feature-set during registerFeatureSet
|
|
529
|
+
nodes,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ==========================================================================
|
|
535
|
+
// Private: Helpers
|
|
536
|
+
// ==========================================================================
|
|
537
|
+
|
|
538
|
+
private key(serverId: string, featureSet: string): string {
|
|
539
|
+
return `${serverId}:${featureSet}`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private stateId(serverId: string, featureSet: string): string {
|
|
543
|
+
return `${STATE_PREFIX}/${serverId}/${featureSet}`;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCPL error codes and helpers.
|
|
3
|
+
*
|
|
4
|
+
* Error codes from Spec Appendix A. Used when rejecting inbound messages
|
|
5
|
+
* from MCPL servers that violate permission or state constraints.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { JsonRpcError } from './types.js';
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Error Codes (Spec Appendix A)
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
/** Message used a disabled feature set. */
|
|
15
|
+
export const FEATURE_SET_NOT_ENABLED = -32001;
|
|
16
|
+
|
|
17
|
+
/** Message used an undeclared feature set. */
|
|
18
|
+
export const UNKNOWN_FEATURE_SET = -32003;
|
|
19
|
+
|
|
20
|
+
/** Rollback targeted a pruned or unknown checkpoint. */
|
|
21
|
+
export const CHECKPOINT_NOT_FOUND = -32005;
|
|
22
|
+
|
|
23
|
+
/** Lacking scope to publish or observe channel. */
|
|
24
|
+
export const CHANNEL_NOT_PERMITTED = -32017;
|
|
25
|
+
|
|
26
|
+
/** Channel ID doesn't exist or not registered. */
|
|
27
|
+
export const UNKNOWN_CHANNEL = -32023;
|
|
28
|
+
|
|
29
|
+
/** Server could not open/connect the requested channel. */
|
|
30
|
+
export const CHANNEL_OPEN_FAILED = -32024;
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Error Factories
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a "feature set not enabled" error.
|
|
38
|
+
* Returned when a server sends a message tagged with a feature set
|
|
39
|
+
* that the host has disabled.
|
|
40
|
+
*/
|
|
41
|
+
export function featureSetNotEnabled(
|
|
42
|
+
featureSet: string,
|
|
43
|
+
canEnable: boolean = true
|
|
44
|
+
): JsonRpcError {
|
|
45
|
+
return {
|
|
46
|
+
code: FEATURE_SET_NOT_ENABLED,
|
|
47
|
+
message: 'Feature set not enabled',
|
|
48
|
+
data: { featureSet, canEnable },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create an "unknown feature set" error.
|
|
54
|
+
* Returned when a server sends a message tagged with a feature set
|
|
55
|
+
* that was never declared in capabilities.
|
|
56
|
+
*/
|
|
57
|
+
export function unknownFeatureSet(featureSet: string): JsonRpcError {
|
|
58
|
+
return {
|
|
59
|
+
code: UNKNOWN_FEATURE_SET,
|
|
60
|
+
message: 'Unknown feature set',
|
|
61
|
+
data: { featureSet },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a "checkpoint not found" error.
|
|
67
|
+
* Returned when a rollback targets a pruned or unknown checkpoint.
|
|
68
|
+
*/
|
|
69
|
+
export function checkpointNotFound(checkpoint: string): JsonRpcError {
|
|
70
|
+
return {
|
|
71
|
+
code: CHECKPOINT_NOT_FOUND,
|
|
72
|
+
message: 'Checkpoint not found',
|
|
73
|
+
data: { checkpoint },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a "channel not permitted" error.
|
|
79
|
+
* Returned when a server lacks scope to publish or observe a channel.
|
|
80
|
+
*/
|
|
81
|
+
export function channelNotPermitted(channelId: string): JsonRpcError {
|
|
82
|
+
return {
|
|
83
|
+
code: CHANNEL_NOT_PERMITTED,
|
|
84
|
+
message: 'Channel not permitted',
|
|
85
|
+
data: { channelId },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Create an "unknown channel" error.
|
|
91
|
+
* Returned when a channel ID doesn't exist or isn't registered.
|
|
92
|
+
*/
|
|
93
|
+
export function unknownChannel(channelId: string): JsonRpcError {
|
|
94
|
+
return {
|
|
95
|
+
code: UNKNOWN_CHANNEL,
|
|
96
|
+
message: 'Unknown channel',
|
|
97
|
+
data: { channelId },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create a "channel open failed" error.
|
|
103
|
+
* Returned when a server could not open/connect the requested channel.
|
|
104
|
+
*/
|
|
105
|
+
export function channelOpenFailed(
|
|
106
|
+
channelId: string,
|
|
107
|
+
reason?: string
|
|
108
|
+
): JsonRpcError {
|
|
109
|
+
return {
|
|
110
|
+
code: CHANNEL_OPEN_FAILED,
|
|
111
|
+
message: 'Channel open failed',
|
|
112
|
+
data: { channelId, reason },
|
|
113
|
+
};
|
|
114
|
+
}
|