@muyichengshayu/promptx 0.2.7 → 0.2.8

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 (43) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/apps/runner/src/engines/claudeCodeRunner.js +69 -1
  3. package/apps/runner/src/engines/claudeCodeRunner.test.js +279 -0
  4. package/apps/runner/src/engines/openCodeRunner.test.js +73 -0
  5. package/apps/runner/src/engines/shellRunner.test.js +46 -0
  6. package/apps/runner/src/runManager.js +110 -11
  7. package/apps/runner/src/runManager.test.js +913 -0
  8. package/apps/runner/src/serverClient.test.js +93 -0
  9. package/apps/server/src/agentSessionDiscovery.test.js +127 -0
  10. package/apps/server/src/agents/claudeCodeRunner.test.js +433 -0
  11. package/apps/server/src/agents/openCodeRunner.test.js +236 -0
  12. package/apps/server/src/agents/runnerContract.test.js +382 -0
  13. package/apps/server/src/appPaths.test.js +52 -0
  14. package/apps/server/src/assetRoutes.test.js +168 -0
  15. package/apps/server/src/codex.test.js +518 -0
  16. package/apps/server/src/codexRoutes.test.js +376 -0
  17. package/apps/server/src/codexRuns.test.js +160 -0
  18. package/apps/server/src/codexSessions.test.js +369 -0
  19. package/apps/server/src/db.test.js +182 -0
  20. package/apps/server/src/gitDiff.test.js +542 -0
  21. package/apps/server/src/gitDiffClient.test.js +140 -0
  22. package/apps/server/src/internalRoutes.test.js +134 -0
  23. package/apps/server/src/maintenance.test.js +154 -0
  24. package/apps/server/src/processControl.test.js +147 -0
  25. package/apps/server/src/relayClient.test.js +478 -0
  26. package/apps/server/src/relayConfig.test.js +73 -0
  27. package/apps/server/src/relayProtocol.test.js +49 -0
  28. package/apps/server/src/relayServer.test.js +798 -0
  29. package/apps/server/src/relayTenants.test.js +137 -0
  30. package/apps/server/src/relayUsageStore.test.js +65 -0
  31. package/apps/server/src/repository.test.js +150 -0
  32. package/apps/server/src/runDispatchService.test.js +563 -0
  33. package/apps/server/src/runEventIngest.test.js +225 -0
  34. package/apps/server/src/runRecovery.test.js +73 -0
  35. package/apps/server/src/runnerClient.test.js +80 -0
  36. package/apps/server/src/runnerDispatch.test.js +136 -0
  37. package/apps/server/src/systemConfig.test.js +112 -0
  38. package/apps/server/src/systemRoutes.test.js +319 -0
  39. package/apps/server/src/taskRoutes.test.js +726 -0
  40. package/apps/server/src/upload.test.js +30 -0
  41. package/apps/server/src/webAppRoutes.test.js +67 -0
  42. package/apps/server/src/workspaceFiles.test.js +262 -0
  43. package/package.json +14 -21
@@ -0,0 +1,726 @@
1
+ import assert from 'node:assert/strict'
2
+ import Fastify from 'fastify'
3
+ import test from 'node:test'
4
+
5
+ import { buildInternalAuthHeaders } from './internalAuth.js'
6
+ import {
7
+ createEmptyWorkspaceDiffSummary,
8
+ createTaskWorkspaceDiffSummaryService,
9
+ registerTaskRoutes,
10
+ } from './taskRoutes.js'
11
+
12
+ test('task workspace diff summary service normalizes and reuses workspace summaries', () => {
13
+ const lookups = []
14
+ const service = createTaskWorkspaceDiffSummaryService({
15
+ getPromptxCodexSessionById(sessionId) {
16
+ return {
17
+ s1: { id: 's1', cwd: '/repo/a' },
18
+ s2: { id: 's2', cwd: '/repo/a' },
19
+ }[sessionId] || null
20
+ },
21
+ getWorkspaceGitDiffStatusSummaryByCwd(cwd) {
22
+ lookups.push(cwd)
23
+ return {
24
+ supported: true,
25
+ summary: {
26
+ fileCount: 3,
27
+ additions: 8,
28
+ deletions: 2,
29
+ statsComplete: true,
30
+ },
31
+ }
32
+ },
33
+ listTasks() {
34
+ return [
35
+ { slug: 'a', codexSessionId: 's1' },
36
+ { slug: 'b', codexSessionId: 's2' },
37
+ { slug: 'c', codexSessionId: '' },
38
+ ]
39
+ },
40
+ })
41
+
42
+ const items = service.listTaskWorkspaceDiffSummaries()
43
+ assert.deepEqual(lookups, ['/repo/a'])
44
+ assert.deepEqual(items, [
45
+ {
46
+ slug: 'a',
47
+ workspaceDiffSummary: {
48
+ supported: true,
49
+ fileCount: 3,
50
+ additions: 8,
51
+ deletions: 2,
52
+ statsComplete: true,
53
+ },
54
+ },
55
+ {
56
+ slug: 'b',
57
+ workspaceDiffSummary: {
58
+ supported: true,
59
+ fileCount: 3,
60
+ additions: 8,
61
+ deletions: 2,
62
+ statsComplete: true,
63
+ },
64
+ },
65
+ {
66
+ slug: 'c',
67
+ workspaceDiffSummary: createEmptyWorkspaceDiffSummary(),
68
+ },
69
+ ])
70
+ })
71
+
72
+ test('task routes return 202 when runner dispatch remains pending', async () => {
73
+ const app = Fastify()
74
+ registerTaskRoutes(app, {
75
+ broadcastServerEvent: () => {},
76
+ buildTaskExports: () => ({ raw: '' }),
77
+ canEditTask: () => true,
78
+ createTask: () => null,
79
+ decorateTask: (task) => task,
80
+ decorateTaskList: (items) => items,
81
+ deleteTask: () => ({ error: 'not_found' }),
82
+ deleteTaskCodexRuns: () => {},
83
+ getPromptxCodexSessionById: () => ({ id: 'session-1' }),
84
+ getRunningCodexRunByTaskSlug: () => null,
85
+ getTaskBySlug: (slug) => ({ slug, expired: false }),
86
+ getTaskGitDiffReviewInSubprocess: async () => ({}),
87
+ listTaskCodexRunsWithOptions: () => [],
88
+ listTaskWorkspaceDiffSummaries: () => [],
89
+ listTasks: () => [],
90
+ reorderTasks: () => ({ changed: false, items: [] }),
91
+ purgeExpiredContent: () => {},
92
+ removeAssetFiles: () => {},
93
+ runDispatchService: {
94
+ async startTaskRunForTask() {
95
+ return {
96
+ run: { id: 'run-1', status: 'queued' },
97
+ runnerDispatchPending: true,
98
+ }
99
+ },
100
+ },
101
+ updateTask: () => null,
102
+ updateTaskCodexSession: () => null,
103
+ })
104
+ await app.ready()
105
+
106
+ try {
107
+ const response = await app.inject({
108
+ method: 'POST',
109
+ url: '/api/tasks/task-1/codex-runs',
110
+ payload: {
111
+ sessionId: 'session-1',
112
+ prompt: 'hello',
113
+ },
114
+ })
115
+
116
+ assert.equal(response.statusCode, 202)
117
+ assert.equal(response.json().run.id, 'run-1')
118
+ } finally {
119
+ await app.close()
120
+ }
121
+ })
122
+
123
+ test('task routes block remote shell commands', async () => {
124
+ const app = Fastify()
125
+ registerTaskRoutes(app, {
126
+ broadcastServerEvent: () => {},
127
+ buildTaskExports: () => ({ raw: '' }),
128
+ canEditTask: () => true,
129
+ createTask: () => null,
130
+ decorateTask: (task) => task,
131
+ decorateTaskList: (items) => items,
132
+ deleteTask: () => ({ error: 'not_found' }),
133
+ deleteTaskCodexRuns: () => {},
134
+ getPromptxCodexSessionById: () => ({ id: 'session-1' }),
135
+ getRunningCodexRunByTaskSlug: () => null,
136
+ getTaskBySlug: (slug) => ({ slug, expired: false }),
137
+ getTaskGitDiffReviewInSubprocess: async () => ({}),
138
+ listTaskCodexRunsWithOptions: () => [],
139
+ listTaskWorkspaceDiffSummaries: () => [],
140
+ listTasks: () => [],
141
+ reorderTasks: () => ({ changed: false, items: [] }),
142
+ purgeExpiredContent: () => {},
143
+ removeAssetFiles: () => {},
144
+ runDispatchService: {
145
+ async startTaskRunForTask(payload = {}) {
146
+ if (payload.allowShellCommand !== true) {
147
+ const error = new Error('命令模式默认仅允许在本机本地界面中使用;如需对远程访问开放,请先到设置里显式开启。')
148
+ error.statusCode = 403
149
+ error.messageKey = 'errors.shellLocalOnly'
150
+ throw error
151
+ }
152
+ return {
153
+ run: { id: 'run-shell-1', status: 'queued' },
154
+ runnerDispatchPending: false,
155
+ }
156
+ },
157
+ },
158
+ updateTask: () => null,
159
+ updateTaskCodexSession: () => null,
160
+ })
161
+ await app.ready()
162
+
163
+ try {
164
+ const response = await app.inject({
165
+ method: 'POST',
166
+ url: '/api/tasks/task-1/codex-runs',
167
+ headers: {
168
+ origin: 'https://dongdong.promptx.mushayu.com',
169
+ },
170
+ payload: {
171
+ sessionId: 'session-1',
172
+ prompt: '!pwd',
173
+ promptBlocks: [{ type: 'text', content: '!pwd' }],
174
+ commandMode: 'shell',
175
+ },
176
+ })
177
+
178
+ assert.equal(response.statusCode, 403)
179
+ assert.equal(response.json().messageKey, 'errors.shellLocalOnly')
180
+ } finally {
181
+ await app.close()
182
+ }
183
+ })
184
+
185
+ test('task routes allow relay remote shell commands when remote command security uses relay mode', async () => {
186
+ let allowShellCommand = false
187
+ const app = Fastify()
188
+ registerTaskRoutes(app, {
189
+ broadcastServerEvent: () => {},
190
+ buildTaskExports: () => ({ raw: '' }),
191
+ canEditTask: () => true,
192
+ createTask: () => null,
193
+ decorateTask: (task) => task,
194
+ decorateTaskList: (items) => items,
195
+ deleteTask: () => ({ error: 'not_found' }),
196
+ deleteTaskCodexRuns: () => {},
197
+ getPromptxCodexSessionById: () => ({ id: 'session-1' }),
198
+ getRelayConfig: () => ({ allowRemoteShell: true }),
199
+ getSystemConfig: () => ({
200
+ remoteCommandSecurity: {
201
+ mode: 'relay',
202
+ trustedProxyToken: '',
203
+ },
204
+ }),
205
+ getRunningCodexRunByTaskSlug: () => null,
206
+ getTaskBySlug: (slug) => ({ slug, expired: false }),
207
+ getTaskGitDiffReviewInSubprocess: async () => ({}),
208
+ listTaskCodexRunsWithOptions: () => [],
209
+ listTaskWorkspaceDiffSummaries: () => [],
210
+ listTasks: () => [],
211
+ reorderTasks: () => ({ changed: false, items: [] }),
212
+ purgeExpiredContent: () => {},
213
+ removeAssetFiles: () => {},
214
+ runDispatchService: {
215
+ async startTaskRunForTask(payload = {}) {
216
+ allowShellCommand = payload.allowShellCommand === true
217
+ return {
218
+ run: { id: 'run-shell-1', status: 'queued' },
219
+ runnerDispatchPending: false,
220
+ }
221
+ },
222
+ },
223
+ updateTask: () => null,
224
+ updateTaskCodexSession: () => null,
225
+ })
226
+ await app.ready()
227
+
228
+ try {
229
+ const response = await app.inject({
230
+ method: 'POST',
231
+ url: '/api/tasks/task-1/codex-runs',
232
+ headers: buildInternalAuthHeaders({
233
+ origin: 'https://dongdong.promptx.mushayu.com',
234
+ 'x-promptx-relay-request': '1',
235
+ }),
236
+ payload: {
237
+ sessionId: 'session-1',
238
+ prompt: '!pwd',
239
+ promptBlocks: [{ type: 'text', content: '!pwd' }],
240
+ commandMode: 'shell',
241
+ },
242
+ })
243
+
244
+ assert.equal(response.statusCode, 201)
245
+ assert.equal(allowShellCommand, true)
246
+ } finally {
247
+ await app.close()
248
+ }
249
+ })
250
+
251
+ test('task routes block non-relay remote shell requests in relay security mode', async () => {
252
+ const app = Fastify()
253
+ registerTaskRoutes(app, {
254
+ broadcastServerEvent: () => {},
255
+ buildTaskExports: () => ({ raw: '' }),
256
+ canEditTask: () => true,
257
+ createTask: () => null,
258
+ decorateTask: (task) => task,
259
+ decorateTaskList: (items) => items,
260
+ deleteTask: () => ({ error: 'not_found' }),
261
+ deleteTaskCodexRuns: () => {},
262
+ getPromptxCodexSessionById: () => ({ id: 'session-1' }),
263
+ getRelayConfig: () => ({ allowRemoteShell: true }),
264
+ getSystemConfig: () => ({
265
+ remoteCommandSecurity: {
266
+ mode: 'relay',
267
+ trustedProxyToken: '',
268
+ },
269
+ }),
270
+ getRunningCodexRunByTaskSlug: () => null,
271
+ getTaskBySlug: (slug) => ({ slug, expired: false }),
272
+ getTaskGitDiffReviewInSubprocess: async () => ({}),
273
+ listTaskCodexRunsWithOptions: () => [],
274
+ listTaskWorkspaceDiffSummaries: () => [],
275
+ listTasks: () => [],
276
+ reorderTasks: () => ({ changed: false, items: [] }),
277
+ purgeExpiredContent: () => {},
278
+ removeAssetFiles: () => {},
279
+ runDispatchService: {
280
+ async startTaskRunForTask(payload = {}) {
281
+ if (payload.allowShellCommand !== true) {
282
+ const error = new Error('命令模式默认仅允许在本机本地界面中使用;如需对远程访问开放,请先到设置里显式开启。')
283
+ error.statusCode = 403
284
+ error.messageKey = 'errors.shellLocalOnly'
285
+ throw error
286
+ }
287
+ return {
288
+ run: { id: 'run-shell-1', status: 'queued' },
289
+ runnerDispatchPending: false,
290
+ }
291
+ },
292
+ },
293
+ updateTask: () => null,
294
+ updateTaskCodexSession: () => null,
295
+ })
296
+ await app.ready()
297
+
298
+ try {
299
+ const response = await app.inject({
300
+ method: 'POST',
301
+ url: '/api/tasks/task-1/codex-runs',
302
+ headers: {
303
+ origin: 'https://dongdong.promptx.mushayu.com',
304
+ },
305
+ payload: {
306
+ sessionId: 'session-1',
307
+ prompt: '!pwd',
308
+ promptBlocks: [{ type: 'text', content: '!pwd' }],
309
+ commandMode: 'shell',
310
+ },
311
+ })
312
+
313
+ assert.equal(response.statusCode, 403)
314
+ assert.equal(response.json().messageKey, 'errors.shellLocalOnly')
315
+ } finally {
316
+ await app.close()
317
+ }
318
+ })
319
+
320
+ test('task routes allow trusted proxy remote shell commands when configured', async () => {
321
+ let allowShellCommand = false
322
+ const app = Fastify()
323
+ registerTaskRoutes(app, {
324
+ broadcastServerEvent: () => {},
325
+ buildTaskExports: () => ({ raw: '' }),
326
+ canEditTask: () => true,
327
+ createTask: () => null,
328
+ decorateTask: (task) => task,
329
+ decorateTaskList: (items) => items,
330
+ deleteTask: () => ({ error: 'not_found' }),
331
+ deleteTaskCodexRuns: () => {},
332
+ getPromptxCodexSessionById: () => ({ id: 'session-1' }),
333
+ getRelayConfig: () => ({ allowRemoteShell: false }),
334
+ getSystemConfig: () => ({
335
+ remoteCommandSecurity: {
336
+ mode: 'trusted-proxy',
337
+ trustedProxyToken: 'trusted-token',
338
+ },
339
+ }),
340
+ getRunningCodexRunByTaskSlug: () => null,
341
+ getTaskBySlug: (slug) => ({ slug, expired: false }),
342
+ getTaskGitDiffReviewInSubprocess: async () => ({}),
343
+ listTaskCodexRunsWithOptions: () => [],
344
+ listTaskWorkspaceDiffSummaries: () => [],
345
+ listTasks: () => [],
346
+ reorderTasks: () => ({ changed: false, items: [] }),
347
+ purgeExpiredContent: () => {},
348
+ removeAssetFiles: () => {},
349
+ runDispatchService: {
350
+ async startTaskRunForTask(payload = {}) {
351
+ allowShellCommand = payload.allowShellCommand === true
352
+ return {
353
+ run: { id: 'run-shell-1', status: 'queued' },
354
+ runnerDispatchPending: false,
355
+ }
356
+ },
357
+ },
358
+ updateTask: () => null,
359
+ updateTaskCodexSession: () => null,
360
+ })
361
+ await app.ready()
362
+
363
+ try {
364
+ const response = await app.inject({
365
+ method: 'POST',
366
+ url: '/api/tasks/task-1/codex-runs',
367
+ headers: {
368
+ origin: 'https://dev.promptx.test',
369
+ 'x-promptx-trusted-proxy': '1',
370
+ 'x-promptx-proxy-token': 'trusted-token',
371
+ },
372
+ payload: {
373
+ sessionId: 'session-1',
374
+ prompt: '!pwd',
375
+ promptBlocks: [{ type: 'text', content: '!pwd' }],
376
+ commandMode: 'shell',
377
+ },
378
+ })
379
+
380
+ assert.equal(response.statusCode, 201)
381
+ assert.equal(allowShellCommand, true)
382
+ } finally {
383
+ await app.close()
384
+ }
385
+ })
386
+
387
+ test('task routes ignore legacy relay allowRemoteShell when remote command security is disabled', async () => {
388
+ const app = Fastify()
389
+ registerTaskRoutes(app, {
390
+ broadcastServerEvent: () => {},
391
+ buildTaskExports: () => ({ raw: '' }),
392
+ canEditTask: () => true,
393
+ createTask: () => null,
394
+ decorateTask: (task) => task,
395
+ decorateTaskList: (items) => items,
396
+ deleteTask: () => ({ error: 'not_found' }),
397
+ deleteTaskCodexRuns: () => {},
398
+ getPromptxCodexSessionById: () => ({ id: 'session-1' }),
399
+ getRelayConfig: () => ({ allowRemoteShell: true }),
400
+ getSystemConfig: () => ({
401
+ remoteCommandSecurity: {
402
+ mode: 'disabled',
403
+ trustedProxyToken: '',
404
+ },
405
+ }),
406
+ getRunningCodexRunByTaskSlug: () => null,
407
+ getTaskBySlug: (slug) => ({ slug, expired: false }),
408
+ getTaskGitDiffReviewInSubprocess: async () => ({}),
409
+ listTaskCodexRunsWithOptions: () => [],
410
+ listTaskWorkspaceDiffSummaries: () => [],
411
+ listTasks: () => [],
412
+ reorderTasks: () => ({ changed: false, items: [] }),
413
+ purgeExpiredContent: () => {},
414
+ removeAssetFiles: () => {},
415
+ runDispatchService: {
416
+ async startTaskRunForTask(payload = {}) {
417
+ if (payload.allowShellCommand !== true) {
418
+ const error = new Error('当前入口未被允许执行命令。请在设置 -> 通用 -> 远程命令安全中启用对应模式,或改为本机本地访问。')
419
+ error.statusCode = 403
420
+ error.messageKey = 'errors.shellLocalOnly'
421
+ throw error
422
+ }
423
+ return {
424
+ run: { id: 'run-shell-1', status: 'queued' },
425
+ runnerDispatchPending: false,
426
+ }
427
+ },
428
+ },
429
+ updateTask: () => null,
430
+ updateTaskCodexSession: () => null,
431
+ })
432
+ await app.ready()
433
+
434
+ try {
435
+ const response = await app.inject({
436
+ method: 'POST',
437
+ url: '/api/tasks/task-1/codex-runs',
438
+ headers: buildInternalAuthHeaders({
439
+ origin: 'https://dongdong.promptx.mushayu.com',
440
+ 'x-promptx-relay-request': '1',
441
+ }),
442
+ payload: {
443
+ sessionId: 'session-1',
444
+ prompt: '!pwd',
445
+ promptBlocks: [{ type: 'text', content: '!pwd' }],
446
+ commandMode: 'shell',
447
+ },
448
+ })
449
+
450
+ assert.equal(response.statusCode, 403)
451
+ assert.equal(response.json().messageKey, 'errors.shellLocalOnly')
452
+ } finally {
453
+ await app.close()
454
+ }
455
+ })
456
+
457
+ test('task routes block trusted proxy remote shell commands when token mismatches', async () => {
458
+ const app = Fastify()
459
+ registerTaskRoutes(app, {
460
+ broadcastServerEvent: () => {},
461
+ buildTaskExports: () => ({ raw: '' }),
462
+ canEditTask: () => true,
463
+ createTask: () => null,
464
+ decorateTask: (task) => task,
465
+ decorateTaskList: (items) => items,
466
+ deleteTask: () => ({ error: 'not_found' }),
467
+ deleteTaskCodexRuns: () => {},
468
+ getPromptxCodexSessionById: () => ({ id: 'session-1' }),
469
+ getRelayConfig: () => ({ allowRemoteShell: false }),
470
+ getSystemConfig: () => ({
471
+ remoteCommandSecurity: {
472
+ mode: 'trusted-proxy',
473
+ trustedProxyToken: 'trusted-token',
474
+ },
475
+ }),
476
+ getRunningCodexRunByTaskSlug: () => null,
477
+ getTaskBySlug: (slug) => ({ slug, expired: false }),
478
+ getTaskGitDiffReviewInSubprocess: async () => ({}),
479
+ listTaskCodexRunsWithOptions: () => [],
480
+ listTaskWorkspaceDiffSummaries: () => [],
481
+ listTasks: () => [],
482
+ reorderTasks: () => ({ changed: false, items: [] }),
483
+ purgeExpiredContent: () => {},
484
+ removeAssetFiles: () => {},
485
+ runDispatchService: {
486
+ async startTaskRunForTask(payload = {}) {
487
+ if (payload.allowShellCommand !== true) {
488
+ const error = new Error('命令模式默认仅允许在本机本地界面中使用;如需对远程访问开放,请先到设置里显式开启。')
489
+ error.statusCode = 403
490
+ error.messageKey = 'errors.shellLocalOnly'
491
+ throw error
492
+ }
493
+ return {
494
+ run: { id: 'run-shell-1', status: 'queued' },
495
+ runnerDispatchPending: false,
496
+ }
497
+ },
498
+ },
499
+ updateTask: () => null,
500
+ updateTaskCodexSession: () => null,
501
+ })
502
+ await app.ready()
503
+
504
+ try {
505
+ const response = await app.inject({
506
+ method: 'POST',
507
+ url: '/api/tasks/task-1/codex-runs',
508
+ headers: {
509
+ origin: 'https://dev.promptx.test',
510
+ 'x-promptx-trusted-proxy': '1',
511
+ 'x-promptx-proxy-token': 'wrong-token',
512
+ },
513
+ payload: {
514
+ sessionId: 'session-1',
515
+ prompt: '!pwd',
516
+ promptBlocks: [{ type: 'text', content: '!pwd' }],
517
+ commandMode: 'shell',
518
+ },
519
+ })
520
+
521
+ assert.equal(response.statusCode, 403)
522
+ assert.equal(response.json().messageKey, 'errors.shellLocalOnly')
523
+ } finally {
524
+ await app.close()
525
+ }
526
+ })
527
+
528
+ test('task routes block clearing runs while task is active', async () => {
529
+ const app = Fastify()
530
+ registerTaskRoutes(app, {
531
+ broadcastServerEvent: () => {},
532
+ buildTaskExports: () => ({ raw: '' }),
533
+ canEditTask: () => true,
534
+ createTask: () => null,
535
+ decorateTask: (task) => task,
536
+ decorateTaskList: (items) => items,
537
+ deleteTask: () => ({ error: 'not_found' }),
538
+ deleteTaskCodexRuns: () => {},
539
+ getPromptxCodexSessionById: () => null,
540
+ getRunningCodexRunByTaskSlug: () => ({ id: 'run-1', status: 'running' }),
541
+ getTaskBySlug: (slug) => ({ slug, expired: false }),
542
+ getTaskGitDiffReviewInSubprocess: async () => ({}),
543
+ listTaskCodexRunsWithOptions: () => [],
544
+ listTaskWorkspaceDiffSummaries: () => [],
545
+ listTasks: () => [],
546
+ reorderTasks: () => ({ changed: false, items: [] }),
547
+ purgeExpiredContent: () => {},
548
+ removeAssetFiles: () => {},
549
+ runDispatchService: {
550
+ async startTaskRunForTask() {
551
+ return null
552
+ },
553
+ },
554
+ updateTask: () => null,
555
+ updateTaskCodexSession: () => null,
556
+ })
557
+ await app.ready()
558
+
559
+ try {
560
+ const response = await app.inject({
561
+ method: 'DELETE',
562
+ url: '/api/tasks/task-1/codex-runs',
563
+ })
564
+
565
+ assert.equal(response.statusCode, 409)
566
+ assert.match(response.json().message, /正在执行/)
567
+ } finally {
568
+ await app.close()
569
+ }
570
+ })
571
+
572
+ test('task routes reorder tasks and broadcast list change', async () => {
573
+ const broadcasts = []
574
+ const app = Fastify()
575
+ registerTaskRoutes(app, {
576
+ broadcastServerEvent: (type, payload) => broadcasts.push({ type, payload }),
577
+ buildTaskExports: () => ({ raw: '' }),
578
+ canEditTask: () => true,
579
+ createTask: () => null,
580
+ decorateTask: (task) => task,
581
+ decorateTaskList: (items) => items,
582
+ deleteTask: () => ({ error: 'not_found' }),
583
+ deleteTaskCodexRuns: () => {},
584
+ getPromptxCodexSessionById: () => null,
585
+ getRunningCodexRunByTaskSlug: () => null,
586
+ getTaskBySlug: (slug) => ({ slug, expired: false }),
587
+ getTaskGitDiffReviewInSubprocess: async () => ({}),
588
+ listTaskCodexRunsWithOptions: () => [],
589
+ listTaskWorkspaceDiffSummaries: () => [],
590
+ listTasks: () => [],
591
+ reorderTasks: (slugs) => ({
592
+ changed: true,
593
+ items: slugs.map((slug) => ({ slug })),
594
+ }),
595
+ purgeExpiredContent: () => {},
596
+ removeAssetFiles: () => {},
597
+ runDispatchService: {
598
+ async startTaskRunForTask() {
599
+ return null
600
+ },
601
+ },
602
+ updateTask: () => null,
603
+ updateTaskCodexSession: () => null,
604
+ })
605
+ await app.ready()
606
+
607
+ try {
608
+ const response = await app.inject({
609
+ method: 'POST',
610
+ url: '/api/tasks/reorder',
611
+ payload: {
612
+ slugs: ['task-b', 'task-a'],
613
+ },
614
+ })
615
+
616
+ assert.equal(response.statusCode, 200)
617
+ assert.deepEqual(response.json().items, [{ slug: 'task-b' }, { slug: 'task-a' }])
618
+ assert.deepEqual(broadcasts, [{
619
+ type: 'tasks.changed',
620
+ payload: { reason: 'reordered' },
621
+ }])
622
+ } finally {
623
+ await app.close()
624
+ }
625
+ })
626
+
627
+ test('task routes reject invalid reorder payload', async () => {
628
+ const app = Fastify()
629
+ registerTaskRoutes(app, {
630
+ broadcastServerEvent: () => {},
631
+ buildTaskExports: () => ({ raw: '' }),
632
+ canEditTask: () => true,
633
+ createTask: () => null,
634
+ decorateTask: (task) => task,
635
+ decorateTaskList: (items) => items,
636
+ deleteTask: () => ({ error: 'not_found' }),
637
+ deleteTaskCodexRuns: () => {},
638
+ getPromptxCodexSessionById: () => null,
639
+ getRunningCodexRunByTaskSlug: () => null,
640
+ getTaskBySlug: (slug) => ({ slug, expired: false }),
641
+ getTaskGitDiffReviewInSubprocess: async () => ({}),
642
+ listTaskCodexRunsWithOptions: () => [],
643
+ listTaskWorkspaceDiffSummaries: () => [],
644
+ listTasks: () => [],
645
+ reorderTasks: () => ({ changed: false, items: [] }),
646
+ purgeExpiredContent: () => {},
647
+ removeAssetFiles: () => {},
648
+ runDispatchService: {
649
+ async startTaskRunForTask() {
650
+ return null
651
+ },
652
+ },
653
+ updateTask: () => null,
654
+ updateTaskCodexSession: () => null,
655
+ })
656
+ await app.ready()
657
+
658
+ try {
659
+ const response = await app.inject({
660
+ method: 'POST',
661
+ url: '/api/tasks/reorder',
662
+ payload: {
663
+ slugs: ['', ' '],
664
+ },
665
+ })
666
+
667
+ assert.equal(response.statusCode, 400)
668
+ assert.match(response.json().message, /排序数据无效/)
669
+ } finally {
670
+ await app.close()
671
+ }
672
+ })
673
+
674
+ test('task routes serve binary diff blobs', async () => {
675
+ const app = Fastify()
676
+ registerTaskRoutes(app, {
677
+ broadcastServerEvent: () => {},
678
+ buildTaskExports: () => ({ raw: '' }),
679
+ canEditTask: () => true,
680
+ createTask: () => null,
681
+ decorateTask: (task) => task,
682
+ decorateTaskList: (items) => items,
683
+ deleteTask: () => ({ error: 'not_found' }),
684
+ deleteTaskCodexRuns: () => {},
685
+ getPromptxCodexSessionById: () => null,
686
+ getRunningCodexRunByTaskSlug: () => null,
687
+ getTaskBySlug: (slug) => ({ slug, expired: false }),
688
+ getTaskGitDiffBlob: () => ({
689
+ supported: true,
690
+ statusCode: 200,
691
+ mimeType: 'image/png',
692
+ size: 4,
693
+ hash: 'abcd1234',
694
+ body: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
695
+ }),
696
+ getTaskGitDiffReviewInSubprocess: async () => ({}),
697
+ listTaskCodexRunsWithOptions: () => [],
698
+ listTaskWorkspaceDiffSummaries: () => [],
699
+ listTasks: () => [],
700
+ reorderTasks: () => ({ changed: false, items: [] }),
701
+ purgeExpiredContent: () => {},
702
+ removeAssetFiles: () => {},
703
+ runDispatchService: {
704
+ async startTaskRunForTask() {
705
+ return null
706
+ },
707
+ },
708
+ updateTask: () => null,
709
+ updateTaskCodexSession: () => null,
710
+ })
711
+ await app.ready()
712
+
713
+ try {
714
+ const response = await app.inject({
715
+ method: 'GET',
716
+ url: '/api/tasks/task-1/git-diff/blob?scope=task&filePath=assets/logo.png&side=after',
717
+ })
718
+
719
+ assert.equal(response.statusCode, 200)
720
+ assert.equal(response.headers['content-type'], 'image/png')
721
+ assert.equal(response.headers['x-promptx-file-size'], '4')
722
+ assert.equal(response.body.length > 0, true)
723
+ } finally {
724
+ await app.close()
725
+ }
726
+ })