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.
Files changed (41) hide show
  1. package/dist/assets/{BossLogsModal-BNfB6g0E.js → BossLogsModal-BFy6Rxdn.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-BIcCbrjM.js → BossSpawnModal-KQyJYj_B.js} +1 -1
  3. package/dist/assets/{ControlsModal-CS5jOEdY.js → ControlsModal-W_ghuG_Q.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-B6sUNqY_.js → DockerLogsModal-valTcuRy.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-DiXmHZpX.js → EmbeddedEditor-CIsFWo6A.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-OPmwhJyE.js → GmailOAuthSetup-AcW1zAlk.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-BEc7lAua.js → GoogleOAuthSetup-C9uZ46Oe.js} +1 -1
  8. package/dist/assets/{IframeModal-VrlQUMlO.js → IframeModal-CmWv7GXl.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-ta1yp-s4.js → IntegrationsPanel-CNvzosgx.js} +2 -2
  10. package/dist/assets/{LogViewerModal-B_4ke-1p.js → LogViewerModal-imTE-619.js} +1 -1
  11. package/dist/assets/{MonitoringModal-BHAuVdYA.js → MonitoringModal-DLxBY738.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-BfKic3hD.js → PM2LogsModal-S4n0D11V.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-Bxk9GcPa.js → RestoreArchivedAreaModal-B32sqCxN.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-COVSkNbV.js → Scene2DCanvas-bEu0UPUm.js} +1 -1
  15. package/dist/assets/{SceneManager-DWVI2idg.js → SceneManager-Ce3X0awy.js} +1 -1
  16. package/dist/assets/{SkillsPanel-BnKqbJyg.js → SkillsPanel-Dj22nDU8.js} +1 -1
  17. package/dist/assets/{SlackMultiInstanceSetup-BuA87Vlm.js → SlackMultiInstanceSetup-J9NdRY0U.js} +1 -1
  18. package/dist/assets/{SpawnModal-ClZUpgWy.js → SpawnModal-F9nspdWd.js} +1 -1
  19. package/dist/assets/{SubordinateAssignmentModal-Dn8tejNU.js → SubordinateAssignmentModal-DT31CkPr.js} +1 -1
  20. package/dist/assets/{TriggerManagerPanel-oBoHJdcv.js → TriggerManagerPanel-CVJ9ozHz.js} +1 -1
  21. package/dist/assets/{WorkflowEditorPanel-Bwju9-46.js → WorkflowEditorPanel-BYRi0cU9.js} +1 -1
  22. package/dist/assets/{index-CBh6qNCb.js → index-B9G4hrFR.js} +1 -1
  23. package/dist/assets/{index-CXBrQLNP.js → index-BPy8NE5B.js} +3 -3
  24. package/dist/assets/{index-DMUs4kjY.js → index-BmjrPptb.js} +1 -1
  25. package/dist/assets/{index-Bu_n7vgB.js → index-ByfAbkqv.js} +1 -1
  26. package/dist/assets/{index-umTVv-4x.js → index-C5wC_Xis.js} +1 -1
  27. package/dist/assets/{index-DgwVJN80.js → index-CLr8DpLX.js} +2 -2
  28. package/dist/assets/{index-fIzifjgU.js → index-DJm7oMXk.js} +1 -1
  29. package/dist/assets/{index-CfliOGe8.js → index-DkjWoHhy.js} +1 -1
  30. package/dist/assets/{index-DvBhO5je.js → index-DqZhHROd.js} +1 -1
  31. package/dist/assets/{main-BaGMbjuZ.js → main-9uTEp9Lr.js} +4 -4
  32. package/dist/assets/{web-pfDqogx0.js → web-CLuvtqCm.js} +1 -1
  33. package/dist/assets/{web-Cp8n5FK3.js → web-D-a3phls.js} +1 -1
  34. package/dist/assets/{web-BD4VGICh.js → web-DM2bFWBN.js} +1 -1
  35. package/dist/index.html +1 -1
  36. package/dist/src/packages/server/data/builtin-skills/create-building.js +521 -484
  37. package/dist/src/packages/server/routes/buildings.js +298 -0
  38. package/dist/src/packages/server/routes/index.js +3 -1
  39. package/dist/src/packages/server/services/building-service.js +400 -85
  40. package/dist/src/packages/server/websocket/handler.js +2 -4
  41. 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
- // Check if this is a PM2-enabled building
964
- if (oldBuilding && newBuilding.pm2?.enabled && oldBuilding.pm2?.enabled) {
965
- const oldPM2Name = pm2Service.getPM2Name(oldBuilding);
966
- const newPM2Name = pm2Service.getPM2Name(newBuilding);
967
- const nameChanged = oldPM2Name !== newPM2Name;
968
- const configChanged = hasPM2ConfigChanged(oldBuilding, newBuilding);
969
- log.log(`Building ${newBuilding.name}: nameChanged=${nameChanged}, configChanged=${configChanged}`);
970
- log.log(` Old args: "${oldBuilding.pm2?.args}", New args: "${newBuilding.pm2?.args}"`);
971
- log.log(` Old script: "${oldBuilding.pm2?.script}", New script: "${newBuilding.pm2?.script}"`);
972
- if (nameChanged || configChanged) {
973
- const changeType = nameChanged ? 'name' : 'config';
974
- log.log(`Building ${newBuilding.id}: PM2 ${changeType} changed`);
975
- // Check if the old process is running
976
- const oldStatus = await pm2Service.getStatus(oldBuilding);
977
- const wasRunning = oldStatus?.status === 'online';
978
- // Delete the old PM2 process
979
- await pm2Service.deleteProcess(oldBuilding);
980
- log.log(`Deleted old PM2 process: ${oldPM2Name}`);
981
- // If it was running, start the new one with updated config
982
- if (wasRunning) {
983
- const result = await pm2Service.startProcess(newBuilding);
984
- if (result.success) {
985
- log.log(`Started PM2 process with new ${changeType}: ${newPM2Name}`);
986
- }
987
- else {
988
- log.error(`Failed to start PM2 process ${newPM2Name}: ${result.error}`);
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
- // Database connection changes — tear down tunnels for connections that
994
- // were removed or whose SSH config materially changed.
995
- if (oldBuilding && oldBuilding.type === 'database') {
996
- const oldConns = oldBuilding.database?.connections || [];
997
- const newConns = newBuilding.type === 'database'
998
- ? (newBuilding.database?.connections || [])
999
- : [];
1000
- const newConnsById = new Map(newConns.map(c => [c.id, c]));
1001
- for (const oldConn of oldConns) {
1002
- const fresh = newConnsById.get(oldConn.id);
1003
- if (!fresh) {
1004
- await databaseService.closeConnection(oldConn.id);
1005
- continue;
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
- const oldKey = JSON.stringify({
1008
- ssh: oldConn.ssh ?? null,
1009
- host: oldConn.host,
1010
- port: oldConn.port,
1011
- });
1012
- const newKey = JSON.stringify({
1013
- ssh: fresh.ssh ?? null,
1014
- host: fresh.host,
1015
- port: fresh.port,
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
- // Check if this is a Docker-enabled building
1023
- if (oldBuilding && newBuilding.docker?.enabled && oldBuilding.docker?.enabled) {
1024
- const oldContainerName = dockerService.getContainerName(oldBuilding);
1025
- const newContainerName = dockerService.getContainerName(newBuilding);
1026
- const nameChanged = oldContainerName !== newContainerName;
1027
- const configChanged = hasDockerConfigChanged(oldBuilding, newBuilding);
1028
- if (nameChanged || configChanged) {
1029
- const changeType = nameChanged ? 'name' : 'config';
1030
- log.log(`Building ${newBuilding.id}: Docker ${changeType} changed`);
1031
- // Check if the old container is running
1032
- const oldStatus = await dockerService.getStatus(oldBuilding);
1033
- const wasRunning = oldStatus?.status === 'running';
1034
- // Remove the old container
1035
- if (oldBuilding.docker.mode === 'compose') {
1036
- await dockerService.composeDown(oldBuilding);
1037
- }
1038
- else {
1039
- await dockerService.removeContainer(oldBuilding);
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
- log.log(`Removed old Docker container: ${oldContainerName}`);
1042
- // If it was running, start the new one with updated config
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tide-commander",
3
- "version": "1.95.0",
3
+ "version": "1.96.0",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",