mojulo 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -4
- package/lib/audit-logger-new.js +11 -0
- package/lib/auth/gate.js +25 -0
- package/lib/auth/service.js +17 -0
- package/lib/auth/session.js +63 -0
- package/lib/builder/chat-processor.js +607 -0
- package/lib/builder/composer-bridge.js +82 -0
- package/lib/builder/evaluator.js +159 -0
- package/lib/builder/executor.js +252 -0
- package/lib/builder/index.js +48 -0
- package/lib/builder/session.js +248 -0
- package/lib/builder/system-prompt.js +422 -0
- package/lib/builder/tone-presets.js +75 -0
- package/lib/builder/tool-executors.js +1418 -0
- package/lib/builder/tools.js +338 -0
- package/lib/builder/validators.js +239 -0
- package/lib/composer/composer.js +225 -0
- package/lib/composer/index.js +40 -0
- package/lib/composer/protocols/00_base.txt +19 -0
- package/lib/composer/protocols/01_knowledge.txt +9 -0
- package/lib/composer/protocols/02_form-gathering.txt +32 -0
- package/lib/composer/protocols/03_appointments.txt +16 -0
- package/lib/composer/protocols/04_triage.txt +15 -0
- package/lib/composer/protocols/05_optical-read.txt +22 -0
- package/lib/composer/response-builder.js +98 -0
- package/lib/config-builder.js +650 -0
- package/lib/db/ids.js +10 -0
- package/lib/db/index.js +179 -0
- package/lib/db/repositories/apiKeys.js +72 -0
- package/lib/db/repositories/auditLogs.js +12 -0
- package/lib/db/repositories/botSpaces.js +12 -0
- package/lib/db/repositories/builderSessions.js +312 -0
- package/lib/db/repositories/deploymentEvents.js +12 -0
- package/lib/db/repositories/deployments.js +385 -0
- package/lib/db/repositories/documents.js +68 -0
- package/lib/db/repositories/mcpJobs.js +84 -0
- package/lib/deployers/bot-fleet.js +110 -0
- package/lib/deployers/bot-proxy.js +72 -0
- package/lib/deployers/build.js +89 -0
- package/lib/deployers/cloud-deploy.js +310 -0
- package/lib/deployers/docker.js +439 -0
- package/lib/deployers/fly.js +432 -0
- package/lib/deployers/index.js +38 -0
- package/lib/deployment-auth.js +36 -0
- package/lib/document-parser.js +171 -0
- package/lib/embedder/chunker.js +93 -0
- package/lib/embedder/local.js +101 -0
- package/lib/embedder/preview-rag.js +93 -0
- package/lib/envelope-schema.js +54 -0
- package/lib/fleet/scoped-sql.js +342 -0
- package/lib/form-schema-config/base.js +135 -0
- package/lib/form-schema-config/index.js +286 -0
- package/lib/form-schema-config/locales/af-ZA.js +153 -0
- package/lib/form-schema-config/locales/ar-AE.js +142 -0
- package/lib/form-schema-config/locales/ar-SA.js +164 -0
- package/lib/form-schema-config/locales/de-DE.js +152 -0
- package/lib/form-schema-config/locales/en-AU.js +161 -0
- package/lib/form-schema-config/locales/en-CA.js +115 -0
- package/lib/form-schema-config/locales/en-GB.js +132 -0
- package/lib/form-schema-config/locales/en-IN.js +219 -0
- package/lib/form-schema-config/locales/en-MY.js +171 -0
- package/lib/form-schema-config/locales/en-NG.js +198 -0
- package/lib/form-schema-config/locales/en-PH.js +186 -0
- package/lib/form-schema-config/locales/en-SG.js +153 -0
- package/lib/form-schema-config/locales/en-US.js +138 -0
- package/lib/form-schema-config/locales/es-ES.js +171 -0
- package/lib/form-schema-config/locales/es-MX.js +193 -0
- package/lib/form-schema-config/locales/fr-CA.js +138 -0
- package/lib/form-schema-config/locales/fr-FR.js +155 -0
- package/lib/form-schema-config/locales/hi-IN.js +219 -0
- package/lib/form-schema-config/locales/it-IT.js +157 -0
- package/lib/form-schema-config/locales/ja-JP.js +169 -0
- package/lib/form-schema-config/locales/ko-KR.js +140 -0
- package/lib/form-schema-config/locales/nl-NL.js +149 -0
- package/lib/form-schema-config/locales/pt-BR.js +168 -0
- package/lib/form-schema-config/locales/zh-CN.js +172 -0
- package/lib/form-schema-config/locales/zh-HK.js +142 -0
- package/lib/form-structure-schema.js +191 -0
- package/lib/llm-providers.js +828 -0
- package/lib/markdown.js +197 -0
- package/lib/mcp/catalysts/appointment-to-calendar.md +84 -0
- package/lib/mcp/catalysts/conversations-to-channel-digest.md +104 -0
- package/lib/mcp/catalysts/document-extract-to-store.md +92 -0
- package/lib/mcp/catalysts/knowledge-gap-miner.md +96 -0
- package/lib/mcp/catalysts/loader.js +144 -0
- package/lib/mcp/catalysts/qualify-lead-to-crm.md +83 -0
- package/lib/mcp/catalysts/scan-conversations-for-signal.md +92 -0
- package/lib/mcp/catalysts/submission-to-ticket.md +83 -0
- package/lib/mcp/catalysts/submissions-to-warehouse.md +103 -0
- package/lib/mcp/catalysts/weekly-submissions-digest.md +82 -0
- package/lib/mcp/jobs.js +64 -0
- package/lib/mcp/server.js +184 -0
- package/lib/mcp/session-binding.js +130 -0
- package/lib/mcp/tools/build.js +123 -0
- package/lib/mcp/tools/catalysts.js +477 -0
- package/lib/mcp/tools/context.js +325 -0
- package/lib/mcp/tools/fleet.js +391 -0
- package/lib/mcp/tools/jobs-tools.js +240 -0
- package/lib/mcp/tools/operate.js +314 -0
- package/lib/preview/build-preview-config.js +136 -0
- package/lib/rate-limiter.js +11 -0
- package/lib/resolve-api-key.js +142 -0
- package/lib/storage/index.js +40 -0
- package/messages/de.json +2136 -0
- package/messages/en.json +2136 -0
- package/messages/es.json +2136 -0
- package/messages/fr.json +2136 -0
- package/messages/it.json +2136 -0
- package/messages/ja.json +2136 -0
- package/messages/ko.json +2136 -0
- package/messages/nl.json +2136 -0
- package/messages/pl.json +2136 -0
- package/messages/pt.json +2136 -0
- package/messages/ru.json +2136 -0
- package/messages/uk.json +2136 -0
- package/messages/zh.json +2136 -0
- package/package.json +61 -5
- package/scripts/mcp-config.mjs +162 -0
- package/scripts/mcp-stdio-loader.mjs +42 -0
- package/scripts/mcp-stdio.mjs +108 -0
- package/scripts/mojulo-paths.mjs +48 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for talking to a registered (running) lite bot.
|
|
3
|
+
*
|
|
4
|
+
* The operator pastes the bot's URL on a deployment row; the row's existing
|
|
5
|
+
* api_key is the same value the bot validates as `x-mojulo-api-key`
|
|
6
|
+
* (see lib/deployers/docker.js — the build writes MOJULO_API_KEY=<row.api_key>
|
|
7
|
+
* into the artifact's .env).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
11
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
12
|
+
|
|
13
|
+
export function normalizeBotUrl(raw) {
|
|
14
|
+
if (typeof raw !== 'string') return null;
|
|
15
|
+
const trimmed = raw.trim().replace(/\/+$/, '');
|
|
16
|
+
if (!trimmed) return null;
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = new URL(trimmed);
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
|
24
|
+
if (!parsed.hostname) return null;
|
|
25
|
+
return `${parsed.protocol}//${parsed.host}${parsed.pathname === '/' ? '' : parsed.pathname}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function fetchFromBot(deployment, path, { method = 'GET', timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
|
|
29
|
+
if (!deployment?.url) {
|
|
30
|
+
throw new Error('Bot is not connected');
|
|
31
|
+
}
|
|
32
|
+
const target = `${deployment.url}${path}`;
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
35
|
+
try {
|
|
36
|
+
return await fetch(target, {
|
|
37
|
+
method,
|
|
38
|
+
headers: { 'x-mojulo-api-key': deployment.apiKey },
|
|
39
|
+
signal: controller.signal,
|
|
40
|
+
});
|
|
41
|
+
} finally {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Probe a candidate URL with the row's api_key. Hits /api/conversations with
|
|
48
|
+
* no search params — the lite container returns 200 + count even with no
|
|
49
|
+
* params, which validates both reachability AND key.
|
|
50
|
+
*/
|
|
51
|
+
export async function probeBotConnection(url, apiKey) {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(`${url}/api/conversations`, {
|
|
56
|
+
headers: { 'x-mojulo-api-key': apiKey },
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
});
|
|
59
|
+
if (res.status === 401 || res.status === 403) {
|
|
60
|
+
return { ok: false, reason: 'unauthorized', status: res.status };
|
|
61
|
+
}
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
return { ok: false, reason: 'bad_status', status: res.status };
|
|
64
|
+
}
|
|
65
|
+
return { ok: true };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err.name === 'AbortError') return { ok: false, reason: 'timeout' };
|
|
68
|
+
return { ok: false, reason: 'network', message: err.message };
|
|
69
|
+
} finally {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact build orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the pure DockerDeployer with the DB row lifecycle:
|
|
5
|
+
* markBuilding → DockerDeployer.deploy → setBuildResult / setBuildFailed.
|
|
6
|
+
*
|
|
7
|
+
* Used by /api/deployments/[id]/build and the lazy-build path in
|
|
8
|
+
* /api/deployments/[id]/download.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { DeploymentRepository, DEPLOYMENT_STATUS } from '../db/repositories/deployments.js';
|
|
12
|
+
import { DocumentRepository } from '../db/repositories/documents.js';
|
|
13
|
+
import { deploy as runDeploy } from './index.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build the artifact for a saved deployment row.
|
|
17
|
+
*
|
|
18
|
+
* The lean build (`withDocs=false`) is the canonical artifact tracked on the
|
|
19
|
+
* deployment row via `artifact_path` and `last_built_hash`. The with-docs
|
|
20
|
+
* variant is built on demand and lives at a sibling zip path; it intentionally
|
|
21
|
+
* does NOT update the row, so the lean cache is never displaced.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} deploymentId
|
|
24
|
+
* @param {Object} [options]
|
|
25
|
+
* @param {boolean} [options.withDocs=false]
|
|
26
|
+
* @returns {Promise<{ deployment: Object, artifactPath: string }>}
|
|
27
|
+
*/
|
|
28
|
+
export async function buildArtifact(deploymentId, { withDocs = false } = {}) {
|
|
29
|
+
const deployment = await DeploymentRepository.findById(deploymentId);
|
|
30
|
+
if (!deployment) {
|
|
31
|
+
throw new Error(`Deployment ${deploymentId} not found`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!withDocs) {
|
|
35
|
+
await DeploymentRepository.markBuilding(deploymentId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const config = deployment.config || {};
|
|
40
|
+
const meta = config._modular || {};
|
|
41
|
+
const enabledProtocols =
|
|
42
|
+
meta.enabledProtocols || config.enabledProtocols || {};
|
|
43
|
+
|
|
44
|
+
const documents = withDocs && deployment.documentIds?.length
|
|
45
|
+
? await DocumentRepository.findByIds(deployment.documentIds)
|
|
46
|
+
: [];
|
|
47
|
+
|
|
48
|
+
const result = await runDeploy({
|
|
49
|
+
deploymentId,
|
|
50
|
+
botName: deployment.botName,
|
|
51
|
+
config,
|
|
52
|
+
apiKey: deployment.apiKey,
|
|
53
|
+
appointmentDestinations: config.appointmentDestinations || [],
|
|
54
|
+
triageDestinations: config.triageRoutes || config.triageDestinations || [],
|
|
55
|
+
opticalReadFields: config.opticalReadFields || [],
|
|
56
|
+
enabledProtocols,
|
|
57
|
+
embeddingStorageKey: deployment.embeddingStorageKey || null,
|
|
58
|
+
embeddingModel: deployment.embeddingModel || null,
|
|
59
|
+
embeddingChunkCount: deployment.embeddingChunkCount || null,
|
|
60
|
+
withDocs,
|
|
61
|
+
documents,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (withDocs) {
|
|
65
|
+
return { deployment, artifactPath: result.artifactPath };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const updated = await DeploymentRepository.setBuildResult(deploymentId, {
|
|
69
|
+
artifactPath: result.relativeArtifactPath || result.artifactPath,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return { deployment: updated, artifactPath: result.artifactPath };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (!withDocs) {
|
|
75
|
+
await DeploymentRepository.setBuildFailed(deploymentId, err.message);
|
|
76
|
+
}
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* True if the deployment's stored artifact matches its current config_hash.
|
|
83
|
+
*/
|
|
84
|
+
export function isArtifactFresh(deployment) {
|
|
85
|
+
if (!deployment) return false;
|
|
86
|
+
if (!deployment.artifactPath) return false;
|
|
87
|
+
if (deployment.status !== DEPLOYMENT_STATUS.READY) return false;
|
|
88
|
+
return deployment.lastBuiltHash === deployment.configHash;
|
|
89
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud deploy orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Wraps a provider deployer (FlyDeployer initially) with the deployment
|
|
5
|
+
* row's cloud-state lifecycle:
|
|
6
|
+
* startCloudDeploy → onProgress → finishCloudDeploy / failCloudDeploy.
|
|
7
|
+
*
|
|
8
|
+
* The lifecycle is deliberately parallel to build.js — that wrapper covers
|
|
9
|
+
* the local-artifact path (markBuilding → setBuildResult / setBuildFailed),
|
|
10
|
+
* this one covers the cloud path. The two are independent: a deployment
|
|
11
|
+
* can have a fresh local ZIP and no cloud deploy, or vice versa.
|
|
12
|
+
*
|
|
13
|
+
* Cloud deploy still calls buildArtifact() first, because the staged
|
|
14
|
+
* config files it produces are exactly what the cloud deployer injects
|
|
15
|
+
* into the container via the platform's file API. No code in docker.js or
|
|
16
|
+
* the composer needs to change.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fsp from 'fs/promises';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import {
|
|
22
|
+
DeploymentRepository,
|
|
23
|
+
CLOUD_STATUS,
|
|
24
|
+
} from '../db/repositories/deployments.js';
|
|
25
|
+
import { ApiKeyRepository } from '../db/repositories/apiKeys.js';
|
|
26
|
+
import { decryptApiKey } from '../deployment-auth.js';
|
|
27
|
+
import { buildArtifact, isArtifactFresh } from './build.js';
|
|
28
|
+
import { FlyDeployer } from './fly.js';
|
|
29
|
+
|
|
30
|
+
const ARTIFACTS_DIR =
|
|
31
|
+
process.env.ARTIFACTS_DIR || path.join(process.cwd(), 'data', 'artifacts');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Recursively walk a directory and return [{ relativePath, contents }] for
|
|
35
|
+
* every regular file. Used to harvest the staged config + documents for
|
|
36
|
+
* file-injection into the cloud container.
|
|
37
|
+
*/
|
|
38
|
+
async function readDirRecursive(rootDir) {
|
|
39
|
+
const out = [];
|
|
40
|
+
async function walk(dir, prefix) {
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err.code === 'ENOENT') return;
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const full = path.join(dir, entry.name);
|
|
50
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
await walk(full, rel);
|
|
53
|
+
} else if (entry.isFile()) {
|
|
54
|
+
const buf = await fsp.readFile(full);
|
|
55
|
+
out.push({ relativePath: rel, contents: buf });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
await walk(rootDir, '');
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read the staged config + documents for a deployment, formatted for the
|
|
65
|
+
* provider's file-injection API. Maps host paths → container paths:
|
|
66
|
+
* <staging>/config/X.json → /app/config/X.json
|
|
67
|
+
* <staging>/documents/Y.txt → /app/documents/Y.txt
|
|
68
|
+
*/
|
|
69
|
+
async function harvestConfigFiles(deployment) {
|
|
70
|
+
const stagingDir = path.join(
|
|
71
|
+
ARTIFACTS_DIR,
|
|
72
|
+
`${deployment.botName}-${deployment.id}`
|
|
73
|
+
);
|
|
74
|
+
const out = [];
|
|
75
|
+
|
|
76
|
+
const configFiles = await readDirRecursive(path.join(stagingDir, 'config'));
|
|
77
|
+
for (const f of configFiles) {
|
|
78
|
+
out.push({ guestPath: `/app/config/${f.relativePath}`, contents: f.contents });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const docFiles = await readDirRecursive(path.join(stagingDir, 'documents'));
|
|
82
|
+
for (const f of docFiles) {
|
|
83
|
+
out.push({
|
|
84
|
+
guestPath: `/app/documents/${f.relativePath}`,
|
|
85
|
+
contents: f.contents,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the LLM API key env var for the bot's selected provider, sourced
|
|
94
|
+
* from the encrypted api_keys store — the same key the operator already
|
|
95
|
+
* configured (and that the local-artifact path uses to build/run the bot).
|
|
96
|
+
*
|
|
97
|
+
* Bedrock is a special case: its encrypted_key holds a JSON blob
|
|
98
|
+
* ({ region, accessKeyId, secretAccessKey }) which expands into the three
|
|
99
|
+
* standard AWS env vars on the container.
|
|
100
|
+
*/
|
|
101
|
+
async function resolveLlmEnv(deployment) {
|
|
102
|
+
const provider = deployment.config?.llm?.provider || 'anthropic';
|
|
103
|
+
const env = { LLM_PROVIDER: provider };
|
|
104
|
+
|
|
105
|
+
const record = await ApiKeyRepository.findByProvider(provider);
|
|
106
|
+
if (!record) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`No saved API key for provider "${provider}". Add one in Settings → API keys before deploying.`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let plaintext;
|
|
113
|
+
try {
|
|
114
|
+
plaintext = decryptApiKey(record.encryptedKey);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Failed to decrypt the saved "${provider}" API key. ` +
|
|
118
|
+
`If API_KEY_ENCRYPTION_KEY changed, re-save the key in Settings. (${err.message})`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (provider === 'bedrock') {
|
|
123
|
+
let creds;
|
|
124
|
+
try {
|
|
125
|
+
creds = JSON.parse(plaintext);
|
|
126
|
+
} catch {
|
|
127
|
+
throw new Error(
|
|
128
|
+
'Saved Bedrock credentials are not valid JSON. Reconfigure them in Settings.'
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
env.AWS_REGION = creds.region || 'us-east-1';
|
|
132
|
+
env.AWS_ACCESS_KEY_ID = creds.accessKeyId || '';
|
|
133
|
+
env.AWS_SECRET_ACCESS_KEY = creds.secretAccessKey || '';
|
|
134
|
+
return env;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const envVarByProvider = {
|
|
138
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
139
|
+
openai: 'OPENAI_API_KEY',
|
|
140
|
+
};
|
|
141
|
+
const envName = envVarByProvider[provider];
|
|
142
|
+
if (!envName) {
|
|
143
|
+
throw new Error(`Unsupported provider for cloud deploy: "${provider}"`);
|
|
144
|
+
}
|
|
145
|
+
env[envName] = plaintext;
|
|
146
|
+
return env;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build a provider deployer instance, sourcing credentials from the
|
|
151
|
+
* encrypted api_keys store (Settings → Provider Keys). FLY_ORG_SLUG can
|
|
152
|
+
* still be set via env for the rare non-personal-org case; the secret
|
|
153
|
+
* token never lives in env.
|
|
154
|
+
*/
|
|
155
|
+
async function buildProviderDeployer(provider) {
|
|
156
|
+
if (provider === 'fly') {
|
|
157
|
+
const record = await ApiKeyRepository.findByProvider('fly');
|
|
158
|
+
if (!record) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
'Fly deploy requires a saved Fly.io token. Add one in Settings → Provider Keys.'
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
let apiToken;
|
|
164
|
+
try {
|
|
165
|
+
apiToken = decryptApiKey(record.encryptedKey);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
'Failed to decrypt the saved Fly.io token. ' +
|
|
169
|
+
`If API_KEY_ENCRYPTION_KEY changed, re-save the key in Settings. (${err.message})`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return new FlyDeployer({
|
|
173
|
+
apiToken,
|
|
174
|
+
orgSlug: process.env.FLY_ORG_SLUG || 'personal',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`Unknown cloud provider: ${provider}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Cloud-deploy a saved deployment row. Idempotent against an existing
|
|
182
|
+
* cloud app: redeploying with the same inputs updates the existing
|
|
183
|
+
* machine + reattaches the existing volume.
|
|
184
|
+
*
|
|
185
|
+
* @param {Object} args
|
|
186
|
+
* @param {string} args.deploymentId
|
|
187
|
+
* @param {string} args.provider 'fly' (only one for now)
|
|
188
|
+
* @param {Object} [args.options] { region, guest: { cpus, memory_mb }, volumeGb }
|
|
189
|
+
* @param {string} [args.userId] Used to derive the deterministic app name
|
|
190
|
+
*/
|
|
191
|
+
export async function cloudDeploy({
|
|
192
|
+
deploymentId,
|
|
193
|
+
provider = 'fly',
|
|
194
|
+
options = {},
|
|
195
|
+
userId = 'local',
|
|
196
|
+
}) {
|
|
197
|
+
const deployment = await DeploymentRepository.findById(deploymentId);
|
|
198
|
+
if (!deployment) throw new Error(`Deployment ${deploymentId} not found`);
|
|
199
|
+
|
|
200
|
+
// Build (or reuse) the local artifact first — its staged config files are
|
|
201
|
+
// what the cloud deployer injects.
|
|
202
|
+
if (!isArtifactFresh(deployment)) {
|
|
203
|
+
await buildArtifact(deploymentId);
|
|
204
|
+
}
|
|
205
|
+
const refreshed = await DeploymentRepository.findById(deploymentId);
|
|
206
|
+
|
|
207
|
+
const flyAppName = FlyDeployer.computeAppName({
|
|
208
|
+
userId,
|
|
209
|
+
botName: refreshed.botName,
|
|
210
|
+
});
|
|
211
|
+
const appName = options.appName || flyAppName;
|
|
212
|
+
|
|
213
|
+
await DeploymentRepository.startCloudDeploy(deploymentId, {
|
|
214
|
+
provider,
|
|
215
|
+
appName,
|
|
216
|
+
options,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
let lastStep = 'init';
|
|
220
|
+
try {
|
|
221
|
+
const configFiles = await harvestConfigFiles(refreshed);
|
|
222
|
+
const llmEnv = await resolveLlmEnv(refreshed);
|
|
223
|
+
const env = {
|
|
224
|
+
...llmEnv,
|
|
225
|
+
MOJULO_API_KEY: refreshed.apiKey,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const deployer = await buildProviderDeployer(provider);
|
|
229
|
+
|
|
230
|
+
const result = await deployer.deploy({
|
|
231
|
+
appName,
|
|
232
|
+
configFiles,
|
|
233
|
+
env,
|
|
234
|
+
region: options.region,
|
|
235
|
+
guest: options.guest,
|
|
236
|
+
volumeGb: options.volumeGb,
|
|
237
|
+
onProgress: async ({ step, message }) => {
|
|
238
|
+
lastStep = step;
|
|
239
|
+
try {
|
|
240
|
+
await DeploymentRepository.appendCloudProgress(deploymentId, {
|
|
241
|
+
step,
|
|
242
|
+
message,
|
|
243
|
+
});
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.error('[cloud-deploy:progress]', err);
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const updated = await DeploymentRepository.finishCloudDeploy(deploymentId, {
|
|
251
|
+
url: result.url,
|
|
252
|
+
machineId: result.machineId,
|
|
253
|
+
volumeId: result.volumeId,
|
|
254
|
+
});
|
|
255
|
+
return { deployment: updated, ...result };
|
|
256
|
+
} catch (err) {
|
|
257
|
+
const detail = `${err.message || String(err)} (failed at step: ${lastStep})`;
|
|
258
|
+
console.error('[cloud-deploy]', err);
|
|
259
|
+
await DeploymentRepository.appendCloudProgress(deploymentId, {
|
|
260
|
+
step: 'error',
|
|
261
|
+
message: detail,
|
|
262
|
+
}).catch(() => {});
|
|
263
|
+
await DeploymentRepository.failCloudDeploy(deploymentId, detail);
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function cloudDestroy({ deploymentId }) {
|
|
269
|
+
const deployment = await DeploymentRepository.findById(deploymentId);
|
|
270
|
+
if (!deployment) throw new Error(`Deployment ${deploymentId} not found`);
|
|
271
|
+
if (!deployment.cloudProvider || !deployment.cloudAppName) {
|
|
272
|
+
return { ok: true, alreadyDestroyed: true };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const deployer = await buildProviderDeployer(deployment.cloudProvider);
|
|
276
|
+
await deployer.destroy(deployment.cloudAppName);
|
|
277
|
+
await DeploymentRepository.clearCloudDeploy(deploymentId);
|
|
278
|
+
return { ok: true };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function cloudPause({ deploymentId }) {
|
|
282
|
+
const deployment = await DeploymentRepository.findById(deploymentId);
|
|
283
|
+
if (!deployment?.cloudAppName || !deployment.cloudProvider) {
|
|
284
|
+
throw new Error('No active cloud deploy to pause');
|
|
285
|
+
}
|
|
286
|
+
const deployer = await buildProviderDeployer(deployment.cloudProvider);
|
|
287
|
+
await deployer.pause(deployment.cloudAppName);
|
|
288
|
+
await DeploymentRepository.setCloudStatus(deploymentId, CLOUD_STATUS.PAUSED);
|
|
289
|
+
return { ok: true };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function cloudResume({ deploymentId }) {
|
|
293
|
+
const deployment = await DeploymentRepository.findById(deploymentId);
|
|
294
|
+
if (!deployment?.cloudAppName || !deployment.cloudProvider) {
|
|
295
|
+
throw new Error('No cloud deploy to resume');
|
|
296
|
+
}
|
|
297
|
+
const deployer = await buildProviderDeployer(deployment.cloudProvider);
|
|
298
|
+
await deployer.resume(deployment.cloudAppName);
|
|
299
|
+
await DeploymentRepository.setCloudStatus(deploymentId, CLOUD_STATUS.RUNNING);
|
|
300
|
+
return { ok: true };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function cloudGetStatus({ deploymentId }) {
|
|
304
|
+
const deployment = await DeploymentRepository.findById(deploymentId);
|
|
305
|
+
if (!deployment?.cloudAppName || !deployment.cloudProvider) {
|
|
306
|
+
return { status: 'not_deployed' };
|
|
307
|
+
}
|
|
308
|
+
const deployer = await buildProviderDeployer(deployment.cloudProvider);
|
|
309
|
+
return deployer.getStatus(deployment.cloudAppName);
|
|
310
|
+
}
|