plugin-cluster-manager 1.1.16 → 1.1.17

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 (49) hide show
  1. package/dist/client/index.js +1 -1
  2. package/dist/client-v2/376.cd1d86e85a50088e.js +10 -0
  3. package/dist/client-v2/index.js +1 -1
  4. package/dist/externalVersion.js +6 -6
  5. package/dist/locale/en-US.json +16 -6
  6. package/dist/locale/vi-VN.json +16 -6
  7. package/dist/locale/zh-CN.json +16 -6
  8. package/dist/server/actions/cluster-nodes.js +43 -10
  9. package/dist/server/actions/doctor.js +69 -4
  10. package/dist/server/actions/event-queue-monitor.js +33 -3
  11. package/dist/server/actions/orchestrator.js +48 -32
  12. package/dist/server/actions/queue-mappings.js +1 -0
  13. package/dist/server/actions/tasks.js +8 -8
  14. package/dist/server/adapters/redis-event-queue-adapter.js +188 -0
  15. package/dist/server/adapters/redis-node-registry.js +42 -3
  16. package/dist/server/collections/orchestrator-stacks.js +6 -0
  17. package/dist/server/collections/worker-queue-mappings.js +1 -1
  18. package/dist/server/orchestrator/PackageManager.js +47 -12
  19. package/dist/server/plugin.js +36 -5
  20. package/dist/server/queue-scanner.js +54 -34
  21. package/dist/server/utils/node.js +3 -7
  22. package/dist/server/utils/redis.js +37 -9
  23. package/dist/shared/worker-processes.js +233 -0
  24. package/package.json +1 -1
  25. package/src/client/ClusterNodes.tsx +76 -10
  26. package/src/client/ContainerOrchestrator.tsx +146 -8
  27. package/src/client/QueueAssignment.tsx +10 -2
  28. package/src/locale/en-US.json +16 -6
  29. package/src/locale/vi-VN.json +16 -6
  30. package/src/locale/zh-CN.json +16 -6
  31. package/src/server/__tests__/worker-processes.test.ts +42 -0
  32. package/src/server/actions/cluster-nodes.ts +43 -8
  33. package/src/server/actions/doctor.ts +77 -0
  34. package/src/server/actions/event-queue-monitor.ts +34 -3
  35. package/src/server/actions/orchestrator.ts +58 -38
  36. package/src/server/actions/queue-mappings.ts +1 -0
  37. package/src/server/actions/tasks.ts +142 -142
  38. package/src/server/adapters/redis-event-queue-adapter.ts +189 -0
  39. package/src/server/adapters/redis-node-registry.ts +44 -4
  40. package/src/server/collections/orchestrator-stacks.ts +6 -0
  41. package/src/server/collections/worker-queue-mappings.ts +3 -3
  42. package/src/server/orchestrator/PackageManager.ts +48 -11
  43. package/src/server/orchestrator/types.ts +5 -4
  44. package/src/server/plugin.ts +40 -6
  45. package/src/server/queue-scanner.ts +65 -51
  46. package/src/server/utils/node.ts +3 -10
  47. package/src/server/utils/redis.ts +39 -4
  48. package/src/shared/worker-processes.ts +216 -0
  49. package/dist/client-v2/914.c0bce51908fd81d7.js +0 -10
@@ -9,6 +9,12 @@ import type { ContainerInfo, StackConfig } from '../orchestrator/types';
9
9
  import { getLocalNodeId, getNodeRoleFrom } from '../utils/node';
10
10
  import { getRedisClient, scanKeys } from '../utils/redis';
11
11
  import { packagesFromConfig, type CustomPackageMap, type WorkerPackageMap } from '../../shared/packages';
12
+ import {
13
+ getWorkerProcessDefinition,
14
+ normalizeWorkerMode,
15
+ resolveWorkerProcessName,
16
+ WORKER_PROCESS_DEFINITIONS,
17
+ } from '../../shared/worker-processes';
12
18
 
13
19
  const ACTIVE_RUN_KEY = 'cluster-manager:doctor:active';
14
20
  const RESPONSE_KEY_PREFIX = 'cluster-manager:doctor-response:';
@@ -1112,29 +1118,91 @@ async function getQueueDiagnostics(app: Application) {
1112
1118
  | { constructor?: { name?: string }; queues?: Map<string, unknown[]> }
1113
1119
  | undefined;
1114
1120
  const channels = [];
1121
+ const requiredProcesses = new Set<string>();
1115
1122
  for (const [channel, options] of eventQueue.events || new Map()) {
1116
1123
  let pending: number | null = null;
1117
1124
  if (adapter?.queues && eventQueue.getFullChannel) {
1118
1125
  const fullChannel = eventQueue.getFullChannel(channel, options.shared);
1119
1126
  pending = adapter.queues.get(fullChannel)?.length || 0;
1120
1127
  }
1128
+ const processName = resolveWorkerProcessName(channel);
1129
+ const definition = getWorkerProcessDefinition(processName);
1130
+ if (definition && !definition.sandbox) {
1131
+ requiredProcesses.add(definition.name);
1132
+ }
1121
1133
  channels.push({
1122
1134
  channel,
1135
+ workerProcessName: definition?.name,
1123
1136
  concurrency: options.concurrency || 1,
1124
1137
  interval: options.interval || 250,
1125
1138
  pending,
1126
1139
  });
1127
1140
  }
1128
1141
 
1142
+ for (const definition of WORKER_PROCESS_DEFINITIONS) {
1143
+ if (definition.common && definition.pluginName) {
1144
+ try {
1145
+ if ((app as any).pm?.get?.(definition.pluginName)) {
1146
+ requiredProcesses.add(definition.name);
1147
+ }
1148
+ } catch {
1149
+ // Ignore plugin-manager lookup errors.
1150
+ }
1151
+ }
1152
+ }
1153
+
1154
+ const coverage = await getWorkerProcessCoverage(app, Array.from(requiredProcesses));
1155
+
1129
1156
  return {
1130
1157
  available: true,
1131
1158
  connected: eventQueue.isConnected?.() || false,
1132
1159
  adapter: adapter?.constructor?.name || 'unknown',
1133
1160
  channels,
1161
+ coverage,
1134
1162
  totalPending: channels.reduce((sum, item) => sum + (item.pending || 0), 0),
1135
1163
  };
1136
1164
  }
1137
1165
 
1166
+ async function getWorkerProcessCoverage(app: Application, requiredProcesses: string[]) {
1167
+ const result = {
1168
+ required: requiredProcesses,
1169
+ covered: [] as string[],
1170
+ missing: [] as string[],
1171
+ wildcard: false,
1172
+ stacks: [] as Array<{ id: unknown; name: unknown; workerMode: string }>,
1173
+ };
1174
+
1175
+ try {
1176
+ const repo = app.db.getRepository('orchestratorStacks');
1177
+ const stacks = await repo.find({ filter: { enabled: true } });
1178
+
1179
+ const covered = new Set<string>();
1180
+ for (const stack of stacks) {
1181
+ const envVars = stack.get?.('envVars') as { WORKER_MODE?: string } | undefined;
1182
+ const workerMode =
1183
+ normalizeWorkerMode((stack.get?.('workerMode') as string | undefined) || envVars?.WORKER_MODE) || '*';
1184
+ result.stacks.push({
1185
+ id: stack.get?.('id'),
1186
+ name: stack.get?.('name'),
1187
+ workerMode,
1188
+ });
1189
+ if (workerMode === '*') {
1190
+ result.wildcard = true;
1191
+ }
1192
+ for (const token of workerMode.split(',').filter(Boolean)) {
1193
+ covered.add(token);
1194
+ }
1195
+ }
1196
+
1197
+ result.covered = Array.from(covered);
1198
+ result.missing = result.wildcard ? [] : requiredProcesses.filter((processName) => !covered.has(processName));
1199
+ } catch {
1200
+ result.missing = requiredProcesses;
1201
+ }
1202
+
1203
+ return result;
1204
+ }
1205
+
1138
1206
  async function getOrchestratorDiagnostics(app: Application, options: DoctorSnapshotOptions) {
1139
1207
  const plugin = getApp(app).pm?.get('plugin-cluster-manager') as {
1140
1208
  orchestrator?: {
@@ -1214,6 +1282,7 @@ function buildRecommendations(params: {
1214
1282
  packageDrifts: number;
1215
1283
  redisAvailable: boolean;
1216
1284
  databaseOk: boolean;
1285
+ queueCoverageMissing?: string[];
1217
1286
  }) {
1218
1287
  const recommendations = [];
1219
1288
  if (!params.redisAvailable) {
@@ -1258,6 +1327,13 @@ function buildRecommendations(params: {
1258
1327
  message: 'One or more worker nodes are missing configured packages or have failed package initialization.',
1259
1328
  });
1260
1329
  }
1330
+ if (params.queueCoverageMissing?.length) {
1331
+ recommendations.push({
1332
+ level: 'warning',
1333
+ code: 'worker_process_coverage_missing',
1334
+ message: `No explicit worker stack covers: ${params.queueCoverageMissing.join(', ')}.`,
1335
+ });
1336
+ }
1261
1337
  if (params.topErrors > 0) {
1262
1338
  recommendations.push({
1263
1339
  level: 'warning',
@@ -1301,6 +1377,7 @@ async function buildDoctorReport(app: Application, run: Record<string, unknown>,
1301
1377
  packageDrifts: packageDiagnostics.packageDrifts.length,
1302
1378
  redisAvailable: Boolean(redisDiagnostics.available),
1303
1379
  databaseOk: Boolean(databaseDiagnostics.ping.ok),
1380
+ queueCoverageMissing: queueDiagnostics.coverage?.missing || [],
1304
1381
  });
1305
1382
  const criticalFindings = recommendations.filter((item) => item.level === 'critical').length;
1306
1383
  const warningFindings = recommendations.filter((item) => item.level === 'warning').length;
@@ -2,7 +2,13 @@ import { Context } from '@nocobase/actions';
2
2
  import { scanKeys } from '../utils/redis';
3
3
 
4
4
  const REDIS_QUEUE_CONNECTION = 'cluster-manager:queue-monitor';
5
- const REDIS_QUEUE_PATTERNS = ['*:plugin-git-manager:review:queue', '*:plugin-build-guide-block:build:queue'];
5
+ const REDIS_QUEUE_PATTERNS = [
6
+ '*:plugin-git-manager:review:queue',
7
+ '*:plugin-build-guide-block:build:queue',
8
+ '*:plugin-build-visualization-block:build:queue',
9
+ 'file-preview-auth.ocr.queue',
10
+ 'nocobase:event-queue:*',
11
+ ];
6
12
 
7
13
  function getQueueRedisUrl() {
8
14
  return process.env.QUEUE_ADAPTER_REDIS_URL || process.env.REDIS_URL;
@@ -19,10 +25,16 @@ async function getQueueRedis(ctx: Context) {
19
25
 
20
26
  function knownRedisQueueKeys(ctx: Context) {
21
27
  const appName = (ctx.app as any).name || process.env.APP_NAME || 'main';
22
- return [`${appName}:plugin-git-manager:review:queue`, `${appName}:plugin-build-guide-block:build:queue`];
28
+ return [
29
+ `${appName}:plugin-git-manager:review:queue`,
30
+ `${appName}:plugin-build-guide-block:build:queue`,
31
+ `${appName}:plugin-build-visualization-block:build:queue`,
32
+ 'file-preview-auth.ocr.queue',
33
+ ];
23
34
  }
24
35
 
25
36
  function isKnownRedisQueueKey(key: string) {
37
+ if (key.startsWith('nocobase:event-queue:')) return true;
26
38
  return REDIS_QUEUE_PATTERNS.some((pattern) => {
27
39
  const suffix = pattern.replace('*:', '');
28
40
  return key === suffix || key.endsWith(`:${suffix}`);
@@ -30,6 +42,25 @@ function isKnownRedisQueueKey(key: string) {
30
42
  }
31
43
 
32
44
  function describeRedisQueueKey(key: string) {
45
+ if (key === 'file-preview-auth.ocr.queue') {
46
+ return {
47
+ appName: 'main',
48
+ plugin: 'plugin-file-preview-auth',
49
+ queue: 'ocr',
50
+ channel: key,
51
+ };
52
+ }
53
+
54
+ if (key.startsWith('nocobase:event-queue:')) {
55
+ const channel = key.slice('nocobase:event-queue:'.length);
56
+ return {
57
+ appName: channel.split('.')[0] || 'main',
58
+ plugin: 'event-queue',
59
+ queue: channel,
60
+ channel,
61
+ };
62
+ }
63
+
33
64
  const parts = String(key).split(':');
34
65
  const queue = parts[parts.length - 2] || key;
35
66
  const plugin = parts[parts.length - 3] || 'unknown';
@@ -96,7 +127,7 @@ function parseRedisQueueMessage(raw: string, key: string, index: number) {
96
127
  } catch {
97
128
  // Keep the raw string for non-JSON messages.
98
129
  }
99
- const queuedAt = content?.queuedAt ? Date.parse(content.queuedAt) : null;
130
+ const queuedAt = content?.queuedAt ? Date.parse(content.queuedAt) : content?.options?.timestamp || null;
100
131
  return {
101
132
  id: `${key}:${index}`,
102
133
  index,
@@ -8,6 +8,11 @@
8
8
  import { Context } from '@nocobase/actions';
9
9
  import { getRedisClient } from '../utils/redis';
10
10
  import type { IOrchestratorAdapter, StackConfig } from '../orchestrator/types';
11
+ import { normalizeWorkerMode } from '../../shared/worker-processes';
12
+
13
+ type QueueMappingRecord = {
14
+ get: (key: string) => unknown;
15
+ };
11
16
 
12
17
  /** Helper: get orchestrator adapter from plugin instance */
13
18
  function getAdapter(ctx: Context): IOrchestratorAdapter {
@@ -54,6 +59,33 @@ async function assertManagedContainer(
54
59
  }
55
60
  }
56
61
 
62
+ function applyWorkerMode(stack: StackConfig, workerMode: string) {
63
+ stack.workerMode = workerMode;
64
+ stack.envVars = {
65
+ ...(stack.envVars || {}),
66
+ APP_ROLE: stack.envVars?.APP_ROLE || 'worker',
67
+ WORKER_MODE: workerMode,
68
+ SKILL_HUB_SANDBOX: stack.envVars?.SKILL_HUB_SANDBOX || 'false',
69
+ };
70
+ }
71
+
72
+ async function resolveMappedWorkerMode(ctx: Context, stack: StackConfig): Promise<string | undefined> {
73
+ const mappingsRepo = ctx.db.getRepository('workerQueueMappings');
74
+ const assigned = (await mappingsRepo.find({
75
+ filter: {
76
+ stackId: stack.id,
77
+ enabled: true,
78
+ },
79
+ })) as QueueMappingRecord[];
80
+
81
+ return normalizeWorkerMode(
82
+ assigned
83
+ .map((mapping) => String(mapping.get('queueName') || ''))
84
+ .filter(Boolean)
85
+ .join(','),
86
+ );
87
+ }
88
+
57
89
  export const orchestratorActions = {
58
90
  /**
59
91
  * GET /workerOrchestrator:ping
@@ -122,9 +154,9 @@ export const orchestratorActions = {
122
154
  * Body: { stackId: 1, replicas: 3 }
123
155
  * Leader-only
124
156
  *
125
- * Before scaling, resolves queue-to-stack mappings and injects
126
- * WORKER_MODE into the stack's envVars so new containers only
127
- * process assigned queues.
157
+ * Before scaling, resolves the stack-level worker mode and injects
158
+ * WORKER_MODE into envVars so new containers process only selected queues.
159
+ * Queue mappings remain as a fallback for legacy stacks.
128
160
  */
129
161
  async scale(ctx: Context, next: () => Promise<void>) {
130
162
  assertLeader(ctx);
@@ -136,44 +168,32 @@ export const orchestratorActions = {
136
168
 
137
169
  const stack = await getStack(ctx, stackId);
138
170
 
139
- // ── Resolve queue assignments for this stack ──
140
- try {
141
- const mappingsRepo = ctx.db.getRepository('workerQueueMappings');
142
- if (mappingsRepo) {
143
- const assigned = await mappingsRepo.find({
144
- filter: {
145
- stackId: stack.id,
146
- enabled: true,
147
- },
148
- });
149
-
150
- const queueNames = assigned.map((m: any) => m.get('queueName') as string).filter(Boolean);
151
-
152
- if (queueNames.length > 0) {
153
- const workerMode = queueNames.join(',');
154
- ctx.app.logger.info(
155
- `[Orchestrator] Injecting WORKER_MODE=${workerMode} for stack "${stack.name}" (${queueNames.length} queue(s) assigned)`,
156
- );
157
- // Merge into envVars; adapter code merges envVars over inherited env
158
- stack.envVars = {
159
- ...(stack.envVars || {}),
160
- WORKER_MODE: workerMode,
161
- };
171
+ const stackWorkerMode = normalizeWorkerMode(stack.workerMode);
172
+ const envWorkerMode = normalizeWorkerMode(stack.envVars?.WORKER_MODE);
173
+
174
+ if (stackWorkerMode) {
175
+ applyWorkerMode(stack, stackWorkerMode);
176
+ ctx.app.logger.info(`[Orchestrator] Using stack WORKER_MODE=${stackWorkerMode} for "${stack.name}"`);
177
+ } else if (envWorkerMode && envWorkerMode !== '*') {
178
+ applyWorkerMode(stack, envWorkerMode);
179
+ ctx.app.logger.info(`[Orchestrator] Using env WORKER_MODE=${envWorkerMode} for "${stack.name}"`);
180
+ } else {
181
+ try {
182
+ const mappedWorkerMode = await resolveMappedWorkerMode(ctx, stack);
183
+
184
+ if (mappedWorkerMode) {
185
+ applyWorkerMode(stack, mappedWorkerMode);
186
+ ctx.app.logger.info(`[Orchestrator] Using mapped WORKER_MODE=${mappedWorkerMode} for "${stack.name}"`);
162
187
  } else {
163
- // No specific assignment default to all queues (backwards compatible)
164
- stack.envVars = {
165
- ...(stack.envVars || {}),
166
- WORKER_MODE: '*',
167
- };
188
+ const fallbackWorkerMode = envWorkerMode || '*';
189
+ applyWorkerMode(stack, fallbackWorkerMode);
190
+ ctx.app.logger.info(`[Orchestrator] Using fallback WORKER_MODE=${fallbackWorkerMode} for "${stack.name}"`);
168
191
  }
192
+ } catch (err: any) {
193
+ const fallbackWorkerMode = envWorkerMode || '*';
194
+ ctx.app.logger.debug(`[Orchestrator] Queue mappings not available: ${err.message}`);
195
+ applyWorkerMode(stack, fallbackWorkerMode);
169
196
  }
170
- } catch (err: any) {
171
- // If workerQueueMappings table doesn't exist yet, fall back gracefully
172
- ctx.app.logger.debug(`[Orchestrator] Queue mappings not available: ${err.message}`);
173
- stack.envVars = {
174
- ...(stack.envVars || {}),
175
- WORKER_MODE: '*',
176
- };
177
197
  }
178
198
 
179
199
  const result = await adapter.scale(stack, Number(replicas));
@@ -42,6 +42,7 @@ export const queueMappingsActions = {
42
42
  .filter((q) => !mappedNames.has(q.name))
43
43
  .map((q) => ({
44
44
  name: q.name,
45
+ workerProcessName: q.workerProcessName,
45
46
  type: q.type,
46
47
  label: q.label,
47
48
  description: q.description,
@@ -1,142 +1,142 @@
1
- import { Context } from '@nocobase/actions';
2
-
3
- export const tasksActions = {
4
- async list(ctx: Context, next: () => Promise<void>) {
5
- const repo = ctx.db.getRepository('asyncTasks');
6
- const { page = 1, pageSize = 20, statusFilter } = ctx.action.params;
7
-
8
- const filter: any = {};
9
- if (statusFilter !== undefined && statusFilter !== '') {
10
- filter.status = statusFilter === 'null' ? null : Number(statusFilter);
11
- }
12
-
13
- const [rows, count] = await repo.findAndCount({
14
- filter,
15
- appends: ['createdBy'],
16
- offset: (page - 1) * pageSize,
17
- limit: pageSize,
18
- sort: ['-createdAt'],
19
- });
20
-
21
- ctx.body = {
22
- data: rows,
23
- meta: { count, page: Number(page), pageSize: Number(pageSize) },
24
- };
25
- await next();
26
- },
27
-
28
- async cancel(ctx: Context, next: () => Promise<void>) {
29
- const { filterByTk } = ctx.action.params;
30
- if (!filterByTk) {
31
- ctx.throw(400, 'Task ID is required');
32
- }
33
-
34
- const repo = ctx.db.getRepository('asyncTasks');
35
- const task = await repo.findOne({ filterByTk });
36
- if (!task) {
37
- ctx.throw(404, 'Task not found');
38
- }
39
-
40
- // Status: RUNNING = 0, PENDING = null
41
- const status = task.get('status');
42
- if (status !== 0 && status !== null) {
43
- ctx.throw(400, 'Task is not running or pending');
44
- }
45
-
46
- const user = ctx.state?.currentUser?.nickname || ctx.state?.currentUser?.id || 'unknown';
47
- ctx.app.logger.info(`[cluster-manager] Canceling task ${filterByTk} by user ${user}`);
48
-
49
- // Try to cancel via pub/sub for cross-instance support
50
- const pluginName = '@nocobase/plugin-async-task-manager';
51
- try {
52
- await ctx.app.pubSubManager.publish(`${pluginName}.task.cancel`, JSON.stringify({
53
- taskId: filterByTk,
54
- }));
55
- } catch {
56
- // Fallback: direct DB update
57
- }
58
-
59
- await repo.update({
60
- filterByTk,
61
- values: {
62
- status: -2, // CANCELED
63
- doneAt: new Date(),
64
- },
65
- });
66
-
67
- ctx.body = { success: true };
68
- await next();
69
- },
70
-
71
- async retry(ctx: Context, next: () => Promise<void>) {
72
- const { filterByTk } = ctx.action.params;
73
- if (!filterByTk) {
74
- ctx.throw(400, 'Task ID is required');
75
- }
76
-
77
- const repo = ctx.db.getRepository('asyncTasks');
78
- const task = await repo.findOne({ filterByTk });
79
- if (!task) {
80
- ctx.throw(404, 'Task not found');
81
- }
82
-
83
- const status = task.get('status');
84
- // Only retry failed (-1) or canceled (-2) tasks
85
- if (status !== -1 && status !== -2) {
86
- ctx.throw(400, 'Only failed or canceled tasks can be retried');
87
- }
88
-
89
- const user = ctx.state?.currentUser?.nickname || ctx.state?.currentUser?.id || 'unknown';
90
- ctx.app.logger.info(`[cluster-manager] Retrying task ${filterByTk} by user ${user}`);
91
-
92
- await repo.update({
93
- filterByTk,
94
- values: {
95
- status: null, // PENDING
96
- result: null,
97
- progressCurrent: 0,
98
- startedAt: null,
99
- doneAt: null,
100
- },
101
- });
102
-
103
- // Re-queue the task
104
- const pluginName = '@nocobase/plugin-async-task-manager';
105
- try {
106
- await ctx.app.eventQueue.publish(`${pluginName}.task`, {
107
- taskId: filterByTk,
108
- });
109
- } catch {
110
- // Queue may not be available
111
- }
112
-
113
- ctx.body = { success: true };
114
- await next();
115
- },
116
-
117
- async purge(ctx: Context, next: () => Promise<void>) {
118
- const { days = 0 } = ctx.action.params.values || ctx.action.params;
119
- const user = ctx.state?.currentUser?.nickname || ctx.state?.currentUser?.id || 'unknown';
120
- ctx.app.logger.info(`[cluster-manager] Purging tasks (days=${days}) by user ${user}`);
121
- const repo = ctx.db.getRepository('asyncTasks');
122
-
123
- const filter: any = {
124
- $and: [
125
- { status: { $ne: 0 } },
126
- { status: { $ne: null } }
127
- ]
128
- };
129
-
130
- if (days && Number(days) > 0) {
131
- const date = new Date();
132
- date.setDate(date.getDate() - Number(days));
133
- filter.createdAt = {
134
- $lt: date.toISOString(),
135
- };
136
- }
137
-
138
- const count = await repo.destroy({ filter });
139
- ctx.body = { success: true, deletedCount: count };
140
- await next();
141
- },
142
- };
1
+ import { Context } from '@nocobase/actions';
2
+
3
+ export const tasksActions = {
4
+ async list(ctx: Context, next: () => Promise<void>) {
5
+ const repo = ctx.db.getRepository('asyncTasks');
6
+ const { page = 1, pageSize = 20, statusFilter } = ctx.action.params;
7
+
8
+ const filter: any = {};
9
+ if (statusFilter !== undefined && statusFilter !== '') {
10
+ filter.status = statusFilter === 'null' ? null : Number(statusFilter);
11
+ }
12
+
13
+ const [rows, count] = await repo.findAndCount({
14
+ filter,
15
+ appends: ['createdBy'],
16
+ offset: (page - 1) * pageSize,
17
+ limit: pageSize,
18
+ sort: ['-createdAt'],
19
+ });
20
+
21
+ ctx.body = {
22
+ data: rows,
23
+ meta: { count, page: Number(page), pageSize: Number(pageSize) },
24
+ };
25
+ await next();
26
+ },
27
+
28
+ async cancel(ctx: Context, next: () => Promise<void>) {
29
+ const { filterByTk } = ctx.action.params;
30
+ if (!filterByTk) {
31
+ ctx.throw(400, 'Task ID is required');
32
+ }
33
+
34
+ const repo = ctx.db.getRepository('asyncTasks');
35
+ const task = await repo.findOne({ filterByTk });
36
+ if (!task) {
37
+ ctx.throw(404, 'Task not found');
38
+ }
39
+
40
+ // Status: RUNNING = 0, PENDING = null
41
+ const status = task.get('status');
42
+ if (status !== 0 && status !== null) {
43
+ ctx.throw(400, 'Task is not running or pending');
44
+ }
45
+
46
+ const user = ctx.state?.currentUser?.nickname || ctx.state?.currentUser?.id || 'unknown';
47
+ ctx.app.logger.info(`[cluster-manager] Canceling task ${filterByTk} by user ${user}`);
48
+
49
+ // Try to cancel via pub/sub for cross-instance support
50
+ const pluginName = '@nocobase/plugin-async-task-manager';
51
+ try {
52
+ await ctx.app.pubSubManager.publish(
53
+ `${pluginName}.task.cancel`,
54
+ JSON.stringify({
55
+ taskId: filterByTk,
56
+ }),
57
+ );
58
+ } catch {
59
+ // Fallback: direct DB update
60
+ }
61
+
62
+ await repo.update({
63
+ filterByTk,
64
+ values: {
65
+ status: -2, // CANCELED
66
+ doneAt: new Date(),
67
+ },
68
+ });
69
+
70
+ ctx.body = { success: true };
71
+ await next();
72
+ },
73
+
74
+ async retry(ctx: Context, next: () => Promise<void>) {
75
+ const { filterByTk } = ctx.action.params;
76
+ if (!filterByTk) {
77
+ ctx.throw(400, 'Task ID is required');
78
+ }
79
+
80
+ const repo = ctx.db.getRepository('asyncTasks');
81
+ const task = await repo.findOne({ filterByTk });
82
+ if (!task) {
83
+ ctx.throw(404, 'Task not found');
84
+ }
85
+
86
+ const status = task.get('status');
87
+ // Only retry failed (-1) or canceled (-2) tasks
88
+ if (status !== -1 && status !== -2) {
89
+ ctx.throw(400, 'Only failed or canceled tasks can be retried');
90
+ }
91
+
92
+ const user = ctx.state?.currentUser?.nickname || ctx.state?.currentUser?.id || 'unknown';
93
+ ctx.app.logger.info(`[cluster-manager] Retrying task ${filterByTk} by user ${user}`);
94
+
95
+ await repo.update({
96
+ filterByTk,
97
+ values: {
98
+ status: null, // PENDING
99
+ result: null,
100
+ progressCurrent: 0,
101
+ startedAt: null,
102
+ doneAt: null,
103
+ },
104
+ });
105
+
106
+ // Re-queue the task
107
+ const pluginName = '@nocobase/plugin-async-task-manager';
108
+ try {
109
+ await ctx.app.eventQueue.publish(`${pluginName}.task`, {
110
+ id: filterByTk,
111
+ });
112
+ } catch {
113
+ // Queue may not be available
114
+ }
115
+
116
+ ctx.body = { success: true };
117
+ await next();
118
+ },
119
+
120
+ async purge(ctx: Context, next: () => Promise<void>) {
121
+ const { days = 0 } = ctx.action.params.values || ctx.action.params;
122
+ const user = ctx.state?.currentUser?.nickname || ctx.state?.currentUser?.id || 'unknown';
123
+ ctx.app.logger.info(`[cluster-manager] Purging tasks (days=${days}) by user ${user}`);
124
+ const repo = ctx.db.getRepository('asyncTasks');
125
+
126
+ const filter: any = {
127
+ $and: [{ status: { $ne: 0 } }, { status: { $ne: null } }],
128
+ };
129
+
130
+ if (days && Number(days) > 0) {
131
+ const date = new Date();
132
+ date.setDate(date.getDate() - Number(days));
133
+ filter.createdAt = {
134
+ $lt: date.toISOString(),
135
+ };
136
+ }
137
+
138
+ const count = await repo.destroy({ filter });
139
+ ctx.body = { success: true, deletedCount: count };
140
+ await next();
141
+ },
142
+ };