plugin-cluster-manager 1.1.11 → 1.1.15

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 (116) hide show
  1. package/client-v2.d.ts +2 -0
  2. package/client-v2.js +1 -0
  3. package/dist/client/index.js +1 -1
  4. package/dist/client-v2/914.c0bce51908fd81d7.js +10 -0
  5. package/dist/client-v2/index.js +10 -0
  6. package/dist/externalVersion.js +6 -5
  7. package/dist/locale/en-US.json +138 -124
  8. package/dist/locale/vi-VN.json +139 -125
  9. package/dist/locale/zh-CN.json +140 -125
  10. package/dist/server/actions/cluster-nodes.js +2 -6
  11. package/dist/server/actions/doctor.js +1 -5
  12. package/dist/server/actions/orchestrator.js +37 -0
  13. package/dist/server/actions/queue-mappings.js +107 -0
  14. package/dist/server/collections/worker-queue-mappings.js +106 -0
  15. package/dist/server/orchestrator/PackageManager.js +1 -8
  16. package/dist/server/orchestrator/docker-adapter.js +49 -27
  17. package/dist/server/plugin.js +10 -8
  18. package/dist/server/queue-scanner.js +141 -0
  19. package/dist/server/utils/node.js +30 -2
  20. package/package.json +46 -42
  21. package/src/client/AclCacheManager.tsx +292 -287
  22. package/src/client/CacheMonitor.tsx +2 -2
  23. package/src/client/ClusterManagerLayout.tsx +6 -0
  24. package/src/client/ClusterNodes.tsx +11 -4
  25. package/src/client/ContainerOrchestrator.tsx +186 -104
  26. package/src/client/Doctor.tsx +2 -2
  27. package/src/client/EventQueueMonitor.tsx +2 -2
  28. package/src/client/LockMonitor.tsx +2 -2
  29. package/src/client/NginxCacheManager.tsx +2 -2
  30. package/src/client/PackageInstaller.tsx +2 -2
  31. package/src/client/PluginOperations.tsx +2 -2
  32. package/src/client/QueueAssignment.tsx +355 -0
  33. package/src/client/RedisMonitor.tsx +3 -3
  34. package/src/client/TaskManager.tsx +194 -187
  35. package/src/client/WorkflowExecutions.tsx +243 -238
  36. package/src/client/utils.ts +1 -1
  37. package/src/client-v2/plugin.tsx +24 -0
  38. package/src/locale/en-US.json +138 -124
  39. package/src/locale/vi-VN.json +139 -125
  40. package/src/locale/zh-CN.json +140 -125
  41. package/src/server/actions/cluster-nodes.ts +4 -7
  42. package/src/server/actions/doctor.ts +11 -9
  43. package/src/server/actions/orchestrator.ts +54 -2
  44. package/src/server/actions/queue-mappings.ts +94 -0
  45. package/src/server/adapters/redis-node-registry.ts +126 -131
  46. package/src/server/collections/worker-queue-mappings.ts +85 -0
  47. package/src/server/orchestrator/PackageManager.ts +2 -10
  48. package/src/server/orchestrator/docker-adapter.ts +74 -37
  49. package/src/server/plugin.ts +15 -12
  50. package/src/server/queue-scanner.ts +154 -0
  51. package/src/server/utils/node.ts +48 -0
  52. package/dist/client/AclCacheManager.d.ts +0 -2
  53. package/dist/client/CacheMonitor.d.ts +0 -2
  54. package/dist/client/ClusterManagerLayout.d.ts +0 -2
  55. package/dist/client/ClusterNodes.d.ts +0 -2
  56. package/dist/client/ContainerOrchestrator.d.ts +0 -2
  57. package/dist/client/Doctor.d.ts +0 -2
  58. package/dist/client/EventQueueMonitor.d.ts +0 -2
  59. package/dist/client/LockMonitor.d.ts +0 -2
  60. package/dist/client/NginxCacheManager.d.ts +0 -2
  61. package/dist/client/PackageInstaller.d.ts +0 -2
  62. package/dist/client/PluginOperations.d.ts +0 -2
  63. package/dist/client/RedisMonitor.d.ts +0 -2
  64. package/dist/client/TaskManager.d.ts +0 -2
  65. package/dist/client/WorkflowExecutions.d.ts +0 -2
  66. package/dist/client/index.d.ts +0 -5
  67. package/dist/client/utils/clientSafeCache.d.ts +0 -3
  68. package/dist/client/utils/requestDedupInterceptor.d.ts +0 -2
  69. package/dist/client/utils.d.ts +0 -12
  70. package/dist/index.d.ts +0 -2
  71. package/dist/server/actions/acl-cache.d.ts +0 -53
  72. package/dist/server/actions/cache-monitor.d.ts +0 -33
  73. package/dist/server/actions/cluster-nodes.d.ts +0 -64
  74. package/dist/server/actions/doctor.d.ts +0 -82
  75. package/dist/server/actions/event-queue-monitor.d.ts +0 -13
  76. package/dist/server/actions/lock-monitor.d.ts +0 -19
  77. package/dist/server/actions/orchestrator.d.ts +0 -58
  78. package/dist/server/actions/package-manager.d.ts +0 -6
  79. package/dist/server/actions/plugin-operations.d.ts +0 -6
  80. package/dist/server/actions/redis-monitor.d.ts +0 -12
  81. package/dist/server/actions/tasks.d.ts +0 -7
  82. package/dist/server/actions/workflow-executions.d.ts +0 -7
  83. package/dist/server/adapters/redis-lock-adapter.d.ts +0 -15
  84. package/dist/server/adapters/redis-node-registry.d.ts +0 -12
  85. package/dist/server/adapters/redis-pubsub-adapter.d.ts +0 -16
  86. package/dist/server/collections/app.d.ts +0 -8
  87. package/dist/server/collections/cluster-manager-acl-cache.d.ts +0 -22
  88. package/dist/server/collections/cluster-manager-cache-mgr.d.ts +0 -22
  89. package/dist/server/collections/cluster-manager-cluster.d.ts +0 -22
  90. package/dist/server/collections/cluster-manager-doctor-runs.d.ts +0 -3
  91. package/dist/server/collections/cluster-manager-doctor.d.ts +0 -18
  92. package/dist/server/collections/cluster-manager-lock.d.ts +0 -22
  93. package/dist/server/collections/cluster-manager-plugins.d.ts +0 -18
  94. package/dist/server/collections/cluster-manager-queue.d.ts +0 -22
  95. package/dist/server/collections/cluster-manager-redis.d.ts +0 -22
  96. package/dist/server/collections/cluster-manager-workflow.d.ts +0 -22
  97. package/dist/server/collections/cluster-manager.d.ts +0 -22
  98. package/dist/server/collections/orchestrator-settings.d.ts +0 -59
  99. package/dist/server/collections/orchestrator-stacks.d.ts +0 -102
  100. package/dist/server/collections/worker-orchestrator.d.ts +0 -22
  101. package/dist/server/collections/worker-packages-configs.d.ts +0 -3
  102. package/dist/server/collections/worker-packages.d.ts +0 -22
  103. package/dist/server/hooks/cacheInvalidationHooks.d.ts +0 -1
  104. package/dist/server/middlewares/listMetaCacheMiddleware.d.ts +0 -2
  105. package/dist/server/orchestrator/PackageManager.d.ts +0 -39
  106. package/dist/server/orchestrator/docker-adapter.d.ts +0 -41
  107. package/dist/server/orchestrator/index.d.ts +0 -4
  108. package/dist/server/orchestrator/k8s-adapter.d.ts +0 -50
  109. package/dist/server/orchestrator/leader-election.d.ts +0 -48
  110. package/dist/server/orchestrator/types.d.ts +0 -84
  111. package/dist/server/plugin.d.ts +0 -26
  112. package/dist/server/utils/node.d.ts +0 -6
  113. package/dist/server/utils/redis.d.ts +0 -29
  114. package/dist/server/utils/versionManager.d.ts +0 -10
  115. package/dist/shared/packages.d.ts +0 -23
  116. /package/{dist/server/index.d.ts → src/client-v2/index.tsx} +0 -0
@@ -1,131 +1,126 @@
1
- import os from 'os';
2
- import { scanKeys, getRedisClient } from '../utils/redis';
3
- import { getLocalNodeId } from '../utils/node';
4
-
5
- export class RedisNodeRegistry {
6
- private timer: NodeJS.Timeout | null = null;
7
- private readonly ttlSecs = 30; // 30 seconds TTL
8
- private readonly intervalMs = 10000; // Heartbeat every 10 seconds
9
- private readonly keyPrefix = 'cluster-manager:nodes:';
10
-
11
- constructor(private app: any) {}
12
-
13
- public start() {
14
- if (this.timer) {
15
- clearInterval(this.timer);
16
- }
17
-
18
- // Initial heartbeat
19
- this.heartbeat();
20
-
21
- // Loop
22
- this.timer = setInterval(() => {
23
- this.heartbeat();
24
- }, this.intervalMs);
25
- }
26
-
27
- public stop() {
28
- if (this.timer) {
29
- clearInterval(this.timer);
30
- this.timer = null;
31
- }
32
- }
33
-
34
- private async heartbeat() {
35
- const redis = getRedisClient(this.app);
36
- if (!redis) return;
37
-
38
- // Unique identifier combining hostname, port, pid, mode, and appName to handle multiple workers on the same host
39
- const port = process.env.APP_PORT || 'unknown';
40
- const mode = process.env.WORKER_MODE || 'main';
41
- const appName = process.env.APP_NAME || this.app.name || 'main';
42
- const nodeId = getLocalNodeId(this.app);
43
- const key = `${this.keyPrefix}${nodeId}`;
44
-
45
- // Collect process-level metrics so any node can read another node's full info from Redis
46
- const mem = process.memoryUsage();
47
-
48
- const metadata = {
49
- id: nodeId,
50
- name: `${appName} (${os.hostname()})`,
51
- hostname: os.hostname(),
52
- appVersion: process.env.NOCOBASE_VERSION || process.version,
53
- workerMode: mode,
54
- isSandbox: process.env.SKILL_HUB_SANDBOX === 'true',
55
- pid: process.pid,
56
- url: process.env.APP_PUBLIC_URL || null,
57
- available: true,
58
- lastHeartbeatAt: Date.now(),
59
- status: 'online', // Implicitly online since it just reported
60
- // Full node details (replicated from the `current` action shape)
61
- // so that any node can serve the "current" endpoint for the APP node
62
- nodeDetails: {
63
- node: {
64
- hostname: os.hostname(),
65
- pid: process.pid,
66
- nodeVersion: process.version,
67
- platform: process.platform,
68
- arch: process.arch,
69
- uptime: process.uptime(),
70
- workerMode: mode,
71
- appPort: port,
72
- clusterMode: process.env.CLUSTER_MODE || '',
73
- },
74
- memory: {
75
- rss: mem.rss,
76
- heapUsed: mem.heapUsed,
77
- heapTotal: mem.heapTotal,
78
- external: mem.external,
79
- arrayBuffers: mem.arrayBuffers || 0,
80
- },
81
- os: {
82
- totalMemory: os.totalmem(),
83
- freeMemory: os.freemem(),
84
- cpuCount: os.cpus().length,
85
- loadAvg: os.loadavg(),
86
- },
87
- },
88
- };
89
-
90
- try {
91
- await redis.sendCommand([
92
- 'SET',
93
- key,
94
- JSON.stringify(metadata),
95
- 'EX',
96
- this.ttlSecs.toString(),
97
- ]);
98
- } catch (err: any) {
99
- this.app.logger.error(`[RedisNodeRegistry] Heartbeat failed: ${err.message}`);
100
- }
101
- }
102
-
103
- public async getNodes(): Promise<any[]> {
104
- const redis = getRedisClient(this.app);
105
- if (!redis) return [];
106
-
107
- try {
108
- const rawKeys = await scanKeys(redis, `${this.keyPrefix}*`);
109
- if (rawKeys.length === 0) return [];
110
-
111
- const values = await redis.sendCommand(['MGET', ...rawKeys]);
112
-
113
- const nodes: any[] = [];
114
- if (Array.isArray(values)) {
115
- for (const val of values) {
116
- if (val) {
117
- try {
118
- nodes.push(JSON.parse(val));
119
- } catch (e) {
120
- // bad JSON, ignore
121
- }
122
- }
123
- }
124
- }
125
- return nodes;
126
- } catch (err: any) {
127
- this.app.logger.error(`[RedisNodeRegistry] Error fetching nodes: ${err.message}`);
128
- return [];
129
- }
130
- }
131
- }
1
+ import os from 'os';
2
+ import { scanKeys, getRedisClient } from '../utils/redis';
3
+ import { getLocalNodeId } from '../utils/node';
4
+
5
+ export class RedisNodeRegistry {
6
+ private timer: NodeJS.Timeout | null = null;
7
+ private readonly ttlSecs = 30; // 30 seconds TTL
8
+ private readonly intervalMs = 10000; // Heartbeat every 10 seconds
9
+ private readonly keyPrefix = 'cluster-manager:nodes:';
10
+
11
+ constructor(private app: any) {}
12
+
13
+ public start() {
14
+ if (this.timer) {
15
+ clearInterval(this.timer);
16
+ }
17
+
18
+ // Initial heartbeat
19
+ this.heartbeat();
20
+
21
+ // Loop
22
+ this.timer = setInterval(() => {
23
+ this.heartbeat();
24
+ }, this.intervalMs);
25
+ }
26
+
27
+ public stop() {
28
+ if (this.timer) {
29
+ clearInterval(this.timer);
30
+ this.timer = null;
31
+ }
32
+ }
33
+
34
+ private async heartbeat() {
35
+ const redis = getRedisClient(this.app);
36
+ if (!redis) return;
37
+
38
+ // Unique identifier combining hostname, port, pid, mode, and appName to handle multiple workers on the same host
39
+ const port = process.env.APP_PORT || 'unknown';
40
+ const mode = process.env.WORKER_MODE || 'main';
41
+ const appName = process.env.APP_NAME || this.app.name || 'main';
42
+ const nodeId = getLocalNodeId(this.app);
43
+ const key = `${this.keyPrefix}${nodeId}`;
44
+
45
+ // Collect process-level metrics so any node can read another node's full info from Redis
46
+ const mem = process.memoryUsage();
47
+
48
+ const metadata = {
49
+ id: nodeId,
50
+ name: `${appName} (${os.hostname()})`,
51
+ hostname: os.hostname(),
52
+ appVersion: process.env.NOCOBASE_VERSION || process.version,
53
+ workerMode: mode,
54
+ appRole: process.env.APP_ROLE,
55
+ isSandbox: process.env.SKILL_HUB_SANDBOX === 'true',
56
+ pid: process.pid,
57
+ url: process.env.APP_PUBLIC_URL || null,
58
+ available: true,
59
+ lastHeartbeatAt: Date.now(),
60
+ status: 'online', // Implicitly online since it just reported
61
+ // Full node details (replicated from the `current` action shape)
62
+ // so that any node can serve the "current" endpoint for the APP node
63
+ nodeDetails: {
64
+ node: {
65
+ hostname: os.hostname(),
66
+ pid: process.pid,
67
+ nodeVersion: process.version,
68
+ platform: process.platform,
69
+ arch: process.arch,
70
+ uptime: process.uptime(),
71
+ workerMode: mode,
72
+ appPort: port,
73
+ clusterMode: process.env.CLUSTER_MODE || '',
74
+ },
75
+ memory: {
76
+ rss: mem.rss,
77
+ heapUsed: mem.heapUsed,
78
+ heapTotal: mem.heapTotal,
79
+ external: mem.external,
80
+ arrayBuffers: mem.arrayBuffers || 0,
81
+ },
82
+ os: {
83
+ totalMemory: os.totalmem(),
84
+ freeMemory: os.freemem(),
85
+ cpuCount: os.cpus().length,
86
+ loadAvg: os.loadavg(),
87
+ },
88
+ },
89
+ };
90
+
91
+ try {
92
+ await redis.sendCommand(['SET', key, JSON.stringify(metadata), 'EX', this.ttlSecs.toString()]);
93
+ } catch (err: any) {
94
+ this.app.logger.error(`[RedisNodeRegistry] Heartbeat failed: ${err.message}`);
95
+ }
96
+ }
97
+
98
+ public async getNodes(): Promise<any[]> {
99
+ const redis = getRedisClient(this.app);
100
+ if (!redis) return [];
101
+
102
+ try {
103
+ const rawKeys = await scanKeys(redis, `${this.keyPrefix}*`);
104
+ if (rawKeys.length === 0) return [];
105
+
106
+ const values = await redis.sendCommand(['MGET', ...rawKeys]);
107
+
108
+ const nodes: any[] = [];
109
+ if (Array.isArray(values)) {
110
+ for (const val of values) {
111
+ if (val) {
112
+ try {
113
+ nodes.push(JSON.parse(val));
114
+ } catch (e) {
115
+ // bad JSON, ignore
116
+ }
117
+ }
118
+ }
119
+ }
120
+ return nodes;
121
+ } catch (err: any) {
122
+ this.app.logger.error(`[RedisNodeRegistry] Error fetching nodes: ${err.message}`);
123
+ return [];
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Collection: workerQueueMappings
3
+ *
4
+ * Maps queue names to worker stacks (orchestratorStacks).
5
+ * When a stack has assigned queues, the orchestrator adapter sets
6
+ * WORKER_MODE=<comma-separated-queue-names> on new containers.
7
+ *
8
+ * If no mappings exist for a stack, WORKER_MODE=* is preserved
9
+ * (backwards compatibility).
10
+ */
11
+ export default {
12
+ name: 'workerQueueMappings',
13
+ autoGenId: true,
14
+ createdAt: true,
15
+ updatedAt: true,
16
+ fields: [
17
+ {
18
+ name: 'queueName',
19
+ type: 'string',
20
+ unique: true,
21
+ interface: 'input',
22
+ uiSchema: {
23
+ title: 'Queue Name',
24
+ 'x-component': 'Input',
25
+ required: true,
26
+ },
27
+ },
28
+ {
29
+ name: 'label',
30
+ type: 'string',
31
+ interface: 'input',
32
+ uiSchema: {
33
+ title: 'Label',
34
+ 'x-component': 'Input',
35
+ description: 'Human-readable display name',
36
+ },
37
+ },
38
+ {
39
+ name: 'description',
40
+ type: 'text',
41
+ interface: 'textarea',
42
+ uiSchema: {
43
+ title: 'Description',
44
+ 'x-component': 'Input.TextArea',
45
+ },
46
+ },
47
+ {
48
+ name: 'type',
49
+ type: 'string',
50
+ interface: 'select',
51
+ defaultValue: 'event-queue',
52
+ uiSchema: {
53
+ title: 'Source Type',
54
+ 'x-component': 'Select',
55
+ enum: [
56
+ { value: 'event-queue', label: 'EventQueue' },
57
+ { value: 'redis-list', label: 'Redis List' },
58
+ ],
59
+ },
60
+ },
61
+ {
62
+ name: 'stackId',
63
+ type: 'integer',
64
+ interface: 'select',
65
+ uiSchema: {
66
+ title: 'Assigned Stack',
67
+ 'x-component': 'Select',
68
+ 'x-component-props': {
69
+ allowClear: true,
70
+ placeholder: 'Unassigned (worker runs all queues)',
71
+ },
72
+ },
73
+ },
74
+ {
75
+ name: 'enabled',
76
+ type: 'boolean',
77
+ defaultValue: true,
78
+ interface: 'checkbox',
79
+ uiSchema: {
80
+ title: 'Enabled',
81
+ 'x-component': 'Checkbox',
82
+ },
83
+ },
84
+ ],
85
+ };
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { getRedisClient } from '../utils/redis';
3
- import { getLocalNodeId } from '../utils/node';
3
+ import { getLocalNodeId, getLocalRole } from '../utils/node';
4
4
  import { promises as fsp } from 'fs';
5
5
  import path from 'path';
6
6
  import Application from '@nocobase/server';
@@ -67,15 +67,7 @@ function redactUrl(value: string): string {
67
67
  }
68
68
 
69
69
  function getCurrentRole(): Exclude<TargetRole, 'all'> {
70
- if (process.env.APP_ROLE === 'app' || process.env.APP_ROLE === 'worker' || process.env.APP_ROLE === 'sandbox') {
71
- return process.env.APP_ROLE;
72
- }
73
- if (process.env.SKILL_HUB_SANDBOX === 'true') {
74
- return 'sandbox';
75
- }
76
-
77
- const workerMode = process.env.WORKER_MODE || 'main';
78
- return workerMode === 'worker' || workerMode === 'task' || workerMode === '*' ? 'worker' : 'app';
70
+ return getLocalRole();
79
71
  }
80
72
 
81
73
  function formatCommand(command: string, args: string[]): string {
@@ -17,9 +17,7 @@ function getDockerode() {
17
17
  try {
18
18
  Dockerode = require('dockerode');
19
19
  } catch {
20
- throw new Error(
21
- '[DockerAdapter] "dockerode" package not found. Install it: yarn add dockerode',
22
- );
20
+ throw new Error('[DockerAdapter] "dockerode" package not found. Install it: yarn add dockerode');
23
21
  }
24
22
  }
25
23
  return Dockerode;
@@ -58,11 +56,7 @@ export class DockerAdapter implements IOrchestratorAdapter {
58
56
  const containers = await this.docker.listContainers({
59
57
  all: true,
60
58
  filters: {
61
- label: [
62
- `${LABEL_STACK}=${stack.name}`,
63
- `${LABEL_MANAGED}=true`,
64
- ...this.buildLabelFilters(this.workerLabels),
65
- ],
59
+ label: [`${LABEL_STACK}=${stack.name}`, `${LABEL_MANAGED}=true`, ...this.buildLabelFilters(this.workerLabels)],
66
60
  },
67
61
  });
68
62
 
@@ -105,9 +99,20 @@ export class DockerAdapter implements IOrchestratorAdapter {
105
99
  if (diff > 0) {
106
100
  // Scale UP
107
101
  let targetNetworks = stack.networks && stack.networks.length > 0 ? stack.networks : [];
108
- let targetNetworkMode = stack.networkMode;
102
+ const targetNetworkMode = stack.networkMode;
109
103
  let targetEnvVars = this.buildEnvArray(stack.envVars);
110
104
  let targetVolumes = stack.volumes || [];
105
+ // Default the worker image to whatever the app container is running, so
106
+ // workers stay version-locked with the app even when the stack record
107
+ // has a stale/empty image. An explicit stack.image still wins.
108
+ let targetImage = stack.image;
109
+ // Inherit the app container's startup command/entrypoint so workers boot
110
+ // identically (e.g. source-tarball extraction + `yarn start`). Without
111
+ // this, a worker created from the bare image runs the image default
112
+ // command, skips the app's bootstrap, never finishes booting, and never
113
+ // registers a heartbeat — so it never appears in Cluster Nodes.
114
+ let inheritedCmd: string[] | undefined;
115
+ let inheritedEntrypoint: string[] | undefined;
111
116
 
112
117
  // Auto-detect current container's configuration to inherit networks and env vars
113
118
  try {
@@ -115,50 +120,70 @@ export class DockerAdapter implements IOrchestratorAdapter {
115
120
  const myContainerId = os.hostname();
116
121
  const myContainer = this.docker.getContainer(myContainerId);
117
122
  const myInfo = await myContainer.inspect();
118
-
123
+
124
+ // Inherit the app container's image when the stack does not pin one
125
+ if (!targetImage && myInfo?.Config?.Image) {
126
+ targetImage = myInfo.Config.Image;
127
+ console.log('[DockerAdapter] Inherited image from app container:', targetImage);
128
+ }
129
+
119
130
  // Always inherit Networks so worker can communicate with main app
120
131
  if (myInfo?.NetworkSettings?.Networks) {
121
132
  const inheritedNetworks = Object.keys(myInfo.NetworkSettings.Networks);
122
133
  targetNetworks = Array.from(new Set([...inheritedNetworks, ...targetNetworks]));
123
134
  console.log('[DockerAdapter] Inherited networks:', targetNetworks);
124
135
  }
125
-
136
+
126
137
  // Inherit Environment Variables and merge with stack.envVars
127
138
  if (myInfo?.Config?.Env) {
128
- const envDict: Record<string, string> = {};
129
- myInfo.Config.Env.forEach((e: string) => {
130
- const idx = e.indexOf('=');
131
- if (idx !== -1) {
132
- envDict[e.substring(0, idx)] = e.substring(idx + 1);
133
- }
134
- });
135
- // Overwrite with explicitly defined env vars
136
- Object.assign(envDict, stack.envVars || {});
137
-
138
- targetEnvVars = Object.entries(envDict).map(([k, v]) => `${k}=${v}`);
139
+ const envDict: Record<string, string> = {};
140
+ myInfo.Config.Env.forEach((e: string) => {
141
+ const idx = e.indexOf('=');
142
+ if (idx !== -1) {
143
+ envDict[e.substring(0, idx)] = e.substring(idx + 1);
144
+ }
145
+ });
146
+ // Overwrite with explicitly defined env vars
147
+ Object.assign(envDict, stack.envVars || {});
148
+
149
+ targetEnvVars = Object.entries(envDict).map(([k, v]) => `${k}=${v}`);
139
150
  }
140
151
  // Inherit Volumes (Binds)
141
152
  if (myInfo?.HostConfig?.Binds) {
142
153
  const inheritedBinds = myInfo.HostConfig.Binds as string[];
143
154
  targetVolumes = Array.from(new Set([...inheritedBinds, ...targetVolumes]));
144
155
  }
156
+ // Inherit the startup Cmd/Entrypoint so the worker runs the same bootstrap
157
+ // as the app container (used only when the stack pins no explicit command).
158
+ if (Array.isArray(myInfo?.Config?.Cmd) && myInfo.Config.Cmd.length > 0) {
159
+ inheritedCmd = myInfo.Config.Cmd as string[];
160
+ }
161
+ if (Array.isArray(myInfo?.Config?.Entrypoint) && myInfo.Config.Entrypoint.length > 0) {
162
+ inheritedEntrypoint = myInfo.Config.Entrypoint as string[];
163
+ }
145
164
  } catch (e: any) {
146
165
  // Ignore error if not running in a container or cannot inspect
147
166
  console.error('[DockerAdapter] Failed to inherit container config:', e.message);
148
167
  }
149
168
 
150
169
  // Automatically separate logs for workers to prevent log interleaving with the main app
151
- const hasLoggerBase = targetEnvVars.some(e => e.startsWith('LOGGER_BASE_PATH='));
170
+ const hasLoggerBase = targetEnvVars.some((e) => e.startsWith('LOGGER_BASE_PATH='));
152
171
  if (!hasLoggerBase) {
153
172
  targetEnvVars.push(`LOGGER_BASE_PATH=/app/nocobase/storage/logs/${stack.name}`);
154
173
  }
155
174
 
175
+ if (!targetImage) {
176
+ throw new Error(
177
+ `[DockerAdapter] No image configured for stack "${stack.name}" and the app container image could not be determined.`,
178
+ );
179
+ }
180
+
156
181
  for (let i = 0; i < diff; i++) {
157
182
  const suffix = `${Date.now()}-${Math.random().toString(36).substring(2, 6)}`;
158
183
  const containerName = `${stack.name}-${suffix}`;
159
184
 
160
185
  const createOpts: any = {
161
- Image: stack.image,
186
+ Image: targetImage,
162
187
  name: containerName,
163
188
  Env: targetEnvVars,
164
189
  Labels: {
@@ -174,6 +199,15 @@ export class DockerAdapter implements IOrchestratorAdapter {
174
199
 
175
200
  if (stack.command) {
176
201
  createOpts.Cmd = ['/bin/sh', '-c', stack.command];
202
+ } else {
203
+ // No explicit command: replay the app container's bootstrap so the
204
+ // worker boots NocoBase the same way and registers a heartbeat.
205
+ if (inheritedEntrypoint) {
206
+ createOpts.Entrypoint = inheritedEntrypoint;
207
+ }
208
+ if (inheritedCmd) {
209
+ createOpts.Cmd = inheritedCmd;
210
+ }
177
211
  }
178
212
 
179
213
  if (stack.resourceLimits?.memory) {
@@ -190,7 +224,7 @@ export class DockerAdapter implements IOrchestratorAdapter {
190
224
  createOpts.HostConfig.SecurityOpt = ['no-new-privileges:true'];
191
225
 
192
226
  const container = await this.docker.createContainer(createOpts);
193
-
227
+
194
228
  // Connect to additional networks before starting
195
229
  if (targetNetworks.length > 0) {
196
230
  const startIndex = targetNetworkMode ? 0 : 1;
@@ -199,7 +233,9 @@ export class DockerAdapter implements IOrchestratorAdapter {
199
233
  const net = this.docker.getNetwork(targetNetworks[i]);
200
234
  await net.connect({ Container: container.id });
201
235
  } catch (err: any) {
202
- console.warn(`[DockerAdapter] Failed to connect container ${container.id} to network ${targetNetworks[i]}: ${err.message}`);
236
+ console.warn(
237
+ `[DockerAdapter] Failed to connect container ${container.id} to network ${targetNetworks[i]}: ${err.message}`,
238
+ );
203
239
  }
204
240
  }
205
241
  }
@@ -209,9 +245,7 @@ export class DockerAdapter implements IOrchestratorAdapter {
209
245
  }
210
246
  } else if (diff < 0) {
211
247
  // Scale DOWN — remove newest first (LIFO)
212
- const sorted = running.sort(
213
- (a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
214
- );
248
+ const sorted = running.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
215
249
  const toRemove = sorted.slice(0, Math.abs(diff));
216
250
 
217
251
  for (const c of toRemove) {
@@ -334,14 +368,17 @@ export class DockerAdapter implements IOrchestratorAdapter {
334
368
  .split(',')
335
369
  .map((part) => part.trim())
336
370
  .filter(Boolean)
337
- .reduce((acc, part) => {
338
- const [key, ...valueParts] = part.split('=');
339
- const value = valueParts.join('=');
340
- if (key?.trim() && value?.trim()) {
341
- acc[key.trim()] = value.trim();
342
- }
343
- return acc;
344
- }, {} as Record<string, string>);
371
+ .reduce(
372
+ (acc, part) => {
373
+ const [key, ...valueParts] = part.split('=');
374
+ const value = valueParts.join('=');
375
+ if (key?.trim() && value?.trim()) {
376
+ acc[key.trim()] = value.trim();
377
+ }
378
+ return acc;
379
+ },
380
+ {} as Record<string, string>,
381
+ );
345
382
  }
346
383
 
347
384
  private labelsMatch(labels: Record<string, string>, expected: Record<string, string>): boolean {
@@ -7,7 +7,7 @@ import { redisActions } from './actions/redis-monitor';
7
7
  import { aclCacheActions, createAclCacheMiddleware } from './actions/acl-cache';
8
8
  import { clusterActions, readLocalLogs } from './actions/cluster-nodes';
9
9
  import { getRedisClient } from './utils/redis';
10
- import { getLocalNodeId } from './utils/node';
10
+ import { getLocalNodeId, isWorkerMode } from './utils/node';
11
11
  import { eventQueueActions } from './actions/event-queue-monitor';
12
12
  import { lockActions } from './actions/lock-monitor';
13
13
  import { cacheMonitorActions } from './actions/cache-monitor';
@@ -16,6 +16,7 @@ import { RedisNodeRegistry } from './adapters/redis-node-registry';
16
16
  import { RedisLockAdapter } from './adapters/redis-lock-adapter';
17
17
  import { orchestratorActions } from './actions/orchestrator';
18
18
  import { pluginOperationsActions } from './actions/plugin-operations';
19
+ import { queueMappingsActions } from './actions/queue-mappings';
19
20
  import type { IOrchestratorAdapter } from './orchestrator/types';
20
21
  import { DockerAdapter } from './orchestrator/docker-adapter';
21
22
  import { K8sAdapter } from './orchestrator/k8s-adapter';
@@ -56,14 +57,12 @@ export class PluginClusterManagerServer extends Plugin {
56
57
  (this.app as any).on('afterStart', () => {
57
58
  this.nodeRegistry?.start();
58
59
 
59
- // Automatically install packages on boot for worker nodes
60
- const mode = process.env.WORKER_MODE || 'main';
60
+ // Automatically install packages on boot for worker / sandbox nodes
61
61
  const isWorker =
62
- mode === 'worker' ||
63
- mode === 'task' ||
64
- mode === '*' ||
62
+ isWorkerMode(process.env.WORKER_MODE) ||
65
63
  process.env.APP_ROLE === 'worker' ||
66
- process.env.APP_ROLE === 'sandbox';
64
+ process.env.APP_ROLE === 'sandbox' ||
65
+ process.env.SKILL_HUB_SANDBOX === 'true';
67
66
  if (isWorker) {
68
67
  setTimeout(async () => {
69
68
  try {
@@ -125,9 +124,7 @@ export class PluginClusterManagerServer extends Plugin {
125
124
 
126
125
  // Workflow hook to trace executing node
127
126
  this.app.db.on('executions.afterSave', async (model: any) => {
128
- const mode = process.env.WORKER_MODE || 'main';
129
- const isWorker = mode === 'worker' || mode === 'task' || mode === '*';
130
- if (isWorker) {
127
+ if (isWorkerMode(process.env.WORKER_MODE)) {
131
128
  const id = model.get('id');
132
129
  const redis = getRedisClient(this.app);
133
130
  if (id && redis) {
@@ -332,6 +329,12 @@ export class PluginClusterManagerServer extends Plugin {
332
329
  actions: pluginOperationsActions,
333
330
  });
334
331
 
332
+ // Queue Mappings (queue-to-worker-stack assignments)
333
+ this.app.resourcer.define({
334
+ name: 'workerQueueMappings',
335
+ actions: queueMappingsActions,
336
+ });
337
+
335
338
  // Install ACL cache middleware inside the ACL chain so cached permissions are not overwritten.
336
339
  const aclCacheMiddleware = createAclCacheMiddleware(this.app);
337
340
  (this.app as any).acl.use(aclCacheMiddleware, {
@@ -380,6 +383,7 @@ export class PluginClusterManagerServer extends Plugin {
380
383
  'orchestratorStacks:*',
381
384
  'workerPackages:*',
382
385
  'clusterManagerPlugins:*',
386
+ 'workerQueueMappings:*',
383
387
  ],
384
388
  });
385
389
 
@@ -558,8 +562,7 @@ export class PluginClusterManagerServer extends Plugin {
558
562
  }
559
563
 
560
564
  private isWorkerOnlyNode(): boolean {
561
- const workerMode = process.env.WORKER_MODE || '';
562
- return workerMode === 'worker' || workerMode === 'task' || workerMode === '*';
565
+ return isWorkerMode(process.env.WORKER_MODE);
563
566
  }
564
567
  }
565
568