steroids-api 0.2.7
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.
- package/dist/API/src/index.d.ts +10 -0
- package/dist/API/src/index.d.ts.map +1 -0
- package/dist/API/src/index.js +130 -0
- package/dist/API/src/index.js.map +1 -0
- package/dist/API/src/routes/activity.d.ts +7 -0
- package/dist/API/src/routes/activity.d.ts.map +1 -0
- package/dist/API/src/routes/activity.js +252 -0
- package/dist/API/src/routes/activity.js.map +1 -0
- package/dist/API/src/routes/config.d.ts +7 -0
- package/dist/API/src/routes/config.d.ts.map +1 -0
- package/dist/API/src/routes/config.js +521 -0
- package/dist/API/src/routes/config.js.map +1 -0
- package/dist/API/src/routes/health.d.ts +7 -0
- package/dist/API/src/routes/health.d.ts.map +1 -0
- package/dist/API/src/routes/health.js +172 -0
- package/dist/API/src/routes/health.js.map +1 -0
- package/dist/API/src/routes/incidents.d.ts +7 -0
- package/dist/API/src/routes/incidents.d.ts.map +1 -0
- package/dist/API/src/routes/incidents.js +117 -0
- package/dist/API/src/routes/incidents.js.map +1 -0
- package/dist/API/src/routes/projects.d.ts +7 -0
- package/dist/API/src/routes/projects.d.ts.map +1 -0
- package/dist/API/src/routes/projects.js +398 -0
- package/dist/API/src/routes/projects.js.map +1 -0
- package/dist/API/src/routes/runners.d.ts +7 -0
- package/dist/API/src/routes/runners.d.ts.map +1 -0
- package/dist/API/src/routes/runners.js +242 -0
- package/dist/API/src/routes/runners.js.map +1 -0
- package/dist/API/src/routes/tasks.d.ts +7 -0
- package/dist/API/src/routes/tasks.d.ts.map +1 -0
- package/dist/API/src/routes/tasks.js +1007 -0
- package/dist/API/src/routes/tasks.js.map +1 -0
- package/dist/API/src/utils/validation.d.ts +22 -0
- package/dist/API/src/utils/validation.d.ts.map +1 -0
- package/dist/API/src/utils/validation.js +50 -0
- package/dist/API/src/utils/validation.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +184 -0
- package/dist/index.js.map +1 -0
- package/dist/routes/activity.d.ts +7 -0
- package/dist/routes/activity.d.ts.map +1 -0
- package/dist/routes/activity.js +252 -0
- package/dist/routes/activity.js.map +1 -0
- package/dist/routes/config.d.ts +7 -0
- package/dist/routes/config.d.ts.map +1 -0
- package/dist/routes/config.js +647 -0
- package/dist/routes/config.js.map +1 -0
- package/dist/routes/credit-alerts.d.ts +2 -0
- package/dist/routes/credit-alerts.d.ts.map +1 -0
- package/dist/routes/credit-alerts.js +97 -0
- package/dist/routes/credit-alerts.js.map +1 -0
- package/dist/routes/health.d.ts +7 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +200 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/incidents.d.ts +7 -0
- package/dist/routes/incidents.d.ts.map +1 -0
- package/dist/routes/incidents.js +117 -0
- package/dist/routes/incidents.js.map +1 -0
- package/dist/routes/projects.d.ts +7 -0
- package/dist/routes/projects.d.ts.map +1 -0
- package/dist/routes/projects.js +643 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/runners.d.ts +7 -0
- package/dist/routes/runners.d.ts.map +1 -0
- package/dist/routes/runners.js +299 -0
- package/dist/routes/runners.js.map +1 -0
- package/dist/routes/skills.d.ts +3 -0
- package/dist/routes/skills.d.ts.map +1 -0
- package/dist/routes/skills.js +109 -0
- package/dist/routes/skills.js.map +1 -0
- package/dist/routes/storage.d.ts +7 -0
- package/dist/routes/storage.d.ts.map +1 -0
- package/dist/routes/storage.js +93 -0
- package/dist/routes/storage.js.map +1 -0
- package/dist/routes/tasks.d.ts +7 -0
- package/dist/routes/tasks.d.ts.map +1 -0
- package/dist/routes/tasks.js +1145 -0
- package/dist/routes/tasks.js.map +1 -0
- package/dist/src/cleanup/invocation-logs.d.ts +30 -0
- package/dist/src/cleanup/invocation-logs.d.ts.map +1 -0
- package/dist/src/cleanup/invocation-logs.js +66 -0
- package/dist/src/cleanup/invocation-logs.js.map +1 -0
- package/dist/src/commands/loop-phases.d.ts +11 -0
- package/dist/src/commands/loop-phases.d.ts.map +1 -0
- package/dist/src/commands/loop-phases.js +304 -0
- package/dist/src/commands/loop-phases.js.map +1 -0
- package/dist/src/config/loader.d.ts +160 -0
- package/dist/src/config/loader.d.ts.map +1 -0
- package/dist/src/config/loader.js +276 -0
- package/dist/src/config/loader.js.map +1 -0
- package/dist/src/database/connection.d.ts +35 -0
- package/dist/src/database/connection.d.ts.map +1 -0
- package/dist/src/database/connection.js +197 -0
- package/dist/src/database/connection.js.map +1 -0
- package/dist/src/database/queries.d.ts +220 -0
- package/dist/src/database/queries.d.ts.map +1 -0
- package/dist/src/database/queries.js +589 -0
- package/dist/src/database/queries.js.map +1 -0
- package/dist/src/database/schema.d.ts +8 -0
- package/dist/src/database/schema.d.ts.map +1 -0
- package/dist/src/database/schema.js +184 -0
- package/dist/src/database/schema.js.map +1 -0
- package/dist/src/git/push.d.ts +26 -0
- package/dist/src/git/push.d.ts.map +1 -0
- package/dist/src/git/push.js +91 -0
- package/dist/src/git/push.js.map +1 -0
- package/dist/src/git/status.d.ts +83 -0
- package/dist/src/git/status.d.ts.map +1 -0
- package/dist/src/git/status.js +315 -0
- package/dist/src/git/status.js.map +1 -0
- package/dist/src/health/stuck-task-detector.d.ts +131 -0
- package/dist/src/health/stuck-task-detector.d.ts.map +1 -0
- package/dist/src/health/stuck-task-detector.js +233 -0
- package/dist/src/health/stuck-task-detector.js.map +1 -0
- package/dist/src/health/stuck-task-recovery.d.ts +45 -0
- package/dist/src/health/stuck-task-recovery.d.ts.map +1 -0
- package/dist/src/health/stuck-task-recovery.js +309 -0
- package/dist/src/health/stuck-task-recovery.js.map +1 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +130 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/locking/queries.d.ts +116 -0
- package/dist/src/locking/queries.d.ts.map +1 -0
- package/dist/src/locking/queries.js +232 -0
- package/dist/src/locking/queries.js.map +1 -0
- package/dist/src/locking/section-lock.d.ts +74 -0
- package/dist/src/locking/section-lock.d.ts.map +1 -0
- package/dist/src/locking/section-lock.js +196 -0
- package/dist/src/locking/section-lock.js.map +1 -0
- package/dist/src/locking/task-lock.d.ts +92 -0
- package/dist/src/locking/task-lock.d.ts.map +1 -0
- package/dist/src/locking/task-lock.js +233 -0
- package/dist/src/locking/task-lock.js.map +1 -0
- package/dist/src/migrations/index.d.ts +7 -0
- package/dist/src/migrations/index.d.ts.map +1 -0
- package/dist/src/migrations/index.js +9 -0
- package/dist/src/migrations/index.js.map +1 -0
- package/dist/src/migrations/manifest.d.ts +92 -0
- package/dist/src/migrations/manifest.d.ts.map +1 -0
- package/dist/src/migrations/manifest.js +255 -0
- package/dist/src/migrations/manifest.js.map +1 -0
- package/dist/src/migrations/runner.d.ts +84 -0
- package/dist/src/migrations/runner.d.ts.map +1 -0
- package/dist/src/migrations/runner.js +338 -0
- package/dist/src/migrations/runner.js.map +1 -0
- package/dist/src/orchestrator/coder.d.ts +32 -0
- package/dist/src/orchestrator/coder.d.ts.map +1 -0
- package/dist/src/orchestrator/coder.js +170 -0
- package/dist/src/orchestrator/coder.js.map +1 -0
- package/dist/src/orchestrator/coordinator.d.ts +28 -0
- package/dist/src/orchestrator/coordinator.d.ts.map +1 -0
- package/dist/src/orchestrator/coordinator.js +252 -0
- package/dist/src/orchestrator/coordinator.js.map +1 -0
- package/dist/src/orchestrator/fallback-handler.d.ts +24 -0
- package/dist/src/orchestrator/fallback-handler.d.ts.map +1 -0
- package/dist/src/orchestrator/fallback-handler.js +280 -0
- package/dist/src/orchestrator/fallback-handler.js.map +1 -0
- package/dist/src/orchestrator/invoke.d.ts +14 -0
- package/dist/src/orchestrator/invoke.d.ts.map +1 -0
- package/dist/src/orchestrator/invoke.js +76 -0
- package/dist/src/orchestrator/invoke.js.map +1 -0
- package/dist/src/orchestrator/post-coder.d.ts +10 -0
- package/dist/src/orchestrator/post-coder.d.ts.map +1 -0
- package/dist/src/orchestrator/post-coder.js +198 -0
- package/dist/src/orchestrator/post-coder.js.map +1 -0
- package/dist/src/orchestrator/post-reviewer.d.ts +10 -0
- package/dist/src/orchestrator/post-reviewer.d.ts.map +1 -0
- package/dist/src/orchestrator/post-reviewer.js +199 -0
- package/dist/src/orchestrator/post-reviewer.js.map +1 -0
- package/dist/src/orchestrator/reviewer.d.ts +35 -0
- package/dist/src/orchestrator/reviewer.d.ts.map +1 -0
- package/dist/src/orchestrator/reviewer.js +237 -0
- package/dist/src/orchestrator/reviewer.js.map +1 -0
- package/dist/src/orchestrator/schemas.d.ts +10 -0
- package/dist/src/orchestrator/schemas.d.ts.map +1 -0
- package/dist/src/orchestrator/schemas.js +81 -0
- package/dist/src/orchestrator/schemas.js.map +1 -0
- package/dist/src/orchestrator/task-selector.d.ts +102 -0
- package/dist/src/orchestrator/task-selector.d.ts.map +1 -0
- package/dist/src/orchestrator/task-selector.js +326 -0
- package/dist/src/orchestrator/task-selector.js.map +1 -0
- package/dist/src/orchestrator/types.d.ts +74 -0
- package/dist/src/orchestrator/types.d.ts.map +1 -0
- package/dist/src/orchestrator/types.js +5 -0
- package/dist/src/orchestrator/types.js.map +1 -0
- package/dist/src/prompts/coder.d.ts +36 -0
- package/dist/src/prompts/coder.d.ts.map +1 -0
- package/dist/src/prompts/coder.js +303 -0
- package/dist/src/prompts/coder.js.map +1 -0
- package/dist/src/prompts/prompt-helpers.d.ts +51 -0
- package/dist/src/prompts/prompt-helpers.d.ts.map +1 -0
- package/dist/src/prompts/prompt-helpers.js +299 -0
- package/dist/src/prompts/prompt-helpers.js.map +1 -0
- package/dist/src/prompts/reviewer.d.ts +40 -0
- package/dist/src/prompts/reviewer.d.ts.map +1 -0
- package/dist/src/prompts/reviewer.js +416 -0
- package/dist/src/prompts/reviewer.js.map +1 -0
- package/dist/src/providers/claude.d.ts +53 -0
- package/dist/src/providers/claude.d.ts.map +1 -0
- package/dist/src/providers/claude.js +227 -0
- package/dist/src/providers/claude.js.map +1 -0
- package/dist/src/providers/codex.d.ts +53 -0
- package/dist/src/providers/codex.d.ts.map +1 -0
- package/dist/src/providers/codex.js +253 -0
- package/dist/src/providers/codex.js.map +1 -0
- package/dist/src/providers/gemini.d.ts +58 -0
- package/dist/src/providers/gemini.d.ts.map +1 -0
- package/dist/src/providers/gemini.js +240 -0
- package/dist/src/providers/gemini.js.map +1 -0
- package/dist/src/providers/interface.d.ts +185 -0
- package/dist/src/providers/interface.d.ts.map +1 -0
- package/dist/src/providers/interface.js +92 -0
- package/dist/src/providers/interface.js.map +1 -0
- package/dist/src/providers/invocation-logger.d.ts +97 -0
- package/dist/src/providers/invocation-logger.d.ts.map +1 -0
- package/dist/src/providers/invocation-logger.js +378 -0
- package/dist/src/providers/invocation-logger.js.map +1 -0
- package/dist/src/providers/openai.d.ts +53 -0
- package/dist/src/providers/openai.d.ts.map +1 -0
- package/dist/src/providers/openai.js +230 -0
- package/dist/src/providers/openai.js.map +1 -0
- package/dist/src/providers/registry.d.ts +100 -0
- package/dist/src/providers/registry.d.ts.map +1 -0
- package/dist/src/providers/registry.js +170 -0
- package/dist/src/providers/registry.js.map +1 -0
- package/dist/src/routes/activity.d.ts +7 -0
- package/dist/src/routes/activity.d.ts.map +1 -0
- package/dist/src/routes/activity.js +252 -0
- package/dist/src/routes/activity.js.map +1 -0
- package/dist/src/routes/config.d.ts +7 -0
- package/dist/src/routes/config.d.ts.map +1 -0
- package/dist/src/routes/config.js +521 -0
- package/dist/src/routes/config.js.map +1 -0
- package/dist/src/routes/health.d.ts +7 -0
- package/dist/src/routes/health.d.ts.map +1 -0
- package/dist/src/routes/health.js +172 -0
- package/dist/src/routes/health.js.map +1 -0
- package/dist/src/routes/incidents.d.ts +7 -0
- package/dist/src/routes/incidents.d.ts.map +1 -0
- package/dist/src/routes/incidents.js +117 -0
- package/dist/src/routes/incidents.js.map +1 -0
- package/dist/src/routes/projects.d.ts +7 -0
- package/dist/src/routes/projects.d.ts.map +1 -0
- package/dist/src/routes/projects.js +398 -0
- package/dist/src/routes/projects.js.map +1 -0
- package/dist/src/routes/runners.d.ts +7 -0
- package/dist/src/routes/runners.d.ts.map +1 -0
- package/dist/src/routes/runners.js +242 -0
- package/dist/src/routes/runners.js.map +1 -0
- package/dist/src/routes/tasks.d.ts +7 -0
- package/dist/src/routes/tasks.d.ts.map +1 -0
- package/dist/src/routes/tasks.js +1007 -0
- package/dist/src/routes/tasks.js.map +1 -0
- package/dist/src/runners/activity-log.d.ts +65 -0
- package/dist/src/runners/activity-log.d.ts.map +1 -0
- package/dist/src/runners/activity-log.js +140 -0
- package/dist/src/runners/activity-log.js.map +1 -0
- package/dist/src/runners/cron.d.ts +30 -0
- package/dist/src/runners/cron.d.ts.map +1 -0
- package/dist/src/runners/cron.js +333 -0
- package/dist/src/runners/cron.js.map +1 -0
- package/dist/src/runners/daemon.d.ts +71 -0
- package/dist/src/runners/daemon.d.ts.map +1 -0
- package/dist/src/runners/daemon.js +233 -0
- package/dist/src/runners/daemon.js.map +1 -0
- package/dist/src/runners/global-db.d.ts +31 -0
- package/dist/src/runners/global-db.d.ts.map +1 -0
- package/dist/src/runners/global-db.js +220 -0
- package/dist/src/runners/global-db.js.map +1 -0
- package/dist/src/runners/hang-detector.d.ts +38 -0
- package/dist/src/runners/hang-detector.d.ts.map +1 -0
- package/dist/src/runners/hang-detector.js +130 -0
- package/dist/src/runners/hang-detector.js.map +1 -0
- package/dist/src/runners/heartbeat.d.ts +39 -0
- package/dist/src/runners/heartbeat.d.ts.map +1 -0
- package/dist/src/runners/heartbeat.js +71 -0
- package/dist/src/runners/heartbeat.js.map +1 -0
- package/dist/src/runners/lock.d.ts +47 -0
- package/dist/src/runners/lock.d.ts.map +1 -0
- package/dist/src/runners/lock.js +140 -0
- package/dist/src/runners/lock.js.map +1 -0
- package/dist/src/runners/orchestrator-loop.d.ts +20 -0
- package/dist/src/runners/orchestrator-loop.d.ts.map +1 -0
- package/dist/src/runners/orchestrator-loop.js +208 -0
- package/dist/src/runners/orchestrator-loop.js.map +1 -0
- package/dist/src/runners/projects.d.ts +96 -0
- package/dist/src/runners/projects.d.ts.map +1 -0
- package/dist/src/runners/projects.js +243 -0
- package/dist/src/runners/projects.js.map +1 -0
- package/dist/src/runners/wakeup.d.ts +37 -0
- package/dist/src/runners/wakeup.d.ts.map +1 -0
- package/dist/src/runners/wakeup.js +355 -0
- package/dist/src/runners/wakeup.js.map +1 -0
- package/dist/src/utils/validation.d.ts +22 -0
- package/dist/src/utils/validation.d.ts.map +1 -0
- package/dist/src/utils/validation.js +50 -0
- package/dist/src/utils/validation.js.map +1 -0
- package/dist/utils/sqlite.d.ts +17 -0
- package/dist/utils/sqlite.d.ts.map +1 -0
- package/dist/utils/sqlite.js +27 -0
- package/dist/utils/sqlite.js.map +1 -0
- package/dist/utils/storage-cache.d.ts +33 -0
- package/dist/utils/storage-cache.d.ts.map +1 -0
- package/dist/utils/storage-cache.js +81 -0
- package/dist/utils/storage-cache.js.map +1 -0
- package/dist/utils/validation.d.ts +22 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/utils/validation.js +51 -0
- package/dist/utils/validation.js.map +1 -0
- package/package.json +39 -0
- package/src/index.ts +199 -0
- package/src/routes/activity.ts +302 -0
- package/src/routes/config.ts +723 -0
- package/src/routes/credit-alerts.ts +73 -0
- package/src/routes/health.ts +219 -0
- package/src/routes/incidents.ts +131 -0
- package/src/routes/projects.ts +854 -0
- package/src/routes/runners.ts +357 -0
- package/src/routes/skills.ts +127 -0
- package/src/routes/storage.ts +108 -0
- package/src/routes/tasks.ts +1372 -0
- package/src/utils/sqlite.ts +36 -0
- package/src/utils/storage-cache.ts +107 -0
- package/src/utils/validation.ts +61 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,1372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tasks API routes
|
|
3
|
+
* Exposes task details and logs for individual tasks
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Router, Request, Response } from 'express';
|
|
7
|
+
import Database from 'better-sqlite3';
|
|
8
|
+
import { createReadStream, existsSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { execSync } from 'node:child_process';
|
|
11
|
+
import { Tail } from 'tail';
|
|
12
|
+
import { openSqliteForRead } from '../utils/sqlite.js';
|
|
13
|
+
|
|
14
|
+
const router = Router();
|
|
15
|
+
|
|
16
|
+
const MAX_SSE_CONNECTIONS = Math.max(1, parseInt(process.env.MAX_SSE_CONNECTIONS || '100', 10) || 100);
|
|
17
|
+
let activeSseConnections = 0;
|
|
18
|
+
|
|
19
|
+
interface TaskDetails {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
status: string;
|
|
23
|
+
section_id: string | null;
|
|
24
|
+
section_name: string | null;
|
|
25
|
+
source_file: string | null;
|
|
26
|
+
rejection_count: number;
|
|
27
|
+
failure_count: number;
|
|
28
|
+
created_at: string;
|
|
29
|
+
updated_at: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface AuditEntry {
|
|
33
|
+
id: number;
|
|
34
|
+
task_id: string;
|
|
35
|
+
from_status: string | null;
|
|
36
|
+
to_status: string;
|
|
37
|
+
actor: string;
|
|
38
|
+
actor_type: string | null;
|
|
39
|
+
model: string | null;
|
|
40
|
+
notes: string | null;
|
|
41
|
+
error_code: string | null;
|
|
42
|
+
commit_sha: string | null;
|
|
43
|
+
created_at: string;
|
|
44
|
+
duration_seconds?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface InvocationEntry {
|
|
48
|
+
id: number;
|
|
49
|
+
task_id: string;
|
|
50
|
+
role: string;
|
|
51
|
+
provider: string;
|
|
52
|
+
model: string;
|
|
53
|
+
exit_code: number;
|
|
54
|
+
duration_ms: number;
|
|
55
|
+
success: number;
|
|
56
|
+
timed_out: number;
|
|
57
|
+
rejection_number: number | null;
|
|
58
|
+
created_at: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface InvocationDetails extends InvocationEntry {
|
|
62
|
+
prompt: string;
|
|
63
|
+
response: string | null;
|
|
64
|
+
error: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface DisputeEntry {
|
|
68
|
+
id: string;
|
|
69
|
+
task_id: string;
|
|
70
|
+
type: string;
|
|
71
|
+
status: string;
|
|
72
|
+
reason: string;
|
|
73
|
+
coder_position: string | null;
|
|
74
|
+
reviewer_position: string | null;
|
|
75
|
+
resolution: string | null;
|
|
76
|
+
resolution_notes: string | null;
|
|
77
|
+
created_by: string;
|
|
78
|
+
resolved_by: string | null;
|
|
79
|
+
created_at: string;
|
|
80
|
+
resolved_at: string | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface TaskResponse extends TaskDetails {
|
|
84
|
+
duration: {
|
|
85
|
+
total_seconds: number;
|
|
86
|
+
in_progress_seconds: number;
|
|
87
|
+
review_seconds: number;
|
|
88
|
+
};
|
|
89
|
+
audit_trail: AuditEntry[];
|
|
90
|
+
invocations: InvocationEntry[];
|
|
91
|
+
disputes: DisputeEntry[];
|
|
92
|
+
github_url: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get GitHub URL from git remote
|
|
97
|
+
* @param projectPath - Path to project root
|
|
98
|
+
* @returns GitHub base URL (e.g., https://github.com/owner/repo) or null
|
|
99
|
+
*/
|
|
100
|
+
function getGitHubUrl(projectPath: string): string | null {
|
|
101
|
+
try {
|
|
102
|
+
const remoteUrl = execSync('git remote get-url origin', {
|
|
103
|
+
cwd: projectPath,
|
|
104
|
+
encoding: 'utf-8',
|
|
105
|
+
}).trim();
|
|
106
|
+
|
|
107
|
+
// Convert SSH or HTTPS URL to web URL
|
|
108
|
+
// SSH: git@github.com:owner/repo.git
|
|
109
|
+
// HTTPS: https://github.com/owner/repo.git
|
|
110
|
+
let webUrl: string | null = null;
|
|
111
|
+
|
|
112
|
+
if (remoteUrl.startsWith('git@github.com:')) {
|
|
113
|
+
// SSH format
|
|
114
|
+
const path = remoteUrl.replace('git@github.com:', '').replace(/\.git$/, '');
|
|
115
|
+
webUrl = `https://github.com/${path}`;
|
|
116
|
+
} else if (remoteUrl.includes('github.com')) {
|
|
117
|
+
// HTTPS format
|
|
118
|
+
webUrl = remoteUrl.replace(/\.git$/, '');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return webUrl;
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Open project database
|
|
129
|
+
* @param projectPath - Path to project root
|
|
130
|
+
* @returns Database connection or null if not found
|
|
131
|
+
*/
|
|
132
|
+
function openProjectDatabase(projectPath: string): Database.Database | null {
|
|
133
|
+
const dbPath = join(projectPath, '.steroids', 'steroids.db');
|
|
134
|
+
if (!existsSync(dbPath)) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return openSqliteForRead(dbPath);
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Calculate duration for each status from audit trail
|
|
146
|
+
* @param auditTrail - Array of audit entries sorted by created_at
|
|
147
|
+
* @returns Audit entries with duration_seconds added
|
|
148
|
+
*/
|
|
149
|
+
function calculateDurations(auditTrail: AuditEntry[]): AuditEntry[] {
|
|
150
|
+
// Sort by created_at ascending for duration calculation
|
|
151
|
+
const sorted = [...auditTrail].sort(
|
|
152
|
+
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return sorted.map((entry, index) => {
|
|
156
|
+
// Duration is time until next status change
|
|
157
|
+
if (index < sorted.length - 1) {
|
|
158
|
+
const startTime = new Date(entry.created_at).getTime();
|
|
159
|
+
const endTime = new Date(sorted[index + 1].created_at).getTime();
|
|
160
|
+
const durationSeconds = Math.round((endTime - startTime) / 1000);
|
|
161
|
+
return { ...entry, duration_seconds: durationSeconds };
|
|
162
|
+
}
|
|
163
|
+
// Current/last status - duration from entry until now
|
|
164
|
+
const startTime = new Date(entry.created_at).getTime();
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
const durationSeconds = Math.round((now - startTime) / 1000);
|
|
167
|
+
return { ...entry, duration_seconds: durationSeconds };
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function sleep(ms: number): Promise<void> {
|
|
172
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function writeSSE(res: Response, payload: unknown): Promise<void> {
|
|
176
|
+
if (res.writableEnded) return;
|
|
177
|
+
const chunk = `data: ${JSON.stringify(payload)}\n\n`;
|
|
178
|
+
const ok = res.write(chunk);
|
|
179
|
+
if (ok) return;
|
|
180
|
+
await new Promise<void>((resolve, reject) => {
|
|
181
|
+
const onDrain = (): void => {
|
|
182
|
+
cleanup();
|
|
183
|
+
resolve();
|
|
184
|
+
};
|
|
185
|
+
const onError = (err: unknown): void => {
|
|
186
|
+
cleanup();
|
|
187
|
+
reject(err);
|
|
188
|
+
};
|
|
189
|
+
const cleanup = (): void => {
|
|
190
|
+
res.off('drain', onDrain);
|
|
191
|
+
res.off('error', onError);
|
|
192
|
+
};
|
|
193
|
+
res.on('drain', onDrain);
|
|
194
|
+
res.on('error', onError);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function writeSSEComment(res: Response, comment: string): Promise<void> {
|
|
199
|
+
if (res.writableEnded) return;
|
|
200
|
+
const chunk = `: ${comment}\n\n`;
|
|
201
|
+
const ok = res.write(chunk);
|
|
202
|
+
if (ok) return;
|
|
203
|
+
await new Promise<void>((resolve, reject) => {
|
|
204
|
+
const onDrain = (): void => {
|
|
205
|
+
cleanup();
|
|
206
|
+
resolve();
|
|
207
|
+
};
|
|
208
|
+
const onError = (err: unknown): void => {
|
|
209
|
+
cleanup();
|
|
210
|
+
reject(err);
|
|
211
|
+
};
|
|
212
|
+
const cleanup = (): void => {
|
|
213
|
+
res.off('drain', onDrain);
|
|
214
|
+
res.off('error', onError);
|
|
215
|
+
};
|
|
216
|
+
res.on('drain', onDrain);
|
|
217
|
+
res.on('error', onError);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function waitForFile(filePath: string, opts: { timeoutMs: number; pollMs: number; isAborted: () => boolean }): Promise<boolean> {
|
|
222
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
223
|
+
while (Date.now() < deadline) {
|
|
224
|
+
if (opts.isAborted()) return false;
|
|
225
|
+
if (existsSync(filePath)) return true;
|
|
226
|
+
await sleep(opts.pollMs);
|
|
227
|
+
}
|
|
228
|
+
return existsSync(filePath);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function streamJsonlFileToSSE(
|
|
232
|
+
res: Response,
|
|
233
|
+
filePath: string,
|
|
234
|
+
opts: { isAborted: () => boolean }
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
const rs = createReadStream(filePath, { encoding: 'utf8' });
|
|
237
|
+
let buffer = '';
|
|
238
|
+
|
|
239
|
+
for await (const chunk of rs) {
|
|
240
|
+
if (opts.isAborted()) return;
|
|
241
|
+
buffer += chunk;
|
|
242
|
+
let nl = buffer.indexOf('\n');
|
|
243
|
+
while (nl >= 0) {
|
|
244
|
+
const line = buffer.slice(0, nl);
|
|
245
|
+
buffer = buffer.slice(nl + 1);
|
|
246
|
+
nl = buffer.indexOf('\n');
|
|
247
|
+
const trimmed = line.trim();
|
|
248
|
+
if (!trimmed) continue;
|
|
249
|
+
try {
|
|
250
|
+
const entry = JSON.parse(trimmed);
|
|
251
|
+
await writeSSE(res, entry);
|
|
252
|
+
} catch {
|
|
253
|
+
// ignore malformed JSONL lines
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const tailLine = buffer.trim();
|
|
259
|
+
if (tailLine && !opts.isAborted()) {
|
|
260
|
+
try {
|
|
261
|
+
const entry = JSON.parse(tailLine);
|
|
262
|
+
await writeSSE(res, entry);
|
|
263
|
+
} catch {
|
|
264
|
+
// ignore
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function readSampledJsonlEntries(
|
|
270
|
+
filePath: string,
|
|
271
|
+
opts: { keepEveryN: number; shouldKeep?: (entry: any, index: number) => boolean }
|
|
272
|
+
): Promise<any[]> {
|
|
273
|
+
const rs = createReadStream(filePath, { encoding: 'utf8' });
|
|
274
|
+
let buffer = '';
|
|
275
|
+
let index = 0;
|
|
276
|
+
const out: any[] = [];
|
|
277
|
+
|
|
278
|
+
const keep = (entry: any, i: number): boolean => {
|
|
279
|
+
if (opts.shouldKeep) return opts.shouldKeep(entry, i);
|
|
280
|
+
// Keep all tools, and sample the rest.
|
|
281
|
+
return entry?.type === 'tool' || i % opts.keepEveryN === 0;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
for await (const chunk of rs) {
|
|
285
|
+
buffer += chunk;
|
|
286
|
+
let nl = buffer.indexOf('\n');
|
|
287
|
+
while (nl >= 0) {
|
|
288
|
+
const line = buffer.slice(0, nl);
|
|
289
|
+
buffer = buffer.slice(nl + 1);
|
|
290
|
+
nl = buffer.indexOf('\n');
|
|
291
|
+
|
|
292
|
+
const trimmed = line.trim();
|
|
293
|
+
if (!trimmed) continue;
|
|
294
|
+
try {
|
|
295
|
+
const entry = JSON.parse(trimmed);
|
|
296
|
+
if (keep(entry, index)) out.push(entry);
|
|
297
|
+
} catch {
|
|
298
|
+
// ignore malformed JSONL lines
|
|
299
|
+
} finally {
|
|
300
|
+
index++;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const tailLine = buffer.trim();
|
|
306
|
+
if (tailLine) {
|
|
307
|
+
try {
|
|
308
|
+
const entry = JSON.parse(tailLine);
|
|
309
|
+
if (keep(entry, index)) out.push(entry);
|
|
310
|
+
} catch {
|
|
311
|
+
// ignore
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* GET /api/tasks/:taskId
|
|
320
|
+
* Get detailed information about a task including audit history
|
|
321
|
+
* Query params:
|
|
322
|
+
* - project: string (required) - project path
|
|
323
|
+
*/
|
|
324
|
+
router.get('/tasks/:taskId', (req: Request, res: Response) => {
|
|
325
|
+
try {
|
|
326
|
+
const { taskId } = req.params;
|
|
327
|
+
const projectPath = req.query.project as string;
|
|
328
|
+
|
|
329
|
+
if (!projectPath) {
|
|
330
|
+
res.status(400).json({
|
|
331
|
+
success: false,
|
|
332
|
+
error: 'Missing required query parameter: project',
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const db = openProjectDatabase(projectPath);
|
|
338
|
+
if (!db) {
|
|
339
|
+
res.status(404).json({
|
|
340
|
+
success: false,
|
|
341
|
+
error: 'Project database not found',
|
|
342
|
+
project: projectPath,
|
|
343
|
+
});
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
// Get task details with section name
|
|
349
|
+
const task = db
|
|
350
|
+
.prepare(
|
|
351
|
+
`SELECT
|
|
352
|
+
t.id, t.title, t.status, t.section_id,
|
|
353
|
+
s.name as section_name,
|
|
354
|
+
t.source_file, t.rejection_count,
|
|
355
|
+
t.created_at, t.updated_at
|
|
356
|
+
FROM tasks t
|
|
357
|
+
LEFT JOIN sections s ON t.section_id = s.id
|
|
358
|
+
WHERE t.id = ?`
|
|
359
|
+
)
|
|
360
|
+
.get(taskId) as TaskDetails | undefined;
|
|
361
|
+
|
|
362
|
+
if (!task) {
|
|
363
|
+
res.status(404).json({
|
|
364
|
+
success: false,
|
|
365
|
+
error: 'Task not found',
|
|
366
|
+
task_id: taskId,
|
|
367
|
+
});
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Get audit trail
|
|
372
|
+
const auditTrail = db
|
|
373
|
+
.prepare(
|
|
374
|
+
`SELECT id, task_id, from_status, to_status, actor, actor_type, model, notes, error_code, commit_sha, created_at
|
|
375
|
+
FROM audit
|
|
376
|
+
WHERE task_id = ?
|
|
377
|
+
ORDER BY created_at ASC`
|
|
378
|
+
)
|
|
379
|
+
.all(taskId) as AuditEntry[];
|
|
380
|
+
|
|
381
|
+
// Get disputes for task
|
|
382
|
+
const disputes = db
|
|
383
|
+
.prepare(
|
|
384
|
+
`SELECT * FROM disputes
|
|
385
|
+
WHERE task_id = ?
|
|
386
|
+
ORDER BY created_at DESC`
|
|
387
|
+
)
|
|
388
|
+
.all(taskId) as DisputeEntry[];
|
|
389
|
+
|
|
390
|
+
// Get LLM invocations (exclude prompt/response to keep payload light)
|
|
391
|
+
const invocations = db
|
|
392
|
+
.prepare(
|
|
393
|
+
`SELECT id, task_id, role, provider, model, exit_code, duration_ms, success, timed_out, rejection_number, created_at
|
|
394
|
+
FROM task_invocations
|
|
395
|
+
WHERE task_id = ?
|
|
396
|
+
ORDER BY created_at ASC`
|
|
397
|
+
)
|
|
398
|
+
.all(taskId) as InvocationEntry[];
|
|
399
|
+
|
|
400
|
+
// Calculate durations for each status
|
|
401
|
+
const auditWithDurations = calculateDurations(auditTrail);
|
|
402
|
+
|
|
403
|
+
// Calculate total time in each status
|
|
404
|
+
let inProgressSeconds = 0;
|
|
405
|
+
let reviewSeconds = 0;
|
|
406
|
+
|
|
407
|
+
for (const entry of auditWithDurations) {
|
|
408
|
+
if (entry.to_status === 'in_progress' && entry.duration_seconds) {
|
|
409
|
+
inProgressSeconds += entry.duration_seconds;
|
|
410
|
+
} else if (entry.to_status === 'review' && entry.duration_seconds) {
|
|
411
|
+
reviewSeconds += entry.duration_seconds;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Total time is just the sum of active work time (coding + review)
|
|
416
|
+
const totalSeconds = inProgressSeconds + reviewSeconds;
|
|
417
|
+
|
|
418
|
+
// Get GitHub URL for commit links
|
|
419
|
+
const githubUrl = getGitHubUrl(projectPath);
|
|
420
|
+
|
|
421
|
+
const response: TaskResponse = {
|
|
422
|
+
...task,
|
|
423
|
+
duration: {
|
|
424
|
+
total_seconds: totalSeconds,
|
|
425
|
+
in_progress_seconds: inProgressSeconds,
|
|
426
|
+
review_seconds: reviewSeconds,
|
|
427
|
+
},
|
|
428
|
+
audit_trail: auditWithDurations.reverse(), // Most recent first for display
|
|
429
|
+
invocations, // Oldest first (chronological)
|
|
430
|
+
disputes,
|
|
431
|
+
github_url: githubUrl,
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
res.json({
|
|
435
|
+
success: true,
|
|
436
|
+
task: response,
|
|
437
|
+
});
|
|
438
|
+
} finally {
|
|
439
|
+
db.close();
|
|
440
|
+
}
|
|
441
|
+
} catch (error) {
|
|
442
|
+
console.error('Error getting task details:', error);
|
|
443
|
+
res.status(500).json({
|
|
444
|
+
success: false,
|
|
445
|
+
error: 'Failed to get task details',
|
|
446
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* GET /api/tasks/:taskId/stream
|
|
453
|
+
* Stream invocation activity (JSONL) for the currently-running invocation using SSE.
|
|
454
|
+
* Query params:
|
|
455
|
+
* - project: string (required) - project path
|
|
456
|
+
*/
|
|
457
|
+
router.get('/tasks/:taskId/stream', async (req: Request, res: Response) => {
|
|
458
|
+
const { taskId } = req.params;
|
|
459
|
+
const projectPath = req.query.project as string | undefined;
|
|
460
|
+
|
|
461
|
+
if (!projectPath) {
|
|
462
|
+
res.status(400).json({
|
|
463
|
+
success: false,
|
|
464
|
+
error: 'Missing required query parameter: project',
|
|
465
|
+
});
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (activeSseConnections >= MAX_SSE_CONNECTIONS) {
|
|
470
|
+
res.status(429).json({
|
|
471
|
+
success: false,
|
|
472
|
+
error: 'Too many active streams',
|
|
473
|
+
max: MAX_SSE_CONNECTIONS,
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
activeSseConnections++;
|
|
479
|
+
|
|
480
|
+
let closed = false;
|
|
481
|
+
const close = (): void => {
|
|
482
|
+
if (closed) return;
|
|
483
|
+
closed = true;
|
|
484
|
+
activeSseConnections = Math.max(0, activeSseConnections - 1);
|
|
485
|
+
try {
|
|
486
|
+
res.end();
|
|
487
|
+
} catch {
|
|
488
|
+
// ignore
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
req.on('close', close);
|
|
493
|
+
|
|
494
|
+
// SSE headers
|
|
495
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
496
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
497
|
+
res.setHeader('Connection', 'keep-alive');
|
|
498
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
499
|
+
res.flushHeaders();
|
|
500
|
+
|
|
501
|
+
const db = openProjectDatabase(projectPath);
|
|
502
|
+
if (!db) {
|
|
503
|
+
await writeSSE(res, { type: 'error', error: 'Project database not found', project: projectPath });
|
|
504
|
+
close();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let invocation: { id: number; status: string } | undefined;
|
|
509
|
+
try {
|
|
510
|
+
invocation = db
|
|
511
|
+
.prepare(
|
|
512
|
+
`SELECT id, status
|
|
513
|
+
FROM task_invocations
|
|
514
|
+
WHERE task_id = ? AND status = 'running'
|
|
515
|
+
ORDER BY started_at_ms DESC
|
|
516
|
+
LIMIT 1`
|
|
517
|
+
)
|
|
518
|
+
.get(taskId) as { id: number; status: string } | undefined;
|
|
519
|
+
} catch (error) {
|
|
520
|
+
await writeSSE(res, {
|
|
521
|
+
type: 'error',
|
|
522
|
+
error: 'Failed to query running invocation (is the database migrated?)',
|
|
523
|
+
message: error instanceof Error ? error.message : String(error),
|
|
524
|
+
});
|
|
525
|
+
close();
|
|
526
|
+
return;
|
|
527
|
+
} finally {
|
|
528
|
+
try {
|
|
529
|
+
db.close();
|
|
530
|
+
} catch {
|
|
531
|
+
// ignore
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (!invocation) {
|
|
536
|
+
await writeSSE(res, { type: 'no_active_invocation', taskId });
|
|
537
|
+
close();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const logFile = join(projectPath, '.steroids', 'invocations', `${invocation.id}.log`);
|
|
542
|
+
const isAborted = (): boolean => closed || res.writableEnded;
|
|
543
|
+
|
|
544
|
+
// If the invocation just started, the log file may not exist yet.
|
|
545
|
+
if (!existsSync(logFile)) {
|
|
546
|
+
await writeSSE(res, { type: 'waiting_for_log', taskId, invocationId: invocation.id });
|
|
547
|
+
const ok = await waitForFile(logFile, { timeoutMs: 5000, pollMs: 100, isAborted });
|
|
548
|
+
if (!ok) {
|
|
549
|
+
await writeSSE(res, { type: 'log_not_found', taskId, invocationId: invocation.id });
|
|
550
|
+
close();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
// 1) Send existing log entries first
|
|
557
|
+
await streamJsonlFileToSSE(res, logFile, { isAborted });
|
|
558
|
+
|
|
559
|
+
if (isAborted()) return;
|
|
560
|
+
|
|
561
|
+
// 2) Tail for new entries
|
|
562
|
+
const tail = new Tail(logFile, { follow: true, useWatchFile: true });
|
|
563
|
+
let writeChain: Promise<void> = Promise.resolve();
|
|
564
|
+
|
|
565
|
+
const heartbeat = setInterval(() => {
|
|
566
|
+
// Keep proxies from timing out the connection.
|
|
567
|
+
writeChain = writeChain.then(() => writeSSEComment(res, 'heartbeat')).catch(() => {});
|
|
568
|
+
}, 30000);
|
|
569
|
+
|
|
570
|
+
const cleanup = (): void => {
|
|
571
|
+
clearInterval(heartbeat);
|
|
572
|
+
try {
|
|
573
|
+
tail.unwatch();
|
|
574
|
+
} catch {
|
|
575
|
+
// ignore
|
|
576
|
+
}
|
|
577
|
+
close();
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
req.on('close', () => {
|
|
581
|
+
clearInterval(heartbeat);
|
|
582
|
+
try {
|
|
583
|
+
tail.unwatch();
|
|
584
|
+
} catch {}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
tail.on('line', (line: string) => {
|
|
588
|
+
if (isAborted()) return;
|
|
589
|
+
const trimmed = line.trim();
|
|
590
|
+
if (!trimmed) return;
|
|
591
|
+
|
|
592
|
+
writeChain = writeChain
|
|
593
|
+
.then(async () => {
|
|
594
|
+
try {
|
|
595
|
+
const entry = JSON.parse(trimmed) as any;
|
|
596
|
+
await writeSSE(res, entry);
|
|
597
|
+
if (entry?.type === 'complete' || entry?.type === 'error') {
|
|
598
|
+
cleanup();
|
|
599
|
+
}
|
|
600
|
+
} catch {
|
|
601
|
+
// ignore malformed JSONL lines
|
|
602
|
+
}
|
|
603
|
+
})
|
|
604
|
+
.catch(() => {});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
tail.on('error', (err: unknown) => {
|
|
608
|
+
void writeChain
|
|
609
|
+
.then(() =>
|
|
610
|
+
writeSSE(res, {
|
|
611
|
+
type: 'error',
|
|
612
|
+
error: 'Tail error',
|
|
613
|
+
message: err instanceof Error ? err.message : String(err),
|
|
614
|
+
})
|
|
615
|
+
)
|
|
616
|
+
.finally(cleanup);
|
|
617
|
+
});
|
|
618
|
+
} catch (error) {
|
|
619
|
+
await writeSSE(res, {
|
|
620
|
+
type: 'error',
|
|
621
|
+
error: 'Failed to stream invocation log',
|
|
622
|
+
message: error instanceof Error ? error.message : String(error),
|
|
623
|
+
});
|
|
624
|
+
close();
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* GET /api/tasks/:taskId/timeline
|
|
630
|
+
* Parse invocation JSONL activity logs on demand and return a sampled timeline.
|
|
631
|
+
* Query params:
|
|
632
|
+
* - project: string (required) - project path
|
|
633
|
+
*/
|
|
634
|
+
router.get('/tasks/:taskId/timeline', async (req: Request, res: Response) => {
|
|
635
|
+
const { taskId } = req.params;
|
|
636
|
+
const projectPath = req.query.project as string | undefined;
|
|
637
|
+
|
|
638
|
+
if (!projectPath) {
|
|
639
|
+
res.status(400).json({
|
|
640
|
+
success: false,
|
|
641
|
+
error: 'Missing required query parameter: project',
|
|
642
|
+
});
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const db = openProjectDatabase(projectPath);
|
|
647
|
+
if (!db) {
|
|
648
|
+
res.status(404).json({
|
|
649
|
+
success: false,
|
|
650
|
+
error: 'Project database not found',
|
|
651
|
+
project: projectPath,
|
|
652
|
+
});
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
type InvocationTimelineRow = {
|
|
657
|
+
id: number;
|
|
658
|
+
role: string;
|
|
659
|
+
provider: string;
|
|
660
|
+
model: string;
|
|
661
|
+
started_at_ms: number;
|
|
662
|
+
completed_at_ms: number | null;
|
|
663
|
+
status: string;
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
let invocations: InvocationTimelineRow[] = [];
|
|
667
|
+
try {
|
|
668
|
+
invocations = db
|
|
669
|
+
.prepare(
|
|
670
|
+
`SELECT id, role, provider, model, started_at_ms, completed_at_ms, status
|
|
671
|
+
FROM task_invocations
|
|
672
|
+
WHERE task_id = ?
|
|
673
|
+
ORDER BY started_at_ms ASC`
|
|
674
|
+
)
|
|
675
|
+
.all(taskId) as InvocationTimelineRow[];
|
|
676
|
+
} catch (error) {
|
|
677
|
+
res.status(500).json({
|
|
678
|
+
success: false,
|
|
679
|
+
error: 'Failed to query invocations (is the database migrated?)',
|
|
680
|
+
message: error instanceof Error ? error.message : String(error),
|
|
681
|
+
});
|
|
682
|
+
return;
|
|
683
|
+
} finally {
|
|
684
|
+
try {
|
|
685
|
+
db.close();
|
|
686
|
+
} catch {}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const timeline: any[] = [];
|
|
690
|
+
|
|
691
|
+
for (const inv of invocations) {
|
|
692
|
+
// Invocation start event (from DB lifecycle timestamps).
|
|
693
|
+
timeline.push({
|
|
694
|
+
ts: inv.started_at_ms,
|
|
695
|
+
type: 'invocation.started',
|
|
696
|
+
invocationId: inv.id,
|
|
697
|
+
role: inv.role,
|
|
698
|
+
provider: inv.provider,
|
|
699
|
+
model: inv.model,
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
const logFile = join(projectPath, '.steroids', 'invocations', `${inv.id}.log`);
|
|
703
|
+
if (existsSync(logFile)) {
|
|
704
|
+
try {
|
|
705
|
+
const sampled = await readSampledJsonlEntries(logFile, { keepEveryN: 10 });
|
|
706
|
+
// Exclude output events from timeline - they're only shown in the live stream
|
|
707
|
+
for (const e of sampled) {
|
|
708
|
+
if (e.type !== 'output') timeline.push({ ...e, invocationId: inv.id });
|
|
709
|
+
}
|
|
710
|
+
} catch {
|
|
711
|
+
// ignore per spec: timeline is best-effort
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Invocation completion event, when available.
|
|
716
|
+
if (inv.completed_at_ms) {
|
|
717
|
+
timeline.push({
|
|
718
|
+
ts: inv.completed_at_ms,
|
|
719
|
+
type: 'invocation.completed',
|
|
720
|
+
invocationId: inv.id,
|
|
721
|
+
success: inv.status === 'completed',
|
|
722
|
+
duration: inv.completed_at_ms - inv.started_at_ms,
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
res.json({ success: true, timeline });
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* GET /api/tasks/:taskId/logs
|
|
732
|
+
* Get execution logs/audit trail for a task
|
|
733
|
+
* Query params:
|
|
734
|
+
* - project: string (required) - project path
|
|
735
|
+
* - limit: number (optional) - max entries to return (default: 50)
|
|
736
|
+
* - offset: number (optional) - offset for pagination (default: 0)
|
|
737
|
+
*/
|
|
738
|
+
router.get('/tasks/:taskId/logs', (req: Request, res: Response) => {
|
|
739
|
+
try {
|
|
740
|
+
const { taskId } = req.params;
|
|
741
|
+
const projectPath = req.query.project as string;
|
|
742
|
+
const limitParam = req.query.limit;
|
|
743
|
+
const offsetParam = req.query.offset;
|
|
744
|
+
|
|
745
|
+
if (!projectPath) {
|
|
746
|
+
res.status(400).json({
|
|
747
|
+
success: false,
|
|
748
|
+
error: 'Missing required query parameter: project',
|
|
749
|
+
});
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Parse limit and offset
|
|
754
|
+
let limit = 50;
|
|
755
|
+
let offset = 0;
|
|
756
|
+
|
|
757
|
+
if (limitParam !== undefined) {
|
|
758
|
+
const parsed = parseInt(limitParam as string, 10);
|
|
759
|
+
if (!isNaN(parsed) && parsed > 0) {
|
|
760
|
+
limit = Math.min(parsed, 500); // Cap at 500
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (offsetParam !== undefined) {
|
|
765
|
+
const parsed = parseInt(offsetParam as string, 10);
|
|
766
|
+
if (!isNaN(parsed) && parsed >= 0) {
|
|
767
|
+
offset = parsed;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const db = openProjectDatabase(projectPath);
|
|
772
|
+
if (!db) {
|
|
773
|
+
res.status(404).json({
|
|
774
|
+
success: false,
|
|
775
|
+
error: 'Project database not found',
|
|
776
|
+
project: projectPath,
|
|
777
|
+
});
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
try {
|
|
782
|
+
// Check task exists
|
|
783
|
+
const task = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?').get(taskId) as
|
|
784
|
+
| { id: string; title: string; status: string }
|
|
785
|
+
| undefined;
|
|
786
|
+
|
|
787
|
+
if (!task) {
|
|
788
|
+
res.status(404).json({
|
|
789
|
+
success: false,
|
|
790
|
+
error: 'Task not found',
|
|
791
|
+
task_id: taskId,
|
|
792
|
+
});
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Get total count
|
|
797
|
+
const countResult = db
|
|
798
|
+
.prepare('SELECT COUNT(*) as count FROM audit WHERE task_id = ?')
|
|
799
|
+
.get(taskId) as { count: number };
|
|
800
|
+
|
|
801
|
+
// Get audit entries with pagination
|
|
802
|
+
const logs = db
|
|
803
|
+
.prepare(
|
|
804
|
+
`SELECT id, task_id, from_status, to_status, actor, actor_type, model, notes, commit_sha, created_at
|
|
805
|
+
FROM audit
|
|
806
|
+
WHERE task_id = ?
|
|
807
|
+
ORDER BY created_at DESC
|
|
808
|
+
LIMIT ? OFFSET ?`
|
|
809
|
+
)
|
|
810
|
+
.all(taskId, limit, offset) as AuditEntry[];
|
|
811
|
+
|
|
812
|
+
res.json({
|
|
813
|
+
success: true,
|
|
814
|
+
task_id: taskId,
|
|
815
|
+
task_title: task.title,
|
|
816
|
+
task_status: task.status,
|
|
817
|
+
logs,
|
|
818
|
+
pagination: {
|
|
819
|
+
total: countResult.count,
|
|
820
|
+
limit,
|
|
821
|
+
offset,
|
|
822
|
+
has_more: offset + logs.length < countResult.count,
|
|
823
|
+
},
|
|
824
|
+
});
|
|
825
|
+
} finally {
|
|
826
|
+
db.close();
|
|
827
|
+
}
|
|
828
|
+
} catch (error) {
|
|
829
|
+
console.error('Error getting task logs:', error);
|
|
830
|
+
res.status(500).json({
|
|
831
|
+
success: false,
|
|
832
|
+
error: 'Failed to get task logs',
|
|
833
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* GET /api/projects/:projectPath/sections
|
|
840
|
+
* List all sections for a project with task counts by status
|
|
841
|
+
*/
|
|
842
|
+
router.get('/projects/:projectPath(*)/sections', (req: Request, res: Response) => {
|
|
843
|
+
try {
|
|
844
|
+
const projectPath = decodeURIComponent(req.params.projectPath);
|
|
845
|
+
|
|
846
|
+
const db = openProjectDatabase(projectPath);
|
|
847
|
+
if (!db) {
|
|
848
|
+
res.status(404).json({
|
|
849
|
+
success: false,
|
|
850
|
+
error: 'Project database not found',
|
|
851
|
+
project: projectPath,
|
|
852
|
+
});
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
try {
|
|
857
|
+
// Check which columns exist (handles older DBs missing priority/branch/pr fields)
|
|
858
|
+
const sectionCols = (() => {
|
|
859
|
+
try {
|
|
860
|
+
const cols = db.prepare("PRAGMA table_info(sections)").all() as Array<{ name: string }>;
|
|
861
|
+
return new Set(cols.map(c => c.name));
|
|
862
|
+
} catch {
|
|
863
|
+
return new Set<string>();
|
|
864
|
+
}
|
|
865
|
+
})();
|
|
866
|
+
|
|
867
|
+
const hasPriority = sectionCols.has('priority');
|
|
868
|
+
const hasBranch = sectionCols.has('branch');
|
|
869
|
+
const hasPrFields = sectionCols.has('pr_number') && sectionCols.has('auto_pr');
|
|
870
|
+
|
|
871
|
+
const prioritySelect = hasPriority ? 's.priority,' : '50 as priority,';
|
|
872
|
+
const branchSelect = hasBranch ? 's.branch,' : 'NULL as branch,';
|
|
873
|
+
const prSelect = hasPrFields ? 's.auto_pr, s.pr_number,' : '0 as auto_pr, NULL as pr_number,';
|
|
874
|
+
const orderBy = hasPriority ? 'ORDER BY s.priority DESC, s.name ASC' : 'ORDER BY s.name ASC';
|
|
875
|
+
|
|
876
|
+
// Get sections with task counts by status
|
|
877
|
+
const sections = db
|
|
878
|
+
.prepare(
|
|
879
|
+
`SELECT
|
|
880
|
+
s.id,
|
|
881
|
+
s.name,
|
|
882
|
+
${prioritySelect}
|
|
883
|
+
${branchSelect}
|
|
884
|
+
${prSelect}
|
|
885
|
+
s.created_at,
|
|
886
|
+
COUNT(t.id) as total_tasks,
|
|
887
|
+
SUM(CASE WHEN t.status = 'pending' THEN 1 ELSE 0 END) as pending,
|
|
888
|
+
SUM(CASE WHEN t.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
|
889
|
+
SUM(CASE WHEN t.status = 'review' THEN 1 ELSE 0 END) as review,
|
|
890
|
+
SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed,
|
|
891
|
+
SUM(CASE WHEN t.status = 'failed' THEN 1 ELSE 0 END) as failed,
|
|
892
|
+
SUM(CASE WHEN t.status = 'skipped' THEN 1 ELSE 0 END) as skipped
|
|
893
|
+
FROM sections s
|
|
894
|
+
LEFT JOIN tasks t ON t.section_id = s.id
|
|
895
|
+
GROUP BY s.id
|
|
896
|
+
${orderBy}`
|
|
897
|
+
)
|
|
898
|
+
.all() as Array<{
|
|
899
|
+
id: string;
|
|
900
|
+
name: string;
|
|
901
|
+
priority: number;
|
|
902
|
+
branch: string | null;
|
|
903
|
+
auto_pr: number;
|
|
904
|
+
pr_number: number | null;
|
|
905
|
+
created_at: string;
|
|
906
|
+
total_tasks: number;
|
|
907
|
+
pending: number;
|
|
908
|
+
in_progress: number;
|
|
909
|
+
review: number;
|
|
910
|
+
completed: number;
|
|
911
|
+
failed: number;
|
|
912
|
+
skipped: number;
|
|
913
|
+
}>;
|
|
914
|
+
|
|
915
|
+
// Derive PR URL from git remote + pr_number (GitHub-only)
|
|
916
|
+
const githubBaseUrl = getGitHubUrl(projectPath);
|
|
917
|
+
|
|
918
|
+
// Fetch all section dependencies in one query, keyed by section_id
|
|
919
|
+
// Wrapped in try/catch for older DBs that pre-date migration 004
|
|
920
|
+
const dependsBySection = new Map<string, string[]>();
|
|
921
|
+
try {
|
|
922
|
+
const deps = db.prepare(`
|
|
923
|
+
SELECT sd.section_id, s.name as depends_on_name
|
|
924
|
+
FROM section_dependencies sd
|
|
925
|
+
JOIN sections s ON sd.depends_on_section_id = s.id
|
|
926
|
+
ORDER BY s.name ASC
|
|
927
|
+
`).all() as Array<{ section_id: string; depends_on_name: string }>;
|
|
928
|
+
for (const row of deps) {
|
|
929
|
+
const existing = dependsBySection.get(row.section_id);
|
|
930
|
+
if (existing) {
|
|
931
|
+
existing.push(row.depends_on_name);
|
|
932
|
+
} else {
|
|
933
|
+
dependsBySection.set(row.section_id, [row.depends_on_name]);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
} catch {
|
|
937
|
+
// section_dependencies table absent — leave map empty
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Also get tasks without a section (null section_id)
|
|
941
|
+
const unassigned = db
|
|
942
|
+
.prepare(
|
|
943
|
+
`SELECT
|
|
944
|
+
COUNT(*) as total_tasks,
|
|
945
|
+
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
|
946
|
+
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
|
947
|
+
SUM(CASE WHEN status = 'review' THEN 1 ELSE 0 END) as review,
|
|
948
|
+
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
|
949
|
+
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
|
|
950
|
+
SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END) as skipped
|
|
951
|
+
FROM tasks
|
|
952
|
+
WHERE section_id IS NULL`
|
|
953
|
+
)
|
|
954
|
+
.get() as {
|
|
955
|
+
total_tasks: number;
|
|
956
|
+
pending: number;
|
|
957
|
+
in_progress: number;
|
|
958
|
+
review: number;
|
|
959
|
+
completed: number;
|
|
960
|
+
failed: number;
|
|
961
|
+
skipped: number;
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
res.json({
|
|
965
|
+
success: true,
|
|
966
|
+
project: projectPath,
|
|
967
|
+
sections: sections.map(s => ({
|
|
968
|
+
...s,
|
|
969
|
+
auto_pr: s.auto_pr !== 0,
|
|
970
|
+
pr_url: s.pr_number && githubBaseUrl ? `${githubBaseUrl}/pull/${s.pr_number}` : null,
|
|
971
|
+
depends_on: dependsBySection.get(s.id) ?? [],
|
|
972
|
+
})),
|
|
973
|
+
unassigned: unassigned.total_tasks > 0 ? unassigned : null,
|
|
974
|
+
});
|
|
975
|
+
} finally {
|
|
976
|
+
db.close();
|
|
977
|
+
}
|
|
978
|
+
} catch (error) {
|
|
979
|
+
console.error('Error listing project sections:', error);
|
|
980
|
+
res.status(500).json({
|
|
981
|
+
success: false,
|
|
982
|
+
error: 'Failed to list project sections',
|
|
983
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* GET /api/projects/:projectPath/tasks
|
|
990
|
+
* List all tasks for a project
|
|
991
|
+
* Query params:
|
|
992
|
+
* - status: string (optional) - filter by status
|
|
993
|
+
* - section: string (optional) - filter by section id
|
|
994
|
+
* - issue: string (optional) - filter by issue bucket (failed_retries, stale)
|
|
995
|
+
* - hours: number (optional) - filter to tasks created within last N hours
|
|
996
|
+
* - limit: number (optional) - max entries (default: 100)
|
|
997
|
+
*/
|
|
998
|
+
router.get('/projects/:projectPath(*)/tasks', (req: Request, res: Response) => {
|
|
999
|
+
try {
|
|
1000
|
+
// projectPath comes URL-encoded, decode it
|
|
1001
|
+
const projectPath = decodeURIComponent(req.params.projectPath);
|
|
1002
|
+
const statusFilter = req.query.status as string | undefined;
|
|
1003
|
+
const sectionFilter = req.query.section as string | undefined;
|
|
1004
|
+
const issueFilter = req.query.issue as string | undefined;
|
|
1005
|
+
const hoursParam = req.query.hours;
|
|
1006
|
+
const limitParam = req.query.limit;
|
|
1007
|
+
|
|
1008
|
+
const normalizedIssueFilter =
|
|
1009
|
+
issueFilter === 'failed_retries' || issueFilter === 'stale'
|
|
1010
|
+
? issueFilter
|
|
1011
|
+
: undefined;
|
|
1012
|
+
|
|
1013
|
+
if (issueFilter && !normalizedIssueFilter) {
|
|
1014
|
+
res.status(400).json({
|
|
1015
|
+
success: false,
|
|
1016
|
+
error: 'Invalid issue parameter - supported values: failed_retries, stale',
|
|
1017
|
+
});
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
let limit = 100;
|
|
1022
|
+
if (limitParam !== undefined) {
|
|
1023
|
+
const parsed = parseInt(limitParam as string, 10);
|
|
1024
|
+
if (!isNaN(parsed) && parsed > 0) {
|
|
1025
|
+
limit = Math.min(parsed, 500);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
let hoursFilter: number | undefined;
|
|
1030
|
+
if (hoursParam !== undefined) {
|
|
1031
|
+
const parsed = parseInt(hoursParam as string, 10);
|
|
1032
|
+
if (isNaN(parsed) || parsed <= 0) {
|
|
1033
|
+
res.status(400).json({
|
|
1034
|
+
success: false,
|
|
1035
|
+
error: 'Invalid hours parameter - must be a positive integer',
|
|
1036
|
+
});
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
hoursFilter = parsed;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const db = openProjectDatabase(projectPath);
|
|
1043
|
+
if (!db) {
|
|
1044
|
+
res.status(404).json({
|
|
1045
|
+
success: false,
|
|
1046
|
+
error: 'Project database not found',
|
|
1047
|
+
project: projectPath,
|
|
1048
|
+
});
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
try {
|
|
1053
|
+
let staleIssueSupported = false;
|
|
1054
|
+
if (normalizedIssueFilter === 'stale') {
|
|
1055
|
+
try {
|
|
1056
|
+
const row = db
|
|
1057
|
+
.prepare(`SELECT 1 as exists_flag FROM sqlite_master WHERE type = 'table' AND name = 'incidents' LIMIT 1`)
|
|
1058
|
+
.get() as { exists_flag: number } | undefined;
|
|
1059
|
+
staleIssueSupported = Boolean(row?.exists_flag);
|
|
1060
|
+
} catch {
|
|
1061
|
+
staleIssueSupported = false;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
let query = `
|
|
1066
|
+
SELECT
|
|
1067
|
+
t.id, t.title, t.status, t.section_id,
|
|
1068
|
+
s.name as section_name,
|
|
1069
|
+
t.source_file, t.rejection_count, t.failure_count,
|
|
1070
|
+
t.created_at, t.updated_at
|
|
1071
|
+
FROM tasks t
|
|
1072
|
+
LEFT JOIN sections s ON t.section_id = s.id
|
|
1073
|
+
WHERE 1=1
|
|
1074
|
+
`;
|
|
1075
|
+
const params: (string | number)[] = [];
|
|
1076
|
+
|
|
1077
|
+
if (statusFilter) {
|
|
1078
|
+
query += ' AND t.status = ?';
|
|
1079
|
+
params.push(statusFilter);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (sectionFilter) {
|
|
1083
|
+
query += ' AND t.section_id = ?';
|
|
1084
|
+
params.push(sectionFilter);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
if (hoursFilter) {
|
|
1088
|
+
query += ` AND julianday(t.created_at) >= julianday('now', ? || ' hours')`;
|
|
1089
|
+
params.push(`-${hoursFilter}`);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
if (normalizedIssueFilter === 'failed_retries') {
|
|
1093
|
+
query += ` AND COALESCE(t.failure_count, 0) > 0`;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (normalizedIssueFilter === 'stale') {
|
|
1097
|
+
if (!staleIssueSupported) {
|
|
1098
|
+
query += ' AND 1=0';
|
|
1099
|
+
} else {
|
|
1100
|
+
query += `
|
|
1101
|
+
AND EXISTS (
|
|
1102
|
+
SELECT 1
|
|
1103
|
+
FROM incidents i
|
|
1104
|
+
WHERE i.task_id = t.id
|
|
1105
|
+
AND i.resolved_at IS NULL
|
|
1106
|
+
AND i.failure_mode IN ('orphaned_task', 'hanging_invocation')
|
|
1107
|
+
)
|
|
1108
|
+
`;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
query += ' ORDER BY t.created_at DESC LIMIT ?';
|
|
1113
|
+
params.push(limit);
|
|
1114
|
+
|
|
1115
|
+
const tasks = db.prepare(query).all(...params) as TaskDetails[];
|
|
1116
|
+
|
|
1117
|
+
// Get task counts by status
|
|
1118
|
+
let countsQuery = `
|
|
1119
|
+
SELECT status, COUNT(*) as count
|
|
1120
|
+
FROM tasks
|
|
1121
|
+
WHERE 1=1
|
|
1122
|
+
`;
|
|
1123
|
+
const countParams: (string | number)[] = [];
|
|
1124
|
+
if (hoursFilter) {
|
|
1125
|
+
countsQuery += ` AND julianday(created_at) >= julianday('now', ? || ' hours')`;
|
|
1126
|
+
countParams.push(`-${hoursFilter}`);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
if (normalizedIssueFilter === 'failed_retries') {
|
|
1130
|
+
countsQuery += ` AND COALESCE(failure_count, 0) > 0`;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
if (normalizedIssueFilter === 'stale') {
|
|
1134
|
+
if (!staleIssueSupported) {
|
|
1135
|
+
countsQuery += ' AND 1=0';
|
|
1136
|
+
} else {
|
|
1137
|
+
countsQuery += `
|
|
1138
|
+
AND EXISTS (
|
|
1139
|
+
SELECT 1
|
|
1140
|
+
FROM incidents i
|
|
1141
|
+
WHERE i.task_id = tasks.id
|
|
1142
|
+
AND i.resolved_at IS NULL
|
|
1143
|
+
AND i.failure_mode IN ('orphaned_task', 'hanging_invocation')
|
|
1144
|
+
)
|
|
1145
|
+
`;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
countsQuery += ' GROUP BY status';
|
|
1149
|
+
|
|
1150
|
+
const statusCounts = db
|
|
1151
|
+
.prepare(countsQuery)
|
|
1152
|
+
.all(...countParams) as { status: string; count: number }[];
|
|
1153
|
+
|
|
1154
|
+
const counts = statusCounts.reduce(
|
|
1155
|
+
(acc, { status, count }) => {
|
|
1156
|
+
acc[status] = count;
|
|
1157
|
+
return acc;
|
|
1158
|
+
},
|
|
1159
|
+
{} as Record<string, number>
|
|
1160
|
+
);
|
|
1161
|
+
|
|
1162
|
+
// Pending tasks are always shown regardless of time range — a task created
|
|
1163
|
+
// two weeks ago that is still pending is just as relevant as one from today.
|
|
1164
|
+
if (hoursFilter) {
|
|
1165
|
+
const pendingRow = db
|
|
1166
|
+
.prepare(`SELECT COUNT(*) as count FROM tasks WHERE status = 'pending'`)
|
|
1167
|
+
.get() as { count: number };
|
|
1168
|
+
counts['pending'] = pendingRow.count;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
res.json({
|
|
1172
|
+
success: true,
|
|
1173
|
+
project: projectPath,
|
|
1174
|
+
tasks,
|
|
1175
|
+
count: tasks.length,
|
|
1176
|
+
status_counts: counts,
|
|
1177
|
+
});
|
|
1178
|
+
} finally {
|
|
1179
|
+
db.close();
|
|
1180
|
+
}
|
|
1181
|
+
} catch (error) {
|
|
1182
|
+
console.error('Error listing project tasks:', error);
|
|
1183
|
+
res.status(500).json({
|
|
1184
|
+
success: false,
|
|
1185
|
+
error: 'Failed to list project tasks',
|
|
1186
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* GET /api/tasks/:taskId/invocations/:invocationId
|
|
1193
|
+
* Get full details for a specific invocation including prompt and response
|
|
1194
|
+
* Query params:
|
|
1195
|
+
* - project: string (required) - project path
|
|
1196
|
+
*/
|
|
1197
|
+
router.get('/tasks/:taskId/invocations/:invocationId', (req: Request, res: Response) => {
|
|
1198
|
+
try {
|
|
1199
|
+
const { taskId, invocationId } = req.params;
|
|
1200
|
+
const projectPath = req.query.project as string;
|
|
1201
|
+
|
|
1202
|
+
if (!projectPath) {
|
|
1203
|
+
res.status(400).json({
|
|
1204
|
+
success: false,
|
|
1205
|
+
error: 'Missing required query parameter: project',
|
|
1206
|
+
});
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const db = openProjectDatabase(projectPath);
|
|
1211
|
+
if (!db) {
|
|
1212
|
+
res.status(404).json({
|
|
1213
|
+
success: false,
|
|
1214
|
+
error: 'Project database not found',
|
|
1215
|
+
project: projectPath,
|
|
1216
|
+
});
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
try {
|
|
1221
|
+
// Get full invocation details including prompt and response
|
|
1222
|
+
const invocation = db
|
|
1223
|
+
.prepare(
|
|
1224
|
+
`SELECT id, task_id, role, provider, model, prompt, response, error, exit_code, duration_ms, success, timed_out, rejection_number, created_at
|
|
1225
|
+
FROM task_invocations
|
|
1226
|
+
WHERE id = ? AND task_id = ?`
|
|
1227
|
+
)
|
|
1228
|
+
.get(invocationId, taskId) as InvocationDetails | undefined;
|
|
1229
|
+
|
|
1230
|
+
if (!invocation) {
|
|
1231
|
+
res.status(404).json({
|
|
1232
|
+
success: false,
|
|
1233
|
+
error: 'Invocation not found',
|
|
1234
|
+
invocation_id: invocationId,
|
|
1235
|
+
task_id: taskId,
|
|
1236
|
+
});
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
res.json({
|
|
1241
|
+
success: true,
|
|
1242
|
+
invocation,
|
|
1243
|
+
});
|
|
1244
|
+
} finally {
|
|
1245
|
+
db.close();
|
|
1246
|
+
}
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
console.error('Error getting invocation details:', error);
|
|
1249
|
+
res.status(500).json({
|
|
1250
|
+
success: false,
|
|
1251
|
+
error: 'Failed to get invocation details',
|
|
1252
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* POST /api/tasks/:taskId/restart
|
|
1259
|
+
* Restart a failed/disputed task by resetting rejection count and setting status to pending
|
|
1260
|
+
* Body: { project: string, notes?: string }
|
|
1261
|
+
* Notes are stored in the audit entry as human guidance for the coder
|
|
1262
|
+
*/
|
|
1263
|
+
router.post('/tasks/:taskId/restart', (req: Request, res: Response) => {
|
|
1264
|
+
try {
|
|
1265
|
+
const { taskId } = req.params;
|
|
1266
|
+
const projectPath = req.body.project as string;
|
|
1267
|
+
const notes = req.body.notes as string | undefined;
|
|
1268
|
+
|
|
1269
|
+
if (!projectPath) {
|
|
1270
|
+
res.status(400).json({
|
|
1271
|
+
success: false,
|
|
1272
|
+
error: 'Missing required body parameter: project',
|
|
1273
|
+
});
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const dbPath = join(projectPath, '.steroids', 'steroids.db');
|
|
1278
|
+
if (!existsSync(dbPath)) {
|
|
1279
|
+
res.status(404).json({
|
|
1280
|
+
success: false,
|
|
1281
|
+
error: 'Project database not found',
|
|
1282
|
+
project: projectPath,
|
|
1283
|
+
});
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
let db: Database.Database;
|
|
1288
|
+
try {
|
|
1289
|
+
db = new Database(dbPath); // Writable mode
|
|
1290
|
+
} catch {
|
|
1291
|
+
res.status(500).json({
|
|
1292
|
+
success: false,
|
|
1293
|
+
error: 'Failed to open project database',
|
|
1294
|
+
});
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
try {
|
|
1299
|
+
// Check task exists
|
|
1300
|
+
const task = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?').get(taskId) as
|
|
1301
|
+
| { id: string; title: string; status: string }
|
|
1302
|
+
| undefined;
|
|
1303
|
+
|
|
1304
|
+
if (!task) {
|
|
1305
|
+
res.status(404).json({
|
|
1306
|
+
success: false,
|
|
1307
|
+
error: 'Task not found',
|
|
1308
|
+
task_id: taskId,
|
|
1309
|
+
});
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Block restart for tasks already in progress
|
|
1314
|
+
if (task.status === 'in_progress' || task.status === 'review') {
|
|
1315
|
+
res.status(400).json({
|
|
1316
|
+
success: false,
|
|
1317
|
+
error: `Cannot restart task in ${task.status} status. Task is currently being worked on.`,
|
|
1318
|
+
});
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// Resolve any open disputes for this task
|
|
1323
|
+
const openDisputes = db
|
|
1324
|
+
.prepare(`SELECT id FROM disputes WHERE task_id = ? AND status = 'open'`)
|
|
1325
|
+
.all(taskId) as { id: string }[];
|
|
1326
|
+
|
|
1327
|
+
for (const dispute of openDisputes) {
|
|
1328
|
+
db.prepare(
|
|
1329
|
+
`UPDATE disputes
|
|
1330
|
+
SET status = 'resolved', resolution = 'custom', resolution_notes = ?,
|
|
1331
|
+
resolved_by = 'human:webui', resolved_at = datetime('now')
|
|
1332
|
+
WHERE id = ?`
|
|
1333
|
+
).run(notes || 'Resolved via WebUI restart', dispute.id);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Reset task: set status to pending and rejection_count to 0
|
|
1337
|
+
db.prepare(
|
|
1338
|
+
`UPDATE tasks
|
|
1339
|
+
SET status = 'pending', rejection_count = 0, updated_at = datetime('now')
|
|
1340
|
+
WHERE id = ?`
|
|
1341
|
+
).run(taskId);
|
|
1342
|
+
|
|
1343
|
+
// Add audit entry with human guidance notes
|
|
1344
|
+
const auditNotes = notes
|
|
1345
|
+
? `Task restarted via WebUI. Human guidance: ${notes}`
|
|
1346
|
+
: 'Task restarted via WebUI';
|
|
1347
|
+
|
|
1348
|
+
db.prepare(
|
|
1349
|
+
`INSERT INTO audit (task_id, from_status, to_status, actor, actor_type, notes, created_at)
|
|
1350
|
+
VALUES (?, ?, 'pending', 'human:webui', 'human', ?, datetime('now'))`
|
|
1351
|
+
).run(taskId, task.status, auditNotes);
|
|
1352
|
+
|
|
1353
|
+
res.json({
|
|
1354
|
+
success: true,
|
|
1355
|
+
message: 'Task restarted successfully',
|
|
1356
|
+
task_id: taskId,
|
|
1357
|
+
disputes_resolved: openDisputes.length,
|
|
1358
|
+
});
|
|
1359
|
+
} finally {
|
|
1360
|
+
db.close();
|
|
1361
|
+
}
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
console.error('Error restarting task:', error);
|
|
1364
|
+
res.status(500).json({
|
|
1365
|
+
success: false,
|
|
1366
|
+
error: 'Failed to restart task',
|
|
1367
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
export default router;
|