specdacular 0.12.0 → 0.13.1
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/bin/specd.js +168 -36
- package/package.json +1 -5
- package/runner/main/agent/parser.js +0 -39
- package/runner/main/agent/runner.js +0 -137
- package/runner/main/agent/template.js +0 -16
- package/runner/main/bootstrap.js +0 -69
- package/runner/main/db.js +0 -45
- package/runner/main/index.js +0 -103
- package/runner/main/ipc.js +0 -72
- package/runner/main/notifications/telegram.js +0 -45
- package/runner/main/orchestrator.js +0 -193
- package/runner/main/paths.js +0 -36
- package/runner/main/pipeline/resolver.js +0 -20
- package/runner/main/pipeline/sequencer.js +0 -42
- package/runner/main/server/api.js +0 -125
- package/runner/main/server/index.js +0 -33
- package/runner/main/server/websocket.js +0 -24
- package/runner/main/state/manager.js +0 -83
- package/runner/main/template-manager.js +0 -41
- package/runner/main/test/agent-parser.test.js +0 -44
- package/runner/main/test/bootstrap.test.js +0 -58
- package/runner/main/test/db.test.js +0 -72
- package/runner/main/test/paths.test.js +0 -29
- package/runner/main/test/state-manager.test.js +0 -72
- package/runner/main/test/template-manager.test.js +0 -66
- package/runner/main/worktree/manager.js +0 -95
- package/runner/package.json +0 -20
- package/runner/preload.js +0 -19
package/runner/main/index.js
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
// runner/main/index.js
|
|
2
|
-
import { app, BrowserWindow } from 'electron';
|
|
3
|
-
import { join, dirname } from 'path';
|
|
4
|
-
import { fileURLToPath } from 'url';
|
|
5
|
-
|
|
6
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
8
|
-
import { Paths } from './paths.js';
|
|
9
|
-
import { bootstrap } from './bootstrap.js';
|
|
10
|
-
import { ProjectDB } from './db.js';
|
|
11
|
-
import { Orchestrator } from './orchestrator.js';
|
|
12
|
-
import { createServer } from './server/index.js';
|
|
13
|
-
import { setupIpc } from './ipc.js';
|
|
14
|
-
|
|
15
|
-
let mainWindow;
|
|
16
|
-
const paths = new Paths();
|
|
17
|
-
const orchestrators = new Map();
|
|
18
|
-
let db;
|
|
19
|
-
let config;
|
|
20
|
-
let server;
|
|
21
|
-
|
|
22
|
-
function getContext() {
|
|
23
|
-
return { db, config, paths, orchestrators };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function initBackend() {
|
|
27
|
-
await bootstrap(paths);
|
|
28
|
-
|
|
29
|
-
db = new ProjectDB(paths.db);
|
|
30
|
-
config = JSON.parse(readFileSync(paths.config, 'utf-8'));
|
|
31
|
-
|
|
32
|
-
// Initialize orchestrators for active projects
|
|
33
|
-
for (const project of db.list().filter(p => p.active)) {
|
|
34
|
-
// Ensure project.json exists (may not if registered via CLI only)
|
|
35
|
-
const projectPaths = paths.forProject(project.id);
|
|
36
|
-
mkdirSync(projectPaths.dir, { recursive: true });
|
|
37
|
-
if (!existsSync(projectPaths.projectJson)) {
|
|
38
|
-
writeFileSync(projectPaths.projectJson, JSON.stringify({
|
|
39
|
-
name: project.name,
|
|
40
|
-
path: project.path,
|
|
41
|
-
registeredAt: project.registeredAt,
|
|
42
|
-
}, null, 2));
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const orch = new Orchestrator({ projectId: project.id, paths, config });
|
|
46
|
-
orch.init();
|
|
47
|
-
orchestrators.set(project.id, orch);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Start API server
|
|
51
|
-
server = createServer(getContext);
|
|
52
|
-
const port = config.server?.port || 3700;
|
|
53
|
-
await server.start(port);
|
|
54
|
-
|
|
55
|
-
// Wire orchestrators to WebSocket
|
|
56
|
-
for (const orch of orchestrators.values()) {
|
|
57
|
-
server.wireOrchestrator(orch);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Start orchestrator loops
|
|
61
|
-
for (const orch of orchestrators.values()) {
|
|
62
|
-
orch.startLoop().catch(err => console.error('Loop error:', err));
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function createWindow() {
|
|
67
|
-
mainWindow = new BrowserWindow({
|
|
68
|
-
width: 1200,
|
|
69
|
-
height: 800,
|
|
70
|
-
webPreferences: {
|
|
71
|
-
preload: join(__dirname, '..', 'preload.js'),
|
|
72
|
-
contextIsolation: true,
|
|
73
|
-
nodeIntegration: false,
|
|
74
|
-
},
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
if (process.env.NODE_ENV === 'development') {
|
|
78
|
-
mainWindow.loadURL('http://localhost:5173');
|
|
79
|
-
} else {
|
|
80
|
-
mainWindow.loadFile(join(__dirname, '..', 'renderer', 'dist', 'index.html'));
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
app.whenReady().then(async () => {
|
|
85
|
-
setupIpc(getContext);
|
|
86
|
-
await initBackend();
|
|
87
|
-
createWindow();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
app.on('window-all-closed', () => {
|
|
91
|
-
if (process.platform !== 'darwin') app.quit();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
app.on('activate', () => {
|
|
95
|
-
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
app.on('before-quit', () => {
|
|
99
|
-
for (const orch of orchestrators.values()) {
|
|
100
|
-
orch.stop();
|
|
101
|
-
}
|
|
102
|
-
if (server) server.stop();
|
|
103
|
-
});
|
package/runner/main/ipc.js
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
// runner/main/ipc.js
|
|
2
|
-
import { ipcMain } from 'electron';
|
|
3
|
-
import { readFileSync, existsSync } from 'fs';
|
|
4
|
-
import { join } from 'path';
|
|
5
|
-
|
|
6
|
-
export function setupIpc(getContext) {
|
|
7
|
-
ipcMain.handle('get-projects', () => {
|
|
8
|
-
const { db, orchestrators } = getContext();
|
|
9
|
-
return db.list().map(p => {
|
|
10
|
-
const orch = orchestrators.get(p.id);
|
|
11
|
-
const tasks = orch ? orch.getTasks() : [];
|
|
12
|
-
return {
|
|
13
|
-
...p,
|
|
14
|
-
taskCounts: {
|
|
15
|
-
total: tasks.length,
|
|
16
|
-
ready: tasks.filter(t => t.status === 'ready').length,
|
|
17
|
-
running: tasks.filter(t => t.status === 'in_progress').length,
|
|
18
|
-
done: tasks.filter(t => t.status === 'done').length,
|
|
19
|
-
failed: tasks.filter(t => t.status === 'failed').length,
|
|
20
|
-
},
|
|
21
|
-
};
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
ipcMain.handle('get-project-status', (event, projectId) => {
|
|
26
|
-
const { orchestrators } = getContext();
|
|
27
|
-
const orch = orchestrators.get(projectId);
|
|
28
|
-
if (!orch) return null;
|
|
29
|
-
return orch.stateManager.getState();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
ipcMain.handle('get-tasks', (event, projectId) => {
|
|
33
|
-
const { orchestrators } = getContext();
|
|
34
|
-
const orch = orchestrators.get(projectId);
|
|
35
|
-
if (!orch) return [];
|
|
36
|
-
return orch.getTasks();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
ipcMain.handle('get-task', (event, projectId, taskId) => {
|
|
40
|
-
const { orchestrators } = getContext();
|
|
41
|
-
const orch = orchestrators.get(projectId);
|
|
42
|
-
if (!orch) return null;
|
|
43
|
-
return orch.getTask(taskId);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
ipcMain.handle('create-task', (event, projectId, taskData) => {
|
|
47
|
-
const { orchestrators } = getContext();
|
|
48
|
-
const orch = orchestrators.get(projectId);
|
|
49
|
-
if (!orch) return null;
|
|
50
|
-
return orch.createTask(taskData);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
ipcMain.handle('retry-task', (event, projectId, taskId) => {
|
|
54
|
-
const { orchestrators } = getContext();
|
|
55
|
-
const orch = orchestrators.get(projectId);
|
|
56
|
-
if (!orch) return null;
|
|
57
|
-
return orch.updateTask(taskId, { status: 'ready' });
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
ipcMain.handle('get-task-logs', (event, projectId, taskId) => {
|
|
61
|
-
const { paths } = getContext();
|
|
62
|
-
const logPath = join(paths.forProject(projectId).logsDir, `${taskId}.log`);
|
|
63
|
-
if (!existsSync(logPath)) return { lines: [] };
|
|
64
|
-
const content = readFileSync(logPath, 'utf-8');
|
|
65
|
-
return { lines: content.split('\n').slice(-200) };
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
ipcMain.handle('get-config', () => {
|
|
69
|
-
const { config } = getContext();
|
|
70
|
-
return config;
|
|
71
|
-
});
|
|
72
|
-
}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
// runner/main/notifications/telegram.js
|
|
2
|
-
import { request } from 'https';
|
|
3
|
-
|
|
4
|
-
export class TelegramNotifier {
|
|
5
|
-
constructor({ bot_token, chat_id, notify_on }) {
|
|
6
|
-
this.botToken = bot_token;
|
|
7
|
-
this.chatId = chat_id;
|
|
8
|
-
this.notifyOn = new Set(notify_on || ['task_complete', 'task_failed']);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
async onTaskComplete(taskId, taskName, summary) {
|
|
12
|
-
if (!this.notifyOn.has('task_complete')) return;
|
|
13
|
-
await this._send(`✅ *Task Complete*\n*${taskName}* (${taskId})\n${summary}`);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
async onTaskFailed(taskId, taskName, stage, error) {
|
|
17
|
-
if (!this.notifyOn.has('task_failed')) return;
|
|
18
|
-
await this._send(`❌ *Task Failed*\n*${taskName}* (${taskId})\nStage: ${stage}\nError: ${error}`);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async onStuck(taskId, taskName, stage) {
|
|
22
|
-
if (!this.notifyOn.has('task_stuck')) return;
|
|
23
|
-
await this._send(`⚠️ *Agent Stuck*\n*${taskName}* (${taskId})\nStage: ${stage}`);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async onNeedsInput(taskId, taskName, stage, question) {
|
|
27
|
-
if (!this.notifyOn.has('needs_input')) return;
|
|
28
|
-
await this._send(`❓ *Input Needed*\n*${taskName}* (${taskId})\nStage: ${stage}\n${question}`);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
_send(text) {
|
|
32
|
-
return new Promise((resolve) => {
|
|
33
|
-
const data = JSON.stringify({ chat_id: this.chatId, text, parse_mode: 'Markdown' });
|
|
34
|
-
const req = request({
|
|
35
|
-
hostname: 'api.telegram.org',
|
|
36
|
-
path: `/bot${this.botToken}/sendMessage`,
|
|
37
|
-
method: 'POST',
|
|
38
|
-
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
|
39
|
-
}, resolve);
|
|
40
|
-
req.on('error', (err) => { console.error('Telegram error:', err.message); resolve(); });
|
|
41
|
-
req.write(data);
|
|
42
|
-
req.end();
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
}
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
// runner/main/orchestrator.js
|
|
2
|
-
import { readFileSync, readdirSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
3
|
-
import { join } from 'path';
|
|
4
|
-
import { EventEmitter } from 'events';
|
|
5
|
-
import { StateManager } from './state/manager.js';
|
|
6
|
-
import { TemplateManager } from './template-manager.js';
|
|
7
|
-
import { AgentRunner } from './agent/runner.js';
|
|
8
|
-
import { StageSequencer } from './pipeline/sequencer.js';
|
|
9
|
-
import { resolvePipeline } from './pipeline/resolver.js';
|
|
10
|
-
import { resolveTemplate, buildTemplateContext } from './agent/template.js';
|
|
11
|
-
import { WorktreeManager } from './worktree/manager.js';
|
|
12
|
-
|
|
13
|
-
export class Orchestrator extends EventEmitter {
|
|
14
|
-
constructor({ projectId, paths, config }) {
|
|
15
|
-
super();
|
|
16
|
-
this.projectId = projectId;
|
|
17
|
-
this.paths = paths;
|
|
18
|
-
this.config = config;
|
|
19
|
-
this.projectPaths = paths.forProject(projectId);
|
|
20
|
-
this.stateManager = new StateManager(this.projectPaths.statusJson);
|
|
21
|
-
this.templateManager = new TemplateManager(paths);
|
|
22
|
-
this.running = false;
|
|
23
|
-
this.runningTasks = new Set();
|
|
24
|
-
this.worktreeManager = null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
init() {
|
|
28
|
-
// Ensure project dirs exist
|
|
29
|
-
mkdirSync(this.projectPaths.tasksDir, { recursive: true });
|
|
30
|
-
mkdirSync(this.projectPaths.logsDir, { recursive: true });
|
|
31
|
-
|
|
32
|
-
// Load project config
|
|
33
|
-
const projectJson = JSON.parse(readFileSync(this.projectPaths.projectJson, 'utf-8'));
|
|
34
|
-
this.projectPath = projectJson.path;
|
|
35
|
-
|
|
36
|
-
// Init worktree manager if parallel
|
|
37
|
-
const maxParallel = this.config.defaults?.max_parallel || 1;
|
|
38
|
-
if (maxParallel > 1 && existsSync(join(this.projectPath, '.git'))) {
|
|
39
|
-
this.worktreeManager = new WorktreeManager(this.projectPath);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Forward state events
|
|
43
|
-
this.stateManager.on('change', (event) => {
|
|
44
|
-
this.emit('change', { ...event, project: this.projectId });
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
getTasks() {
|
|
49
|
-
const tasksDir = this.projectPaths.tasksDir;
|
|
50
|
-
if (!existsSync(tasksDir)) return [];
|
|
51
|
-
return readdirSync(tasksDir)
|
|
52
|
-
.filter(f => f.endsWith('.json'))
|
|
53
|
-
.map(f => JSON.parse(readFileSync(join(tasksDir, f), 'utf-8')));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
getTask(taskId) {
|
|
57
|
-
const taskPath = join(this.projectPaths.tasksDir, `${taskId}.json`);
|
|
58
|
-
if (!existsSync(taskPath)) return null;
|
|
59
|
-
return JSON.parse(readFileSync(taskPath, 'utf-8'));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
createTask(task) {
|
|
63
|
-
mkdirSync(this.projectPaths.tasksDir, { recursive: true });
|
|
64
|
-
const taskPath = join(this.projectPaths.tasksDir, `${task.id}.json`);
|
|
65
|
-
writeFileSync(taskPath, JSON.stringify(task, null, 2));
|
|
66
|
-
return task;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
updateTask(taskId, updates) {
|
|
70
|
-
const task = this.getTask(taskId);
|
|
71
|
-
if (!task) return null;
|
|
72
|
-
const updated = { ...task, ...updates };
|
|
73
|
-
const taskPath = join(this.projectPaths.tasksDir, `${taskId}.json`);
|
|
74
|
-
writeFileSync(taskPath, JSON.stringify(updated, null, 2));
|
|
75
|
-
return updated;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
pickNextTask() {
|
|
79
|
-
const tasks = this.getTasks();
|
|
80
|
-
const completedIds = new Set(
|
|
81
|
-
Object.entries(this.stateManager.getState().tasks)
|
|
82
|
-
.filter(([, t]) => t.status === 'done')
|
|
83
|
-
.map(([id]) => id)
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
return tasks
|
|
87
|
-
.filter(t => t.status === 'ready' && !this.runningTasks.has(t.id))
|
|
88
|
-
.filter(t => (t.depends_on || []).every(dep => completedIds.has(dep)))
|
|
89
|
-
.sort((a, b) => (a.priority || 99) - (b.priority || 99))[0] || null;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
async runTask(task) {
|
|
93
|
-
const agents = this.templateManager.getAgents(this.projectId);
|
|
94
|
-
const pipelines = this.templateManager.getPipelines(this.projectId);
|
|
95
|
-
const pipelineName = task.pipeline || this.config.defaults?.pipeline || 'default';
|
|
96
|
-
const pipeline = resolvePipeline(pipelineName, pipelines, task.stage_overrides, this.config.defaults);
|
|
97
|
-
|
|
98
|
-
this.runningTasks.add(task.id);
|
|
99
|
-
this.stateManager.registerTask(task.id, { name: task.name, pipeline: pipelineName });
|
|
100
|
-
this.stateManager.updateTaskStatus(task.id, 'in_progress');
|
|
101
|
-
this.updateTask(task.id, { status: 'in_progress' });
|
|
102
|
-
|
|
103
|
-
// Determine working directory
|
|
104
|
-
const workingDir = task.working_dir || '.';
|
|
105
|
-
let cwd = join(this.projectPath, workingDir);
|
|
106
|
-
let worktreePath = null;
|
|
107
|
-
|
|
108
|
-
if (this.worktreeManager && existsSync(join(cwd, '.git'))) {
|
|
109
|
-
worktreePath = this.worktreeManager.create(task.id);
|
|
110
|
-
cwd = worktreePath;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const logPath = join(this.projectPaths.logsDir, `${task.id}.log`);
|
|
114
|
-
|
|
115
|
-
const sequencer = new StageSequencer({
|
|
116
|
-
stages: pipeline.stages,
|
|
117
|
-
createRunner: (stage) => {
|
|
118
|
-
const agentDef = agents[stage.agent];
|
|
119
|
-
if (!agentDef) throw new Error(`Agent not found: ${stage.agent}`);
|
|
120
|
-
|
|
121
|
-
const context = buildTemplateContext(task, stage, pipeline, this.projectPaths);
|
|
122
|
-
const resolvedPrompt = resolveTemplate(agentDef.system_prompt || '', context);
|
|
123
|
-
|
|
124
|
-
const runner = new AgentRunner({
|
|
125
|
-
...agentDef,
|
|
126
|
-
system_prompt: resolvedPrompt,
|
|
127
|
-
timeout: stage.timeout,
|
|
128
|
-
stuck_timeout: this.config.defaults?.stuck_timeout,
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
runner.on('status', (s) => this.stateManager.updateLiveProgress(task.id, s));
|
|
132
|
-
runner.on('output', () => this.stateManager.persist());
|
|
133
|
-
|
|
134
|
-
return { run: () => runner.run(task.spec || task.description || task.name, { cwd, logPath }) };
|
|
135
|
-
},
|
|
136
|
-
onStageStart: async (stage, attempt) => {
|
|
137
|
-
this.stateManager.startStage(task.id, { stage: stage.stage, agent: stage.agent });
|
|
138
|
-
this.stateManager.persist();
|
|
139
|
-
},
|
|
140
|
-
onStageComplete: async (stage, result, attempt) => {
|
|
141
|
-
this.stateManager.completeStage(task.id, result.status, result.summary);
|
|
142
|
-
this.stateManager.persist();
|
|
143
|
-
},
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
const result = await sequencer.run();
|
|
147
|
-
|
|
148
|
-
const finalStatus = result.status === 'success' ? 'done' : 'failed';
|
|
149
|
-
this.stateManager.updateTaskStatus(task.id, finalStatus);
|
|
150
|
-
this.stateManager.persist();
|
|
151
|
-
this.updateTask(task.id, { status: finalStatus });
|
|
152
|
-
|
|
153
|
-
// Create PR if worktree has changes
|
|
154
|
-
if (worktreePath && this.worktreeManager.hasChanges(task.id)) {
|
|
155
|
-
const summary = result.results.map(r => `- ${r.stage}: ${r.summary}`).join('\n');
|
|
156
|
-
const prUrl = this.worktreeManager.createPR(task.id, task.name, summary);
|
|
157
|
-
if (prUrl) this.stateManager.setPrUrl(task.id, prUrl);
|
|
158
|
-
this.stateManager.persist();
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Cleanup worktree
|
|
162
|
-
if (worktreePath) {
|
|
163
|
-
this.worktreeManager.remove(task.id);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
this.runningTasks.delete(task.id);
|
|
167
|
-
return result;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
async startLoop(interval = 5000) {
|
|
171
|
-
this.running = true;
|
|
172
|
-
|
|
173
|
-
while (this.running) {
|
|
174
|
-
const maxParallel = this.config.defaults?.max_parallel || 1;
|
|
175
|
-
const available = maxParallel - this.runningTasks.size;
|
|
176
|
-
|
|
177
|
-
for (let i = 0; i < available; i++) {
|
|
178
|
-
const task = this.pickNextTask();
|
|
179
|
-
if (!task) break;
|
|
180
|
-
// Fire and forget — runs in parallel
|
|
181
|
-
this.runTask(task).catch(err => {
|
|
182
|
-
console.error(`Task ${task.id} failed:`, err);
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
await new Promise(r => setTimeout(r, interval));
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
stop() {
|
|
191
|
-
this.running = false;
|
|
192
|
-
}
|
|
193
|
-
}
|
package/runner/main/paths.js
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { join } from 'path';
|
|
2
|
-
import { homedir, platform } from 'os';
|
|
3
|
-
|
|
4
|
-
export class Paths {
|
|
5
|
-
constructor(root) {
|
|
6
|
-
this.root = root || Paths.defaultRoot();
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
static defaultRoot() {
|
|
10
|
-
if (platform() === 'darwin') {
|
|
11
|
-
return join(homedir(), 'Library', 'Application Support', 'Specd');
|
|
12
|
-
}
|
|
13
|
-
return join(homedir(), '.config', 'specd');
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
get db() { return join(this.root, 'db.json'); }
|
|
17
|
-
get config() { return join(this.root, 'config.json'); }
|
|
18
|
-
get templatesDir() { return join(this.root, 'templates'); }
|
|
19
|
-
get agentTemplatesDir() { return join(this.root, 'templates', 'agents'); }
|
|
20
|
-
get pipelineTemplatesDir() { return join(this.root, 'templates', 'pipelines'); }
|
|
21
|
-
get projectsDir() { return join(this.root, 'projects'); }
|
|
22
|
-
get electronDir() { return join(this.root, 'electron'); }
|
|
23
|
-
|
|
24
|
-
forProject(projectId) {
|
|
25
|
-
const dir = join(this.root, 'projects', projectId);
|
|
26
|
-
return {
|
|
27
|
-
dir,
|
|
28
|
-
projectJson: join(dir, 'project.json'),
|
|
29
|
-
statusJson: join(dir, 'status.json'),
|
|
30
|
-
tasksDir: join(dir, 'tasks'),
|
|
31
|
-
logsDir: join(dir, 'logs'),
|
|
32
|
-
agentsDir: join(dir, 'agents'),
|
|
33
|
-
pipelinesDir: join(dir, 'pipelines'),
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
// runner/main/pipeline/resolver.js
|
|
2
|
-
export function resolvePipeline(pipelineName, pipelines, stageOverrides, defaults) {
|
|
3
|
-
const pipeline = pipelines[pipelineName];
|
|
4
|
-
if (!pipeline) throw new Error(`Pipeline not found: ${pipelineName}`);
|
|
5
|
-
|
|
6
|
-
const stages = pipeline.stages.map((stage, index) => {
|
|
7
|
-
const override = stageOverrides?.[stage.stage] || {};
|
|
8
|
-
return {
|
|
9
|
-
...stage,
|
|
10
|
-
...override,
|
|
11
|
-
timeout: stage.timeout || defaults?.timeout || 3600,
|
|
12
|
-
on_fail: stage.on_fail || defaults?.failure_policy || 'skip',
|
|
13
|
-
max_retries: stage.max_retries || 0,
|
|
14
|
-
index: index + 1,
|
|
15
|
-
total: pipeline.stages.length,
|
|
16
|
-
};
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
return { name: pipelineName, stages };
|
|
20
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
// runner/main/pipeline/sequencer.js
|
|
2
|
-
export class StageSequencer {
|
|
3
|
-
constructor({ stages, createRunner, onStageStart, onStageComplete }) {
|
|
4
|
-
this.stages = stages;
|
|
5
|
-
this.createRunner = createRunner;
|
|
6
|
-
this.onStageStart = onStageStart;
|
|
7
|
-
this.onStageComplete = onStageComplete;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
async run() {
|
|
11
|
-
const results = [];
|
|
12
|
-
|
|
13
|
-
for (const stage of this.stages) {
|
|
14
|
-
const maxAttempts = 1 + (stage.max_retries || 0);
|
|
15
|
-
let stageResult = null;
|
|
16
|
-
|
|
17
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
18
|
-
const runner = this.createRunner(stage);
|
|
19
|
-
await this.onStageStart(stage, attempt);
|
|
20
|
-
|
|
21
|
-
try {
|
|
22
|
-
stageResult = await runner.run('');
|
|
23
|
-
} catch (err) {
|
|
24
|
-
stageResult = { status: 'failure', summary: err.message };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
await this.onStageComplete(stage, stageResult, attempt);
|
|
28
|
-
|
|
29
|
-
if (stageResult.status === 'success') break;
|
|
30
|
-
if (stageResult.status === 'failure' && stage.on_fail !== 'retry') break;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
results.push({ stage: stage.stage, ...stageResult });
|
|
34
|
-
|
|
35
|
-
if (stageResult.status !== 'success' && stage.critical) {
|
|
36
|
-
return { status: 'failure', results, failedStage: stage.stage };
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return { status: 'success', results };
|
|
41
|
-
}
|
|
42
|
-
}
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
// runner/main/server/api.js
|
|
2
|
-
import { Router } from 'express';
|
|
3
|
-
import { readFileSync, existsSync } from 'fs';
|
|
4
|
-
import { join } from 'path';
|
|
5
|
-
import { randomUUID } from 'crypto';
|
|
6
|
-
|
|
7
|
-
export function createApiRouter(getContext) {
|
|
8
|
-
const router = Router();
|
|
9
|
-
|
|
10
|
-
// Find project by path (for skill auto-detection) — must be before :id routes
|
|
11
|
-
router.get('/projects/by-path', (req, res) => {
|
|
12
|
-
const { db } = getContext();
|
|
13
|
-
const folderPath = req.query.path;
|
|
14
|
-
if (!folderPath) return res.status(400).json({ error: 'path query param required' });
|
|
15
|
-
const project = db.findByPath(folderPath);
|
|
16
|
-
if (!project) return res.status(404).json({ error: 'No project matches this path' });
|
|
17
|
-
res.json(project);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
// List projects
|
|
21
|
-
router.get('/projects', (req, res) => {
|
|
22
|
-
const { db, orchestrators } = getContext();
|
|
23
|
-
const projects = db.list().map(p => {
|
|
24
|
-
const orch = orchestrators.get(p.id);
|
|
25
|
-
const tasks = orch ? orch.getTasks() : [];
|
|
26
|
-
return {
|
|
27
|
-
...p,
|
|
28
|
-
taskCounts: {
|
|
29
|
-
total: tasks.length,
|
|
30
|
-
ready: tasks.filter(t => t.status === 'ready').length,
|
|
31
|
-
running: tasks.filter(t => t.status === 'in_progress').length,
|
|
32
|
-
done: tasks.filter(t => t.status === 'done').length,
|
|
33
|
-
failed: tasks.filter(t => t.status === 'failed').length,
|
|
34
|
-
},
|
|
35
|
-
};
|
|
36
|
-
});
|
|
37
|
-
res.json(projects);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
// Global status
|
|
41
|
-
router.get('/status', (req, res) => {
|
|
42
|
-
const { orchestrators } = getContext();
|
|
43
|
-
const status = {};
|
|
44
|
-
for (const [id, orch] of orchestrators) {
|
|
45
|
-
status[id] = orch.stateManager.getState();
|
|
46
|
-
}
|
|
47
|
-
res.json(status);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
// Project status
|
|
51
|
-
router.get('/projects/:id/status', (req, res) => {
|
|
52
|
-
const { orchestrators } = getContext();
|
|
53
|
-
const orch = orchestrators.get(req.params.id);
|
|
54
|
-
if (!orch) return res.status(404).json({ error: 'Project not found' });
|
|
55
|
-
res.json(orch.stateManager.getState());
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// List tasks
|
|
59
|
-
router.get('/projects/:id/tasks', (req, res) => {
|
|
60
|
-
const { orchestrators } = getContext();
|
|
61
|
-
const orch = orchestrators.get(req.params.id);
|
|
62
|
-
if (!orch) return res.status(404).json({ error: 'Project not found' });
|
|
63
|
-
res.json(orch.getTasks());
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
// Get task
|
|
67
|
-
router.get('/projects/:id/tasks/:taskId', (req, res) => {
|
|
68
|
-
const { orchestrators } = getContext();
|
|
69
|
-
const orch = orchestrators.get(req.params.id);
|
|
70
|
-
if (!orch) return res.status(404).json({ error: 'Project not found' });
|
|
71
|
-
const task = orch.getTask(req.params.taskId);
|
|
72
|
-
if (!task) return res.status(404).json({ error: 'Task not found' });
|
|
73
|
-
res.json(task);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// Create task
|
|
77
|
-
router.post('/projects/:id/tasks', (req, res) => {
|
|
78
|
-
const { orchestrators } = getContext();
|
|
79
|
-
const orch = orchestrators.get(req.params.id);
|
|
80
|
-
if (!orch) return res.status(404).json({ error: 'Project not found' });
|
|
81
|
-
if (!req.body.name) return res.status(400).json({ error: 'name is required' });
|
|
82
|
-
|
|
83
|
-
const task = {
|
|
84
|
-
id: req.body.id || `task-${randomUUID().slice(0, 6)}`,
|
|
85
|
-
name: req.body.name,
|
|
86
|
-
description: req.body.description || '',
|
|
87
|
-
project_id: req.params.id,
|
|
88
|
-
working_dir: req.body.working_dir || '.',
|
|
89
|
-
pipeline: req.body.pipeline || 'default',
|
|
90
|
-
status: req.body.status || 'ready',
|
|
91
|
-
priority: req.body.priority || 10,
|
|
92
|
-
depends_on: req.body.depends_on || [],
|
|
93
|
-
spec: req.body.spec || '',
|
|
94
|
-
created_at: new Date().toISOString(),
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
orch.createTask(task);
|
|
98
|
-
res.status(201).json(task);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
// Retry task
|
|
102
|
-
router.post('/projects/:id/tasks/:taskId/retry', (req, res) => {
|
|
103
|
-
const { orchestrators } = getContext();
|
|
104
|
-
const orch = orchestrators.get(req.params.id);
|
|
105
|
-
if (!orch) return res.status(404).json({ error: 'Project not found' });
|
|
106
|
-
|
|
107
|
-
const task = orch.updateTask(req.params.taskId, { status: 'ready' });
|
|
108
|
-
if (!task) return res.status(404).json({ error: 'Task not found' });
|
|
109
|
-
res.json(task);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
// Task logs
|
|
113
|
-
router.get('/projects/:id/tasks/:taskId/logs', (req, res) => {
|
|
114
|
-
const { paths } = getContext();
|
|
115
|
-
const logPath = join(paths.forProject(req.params.id).logsDir, `${req.params.taskId}.log`);
|
|
116
|
-
if (!existsSync(logPath)) return res.status(404).json({ error: 'Log not found' });
|
|
117
|
-
|
|
118
|
-
const content = readFileSync(logPath, 'utf-8');
|
|
119
|
-
const lines = content.split('\n');
|
|
120
|
-
const tail = parseInt(req.query.tail) || 200;
|
|
121
|
-
res.json({ lines: lines.slice(-tail) });
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
return router;
|
|
125
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
// runner/main/server/index.js
|
|
2
|
-
import express from 'express';
|
|
3
|
-
import { createServer as createHttpServer } from 'http';
|
|
4
|
-
import { createApiRouter } from './api.js';
|
|
5
|
-
import { WsBroadcaster } from './websocket.js';
|
|
6
|
-
|
|
7
|
-
export function createServer(getContext) {
|
|
8
|
-
const app = express();
|
|
9
|
-
app.use(express.json());
|
|
10
|
-
app.use('/api', createApiRouter(getContext));
|
|
11
|
-
|
|
12
|
-
const httpServer = createHttpServer(app);
|
|
13
|
-
const broadcaster = new WsBroadcaster(httpServer);
|
|
14
|
-
|
|
15
|
-
return {
|
|
16
|
-
start(port) {
|
|
17
|
-
return new Promise((resolve) => {
|
|
18
|
-
httpServer.listen(port, '127.0.0.1', () => {
|
|
19
|
-
console.log(`Specd API server on http://127.0.0.1:${port}`);
|
|
20
|
-
resolve();
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
},
|
|
24
|
-
stop() {
|
|
25
|
-
broadcaster.close();
|
|
26
|
-
httpServer.close();
|
|
27
|
-
},
|
|
28
|
-
broadcaster,
|
|
29
|
-
wireOrchestrator(orch) {
|
|
30
|
-
orch.on('change', (event) => broadcaster.broadcast(event));
|
|
31
|
-
},
|
|
32
|
-
};
|
|
33
|
-
}
|