tide-commander 1.95.0 → 1.96.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/dist/assets/{BossLogsModal-BNfB6g0E.js → BossLogsModal-BFy6Rxdn.js} +1 -1
- package/dist/assets/{BossSpawnModal-BIcCbrjM.js → BossSpawnModal-KQyJYj_B.js} +1 -1
- package/dist/assets/{ControlsModal-CS5jOEdY.js → ControlsModal-W_ghuG_Q.js} +1 -1
- package/dist/assets/{DockerLogsModal-B6sUNqY_.js → DockerLogsModal-valTcuRy.js} +1 -1
- package/dist/assets/{EmbeddedEditor-DiXmHZpX.js → EmbeddedEditor-CIsFWo6A.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-OPmwhJyE.js → GmailOAuthSetup-AcW1zAlk.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-BEc7lAua.js → GoogleOAuthSetup-C9uZ46Oe.js} +1 -1
- package/dist/assets/{IframeModal-VrlQUMlO.js → IframeModal-CmWv7GXl.js} +1 -1
- package/dist/assets/{IntegrationsPanel-ta1yp-s4.js → IntegrationsPanel-CNvzosgx.js} +2 -2
- package/dist/assets/{LogViewerModal-B_4ke-1p.js → LogViewerModal-imTE-619.js} +1 -1
- package/dist/assets/{MonitoringModal-BHAuVdYA.js → MonitoringModal-DLxBY738.js} +1 -1
- package/dist/assets/{PM2LogsModal-BfKic3hD.js → PM2LogsModal-S4n0D11V.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-Bxk9GcPa.js → RestoreArchivedAreaModal-B32sqCxN.js} +1 -1
- package/dist/assets/{Scene2DCanvas-COVSkNbV.js → Scene2DCanvas-bEu0UPUm.js} +1 -1
- package/dist/assets/{SceneManager-DWVI2idg.js → SceneManager-Ce3X0awy.js} +1 -1
- package/dist/assets/{SkillsPanel-BnKqbJyg.js → SkillsPanel-Dj22nDU8.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-BuA87Vlm.js → SlackMultiInstanceSetup-J9NdRY0U.js} +1 -1
- package/dist/assets/{SpawnModal-ClZUpgWy.js → SpawnModal-F9nspdWd.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-Dn8tejNU.js → SubordinateAssignmentModal-DT31CkPr.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-oBoHJdcv.js → TriggerManagerPanel-CVJ9ozHz.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-Bwju9-46.js → WorkflowEditorPanel-BYRi0cU9.js} +1 -1
- package/dist/assets/{index-CBh6qNCb.js → index-B9G4hrFR.js} +1 -1
- package/dist/assets/{index-CXBrQLNP.js → index-BPy8NE5B.js} +3 -3
- package/dist/assets/{index-DMUs4kjY.js → index-BmjrPptb.js} +1 -1
- package/dist/assets/{index-Bu_n7vgB.js → index-ByfAbkqv.js} +1 -1
- package/dist/assets/{index-umTVv-4x.js → index-C5wC_Xis.js} +1 -1
- package/dist/assets/{index-DgwVJN80.js → index-CLr8DpLX.js} +2 -2
- package/dist/assets/{index-fIzifjgU.js → index-DJm7oMXk.js} +1 -1
- package/dist/assets/{index-CfliOGe8.js → index-DkjWoHhy.js} +1 -1
- package/dist/assets/{index-DvBhO5je.js → index-DqZhHROd.js} +1 -1
- package/dist/assets/{main-BaGMbjuZ.js → main-9uTEp9Lr.js} +4 -4
- package/dist/assets/{web-pfDqogx0.js → web-CLuvtqCm.js} +1 -1
- package/dist/assets/{web-Cp8n5FK3.js → web-D-a3phls.js} +1 -1
- package/dist/assets/{web-BD4VGICh.js → web-DM2bFWBN.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/src/packages/server/data/builtin-skills/create-building.js +521 -484
- package/dist/src/packages/server/routes/buildings.js +298 -0
- package/dist/src/packages/server/routes/index.js +3 -1
- package/dist/src/packages/server/services/building-service.js +400 -85
- package/dist/src/packages/server/websocket/handler.js +2 -4
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { exec } from 'child_process';
|
|
7
7
|
import { promisify } from 'util';
|
|
8
8
|
import { loadBuildings, saveBuildings } from '../data/index.js';
|
|
9
|
-
import { createLogger } from '../utils/index.js';
|
|
9
|
+
import { createLogger, generateId } from '../utils/index.js';
|
|
10
10
|
import * as pm2Service from './pm2-service.js';
|
|
11
11
|
import * as dockerService from './docker-service.js';
|
|
12
12
|
import * as terminalService from './terminal-service.js';
|
|
@@ -939,6 +939,95 @@ function pollTerminalStatus(broadcast) {
|
|
|
939
939
|
export function cleanupAllTerminals() {
|
|
940
940
|
terminalService.cleanupAllTerminals();
|
|
941
941
|
}
|
|
942
|
+
/**
|
|
943
|
+
* Reconcile runtime state for a single building when its config changes.
|
|
944
|
+
* Handles PM2 rename/restart, Docker rename/restart, and database tunnel teardown.
|
|
945
|
+
* Shared by handleBuildingSync (full sync from client) and updateBuilding (REST patch).
|
|
946
|
+
*/
|
|
947
|
+
async function reconcileBuilding(oldBuilding, newBuilding) {
|
|
948
|
+
if (newBuilding.pm2?.enabled && oldBuilding.pm2?.enabled) {
|
|
949
|
+
const oldPM2Name = pm2Service.getPM2Name(oldBuilding);
|
|
950
|
+
const newPM2Name = pm2Service.getPM2Name(newBuilding);
|
|
951
|
+
const nameChanged = oldPM2Name !== newPM2Name;
|
|
952
|
+
const configChanged = hasPM2ConfigChanged(oldBuilding, newBuilding);
|
|
953
|
+
if (nameChanged || configChanged) {
|
|
954
|
+
const changeType = nameChanged ? 'name' : 'config';
|
|
955
|
+
log.log(`Building ${newBuilding.id}: PM2 ${changeType} changed`);
|
|
956
|
+
const oldStatus = await pm2Service.getStatus(oldBuilding);
|
|
957
|
+
const wasRunning = oldStatus?.status === 'online';
|
|
958
|
+
await pm2Service.deleteProcess(oldBuilding);
|
|
959
|
+
log.log(`Deleted old PM2 process: ${oldPM2Name}`);
|
|
960
|
+
if (wasRunning) {
|
|
961
|
+
const result = await pm2Service.startProcess(newBuilding);
|
|
962
|
+
if (result.success) {
|
|
963
|
+
log.log(`Started PM2 process with new ${changeType}: ${newPM2Name}`);
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
log.error(`Failed to start PM2 process ${newPM2Name}: ${result.error}`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
// Database connection changes — tear down tunnels for connections that
|
|
972
|
+
// were removed or whose SSH config materially changed.
|
|
973
|
+
if (oldBuilding.type === 'database') {
|
|
974
|
+
const oldConns = oldBuilding.database?.connections || [];
|
|
975
|
+
const newConns = newBuilding.type === 'database'
|
|
976
|
+
? (newBuilding.database?.connections || [])
|
|
977
|
+
: [];
|
|
978
|
+
const newConnsById = new Map(newConns.map(c => [c.id, c]));
|
|
979
|
+
for (const oldConn of oldConns) {
|
|
980
|
+
const fresh = newConnsById.get(oldConn.id);
|
|
981
|
+
if (!fresh) {
|
|
982
|
+
await databaseService.closeConnection(oldConn.id);
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
const oldKey = JSON.stringify({
|
|
986
|
+
ssh: oldConn.ssh ?? null,
|
|
987
|
+
host: oldConn.host,
|
|
988
|
+
port: oldConn.port,
|
|
989
|
+
});
|
|
990
|
+
const newKey = JSON.stringify({
|
|
991
|
+
ssh: fresh.ssh ?? null,
|
|
992
|
+
host: fresh.host,
|
|
993
|
+
port: fresh.port,
|
|
994
|
+
});
|
|
995
|
+
if (oldKey !== newKey) {
|
|
996
|
+
await databaseService.closeConnection(oldConn.id);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (newBuilding.docker?.enabled && oldBuilding.docker?.enabled) {
|
|
1001
|
+
const oldContainerName = dockerService.getContainerName(oldBuilding);
|
|
1002
|
+
const newContainerName = dockerService.getContainerName(newBuilding);
|
|
1003
|
+
const nameChanged = oldContainerName !== newContainerName;
|
|
1004
|
+
const configChanged = hasDockerConfigChanged(oldBuilding, newBuilding);
|
|
1005
|
+
if (nameChanged || configChanged) {
|
|
1006
|
+
const changeType = nameChanged ? 'name' : 'config';
|
|
1007
|
+
log.log(`Building ${newBuilding.id}: Docker ${changeType} changed`);
|
|
1008
|
+
const oldStatus = await dockerService.getStatus(oldBuilding);
|
|
1009
|
+
const wasRunning = oldStatus?.status === 'running';
|
|
1010
|
+
if (oldBuilding.docker.mode === 'compose') {
|
|
1011
|
+
await dockerService.composeDown(oldBuilding);
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
await dockerService.removeContainer(oldBuilding);
|
|
1015
|
+
}
|
|
1016
|
+
log.log(`Removed old Docker container: ${oldContainerName}`);
|
|
1017
|
+
if (wasRunning) {
|
|
1018
|
+
const result = newBuilding.docker.mode === 'compose'
|
|
1019
|
+
? await dockerService.composeUp(newBuilding)
|
|
1020
|
+
: await dockerService.startContainer(newBuilding);
|
|
1021
|
+
if (result.success) {
|
|
1022
|
+
log.log(`Started Docker container with new ${changeType}: ${newContainerName}`);
|
|
1023
|
+
}
|
|
1024
|
+
else {
|
|
1025
|
+
log.error(`Failed to start Docker container ${newContainerName}: ${result.error}`);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
942
1031
|
/**
|
|
943
1032
|
* Handle building sync - detect PM2/Docker name/config changes and update processes
|
|
944
1033
|
* Called when buildings are synced from the client
|
|
@@ -960,98 +1049,324 @@ export async function handleBuildingSync(newBuildings, _broadcast) {
|
|
|
960
1049
|
}
|
|
961
1050
|
for (const newBuilding of newBuildings) {
|
|
962
1051
|
const oldBuilding = oldBuildingsMap.get(newBuilding.id);
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1052
|
+
if (oldBuilding) {
|
|
1053
|
+
await reconcileBuilding(oldBuilding, newBuilding);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
// ============================================================================
|
|
1058
|
+
// Validation & CRUD (Phase 1 - REST API support)
|
|
1059
|
+
// ============================================================================
|
|
1060
|
+
const VALID_TYPES = new Set([
|
|
1061
|
+
'server', 'link', 'database', 'docker', 'monitor', 'folder', 'boss', 'terminal',
|
|
1062
|
+
]);
|
|
1063
|
+
const VALID_STYLES = new Set([
|
|
1064
|
+
'server-rack', 'tower', 'dome', 'pyramid', 'desktop',
|
|
1065
|
+
'filing-cabinet', 'satellite', 'crystal', 'factory', 'command-center',
|
|
1066
|
+
]);
|
|
1067
|
+
const VALID_INTERPRETERS = new Set([
|
|
1068
|
+
'', 'node', 'bun', 'python3', 'python', 'java', 'php', 'bash', 'none',
|
|
1069
|
+
]);
|
|
1070
|
+
const VALID_DOCKER_MODES = new Set([
|
|
1071
|
+
'container', 'compose', 'existing',
|
|
1072
|
+
]);
|
|
1073
|
+
const VALID_DB_ENGINES = new Set([
|
|
1074
|
+
'mysql', 'postgresql', 'oracle', 'sqlite', 'mssql',
|
|
1075
|
+
]);
|
|
1076
|
+
const DEFAULT_STYLE_BY_TYPE = {
|
|
1077
|
+
server: 'server-rack',
|
|
1078
|
+
link: 'tower',
|
|
1079
|
+
database: 'dome',
|
|
1080
|
+
docker: 'crystal',
|
|
1081
|
+
monitor: 'satellite',
|
|
1082
|
+
folder: 'filing-cabinet',
|
|
1083
|
+
boss: 'command-center',
|
|
1084
|
+
terminal: 'desktop',
|
|
1085
|
+
};
|
|
1086
|
+
function generateBuildingId(name) {
|
|
1087
|
+
const slug = name
|
|
1088
|
+
.toLowerCase()
|
|
1089
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
1090
|
+
.replace(/^_+|_+$/g, '')
|
|
1091
|
+
.slice(0, 40);
|
|
1092
|
+
return `building_${Date.now()}_${slug || generateId()}`;
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Pure schema validator. Does not consult other buildings (cross-building
|
|
1096
|
+
* checks like dangling boss subordinates happen in createBuilding/updateBuilding).
|
|
1097
|
+
*/
|
|
1098
|
+
export function validateBuilding(input) {
|
|
1099
|
+
const errors = [];
|
|
1100
|
+
if (typeof input.name !== 'string' || !input.name.trim()) {
|
|
1101
|
+
errors.push('name is required (non-empty string)');
|
|
1102
|
+
}
|
|
1103
|
+
if (!input.type || !VALID_TYPES.has(input.type)) {
|
|
1104
|
+
errors.push(`type must be one of: ${[...VALID_TYPES].join(', ')}`);
|
|
1105
|
+
}
|
|
1106
|
+
if (input.style && !VALID_STYLES.has(input.style)) {
|
|
1107
|
+
errors.push(`style must be one of: ${[...VALID_STYLES].join(', ')}`);
|
|
1108
|
+
}
|
|
1109
|
+
if (!input.position
|
|
1110
|
+
|| typeof input.position.x !== 'number'
|
|
1111
|
+
|| typeof input.position.z !== 'number') {
|
|
1112
|
+
errors.push('position is required ({x: number, z: number})');
|
|
1113
|
+
}
|
|
1114
|
+
if (input.pm2?.enabled) {
|
|
1115
|
+
if (!input.pm2.script || typeof input.pm2.script !== 'string') {
|
|
1116
|
+
errors.push('pm2.script is required when pm2.enabled is true');
|
|
1117
|
+
}
|
|
1118
|
+
if (input.pm2.interpreter !== undefined && !VALID_INTERPRETERS.has(input.pm2.interpreter)) {
|
|
1119
|
+
const allowed = [...VALID_INTERPRETERS].filter(i => i).join(', ');
|
|
1120
|
+
errors.push(`pm2.interpreter must be one of: ${allowed} (or "" for auto-detect)`);
|
|
992
1121
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
if (
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1122
|
+
}
|
|
1123
|
+
if (input.docker?.enabled) {
|
|
1124
|
+
if (!input.docker.mode || !VALID_DOCKER_MODES.has(input.docker.mode)) {
|
|
1125
|
+
errors.push(`docker.mode must be one of: ${[...VALID_DOCKER_MODES].join(', ')}`);
|
|
1126
|
+
}
|
|
1127
|
+
if (input.docker.mode === 'container' && !input.docker.image) {
|
|
1128
|
+
errors.push('docker.image is required when docker.mode is "container"');
|
|
1129
|
+
}
|
|
1130
|
+
if (input.docker.mode === 'compose' && !input.docker.composePath) {
|
|
1131
|
+
errors.push('docker.composePath is required when docker.mode is "compose"');
|
|
1132
|
+
}
|
|
1133
|
+
if (input.docker.mode === 'existing' && !input.docker.containerName) {
|
|
1134
|
+
errors.push('docker.containerName is required when docker.mode is "existing"');
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
if (input.type === 'database') {
|
|
1138
|
+
const conns = input.database?.connections;
|
|
1139
|
+
if (!Array.isArray(conns) || conns.length === 0) {
|
|
1140
|
+
errors.push('database.connections must be a non-empty array for database buildings');
|
|
1141
|
+
}
|
|
1142
|
+
else {
|
|
1143
|
+
for (const conn of conns) {
|
|
1144
|
+
const label = conn.id || conn.name || '(unnamed)';
|
|
1145
|
+
if (!conn.id)
|
|
1146
|
+
errors.push(`database.connection ${label}: id is required`);
|
|
1147
|
+
if (!conn.name)
|
|
1148
|
+
errors.push(`database.connection ${label}: name is required`);
|
|
1149
|
+
if (!conn.engine || !VALID_DB_ENGINES.has(conn.engine)) {
|
|
1150
|
+
errors.push(`database.connection ${label}: engine must be one of ${[...VALID_DB_ENGINES].join(', ')}`);
|
|
1006
1151
|
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
if (oldKey !== newKey) {
|
|
1018
|
-
await databaseService.closeConnection(oldConn.id);
|
|
1152
|
+
if (conn.engine === 'sqlite') {
|
|
1153
|
+
if (!conn.filepath) {
|
|
1154
|
+
errors.push(`database.connection ${label}: filepath is required for sqlite`);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
else if (conn.engine) {
|
|
1158
|
+
if (!conn.host)
|
|
1159
|
+
errors.push(`database.connection ${label}: host is required for ${conn.engine}`);
|
|
1160
|
+
if (typeof conn.port !== 'number')
|
|
1161
|
+
errors.push(`database.connection ${label}: port must be a number for ${conn.engine}`);
|
|
1019
1162
|
}
|
|
1020
1163
|
}
|
|
1021
1164
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1165
|
+
}
|
|
1166
|
+
if (input.type === 'terminal' && !input.terminal?.enabled) {
|
|
1167
|
+
errors.push('terminal.enabled must be true for terminal buildings');
|
|
1168
|
+
}
|
|
1169
|
+
if (input.type === 'folder' && !input.folderPath) {
|
|
1170
|
+
errors.push('folderPath is required for folder buildings');
|
|
1171
|
+
}
|
|
1172
|
+
if (input.type === 'link' && (!input.urls || input.urls.length === 0)) {
|
|
1173
|
+
errors.push('urls is required (non-empty array) for link buildings');
|
|
1174
|
+
}
|
|
1175
|
+
return { ok: errors.length === 0, errors };
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Create a new building. Server assigns id, createdAt, and initial status.
|
|
1179
|
+
* Validates schema and cross-building references (boss subordinates).
|
|
1180
|
+
*/
|
|
1181
|
+
export async function createBuilding(input, broadcast) {
|
|
1182
|
+
const validation = validateBuilding(input);
|
|
1183
|
+
if (!validation.ok) {
|
|
1184
|
+
return { ok: false, status: 400, errors: validation.errors };
|
|
1185
|
+
}
|
|
1186
|
+
const buildings = loadBuildings();
|
|
1187
|
+
if (input.type === 'boss' && input.subordinateBuildingIds?.length) {
|
|
1188
|
+
const existingIds = new Set(buildings.map(b => b.id));
|
|
1189
|
+
const dangling = input.subordinateBuildingIds.filter(id => !existingIds.has(id));
|
|
1190
|
+
if (dangling.length > 0) {
|
|
1191
|
+
return {
|
|
1192
|
+
ok: false,
|
|
1193
|
+
status: 400,
|
|
1194
|
+
errors: [`subordinateBuildingIds reference unknown buildings: ${dangling.join(', ')}`],
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
const now = Date.now();
|
|
1199
|
+
const building = {
|
|
1200
|
+
...input,
|
|
1201
|
+
id: generateBuildingId(input.name),
|
|
1202
|
+
createdAt: now,
|
|
1203
|
+
lastActivity: now,
|
|
1204
|
+
status: 'stopped',
|
|
1205
|
+
style: input.style || DEFAULT_STYLE_BY_TYPE[input.type],
|
|
1206
|
+
};
|
|
1207
|
+
buildings.push(building);
|
|
1208
|
+
saveBuildings(buildings);
|
|
1209
|
+
broadcast({ type: 'building_created', payload: building });
|
|
1210
|
+
log.log(`Created building ${building.name} (${building.id})`);
|
|
1211
|
+
return { ok: true, building };
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Update an existing building. Strips server-managed fields from the patch,
|
|
1215
|
+
* runs the same validation as create, and reconciles runtime state
|
|
1216
|
+
* (PM2 / Docker / DB tunnels) if relevant fields changed.
|
|
1217
|
+
*/
|
|
1218
|
+
export async function updateBuilding(id, patch, broadcast) {
|
|
1219
|
+
const buildings = loadBuildings();
|
|
1220
|
+
const idx = buildings.findIndex(b => b.id === id);
|
|
1221
|
+
if (idx === -1) {
|
|
1222
|
+
return { ok: false, status: 404, errors: [`Building not found: ${id}`] };
|
|
1223
|
+
}
|
|
1224
|
+
const oldBuilding = buildings[idx];
|
|
1225
|
+
// Strip server-managed fields from the patch — clients cannot change them.
|
|
1226
|
+
const safePatch = { ...patch };
|
|
1227
|
+
delete safePatch.id;
|
|
1228
|
+
delete safePatch.createdAt;
|
|
1229
|
+
delete safePatch.pm2Status;
|
|
1230
|
+
delete safePatch.dockerStatus;
|
|
1231
|
+
delete safePatch.terminalStatus;
|
|
1232
|
+
const newBuilding = {
|
|
1233
|
+
...oldBuilding,
|
|
1234
|
+
...safePatch,
|
|
1235
|
+
id: oldBuilding.id,
|
|
1236
|
+
createdAt: oldBuilding.createdAt,
|
|
1237
|
+
lastActivity: Date.now(),
|
|
1238
|
+
};
|
|
1239
|
+
const validation = validateBuilding(newBuilding);
|
|
1240
|
+
if (!validation.ok) {
|
|
1241
|
+
return { ok: false, status: 400, errors: validation.errors };
|
|
1242
|
+
}
|
|
1243
|
+
if (newBuilding.type === 'boss' && newBuilding.subordinateBuildingIds?.length) {
|
|
1244
|
+
const existingIds = new Set(buildings.map(b => b.id));
|
|
1245
|
+
const dangling = newBuilding.subordinateBuildingIds.filter(sid => !existingIds.has(sid));
|
|
1246
|
+
if (dangling.length > 0) {
|
|
1247
|
+
return {
|
|
1248
|
+
ok: false,
|
|
1249
|
+
status: 400,
|
|
1250
|
+
errors: [`subordinateBuildingIds reference unknown buildings: ${dangling.join(', ')}`],
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
buildings[idx] = newBuilding;
|
|
1255
|
+
saveBuildings(buildings);
|
|
1256
|
+
try {
|
|
1257
|
+
await reconcileBuilding(oldBuilding, newBuilding);
|
|
1258
|
+
}
|
|
1259
|
+
catch (err) {
|
|
1260
|
+
log.error(`Reconcile failed for building ${id}:`, err);
|
|
1261
|
+
}
|
|
1262
|
+
broadcast({ type: 'building_updated', payload: newBuilding });
|
|
1263
|
+
log.log(`Updated building ${newBuilding.name} (${newBuilding.id})`);
|
|
1264
|
+
return { ok: true, building: newBuilding };
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Delete a building. By default runs runtime cleanup (PM2 delete, Docker rm,
|
|
1268
|
+
* terminal teardown, DB tunnel close) before removing from the list.
|
|
1269
|
+
* Pass { cleanup: false } to skip cleanup (caller takes responsibility for orphan processes).
|
|
1270
|
+
* Also removes the deleted ID from any boss building's subordinate list.
|
|
1271
|
+
*/
|
|
1272
|
+
export async function deleteBuilding(id, broadcast, opts = {}) {
|
|
1273
|
+
const buildings = loadBuildings();
|
|
1274
|
+
const idx = buildings.findIndex(b => b.id === id);
|
|
1275
|
+
if (idx === -1) {
|
|
1276
|
+
return { ok: false, status: 404, error: `Building not found: ${id}` };
|
|
1277
|
+
}
|
|
1278
|
+
const building = buildings[idx];
|
|
1279
|
+
if (opts.cleanup !== false) {
|
|
1280
|
+
try {
|
|
1281
|
+
await executeCommand(id, 'delete', broadcast);
|
|
1282
|
+
}
|
|
1283
|
+
catch (err) {
|
|
1284
|
+
log.error(`Cleanup during delete failed for ${building.name}:`, err);
|
|
1285
|
+
}
|
|
1286
|
+
if (building.type === 'database') {
|
|
1287
|
+
for (const conn of building.database?.connections || []) {
|
|
1288
|
+
try {
|
|
1289
|
+
await databaseService.closeConnection(conn.id);
|
|
1040
1290
|
}
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
if (wasRunning) {
|
|
1044
|
-
const result = newBuilding.docker.mode === 'compose'
|
|
1045
|
-
? await dockerService.composeUp(newBuilding)
|
|
1046
|
-
: await dockerService.startContainer(newBuilding);
|
|
1047
|
-
if (result.success) {
|
|
1048
|
-
log.log(`Started Docker container with new ${changeType}: ${newContainerName}`);
|
|
1049
|
-
}
|
|
1050
|
-
else {
|
|
1051
|
-
log.error(`Failed to start Docker container ${newContainerName}: ${result.error}`);
|
|
1052
|
-
}
|
|
1291
|
+
catch (err) {
|
|
1292
|
+
log.error(`Failed to close DB connection ${conn.id}:`, err);
|
|
1053
1293
|
}
|
|
1054
1294
|
}
|
|
1055
1295
|
}
|
|
1056
1296
|
}
|
|
1297
|
+
buildings.splice(idx, 1);
|
|
1298
|
+
// Drop dangling subordinate references in remaining boss buildings.
|
|
1299
|
+
for (const b of buildings) {
|
|
1300
|
+
if (b.type === 'boss' && b.subordinateBuildingIds?.includes(id)) {
|
|
1301
|
+
b.subordinateBuildingIds = b.subordinateBuildingIds.filter(sid => sid !== id);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
saveBuildings(buildings);
|
|
1305
|
+
broadcast({ type: 'building_deleted', payload: { id } });
|
|
1306
|
+
log.log(`Deleted building ${building.name} (${id})`);
|
|
1307
|
+
return { ok: true };
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Assign / replace the subordinates of a boss building.
|
|
1311
|
+
* Validates that the building is a boss and that all subordinate IDs exist.
|
|
1312
|
+
*/
|
|
1313
|
+
export async function assignSubordinates(bossId, subordinateBuildingIds, broadcast) {
|
|
1314
|
+
const buildings = loadBuildings();
|
|
1315
|
+
const idx = buildings.findIndex(b => b.id === bossId);
|
|
1316
|
+
if (idx === -1) {
|
|
1317
|
+
return { ok: false, status: 404, errors: [`Building not found: ${bossId}`] };
|
|
1318
|
+
}
|
|
1319
|
+
if (buildings[idx].type !== 'boss') {
|
|
1320
|
+
return { ok: false, status: 400, errors: [`Building ${bossId} is not a boss building`] };
|
|
1321
|
+
}
|
|
1322
|
+
const existingIds = new Set(buildings.map(b => b.id));
|
|
1323
|
+
const dangling = subordinateBuildingIds.filter(sid => !existingIds.has(sid));
|
|
1324
|
+
if (dangling.length > 0) {
|
|
1325
|
+
return {
|
|
1326
|
+
ok: false,
|
|
1327
|
+
status: 400,
|
|
1328
|
+
errors: [`subordinateBuildingIds reference unknown buildings: ${dangling.join(', ')}`],
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
buildings[idx] = {
|
|
1332
|
+
...buildings[idx],
|
|
1333
|
+
subordinateBuildingIds: [...subordinateBuildingIds],
|
|
1334
|
+
lastActivity: Date.now(),
|
|
1335
|
+
};
|
|
1336
|
+
saveBuildings(buildings);
|
|
1337
|
+
broadcast({ type: 'building_updated', payload: buildings[idx] });
|
|
1338
|
+
return { ok: true, building: buildings[idx] };
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Snapshot logs for a building. Returns logs as a string and the source
|
|
1342
|
+
* ('pm2' | 'docker' | 'custom' | 'none') so the caller can label them.
|
|
1343
|
+
*/
|
|
1344
|
+
export async function getBuildingLogs(id, lines = 200, service) {
|
|
1345
|
+
const building = getBuilding(id);
|
|
1346
|
+
if (!building) {
|
|
1347
|
+
return { ok: false, status: 404, error: `Building not found: ${id}` };
|
|
1348
|
+
}
|
|
1349
|
+
if (building.pm2?.enabled) {
|
|
1350
|
+
const logs = await pm2Service.getLogs(building, lines);
|
|
1351
|
+
return { ok: true, source: 'pm2', logs };
|
|
1352
|
+
}
|
|
1353
|
+
if (building.docker?.enabled) {
|
|
1354
|
+
const logs = await dockerService.getLogs(building, lines, service);
|
|
1355
|
+
return { ok: true, source: 'docker', logs };
|
|
1356
|
+
}
|
|
1357
|
+
const customLogsCmd = building.commands?.logs;
|
|
1358
|
+
if (customLogsCmd) {
|
|
1359
|
+
try {
|
|
1360
|
+
const { stdout, stderr } = await execAsync(customLogsCmd, {
|
|
1361
|
+
cwd: building.cwd,
|
|
1362
|
+
maxBuffer: 1024 * 1024,
|
|
1363
|
+
timeout: 15000,
|
|
1364
|
+
});
|
|
1365
|
+
return { ok: true, source: 'custom', logs: stdout + (stderr ? `\n${stderr}` : '') };
|
|
1366
|
+
}
|
|
1367
|
+
catch (err) {
|
|
1368
|
+
return { ok: true, source: 'custom', logs: `Error running logs command: ${err.message}` };
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
return { ok: true, source: 'none', logs: '' };
|
|
1057
1372
|
}
|
|
@@ -6,7 +6,7 @@ import { WebSocketServer, WebSocket } from 'ws';
|
|
|
6
6
|
import { agentService, bossMessageService, customClassService, permissionService, runtimeService, skillService, triggerService, workflowService, } from '../services/index.js';
|
|
7
7
|
import { loadAreas, loadBuildings } from '../data/index.js';
|
|
8
8
|
import { logger } from '../utils/index.js';
|
|
9
|
-
import { setNotificationBroadcast, setExecBroadcast, setFocusAgentBroadcast, setAgentsBroadcast, setTriggerBroadcast } from '../routes/index.js';
|
|
9
|
+
import { setNotificationBroadcast, setExecBroadcast, setFocusAgentBroadcast, setAgentsBroadcast, setTriggerBroadcast, setBuildingsBroadcast } from '../routes/index.js';
|
|
10
10
|
import { validateWebSocketAuth, isAuthEnabled } from '../auth/index.js';
|
|
11
11
|
import { incrementWsSent, incrementWsReceived, setWsClientsCount } from '../routes/perf.js';
|
|
12
12
|
import { handleSpawnAgent, handleKillAgent, handleStopAgent, handleClearContext, handleRestoreSession, handleRequestSessionHistory, handleCollapseContext, handleRequestContextStats, handleMoveAgent, handleRemoveAgent, handleRenameAgent, handleUpdateAgentProperties, handleCreateDirectory, handleReattachAgent, } from './handlers/agent-handler.js';
|
|
@@ -146,9 +146,6 @@ const messageHandlers = {
|
|
|
146
146
|
update_agent_properties: handleUpdateAgentProperties,
|
|
147
147
|
sync_areas: handleSyncAreas,
|
|
148
148
|
sync_buildings: handleSyncBuildings,
|
|
149
|
-
create_building: noopHandler,
|
|
150
|
-
update_building: noopHandler,
|
|
151
|
-
delete_building: noopHandler,
|
|
152
149
|
building_command: handleBuildingCommand,
|
|
153
150
|
pm2_logs_start: handlePM2LogsStart,
|
|
154
151
|
pm2_logs_stop: handlePM2LogsStop,
|
|
@@ -304,6 +301,7 @@ export function init(server) {
|
|
|
304
301
|
setFocusAgentBroadcast(broadcast);
|
|
305
302
|
setAgentsBroadcast(broadcast);
|
|
306
303
|
setTriggerBroadcast(broadcast);
|
|
304
|
+
setBuildingsBroadcast(broadcast);
|
|
307
305
|
log.log('Handler initialized');
|
|
308
306
|
return wss;
|
|
309
307
|
}
|