@ema.co/mcp-toolkit 0.2.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 +321 -0
- package/config.example.yaml +32 -0
- package/dist/cli/index.js +333 -0
- package/dist/config.js +136 -0
- package/dist/emaClient.js +398 -0
- package/dist/index.js +109 -0
- package/dist/mcp/handlers-consolidated.js +851 -0
- package/dist/mcp/index.js +15 -0
- package/dist/mcp/prompts.js +1753 -0
- package/dist/mcp/resources.js +624 -0
- package/dist/mcp/server.js +4723 -0
- package/dist/mcp/tools-consolidated.js +590 -0
- package/dist/mcp/tools-legacy.js +736 -0
- package/dist/models.js +8 -0
- package/dist/scheduler.js +21 -0
- package/dist/sdk/client.js +788 -0
- package/dist/sdk/config.js +136 -0
- package/dist/sdk/contracts.js +429 -0
- package/dist/sdk/generation-schema.js +189 -0
- package/dist/sdk/index.js +39 -0
- package/dist/sdk/knowledge.js +2780 -0
- package/dist/sdk/models.js +8 -0
- package/dist/sdk/state.js +88 -0
- package/dist/sdk/sync-options.js +216 -0
- package/dist/sdk/sync.js +220 -0
- package/dist/sdk/validation-rules.js +355 -0
- package/dist/sdk/workflow-generator.js +291 -0
- package/dist/sdk/workflow-intent.js +1585 -0
- package/dist/state.js +88 -0
- package/dist/sync.js +416 -0
- package/dist/syncOptions.js +216 -0
- package/dist/ui.js +334 -0
- package/docs/advisor-comms-assistant-fixes.md +175 -0
- package/docs/api-contracts.md +216 -0
- package/docs/auto-builder-analysis.md +271 -0
- package/docs/data-architecture.md +166 -0
- package/docs/ema-auto-builder-guide.html +394 -0
- package/docs/ema-user-guide.md +1121 -0
- package/docs/mcp-tools-guide.md +149 -0
- package/docs/naming-conventions.md +218 -0
- package/docs/tool-consolidation-proposal.md +427 -0
- package/package.json +98 -0
- package/resources/templates/chat-ai/README.md +119 -0
- package/resources/templates/chat-ai/persona-config.json +111 -0
- package/resources/templates/dashboard-ai/README.md +156 -0
- package/resources/templates/dashboard-ai/persona-config.json +180 -0
- package/resources/templates/voice-ai/README.md +123 -0
- package/resources/templates/voice-ai/persona-config.json +74 -0
- package/resources/templates/voice-ai/workflow-prompt.md +120 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment Configuration
|
|
3
|
+
*
|
|
4
|
+
* Defines Ema API environments and service settings.
|
|
5
|
+
* Used by both MCP server and CLI/service modes.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import yaml from "js-yaml";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
// Schema
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
const EnvironmentSchema = z.object({
|
|
14
|
+
name: z.string().min(1),
|
|
15
|
+
baseUrl: z.string().url(),
|
|
16
|
+
bearerTokenEnv: z.string().min(1),
|
|
17
|
+
isMaster: z.boolean().optional().default(false),
|
|
18
|
+
// Deprecated but accepted for backward compatibility
|
|
19
|
+
userId: z.string().optional(),
|
|
20
|
+
templateIdMap: z.record(z.string()).optional(),
|
|
21
|
+
});
|
|
22
|
+
const SchedulerSchema = z.object({
|
|
23
|
+
intervalSeconds: z.number().int().positive().optional(),
|
|
24
|
+
cron: z.string().min(1).optional(),
|
|
25
|
+
}).strict().refine((v) => v.cron || v.intervalSeconds, { message: "scheduler must set either intervalSeconds or cron" });
|
|
26
|
+
const ServiceSchema = z.object({
|
|
27
|
+
stateDbPath: z.string().min(1).optional().default("./ema-state.sqlite3"),
|
|
28
|
+
scheduler: SchedulerSchema.optional(),
|
|
29
|
+
}).strict();
|
|
30
|
+
// Legacy schema for backward compatibility
|
|
31
|
+
const LegacyRouteRuleSchema = z.object({
|
|
32
|
+
personaIds: z.array(z.string()).default([]),
|
|
33
|
+
targetEnvs: z.array(z.string()).default([]),
|
|
34
|
+
nameGlobs: z.array(z.string()).optional().default([]),
|
|
35
|
+
nameRegexes: z.array(z.string()).optional().default([]),
|
|
36
|
+
namePrefixes: z.array(z.string()).optional().default([]),
|
|
37
|
+
}).passthrough();
|
|
38
|
+
const AppConfigSchema = z.object({
|
|
39
|
+
environments: z.array(EnvironmentSchema).min(1),
|
|
40
|
+
service: ServiceSchema.optional(),
|
|
41
|
+
dryRun: z.boolean().optional().default(false),
|
|
42
|
+
verbose: z.boolean().optional().default(false),
|
|
43
|
+
// Legacy fields (kept for backward compatibility)
|
|
44
|
+
routing: z.array(LegacyRouteRuleSchema).optional(),
|
|
45
|
+
scheduler: SchedulerSchema.optional(),
|
|
46
|
+
stateDbPath: z.string().optional(),
|
|
47
|
+
eventSharedSecretEnv: z.string().optional(),
|
|
48
|
+
}).refine((cfg) => {
|
|
49
|
+
// Validate at most one master
|
|
50
|
+
const masters = cfg.environments.filter((e) => e.isMaster);
|
|
51
|
+
return masters.length <= 1;
|
|
52
|
+
}, { message: "At most one environment can be isMaster: true" });
|
|
53
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
// Loading functions
|
|
55
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
export function loadConfig(path) {
|
|
57
|
+
const raw = fs.readFileSync(path, "utf8");
|
|
58
|
+
const parsed = yaml.load(raw);
|
|
59
|
+
if (!parsed || typeof parsed !== "object") {
|
|
60
|
+
throw new Error("Invalid config YAML");
|
|
61
|
+
}
|
|
62
|
+
const res = AppConfigSchema.safeParse(parsed);
|
|
63
|
+
if (!res.success) {
|
|
64
|
+
const errors = res.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
|
|
65
|
+
throw new Error(`Invalid config: ${errors.join("; ")}`);
|
|
66
|
+
}
|
|
67
|
+
return res.data;
|
|
68
|
+
}
|
|
69
|
+
export function loadConfigOptional(path) {
|
|
70
|
+
try {
|
|
71
|
+
return loadConfig(path);
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
if (e && typeof e === "object" && "code" in e && e.code === "ENOENT") {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
78
|
+
if (msg.includes("ENOENT"))
|
|
79
|
+
return null;
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function loadConfigFromJsonEnv() {
|
|
84
|
+
const raw = process.env.EMA_CONFIG_JSON ?? process.env.EMA_AGENT_SYNC_CONFIG_JSON;
|
|
85
|
+
if (!raw)
|
|
86
|
+
return null;
|
|
87
|
+
let parsed;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(raw);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new Error("EMA_CONFIG_JSON is not valid JSON");
|
|
93
|
+
}
|
|
94
|
+
const res = validateConfig(parsed);
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
throw new Error(`Invalid EMA_CONFIG_JSON: ${res.errors.join("; ")}`);
|
|
97
|
+
}
|
|
98
|
+
return res.config;
|
|
99
|
+
}
|
|
100
|
+
export function validateConfig(input) {
|
|
101
|
+
const res = AppConfigSchema.safeParse(input);
|
|
102
|
+
if (!res.success) {
|
|
103
|
+
return { ok: false, errors: res.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) };
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, config: res.data };
|
|
106
|
+
}
|
|
107
|
+
export function configToYaml(cfg) {
|
|
108
|
+
return yaml.dump(cfg, { noRefs: true, sortKeys: true });
|
|
109
|
+
}
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
// Utilities
|
|
112
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
113
|
+
export function resolveBearerToken(envVarName) {
|
|
114
|
+
const v = process.env[envVarName];
|
|
115
|
+
if (!v)
|
|
116
|
+
throw new Error(`Missing bearer token env var: ${envVarName}`);
|
|
117
|
+
// Strip "Bearer " prefix if user included it
|
|
118
|
+
const trimmed = v.trim();
|
|
119
|
+
if (trimmed.toLowerCase().startsWith("bearer ")) {
|
|
120
|
+
return trimmed.slice(7).trim();
|
|
121
|
+
}
|
|
122
|
+
return trimmed;
|
|
123
|
+
}
|
|
124
|
+
export function assertEnvVarsPresent(cfg) {
|
|
125
|
+
for (const e of cfg.environments) {
|
|
126
|
+
if (!process.env[e.bearerTokenEnv]) {
|
|
127
|
+
throw new Error(`Missing required env var for ${e.name}: ${e.bearerTokenEnv}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export function getMasterEnv(cfg) {
|
|
132
|
+
return cfg.environments.find((e) => e.isMaster);
|
|
133
|
+
}
|
|
134
|
+
export function getEnvByName(cfg, name) {
|
|
135
|
+
return cfg.environments.find((e) => e.name === name);
|
|
136
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
export class EmaApiError extends Error {
|
|
2
|
+
statusCode;
|
|
3
|
+
body;
|
|
4
|
+
constructor(opts) {
|
|
5
|
+
super(opts.message);
|
|
6
|
+
this.statusCode = opts.statusCode;
|
|
7
|
+
this.body = opts.body;
|
|
8
|
+
this.name = "EmaApiError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function sleep(ms) {
|
|
12
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
}
|
|
14
|
+
export class EmaClient {
|
|
15
|
+
env;
|
|
16
|
+
timeoutMs;
|
|
17
|
+
constructor(env, opts) {
|
|
18
|
+
this.env = env;
|
|
19
|
+
this.timeoutMs = opts?.timeoutMs ?? 30_000;
|
|
20
|
+
}
|
|
21
|
+
async requestWithRetries(method, path, opts) {
|
|
22
|
+
const retries = opts?.retries ?? 4;
|
|
23
|
+
const baseDelayMs = opts?.baseDelayMs ?? 500;
|
|
24
|
+
let lastErr;
|
|
25
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
26
|
+
try {
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
29
|
+
const fullUrl = `${this.env.baseUrl.replace(/\/$/, "")}${path}`;
|
|
30
|
+
const response = await fetch(fullUrl, {
|
|
31
|
+
method,
|
|
32
|
+
headers: {
|
|
33
|
+
Authorization: `Bearer ${this.env.bearerToken}`,
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
},
|
|
36
|
+
body: opts?.json !== undefined ? JSON.stringify(opts.json) : undefined,
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
});
|
|
39
|
+
clearTimeout(timeoutId);
|
|
40
|
+
// Retry on transient errors
|
|
41
|
+
if ([429, 500, 502, 503, 504].includes(response.status) && attempt < retries) {
|
|
42
|
+
const delay = baseDelayMs * 2 ** attempt;
|
|
43
|
+
await sleep(delay);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
return response;
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
lastErr = e instanceof Error ? e : new Error(String(e));
|
|
50
|
+
if (attempt >= retries)
|
|
51
|
+
throw lastErr;
|
|
52
|
+
const delay = baseDelayMs * 2 ** attempt;
|
|
53
|
+
await sleep(delay);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw lastErr ?? new Error("Unreachable");
|
|
57
|
+
}
|
|
58
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
// AI Employees (Personas)
|
|
60
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
async getPersonasForTenant() {
|
|
62
|
+
// Try POST first (original API), fall back to GET if 405
|
|
63
|
+
let resp = await this.requestWithRetries("POST", "/api/personas/get_personas_for_tenant", {
|
|
64
|
+
json: {},
|
|
65
|
+
});
|
|
66
|
+
// If POST fails with 405, try GET method
|
|
67
|
+
if (resp.status === 405) {
|
|
68
|
+
resp = await this.requestWithRetries("GET", "/api/personas/get_personas_for_tenant", {});
|
|
69
|
+
}
|
|
70
|
+
if (!resp.ok) {
|
|
71
|
+
throw new EmaApiError({
|
|
72
|
+
statusCode: resp.status,
|
|
73
|
+
body: await resp.text(),
|
|
74
|
+
message: `get_personas_for_tenant failed (${this.env.name})`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const data = (await resp.json());
|
|
78
|
+
const personas = data.personas ?? data.configs ?? [];
|
|
79
|
+
return personas;
|
|
80
|
+
}
|
|
81
|
+
/** Alias: in the UI, personas are called "AI Employees" */
|
|
82
|
+
async getAiEmployeesForTenant() {
|
|
83
|
+
return this.getPersonasForTenant();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Get a single persona by ID with full details including workflow_def.
|
|
87
|
+
* Uses /api/personas/{id} endpoint which returns the complete persona including workflow_def.
|
|
88
|
+
*/
|
|
89
|
+
async getPersonaById(personaId) {
|
|
90
|
+
// #region agent log
|
|
91
|
+
fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:getPersonaById:start', message: 'Fetching full persona details', data: { env: this.env.name, personaId }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'workflow-fix', hypothesisId: 'D' }) }).catch(() => { });
|
|
92
|
+
// #endregion
|
|
93
|
+
// Primary: /api/personas/{id} - returns full persona with workflow_def
|
|
94
|
+
try {
|
|
95
|
+
const resp = await this.requestWithRetries("GET", `/api/personas/${personaId}`, {});
|
|
96
|
+
if (resp.ok) {
|
|
97
|
+
const persona = (await resp.json());
|
|
98
|
+
// #region agent log
|
|
99
|
+
const wfActions = persona?.workflow_def?.actions;
|
|
100
|
+
fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:getPersonaById:success', message: 'Got full persona from /api/personas/{id}', data: { env: this.env.name, personaId, hasWorkflowDef: !!persona?.workflow_def, workflowActionsCount: Array.isArray(wfActions) ? wfActions.length : 0 }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'workflow-fix', hypothesisId: 'D' }) }).catch(() => { });
|
|
101
|
+
// #endregion
|
|
102
|
+
return persona;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Fall through to fallback
|
|
107
|
+
}
|
|
108
|
+
// Fallback: /api/ai_employee/get_ai_employee endpoint
|
|
109
|
+
try {
|
|
110
|
+
const resp = await this.requestWithRetries("GET", `/api/ai_employee/get_ai_employee?persona_id=${personaId}`, {});
|
|
111
|
+
if (resp.ok) {
|
|
112
|
+
const data = (await resp.json());
|
|
113
|
+
const persona = data.persona ?? data.ai_employee ?? data.config ?? null;
|
|
114
|
+
// #region agent log
|
|
115
|
+
fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:getPersonaById:fallback', message: 'Got persona from fallback endpoint', data: { env: this.env.name, personaId, hasWorkflowDef: !!persona?.workflow_def }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'workflow-fix', hypothesisId: 'D' }) }).catch(() => { });
|
|
116
|
+
// #endregion
|
|
117
|
+
return persona;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Fall through to return null
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Get workflow definition by workflow_id.
|
|
127
|
+
* Tries multiple API endpoints to fetch the workflow.
|
|
128
|
+
*/
|
|
129
|
+
async getWorkflowDef(workflowId) {
|
|
130
|
+
// Try gRPC-web style GetWorkflow endpoint
|
|
131
|
+
try {
|
|
132
|
+
const resp = await this.requestWithRetries("POST", "/workflows.v1.WorkflowManager/GetWorkflow", {
|
|
133
|
+
json: { workflow_id: workflowId },
|
|
134
|
+
});
|
|
135
|
+
if (resp.ok) {
|
|
136
|
+
const data = (await resp.json());
|
|
137
|
+
return data.workflow ?? (Object.keys(data).length > 0 ? data : null);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Fall through
|
|
142
|
+
}
|
|
143
|
+
// Try GetWorkflowDefinition endpoint (alternate name)
|
|
144
|
+
try {
|
|
145
|
+
const resp = await this.requestWithRetries("POST", "/workflows.v1.WorkflowManager/GetWorkflowDefinition", {
|
|
146
|
+
json: { workflow_id: workflowId },
|
|
147
|
+
});
|
|
148
|
+
if (resp.ok) {
|
|
149
|
+
const data = (await resp.json());
|
|
150
|
+
return data;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Fall through
|
|
155
|
+
}
|
|
156
|
+
// Try listing actions and constructing workflow
|
|
157
|
+
try {
|
|
158
|
+
const actions = await this.listActionsFromWorkflow(workflowId);
|
|
159
|
+
if (actions && actions.length > 0) {
|
|
160
|
+
// Return actions as part of workflow def (partial reconstruction)
|
|
161
|
+
return { workflow_id: workflowId, actions: actions };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Fall through
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
async updateAiEmployee(req, opts) {
|
|
170
|
+
// #region agent log
|
|
171
|
+
if (opts?.verbose) {
|
|
172
|
+
fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:updateAiEmployee:fullRequest', message: 'FULL UPDATE REQUEST', data: { env: this.env.name, fullRequest: req }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'verbose-test', hypothesisId: 'full-request' }) }).catch(() => { });
|
|
173
|
+
}
|
|
174
|
+
// #endregion
|
|
175
|
+
const resp = await this.requestWithRetries("POST", "/api/ai_employee/update_ai_employee", {
|
|
176
|
+
json: req,
|
|
177
|
+
});
|
|
178
|
+
if (!resp.ok) {
|
|
179
|
+
const body = await resp.text();
|
|
180
|
+
// #region agent log
|
|
181
|
+
fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:updateAiEmployee:error', message: 'UPDATE FAILED', data: { env: this.env.name, status: resp.status, body }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'verbose-test', hypothesisId: 'api-error' }) }).catch(() => { });
|
|
182
|
+
// #endregion
|
|
183
|
+
throw new EmaApiError({
|
|
184
|
+
statusCode: resp.status,
|
|
185
|
+
body,
|
|
186
|
+
message: `update_ai_employee failed (${this.env.name})`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
const result = (await resp.json());
|
|
190
|
+
// #region agent log
|
|
191
|
+
if (opts?.verbose) {
|
|
192
|
+
fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:updateAiEmployee:response', message: 'UPDATE RESPONSE', data: { env: this.env.name, responseKeys: Object.keys(result), response: result }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'verbose-test', hypothesisId: 'full-response' }) }).catch(() => { });
|
|
193
|
+
}
|
|
194
|
+
// #endregion
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
async createAiEmployee(req, opts) {
|
|
198
|
+
// #region agent log
|
|
199
|
+
if (opts?.verbose) {
|
|
200
|
+
fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:createAiEmployee:fullRequest', message: 'FULL CREATE REQUEST', data: { env: this.env.name, fullRequest: req }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'verbose-test', hypothesisId: 'full-request' }) }).catch(() => { });
|
|
201
|
+
}
|
|
202
|
+
// #endregion
|
|
203
|
+
const resp = await this.requestWithRetries("POST", "/api/ai_employee/create_ai_employee", {
|
|
204
|
+
json: req,
|
|
205
|
+
});
|
|
206
|
+
if (!resp.ok) {
|
|
207
|
+
const body = await resp.text();
|
|
208
|
+
// #region agent log
|
|
209
|
+
fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:createAiEmployee:error', message: 'CREATE FAILED', data: { env: this.env.name, status: resp.status, body }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'verbose-test', hypothesisId: 'api-error' }) }).catch(() => { });
|
|
210
|
+
// #endregion
|
|
211
|
+
throw new EmaApiError({
|
|
212
|
+
statusCode: resp.status,
|
|
213
|
+
body,
|
|
214
|
+
message: `create_ai_employee failed (${this.env.name})`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const result = (await resp.json());
|
|
218
|
+
// #region agent log
|
|
219
|
+
if (opts?.verbose) {
|
|
220
|
+
fetch('http://127.0.0.1:7242/ingest/4b191179-efb6-478a-b628-20269a94f0b3', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'emaClient.ts:createAiEmployee:response', message: 'CREATE RESPONSE', data: { env: this.env.name, responseKeys: Object.keys(result), response: result }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'verbose-test', hypothesisId: 'full-response' }) }).catch(() => { });
|
|
221
|
+
}
|
|
222
|
+
// #endregion
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
226
|
+
// Actions (displayed as "Agents" in the UI)
|
|
227
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
228
|
+
/**
|
|
229
|
+
* List all actions available to the tenant.
|
|
230
|
+
* API endpoint: workflows.v1.ActionManager/ListActions
|
|
231
|
+
* UI label: "Agents"
|
|
232
|
+
*/
|
|
233
|
+
async listActions() {
|
|
234
|
+
const resp = await this.requestWithRetries("POST", "/workflows.v1.ActionManager/ListActions", {
|
|
235
|
+
json: {},
|
|
236
|
+
});
|
|
237
|
+
if (!resp.ok) {
|
|
238
|
+
throw new EmaApiError({
|
|
239
|
+
statusCode: resp.status,
|
|
240
|
+
body: await resp.text(),
|
|
241
|
+
message: `listActions failed (${this.env.name})`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
const data = (await resp.json());
|
|
245
|
+
return data.actions ?? [];
|
|
246
|
+
}
|
|
247
|
+
/** Alias: in the UI, actions are called "Agents" */
|
|
248
|
+
async listAgents() {
|
|
249
|
+
return this.listActions();
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* List actions associated with a specific workflow.
|
|
253
|
+
* API endpoint: workflows.v1.ActionManager/ListActionsFromWorkflow
|
|
254
|
+
* UI label: "Agents"
|
|
255
|
+
*
|
|
256
|
+
* @param workflowId - The workflow ID to list actions for
|
|
257
|
+
*/
|
|
258
|
+
async listActionsFromWorkflow(workflowId) {
|
|
259
|
+
const resp = await this.requestWithRetries("POST", "/workflows.v1.ActionManager/ListActionsFromWorkflow", { json: { workflow_id: workflowId } });
|
|
260
|
+
if (!resp.ok) {
|
|
261
|
+
throw new EmaApiError({
|
|
262
|
+
statusCode: resp.status,
|
|
263
|
+
body: await resp.text(),
|
|
264
|
+
message: `listActionsFromWorkflow failed (${this.env.name})`,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
const data = (await resp.json());
|
|
268
|
+
return data.actions ?? [];
|
|
269
|
+
}
|
|
270
|
+
/** Alias: in the UI, actions are called "Agents" */
|
|
271
|
+
async listAgentsFromWorkflow(workflowId) {
|
|
272
|
+
return this.listActionsFromWorkflow(workflowId);
|
|
273
|
+
}
|
|
274
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
275
|
+
// Sync Metadata (tagging synced personas)
|
|
276
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
277
|
+
/**
|
|
278
|
+
* Sync tag regex patterns.
|
|
279
|
+
* Current format: <!-- synced_from:env/id -->
|
|
280
|
+
* Legacy formats also supported for backward compatibility.
|
|
281
|
+
*/
|
|
282
|
+
static SYNC_TAG_REGEX = /<!-- synced_from:([^/]+)\/([a-f0-9-]+) -->/;
|
|
283
|
+
static LEGACY_JSON_REGEX = /<!-- _ema_sync:(.+?) -->/;
|
|
284
|
+
/**
|
|
285
|
+
* Get sync metadata from a persona's description.
|
|
286
|
+
* Returns null if the persona has never been synced.
|
|
287
|
+
*/
|
|
288
|
+
getSyncMetadata(persona) {
|
|
289
|
+
// Check top-level description for sync tag
|
|
290
|
+
const desc = persona.description;
|
|
291
|
+
if (desc) {
|
|
292
|
+
const meta = this.extractSyncMetadataFromString(desc);
|
|
293
|
+
if (meta)
|
|
294
|
+
return meta;
|
|
295
|
+
}
|
|
296
|
+
// Fallback: check proto_config._ema_sync (legacy)
|
|
297
|
+
const protoConfig = persona.proto_config;
|
|
298
|
+
if (protoConfig?._ema_sync) {
|
|
299
|
+
return protoConfig._ema_sync;
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Extract sync metadata from a string (description field).
|
|
305
|
+
* Supports both new compact format and legacy JSON format.
|
|
306
|
+
*/
|
|
307
|
+
extractSyncMetadataFromString(text) {
|
|
308
|
+
// Try new compact format: <!-- synced_from:env/id -->
|
|
309
|
+
const compactMatch = text.match(EmaClient.SYNC_TAG_REGEX);
|
|
310
|
+
if (compactMatch) {
|
|
311
|
+
return {
|
|
312
|
+
master_env: compactMatch[1],
|
|
313
|
+
master_id: compactMatch[2],
|
|
314
|
+
synced_at: new Date().toISOString(),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
// Try legacy JSON format: <!-- _ema_sync:{...} -->
|
|
318
|
+
const legacyMatch = text.match(EmaClient.LEGACY_JSON_REGEX);
|
|
319
|
+
if (legacyMatch) {
|
|
320
|
+
try {
|
|
321
|
+
return JSON.parse(legacyMatch[1]);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Check if a persona was synced from a master environment.
|
|
331
|
+
*/
|
|
332
|
+
isSyncedPersona(persona) {
|
|
333
|
+
return this.getSyncMetadata(persona) !== null;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Regex to clean all sync tag formats from description.
|
|
337
|
+
*/
|
|
338
|
+
static SYNC_CLEANUP_REGEX = /<!-- (?:synced_from|sync|_ema_sync):[^\n]*-->/g;
|
|
339
|
+
/**
|
|
340
|
+
* Extract the clean description (without sync marker).
|
|
341
|
+
*/
|
|
342
|
+
getCleanDescription(description) {
|
|
343
|
+
if (!description)
|
|
344
|
+
return "";
|
|
345
|
+
return description.replace(EmaClient.SYNC_CLEANUP_REGEX, "").trim();
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Build description with compact sync tag appended.
|
|
349
|
+
*/
|
|
350
|
+
buildDescriptionWithSyncTag(cleanDescription, metadata) {
|
|
351
|
+
const marker = `<!-- synced_from:${metadata.master_env}/${metadata.master_id} -->`;
|
|
352
|
+
return cleanDescription ? `${cleanDescription}\n\n${marker}` : marker;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Tag a persona as synced by appending metadata to description.
|
|
356
|
+
*/
|
|
357
|
+
async tagAsSynced(personaId, metadata, currentDescription, existingProtoConfig) {
|
|
358
|
+
const cleanDesc = this.getCleanDescription(currentDescription);
|
|
359
|
+
const newDescription = this.buildDescriptionWithSyncTag(cleanDesc, metadata);
|
|
360
|
+
await this.updateAiEmployee({
|
|
361
|
+
persona_id: personaId,
|
|
362
|
+
proto_config: existingProtoConfig ?? {},
|
|
363
|
+
description: newDescription,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Remove sync metadata from a persona (unlink from master).
|
|
368
|
+
*/
|
|
369
|
+
async removeSyncTag(personaId, currentDescription, existingProtoConfig) {
|
|
370
|
+
const cleanDesc = this.getCleanDescription(currentDescription);
|
|
371
|
+
await this.updateAiEmployee({
|
|
372
|
+
persona_id: personaId,
|
|
373
|
+
proto_config: existingProtoConfig ?? {},
|
|
374
|
+
description: cleanDesc,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* List all personas that have sync metadata (were synced from master).
|
|
379
|
+
*/
|
|
380
|
+
async listSyncedPersonas() {
|
|
381
|
+
const personas = await this.getPersonasForTenant();
|
|
382
|
+
const synced = [];
|
|
383
|
+
for (const p of personas) {
|
|
384
|
+
const meta = this.getSyncMetadata(p);
|
|
385
|
+
if (meta) {
|
|
386
|
+
synced.push({ persona: p, syncMetadata: meta });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return synced;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Find a synced persona by its master environment and master ID.
|
|
393
|
+
*/
|
|
394
|
+
async findSyncedPersona(masterEnv, masterId) {
|
|
395
|
+
const synced = await this.listSyncedPersonas();
|
|
396
|
+
return (synced.find((s) => s.syncMetadata.master_env === masterEnv && s.syncMetadata.master_id === masterId) ?? null);
|
|
397
|
+
}
|
|
398
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import Fastify from "fastify";
|
|
3
|
+
import { assertEnvVarsPresent, configToYaml, getMasterEnv, loadConfigFromJsonEnv, loadConfigOptional, validateConfig, } from "./sdk/config.js";
|
|
4
|
+
import { StateStore } from "./sdk/state.js";
|
|
5
|
+
import { syncOnce } from "./sync.js";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { startScheduler } from "./scheduler.js";
|
|
8
|
+
import { uiHtml, uiJs } from "./ui.js";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
async function main() {
|
|
12
|
+
const configPath = process.env.EMA_AGENT_SYNC_CONFIG ?? "./config.yaml";
|
|
13
|
+
const cfg = loadConfigFromJsonEnv() ?? loadConfigOptional(configPath);
|
|
14
|
+
const hasConfig = !!cfg;
|
|
15
|
+
const app = Fastify({ logger: true });
|
|
16
|
+
const state = new StateStore((cfg?.stateDbPath ?? "./ema-agent-sync.sqlite3"));
|
|
17
|
+
const sched = cfg ? startScheduler(cfg, state) : null;
|
|
18
|
+
app.get("/health", async () => ({ status: "ok", mode: hasConfig ? "sync" : "config-builder" }));
|
|
19
|
+
// --- Config wizard UI ---
|
|
20
|
+
app.get("/ui", async (_req, reply) => {
|
|
21
|
+
reply.header("Content-Type", "text/html; charset=utf-8");
|
|
22
|
+
return uiHtml();
|
|
23
|
+
});
|
|
24
|
+
app.get("/ui/app.js", async (_req, reply) => {
|
|
25
|
+
reply.header("Content-Type", "text/javascript; charset=utf-8");
|
|
26
|
+
return uiJs();
|
|
27
|
+
});
|
|
28
|
+
// --- Config helper APIs (used by UI) ---
|
|
29
|
+
app.post("/api/config/validate", async (req) => {
|
|
30
|
+
const result = validateConfig(req.body);
|
|
31
|
+
if (result.ok)
|
|
32
|
+
return { ok: true };
|
|
33
|
+
return { ok: false, errors: result.errors };
|
|
34
|
+
});
|
|
35
|
+
app.post("/api/config/yaml", async (req) => {
|
|
36
|
+
const result = validateConfig(req.body);
|
|
37
|
+
if (!result.ok)
|
|
38
|
+
return { ok: false, errors: result.errors };
|
|
39
|
+
return { ok: true, yaml: configToYaml(result.config) };
|
|
40
|
+
});
|
|
41
|
+
app.post("/api/config/save", async (req, reply) => {
|
|
42
|
+
const result = validateConfig(req.body);
|
|
43
|
+
if (!result.ok)
|
|
44
|
+
return { ok: false, errors: result.errors };
|
|
45
|
+
const out = String((req.headers["x-config-path"] ?? "./config.yaml"));
|
|
46
|
+
// Guard: only allow relative paths in cwd, no traversal
|
|
47
|
+
if (path.isAbsolute(out) || out.includes("..")) {
|
|
48
|
+
reply.code(400);
|
|
49
|
+
return { ok: false, errors: ["Invalid x-config-path (must be relative, no '..')"] };
|
|
50
|
+
}
|
|
51
|
+
const ext = path.extname(out);
|
|
52
|
+
if (ext !== ".yaml" && ext !== ".yml") {
|
|
53
|
+
reply.code(400);
|
|
54
|
+
return { ok: false, errors: ["Config path must end with .yaml or .yml"] };
|
|
55
|
+
}
|
|
56
|
+
fs.writeFileSync(out, configToYaml(result.config), "utf8");
|
|
57
|
+
return { ok: true, savedTo: out };
|
|
58
|
+
});
|
|
59
|
+
app.post("/events/persona-changed", async (req, reply) => {
|
|
60
|
+
if (!cfg) {
|
|
61
|
+
reply.code(400);
|
|
62
|
+
return { error: "ConfigMissing", message: "No config loaded. Create one via /ui or set EMA_AGENT_SYNC_CONFIG_JSON." };
|
|
63
|
+
}
|
|
64
|
+
// Useful for running just the UI wizard without having secrets set in the environment yet.
|
|
65
|
+
if (process.env.EMA_AGENT_SYNC_SKIP_ENV_CHECK !== "1") {
|
|
66
|
+
assertEnvVarsPresent(cfg);
|
|
67
|
+
}
|
|
68
|
+
// Optional shared secret auth
|
|
69
|
+
if (cfg.eventSharedSecretEnv) {
|
|
70
|
+
const expected = process.env[cfg.eventSharedSecretEnv];
|
|
71
|
+
const got = String((req.headers["x-ema-sync-secret"] ?? ""));
|
|
72
|
+
if (!expected) {
|
|
73
|
+
reply.code(500);
|
|
74
|
+
return { error: "ServerMisconfigured", message: "Missing event shared secret env var" };
|
|
75
|
+
}
|
|
76
|
+
const a = Buffer.from(expected);
|
|
77
|
+
const b = Buffer.from(got);
|
|
78
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
|
79
|
+
reply.code(401);
|
|
80
|
+
return { error: "Unauthorized" };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Payload shape: { env, persona_id } (we still run full sync for now; targeted comes next)
|
|
84
|
+
const result = await syncOnce(cfg, state);
|
|
85
|
+
reply.code(202);
|
|
86
|
+
return { accepted: true, result };
|
|
87
|
+
});
|
|
88
|
+
const port = Number(process.env.PORT ?? 8080);
|
|
89
|
+
const host = process.env.HOST ?? "0.0.0.0";
|
|
90
|
+
await app.listen({ port, host });
|
|
91
|
+
if (cfg) {
|
|
92
|
+
const master = getMasterEnv(cfg);
|
|
93
|
+
// Ensure bearer tokens exist unless explicitly disabled
|
|
94
|
+
if (process.env.EMA_AGENT_SYNC_SKIP_ENV_CHECK !== "1") {
|
|
95
|
+
assertEnvVarsPresent(cfg);
|
|
96
|
+
}
|
|
97
|
+
app.log.info({ masterEnv: master?.name ?? "none" }, "started");
|
|
98
|
+
if (sched)
|
|
99
|
+
app.log.info("scheduler_started");
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
app.log.info({ mode: "config-builder" }, "started_without_config");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
main().catch((err) => {
|
|
106
|
+
// eslint-disable-next-line no-console
|
|
107
|
+
console.error(err);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
});
|