@ema.co/mcp-toolkit 1.4.0 → 1.4.2
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/dist/cli/index.js +0 -0
- package/dist/mcp/handlers-consolidated.js +16 -1
- package/dist/mcp/server.js +0 -0
- package/dist/mcp/tools-consolidated.js +4 -0
- package/package.json +5 -3
- package/dist/config.js +0 -136
- package/dist/emaClient.js +0 -398
- package/dist/models.js +0 -8
- package/dist/state.js +0 -88
- package/dist/syncOptions.js +0 -216
package/dist/cli/index.js
CHANGED
|
File without changes
|
|
@@ -122,17 +122,32 @@ export async function handlePersona(args, client, getTemplateId, createClientFor
|
|
|
122
122
|
if (!persona) {
|
|
123
123
|
return { error: `Persona not found: ${idOrName}` };
|
|
124
124
|
}
|
|
125
|
+
// Merge new proto_config with existing, or use existing if not provided
|
|
126
|
+
const existingProtoConfig = persona.proto_config ?? {};
|
|
127
|
+
const newProtoConfig = args.proto_config;
|
|
128
|
+
const mergedProtoConfig = newProtoConfig
|
|
129
|
+
? { ...existingProtoConfig, ...newProtoConfig } // Merge: new values override existing
|
|
130
|
+
: existingProtoConfig;
|
|
131
|
+
// IMPORTANT: The Ema API requires workflow to be sent along with proto_config
|
|
132
|
+
// for proto_config changes to persist. This matches what the UI does.
|
|
133
|
+
// Get full persona details to include workflow
|
|
134
|
+
const fullPersona = await client.getPersonaById(persona.id);
|
|
135
|
+
const existingWorkflow = fullPersona
|
|
136
|
+
? (fullPersona.workflow_def ?? fullPersona.workflow)
|
|
137
|
+
: undefined;
|
|
125
138
|
await client.updateAiEmployee({
|
|
126
139
|
persona_id: persona.id,
|
|
127
140
|
name: args.name,
|
|
128
141
|
description: args.description,
|
|
129
|
-
proto_config:
|
|
142
|
+
proto_config: mergedProtoConfig,
|
|
143
|
+
workflow: existingWorkflow, // Include workflow for proto_config to persist
|
|
130
144
|
enabled_by_user: typeof args.enabled === "boolean" ? args.enabled : undefined,
|
|
131
145
|
});
|
|
132
146
|
return {
|
|
133
147
|
success: true,
|
|
134
148
|
persona_id: persona.id,
|
|
135
149
|
updated_fields: Object.keys(args).filter(k => !["id", "identifier", "mode", "env"].includes(k)),
|
|
150
|
+
proto_config_updated: !!newProtoConfig,
|
|
136
151
|
};
|
|
137
152
|
}
|
|
138
153
|
case "compare": {
|
package/dist/mcp/server.js
CHANGED
|
File without changes
|
|
@@ -119,6 +119,10 @@ export function generateConsolidatedTools(envNames, defaultEnv) {
|
|
|
119
119
|
clone_from: { type: "string", description: "Clone from persona ID (for create)" },
|
|
120
120
|
// Update flags
|
|
121
121
|
enabled: { type: "boolean", description: "Enable/disable (for update)" },
|
|
122
|
+
proto_config: {
|
|
123
|
+
type: "object",
|
|
124
|
+
description: "Voice/chat settings (welcomeMessage, identityAndPurpose, etc.) for update"
|
|
125
|
+
},
|
|
122
126
|
// Compare flags
|
|
123
127
|
compare_to: { type: "string", description: "Second persona ID (for compare)" },
|
|
124
128
|
compare_env: { type: "string", description: "Environment of compare_to persona" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ema.co/mcp-toolkit",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "Ema AI Employee toolkit - MCP server, CLI, and SDK for managing AI Employees across environments",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -41,8 +41,10 @@
|
|
|
41
41
|
"restart": "npm run stop && npm run dev",
|
|
42
42
|
"restart:mcp": "npm run stop && npm run mcp",
|
|
43
43
|
"cli": "tsx src/cli/index.ts",
|
|
44
|
-
"test": "vitest run",
|
|
45
|
-
"test:watch": "vitest",
|
|
44
|
+
"test": "vitest run --exclude 'test/integration/**'",
|
|
45
|
+
"test:watch": "vitest --exclude 'test/integration/**'",
|
|
46
|
+
"test:integration": "EMA_TEST_INTEGRATION=true vitest run test/integration/",
|
|
47
|
+
"test:all": "npm run test && npm run test:integration",
|
|
46
48
|
"typecheck": "tsc --noEmit",
|
|
47
49
|
"precommit": "npm run typecheck && npm run test",
|
|
48
50
|
"prepare": "husky",
|
package/dist/config.js
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/emaClient.js
DELETED
|
@@ -1,398 +0,0 @@
|
|
|
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/models.js
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
-
// Sync Metadata (stored in description as HTML comment)
|
|
3
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
-
/**
|
|
5
|
-
* The key used to store sync metadata in status_log.
|
|
6
|
-
* Prefixed with underscore to indicate internal/system use.
|
|
7
|
-
*/
|
|
8
|
-
export const SYNC_METADATA_KEY = "_ema_sync";
|
package/dist/state.js
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import Database from "better-sqlite3";
|
|
2
|
-
export class StateStore {
|
|
3
|
-
db;
|
|
4
|
-
mapTable = "persona_map_v2";
|
|
5
|
-
fpTable = "persona_fingerprint_v2";
|
|
6
|
-
runsTable = "sync_runs_v2";
|
|
7
|
-
constructor(path) {
|
|
8
|
-
this.db = new Database(path);
|
|
9
|
-
this.db.pragma("journal_mode = WAL");
|
|
10
|
-
this.migrate();
|
|
11
|
-
}
|
|
12
|
-
close() {
|
|
13
|
-
this.db.close();
|
|
14
|
-
}
|
|
15
|
-
migrate() {
|
|
16
|
-
this.db.exec(`
|
|
17
|
-
CREATE TABLE IF NOT EXISTS ${this.mapTable} (
|
|
18
|
-
master_env TEXT NOT NULL,
|
|
19
|
-
master_user_id TEXT NOT NULL,
|
|
20
|
-
master_persona_id TEXT NOT NULL,
|
|
21
|
-
target_env TEXT NOT NULL,
|
|
22
|
-
target_user_id TEXT NOT NULL,
|
|
23
|
-
target_persona_id TEXT NOT NULL,
|
|
24
|
-
PRIMARY KEY (master_env, master_user_id, master_persona_id, target_env, target_user_id)
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
CREATE TABLE IF NOT EXISTS ${this.fpTable} (
|
|
28
|
-
env TEXT NOT NULL,
|
|
29
|
-
env_user_id TEXT NOT NULL,
|
|
30
|
-
persona_id TEXT NOT NULL,
|
|
31
|
-
fingerprint TEXT NOT NULL,
|
|
32
|
-
last_seen_at TEXT NOT NULL,
|
|
33
|
-
PRIMARY KEY (env, env_user_id, persona_id)
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
CREATE TABLE IF NOT EXISTS ${this.runsTable} (
|
|
37
|
-
id TEXT PRIMARY KEY,
|
|
38
|
-
master_env TEXT NOT NULL,
|
|
39
|
-
master_env_user_id TEXT NOT NULL,
|
|
40
|
-
started_at TEXT NOT NULL,
|
|
41
|
-
finished_at TEXT,
|
|
42
|
-
status TEXT NOT NULL,
|
|
43
|
-
error TEXT
|
|
44
|
-
);
|
|
45
|
-
`);
|
|
46
|
-
}
|
|
47
|
-
getMapping(args) {
|
|
48
|
-
const row = this.db
|
|
49
|
-
.prepare(`SELECT target_persona_id
|
|
50
|
-
FROM ${this.mapTable}
|
|
51
|
-
WHERE master_env = ? AND master_user_id = ? AND master_persona_id = ? AND target_env = ? AND target_user_id = ?`)
|
|
52
|
-
.get(args.masterEnv, args.masterUserId, args.masterPersonaId, args.targetEnv, args.targetUserId);
|
|
53
|
-
return row?.target_persona_id ?? null;
|
|
54
|
-
}
|
|
55
|
-
upsertMapping(row) {
|
|
56
|
-
this.db
|
|
57
|
-
.prepare(`INSERT INTO ${this.mapTable} (master_env, master_user_id, master_persona_id, target_env, target_user_id, target_persona_id)
|
|
58
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
59
|
-
ON CONFLICT(master_env, master_user_id, master_persona_id, target_env, target_user_id)
|
|
60
|
-
DO UPDATE SET target_persona_id = excluded.target_persona_id`)
|
|
61
|
-
.run(row.master_env, row.master_user_id, row.master_persona_id, row.target_env, row.target_user_id, row.target_persona_id);
|
|
62
|
-
}
|
|
63
|
-
getFingerprint(args) {
|
|
64
|
-
const row = this.db
|
|
65
|
-
.prepare(`SELECT fingerprint FROM ${this.fpTable} WHERE env = ? AND env_user_id = ? AND persona_id = ?`)
|
|
66
|
-
.get(args.env, args.envUserId, args.personaId);
|
|
67
|
-
return row?.fingerprint ?? null;
|
|
68
|
-
}
|
|
69
|
-
upsertFingerprint(row) {
|
|
70
|
-
this.db
|
|
71
|
-
.prepare(`INSERT INTO ${this.fpTable} (env, env_user_id, persona_id, fingerprint, last_seen_at)
|
|
72
|
-
VALUES (?, ?, ?, ?, ?)
|
|
73
|
-
ON CONFLICT(env, env_user_id, persona_id)
|
|
74
|
-
DO UPDATE SET fingerprint = excluded.fingerprint, last_seen_at = excluded.last_seen_at`)
|
|
75
|
-
.run(row.env, row.env_user_id, row.persona_id, row.fingerprint, row.last_seen_at);
|
|
76
|
-
}
|
|
77
|
-
beginRun(row) {
|
|
78
|
-
this.db
|
|
79
|
-
.prepare(`INSERT INTO ${this.runsTable} (id, master_env, master_env_user_id, started_at, status)
|
|
80
|
-
VALUES (?, ?, ?, ?, ?)`)
|
|
81
|
-
.run(row.id, row.master_env, row.master_env_user_id, row.started_at, row.status);
|
|
82
|
-
}
|
|
83
|
-
finishRun(args) {
|
|
84
|
-
this.db
|
|
85
|
-
.prepare(`UPDATE ${this.runsTable} SET finished_at = ?, status = ?, error = ? WHERE id = ?`)
|
|
86
|
-
.run(args.finishedAt, args.status, args.error ?? null, args.id);
|
|
87
|
-
}
|
|
88
|
-
}
|
package/dist/syncOptions.js
DELETED
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sync Options Configuration
|
|
3
|
-
*
|
|
4
|
-
* Hierarchical configuration for sync behavior, loaded from:
|
|
5
|
-
* 1. .ema.yaml in repo root (shared team defaults)
|
|
6
|
-
* 2. ~/.ema.yaml in user home (personal overrides)
|
|
7
|
-
* 3. Tool parameters (runtime overrides)
|
|
8
|
-
*
|
|
9
|
-
* Resolution order (lowest → highest priority):
|
|
10
|
-
* Built-in defaults → defaults → targets[env] → personas[name] → tool params
|
|
11
|
-
*/
|
|
12
|
-
import fs from "node:fs";
|
|
13
|
-
import path from "node:path";
|
|
14
|
-
import os from "node:os";
|
|
15
|
-
import yaml from "js-yaml";
|
|
16
|
-
import { z } from "zod";
|
|
17
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
18
|
-
// Schema
|
|
19
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
-
const SyncBehaviorSchema = z.object({
|
|
21
|
-
sync_status: z.boolean().optional(),
|
|
22
|
-
dry_run: z.boolean().optional(),
|
|
23
|
-
}).strict();
|
|
24
|
-
const RoutingRuleSchema = z.object({
|
|
25
|
-
match: z.object({
|
|
26
|
-
names: z.array(z.string()).optional().default([]),
|
|
27
|
-
ids: z.array(z.string()).optional().default([]),
|
|
28
|
-
}).strict(),
|
|
29
|
-
targets: z.array(z.string()).min(1),
|
|
30
|
-
}).strict();
|
|
31
|
-
const SyncOptionsConfigSchema = z.object({
|
|
32
|
-
defaults: SyncBehaviorSchema.optional().default({}),
|
|
33
|
-
targets: z.record(SyncBehaviorSchema).optional().default({}),
|
|
34
|
-
personas: z.record(SyncBehaviorSchema).optional().default({}),
|
|
35
|
-
routing: z.array(RoutingRuleSchema).optional().default([]),
|
|
36
|
-
}).strict();
|
|
37
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
-
// Built-in defaults
|
|
39
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
-
const BUILTIN_DEFAULTS = {
|
|
41
|
-
sync_status: false,
|
|
42
|
-
dry_run: false,
|
|
43
|
-
};
|
|
44
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
-
// Config loading
|
|
46
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
47
|
-
let cachedSyncOptions = null;
|
|
48
|
-
function loadYamlFile(filePath) {
|
|
49
|
-
try {
|
|
50
|
-
const content = fs.readFileSync(filePath, "utf8");
|
|
51
|
-
return yaml.load(content);
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
function findRepoRoot() {
|
|
58
|
-
// Start from current working directory and look for .git or package.json
|
|
59
|
-
let dir = process.cwd();
|
|
60
|
-
while (dir !== path.dirname(dir)) {
|
|
61
|
-
if (fs.existsSync(path.join(dir, ".git")) || fs.existsSync(path.join(dir, "package.json"))) {
|
|
62
|
-
return dir;
|
|
63
|
-
}
|
|
64
|
-
dir = path.dirname(dir);
|
|
65
|
-
}
|
|
66
|
-
return process.cwd();
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Load and merge sync options from all config sources.
|
|
70
|
-
* Caches result for performance.
|
|
71
|
-
*/
|
|
72
|
-
export function loadSyncOptions() {
|
|
73
|
-
if (cachedSyncOptions)
|
|
74
|
-
return cachedSyncOptions;
|
|
75
|
-
const repoRoot = findRepoRoot();
|
|
76
|
-
const homeDir = os.homedir();
|
|
77
|
-
// Load from repo .ema.yaml
|
|
78
|
-
const repoConfigPath = path.join(repoRoot, ".ema.yaml");
|
|
79
|
-
const repoConfig = loadYamlFile(repoConfigPath);
|
|
80
|
-
// Load from ~/.ema.yaml
|
|
81
|
-
const userConfigPath = path.join(homeDir, ".ema.yaml");
|
|
82
|
-
const userConfig = loadYamlFile(userConfigPath);
|
|
83
|
-
// Merge configs (user overrides repo)
|
|
84
|
-
const merged = {
|
|
85
|
-
defaults: {},
|
|
86
|
-
targets: {},
|
|
87
|
-
personas: {},
|
|
88
|
-
routing: [],
|
|
89
|
-
};
|
|
90
|
-
// Apply repo config
|
|
91
|
-
if (repoConfig && typeof repoConfig === "object") {
|
|
92
|
-
const parsed = SyncOptionsConfigSchema.safeParse(repoConfig);
|
|
93
|
-
if (parsed.success) {
|
|
94
|
-
merged.defaults = { ...merged.defaults, ...parsed.data.defaults };
|
|
95
|
-
merged.targets = { ...merged.targets, ...parsed.data.targets };
|
|
96
|
-
merged.personas = { ...merged.personas, ...parsed.data.personas };
|
|
97
|
-
merged.routing = [...(parsed.data.routing ?? [])];
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
// Apply user config (overrides repo)
|
|
101
|
-
if (userConfig && typeof userConfig === "object") {
|
|
102
|
-
const parsed = SyncOptionsConfigSchema.safeParse(userConfig);
|
|
103
|
-
if (parsed.success) {
|
|
104
|
-
merged.defaults = { ...merged.defaults, ...parsed.data.defaults };
|
|
105
|
-
// Deep merge targets
|
|
106
|
-
for (const [env, behavior] of Object.entries(parsed.data.targets ?? {})) {
|
|
107
|
-
merged.targets[env] = { ...merged.targets[env], ...behavior };
|
|
108
|
-
}
|
|
109
|
-
// Deep merge personas
|
|
110
|
-
for (const [name, behavior] of Object.entries(parsed.data.personas ?? {})) {
|
|
111
|
-
merged.personas[name] = { ...merged.personas[name], ...behavior };
|
|
112
|
-
}
|
|
113
|
-
// Prepend user routing rules (higher priority)
|
|
114
|
-
merged.routing = [...(parsed.data.routing ?? []), ...merged.routing];
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
cachedSyncOptions = merged;
|
|
118
|
-
return merged;
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* Clear the cached sync options (for testing or config reload).
|
|
122
|
-
*/
|
|
123
|
-
export function clearSyncOptionsCache() {
|
|
124
|
-
cachedSyncOptions = null;
|
|
125
|
-
}
|
|
126
|
-
/**
|
|
127
|
-
* Resolve sync behavior for a specific persona and target environment.
|
|
128
|
-
*
|
|
129
|
-
* Resolution order (lowest → highest priority):
|
|
130
|
-
* 1. Built-in defaults
|
|
131
|
-
* 2. Config defaults
|
|
132
|
-
* 3. Config targets[targetEnv]
|
|
133
|
-
* 4. Config personas[personaName]
|
|
134
|
-
* 5. Runtime overrides (passed as parameter)
|
|
135
|
-
*/
|
|
136
|
-
export function resolveSyncBehavior(opts) {
|
|
137
|
-
const { personaName, targetEnv, overrides } = opts;
|
|
138
|
-
const config = loadSyncOptions();
|
|
139
|
-
// Start with built-in defaults
|
|
140
|
-
const resolved = { ...BUILTIN_DEFAULTS };
|
|
141
|
-
// Apply config defaults
|
|
142
|
-
if (config.defaults) {
|
|
143
|
-
if (config.defaults.sync_status !== undefined)
|
|
144
|
-
resolved.sync_status = config.defaults.sync_status;
|
|
145
|
-
if (config.defaults.dry_run !== undefined)
|
|
146
|
-
resolved.dry_run = config.defaults.dry_run;
|
|
147
|
-
}
|
|
148
|
-
// Apply target env overrides
|
|
149
|
-
const targetConfig = config.targets?.[targetEnv];
|
|
150
|
-
if (targetConfig) {
|
|
151
|
-
if (targetConfig.sync_status !== undefined)
|
|
152
|
-
resolved.sync_status = targetConfig.sync_status;
|
|
153
|
-
if (targetConfig.dry_run !== undefined)
|
|
154
|
-
resolved.dry_run = targetConfig.dry_run;
|
|
155
|
-
}
|
|
156
|
-
// Apply persona overrides
|
|
157
|
-
if (personaName) {
|
|
158
|
-
const personaConfig = config.personas?.[personaName];
|
|
159
|
-
if (personaConfig) {
|
|
160
|
-
if (personaConfig.sync_status !== undefined)
|
|
161
|
-
resolved.sync_status = personaConfig.sync_status;
|
|
162
|
-
if (personaConfig.dry_run !== undefined)
|
|
163
|
-
resolved.dry_run = personaConfig.dry_run;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
// Apply runtime overrides
|
|
167
|
-
if (overrides) {
|
|
168
|
-
if (overrides.sync_status !== undefined)
|
|
169
|
-
resolved.sync_status = overrides.sync_status;
|
|
170
|
-
if (overrides.dry_run !== undefined)
|
|
171
|
-
resolved.dry_run = overrides.dry_run;
|
|
172
|
-
}
|
|
173
|
-
return resolved;
|
|
174
|
-
}
|
|
175
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
176
|
-
// Routing
|
|
177
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
178
|
-
/**
|
|
179
|
-
* Check if a string matches a glob pattern (simple implementation).
|
|
180
|
-
* Supports * as wildcard.
|
|
181
|
-
*/
|
|
182
|
-
function matchGlob(pattern, value) {
|
|
183
|
-
const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
|
|
184
|
-
return regex.test(value);
|
|
185
|
-
}
|
|
186
|
-
/**
|
|
187
|
-
* Get target environments for a persona based on routing rules.
|
|
188
|
-
*/
|
|
189
|
-
export function getRoutingTargets(personaName, personaId) {
|
|
190
|
-
const config = loadSyncOptions();
|
|
191
|
-
const targets = new Set();
|
|
192
|
-
for (const rule of config.routing ?? []) {
|
|
193
|
-
// Check name patterns
|
|
194
|
-
for (const pattern of rule.match.names ?? []) {
|
|
195
|
-
if (matchGlob(pattern, personaName)) {
|
|
196
|
-
rule.targets.forEach((t) => targets.add(t));
|
|
197
|
-
break;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
// Check IDs
|
|
201
|
-
if (rule.match.ids?.includes(personaId)) {
|
|
202
|
-
rule.targets.forEach((t) => targets.add(t));
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return [...targets];
|
|
206
|
-
}
|
|
207
|
-
/**
|
|
208
|
-
* Validate sync options config.
|
|
209
|
-
*/
|
|
210
|
-
export function validateSyncOptions(input) {
|
|
211
|
-
const result = SyncOptionsConfigSchema.safeParse(input);
|
|
212
|
-
if (!result.success) {
|
|
213
|
-
return { ok: false, errors: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) };
|
|
214
|
-
}
|
|
215
|
-
return { ok: true, config: result.data };
|
|
216
|
-
}
|