@muyichengshayu/promptx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,525 @@
1
+ import { nanoid } from 'nanoid'
2
+ import { all, get, run, transaction } from './db.js'
3
+ import { getPromptxCodexSessionById } from './codexSessions.js'
4
+ import { captureRunGitBaseline, captureRunGitFinalSnapshot, captureTaskGitBaseline } from './gitDiff.js'
5
+ import { getTaskBySlug, updateTaskCodexSession } from './repository.js'
6
+
7
+ const TERMINAL_RUN_STATUSES = new Set(['completed', 'error', 'stopped', 'interrupted'])
8
+ const EVENT_FLUSH_DELAY_MS = 180
9
+
10
+ const pendingRunEventsByRunId = new Map()
11
+ let pendingRunEventsFlushTimer = null
12
+
13
+ function parseEventPayload(rawValue = '{}') {
14
+ try {
15
+ return JSON.parse(rawValue || '{}')
16
+ } catch {
17
+ return {}
18
+ }
19
+ }
20
+
21
+ function toCodexRunEvent(row) {
22
+ return {
23
+ id: Number(row.id),
24
+ seq: Number(row.seq),
25
+ eventType: String(row.event_type || '').trim() || 'event',
26
+ payload: parseEventPayload(row.payload_json),
27
+ createdAt: row.created_at,
28
+ }
29
+ }
30
+
31
+ function toCodexRun(row, events = []) {
32
+ if (!row) {
33
+ return null
34
+ }
35
+
36
+ return {
37
+ id: row.id,
38
+ taskSlug: row.task_slug,
39
+ sessionId: row.session_id,
40
+ prompt: row.prompt || '',
41
+ status: row.status || 'running',
42
+ responseMessage: row.response_message || '',
43
+ errorMessage: row.error_message || '',
44
+ createdAt: row.created_at,
45
+ updatedAt: row.updated_at,
46
+ startedAt: row.started_at || row.created_at,
47
+ finishedAt: row.finished_at || '',
48
+ completed: TERMINAL_RUN_STATUSES.has(String(row.status || '')),
49
+ eventCount: Math.max(0, Number(row.event_count) || 0),
50
+ lastEventSeq: Math.max(0, Number(row.last_event_seq) || 0),
51
+ events,
52
+ }
53
+ }
54
+
55
+ function loadEventsForRunIds(runIds = [], afterSeq = 0) {
56
+ if (!runIds.length) {
57
+ return new Map()
58
+ }
59
+
60
+ const placeholders = runIds.map(() => '?').join(', ')
61
+ const rows = all(
62
+ `SELECT id, run_id, seq, event_type, payload_json, created_at
63
+ FROM codex_run_events
64
+ WHERE run_id IN (${placeholders})
65
+ AND seq > ?
66
+ ORDER BY run_id ASC, seq ASC, id ASC`,
67
+ [...runIds, Math.max(0, Number(afterSeq) || 0)]
68
+ )
69
+
70
+ const grouped = new Map()
71
+ rows.forEach((row) => {
72
+ const runId = row.run_id
73
+ if (!grouped.has(runId)) {
74
+ grouped.set(runId, [])
75
+ }
76
+ grouped.get(runId).push(toCodexRunEvent(row))
77
+ })
78
+
79
+ return grouped
80
+ }
81
+
82
+ function getTaskRowBySlug(slug) {
83
+ const targetSlug = String(slug || '').trim()
84
+ if (!targetSlug) {
85
+ return null
86
+ }
87
+
88
+ return get(
89
+ `SELECT id, slug
90
+ FROM tasks
91
+ WHERE slug = ?`,
92
+ [targetSlug]
93
+ )
94
+ }
95
+
96
+ function getRunRowById(runId) {
97
+ const targetId = String(runId || '').trim()
98
+ if (!targetId) {
99
+ return null
100
+ }
101
+
102
+ return get(
103
+ `SELECT id, task_slug, session_id, prompt, status, response_message, error_message, created_at, updated_at, started_at, finished_at
104
+ FROM codex_runs
105
+ WHERE id = ?`,
106
+ [targetId]
107
+ )
108
+ }
109
+
110
+ function clearPendingRunEventsFlushTimer() {
111
+ if (pendingRunEventsFlushTimer) {
112
+ clearTimeout(pendingRunEventsFlushTimer)
113
+ pendingRunEventsFlushTimer = null
114
+ }
115
+ }
116
+
117
+ function flushPendingRunEvents(runId = '') {
118
+ const normalizedRunId = String(runId || '').trim()
119
+ const targetRunIds = normalizedRunId
120
+ ? [normalizedRunId]
121
+ : [...pendingRunEventsByRunId.keys()]
122
+
123
+ const flushableRunIds = targetRunIds.filter((item) => {
124
+ const events = pendingRunEventsByRunId.get(item)
125
+ return Array.isArray(events) && events.length > 0
126
+ })
127
+
128
+ if (!flushableRunIds.length) {
129
+ return 0
130
+ }
131
+
132
+ let insertedCount = 0
133
+ transaction(() => {
134
+ flushableRunIds.forEach((targetRunId) => {
135
+ const events = pendingRunEventsByRunId.get(targetRunId) || []
136
+ if (!events.length) {
137
+ pendingRunEventsByRunId.delete(targetRunId)
138
+ return
139
+ }
140
+
141
+ events.forEach((event) => {
142
+ run(
143
+ `INSERT INTO codex_run_events (run_id, seq, event_type, payload_json, created_at)
144
+ VALUES (?, ?, ?, ?, ?)`,
145
+ [targetRunId, event.seq, event.eventType, event.payloadJson, event.createdAt]
146
+ )
147
+ insertedCount += 1
148
+ })
149
+
150
+ pendingRunEventsByRunId.delete(targetRunId)
151
+ })
152
+ })
153
+
154
+ if (!pendingRunEventsByRunId.size) {
155
+ clearPendingRunEventsFlushTimer()
156
+ }
157
+
158
+ return insertedCount
159
+ }
160
+
161
+ function schedulePendingRunEventsFlush() {
162
+ if (pendingRunEventsFlushTimer) {
163
+ return
164
+ }
165
+
166
+ pendingRunEventsFlushTimer = setTimeout(() => {
167
+ pendingRunEventsFlushTimer = null
168
+ flushPendingRunEvents()
169
+ }, EVENT_FLUSH_DELAY_MS)
170
+ pendingRunEventsFlushTimer.unref?.()
171
+ }
172
+
173
+ export function isTerminalRunStatus(status = '') {
174
+ return TERMINAL_RUN_STATUSES.has(String(status || '').trim())
175
+ }
176
+
177
+ export function getCodexRunById(runId, options = {}) {
178
+ flushPendingRunEvents(runId)
179
+ const row = getRunRowById(runId)
180
+ if (!row) {
181
+ return null
182
+ }
183
+
184
+ if (!options.withEvents) {
185
+ return toCodexRun(row)
186
+ }
187
+
188
+ const events = loadEventsForRunIds([row.id]).get(row.id) || []
189
+ return toCodexRun(row, events)
190
+ }
191
+
192
+ export function listTaskCodexRuns(taskSlug, limit = 20) {
193
+ return listTaskCodexRunsWithOptions(taskSlug, { limit })
194
+ }
195
+
196
+ export function listTaskCodexRunsWithOptions(taskSlug, options = {}) {
197
+ const task = getTaskRowBySlug(taskSlug)
198
+ if (!task) {
199
+ return null
200
+ }
201
+
202
+ flushPendingRunEvents()
203
+ const includeEvents = Boolean(options.includeEvents)
204
+ const limit = Math.max(1, Number(options.limit) || 20)
205
+ const rows = all(
206
+ `SELECT
207
+ runs.id,
208
+ runs.task_slug,
209
+ runs.session_id,
210
+ runs.prompt,
211
+ runs.status,
212
+ runs.response_message,
213
+ runs.error_message,
214
+ runs.created_at,
215
+ runs.updated_at,
216
+ runs.started_at,
217
+ runs.finished_at,
218
+ COUNT(events.id) AS event_count,
219
+ MAX(events.seq) AS last_event_seq
220
+ FROM codex_runs
221
+ AS runs
222
+ LEFT JOIN codex_run_events AS events
223
+ ON events.run_id = runs.id
224
+ WHERE runs.task_slug = ?
225
+ GROUP BY
226
+ runs.id,
227
+ runs.task_slug,
228
+ runs.session_id,
229
+ runs.prompt,
230
+ runs.status,
231
+ runs.response_message,
232
+ runs.error_message,
233
+ runs.created_at,
234
+ runs.updated_at,
235
+ runs.started_at,
236
+ runs.finished_at
237
+ ORDER BY runs.created_at DESC
238
+ LIMIT ?`,
239
+ [task.slug, limit]
240
+ )
241
+
242
+ const eventsByRunId = includeEvents
243
+ ? loadEventsForRunIds(rows.map((row) => row.id))
244
+ : new Map()
245
+ return rows.map((row) => toCodexRun(row, eventsByRunId.get(row.id) || []))
246
+ }
247
+
248
+ export function listCodexRunEvents(runId, options = {}) {
249
+ flushPendingRunEvents(runId)
250
+ const targetRun = getRunRowById(runId)
251
+ if (!targetRun) {
252
+ return null
253
+ }
254
+
255
+ const afterSeq = Math.max(0, Number(options.afterSeq) || 0)
256
+ const limit = Math.max(1, Number(options.limit) || 500)
257
+ const rows = all(
258
+ `SELECT id, run_id, seq, event_type, payload_json, created_at
259
+ FROM codex_run_events
260
+ WHERE run_id = ?
261
+ AND seq > ?
262
+ ORDER BY seq ASC, id ASC
263
+ LIMIT ?`,
264
+ [targetRun.id, afterSeq, limit]
265
+ )
266
+
267
+ return rows.map(toCodexRunEvent)
268
+ }
269
+
270
+ export function createCodexRun(input = {}) {
271
+ const taskSlug = String(input.taskSlug || '').trim()
272
+ const sessionId = String(input.sessionId || '').trim()
273
+ const prompt = String(input.prompt || '').trim()
274
+
275
+ if (!taskSlug) {
276
+ throw new Error('缺少任务。')
277
+ }
278
+ if (!sessionId) {
279
+ throw new Error('请先选择 PromptX 项目。')
280
+ }
281
+ if (!prompt) {
282
+ throw new Error('没有可发送的提示词。')
283
+ }
284
+
285
+ const task = getTaskBySlug(taskSlug)
286
+ if (!task || task.expired) {
287
+ throw new Error('任务不存在。')
288
+ }
289
+
290
+ const session = getPromptxCodexSessionById(sessionId)
291
+ if (!session) {
292
+ throw new Error('没有找到对应的 PromptX 项目。')
293
+ }
294
+
295
+ const now = new Date().toISOString()
296
+ const runId = `pxcr_${nanoid(12)}`
297
+
298
+ transaction(() => {
299
+ run(
300
+ `INSERT INTO codex_runs (
301
+ id, task_slug, session_id, prompt, status,
302
+ response_message, error_message, created_at, updated_at, started_at, finished_at
303
+ )
304
+ VALUES (?, ?, ?, ?, 'running', '', '', ?, ?, ?, NULL)`,
305
+ [runId, task.slug, session.id, prompt, now, now, now]
306
+ )
307
+ })
308
+
309
+ updateTaskCodexSession(task.slug, session.id)
310
+
311
+ try {
312
+ captureTaskGitBaseline(task.slug, session.cwd)
313
+ captureRunGitBaseline(runId, session.cwd)
314
+ } catch {
315
+ // Ignore diff baseline failures so they do not block the Codex run itself.
316
+ }
317
+
318
+ return getCodexRunById(runId, { withEvents: true })
319
+ }
320
+
321
+ function captureTerminalRunSnapshot(runRecord) {
322
+ const sessionId = String(runRecord?.session_id || runRecord?.sessionId || '').trim()
323
+ if (!sessionId) {
324
+ return null
325
+ }
326
+
327
+ const session = getPromptxCodexSessionById(sessionId)
328
+ if (!session?.cwd) {
329
+ return null
330
+ }
331
+
332
+ try {
333
+ return captureRunGitFinalSnapshot(runRecord.id, session.cwd)
334
+ } catch {
335
+ return null
336
+ }
337
+ }
338
+
339
+ export function appendCodexRunEvent(runId, payloadOrSeq = {}, maybeSeqOrPayload = 1) {
340
+ const targetRun = getRunRowById(runId)
341
+ if (!targetRun) {
342
+ return null
343
+ }
344
+
345
+ const seqFirst = typeof payloadOrSeq === 'number'
346
+ const seq = Math.max(1, Number(seqFirst ? payloadOrSeq : maybeSeqOrPayload) || 1)
347
+ const rawPayload = seqFirst ? maybeSeqOrPayload : payloadOrSeq
348
+ const normalizedPayload = rawPayload && typeof rawPayload === 'object'
349
+ ? rawPayload
350
+ : { type: 'info', message: String(rawPayload || '') }
351
+ const now = new Date().toISOString()
352
+ const eventType = String(normalizedPayload.type || '').trim() || 'event'
353
+ const payloadJson = JSON.stringify(normalizedPayload)
354
+
355
+ const pendingEvents = pendingRunEventsByRunId.get(targetRun.id) || []
356
+ pendingEvents.push({
357
+ seq,
358
+ eventType,
359
+ payloadJson,
360
+ createdAt: now,
361
+ })
362
+ pendingRunEventsByRunId.set(targetRun.id, pendingEvents)
363
+ schedulePendingRunEventsFlush()
364
+
365
+ return {
366
+ id: 0,
367
+ seq,
368
+ eventType,
369
+ payload: normalizedPayload,
370
+ createdAt: now,
371
+ }
372
+ }
373
+
374
+ export function updateCodexRun(runId, patch = {}) {
375
+ const existing = getRunRowById(runId)
376
+ if (!existing) {
377
+ return null
378
+ }
379
+
380
+ const status = String(patch.status || existing.status || 'running').trim() || 'running'
381
+ const responseMessage = Object.prototype.hasOwnProperty.call(patch, 'responseMessage')
382
+ ? String(patch.responseMessage || '')
383
+ : String(existing.response_message || '')
384
+ const errorMessage = Object.prototype.hasOwnProperty.call(patch, 'errorMessage')
385
+ ? String(patch.errorMessage || '')
386
+ : String(existing.error_message || '')
387
+ const finishedAt = Object.prototype.hasOwnProperty.call(patch, 'finishedAt')
388
+ ? String(patch.finishedAt || '')
389
+ : String(existing.finished_at || '')
390
+ const updatedAt = patch.updatedAt || new Date().toISOString()
391
+
392
+ if (isTerminalRunStatus(status) || finishedAt) {
393
+ flushPendingRunEvents(existing.id)
394
+ captureTerminalRunSnapshot(existing)
395
+ }
396
+
397
+ transaction(() => {
398
+ run(
399
+ `UPDATE codex_runs
400
+ SET status = ?, response_message = ?, error_message = ?, finished_at = ?, updated_at = ?
401
+ WHERE id = ?`,
402
+ [status, responseMessage, errorMessage, finishedAt || null, updatedAt, existing.id]
403
+ )
404
+ })
405
+
406
+ return getCodexRunById(existing.id, { withEvents: true })
407
+ }
408
+
409
+ export function listRunningCodexSessionIds() {
410
+ return all(
411
+ `SELECT DISTINCT session_id
412
+ FROM codex_runs
413
+ WHERE status = 'running'`
414
+ ).map((row) => String(row.session_id || '').trim()).filter(Boolean)
415
+ }
416
+
417
+ export function listRunningCodexTaskSlugs() {
418
+ return all(
419
+ `SELECT DISTINCT task_slug
420
+ FROM codex_runs
421
+ WHERE status = 'running'`
422
+ ).map((row) => String(row.task_slug || '').trim()).filter(Boolean)
423
+ }
424
+
425
+ export function getRunningCodexRunBySessionId(sessionId) {
426
+ const targetId = String(sessionId || '').trim()
427
+ if (!targetId) {
428
+ return null
429
+ }
430
+
431
+ const row = get(
432
+ `SELECT id, task_slug, session_id, prompt, status, response_message, error_message, created_at, updated_at, started_at, finished_at
433
+ FROM codex_runs
434
+ WHERE session_id = ?
435
+ AND status = 'running'
436
+ ORDER BY created_at DESC
437
+ LIMIT 1`,
438
+ [targetId]
439
+ )
440
+
441
+ return toCodexRun(row)
442
+ }
443
+
444
+ export function getRunningCodexRunByTaskSlug(taskSlug) {
445
+ const targetSlug = String(taskSlug || '').trim()
446
+ if (!targetSlug) {
447
+ return null
448
+ }
449
+
450
+ const row = get(
451
+ `SELECT id, task_slug, session_id, prompt, status, response_message, error_message, created_at, updated_at, started_at, finished_at
452
+ FROM codex_runs
453
+ WHERE task_slug = ?
454
+ AND status = 'running'
455
+ ORDER BY created_at DESC
456
+ LIMIT 1`,
457
+ [targetSlug]
458
+ )
459
+
460
+ return toCodexRun(row)
461
+ }
462
+
463
+ export function hasRunningCodexRunsForTask(taskSlug) {
464
+ const task = getTaskRowBySlug(taskSlug)
465
+ if (!task) {
466
+ return false
467
+ }
468
+
469
+ return Boolean(
470
+ get(
471
+ `SELECT 1
472
+ FROM codex_runs
473
+ WHERE task_slug = ?
474
+ AND status = 'running'
475
+ LIMIT 1`,
476
+ [task.slug]
477
+ )
478
+ )
479
+ }
480
+
481
+ export function deleteTaskCodexRuns(taskSlug) {
482
+ const task = getTaskRowBySlug(taskSlug)
483
+ if (!task) {
484
+ return { error: 'not_found' }
485
+ }
486
+
487
+ transaction(() => {
488
+ run('DELETE FROM codex_runs WHERE task_slug = ?', [task.slug])
489
+ })
490
+
491
+ return { ok: true }
492
+ }
493
+
494
+ export function markRunningCodexRunsInterrupted(message = '服务已重启,之前的执行已中断。') {
495
+ const runningRuns = all(
496
+ `SELECT id
497
+ FROM codex_runs
498
+ WHERE status = 'running'
499
+ ORDER BY created_at ASC`
500
+ )
501
+
502
+ runningRuns.forEach((row) => {
503
+ const existingEvents = listCodexRunEvents(row.id) || []
504
+ const nextSeq = existingEvents.length
505
+ ? Math.max(...existingEvents.map((item) => Number(item.seq) || 0)) + 1
506
+ : 1
507
+
508
+ appendCodexRunEvent(row.id, {
509
+ type: 'interrupted',
510
+ message,
511
+ }, nextSeq)
512
+
513
+ updateCodexRun(row.id, {
514
+ status: 'interrupted',
515
+ errorMessage: message,
516
+ finishedAt: new Date().toISOString(),
517
+ })
518
+ })
519
+
520
+ return runningRuns.length
521
+ }
522
+
523
+ export function markInterruptedCodexRuns(message) {
524
+ return markRunningCodexRunsInterrupted(message)
525
+ }
@@ -0,0 +1,149 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { nanoid } from 'nanoid'
4
+ import { all, get, run, transaction } from './db.js'
5
+
6
+ function createHttpError(message, statusCode = 400) {
7
+ const error = new Error(message)
8
+ error.statusCode = statusCode
9
+ return error
10
+ }
11
+
12
+ function toCodexSession(row) {
13
+ if (!row) {
14
+ return null
15
+ }
16
+
17
+ return {
18
+ id: row.id,
19
+ title: row.title,
20
+ cwd: row.cwd,
21
+ codexThreadId: row.codex_thread_id || '',
22
+ running: false,
23
+ createdAt: row.created_at,
24
+ updatedAt: row.updated_at,
25
+ started: Boolean(row.codex_thread_id),
26
+ }
27
+ }
28
+
29
+ function normalizeTitle(input = '', cwd = '') {
30
+ const title = String(input || '').trim().slice(0, 140)
31
+ if (title) {
32
+ return title
33
+ }
34
+
35
+ const baseName = path.basename(String(cwd || '').trim())
36
+ return baseName || 'PromptX 项目'
37
+ }
38
+
39
+ export function normalizeCwd(input = '') {
40
+ const cwd = String(input || '').trim()
41
+ if (!cwd) {
42
+ throw createHttpError('请先填写工作目录。')
43
+ }
44
+
45
+ const resolved = path.resolve(cwd)
46
+ if (!fs.existsSync(resolved)) {
47
+ throw createHttpError('工作目录不存在,请重新确认。')
48
+ }
49
+
50
+ const stats = fs.statSync(resolved)
51
+ if (!stats.isDirectory()) {
52
+ throw createHttpError('工作目录必须是文件夹。')
53
+ }
54
+
55
+ return resolved
56
+ }
57
+
58
+ export function listPromptxCodexSessions(limit = 30) {
59
+ const rows = all(
60
+ `SELECT id, title, cwd, codex_thread_id, created_at, updated_at
61
+ FROM codex_sessions
62
+ ORDER BY updated_at DESC
63
+ LIMIT ?`,
64
+ [Math.max(1, Number(limit) || 30)]
65
+ )
66
+
67
+ return rows.map(toCodexSession)
68
+ }
69
+
70
+ export function getPromptxCodexSessionById(sessionId) {
71
+ const targetId = String(sessionId || '').trim()
72
+ if (!targetId) {
73
+ return null
74
+ }
75
+
76
+ return toCodexSession(
77
+ get(
78
+ `SELECT id, title, cwd, codex_thread_id, created_at, updated_at
79
+ FROM codex_sessions
80
+ WHERE id = ?`,
81
+ [targetId]
82
+ )
83
+ )
84
+ }
85
+
86
+ export function createPromptxCodexSession(input = {}) {
87
+ const cwd = normalizeCwd(input.cwd)
88
+ const title = normalizeTitle(input.title, cwd)
89
+ const now = new Date().toISOString()
90
+ const id = `pxcs_${nanoid(12)}`
91
+
92
+ transaction(() => {
93
+ run(
94
+ `INSERT INTO codex_sessions (id, title, cwd, codex_thread_id, created_at, updated_at)
95
+ VALUES (?, ?, ?, '', ?, ?)`,
96
+ [id, title, cwd, now, now]
97
+ )
98
+ })
99
+
100
+ return getPromptxCodexSessionById(id)
101
+ }
102
+
103
+ export function updatePromptxCodexSession(sessionId, patch = {}) {
104
+ const existing = getPromptxCodexSessionById(sessionId)
105
+ if (!existing) {
106
+ return null
107
+ }
108
+
109
+ const wantsCwd = Object.prototype.hasOwnProperty.call(patch, 'cwd')
110
+ const nextCwd = wantsCwd
111
+ ? normalizeCwd(patch.cwd)
112
+ : existing.cwd
113
+
114
+ if (existing.started && wantsCwd && nextCwd !== existing.cwd) {
115
+ throw createHttpError('已启动的 PromptX 项目不能直接修改工作目录。', 409)
116
+ }
117
+
118
+ const title = Object.prototype.hasOwnProperty.call(patch, 'title')
119
+ ? normalizeTitle(patch.title, nextCwd)
120
+ : existing.title
121
+ const codexThreadId = Object.prototype.hasOwnProperty.call(patch, 'codexThreadId')
122
+ ? String(patch.codexThreadId || '').trim()
123
+ : existing.codexThreadId
124
+ const updatedAt = patch.updatedAt || new Date().toISOString()
125
+
126
+ transaction(() => {
127
+ run(
128
+ `UPDATE codex_sessions
129
+ SET title = ?, cwd = ?, codex_thread_id = ?, updated_at = ?
130
+ WHERE id = ?`,
131
+ [title, nextCwd, codexThreadId, updatedAt, existing.id]
132
+ )
133
+ })
134
+
135
+ return getPromptxCodexSessionById(existing.id)
136
+ }
137
+
138
+ export function deletePromptxCodexSession(sessionId) {
139
+ const existing = getPromptxCodexSessionById(sessionId)
140
+ if (!existing) {
141
+ return null
142
+ }
143
+
144
+ transaction(() => {
145
+ run('DELETE FROM codex_sessions WHERE id = ?', [existing.id])
146
+ })
147
+
148
+ return existing
149
+ }