@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,376 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import Fastify from 'fastify'
6
+ import test from 'node:test'
7
+
8
+ import {
9
+ createWorkspaceSuggestionService,
10
+ registerCodexRoutes,
11
+ } from './codexRoutes.js'
12
+
13
+ test('workspace suggestion service merges and deduplicates sources', () => {
14
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-workspaces-'))
15
+ const workspaceRootDir = path.join(tempRoot, 'promptx')
16
+ const siblingA = path.join(tempRoot, 'repo-a')
17
+ const siblingB = path.join(tempRoot, 'repo-b')
18
+ const knownOnly = path.join(tempRoot, 'known-only')
19
+
20
+ fs.mkdirSync(workspaceRootDir, { recursive: true })
21
+ fs.mkdirSync(siblingA, { recursive: true })
22
+ fs.mkdirSync(siblingB, { recursive: true })
23
+ fs.mkdirSync(knownOnly, { recursive: true })
24
+
25
+ try {
26
+ const service = createWorkspaceSuggestionService({
27
+ listKnownWorkspacesByEngine: () => [siblingA, knownOnly],
28
+ listPromptxCodexSessions: () => [
29
+ { cwd: siblingB },
30
+ { cwd: workspaceRootDir },
31
+ ],
32
+ workspaceParentDir: tempRoot,
33
+ workspaceRootDir,
34
+ })
35
+
36
+ const items = service.listWorkspaceSuggestions(10, 'codex')
37
+ assert.deepEqual(items, [
38
+ workspaceRootDir,
39
+ knownOnly,
40
+ siblingA,
41
+ siblingB,
42
+ ])
43
+ } finally {
44
+ fs.rmSync(tempRoot, { recursive: true, force: true })
45
+ }
46
+ })
47
+
48
+ test('codex routes return stop acceptance and event history', async () => {
49
+ const app = Fastify()
50
+ registerCodexRoutes(app, {
51
+ broadcastServerEvent: () => {},
52
+ clearTaskCodexSessionReferences: () => [],
53
+ createPromptxCodexSession: () => ({ id: 'session-1' }),
54
+ decorateCodexSession: (session) => session,
55
+ decorateCodexSessionList: (items) => items,
56
+ deletePromptxCodexSession: () => null,
57
+ getCodexRunById(runId) {
58
+ if (runId !== 'run-1') {
59
+ return null
60
+ }
61
+ return {
62
+ id: 'run-1',
63
+ status: 'running',
64
+ }
65
+ },
66
+ getPromptxCodexSessionById: () => null,
67
+ getRunningCodexRunBySessionId: () => null,
68
+ isActiveRunStatus: (status) => ['queued', 'starting', 'running', 'stopping'].includes(status),
69
+ listCodexRunEvents: () => [{ seq: 1, type: 'stdout', text: 'hello' }],
70
+ listDirectoryPickerTree: () => ({}),
71
+ listPromptxCodexSessions: () => [],
72
+ listWorkspaceSuggestions: () => [],
73
+ listWorkspaceTree: () => ({}),
74
+ runDispatchService: {
75
+ async requestRunStop() {
76
+ return {
77
+ accepted: true,
78
+ run: {
79
+ id: 'run-1',
80
+ status: 'stopping',
81
+ },
82
+ }
83
+ },
84
+ },
85
+ searchDirectoryPickerEntries: () => ({}),
86
+ searchWorkspaceFileContent: () => ({}),
87
+ searchWorkspaceEntries: () => ({}),
88
+ updatePromptxCodexSession: () => null,
89
+ })
90
+ await app.ready()
91
+
92
+ try {
93
+ const stopResponse = await app.inject({
94
+ method: 'POST',
95
+ url: '/api/codex/runs/run-1/stop',
96
+ payload: {
97
+ reason: 'user_requested',
98
+ },
99
+ })
100
+ assert.equal(stopResponse.statusCode, 202)
101
+ assert.equal(stopResponse.json().run.status, 'stopping')
102
+
103
+ const eventsResponse = await app.inject({
104
+ method: 'GET',
105
+ url: '/api/codex/runs/run-1/events',
106
+ })
107
+ assert.equal(eventsResponse.statusCode, 200)
108
+ assert.deepEqual(eventsResponse.json(), {
109
+ items: [{ seq: 1, type: 'stdout', text: 'hello' }],
110
+ })
111
+ } finally {
112
+ await app.close()
113
+ }
114
+ })
115
+
116
+ test('codex routes expose local session candidates by engine', async () => {
117
+ const app = Fastify()
118
+ registerCodexRoutes(app, {
119
+ broadcastServerEvent: () => {},
120
+ clearTaskCodexSessionReferences: () => [],
121
+ createPromptxCodexSession: () => ({ id: 'session-1' }),
122
+ decorateCodexSession: (session) => session,
123
+ decorateCodexSessionList: (items) => items,
124
+ deletePromptxCodexSession: () => null,
125
+ getCodexRunById: () => null,
126
+ getPromptxCodexSessionById: () => null,
127
+ getRunningCodexRunBySessionId: () => null,
128
+ isActiveRunStatus: () => false,
129
+ listCodexRunEvents: () => [],
130
+ listDirectoryPickerTree: () => ({}),
131
+ listKnownSessionsByEngine(engine, options) {
132
+ return [{
133
+ id: `${engine}-session-1`,
134
+ engine,
135
+ cwd: options.cwd,
136
+ updatedAt: '2026-04-13T08:00:00.000Z',
137
+ }]
138
+ },
139
+ listPromptxCodexSessions: () => [],
140
+ listWorkspaceSuggestions: () => [],
141
+ listWorkspaceTree: () => ({}),
142
+ runDispatchService: {
143
+ async requestRunStop() {
144
+ return null
145
+ },
146
+ },
147
+ searchDirectoryPickerEntries: () => ({}),
148
+ searchWorkspaceFileContent: () => ({}),
149
+ searchWorkspaceEntries: () => ({}),
150
+ updatePromptxCodexSession: () => null,
151
+ })
152
+ await app.ready()
153
+
154
+ try {
155
+ const response = await app.inject({
156
+ method: 'GET',
157
+ url: '/api/codex/session-candidates?engine=claude-code&cwd=%2Ftmp%2Fproject&limit=20',
158
+ })
159
+
160
+ assert.equal(response.statusCode, 200)
161
+ assert.deepEqual(response.json(), {
162
+ items: [{
163
+ id: 'claude-code-session-1',
164
+ engine: 'claude-code',
165
+ cwd: '/tmp/project',
166
+ updatedAt: '2026-04-13T08:00:00.000Z',
167
+ }],
168
+ })
169
+ } finally {
170
+ await app.close()
171
+ }
172
+ })
173
+
174
+ test('codex routes broadcast task updates when deleting a session with references', async () => {
175
+ const broadcasts = []
176
+ const app = Fastify()
177
+ registerCodexRoutes(app, {
178
+ broadcastServerEvent(type, payload) {
179
+ broadcasts.push({ type, payload })
180
+ },
181
+ clearTaskCodexSessionReferences: () => ['task-a', 'task-b'],
182
+ createPromptxCodexSession: () => ({ id: 'session-1' }),
183
+ decorateCodexSession: (session) => session,
184
+ decorateCodexSessionList: (items) => items,
185
+ deletePromptxCodexSession: () => ({ id: 'session-1' }),
186
+ getCodexRunById: () => null,
187
+ getPromptxCodexSessionById: () => ({ id: 'session-1', agentBindings: [] }),
188
+ getRunningCodexRunBySessionId: () => null,
189
+ isActiveRunStatus: () => false,
190
+ listCodexRunEvents: () => [],
191
+ listDirectoryPickerTree: () => ({}),
192
+ listPromptxCodexSessions: () => [],
193
+ listWorkspaceSuggestions: () => [],
194
+ listWorkspaceTree: () => ({}),
195
+ runDispatchService: {
196
+ async requestRunStop() {
197
+ return null
198
+ },
199
+ },
200
+ searchDirectoryPickerEntries: () => ({}),
201
+ searchWorkspaceFileContent: () => ({}),
202
+ searchWorkspaceEntries: () => ({}),
203
+ updatePromptxCodexSession: () => null,
204
+ })
205
+ await app.ready()
206
+
207
+ try {
208
+ const response = await app.inject({
209
+ method: 'DELETE',
210
+ url: '/api/codex/sessions/session-1',
211
+ })
212
+
213
+ assert.equal(response.statusCode, 204)
214
+ assert.deepEqual(broadcasts, [
215
+ {
216
+ type: 'sessions.changed',
217
+ payload: { sessionId: 'session-1' },
218
+ },
219
+ {
220
+ type: 'tasks.changed',
221
+ payload: { taskSlug: 'task-a', reason: 'session-cleared' },
222
+ },
223
+ {
224
+ type: 'tasks.changed',
225
+ payload: { taskSlug: 'task-b', reason: 'session-cleared' },
226
+ },
227
+ ])
228
+ } finally {
229
+ await app.close()
230
+ }
231
+ })
232
+
233
+ test('codex routes reset session and clear related run history', async () => {
234
+ const broadcasts = []
235
+ const deletedTaskRuns = []
236
+ const app = Fastify()
237
+ registerCodexRoutes(app, {
238
+ broadcastServerEvent(type, payload) {
239
+ broadcasts.push({ type, payload })
240
+ },
241
+ clearTaskCodexSessionReferences: () => [],
242
+ createPromptxCodexSession: () => ({ id: 'session-1' }),
243
+ decorateCodexSession: (session) => ({ ...session, decorated: true }),
244
+ decorateCodexSessionList: (items) => items,
245
+ deletePromptxCodexSession: () => null,
246
+ deleteTaskCodexRuns(taskSlug) {
247
+ deletedTaskRuns.push(taskSlug)
248
+ },
249
+ getCodexRunById: () => null,
250
+ getPromptxCodexSessionById: () => ({ id: 'session-1' }),
251
+ getRunningCodexRunBySessionId: () => null,
252
+ getRunningCodexRunByTaskSlug: () => null,
253
+ isActiveRunStatus: () => false,
254
+ listCodexRunEvents: () => [],
255
+ listDirectoryPickerTree: () => ({}),
256
+ listPromptxCodexSessions: () => [],
257
+ listTaskSlugsByCodexSessionId: () => ['task-a', 'task-b'],
258
+ listWorkspaceSuggestions: () => [],
259
+ listWorkspaceTree: () => ({}),
260
+ resetPromptxCodexSession: () => ({ id: 'session-1', title: '项目 A' }),
261
+ runDispatchService: {
262
+ async requestRunStop() {
263
+ return null
264
+ },
265
+ },
266
+ searchDirectoryPickerEntries: () => ({}),
267
+ searchWorkspaceFileContent: () => ({}),
268
+ searchWorkspaceEntries: () => ({}),
269
+ updatePromptxCodexSession: () => null,
270
+ })
271
+ await app.ready()
272
+
273
+ try {
274
+ const response = await app.inject({
275
+ method: 'POST',
276
+ url: '/api/codex/sessions/session-1/reset',
277
+ })
278
+
279
+ assert.equal(response.statusCode, 200)
280
+ assert.deepEqual(response.json(), {
281
+ session: {
282
+ id: 'session-1',
283
+ title: '项目 A',
284
+ decorated: true,
285
+ },
286
+ affectedTaskSlugs: ['task-a', 'task-b'],
287
+ })
288
+ assert.deepEqual(deletedTaskRuns, ['task-a', 'task-b'])
289
+ assert.deepEqual(broadcasts, [
290
+ {
291
+ type: 'sessions.changed',
292
+ payload: { sessionId: 'session-1' },
293
+ },
294
+ {
295
+ type: 'runs.changed',
296
+ payload: { taskSlug: 'task-a' },
297
+ },
298
+ {
299
+ type: 'runs.changed',
300
+ payload: { taskSlug: 'task-b' },
301
+ },
302
+ ])
303
+ } finally {
304
+ await app.close()
305
+ }
306
+ })
307
+
308
+ test('codex routes expose workspace content search for source browser', async () => {
309
+ const app = Fastify()
310
+ registerCodexRoutes(app, {
311
+ broadcastServerEvent: () => {},
312
+ clearTaskCodexSessionReferences: () => [],
313
+ createPromptxCodexSession: () => ({ id: 'session-1' }),
314
+ decorateCodexSession: (session) => session,
315
+ decorateCodexSessionList: (items) => items,
316
+ deletePromptxCodexSession: () => null,
317
+ getCodexRunById: () => null,
318
+ getPromptxCodexSessionById: (sessionId) => (sessionId === 'session-1' ? { id: 'session-1', cwd: '/tmp/project' } : null),
319
+ getRunningCodexRunBySessionId: () => null,
320
+ isActiveRunStatus: () => false,
321
+ listCodexRunEvents: () => [],
322
+ listDirectoryPickerTree: () => ({}),
323
+ listPromptxCodexSessions: () => [],
324
+ listWorkspaceSuggestions: () => [],
325
+ listWorkspaceTree: () => ({}),
326
+ readWorkspaceFileContent: () => ({}),
327
+ runDispatchService: {
328
+ async requestRunStop() {
329
+ return null
330
+ },
331
+ },
332
+ searchDirectoryPickerEntries: () => ({}),
333
+ searchWorkspaceEntries: () => ({}),
334
+ searchWorkspaceFileContent(cwd, options) {
335
+ return {
336
+ cwd,
337
+ query: options.query,
338
+ items: [
339
+ {
340
+ path: 'src/main.ts',
341
+ line: 3,
342
+ column: 5,
343
+ preview: 'const needle = true',
344
+ },
345
+ ],
346
+ truncated: false,
347
+ }
348
+ },
349
+ updatePromptxCodexSession: () => null,
350
+ })
351
+ await app.ready()
352
+
353
+ try {
354
+ const response = await app.inject({
355
+ method: 'GET',
356
+ url: '/api/codex/sessions/session-1/files/content-search?q=needle',
357
+ })
358
+
359
+ assert.equal(response.statusCode, 200)
360
+ assert.deepEqual(response.json(), {
361
+ cwd: '/tmp/project',
362
+ query: 'needle',
363
+ items: [
364
+ {
365
+ path: 'src/main.ts',
366
+ line: 3,
367
+ column: 5,
368
+ preview: 'const needle = true',
369
+ },
370
+ ],
371
+ truncated: false,
372
+ })
373
+ } finally {
374
+ await app.close()
375
+ }
376
+ })
@@ -0,0 +1,160 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import test from 'node:test'
6
+
7
+ test('listTaskCodexRunsWithOptions 支持 events=none|latest|all,并兼容旧参数', async () => {
8
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-codex-runs-'))
9
+ const originalCwd = process.cwd()
10
+ const originalDataDir = process.env.PROMPTX_DATA_DIR
11
+ const dataDir = path.join(tempDir, 'data')
12
+ fs.mkdirSync(dataDir, { recursive: true })
13
+ process.chdir(tempDir)
14
+ process.env.PROMPTX_DATA_DIR = dataDir
15
+
16
+ try {
17
+ const { run } = await import('./db.js')
18
+ const {
19
+ appendCodexRunEvent,
20
+ listTaskCodexRunsWithOptions,
21
+ } = await import(`./codexRuns.js?test=${Date.now()}`)
22
+
23
+ const now = new Date().toISOString()
24
+ const earlier = new Date(Date.now() - 60_000).toISOString()
25
+
26
+ run(
27
+ `INSERT INTO tasks (slug, edit_token, title, auto_title, last_prompt_preview, codex_session_id, visibility, expires_at, created_at, updated_at)
28
+ VALUES (?, ?, '', '', '', ?, 'private', NULL, ?, ?)`,
29
+ ['task-1', 'token-1', 'session-1', now, now]
30
+ )
31
+ run(
32
+ `INSERT INTO codex_sessions (id, title, cwd, codex_thread_id, created_at, updated_at)
33
+ VALUES (?, ?, ?, '', ?, ?)`,
34
+ ['session-1', 'Repo Session', tempDir, now, now]
35
+ )
36
+ run(
37
+ `INSERT INTO codex_runs (id, task_slug, session_id, prompt, prompt_blocks_json, status, response_message, error_message, created_at, updated_at, started_at, finished_at)
38
+ VALUES (?, ?, ?, ?, ?, 'completed', '', '', ?, ?, ?, ?)`,
39
+ ['run-1', 'task-1', 'session-1', 'hello', JSON.stringify([
40
+ { type: 'text', content: '请看这张图', meta: {} },
41
+ { type: 'image', content: '/uploads/demo.png', meta: {} },
42
+ ]), now, now, now, now]
43
+ )
44
+ run(
45
+ `INSERT INTO codex_runs (id, task_slug, session_id, prompt, prompt_blocks_json, status, response_message, error_message, created_at, updated_at, started_at, finished_at)
46
+ VALUES (?, ?, ?, ?, ?, 'completed', '', '', ?, ?, ?, ?)`,
47
+ ['run-2', 'task-1', 'session-1', 'older', '[]', earlier, earlier, earlier, earlier]
48
+ )
49
+
50
+ appendCodexRunEvent('run-1', 1, { type: 'turn.started' })
51
+ appendCodexRunEvent('run-1', 2, { type: 'turn.completed' })
52
+ appendCodexRunEvent('run-2', 1, { type: 'thread.started', thread_id: 'thread-older' })
53
+
54
+ const summaryRuns = listTaskCodexRunsWithOptions('task-1', { limit: 20, events: 'none' })
55
+ assert.equal(summaryRuns?.length, 2)
56
+ assert.equal(summaryRuns[0].id, 'run-1')
57
+ assert.equal(summaryRuns[0].eventCount, 2)
58
+ assert.equal(summaryRuns[0].lastEventSeq, 2)
59
+ assert.equal(summaryRuns[0].eventsIncluded, false)
60
+ assert.deepEqual(summaryRuns[0].events, [])
61
+ assert.deepEqual(summaryRuns[0].promptBlocks, [
62
+ { type: 'text', content: '请看这张图', meta: {} },
63
+ { type: 'image', content: '/uploads/demo.png', meta: {} },
64
+ ])
65
+ assert.equal(summaryRuns[1].id, 'run-2')
66
+ assert.equal(summaryRuns[1].eventsIncluded, false)
67
+ assert.deepEqual(summaryRuns[1].events, [])
68
+
69
+ const latestRuns = listTaskCodexRunsWithOptions('task-1', { limit: 20, events: 'latest' })
70
+ assert.equal(latestRuns?.length, 2)
71
+ assert.equal(latestRuns[0].eventsIncluded, true)
72
+ assert.deepEqual(latestRuns[0].events.map((item) => item.seq), [1, 2])
73
+ assert.equal(latestRuns[1].eventsIncluded, false)
74
+ assert.deepEqual(latestRuns[1].events, [])
75
+
76
+ const detailedRuns = listTaskCodexRunsWithOptions('task-1', { limit: 20, events: 'all' })
77
+ assert.equal(detailedRuns?.length, 2)
78
+ assert.equal(detailedRuns[0].eventsIncluded, true)
79
+ assert.deepEqual(detailedRuns[0].events.map((item) => item.seq), [1, 2])
80
+ assert.equal(detailedRuns[1].eventsIncluded, true)
81
+ assert.deepEqual(detailedRuns[1].events.map((item) => item.seq), [1])
82
+
83
+ const legacyAllRuns = listTaskCodexRunsWithOptions('task-1', { limit: 20, includeEvents: true })
84
+ assert.equal(legacyAllRuns?.[0]?.eventsIncluded, true)
85
+ assert.equal(legacyAllRuns?.[1]?.eventsIncluded, true)
86
+
87
+ const legacyLatestRuns = listTaskCodexRunsWithOptions('task-1', { limit: 20, includeLatestEvents: true })
88
+ assert.equal(legacyLatestRuns?.[0]?.eventsIncluded, true)
89
+ assert.equal(legacyLatestRuns?.[1]?.eventsIncluded, false)
90
+ } finally {
91
+ process.chdir(originalCwd)
92
+ if (typeof originalDataDir === 'string') {
93
+ process.env.PROMPTX_DATA_DIR = originalDataDir
94
+ } else {
95
+ delete process.env.PROMPTX_DATA_DIR
96
+ }
97
+ }
98
+ })
99
+
100
+ test('listStaleActiveCodexRuns 会把最近事件视为运行活跃信号', async () => {
101
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-codex-stale-'))
102
+ const originalCwd = process.cwd()
103
+ const originalDataDir = process.env.PROMPTX_DATA_DIR
104
+ const dataDir = path.join(tempDir, 'data')
105
+ fs.mkdirSync(dataDir, { recursive: true })
106
+ process.chdir(tempDir)
107
+ process.env.PROMPTX_DATA_DIR = dataDir
108
+
109
+ try {
110
+ const { run } = await import('./db.js')
111
+ const {
112
+ appendCodexRunEvent,
113
+ listStaleActiveCodexRuns,
114
+ } = await import(`./codexRuns.js?test=${Date.now() + 1}`)
115
+
116
+ const suffix = Date.now().toString(36)
117
+ const staleAt = new Date(Date.now() - 60_000).toISOString()
118
+ const now = new Date().toISOString()
119
+ const taskSlug = `task-${suffix}`
120
+ const sessionId = `session-${suffix}`
121
+ const activeRunId = `run-active-${suffix}`
122
+ const staleRunId = `run-stale-${suffix}`
123
+
124
+ run(
125
+ `INSERT INTO tasks (slug, edit_token, title, auto_title, last_prompt_preview, codex_session_id, visibility, expires_at, created_at, updated_at)
126
+ VALUES (?, ?, '', '', '', ?, 'private', NULL, ?, ?)`,
127
+ [taskSlug, `token-${suffix}`, sessionId, now, now]
128
+ )
129
+ run(
130
+ `INSERT INTO codex_sessions (id, title, cwd, codex_thread_id, created_at, updated_at)
131
+ VALUES (?, ?, ?, '', ?, ?)`,
132
+ [sessionId, 'Session 1', tempDir, now, now]
133
+ )
134
+ run(
135
+ `INSERT INTO codex_runs (id, task_slug, session_id, prompt, prompt_blocks_json, status, response_message, error_message, created_at, updated_at, started_at, finished_at)
136
+ VALUES (?, ?, ?, ?, '[]', 'running', '', '', ?, ?, ?, NULL)`,
137
+ [activeRunId, taskSlug, sessionId, 'hello', staleAt, staleAt, staleAt]
138
+ )
139
+ run(
140
+ `INSERT INTO codex_runs (id, task_slug, session_id, prompt, prompt_blocks_json, status, response_message, error_message, created_at, updated_at, started_at, finished_at)
141
+ VALUES (?, ?, ?, ?, '[]', 'running', '', '', ?, ?, ?, NULL)`,
142
+ [staleRunId, taskSlug, sessionId, 'hello again', staleAt, staleAt, staleAt]
143
+ )
144
+
145
+ appendCodexRunEvent(activeRunId, {
146
+ type: 'stdout',
147
+ text: 'heartbeat',
148
+ }, 1)
149
+
150
+ const staleRuns = listStaleActiveCodexRuns(1000)
151
+ assert.deepEqual(staleRuns.map((item) => item.id), [staleRunId])
152
+ } finally {
153
+ process.chdir(originalCwd)
154
+ if (typeof originalDataDir === 'string') {
155
+ process.env.PROMPTX_DATA_DIR = originalDataDir
156
+ } else {
157
+ delete process.env.PROMPTX_DATA_DIR
158
+ }
159
+ }
160
+ })