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,1007 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tasks API routes
|
|
3
|
+
* Exposes task details and logs for individual tasks
|
|
4
|
+
*/
|
|
5
|
+
import { Router } from 'express';
|
|
6
|
+
import Database from 'better-sqlite3';
|
|
7
|
+
import { createReadStream, existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { execSync } from 'node:child_process';
|
|
10
|
+
import { Tail } from 'tail';
|
|
11
|
+
const router = Router();
|
|
12
|
+
const MAX_SSE_CONNECTIONS = Math.max(1, parseInt(process.env.MAX_SSE_CONNECTIONS || '100', 10) || 100);
|
|
13
|
+
let activeSseConnections = 0;
|
|
14
|
+
/**
|
|
15
|
+
* Get GitHub URL from git remote
|
|
16
|
+
* @param projectPath - Path to project root
|
|
17
|
+
* @returns GitHub base URL (e.g., https://github.com/owner/repo) or null
|
|
18
|
+
*/
|
|
19
|
+
function getGitHubUrl(projectPath) {
|
|
20
|
+
try {
|
|
21
|
+
const remoteUrl = execSync('git remote get-url origin', {
|
|
22
|
+
cwd: projectPath,
|
|
23
|
+
encoding: 'utf-8',
|
|
24
|
+
}).trim();
|
|
25
|
+
// Convert SSH or HTTPS URL to web URL
|
|
26
|
+
// SSH: git@github.com:owner/repo.git
|
|
27
|
+
// HTTPS: https://github.com/owner/repo.git
|
|
28
|
+
let webUrl = null;
|
|
29
|
+
if (remoteUrl.startsWith('git@github.com:')) {
|
|
30
|
+
// SSH format
|
|
31
|
+
const path = remoteUrl.replace('git@github.com:', '').replace(/\.git$/, '');
|
|
32
|
+
webUrl = `https://github.com/${path}`;
|
|
33
|
+
}
|
|
34
|
+
else if (remoteUrl.includes('github.com')) {
|
|
35
|
+
// HTTPS format
|
|
36
|
+
webUrl = remoteUrl.replace(/\.git$/, '');
|
|
37
|
+
}
|
|
38
|
+
return webUrl;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Open project database
|
|
46
|
+
* @param projectPath - Path to project root
|
|
47
|
+
* @returns Database connection or null if not found
|
|
48
|
+
*/
|
|
49
|
+
function openProjectDatabase(projectPath) {
|
|
50
|
+
const dbPath = join(projectPath, '.steroids', 'steroids.db');
|
|
51
|
+
if (!existsSync(dbPath)) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return new Database(dbPath, { readonly: true });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Calculate duration for each status from audit trail
|
|
63
|
+
* @param auditTrail - Array of audit entries sorted by created_at
|
|
64
|
+
* @returns Audit entries with duration_seconds added
|
|
65
|
+
*/
|
|
66
|
+
function calculateDurations(auditTrail) {
|
|
67
|
+
// Sort by created_at ascending for duration calculation
|
|
68
|
+
const sorted = [...auditTrail].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
69
|
+
return sorted.map((entry, index) => {
|
|
70
|
+
// Duration is time until next status change
|
|
71
|
+
if (index < sorted.length - 1) {
|
|
72
|
+
const startTime = new Date(entry.created_at).getTime();
|
|
73
|
+
const endTime = new Date(sorted[index + 1].created_at).getTime();
|
|
74
|
+
const durationSeconds = Math.round((endTime - startTime) / 1000);
|
|
75
|
+
return { ...entry, duration_seconds: durationSeconds };
|
|
76
|
+
}
|
|
77
|
+
// Current/last status - duration from entry until now
|
|
78
|
+
const startTime = new Date(entry.created_at).getTime();
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
const durationSeconds = Math.round((now - startTime) / 1000);
|
|
81
|
+
return { ...entry, duration_seconds: durationSeconds };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function sleep(ms) {
|
|
85
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
86
|
+
}
|
|
87
|
+
async function writeSSE(res, payload) {
|
|
88
|
+
if (res.writableEnded)
|
|
89
|
+
return;
|
|
90
|
+
const chunk = `data: ${JSON.stringify(payload)}\n\n`;
|
|
91
|
+
const ok = res.write(chunk);
|
|
92
|
+
if (ok)
|
|
93
|
+
return;
|
|
94
|
+
await new Promise((resolve, reject) => {
|
|
95
|
+
const onDrain = () => {
|
|
96
|
+
cleanup();
|
|
97
|
+
resolve();
|
|
98
|
+
};
|
|
99
|
+
const onError = (err) => {
|
|
100
|
+
cleanup();
|
|
101
|
+
reject(err);
|
|
102
|
+
};
|
|
103
|
+
const cleanup = () => {
|
|
104
|
+
res.off('drain', onDrain);
|
|
105
|
+
res.off('error', onError);
|
|
106
|
+
};
|
|
107
|
+
res.on('drain', onDrain);
|
|
108
|
+
res.on('error', onError);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async function writeSSEComment(res, comment) {
|
|
112
|
+
if (res.writableEnded)
|
|
113
|
+
return;
|
|
114
|
+
const chunk = `: ${comment}\n\n`;
|
|
115
|
+
const ok = res.write(chunk);
|
|
116
|
+
if (ok)
|
|
117
|
+
return;
|
|
118
|
+
await new Promise((resolve, reject) => {
|
|
119
|
+
const onDrain = () => {
|
|
120
|
+
cleanup();
|
|
121
|
+
resolve();
|
|
122
|
+
};
|
|
123
|
+
const onError = (err) => {
|
|
124
|
+
cleanup();
|
|
125
|
+
reject(err);
|
|
126
|
+
};
|
|
127
|
+
const cleanup = () => {
|
|
128
|
+
res.off('drain', onDrain);
|
|
129
|
+
res.off('error', onError);
|
|
130
|
+
};
|
|
131
|
+
res.on('drain', onDrain);
|
|
132
|
+
res.on('error', onError);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async function waitForFile(filePath, opts) {
|
|
136
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
137
|
+
while (Date.now() < deadline) {
|
|
138
|
+
if (opts.isAborted())
|
|
139
|
+
return false;
|
|
140
|
+
if (existsSync(filePath))
|
|
141
|
+
return true;
|
|
142
|
+
await sleep(opts.pollMs);
|
|
143
|
+
}
|
|
144
|
+
return existsSync(filePath);
|
|
145
|
+
}
|
|
146
|
+
async function streamJsonlFileToSSE(res, filePath, opts) {
|
|
147
|
+
const rs = createReadStream(filePath, { encoding: 'utf8' });
|
|
148
|
+
let buffer = '';
|
|
149
|
+
for await (const chunk of rs) {
|
|
150
|
+
if (opts.isAborted())
|
|
151
|
+
return;
|
|
152
|
+
buffer += chunk;
|
|
153
|
+
let nl = buffer.indexOf('\n');
|
|
154
|
+
while (nl >= 0) {
|
|
155
|
+
const line = buffer.slice(0, nl);
|
|
156
|
+
buffer = buffer.slice(nl + 1);
|
|
157
|
+
nl = buffer.indexOf('\n');
|
|
158
|
+
const trimmed = line.trim();
|
|
159
|
+
if (!trimmed)
|
|
160
|
+
continue;
|
|
161
|
+
try {
|
|
162
|
+
const entry = JSON.parse(trimmed);
|
|
163
|
+
await writeSSE(res, entry);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// ignore malformed JSONL lines
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const tailLine = buffer.trim();
|
|
171
|
+
if (tailLine && !opts.isAborted()) {
|
|
172
|
+
try {
|
|
173
|
+
const entry = JSON.parse(tailLine);
|
|
174
|
+
await writeSSE(res, entry);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// ignore
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async function readSampledJsonlEntries(filePath, opts) {
|
|
182
|
+
const rs = createReadStream(filePath, { encoding: 'utf8' });
|
|
183
|
+
let buffer = '';
|
|
184
|
+
let index = 0;
|
|
185
|
+
const out = [];
|
|
186
|
+
const keep = (entry, i) => {
|
|
187
|
+
if (opts.shouldKeep)
|
|
188
|
+
return opts.shouldKeep(entry, i);
|
|
189
|
+
// Keep all tools, and sample the rest.
|
|
190
|
+
return entry?.type === 'tool' || i % opts.keepEveryN === 0;
|
|
191
|
+
};
|
|
192
|
+
for await (const chunk of rs) {
|
|
193
|
+
buffer += chunk;
|
|
194
|
+
let nl = buffer.indexOf('\n');
|
|
195
|
+
while (nl >= 0) {
|
|
196
|
+
const line = buffer.slice(0, nl);
|
|
197
|
+
buffer = buffer.slice(nl + 1);
|
|
198
|
+
nl = buffer.indexOf('\n');
|
|
199
|
+
const trimmed = line.trim();
|
|
200
|
+
if (!trimmed)
|
|
201
|
+
continue;
|
|
202
|
+
try {
|
|
203
|
+
const entry = JSON.parse(trimmed);
|
|
204
|
+
if (keep(entry, index))
|
|
205
|
+
out.push(entry);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// ignore malformed JSONL lines
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
index++;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const tailLine = buffer.trim();
|
|
216
|
+
if (tailLine) {
|
|
217
|
+
try {
|
|
218
|
+
const entry = JSON.parse(tailLine);
|
|
219
|
+
if (keep(entry, index))
|
|
220
|
+
out.push(entry);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// ignore
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* GET /api/tasks/:taskId
|
|
230
|
+
* Get detailed information about a task including audit history
|
|
231
|
+
* Query params:
|
|
232
|
+
* - project: string (required) - project path
|
|
233
|
+
*/
|
|
234
|
+
router.get('/tasks/:taskId', (req, res) => {
|
|
235
|
+
try {
|
|
236
|
+
const { taskId } = req.params;
|
|
237
|
+
const projectPath = req.query.project;
|
|
238
|
+
if (!projectPath) {
|
|
239
|
+
res.status(400).json({
|
|
240
|
+
success: false,
|
|
241
|
+
error: 'Missing required query parameter: project',
|
|
242
|
+
});
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const db = openProjectDatabase(projectPath);
|
|
246
|
+
if (!db) {
|
|
247
|
+
res.status(404).json({
|
|
248
|
+
success: false,
|
|
249
|
+
error: 'Project database not found',
|
|
250
|
+
project: projectPath,
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
// Get task details with section name
|
|
256
|
+
const task = db
|
|
257
|
+
.prepare(`SELECT
|
|
258
|
+
t.id, t.title, t.status, t.section_id,
|
|
259
|
+
s.name as section_name,
|
|
260
|
+
t.source_file, t.rejection_count,
|
|
261
|
+
t.created_at, t.updated_at
|
|
262
|
+
FROM tasks t
|
|
263
|
+
LEFT JOIN sections s ON t.section_id = s.id
|
|
264
|
+
WHERE t.id = ?`)
|
|
265
|
+
.get(taskId);
|
|
266
|
+
if (!task) {
|
|
267
|
+
res.status(404).json({
|
|
268
|
+
success: false,
|
|
269
|
+
error: 'Task not found',
|
|
270
|
+
task_id: taskId,
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// Get audit trail
|
|
275
|
+
const auditTrail = db
|
|
276
|
+
.prepare(`SELECT id, task_id, from_status, to_status, actor, actor_type, model, notes, commit_sha, created_at
|
|
277
|
+
FROM audit
|
|
278
|
+
WHERE task_id = ?
|
|
279
|
+
ORDER BY created_at ASC`)
|
|
280
|
+
.all(taskId);
|
|
281
|
+
// Get disputes for task
|
|
282
|
+
const disputes = db
|
|
283
|
+
.prepare(`SELECT * FROM disputes
|
|
284
|
+
WHERE task_id = ?
|
|
285
|
+
ORDER BY created_at DESC`)
|
|
286
|
+
.all(taskId);
|
|
287
|
+
// Get LLM invocations (exclude prompt/response to keep payload light)
|
|
288
|
+
const invocations = db
|
|
289
|
+
.prepare(`SELECT id, task_id, role, provider, model, exit_code, duration_ms, success, timed_out, rejection_number, created_at
|
|
290
|
+
FROM task_invocations
|
|
291
|
+
WHERE task_id = ?
|
|
292
|
+
ORDER BY created_at ASC`)
|
|
293
|
+
.all(taskId);
|
|
294
|
+
// Calculate durations for each status
|
|
295
|
+
const auditWithDurations = calculateDurations(auditTrail);
|
|
296
|
+
// Calculate total time in each status
|
|
297
|
+
let inProgressSeconds = 0;
|
|
298
|
+
let reviewSeconds = 0;
|
|
299
|
+
for (const entry of auditWithDurations) {
|
|
300
|
+
if (entry.to_status === 'in_progress' && entry.duration_seconds) {
|
|
301
|
+
inProgressSeconds += entry.duration_seconds;
|
|
302
|
+
}
|
|
303
|
+
else if (entry.to_status === 'review' && entry.duration_seconds) {
|
|
304
|
+
reviewSeconds += entry.duration_seconds;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// Total time is just the sum of active work time (coding + review)
|
|
308
|
+
const totalSeconds = inProgressSeconds + reviewSeconds;
|
|
309
|
+
// Get GitHub URL for commit links
|
|
310
|
+
const githubUrl = getGitHubUrl(projectPath);
|
|
311
|
+
const response = {
|
|
312
|
+
...task,
|
|
313
|
+
duration: {
|
|
314
|
+
total_seconds: totalSeconds,
|
|
315
|
+
in_progress_seconds: inProgressSeconds,
|
|
316
|
+
review_seconds: reviewSeconds,
|
|
317
|
+
},
|
|
318
|
+
audit_trail: auditWithDurations.reverse(), // Most recent first for display
|
|
319
|
+
invocations, // Oldest first (chronological)
|
|
320
|
+
disputes,
|
|
321
|
+
github_url: githubUrl,
|
|
322
|
+
};
|
|
323
|
+
res.json({
|
|
324
|
+
success: true,
|
|
325
|
+
task: response,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
db.close();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
console.error('Error getting task details:', error);
|
|
334
|
+
res.status(500).json({
|
|
335
|
+
success: false,
|
|
336
|
+
error: 'Failed to get task details',
|
|
337
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
/**
|
|
342
|
+
* GET /api/tasks/:taskId/stream
|
|
343
|
+
* Stream invocation activity (JSONL) for the currently-running invocation using SSE.
|
|
344
|
+
* Query params:
|
|
345
|
+
* - project: string (required) - project path
|
|
346
|
+
*/
|
|
347
|
+
router.get('/tasks/:taskId/stream', async (req, res) => {
|
|
348
|
+
const { taskId } = req.params;
|
|
349
|
+
const projectPath = req.query.project;
|
|
350
|
+
if (!projectPath) {
|
|
351
|
+
res.status(400).json({
|
|
352
|
+
success: false,
|
|
353
|
+
error: 'Missing required query parameter: project',
|
|
354
|
+
});
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (activeSseConnections >= MAX_SSE_CONNECTIONS) {
|
|
358
|
+
res.status(429).json({
|
|
359
|
+
success: false,
|
|
360
|
+
error: 'Too many active streams',
|
|
361
|
+
max: MAX_SSE_CONNECTIONS,
|
|
362
|
+
});
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
activeSseConnections++;
|
|
366
|
+
let closed = false;
|
|
367
|
+
const close = () => {
|
|
368
|
+
if (closed)
|
|
369
|
+
return;
|
|
370
|
+
closed = true;
|
|
371
|
+
activeSseConnections = Math.max(0, activeSseConnections - 1);
|
|
372
|
+
try {
|
|
373
|
+
res.end();
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
// ignore
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
req.on('close', close);
|
|
380
|
+
// SSE headers
|
|
381
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
382
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
383
|
+
res.setHeader('Connection', 'keep-alive');
|
|
384
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
385
|
+
res.flushHeaders();
|
|
386
|
+
const db = openProjectDatabase(projectPath);
|
|
387
|
+
if (!db) {
|
|
388
|
+
await writeSSE(res, { type: 'error', error: 'Project database not found', project: projectPath });
|
|
389
|
+
close();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
let invocation;
|
|
393
|
+
try {
|
|
394
|
+
invocation = db
|
|
395
|
+
.prepare(`SELECT id, status
|
|
396
|
+
FROM task_invocations
|
|
397
|
+
WHERE task_id = ? AND status = 'running'
|
|
398
|
+
ORDER BY started_at_ms DESC
|
|
399
|
+
LIMIT 1`)
|
|
400
|
+
.get(taskId);
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
await writeSSE(res, {
|
|
404
|
+
type: 'error',
|
|
405
|
+
error: 'Failed to query running invocation (is the database migrated?)',
|
|
406
|
+
message: error instanceof Error ? error.message : String(error),
|
|
407
|
+
});
|
|
408
|
+
close();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
finally {
|
|
412
|
+
try {
|
|
413
|
+
db.close();
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
// ignore
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (!invocation) {
|
|
420
|
+
await writeSSE(res, { type: 'no_active_invocation', taskId });
|
|
421
|
+
close();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const logFile = join(projectPath, '.steroids', 'invocations', `${invocation.id}.log`);
|
|
425
|
+
const isAborted = () => closed || res.writableEnded;
|
|
426
|
+
// If the invocation just started, the log file may not exist yet.
|
|
427
|
+
if (!existsSync(logFile)) {
|
|
428
|
+
await writeSSE(res, { type: 'waiting_for_log', taskId, invocationId: invocation.id });
|
|
429
|
+
const ok = await waitForFile(logFile, { timeoutMs: 5000, pollMs: 100, isAborted });
|
|
430
|
+
if (!ok) {
|
|
431
|
+
await writeSSE(res, { type: 'log_not_found', taskId, invocationId: invocation.id });
|
|
432
|
+
close();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
// 1) Send existing log entries first
|
|
438
|
+
await streamJsonlFileToSSE(res, logFile, { isAborted });
|
|
439
|
+
if (isAborted())
|
|
440
|
+
return;
|
|
441
|
+
// 2) Tail for new entries
|
|
442
|
+
const tail = new Tail(logFile, { follow: true, useWatchFile: true });
|
|
443
|
+
let writeChain = Promise.resolve();
|
|
444
|
+
const heartbeat = setInterval(() => {
|
|
445
|
+
// Keep proxies from timing out the connection.
|
|
446
|
+
writeChain = writeChain.then(() => writeSSEComment(res, 'heartbeat')).catch(() => { });
|
|
447
|
+
}, 30000);
|
|
448
|
+
const cleanup = () => {
|
|
449
|
+
clearInterval(heartbeat);
|
|
450
|
+
try {
|
|
451
|
+
tail.unwatch();
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// ignore
|
|
455
|
+
}
|
|
456
|
+
close();
|
|
457
|
+
};
|
|
458
|
+
req.on('close', () => {
|
|
459
|
+
clearInterval(heartbeat);
|
|
460
|
+
try {
|
|
461
|
+
tail.unwatch();
|
|
462
|
+
}
|
|
463
|
+
catch { }
|
|
464
|
+
});
|
|
465
|
+
tail.on('line', (line) => {
|
|
466
|
+
if (isAborted())
|
|
467
|
+
return;
|
|
468
|
+
const trimmed = line.trim();
|
|
469
|
+
if (!trimmed)
|
|
470
|
+
return;
|
|
471
|
+
writeChain = writeChain
|
|
472
|
+
.then(async () => {
|
|
473
|
+
try {
|
|
474
|
+
const entry = JSON.parse(trimmed);
|
|
475
|
+
await writeSSE(res, entry);
|
|
476
|
+
if (entry?.type === 'complete' || entry?.type === 'error') {
|
|
477
|
+
cleanup();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
// ignore malformed JSONL lines
|
|
482
|
+
}
|
|
483
|
+
})
|
|
484
|
+
.catch(() => { });
|
|
485
|
+
});
|
|
486
|
+
tail.on('error', (err) => {
|
|
487
|
+
void writeChain
|
|
488
|
+
.then(() => writeSSE(res, {
|
|
489
|
+
type: 'error',
|
|
490
|
+
error: 'Tail error',
|
|
491
|
+
message: err instanceof Error ? err.message : String(err),
|
|
492
|
+
}))
|
|
493
|
+
.finally(cleanup);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
await writeSSE(res, {
|
|
498
|
+
type: 'error',
|
|
499
|
+
error: 'Failed to stream invocation log',
|
|
500
|
+
message: error instanceof Error ? error.message : String(error),
|
|
501
|
+
});
|
|
502
|
+
close();
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
/**
|
|
506
|
+
* GET /api/tasks/:taskId/timeline
|
|
507
|
+
* Parse invocation JSONL activity logs on demand and return a sampled timeline.
|
|
508
|
+
* Query params:
|
|
509
|
+
* - project: string (required) - project path
|
|
510
|
+
*/
|
|
511
|
+
router.get('/tasks/:taskId/timeline', async (req, res) => {
|
|
512
|
+
const { taskId } = req.params;
|
|
513
|
+
const projectPath = req.query.project;
|
|
514
|
+
if (!projectPath) {
|
|
515
|
+
res.status(400).json({
|
|
516
|
+
success: false,
|
|
517
|
+
error: 'Missing required query parameter: project',
|
|
518
|
+
});
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const db = openProjectDatabase(projectPath);
|
|
522
|
+
if (!db) {
|
|
523
|
+
res.status(404).json({
|
|
524
|
+
success: false,
|
|
525
|
+
error: 'Project database not found',
|
|
526
|
+
project: projectPath,
|
|
527
|
+
});
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
let invocations = [];
|
|
531
|
+
try {
|
|
532
|
+
invocations = db
|
|
533
|
+
.prepare(`SELECT id, role, provider, model, started_at_ms, completed_at_ms, status
|
|
534
|
+
FROM task_invocations
|
|
535
|
+
WHERE task_id = ?
|
|
536
|
+
ORDER BY started_at_ms ASC`)
|
|
537
|
+
.all(taskId);
|
|
538
|
+
}
|
|
539
|
+
catch (error) {
|
|
540
|
+
res.status(500).json({
|
|
541
|
+
success: false,
|
|
542
|
+
error: 'Failed to query invocations (is the database migrated?)',
|
|
543
|
+
message: error instanceof Error ? error.message : String(error),
|
|
544
|
+
});
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
finally {
|
|
548
|
+
try {
|
|
549
|
+
db.close();
|
|
550
|
+
}
|
|
551
|
+
catch { }
|
|
552
|
+
}
|
|
553
|
+
const timeline = [];
|
|
554
|
+
for (const inv of invocations) {
|
|
555
|
+
// Invocation start event (from DB lifecycle timestamps).
|
|
556
|
+
timeline.push({
|
|
557
|
+
ts: inv.started_at_ms,
|
|
558
|
+
type: 'invocation.started',
|
|
559
|
+
invocationId: inv.id,
|
|
560
|
+
role: inv.role,
|
|
561
|
+
provider: inv.provider,
|
|
562
|
+
model: inv.model,
|
|
563
|
+
});
|
|
564
|
+
const logFile = join(projectPath, '.steroids', 'invocations', `${inv.id}.log`);
|
|
565
|
+
if (existsSync(logFile)) {
|
|
566
|
+
try {
|
|
567
|
+
const sampled = await readSampledJsonlEntries(logFile, { keepEveryN: 10 });
|
|
568
|
+
for (const e of sampled)
|
|
569
|
+
timeline.push({ ...e, invocationId: inv.id });
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
// ignore per spec: timeline is best-effort
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// Invocation completion event, when available.
|
|
576
|
+
if (inv.completed_at_ms) {
|
|
577
|
+
timeline.push({
|
|
578
|
+
ts: inv.completed_at_ms,
|
|
579
|
+
type: 'invocation.completed',
|
|
580
|
+
invocationId: inv.id,
|
|
581
|
+
success: inv.status === 'completed',
|
|
582
|
+
duration: inv.completed_at_ms - inv.started_at_ms,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
res.json({ success: true, timeline });
|
|
587
|
+
});
|
|
588
|
+
/**
|
|
589
|
+
* GET /api/tasks/:taskId/logs
|
|
590
|
+
* Get execution logs/audit trail for a task
|
|
591
|
+
* Query params:
|
|
592
|
+
* - project: string (required) - project path
|
|
593
|
+
* - limit: number (optional) - max entries to return (default: 50)
|
|
594
|
+
* - offset: number (optional) - offset for pagination (default: 0)
|
|
595
|
+
*/
|
|
596
|
+
router.get('/tasks/:taskId/logs', (req, res) => {
|
|
597
|
+
try {
|
|
598
|
+
const { taskId } = req.params;
|
|
599
|
+
const projectPath = req.query.project;
|
|
600
|
+
const limitParam = req.query.limit;
|
|
601
|
+
const offsetParam = req.query.offset;
|
|
602
|
+
if (!projectPath) {
|
|
603
|
+
res.status(400).json({
|
|
604
|
+
success: false,
|
|
605
|
+
error: 'Missing required query parameter: project',
|
|
606
|
+
});
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
// Parse limit and offset
|
|
610
|
+
let limit = 50;
|
|
611
|
+
let offset = 0;
|
|
612
|
+
if (limitParam !== undefined) {
|
|
613
|
+
const parsed = parseInt(limitParam, 10);
|
|
614
|
+
if (!isNaN(parsed) && parsed > 0) {
|
|
615
|
+
limit = Math.min(parsed, 500); // Cap at 500
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (offsetParam !== undefined) {
|
|
619
|
+
const parsed = parseInt(offsetParam, 10);
|
|
620
|
+
if (!isNaN(parsed) && parsed >= 0) {
|
|
621
|
+
offset = parsed;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const db = openProjectDatabase(projectPath);
|
|
625
|
+
if (!db) {
|
|
626
|
+
res.status(404).json({
|
|
627
|
+
success: false,
|
|
628
|
+
error: 'Project database not found',
|
|
629
|
+
project: projectPath,
|
|
630
|
+
});
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
try {
|
|
634
|
+
// Check task exists
|
|
635
|
+
const task = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?').get(taskId);
|
|
636
|
+
if (!task) {
|
|
637
|
+
res.status(404).json({
|
|
638
|
+
success: false,
|
|
639
|
+
error: 'Task not found',
|
|
640
|
+
task_id: taskId,
|
|
641
|
+
});
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
// Get total count
|
|
645
|
+
const countResult = db
|
|
646
|
+
.prepare('SELECT COUNT(*) as count FROM audit WHERE task_id = ?')
|
|
647
|
+
.get(taskId);
|
|
648
|
+
// Get audit entries with pagination
|
|
649
|
+
const logs = db
|
|
650
|
+
.prepare(`SELECT id, task_id, from_status, to_status, actor, actor_type, model, notes, commit_sha, created_at
|
|
651
|
+
FROM audit
|
|
652
|
+
WHERE task_id = ?
|
|
653
|
+
ORDER BY created_at DESC
|
|
654
|
+
LIMIT ? OFFSET ?`)
|
|
655
|
+
.all(taskId, limit, offset);
|
|
656
|
+
res.json({
|
|
657
|
+
success: true,
|
|
658
|
+
task_id: taskId,
|
|
659
|
+
task_title: task.title,
|
|
660
|
+
task_status: task.status,
|
|
661
|
+
logs,
|
|
662
|
+
pagination: {
|
|
663
|
+
total: countResult.count,
|
|
664
|
+
limit,
|
|
665
|
+
offset,
|
|
666
|
+
has_more: offset + logs.length < countResult.count,
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
finally {
|
|
671
|
+
db.close();
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
console.error('Error getting task logs:', error);
|
|
676
|
+
res.status(500).json({
|
|
677
|
+
success: false,
|
|
678
|
+
error: 'Failed to get task logs',
|
|
679
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
/**
|
|
684
|
+
* GET /api/projects/:projectPath/sections
|
|
685
|
+
* List all sections for a project with task counts by status
|
|
686
|
+
*/
|
|
687
|
+
router.get('/projects/:projectPath(*)/sections', (req, res) => {
|
|
688
|
+
try {
|
|
689
|
+
const projectPath = decodeURIComponent(req.params.projectPath);
|
|
690
|
+
const db = openProjectDatabase(projectPath);
|
|
691
|
+
if (!db) {
|
|
692
|
+
res.status(404).json({
|
|
693
|
+
success: false,
|
|
694
|
+
error: 'Project database not found',
|
|
695
|
+
project: projectPath,
|
|
696
|
+
});
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
try {
|
|
700
|
+
// Check if priority column exists (older databases may not have it)
|
|
701
|
+
const hasPriority = (() => {
|
|
702
|
+
try {
|
|
703
|
+
const cols = db.prepare("PRAGMA table_info(sections)").all();
|
|
704
|
+
return cols.some(c => c.name === 'priority');
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
})();
|
|
710
|
+
const prioritySelect = hasPriority ? 's.priority,' : '50 as priority,';
|
|
711
|
+
const orderBy = hasPriority ? 'ORDER BY s.priority DESC, s.name ASC' : 'ORDER BY s.name ASC';
|
|
712
|
+
// Get sections with task counts by status
|
|
713
|
+
const sections = db
|
|
714
|
+
.prepare(`SELECT
|
|
715
|
+
s.id,
|
|
716
|
+
s.name,
|
|
717
|
+
${prioritySelect}
|
|
718
|
+
s.created_at,
|
|
719
|
+
COUNT(t.id) as total_tasks,
|
|
720
|
+
SUM(CASE WHEN t.status = 'pending' THEN 1 ELSE 0 END) as pending,
|
|
721
|
+
SUM(CASE WHEN t.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
|
722
|
+
SUM(CASE WHEN t.status = 'review' THEN 1 ELSE 0 END) as review,
|
|
723
|
+
SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed,
|
|
724
|
+
SUM(CASE WHEN t.status = 'failed' THEN 1 ELSE 0 END) as failed,
|
|
725
|
+
SUM(CASE WHEN t.status = 'skipped' THEN 1 ELSE 0 END) as skipped
|
|
726
|
+
FROM sections s
|
|
727
|
+
LEFT JOIN tasks t ON t.section_id = s.id
|
|
728
|
+
GROUP BY s.id
|
|
729
|
+
${orderBy}`)
|
|
730
|
+
.all();
|
|
731
|
+
// Also get tasks without a section (null section_id)
|
|
732
|
+
const unassigned = db
|
|
733
|
+
.prepare(`SELECT
|
|
734
|
+
COUNT(*) as total_tasks,
|
|
735
|
+
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
|
736
|
+
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
|
737
|
+
SUM(CASE WHEN status = 'review' THEN 1 ELSE 0 END) as review,
|
|
738
|
+
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
|
739
|
+
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
|
|
740
|
+
SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END) as skipped
|
|
741
|
+
FROM tasks
|
|
742
|
+
WHERE section_id IS NULL`)
|
|
743
|
+
.get();
|
|
744
|
+
res.json({
|
|
745
|
+
success: true,
|
|
746
|
+
project: projectPath,
|
|
747
|
+
sections,
|
|
748
|
+
unassigned: unassigned.total_tasks > 0 ? unassigned : null,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
finally {
|
|
752
|
+
db.close();
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
catch (error) {
|
|
756
|
+
console.error('Error listing project sections:', error);
|
|
757
|
+
res.status(500).json({
|
|
758
|
+
success: false,
|
|
759
|
+
error: 'Failed to list project sections',
|
|
760
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
/**
|
|
765
|
+
* GET /api/projects/:projectPath/tasks
|
|
766
|
+
* List all tasks for a project
|
|
767
|
+
* Query params:
|
|
768
|
+
* - status: string (optional) - filter by status
|
|
769
|
+
* - section: string (optional) - filter by section id
|
|
770
|
+
* - limit: number (optional) - max entries (default: 100)
|
|
771
|
+
*/
|
|
772
|
+
router.get('/projects/:projectPath(*)/tasks', (req, res) => {
|
|
773
|
+
try {
|
|
774
|
+
// projectPath comes URL-encoded, decode it
|
|
775
|
+
const projectPath = decodeURIComponent(req.params.projectPath);
|
|
776
|
+
const statusFilter = req.query.status;
|
|
777
|
+
const sectionFilter = req.query.section;
|
|
778
|
+
const limitParam = req.query.limit;
|
|
779
|
+
let limit = 100;
|
|
780
|
+
if (limitParam !== undefined) {
|
|
781
|
+
const parsed = parseInt(limitParam, 10);
|
|
782
|
+
if (!isNaN(parsed) && parsed > 0) {
|
|
783
|
+
limit = Math.min(parsed, 500);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const db = openProjectDatabase(projectPath);
|
|
787
|
+
if (!db) {
|
|
788
|
+
res.status(404).json({
|
|
789
|
+
success: false,
|
|
790
|
+
error: 'Project database not found',
|
|
791
|
+
project: projectPath,
|
|
792
|
+
});
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
try {
|
|
796
|
+
let query = `
|
|
797
|
+
SELECT
|
|
798
|
+
t.id, t.title, t.status, t.section_id,
|
|
799
|
+
s.name as section_name,
|
|
800
|
+
t.source_file, t.rejection_count,
|
|
801
|
+
t.created_at, t.updated_at
|
|
802
|
+
FROM tasks t
|
|
803
|
+
LEFT JOIN sections s ON t.section_id = s.id
|
|
804
|
+
WHERE 1=1
|
|
805
|
+
`;
|
|
806
|
+
const params = [];
|
|
807
|
+
if (statusFilter) {
|
|
808
|
+
query += ' AND t.status = ?';
|
|
809
|
+
params.push(statusFilter);
|
|
810
|
+
}
|
|
811
|
+
if (sectionFilter) {
|
|
812
|
+
query += ' AND t.section_id = ?';
|
|
813
|
+
params.push(sectionFilter);
|
|
814
|
+
}
|
|
815
|
+
query += ' ORDER BY t.created_at DESC LIMIT ?';
|
|
816
|
+
params.push(limit);
|
|
817
|
+
const tasks = db.prepare(query).all(...params);
|
|
818
|
+
// Get task counts by status
|
|
819
|
+
const statusCounts = db
|
|
820
|
+
.prepare(`SELECT status, COUNT(*) as count
|
|
821
|
+
FROM tasks
|
|
822
|
+
GROUP BY status`)
|
|
823
|
+
.all();
|
|
824
|
+
const counts = statusCounts.reduce((acc, { status, count }) => {
|
|
825
|
+
acc[status] = count;
|
|
826
|
+
return acc;
|
|
827
|
+
}, {});
|
|
828
|
+
res.json({
|
|
829
|
+
success: true,
|
|
830
|
+
project: projectPath,
|
|
831
|
+
tasks,
|
|
832
|
+
count: tasks.length,
|
|
833
|
+
status_counts: counts,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
finally {
|
|
837
|
+
db.close();
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
catch (error) {
|
|
841
|
+
console.error('Error listing project tasks:', error);
|
|
842
|
+
res.status(500).json({
|
|
843
|
+
success: false,
|
|
844
|
+
error: 'Failed to list project tasks',
|
|
845
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
/**
|
|
850
|
+
* GET /api/tasks/:taskId/invocations/:invocationId
|
|
851
|
+
* Get full details for a specific invocation including prompt and response
|
|
852
|
+
* Query params:
|
|
853
|
+
* - project: string (required) - project path
|
|
854
|
+
*/
|
|
855
|
+
router.get('/tasks/:taskId/invocations/:invocationId', (req, res) => {
|
|
856
|
+
try {
|
|
857
|
+
const { taskId, invocationId } = req.params;
|
|
858
|
+
const projectPath = req.query.project;
|
|
859
|
+
if (!projectPath) {
|
|
860
|
+
res.status(400).json({
|
|
861
|
+
success: false,
|
|
862
|
+
error: 'Missing required query parameter: project',
|
|
863
|
+
});
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const db = openProjectDatabase(projectPath);
|
|
867
|
+
if (!db) {
|
|
868
|
+
res.status(404).json({
|
|
869
|
+
success: false,
|
|
870
|
+
error: 'Project database not found',
|
|
871
|
+
project: projectPath,
|
|
872
|
+
});
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
try {
|
|
876
|
+
// Get full invocation details including prompt and response
|
|
877
|
+
const invocation = db
|
|
878
|
+
.prepare(`SELECT id, task_id, role, provider, model, prompt, response, error, exit_code, duration_ms, success, timed_out, rejection_number, created_at
|
|
879
|
+
FROM task_invocations
|
|
880
|
+
WHERE id = ? AND task_id = ?`)
|
|
881
|
+
.get(invocationId, taskId);
|
|
882
|
+
if (!invocation) {
|
|
883
|
+
res.status(404).json({
|
|
884
|
+
success: false,
|
|
885
|
+
error: 'Invocation not found',
|
|
886
|
+
invocation_id: invocationId,
|
|
887
|
+
task_id: taskId,
|
|
888
|
+
});
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
res.json({
|
|
892
|
+
success: true,
|
|
893
|
+
invocation,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
finally {
|
|
897
|
+
db.close();
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
catch (error) {
|
|
901
|
+
console.error('Error getting invocation details:', error);
|
|
902
|
+
res.status(500).json({
|
|
903
|
+
success: false,
|
|
904
|
+
error: 'Failed to get invocation details',
|
|
905
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
/**
|
|
910
|
+
* POST /api/tasks/:taskId/restart
|
|
911
|
+
* Restart a failed/disputed task by resetting rejection count and setting status to pending
|
|
912
|
+
* Body: { project: string, notes?: string }
|
|
913
|
+
* Notes are stored in the audit entry as human guidance for the coder
|
|
914
|
+
*/
|
|
915
|
+
router.post('/tasks/:taskId/restart', (req, res) => {
|
|
916
|
+
try {
|
|
917
|
+
const { taskId } = req.params;
|
|
918
|
+
const projectPath = req.body.project;
|
|
919
|
+
const notes = req.body.notes;
|
|
920
|
+
if (!projectPath) {
|
|
921
|
+
res.status(400).json({
|
|
922
|
+
success: false,
|
|
923
|
+
error: 'Missing required body parameter: project',
|
|
924
|
+
});
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const dbPath = join(projectPath, '.steroids', 'steroids.db');
|
|
928
|
+
if (!existsSync(dbPath)) {
|
|
929
|
+
res.status(404).json({
|
|
930
|
+
success: false,
|
|
931
|
+
error: 'Project database not found',
|
|
932
|
+
project: projectPath,
|
|
933
|
+
});
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
let db;
|
|
937
|
+
try {
|
|
938
|
+
db = new Database(dbPath); // Writable mode
|
|
939
|
+
}
|
|
940
|
+
catch {
|
|
941
|
+
res.status(500).json({
|
|
942
|
+
success: false,
|
|
943
|
+
error: 'Failed to open project database',
|
|
944
|
+
});
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
// Check task exists
|
|
949
|
+
const task = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?').get(taskId);
|
|
950
|
+
if (!task) {
|
|
951
|
+
res.status(404).json({
|
|
952
|
+
success: false,
|
|
953
|
+
error: 'Task not found',
|
|
954
|
+
task_id: taskId,
|
|
955
|
+
});
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
// Block restart for tasks already in progress
|
|
959
|
+
if (task.status === 'in_progress' || task.status === 'review') {
|
|
960
|
+
res.status(400).json({
|
|
961
|
+
success: false,
|
|
962
|
+
error: `Cannot restart task in ${task.status} status. Task is currently being worked on.`,
|
|
963
|
+
});
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
// Resolve any open disputes for this task
|
|
967
|
+
const openDisputes = db
|
|
968
|
+
.prepare(`SELECT id FROM disputes WHERE task_id = ? AND status = 'open'`)
|
|
969
|
+
.all(taskId);
|
|
970
|
+
for (const dispute of openDisputes) {
|
|
971
|
+
db.prepare(`UPDATE disputes
|
|
972
|
+
SET status = 'resolved', resolution = 'custom', resolution_notes = ?,
|
|
973
|
+
resolved_by = 'human:webui', resolved_at = datetime('now')
|
|
974
|
+
WHERE id = ?`).run(notes || 'Resolved via WebUI restart', dispute.id);
|
|
975
|
+
}
|
|
976
|
+
// Reset task: set status to pending and rejection_count to 0
|
|
977
|
+
db.prepare(`UPDATE tasks
|
|
978
|
+
SET status = 'pending', rejection_count = 0, updated_at = datetime('now')
|
|
979
|
+
WHERE id = ?`).run(taskId);
|
|
980
|
+
// Add audit entry with human guidance notes
|
|
981
|
+
const auditNotes = notes
|
|
982
|
+
? `Task restarted via WebUI. Human guidance: ${notes}`
|
|
983
|
+
: 'Task restarted via WebUI';
|
|
984
|
+
db.prepare(`INSERT INTO audit (task_id, from_status, to_status, actor, actor_type, notes, created_at)
|
|
985
|
+
VALUES (?, ?, 'pending', 'human:webui', 'human', ?, datetime('now'))`).run(taskId, task.status, auditNotes);
|
|
986
|
+
res.json({
|
|
987
|
+
success: true,
|
|
988
|
+
message: 'Task restarted successfully',
|
|
989
|
+
task_id: taskId,
|
|
990
|
+
disputes_resolved: openDisputes.length,
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
finally {
|
|
994
|
+
db.close();
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
catch (error) {
|
|
998
|
+
console.error('Error restarting task:', error);
|
|
999
|
+
res.status(500).json({
|
|
1000
|
+
success: false,
|
|
1001
|
+
error: 'Failed to restart task',
|
|
1002
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
export default router;
|
|
1007
|
+
//# sourceMappingURL=tasks.js.map
|