@ema.co/mcp-toolkit 2026.2.27 → 2026.2.28
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.
Potentially problematic release.
This version of @ema.co/mcp-toolkit might be problematic. Click here for more details.
- package/.context/public/guides/ema-user-guide.md +7 -6
- package/.context/public/guides/mcp-tools-guide.md +46 -23
- package/dist/config/index.js +11 -0
- package/dist/config/workflow-patterns.js +361 -0
- package/dist/mcp/autobuilder.js +2 -2
- package/dist/mcp/domain/generation-schema.js +15 -9
- package/dist/mcp/domain/structural-rules.js +3 -3
- package/dist/mcp/domain/validation-rules.js +20 -27
- package/dist/mcp/domain/workflow-generator.js +3 -3
- package/dist/mcp/domain/workflow-graph.js +1 -1
- package/dist/mcp/guidance.js +60 -1
- package/dist/mcp/handlers/conversation/adapter.js +13 -0
- package/dist/mcp/handlers/conversation/create.js +19 -0
- package/dist/mcp/handlers/conversation/delete.js +18 -0
- package/dist/mcp/handlers/conversation/formatters.js +62 -0
- package/dist/mcp/handlers/conversation/history.js +15 -0
- package/dist/mcp/handlers/conversation/index.js +43 -0
- package/dist/mcp/handlers/conversation/list.js +40 -0
- package/dist/mcp/handlers/conversation/messages.js +13 -0
- package/dist/mcp/handlers/conversation/rename.js +16 -0
- package/dist/mcp/handlers/conversation/send.js +90 -0
- package/dist/mcp/handlers/data/index.js +169 -3
- package/dist/mcp/handlers/feedback/client-id.js +49 -0
- package/dist/mcp/handlers/feedback/coalesce.js +167 -0
- package/dist/mcp/handlers/feedback/index.js +42 -1
- package/dist/mcp/handlers/feedback/outbox.js +301 -0
- package/dist/mcp/handlers/feedback/probes.js +127 -0
- package/dist/mcp/handlers/feedback/remote-store.js +59 -0
- package/dist/mcp/handlers/feedback/store.js +13 -1
- package/dist/mcp/handlers/persona/delete.js +7 -28
- package/dist/mcp/handlers/persona/update.js +7 -26
- package/dist/mcp/handlers/persona/version.js +30 -15
- package/dist/mcp/handlers/template/adapter.js +23 -0
- package/dist/mcp/handlers/template/crud.js +174 -0
- package/dist/mcp/handlers/template/index.js +6 -7
- package/dist/mcp/handlers/workflow/adapter.js +30 -46
- package/dist/mcp/handlers/workflow/index.js +2 -2
- package/dist/mcp/handlers/workflow/validation.js +2 -2
- package/dist/mcp/knowledge-guidance-topics.js +90 -53
- package/dist/mcp/knowledge.js +7 -357
- package/dist/mcp/prompts.js +5 -5
- package/dist/mcp/resources-dynamic.js +46 -38
- package/dist/mcp/resources-validation.js +5 -5
- package/dist/mcp/server.js +38 -5
- package/dist/mcp/tools.js +340 -8
- package/dist/sdk/client-adapter.js +90 -2
- package/dist/sdk/client.js +7 -0
- package/dist/sdk/ema-client.js +242 -27
- package/dist/sdk/generated/agent-catalog.js +96 -39
- package/dist/sdk/generated/deprecated-actions.js +1 -1
- package/dist/sdk/grpc-client.js +67 -5
- package/dist/sync/central-factory.js +86 -0
- package/dist/sync/central-version-storage.js +387 -0
- package/dist/sync/dis-port.js +75 -0
- package/dist/sync/version-policy.js +29 -31
- package/dist/sync/version-storage-interface.js +11 -0
- package/dist/sync/version-storage.js +22 -22
- package/package.json +2 -1
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central Version Storage — DIS-backed version snapshot storage.
|
|
3
|
+
*
|
|
4
|
+
* Implements IVersionStorage using the Data Ingest Service (DIS) as the backing
|
|
5
|
+
* store, attaching a system widget (`_ema_versions`) to each persona.
|
|
6
|
+
*
|
|
7
|
+
* Design:
|
|
8
|
+
* - Each version is a JSON file: `v{NNN}__{hash}.json`
|
|
9
|
+
* - A `_manifest.json` file tracks all versions for efficient listing
|
|
10
|
+
* - A `_policy.json` file stores per-persona versioning policy
|
|
11
|
+
* - DIS has no overwrite — re-uploads create duplicates. Cleaned up on read.
|
|
12
|
+
* - Manifest reconciliation rebuilds from DIS scan if counts mismatch.
|
|
13
|
+
*
|
|
14
|
+
* ───────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
* TODO(data-snapshots): Future — Data Source Snapshot Support
|
|
16
|
+
* ───────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* Currently, persona versions capture config + workflow + data source REFERENCES
|
|
19
|
+
* (file_id, filename, widget_name) but not the actual file contents. This means:
|
|
20
|
+
*
|
|
21
|
+
* - Restore works for config/workflow (re-pushed via updateAiEmployee)
|
|
22
|
+
* - Restore does NOT restore deleted data source files
|
|
23
|
+
* - The `data_sources` array in the snapshot is a point-in-time inventory
|
|
24
|
+
*
|
|
25
|
+
* To support full data restore, consider:
|
|
26
|
+
*
|
|
27
|
+
* 1. **On snapshot**: For each DataSourceRef, download the file content from DIS
|
|
28
|
+
* and upload it into `_ema_versions` with a name like `data_v{N}__{file_id}.bin`.
|
|
29
|
+
* Pro: complete restore. Con: doubles storage, N downloads per snapshot.
|
|
30
|
+
*
|
|
31
|
+
* 2. **On restore**: For each DataSourceRef in the snapshot, check if file_id
|
|
32
|
+
* still exists (via list). If missing, either warn or re-upload from the
|
|
33
|
+
* backed-up copy. Pro: graceful degradation. Con: requires backup from (1).
|
|
34
|
+
*
|
|
35
|
+
* 3. **Deferred download**: Don't back up files on snapshot. On restore, check
|
|
36
|
+
* existence and warn about missing files. Cheapest option — works if files
|
|
37
|
+
* are rarely deleted. Pro: zero overhead. Con: lossy restore.
|
|
38
|
+
*
|
|
39
|
+
* 4. **Content-addressable dedup**: Store file content keyed by hash. Multiple
|
|
40
|
+
* versions sharing the same file reference one copy. Pro: optimal storage.
|
|
41
|
+
* Con: complex, needs hash computation for every file on snapshot.
|
|
42
|
+
*
|
|
43
|
+
* Implementation hooks (where to add this):
|
|
44
|
+
* - `saveVersion()` — optionally download + store data source files
|
|
45
|
+
* - `getRestoreData()` in VersionPolicyEngine — check file existence, include re-upload plan
|
|
46
|
+
* - `version.ts` restore handler — execute re-upload plan if files were backed up
|
|
47
|
+
* - `pruneVersions()` — also clean up backed-up data files for pruned versions
|
|
48
|
+
*
|
|
49
|
+
* See also: DataSourceRef TODO in version-tracking.ts
|
|
50
|
+
* ───────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
*/
|
|
52
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
// Constants
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
const MANIFEST_FILENAME = "_manifest.json";
|
|
56
|
+
const POLICY_FILENAME = "_policy.json";
|
|
57
|
+
/** Pattern: v{NNN}__{hash}.json */
|
|
58
|
+
const VERSION_FILE_PATTERN = /^v(\d+)__([a-f0-9]+)\.json$/;
|
|
59
|
+
const DEFAULT_POLICY = {
|
|
60
|
+
auto_version_on_deploy: true,
|
|
61
|
+
auto_version_on_sync: true,
|
|
62
|
+
max_versions: 50,
|
|
63
|
+
prune_strategy: "oldest",
|
|
64
|
+
};
|
|
65
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
// CentralVersionStorage
|
|
67
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
export class CentralVersionStorage {
|
|
69
|
+
port;
|
|
70
|
+
personaId;
|
|
71
|
+
/**
|
|
72
|
+
* @param port - DIS port for file operations
|
|
73
|
+
* @param personaId - The persona ID this storage is scoped to.
|
|
74
|
+
* CentralVersionStorage is constructed per-persona (unlike local storage
|
|
75
|
+
* which is per-workspace). Call sites create one per operation.
|
|
76
|
+
*/
|
|
77
|
+
constructor(port, personaId) {
|
|
78
|
+
this.port = port;
|
|
79
|
+
this.personaId = personaId;
|
|
80
|
+
}
|
|
81
|
+
// ─────────────── Version CRUD ───────────────
|
|
82
|
+
async saveVersion(version) {
|
|
83
|
+
const filename = `v${String(version.version_number).padStart(3, "0")}__${version.content_hash}.json`;
|
|
84
|
+
const content = Buffer.from(JSON.stringify(version, null, 2), "utf-8");
|
|
85
|
+
// Upload the version file
|
|
86
|
+
const fileId = await this.port.upload(this.personaId, filename, content);
|
|
87
|
+
// Update manifest
|
|
88
|
+
const manifest = await this.loadManifest();
|
|
89
|
+
const entry = {
|
|
90
|
+
id: version.id,
|
|
91
|
+
version_number: version.version_number,
|
|
92
|
+
version_name: version.version_name,
|
|
93
|
+
content_hash: version.content_hash,
|
|
94
|
+
created_at: version.created_at,
|
|
95
|
+
created_by: version.created_by,
|
|
96
|
+
trigger: version.trigger,
|
|
97
|
+
message: version.message,
|
|
98
|
+
parent_version_id: version.parent_version_id,
|
|
99
|
+
dis_file_id: fileId,
|
|
100
|
+
};
|
|
101
|
+
// Guard: reconcileManifest may have already added this entry
|
|
102
|
+
const alreadyExists = manifest.versions.some((v) => v.dis_file_id === fileId || (v.version_number === version.version_number && v.content_hash === version.content_hash));
|
|
103
|
+
if (!alreadyExists) {
|
|
104
|
+
manifest.versions.push(entry);
|
|
105
|
+
}
|
|
106
|
+
manifest.latest_version_number = version.version_number;
|
|
107
|
+
manifest.latest_version_id = version.id;
|
|
108
|
+
manifest.updated_at = new Date().toISOString();
|
|
109
|
+
await this.saveManifest(manifest);
|
|
110
|
+
}
|
|
111
|
+
async getVersion(personaId, versionNumber) {
|
|
112
|
+
const manifest = await this.loadManifest();
|
|
113
|
+
const entry = manifest.versions.find((v) => v.version_number === versionNumber);
|
|
114
|
+
if (!entry)
|
|
115
|
+
return null;
|
|
116
|
+
return this.downloadSnapshot(entry.dis_file_id);
|
|
117
|
+
}
|
|
118
|
+
async getVersionById(personaId, versionId) {
|
|
119
|
+
// Check manifest first (avoids downloading all versions)
|
|
120
|
+
const manifest = await this.loadManifest();
|
|
121
|
+
const entry = manifest.versions.find((v) => v.id === versionId);
|
|
122
|
+
if (!entry)
|
|
123
|
+
return null;
|
|
124
|
+
return this.downloadSnapshot(entry.dis_file_id);
|
|
125
|
+
}
|
|
126
|
+
async getVersionByName(personaId, versionName) {
|
|
127
|
+
if (versionName === "latest") {
|
|
128
|
+
return this.getLatestVersion(personaId);
|
|
129
|
+
}
|
|
130
|
+
const match = versionName.match(/^v(\d+)$/);
|
|
131
|
+
if (!match)
|
|
132
|
+
return null;
|
|
133
|
+
return this.getVersion(personaId, parseInt(match[1], 10));
|
|
134
|
+
}
|
|
135
|
+
async getLatestVersion(personaId) {
|
|
136
|
+
const manifest = await this.loadManifest();
|
|
137
|
+
if (manifest.versions.length === 0)
|
|
138
|
+
return null;
|
|
139
|
+
// Sort descending and pick the latest
|
|
140
|
+
const sorted = [...manifest.versions].sort((a, b) => b.version_number - a.version_number);
|
|
141
|
+
return this.downloadSnapshot(sorted[0].dis_file_id);
|
|
142
|
+
}
|
|
143
|
+
// ─────────────── Listing & Queries ───────────────
|
|
144
|
+
async listVersions(_personaId) {
|
|
145
|
+
const manifest = await this.loadManifest();
|
|
146
|
+
// Return without DIS-specific fields
|
|
147
|
+
return manifest.versions.map(({ dis_file_id, ...entry }) => entry);
|
|
148
|
+
}
|
|
149
|
+
async hasContentHash(_personaId, contentHash) {
|
|
150
|
+
const manifest = await this.loadManifest();
|
|
151
|
+
return manifest.versions.some((v) => v.content_hash === contentHash);
|
|
152
|
+
}
|
|
153
|
+
async getNextVersionNumber(_personaId) {
|
|
154
|
+
const manifest = await this.loadManifest();
|
|
155
|
+
if (manifest.versions.length === 0)
|
|
156
|
+
return 1;
|
|
157
|
+
// Compute from actual entries — don't trust latest_version_number field
|
|
158
|
+
const maxVer = Math.max(...manifest.versions.map((v) => v.version_number));
|
|
159
|
+
return maxVer + 1;
|
|
160
|
+
}
|
|
161
|
+
async getManifest(_personaId) {
|
|
162
|
+
const manifest = await this.loadManifest();
|
|
163
|
+
if (manifest.versions.length === 0 && manifest.latest_version_number === 0) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
// Strip DIS-specific fields for the public interface
|
|
167
|
+
// Compute latest from actual entries (not stored field which may be stale)
|
|
168
|
+
const latestEntry = manifest.versions.length > 0
|
|
169
|
+
? [...manifest.versions].sort((a, b) => b.version_number - a.version_number)[0]
|
|
170
|
+
: null;
|
|
171
|
+
return {
|
|
172
|
+
persona_id: manifest.persona_id,
|
|
173
|
+
environment: manifest.environment,
|
|
174
|
+
latest_version_id: latestEntry?.id ?? null,
|
|
175
|
+
latest_version_number: latestEntry?.version_number ?? 0,
|
|
176
|
+
versions: manifest.versions.map(({ dis_file_id, ...entry }) => entry),
|
|
177
|
+
updated_at: manifest.updated_at,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
// ─────────────── Policy ───────────────
|
|
181
|
+
async getPolicy(personaId) {
|
|
182
|
+
const files = await this.port.list(this.personaId);
|
|
183
|
+
const policyFiles = files.filter((f) => f.filename === POLICY_FILENAME);
|
|
184
|
+
if (policyFiles.length === 0) {
|
|
185
|
+
return { persona_id: personaId, ...DEFAULT_POLICY };
|
|
186
|
+
}
|
|
187
|
+
// Cleanup duplicates (DIS has no overwrite)
|
|
188
|
+
if (policyFiles.length > 1) {
|
|
189
|
+
await this.cleanupDuplicates(policyFiles);
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
// Download the most recent one (last uploaded)
|
|
193
|
+
const latest = policyFiles[policyFiles.length - 1];
|
|
194
|
+
const buf = await this.port.download(latest.file_id);
|
|
195
|
+
const policy = JSON.parse(buf.toString("utf-8"));
|
|
196
|
+
return { ...DEFAULT_POLICY, ...policy, persona_id: personaId };
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return { persona_id: personaId, ...DEFAULT_POLICY };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async savePolicy(policy) {
|
|
203
|
+
const content = Buffer.from(JSON.stringify(policy, null, 2), "utf-8");
|
|
204
|
+
await this.port.upload(this.personaId, POLICY_FILENAME, content);
|
|
205
|
+
// Best-effort cleanup of old policy files
|
|
206
|
+
const files = await this.port.list(this.personaId);
|
|
207
|
+
const policyFiles = files.filter((f) => f.filename === POLICY_FILENAME);
|
|
208
|
+
if (policyFiles.length > 1) {
|
|
209
|
+
await this.cleanupDuplicates(policyFiles);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// ─────────────── Maintenance ───────────────
|
|
213
|
+
async pruneVersions(_personaId) {
|
|
214
|
+
const policy = await this.getPolicy(this.personaId);
|
|
215
|
+
const maxVersions = policy.max_versions ?? 50;
|
|
216
|
+
const manifest = await this.loadManifest();
|
|
217
|
+
if (manifest.versions.length <= maxVersions)
|
|
218
|
+
return 0;
|
|
219
|
+
// Sort oldest first
|
|
220
|
+
const sorted = [...manifest.versions].sort((a, b) => a.version_number - b.version_number);
|
|
221
|
+
const toRemove = sorted.slice(0, sorted.length - maxVersions);
|
|
222
|
+
// Track which file IDs were actually deleted — only remove those from manifest
|
|
223
|
+
const deletedFileIds = new Set();
|
|
224
|
+
for (const entry of toRemove) {
|
|
225
|
+
try {
|
|
226
|
+
await this.port.delete(this.personaId, entry.dis_file_id);
|
|
227
|
+
deletedFileIds.add(entry.dis_file_id);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Keep in manifest — DIS file still exists
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Only remove manifest entries for successfully deleted files
|
|
234
|
+
manifest.versions = manifest.versions.filter((v) => !deletedFileIds.has(v.dis_file_id));
|
|
235
|
+
// Recompute latest_version_number from remaining entries
|
|
236
|
+
if (manifest.versions.length > 0) {
|
|
237
|
+
const maxVer = Math.max(...manifest.versions.map((v) => v.version_number));
|
|
238
|
+
manifest.latest_version_number = maxVer;
|
|
239
|
+
const latestEntry = manifest.versions.find((v) => v.version_number === maxVer);
|
|
240
|
+
manifest.latest_version_id = latestEntry?.id ?? null;
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
manifest.latest_version_number = 0;
|
|
244
|
+
manifest.latest_version_id = null;
|
|
245
|
+
}
|
|
246
|
+
manifest.updated_at = new Date().toISOString();
|
|
247
|
+
await this.saveManifest(manifest);
|
|
248
|
+
return deletedFileIds.size;
|
|
249
|
+
}
|
|
250
|
+
// ─────────────── Private Helpers ───────────────
|
|
251
|
+
/**
|
|
252
|
+
* Load the manifest from DIS, with cleanup-on-read for duplicates
|
|
253
|
+
* and reconciliation if the manifest is missing/corrupt.
|
|
254
|
+
*/
|
|
255
|
+
async loadManifest() {
|
|
256
|
+
const files = await this.port.list(this.personaId);
|
|
257
|
+
const manifestFiles = files.filter((f) => f.filename === MANIFEST_FILENAME);
|
|
258
|
+
if (manifestFiles.length === 0) {
|
|
259
|
+
// No manifest — try to reconcile from version files
|
|
260
|
+
return this.reconcileManifest(files);
|
|
261
|
+
}
|
|
262
|
+
// Cleanup duplicate manifests
|
|
263
|
+
if (manifestFiles.length > 1) {
|
|
264
|
+
await this.cleanupDuplicates(manifestFiles);
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
// Download the most recent manifest
|
|
268
|
+
const latest = manifestFiles[manifestFiles.length - 1];
|
|
269
|
+
const buf = await this.port.download(latest.file_id);
|
|
270
|
+
const manifest = JSON.parse(buf.toString("utf-8"));
|
|
271
|
+
// Validate: count version files in DIS vs manifest entries
|
|
272
|
+
const versionFiles = files.filter((f) => VERSION_FILE_PATTERN.test(f.filename));
|
|
273
|
+
if (versionFiles.length !== manifest.versions.length) {
|
|
274
|
+
// Mismatch — reconcile from DIS
|
|
275
|
+
return this.reconcileManifest(files);
|
|
276
|
+
}
|
|
277
|
+
return manifest;
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// Corrupt manifest — reconcile from DIS
|
|
281
|
+
return this.reconcileManifest(files);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Rebuild manifest by scanning version files in DIS.
|
|
286
|
+
*/
|
|
287
|
+
async reconcileManifest(files) {
|
|
288
|
+
const versionFiles = files.filter((f) => VERSION_FILE_PATTERN.test(f.filename));
|
|
289
|
+
const entries = [];
|
|
290
|
+
let maxVersionNumber = 0;
|
|
291
|
+
for (const file of versionFiles) {
|
|
292
|
+
const match = file.filename.match(VERSION_FILE_PATTERN);
|
|
293
|
+
if (!match)
|
|
294
|
+
continue;
|
|
295
|
+
const versionNumber = parseInt(match[1], 10);
|
|
296
|
+
// Download to get full metadata
|
|
297
|
+
try {
|
|
298
|
+
const buf = await this.port.download(file.file_id);
|
|
299
|
+
const snapshot = JSON.parse(buf.toString("utf-8"));
|
|
300
|
+
entries.push({
|
|
301
|
+
id: snapshot.id,
|
|
302
|
+
version_number: snapshot.version_number,
|
|
303
|
+
version_name: snapshot.version_name,
|
|
304
|
+
content_hash: snapshot.content_hash,
|
|
305
|
+
created_at: snapshot.created_at,
|
|
306
|
+
created_by: snapshot.created_by,
|
|
307
|
+
trigger: snapshot.trigger,
|
|
308
|
+
message: snapshot.message,
|
|
309
|
+
parent_version_id: snapshot.parent_version_id,
|
|
310
|
+
dis_file_id: file.file_id,
|
|
311
|
+
});
|
|
312
|
+
if (versionNumber > maxVersionNumber) {
|
|
313
|
+
maxVersionNumber = versionNumber;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// Skip corrupt version files during reconciliation
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Sort by version number
|
|
321
|
+
entries.sort((a, b) => a.version_number - b.version_number);
|
|
322
|
+
const latestEntry = entries.length > 0 ? entries[entries.length - 1] : null;
|
|
323
|
+
const manifest = {
|
|
324
|
+
persona_id: this.personaId,
|
|
325
|
+
environment: "central",
|
|
326
|
+
latest_version_id: latestEntry?.id ?? null,
|
|
327
|
+
latest_version_number: maxVersionNumber,
|
|
328
|
+
versions: entries,
|
|
329
|
+
updated_at: new Date().toISOString(),
|
|
330
|
+
};
|
|
331
|
+
// Save the reconciled manifest (if there are any versions)
|
|
332
|
+
if (entries.length > 0) {
|
|
333
|
+
await this.saveManifest(manifest);
|
|
334
|
+
}
|
|
335
|
+
return manifest;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Save manifest to DIS. Uploads a new copy and cleans up old ones.
|
|
339
|
+
*/
|
|
340
|
+
async saveManifest(manifest) {
|
|
341
|
+
const content = Buffer.from(JSON.stringify(manifest, null, 2), "utf-8");
|
|
342
|
+
await this.port.upload(this.personaId, MANIFEST_FILENAME, content);
|
|
343
|
+
// Best-effort cleanup of old manifest files
|
|
344
|
+
const files = await this.port.list(this.personaId);
|
|
345
|
+
const manifestFiles = files.filter((f) => f.filename === MANIFEST_FILENAME);
|
|
346
|
+
if (manifestFiles.length > 1) {
|
|
347
|
+
await this.cleanupDuplicates(manifestFiles);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Download and parse a version snapshot by file ID.
|
|
352
|
+
*/
|
|
353
|
+
async downloadSnapshot(fileId) {
|
|
354
|
+
try {
|
|
355
|
+
const buf = await this.port.download(fileId);
|
|
356
|
+
return JSON.parse(buf.toString("utf-8"));
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Clean up duplicate files (DIS has no overwrite), keeping the most recent.
|
|
364
|
+
* Deletes all but the last entry (assumed most recently uploaded).
|
|
365
|
+
*/
|
|
366
|
+
async cleanupDuplicates(files) {
|
|
367
|
+
// Keep the last one (most recently uploaded), delete the rest
|
|
368
|
+
const toDelete = files.slice(0, files.length - 1);
|
|
369
|
+
for (const file of toDelete) {
|
|
370
|
+
try {
|
|
371
|
+
await this.port.delete(this.personaId, file.file_id);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
// Best-effort
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
380
|
+
// Factory
|
|
381
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
382
|
+
/**
|
|
383
|
+
* Create a CentralVersionStorage instance scoped to a persona.
|
|
384
|
+
*/
|
|
385
|
+
export function createCentralVersionStorage(port, personaId) {
|
|
386
|
+
return new CentralVersionStorage(port, personaId);
|
|
387
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DIS Port - Dependency injection interface for Data Ingest Service operations.
|
|
3
|
+
*
|
|
4
|
+
* DISPort abstracts the DIS operations needed by CentralVersionStorage,
|
|
5
|
+
* enabling both production (DISAdapter → SDK) and test (MockDISPort → Map) usage.
|
|
6
|
+
*/
|
|
7
|
+
/** System widget name for version storage. Prefixed with `_` to exclude from search/validation. */
|
|
8
|
+
export const SYSTEM_WIDGET_NAME = "_ema_versions";
|
|
9
|
+
/** ContentNodeStatus values for soft-deleted files (still appear in listings during deletion). */
|
|
10
|
+
const DELETED_STATUSES = new Set([5 /* DELETE_IN_PROGRESS */, 7 /* DELETED */]);
|
|
11
|
+
/**
|
|
12
|
+
* Production DIS adapter wrapping the SDK client (EmaClientV2).
|
|
13
|
+
*/
|
|
14
|
+
export class DISAdapter {
|
|
15
|
+
client;
|
|
16
|
+
personaIdForAuth;
|
|
17
|
+
constructor(client, personaIdForAuth) {
|
|
18
|
+
this.client = client;
|
|
19
|
+
this.personaIdForAuth = personaIdForAuth;
|
|
20
|
+
}
|
|
21
|
+
async upload(personaId, filename, content) {
|
|
22
|
+
const result = await this.client.uploadDataSource(personaId, content, filename, {
|
|
23
|
+
widgetName: SYSTEM_WIDGET_NAME,
|
|
24
|
+
tags: SYSTEM_WIDGET_NAME,
|
|
25
|
+
mimeType: "application/json",
|
|
26
|
+
});
|
|
27
|
+
return result.fileId;
|
|
28
|
+
}
|
|
29
|
+
async list(personaId) {
|
|
30
|
+
const allFiles = [];
|
|
31
|
+
let page = 1;
|
|
32
|
+
const limit = 100;
|
|
33
|
+
// Paginate through all files
|
|
34
|
+
// eslint-disable-next-line no-constant-condition
|
|
35
|
+
while (true) {
|
|
36
|
+
const resp = await this.client.listDataSourceFiles(personaId, {
|
|
37
|
+
widgetName: SYSTEM_WIDGET_NAME,
|
|
38
|
+
page,
|
|
39
|
+
limit,
|
|
40
|
+
});
|
|
41
|
+
for (const node of resp.data) {
|
|
42
|
+
// Skip soft-deleted files (still appear in listings during DIS deletion)
|
|
43
|
+
if (node.status != null && DELETED_STATUSES.has(node.status))
|
|
44
|
+
continue;
|
|
45
|
+
allFiles.push({
|
|
46
|
+
file_id: node.id,
|
|
47
|
+
filename: node.nodeName,
|
|
48
|
+
created_at: node.createdAt
|
|
49
|
+
? new Date(Number(node.createdAt.seconds) * 1000).toISOString()
|
|
50
|
+
: undefined,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
// Exit when: no pagination info, at last page, or no data returned
|
|
54
|
+
const pagination = resp.pagination;
|
|
55
|
+
if (!pagination || page >= pagination.totalPages || resp.data.length === 0) {
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
page++;
|
|
59
|
+
}
|
|
60
|
+
return allFiles;
|
|
61
|
+
}
|
|
62
|
+
async download(fileId) {
|
|
63
|
+
return this.client.downloadFile(fileId, this.personaIdForAuth);
|
|
64
|
+
}
|
|
65
|
+
async delete(personaId, fileId) {
|
|
66
|
+
try {
|
|
67
|
+
await this.client.deleteDataSource(personaId, fileId, {
|
|
68
|
+
widgetName: SYSTEM_WIDGET_NAME,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Best-effort: file may already be gone
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Handles automatic versioning decisions based on policies.
|
|
5
5
|
* Determines when to create snapshots, manages retention, and
|
|
6
6
|
* enforces versioning rules.
|
|
7
|
+
*
|
|
8
|
+
* All methods are async to support both local and central (DIS) storage.
|
|
7
9
|
*/
|
|
8
10
|
import { createVersionSnapshot, hashSnapshot, createSnapshotFromPersona, generateChangesSummary, compareVersions, } from "./version-tracking.js";
|
|
9
11
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -18,12 +20,12 @@ export class VersionPolicyEngine {
|
|
|
18
20
|
/**
|
|
19
21
|
* Check if a version should be created for a persona.
|
|
20
22
|
*/
|
|
21
|
-
checkPolicy(persona, trigger) {
|
|
22
|
-
const policy = this.storage.getPolicy(persona.id);
|
|
23
|
+
async checkPolicy(persona, trigger) {
|
|
24
|
+
const policy = await this.storage.getPolicy(persona.id);
|
|
23
25
|
const snapshot = createSnapshotFromPersona(persona);
|
|
24
26
|
const contentHash = hashSnapshot(snapshot);
|
|
25
27
|
// Check if this would be a duplicate
|
|
26
|
-
const isDuplicate = this.storage.hasContentHash(persona.id, contentHash);
|
|
28
|
+
const isDuplicate = await this.storage.hasContentHash(persona.id, contentHash);
|
|
27
29
|
// Determine if we should create based on trigger and policy
|
|
28
30
|
let shouldCreate = false;
|
|
29
31
|
let reason = "";
|
|
@@ -69,7 +71,7 @@ export class VersionPolicyEngine {
|
|
|
69
71
|
case "auto":
|
|
70
72
|
// Check interval if specified
|
|
71
73
|
if (policy.min_interval) {
|
|
72
|
-
const latest = this.storage.getLatestVersion(persona.id);
|
|
74
|
+
const latest = await this.storage.getLatestVersion(persona.id);
|
|
73
75
|
if (latest && !this.hasIntervalPassed(latest.created_at, policy.min_interval)) {
|
|
74
76
|
shouldCreate = false;
|
|
75
77
|
skipReason = `Minimum interval (${policy.min_interval}) not yet passed`;
|
|
@@ -122,6 +124,9 @@ export class VersionPolicyEngine {
|
|
|
122
124
|
intervalMs += parseInt(minutes, 10) * 60 * 1000;
|
|
123
125
|
if (days)
|
|
124
126
|
intervalMs += parseInt(days, 10) * 24 * 60 * 60 * 1000;
|
|
127
|
+
// Unrecognized format → interval not passed (safe default: don't auto-create)
|
|
128
|
+
if (intervalMs === 0)
|
|
129
|
+
return false;
|
|
125
130
|
return now.getTime() - last.getTime() >= intervalMs;
|
|
126
131
|
}
|
|
127
132
|
// ─────────────── Version Creation ───────────────
|
|
@@ -129,9 +134,9 @@ export class VersionPolicyEngine {
|
|
|
129
134
|
* Create a version if policy allows.
|
|
130
135
|
* This is the main entry point for automatic versioning.
|
|
131
136
|
*/
|
|
132
|
-
createVersionIfAllowed(persona, trigger, options) {
|
|
137
|
+
async createVersionIfAllowed(persona, trigger, options) {
|
|
133
138
|
// Check policy (unless forced)
|
|
134
|
-
const check = this.checkPolicy(persona, trigger);
|
|
139
|
+
const check = await this.checkPolicy(persona, trigger);
|
|
135
140
|
if (!options.force && !check.should_create_version) {
|
|
136
141
|
return {
|
|
137
142
|
created: false,
|
|
@@ -140,7 +145,7 @@ export class VersionPolicyEngine {
|
|
|
140
145
|
};
|
|
141
146
|
}
|
|
142
147
|
// Get parent version info
|
|
143
|
-
const latest = this.storage.getLatestVersion(persona.id);
|
|
148
|
+
const latest = await this.storage.getLatestVersion(persona.id);
|
|
144
149
|
const previousVersionNumber = latest?.version_number;
|
|
145
150
|
const parentVersionId = latest?.id;
|
|
146
151
|
// Create the version
|
|
@@ -162,9 +167,9 @@ export class VersionPolicyEngine {
|
|
|
162
167
|
version.changes_summary = changesFromParent;
|
|
163
168
|
}
|
|
164
169
|
// Save the version
|
|
165
|
-
this.storage.saveVersion(version);
|
|
170
|
+
await this.storage.saveVersion(version);
|
|
166
171
|
// Prune if necessary
|
|
167
|
-
const versionsPruned = this.storage.pruneVersions(persona.id);
|
|
172
|
+
const versionsPruned = await this.storage.pruneVersions(persona.id);
|
|
168
173
|
return {
|
|
169
174
|
created: true,
|
|
170
175
|
version,
|
|
@@ -176,7 +181,7 @@ export class VersionPolicyEngine {
|
|
|
176
181
|
/**
|
|
177
182
|
* Force create a version regardless of policy.
|
|
178
183
|
*/
|
|
179
|
-
forceCreateVersion(persona, options) {
|
|
184
|
+
async forceCreateVersion(persona, options) {
|
|
180
185
|
return this.createVersionIfAllowed(persona, "manual", {
|
|
181
186
|
...options,
|
|
182
187
|
force: true,
|
|
@@ -187,18 +192,18 @@ export class VersionPolicyEngine {
|
|
|
187
192
|
* Get the snapshot data needed to restore a version.
|
|
188
193
|
* Returns the persona configuration that can be applied via updateAiEmployee.
|
|
189
194
|
*/
|
|
190
|
-
getRestoreData(personaId, versionIdentifier) {
|
|
195
|
+
async getRestoreData(personaId, versionIdentifier) {
|
|
191
196
|
// Get the version
|
|
192
197
|
let version = null;
|
|
193
198
|
if (typeof versionIdentifier === "number") {
|
|
194
|
-
version = this.storage.getVersion(personaId, versionIdentifier);
|
|
199
|
+
version = await this.storage.getVersion(personaId, versionIdentifier);
|
|
195
200
|
}
|
|
196
201
|
else if (versionIdentifier.match(/^v\d+$/)) {
|
|
197
|
-
version = this.storage.getVersionByName(personaId, versionIdentifier);
|
|
202
|
+
version = await this.storage.getVersionByName(personaId, versionIdentifier);
|
|
198
203
|
}
|
|
199
204
|
else {
|
|
200
205
|
// Assume it's a version ID
|
|
201
|
-
version = this.storage.getVersionById(personaId, versionIdentifier);
|
|
206
|
+
version = await this.storage.getVersionById(personaId, versionIdentifier);
|
|
202
207
|
}
|
|
203
208
|
if (!version) {
|
|
204
209
|
return {
|
|
@@ -226,28 +231,28 @@ export class VersionPolicyEngine {
|
|
|
226
231
|
/**
|
|
227
232
|
* Get policy for a persona.
|
|
228
233
|
*/
|
|
229
|
-
getPolicy(personaId) {
|
|
234
|
+
async getPolicy(personaId) {
|
|
230
235
|
return this.storage.getPolicy(personaId);
|
|
231
236
|
}
|
|
232
237
|
/**
|
|
233
238
|
* Update policy for a persona.
|
|
234
239
|
*/
|
|
235
|
-
updatePolicy(personaId, updates) {
|
|
236
|
-
const current = this.storage.getPolicy(personaId);
|
|
240
|
+
async updatePolicy(personaId, updates) {
|
|
241
|
+
const current = await this.storage.getPolicy(personaId);
|
|
237
242
|
const updated = {
|
|
238
243
|
...current,
|
|
239
244
|
...updates,
|
|
240
245
|
persona_id: personaId, // Ensure this is always set
|
|
241
246
|
};
|
|
242
|
-
this.storage.savePolicy(updated);
|
|
247
|
+
await this.storage.savePolicy(updated);
|
|
243
248
|
return updated;
|
|
244
249
|
}
|
|
245
250
|
// ─────────────── Query Methods ───────────────
|
|
246
251
|
/**
|
|
247
252
|
* List versions for a persona with optional filtering.
|
|
248
253
|
*/
|
|
249
|
-
listVersions(personaId, options) {
|
|
250
|
-
let versions = this.storage.listVersions(personaId);
|
|
254
|
+
async listVersions(personaId, options) {
|
|
255
|
+
let versions = await this.storage.listVersions(personaId);
|
|
251
256
|
// Apply filters
|
|
252
257
|
if (options?.trigger) {
|
|
253
258
|
versions = versions.filter((v) => v.trigger === options.trigger);
|
|
@@ -275,7 +280,7 @@ export class VersionPolicyEngine {
|
|
|
275
280
|
/**
|
|
276
281
|
* Get a specific version with full details.
|
|
277
282
|
*/
|
|
278
|
-
getVersion(personaId, versionIdentifier) {
|
|
283
|
+
async getVersion(personaId, versionIdentifier) {
|
|
279
284
|
if (versionIdentifier === "latest") {
|
|
280
285
|
return this.storage.getLatestVersion(personaId);
|
|
281
286
|
}
|
|
@@ -292,9 +297,9 @@ export class VersionPolicyEngine {
|
|
|
292
297
|
/**
|
|
293
298
|
* Compare two versions.
|
|
294
299
|
*/
|
|
295
|
-
compareVersions(personaId, v1Identifier, v2Identifier) {
|
|
296
|
-
const v1 = this.getVersion(personaId, v1Identifier);
|
|
297
|
-
const v2 = this.getVersion(personaId, v2Identifier);
|
|
300
|
+
async compareVersions(personaId, v1Identifier, v2Identifier) {
|
|
301
|
+
const v1 = await this.getVersion(personaId, v1Identifier);
|
|
302
|
+
const v2 = await this.getVersion(personaId, v2Identifier);
|
|
298
303
|
if (!v1) {
|
|
299
304
|
return { success: false, error: `Version not found: ${v1Identifier}` };
|
|
300
305
|
}
|
|
@@ -309,13 +314,6 @@ export class VersionPolicyEngine {
|
|
|
309
314
|
summary,
|
|
310
315
|
};
|
|
311
316
|
}
|
|
312
|
-
// ─────────────── Storage Access ───────────────
|
|
313
|
-
/**
|
|
314
|
-
* Get the underlying storage instance.
|
|
315
|
-
*/
|
|
316
|
-
getStorage() {
|
|
317
|
-
return this.storage;
|
|
318
|
-
}
|
|
319
317
|
}
|
|
320
318
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
321
319
|
// Factory Function
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IVersionStorage - Async contract for version snapshot storage.
|
|
3
|
+
*
|
|
4
|
+
* Implemented by:
|
|
5
|
+
* - VersionStorage (local file-based, .ema-versions/)
|
|
6
|
+
* - CentralVersionStorage (DIS-backed, _ema_versions widget)
|
|
7
|
+
*
|
|
8
|
+
* All methods return Promise<T> to accommodate async DIS operations.
|
|
9
|
+
* Local storage wraps synchronous fs calls in Promise.resolve().
|
|
10
|
+
*/
|
|
11
|
+
export {};
|