ai-control-center 1.15.2
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/LICENSE +21 -0
- package/README.md +584 -0
- package/bin/aicc.js +772 -0
- package/lib/actions/approve.js +71 -0
- package/lib/actions/assign-project.js +132 -0
- package/lib/actions/browser-test.js +64 -0
- package/lib/actions/cleanup.js +174 -0
- package/lib/actions/debug.js +298 -0
- package/lib/actions/deploy.js +1229 -0
- package/lib/actions/fix-bug.js +134 -0
- package/lib/actions/new-feature.js +255 -0
- package/lib/actions/reject.js +307 -0
- package/lib/actions/review.js +706 -0
- package/lib/actions/status.js +47 -0
- package/lib/agents/browser-qa-agent.js +611 -0
- package/lib/agents/payment-agent.js +116 -0
- package/lib/agents/suggestion-agent.js +88 -0
- package/lib/cli.js +303 -0
- package/lib/config.js +243 -0
- package/lib/hub/hub-server.js +440 -0
- package/lib/hub/project-poller.js +75 -0
- package/lib/hub/skill-registry.js +89 -0
- package/lib/hub/state-aggregator.js +204 -0
- package/lib/index.js +471 -0
- package/lib/init/doctor.js +523 -0
- package/lib/init/presets.js +222 -0
- package/lib/init/skill-fetcher.js +77 -0
- package/lib/init/wizard.js +973 -0
- package/lib/integrations/codex-runner.js +128 -0
- package/lib/integrations/github-actions.js +248 -0
- package/lib/integrations/github-reporter.js +229 -0
- package/lib/integrations/screenshot-store.js +102 -0
- package/lib/openclaw/bridge.js +650 -0
- package/lib/openclaw/generate-skill.js +235 -0
- package/lib/openclaw/openclaw.json +64 -0
- package/lib/orchestrator/autonomous-loop.js +429 -0
- package/lib/orchestrator/thread-triggers.js +63 -0
- package/lib/roleplay/agent-messenger.js +75 -0
- package/lib/roleplay/discussion-threads.js +303 -0
- package/lib/roleplay/health-monitor.js +121 -0
- package/lib/roleplay/pm-agent.js +513 -0
- package/lib/roleplay/roleplay-config.js +25 -0
- package/lib/roleplay/room.js +164 -0
- package/lib/shared/action-runner.js +2330 -0
- package/lib/shared/event-bus.js +185 -0
- package/lib/slack/bot.js +378 -0
- package/lib/telegram/bot.js +416 -0
- package/lib/telegram/commands.js +1267 -0
- package/lib/telegram/keyboards.js +113 -0
- package/lib/telegram/notifications.js +247 -0
- package/lib/twitch/bot.js +354 -0
- package/lib/twitch/commands.js +302 -0
- package/lib/twitch/notifications.js +63 -0
- package/lib/utils/achievements.js +191 -0
- package/lib/utils/activity-log.js +182 -0
- package/lib/utils/agent-leaderboard.js +119 -0
- package/lib/utils/audit-logger.js +232 -0
- package/lib/utils/codebase-context.js +288 -0
- package/lib/utils/codebase-indexer.js +381 -0
- package/lib/utils/config-schema.js +230 -0
- package/lib/utils/context-compressor.js +172 -0
- package/lib/utils/correlation.js +63 -0
- package/lib/utils/cost-tracker.js +423 -0
- package/lib/utils/cron-scheduler.js +53 -0
- package/lib/utils/db-adapter.js +293 -0
- package/lib/utils/display.js +272 -0
- package/lib/utils/errors.js +116 -0
- package/lib/utils/format.js +134 -0
- package/lib/utils/intent-engine.js +464 -0
- package/lib/utils/mcp-client.js +238 -0
- package/lib/utils/model-ab-test.js +164 -0
- package/lib/utils/notify.js +122 -0
- package/lib/utils/persona-loader.js +80 -0
- package/lib/utils/pipeline-lock.js +73 -0
- package/lib/utils/pipeline.js +214 -0
- package/lib/utils/plugin-runner.js +234 -0
- package/lib/utils/rate-limiter.js +84 -0
- package/lib/utils/rbac.js +74 -0
- package/lib/utils/runner.js +1809 -0
- package/lib/utils/security.js +191 -0
- package/lib/utils/self-healer.js +144 -0
- package/lib/utils/skill-loader.js +255 -0
- package/lib/utils/spinner.js +132 -0
- package/lib/utils/stage-queue.js +50 -0
- package/lib/utils/state-machine.js +89 -0
- package/lib/utils/status-bar.js +327 -0
- package/lib/utils/token-estimator.js +101 -0
- package/lib/utils/ux-analyzer.js +101 -0
- package/lib/utils/webhook-emitter.js +83 -0
- package/lib/web/public/css/styles.css +417 -0
- package/lib/web/public/dark-mode.js +44 -0
- package/lib/web/public/hub/kanban.html +206 -0
- package/lib/web/public/index.html +45 -0
- package/lib/web/public/js/app.js +71 -0
- package/lib/web/public/js/ask.js +110 -0
- package/lib/web/public/js/dashboard.js +165 -0
- package/lib/web/public/js/deploy.js +72 -0
- package/lib/web/public/js/feature.js +79 -0
- package/lib/web/public/js/health.js +65 -0
- package/lib/web/public/js/logs.js +93 -0
- package/lib/web/public/js/review.js +123 -0
- package/lib/web/public/js/ws-client.js +82 -0
- package/lib/web/public/office/css/office.css +678 -0
- package/lib/web/public/office/index.html +148 -0
- package/lib/web/public/office/js/achievements-ui.js +117 -0
- package/lib/web/public/office/js/character.js +1056 -0
- package/lib/web/public/office/js/chat-bubbles.js +177 -0
- package/lib/web/public/office/js/cost-overlay.js +123 -0
- package/lib/web/public/office/js/day-night.js +68 -0
- package/lib/web/public/office/js/effects.js +632 -0
- package/lib/web/public/office/js/engine.js +146 -0
- package/lib/web/public/office/js/feature-ticket.js +216 -0
- package/lib/web/public/office/js/hub-client.js +60 -0
- package/lib/web/public/office/js/main.js +1757 -0
- package/lib/web/public/office/js/office-layout.js +1524 -0
- package/lib/web/public/office/js/pathfinding.js +144 -0
- package/lib/web/public/office/js/pixel-sprites.js +1454 -0
- package/lib/web/public/office/js/progress-bars.js +117 -0
- package/lib/web/public/office/js/replay.js +191 -0
- package/lib/web/public/office/js/sound-effects.js +91 -0
- package/lib/web/public/office/js/sprite-renderer.js +211 -0
- package/lib/web/public/office/js/stamina-system.js +89 -0
- package/lib/web/public/office/js/ui.js +107 -0
- package/lib/web/public/onboarding/index.html +243 -0
- package/lib/web/public/timeline/index.html +195 -0
- package/lib/web/routes/api.js +499 -0
- package/lib/web/routes/logs.js +20 -0
- package/lib/web/routes/metrics.js +99 -0
- package/lib/web/server.js +183 -0
- package/lib/web/ws/handler.js +65 -0
- package/package.json +67 -0
- package/templates/agent-architect.md +69 -0
- package/templates/agent-gemini-pm.md +49 -0
- package/templates/agent-gemini-reviewer.md +52 -0
- package/templates/copilot-instructions.md +36 -0
- package/templates/pipelines/mobile.json +27 -0
- package/templates/pipelines/nodejs-api.json +27 -0
- package/templates/pipelines/python.json +27 -0
- package/templates/pipelines/react.json +27 -0
- package/templates/pipelines/salesforce.json +27 -0
- package/templates/role-gemini.md +97 -0
- package/templates/skill-architect.md +114 -0
- package/templates/skill-browser-qa.md +50 -0
- package/templates/skill-bug-from-qa.md +58 -0
- package/templates/skill-chatbot.md +93 -0
- package/templates/skill-implement.md +78 -0
- package/templates/skill-openclaw.md +174 -0
- package/templates/skill-payment.md +110 -0
- package/templates/skill-pm-spec.md +77 -0
- package/templates/skill-requirement-capture.md +97 -0
- package/templates/skill-review.md +108 -0
- package/templates/skill-reviewer-qa.md +44 -0
- package/templates/skill-suggestion.md +45 -0
- package/templates/skill-template.md +142 -0
package/lib/config.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config loader for ai-control-center.
|
|
3
|
+
*
|
|
4
|
+
* Finds and loads `aicc.config.js` from the project root.
|
|
5
|
+
* Provides a singleton via getConfig() and env-var helper via env().
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from 'fs';
|
|
8
|
+
import { dirname, resolve } from 'path';
|
|
9
|
+
import { pathToFileURL } from 'url';
|
|
10
|
+
|
|
11
|
+
let _config = null;
|
|
12
|
+
|
|
13
|
+
const DEFAULTS = {
|
|
14
|
+
workflowDir: '.ai-workflow',
|
|
15
|
+
skillsDir: '.claude/skills',
|
|
16
|
+
envPrefix: 'AICC',
|
|
17
|
+
stages: {
|
|
18
|
+
idle: { label: 'Idle', color: 'gray' },
|
|
19
|
+
inbox: { label: 'Feature Submitted', color: 'yellow' },
|
|
20
|
+
spec_complete: { label: 'PM Spec Ready', color: 'cyan' },
|
|
21
|
+
arch_complete: { label: 'Arch Ready', color: 'blue' },
|
|
22
|
+
implementation_complete: { label: 'Code Done', color: 'magenta' },
|
|
23
|
+
review_complete: { label: 'Review Ready', color: 'green' },
|
|
24
|
+
approved: { label: 'Approved', color: 'green' },
|
|
25
|
+
rejected: { label: 'Rejected', color: 'red' },
|
|
26
|
+
deployed: { label: 'Deployed', color: 'green' },
|
|
27
|
+
},
|
|
28
|
+
deploy: { command: null, options: [] },
|
|
29
|
+
review: { extensions: ['.js', '.ts', '.py', '.go'], sourceDir: 'src/' },
|
|
30
|
+
ai: { system: 'You are a concise DevOps assistant.', roleFile: null },
|
|
31
|
+
feature: { placeholder: 'Describe the feature you want to build...' },
|
|
32
|
+
queries: [],
|
|
33
|
+
debug: { commands: [] },
|
|
34
|
+
web: { port: 3847 },
|
|
35
|
+
plugins: [],
|
|
36
|
+
|
|
37
|
+
// ─── Pipeline — Stage × AI Model Configuration ──────────────────────────
|
|
38
|
+
// Each entry defines ONE stage: which AI provider runs it and which model.
|
|
39
|
+
// To switch a model, just change the `model` value for that stage.
|
|
40
|
+
//
|
|
41
|
+
// provider: 'openclaw' | 'copilot' | 'claude' | 'gemini' | 'ollama'
|
|
42
|
+
// model: the AI model name (copilot uses period format, claude uses hyphen)
|
|
43
|
+
// fallbacks: ordered list of backup 'provider:model' pairs (optional)
|
|
44
|
+
//
|
|
45
|
+
// Env var overrides (applied at runtime, take precedence over config):
|
|
46
|
+
// GEMINI_MODEL, CLAUDE_MODEL, COPILOT_MODEL, OLLAMA_MODEL
|
|
47
|
+
pipeline: [
|
|
48
|
+
{ stage: 'chat', provider: 'claude', model: 'claude-haiku-4-5', fallbacks: ['gemini:gemini-2.5-flash', 'copilot:claude-haiku-4.5', 'ollama'] },
|
|
49
|
+
{ stage: 'pm', provider: 'copilot', model: 'claude-sonnet-4.6', fallbacks: ['gemini:gemini-2.5-pro', 'ollama'] },
|
|
50
|
+
{ stage: 'architect', provider: 'copilot', model: 'claude-sonnet-4.6', fallbacks: ['gemini:gemini-2.5-pro', 'ollama'] },
|
|
51
|
+
{ stage: 'implement', provider: 'claude', model: 'claude-sonnet-4-6', fallbacks: ['copilot:claude-sonnet-4.6', 'ollama'] },
|
|
52
|
+
{ stage: 'review', provider: 'copilot', model: 'claude-sonnet-4.6', fallbacks: ['gemini:gemini-2.5-pro', 'ollama'] },
|
|
53
|
+
{ stage: 'deploy', provider: 'copilot', model: 'claude-sonnet-4.6', fallbacks: ['gemini:gemini-2.5-pro', 'ollama'] },
|
|
54
|
+
],
|
|
55
|
+
|
|
56
|
+
// Ollama local fallback settings
|
|
57
|
+
ollama: { model: 'llama3.1', baseUrl: 'http://localhost:11434' },
|
|
58
|
+
|
|
59
|
+
roleplay: {
|
|
60
|
+
enabled: true,
|
|
61
|
+
verbosity: 'normal',
|
|
62
|
+
pmPersonality: 'professional',
|
|
63
|
+
agentChat: true,
|
|
64
|
+
progressUpdates: true,
|
|
65
|
+
progressInterval: 60000,
|
|
66
|
+
maxReviewCycles: 3,
|
|
67
|
+
autoDeployOnApproval: false,
|
|
68
|
+
openclawBridge: true, // Enable OpenClaw bridge for multi-channel communication
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Walk up from `startDir` to find aicc.config.js.
|
|
74
|
+
*/
|
|
75
|
+
function findProjectRoot(startDir) {
|
|
76
|
+
let dir = startDir;
|
|
77
|
+
while (true) {
|
|
78
|
+
if (existsSync(resolve(dir, 'aicc.config.js'))) return dir;
|
|
79
|
+
const parent = dirname(dir);
|
|
80
|
+
if (parent === dir) return null; // reached filesystem root
|
|
81
|
+
dir = parent;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Load the project config. Must be called once before getConfig().
|
|
87
|
+
*/
|
|
88
|
+
export async function loadConfig(cwd) {
|
|
89
|
+
if (_config) return _config;
|
|
90
|
+
|
|
91
|
+
const root = findProjectRoot(cwd || process.cwd());
|
|
92
|
+
if (!root) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
'No aicc.config.js found.\n' +
|
|
95
|
+
'Run "aicc init" to create one, or ensure you\'re in the project directory.'
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Load .env file if present (does NOT override existing env vars)
|
|
100
|
+
const envFilePath = resolve(root, '.env');
|
|
101
|
+
if (existsSync(envFilePath)) {
|
|
102
|
+
try {
|
|
103
|
+
const envContent = readFileSync(envFilePath, 'utf8');
|
|
104
|
+
for (const line of envContent.split('\n')) {
|
|
105
|
+
const trimmed = line.trim();
|
|
106
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
107
|
+
const eqIdx = trimmed.indexOf('=');
|
|
108
|
+
if (eqIdx === -1) continue;
|
|
109
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
110
|
+
const val = trimmed.slice(eqIdx + 1).trim();
|
|
111
|
+
if (key && !(key in process.env)) {
|
|
112
|
+
process.env[key] = val;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch { /* ignore .env read errors */ }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const configPath = resolve(root, 'aicc.config.js');
|
|
119
|
+
const mod = await import(pathToFileURL(configPath).href);
|
|
120
|
+
const userConfig = mod.default || mod;
|
|
121
|
+
|
|
122
|
+
// ── Backward compat: convert old `models` config → new `pipeline` format ──
|
|
123
|
+
let pipeline = userConfig.pipeline || null;
|
|
124
|
+
if (!pipeline && userConfig.models) {
|
|
125
|
+
pipeline = _migrateModelsToPipeline(userConfig.models);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Merge with defaults
|
|
129
|
+
_config = {
|
|
130
|
+
...DEFAULTS,
|
|
131
|
+
...userConfig,
|
|
132
|
+
stages: { ...DEFAULTS.stages, ...(userConfig.stages || {}) },
|
|
133
|
+
deploy: { ...DEFAULTS.deploy, ...(userConfig.deploy || {}) },
|
|
134
|
+
review: { ...DEFAULTS.review, ...(userConfig.review || {}) },
|
|
135
|
+
ai: { ...DEFAULTS.ai, ...(userConfig.ai || {}) },
|
|
136
|
+
feature: { ...DEFAULTS.feature, ...(userConfig.feature || {}) },
|
|
137
|
+
debug: { ...DEFAULTS.debug, ...(userConfig.debug || {}) },
|
|
138
|
+
web: { ...DEFAULTS.web, ...(userConfig.web || {}) },
|
|
139
|
+
pipeline: pipeline || DEFAULTS.pipeline,
|
|
140
|
+
ollama: { ...DEFAULTS.ollama, ...(userConfig.ollama || {}) },
|
|
141
|
+
roleplay: { ...DEFAULTS.roleplay, ...(userConfig.roleplay || {}) },
|
|
142
|
+
// Resolved absolute paths
|
|
143
|
+
_root: root,
|
|
144
|
+
_workflowDir: resolve(root, userConfig.workflowDir || DEFAULTS.workflowDir),
|
|
145
|
+
_statusFile: resolve(root, userConfig.workflowDir || DEFAULTS.workflowDir, 'status.json'),
|
|
146
|
+
_configPath: configPath,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return _config;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get the loaded config. Throws if loadConfig() hasn't been called.
|
|
154
|
+
*/
|
|
155
|
+
export function getConfig() {
|
|
156
|
+
if (!_config) {
|
|
157
|
+
throw new Error('Config not loaded. Call loadConfig() first.');
|
|
158
|
+
}
|
|
159
|
+
return _config;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get an environment variable using the configured prefix.
|
|
164
|
+
* env('TELEGRAM_TOKEN') → process.env.XCONN_TELEGRAM_TOKEN (if envPrefix is 'XCONN')
|
|
165
|
+
* Falls back to AICC_ prefix.
|
|
166
|
+
*/
|
|
167
|
+
export function env(suffix) {
|
|
168
|
+
const prefix = _config ? _config.envPrefix : 'AICC';
|
|
169
|
+
return process.env[`${prefix}_${suffix}`] || process.env[`AICC_${suffix}`];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get the pipeline entry for a given stage.
|
|
174
|
+
* Returns { stage, provider, model, fallbacks } or a sensible default.
|
|
175
|
+
*/
|
|
176
|
+
export function getPipelineStage(stage) {
|
|
177
|
+
const cfg = _config || DEFAULTS;
|
|
178
|
+
const pipeline = cfg.pipeline || DEFAULTS.pipeline;
|
|
179
|
+
const entry = pipeline.find(e => e.stage === stage);
|
|
180
|
+
if (entry) return entry;
|
|
181
|
+
// Fallback: use 'default' entry or first non-chat entry
|
|
182
|
+
const defaultEntry = pipeline.find(e => e.stage === 'default');
|
|
183
|
+
if (defaultEntry) return { ...defaultEntry, stage };
|
|
184
|
+
return { stage, provider: 'copilot', model: 'claude-sonnet-4.6', fallbacks: ['gemini:gemini-2.5-pro', 'ollama'] };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get the Ollama config.
|
|
189
|
+
*/
|
|
190
|
+
export function getOllamaConfig() {
|
|
191
|
+
const cfg = _config || DEFAULTS;
|
|
192
|
+
return cfg.ollama || DEFAULTS.ollama;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Backward compat: convert old `models` config to new `pipeline[]` format.
|
|
197
|
+
*/
|
|
198
|
+
function _migrateModelsToPipeline(models) {
|
|
199
|
+
const sp = models.stageProviders || {};
|
|
200
|
+
const chatModel = models.chatModel || 'claude-haiku-4.5';
|
|
201
|
+
const crossModel = models.copilotCrossAiModel || 'claude-sonnet-4.6';
|
|
202
|
+
const claudeModel = (models.claudeChain || ['claude-sonnet-4-6'])[0];
|
|
203
|
+
const copilotModel = (models.copilotChain || ['claude-sonnet-4.6'])[0];
|
|
204
|
+
const geminiModel = (models.geminiChain || ['gemini-2.5-pro'])[0];
|
|
205
|
+
|
|
206
|
+
function buildEntry(stage) {
|
|
207
|
+
const providers = sp[stage] || sp.default || ['copilot', 'gemini', 'ollama'];
|
|
208
|
+
const primary = providers[0];
|
|
209
|
+
let model;
|
|
210
|
+
switch (primary) {
|
|
211
|
+
case 'claude': model = claudeModel; break;
|
|
212
|
+
case 'gemini': model = (models.stages?.[stage]?.geminiChain || [geminiModel])[0]; break;
|
|
213
|
+
case 'copilot': model = stage === 'implement' ? copilotModel : crossModel; break;
|
|
214
|
+
default: model = models.ollamaModel || 'llama3.1';
|
|
215
|
+
}
|
|
216
|
+
const fallbacks = providers.slice(1).map(p => {
|
|
217
|
+
switch (p) {
|
|
218
|
+
case 'gemini': return `gemini:${geminiModel}`;
|
|
219
|
+
case 'claude': return `claude:${claudeModel}`;
|
|
220
|
+
case 'copilot': return `copilot:${copilotModel}`;
|
|
221
|
+
case 'ollama': return 'ollama';
|
|
222
|
+
default: return p;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
return { stage, provider: primary, model, fallbacks };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return [
|
|
229
|
+
{ stage: 'chat', provider: 'copilot', model: chatModel },
|
|
230
|
+
buildEntry('pm'),
|
|
231
|
+
buildEntry('architect'),
|
|
232
|
+
buildEntry('implement'),
|
|
233
|
+
buildEntry('review'),
|
|
234
|
+
buildEntry('deploy'),
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Reset config (for testing).
|
|
240
|
+
*/
|
|
241
|
+
export function resetConfig() {
|
|
242
|
+
_config = null;
|
|
243
|
+
}
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub Server — Multi-project aggregator for AI Control Center.
|
|
3
|
+
*
|
|
4
|
+
* Polls each registered project's /api/status and /api/health,
|
|
5
|
+
* aggregates state, and broadcasts changes via WebSocket.
|
|
6
|
+
*
|
|
7
|
+
* Uses native http.createServer with simple request routing (no Express).
|
|
8
|
+
*
|
|
9
|
+
* Usage: aicc hub
|
|
10
|
+
*/
|
|
11
|
+
import { createServer } from 'http';
|
|
12
|
+
import { existsSync, readFileSync } from 'fs';
|
|
13
|
+
import { dirname, extname, join, resolve } from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
16
|
+
import { getConfig } from '../config.js';
|
|
17
|
+
import { aggregateStates, detectChanges } from './state-aggregator.js';
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
|
|
21
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
24
|
+
|
|
25
|
+
/** Parse JSON body from an IncomingMessage */
|
|
26
|
+
function parseBody(req) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
req.on('data', (c) => chunks.push(c));
|
|
30
|
+
req.on('end', () => {
|
|
31
|
+
try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}); }
|
|
32
|
+
catch (e) { reject(e); }
|
|
33
|
+
});
|
|
34
|
+
req.on('error', reject);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Send a JSON response */
|
|
39
|
+
function json(res, data, status = 200) {
|
|
40
|
+
const body = JSON.stringify(data);
|
|
41
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
|
|
42
|
+
res.end(body);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Simple URL path matcher — returns params or null */
|
|
46
|
+
function matchRoute(method, url, pattern, expectedMethod) {
|
|
47
|
+
if (method !== expectedMethod) return null;
|
|
48
|
+
const urlPath = url.split('?')[0];
|
|
49
|
+
const patternParts = pattern.split('/');
|
|
50
|
+
const urlParts = urlPath.split('/');
|
|
51
|
+
if (patternParts.length !== urlParts.length) return null;
|
|
52
|
+
const params = {};
|
|
53
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
54
|
+
if (patternParts[i].startsWith(':')) {
|
|
55
|
+
params[patternParts[i].slice(1)] = decodeURIComponent(urlParts[i]);
|
|
56
|
+
} else if (patternParts[i] !== urlParts[i]) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return params;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── HubServer class ───────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Multi-project hub that aggregates status from multiple AICC instances.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* const hub = new HubServer({ port: 3850, pollInterval: 3000, projects: [...] });
|
|
70
|
+
* await hub.start();
|
|
71
|
+
*/
|
|
72
|
+
export class HubServer {
|
|
73
|
+
/**
|
|
74
|
+
* @param {object} config — hub configuration
|
|
75
|
+
* @param {number} [config.port=3850] — HTTP port
|
|
76
|
+
* @param {number} [config.pollInterval=3000] — polling interval in ms
|
|
77
|
+
* @param {Array<{name:string, url:string, color?:string, icon?:string}>} config.projects
|
|
78
|
+
*/
|
|
79
|
+
constructor(config = {}) {
|
|
80
|
+
this._port = config.port || 3850;
|
|
81
|
+
this._pollInterval = config.pollInterval || 30000; // health poll only, slow
|
|
82
|
+
this._projects = config.projects || [];
|
|
83
|
+
this._server = null;
|
|
84
|
+
this._wss = null;
|
|
85
|
+
this._pollTimer = null;
|
|
86
|
+
this._state = null;
|
|
87
|
+
this._running = false;
|
|
88
|
+
this._projectWs = new Map(); // project name → WebSocket client
|
|
89
|
+
this._projectStatus = new Map(); // project name → latest status
|
|
90
|
+
this._projectHealth = new Map(); // project name → latest health
|
|
91
|
+
this._projectOnline = new Map(); // project name → boolean
|
|
92
|
+
this._reconnectTimers = new Map(); // project name → reconnect timer
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Start HTTP server + WebSocket, begin polling */
|
|
96
|
+
async start() {
|
|
97
|
+
this._server = createServer((req, res) => this._handleRequest(req, res));
|
|
98
|
+
this._wss = new WebSocketServer({ server: this._server, path: '/ws' });
|
|
99
|
+
|
|
100
|
+
this._wss.on('connection', (ws) => {
|
|
101
|
+
// Send current state on connect
|
|
102
|
+
if (this._state) {
|
|
103
|
+
try { ws.send(JSON.stringify({ type: 'state', ...this._state })); } catch { /* skip */ }
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
this._running = true;
|
|
108
|
+
// Subscribe to each project's WebSocket for real-time updates
|
|
109
|
+
for (const project of this._projects) {
|
|
110
|
+
this._subscribeToProject(project);
|
|
111
|
+
}
|
|
112
|
+
// Initial poll to get health data and bootstrap state
|
|
113
|
+
await this._pollProjects();
|
|
114
|
+
// Slow poll for health checks only (status comes via WebSocket)
|
|
115
|
+
this._schedulePoll();
|
|
116
|
+
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
this._server.listen(this._port, () => {
|
|
119
|
+
console.log(`\n 🏢 AI Hub Server`);
|
|
120
|
+
console.log(` ─────────────────────────────────────`);
|
|
121
|
+
console.log(` API: http://localhost:${this._port}/api/projects`);
|
|
122
|
+
console.log(` Health: http://localhost:${this._port}/api/health`);
|
|
123
|
+
console.log(` WebSocket: ws://localhost:${this._port}/ws`);
|
|
124
|
+
console.log(` Projects:`);
|
|
125
|
+
for (const p of this._projects) {
|
|
126
|
+
console.log(` • ${p.icon || '📦'} ${p.name} → ${p.url}`);
|
|
127
|
+
}
|
|
128
|
+
console.log();
|
|
129
|
+
resolve();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Gracefully shut down */
|
|
135
|
+
async stop() {
|
|
136
|
+
this._running = false;
|
|
137
|
+
if (this._pollTimer) { clearTimeout(this._pollTimer); this._pollTimer = null; }
|
|
138
|
+
// Close all project WebSocket subscriptions
|
|
139
|
+
for (const [name, ws] of this._projectWs) {
|
|
140
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
141
|
+
}
|
|
142
|
+
this._projectWs.clear();
|
|
143
|
+
for (const timer of this._reconnectTimers.values()) clearTimeout(timer);
|
|
144
|
+
this._reconnectTimers.clear();
|
|
145
|
+
if (this._wss) { this._wss.close(); this._wss = null; }
|
|
146
|
+
if (this._server) {
|
|
147
|
+
await new Promise((resolve) => this._server.close(resolve));
|
|
148
|
+
this._server = null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Returns the current aggregated project states */
|
|
153
|
+
getProjectStates() {
|
|
154
|
+
return this._state;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Real-time WebSocket subscriptions ───────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/** Subscribe to a project's WebSocket for instant status updates */
|
|
160
|
+
_subscribeToProject(project) {
|
|
161
|
+
if (!this._running) return;
|
|
162
|
+
const wsUrl = project.url.replace(/^http/, 'ws') + '/ws';
|
|
163
|
+
try {
|
|
164
|
+
const ws = new WebSocket(wsUrl);
|
|
165
|
+
ws.on('open', () => {
|
|
166
|
+
this._projectOnline.set(project.name, true);
|
|
167
|
+
this._projectWs.set(project.name, ws);
|
|
168
|
+
// Clear reconnect timer
|
|
169
|
+
const rt = this._reconnectTimers.get(project.name);
|
|
170
|
+
if (rt) { clearTimeout(rt); this._reconnectTimers.delete(project.name); }
|
|
171
|
+
});
|
|
172
|
+
ws.on('message', (raw) => {
|
|
173
|
+
try {
|
|
174
|
+
const msg = JSON.parse(raw.toString());
|
|
175
|
+
if (msg.type === 'status' && msg.data) {
|
|
176
|
+
this._projectStatus.set(project.name, msg.data.status || msg.data);
|
|
177
|
+
this._projectOnline.set(project.name, true);
|
|
178
|
+
this._rebuildAndBroadcast();
|
|
179
|
+
}
|
|
180
|
+
} catch { /* ignore non-JSON */ }
|
|
181
|
+
});
|
|
182
|
+
ws.on('close', () => {
|
|
183
|
+
this._projectWs.delete(project.name);
|
|
184
|
+
this._projectOnline.set(project.name, false);
|
|
185
|
+
this._rebuildAndBroadcast();
|
|
186
|
+
this._scheduleReconnect(project);
|
|
187
|
+
});
|
|
188
|
+
ws.on('error', () => {
|
|
189
|
+
this._projectOnline.set(project.name, false);
|
|
190
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
191
|
+
});
|
|
192
|
+
} catch {
|
|
193
|
+
this._scheduleReconnect(project);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Reconnect to a project's WebSocket after a delay */
|
|
198
|
+
_scheduleReconnect(project) {
|
|
199
|
+
if (!this._running) return;
|
|
200
|
+
if (this._reconnectTimers.has(project.name)) return;
|
|
201
|
+
const timer = setTimeout(() => {
|
|
202
|
+
this._reconnectTimers.delete(project.name);
|
|
203
|
+
this._subscribeToProject(project);
|
|
204
|
+
}, 5000);
|
|
205
|
+
this._reconnectTimers.set(project.name, timer);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Rebuild aggregated state from cached data and broadcast */
|
|
209
|
+
_rebuildAndBroadcast() {
|
|
210
|
+
const responses = this._projects.map((project) => ({
|
|
211
|
+
name: project.name,
|
|
212
|
+
status: this._projectStatus.get(project.name) || null,
|
|
213
|
+
health: this._projectHealth.get(project.name) || null,
|
|
214
|
+
online: this._projectOnline.get(project.name) || false,
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
const oldState = this._state;
|
|
218
|
+
this._state = aggregateStates(this._projects, responses);
|
|
219
|
+
|
|
220
|
+
const changes = oldState ? detectChanges(oldState, this._state) : [];
|
|
221
|
+
if (!oldState || changes.length > 0) {
|
|
222
|
+
if (changes.length > 0) {
|
|
223
|
+
for (const c of changes) {
|
|
224
|
+
console.log(`[Hub] Change: ${c.project} ${c.type} ${c.from} → ${c.to}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
this._broadcastState();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Polling (health checks only) ──────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/** Poll each project's /api/status and /api/health — updates cache maps */
|
|
234
|
+
async _pollProjects() {
|
|
235
|
+
await Promise.all(
|
|
236
|
+
this._projects.map(async (project) => {
|
|
237
|
+
try {
|
|
238
|
+
const [statusRes, healthRes] = await Promise.all([
|
|
239
|
+
fetch(`${project.url}/api/status`, { signal: AbortSignal.timeout(5000) }),
|
|
240
|
+
fetch(`${project.url}/api/health`, { signal: AbortSignal.timeout(5000) }),
|
|
241
|
+
]);
|
|
242
|
+
if (statusRes.ok) this._projectStatus.set(project.name, await statusRes.json());
|
|
243
|
+
if (healthRes.ok) this._projectHealth.set(project.name, await healthRes.json());
|
|
244
|
+
this._projectOnline.set(project.name, statusRes.ok);
|
|
245
|
+
} catch {
|
|
246
|
+
// Only mark offline if no WebSocket is connected
|
|
247
|
+
if (!this._projectWs.has(project.name)) {
|
|
248
|
+
this._projectOnline.set(project.name, false);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
this._rebuildAndBroadcast();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Schedule the next poll */
|
|
257
|
+
_schedulePoll() {
|
|
258
|
+
if (!this._running) return;
|
|
259
|
+
this._pollTimer = setTimeout(async () => {
|
|
260
|
+
await this._pollProjects();
|
|
261
|
+
this._schedulePoll();
|
|
262
|
+
}, this._pollInterval);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Broadcast current state to all connected WebSocket clients */
|
|
266
|
+
_broadcastState() {
|
|
267
|
+
if (!this._wss || !this._state) return;
|
|
268
|
+
const msg = JSON.stringify({ type: 'state', ...this._state });
|
|
269
|
+
for (const ws of this._wss.clients) {
|
|
270
|
+
if (ws.readyState === 1) {
|
|
271
|
+
try { ws.send(msg); } catch { /* skip dead connections */ }
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── HTTP request routing ─────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
async _handleRequest(req, res) {
|
|
279
|
+
const { method, url } = req;
|
|
280
|
+
let params;
|
|
281
|
+
|
|
282
|
+
// CORS headers for dashboard access
|
|
283
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
284
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
285
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
286
|
+
if (method === 'OPTIONS') { res.writeHead(204); return res.end(); }
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
// GET /api/projects — all projects with current state
|
|
290
|
+
if (method === 'GET' && url.split('?')[0] === '/api/projects') {
|
|
291
|
+
const state = this._state || { projects: {}, summary: {} };
|
|
292
|
+
return json(res, {
|
|
293
|
+
projects: Object.values(state.projects),
|
|
294
|
+
summary: state.summary,
|
|
295
|
+
hub: { port: this._port, pollInterval: this._pollInterval },
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// GET /api/projects/:name — single project detail
|
|
300
|
+
if ((params = matchRoute(method, url, '/api/projects/:name', 'GET'))) {
|
|
301
|
+
const state = this._state || { projects: {} };
|
|
302
|
+
const project = state.projects[params.name];
|
|
303
|
+
if (!project) return json(res, { error: 'Project not found' }, 404);
|
|
304
|
+
return json(res, project);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// POST /api/projects/:name/feature — forward feature submission
|
|
308
|
+
if ((params = matchRoute(method, url, '/api/projects/:name/feature', 'POST'))) {
|
|
309
|
+
const projectCfg = this._projects.find(p => p.name === params.name);
|
|
310
|
+
if (!projectCfg) return json(res, { error: 'Project not found' }, 404);
|
|
311
|
+
const body = await parseBody(req);
|
|
312
|
+
try {
|
|
313
|
+
const r = await fetch(`${projectCfg.url}/api/feature`, {
|
|
314
|
+
method: 'POST',
|
|
315
|
+
headers: { 'Content-Type': 'application/json' },
|
|
316
|
+
body: JSON.stringify(body),
|
|
317
|
+
});
|
|
318
|
+
return json(res, await r.json(), r.status);
|
|
319
|
+
} catch (e) {
|
|
320
|
+
return json(res, { error: `Cannot reach ${projectCfg.name}: ${e.message}` }, 502);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// POST /api/projects/:name/action/:action — forward any pipeline action
|
|
325
|
+
if (method === 'POST') {
|
|
326
|
+
const actionMatch = url.split('?')[0].match(/^\/api\/projects\/([^/]+)\/action\/([^/]+)$/);
|
|
327
|
+
if (actionMatch) {
|
|
328
|
+
const projectName = decodeURIComponent(actionMatch[1]);
|
|
329
|
+
const action = decodeURIComponent(actionMatch[2]);
|
|
330
|
+
const projectCfg = this._projects.find(p => p.name === projectName);
|
|
331
|
+
if (!projectCfg) return json(res, { error: 'Project not found' }, 404);
|
|
332
|
+
const body = await parseBody(req);
|
|
333
|
+
try {
|
|
334
|
+
const r = await fetch(`${projectCfg.url}/api/${action}`, {
|
|
335
|
+
method: 'POST',
|
|
336
|
+
headers: { 'Content-Type': 'application/json' },
|
|
337
|
+
body: JSON.stringify(body),
|
|
338
|
+
signal: AbortSignal.timeout(30000),
|
|
339
|
+
});
|
|
340
|
+
return json(res, await r.json(), r.status);
|
|
341
|
+
} catch (e) {
|
|
342
|
+
return json(res, { error: `Cannot reach ${projectCfg.name}: ${e.message}` }, 502);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// GET /api/health — hub health + all project health
|
|
348
|
+
if (method === 'GET' && url.split('?')[0] === '/api/health') {
|
|
349
|
+
const state = this._state || { projects: {}, summary: { total: 0, active: 0, idle: 0, offline: 0 } };
|
|
350
|
+
return json(res, {
|
|
351
|
+
hub: 'ok',
|
|
352
|
+
uptime: process.uptime(),
|
|
353
|
+
projectCount: state.summary.total,
|
|
354
|
+
onlineCount: state.summary.total - state.summary.offline,
|
|
355
|
+
projects: Object.values(state.projects).map(p => ({
|
|
356
|
+
name: p.name,
|
|
357
|
+
online: p.online,
|
|
358
|
+
health: p.health,
|
|
359
|
+
})),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Serve /office/ static files (pixel art RPG visualizer)
|
|
364
|
+
const urlPath = url.split('?')[0];
|
|
365
|
+
if (method === 'GET' && urlPath.startsWith('/office')) {
|
|
366
|
+
const officeDir = resolve(__dirname, '../web/public/office');
|
|
367
|
+
let filePath = urlPath === '/office' || urlPath === '/office/'
|
|
368
|
+
? join(officeDir, 'index.html')
|
|
369
|
+
: join(officeDir, urlPath.replace('/office/', ''));
|
|
370
|
+
|
|
371
|
+
if (existsSync(filePath)) {
|
|
372
|
+
const MIME = {
|
|
373
|
+
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
|
374
|
+
'.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml',
|
|
375
|
+
'.woff2': 'font/woff2', '.woff': 'font/woff',
|
|
376
|
+
};
|
|
377
|
+
const ext = extname(filePath);
|
|
378
|
+
const content = readFileSync(filePath);
|
|
379
|
+
res.writeHead(200, {
|
|
380
|
+
'Content-Type': MIME[ext] || 'application/octet-stream',
|
|
381
|
+
'Cache-Control': 'no-cache',
|
|
382
|
+
});
|
|
383
|
+
return res.end(content);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 404 fallback
|
|
388
|
+
json(res, { error: 'Not found' }, 404);
|
|
389
|
+
} catch (e) {
|
|
390
|
+
json(res, { error: e.message }, 500);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ─── Convenience starter ───────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Start the hub server with the given options or from loaded config.
|
|
399
|
+
*
|
|
400
|
+
* @param {object} [options]
|
|
401
|
+
* @param {number} [options.port] — override port (default from config or 3850)
|
|
402
|
+
* @returns {{ server: import('http').Server, wss: WebSocketServer }}
|
|
403
|
+
*/
|
|
404
|
+
export async function startHub(options = {}) {
|
|
405
|
+
let hubConfig;
|
|
406
|
+
try {
|
|
407
|
+
const cfg = getConfig();
|
|
408
|
+
hubConfig = cfg.hub || {};
|
|
409
|
+
} catch {
|
|
410
|
+
hubConfig = {};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const config = {
|
|
414
|
+
port: options.port || hubConfig.port || 3850,
|
|
415
|
+
pollInterval: hubConfig.pollInterval || 3000,
|
|
416
|
+
projects: hubConfig.projects || [],
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
if (config.projects.length === 0) {
|
|
420
|
+
console.error(' ✗ No projects configured for hub.');
|
|
421
|
+
console.error(' Add hub.projects to aicc.config.js');
|
|
422
|
+
console.error(' Example:');
|
|
423
|
+
console.error(' hub: {');
|
|
424
|
+
console.error(' projects: [');
|
|
425
|
+
console.error(' { name: "MyProject", url: "http://localhost:3847" }');
|
|
426
|
+
console.error(' ]');
|
|
427
|
+
console.error(' }');
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const hub = new HubServer(config);
|
|
432
|
+
await hub.start();
|
|
433
|
+
|
|
434
|
+
// Graceful shutdown
|
|
435
|
+
const shutdown = () => { hub.stop().then(() => process.exit(0)); };
|
|
436
|
+
process.on('SIGINT', shutdown);
|
|
437
|
+
process.on('SIGTERM', shutdown);
|
|
438
|
+
|
|
439
|
+
return { server: hub._server, wss: hub._wss };
|
|
440
|
+
}
|