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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Poller — periodically fetches /api/status and /api/health
|
|
3
|
+
* from each registered project instance.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class ProjectPoller {
|
|
7
|
+
/**
|
|
8
|
+
* @param {Array<{name:string, url:string, color?:string}>} projects
|
|
9
|
+
* @param {import('./state-aggregator.js').StateAggregator} aggregator
|
|
10
|
+
* @param {number} intervalMs — polling interval (default 3000ms)
|
|
11
|
+
*/
|
|
12
|
+
constructor(projects, aggregator, intervalMs = 3000) {
|
|
13
|
+
this._projects = projects;
|
|
14
|
+
this._aggregator = aggregator;
|
|
15
|
+
this._intervalMs = intervalMs;
|
|
16
|
+
this._timer = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Start polling all projects */
|
|
20
|
+
start() {
|
|
21
|
+
this._poll(); // immediate first poll
|
|
22
|
+
this._timer = setInterval(() => this._poll(), this._intervalMs);
|
|
23
|
+
console.log(` [Hub] Polling ${this._projects.length} project(s) every ${this._intervalMs / 1000}s`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Stop polling */
|
|
27
|
+
stop() {
|
|
28
|
+
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Immediately re-poll a single project by name (called on push-event) */
|
|
32
|
+
pollProject(name) {
|
|
33
|
+
const project = this._projects.find(p => p.name === name);
|
|
34
|
+
if (project) this._pollOne(project).catch(() => {});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Poll all projects in parallel */
|
|
38
|
+
async _poll() {
|
|
39
|
+
await Promise.allSettled(
|
|
40
|
+
this._projects.map(p => this._pollOne(p))
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Poll a single project */
|
|
45
|
+
async _pollOne(project) {
|
|
46
|
+
const { name, url } = project;
|
|
47
|
+
try {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
50
|
+
|
|
51
|
+
const [statusRes, healthRes] = await Promise.allSettled([
|
|
52
|
+
fetch(`${url}/api/status`, { signal: controller.signal }),
|
|
53
|
+
fetch(`${url}/api/health`, { signal: controller.signal }),
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
clearTimeout(timeout);
|
|
57
|
+
|
|
58
|
+
const status = statusRes.status === 'fulfilled' && statusRes.value.ok
|
|
59
|
+
? await statusRes.value.json()
|
|
60
|
+
: null;
|
|
61
|
+
|
|
62
|
+
const health = healthRes.status === 'fulfilled' && healthRes.value.ok
|
|
63
|
+
? await healthRes.value.json()
|
|
64
|
+
: null;
|
|
65
|
+
|
|
66
|
+
if (status || health) {
|
|
67
|
+
this._aggregator.update(name, status, health);
|
|
68
|
+
} else {
|
|
69
|
+
this._aggregator.markOffline(name);
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
this._aggregator.markOffline(name);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { resolve, dirname } from 'path';
|
|
3
|
+
import { getWorkflowDir } from '../utils/pipeline.js';
|
|
4
|
+
|
|
5
|
+
const REGISTRY_FILENAME = 'skills_index.json';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_REGISTRY = [
|
|
8
|
+
{ name: 'security-review', stage: 'review', description: 'Security-focused code review with OWASP checklist', tags: ['security', 'review', 'owasp'], source: 'built-in' },
|
|
9
|
+
{ name: 'api-design', stage: 'arch', description: 'REST API design best practices and validation', tags: ['api', 'rest', 'architecture'], source: 'built-in' },
|
|
10
|
+
{ name: 'unit-testing', stage: 'test', description: 'Unit test generation and coverage analysis', tags: ['testing', 'unit-tests', 'coverage'], source: 'built-in' },
|
|
11
|
+
{ name: 'integration-testing', stage: 'test', description: 'Integration and end-to-end test patterns', tags: ['testing', 'integration', 'e2e'], source: 'built-in' },
|
|
12
|
+
{ name: 'performance-audit', stage: 'review', description: 'Performance profiling and optimization recommendations', tags: ['performance', 'profiling', 'optimization'], source: 'built-in' },
|
|
13
|
+
{ name: 'accessibility-check', stage: 'review', description: 'WCAG compliance and accessibility audit', tags: ['accessibility', 'a11y', 'wcag'], source: 'built-in' },
|
|
14
|
+
{ name: 'doc-generator', stage: 'docs', description: 'Auto-generate API and module documentation', tags: ['documentation', 'jsdoc', 'markdown'], source: 'built-in' },
|
|
15
|
+
{ name: 'db-schema-review', stage: 'arch', description: 'Database schema design and migration review', tags: ['database', 'schema', 'migrations'], source: 'built-in' },
|
|
16
|
+
{ name: 'error-handling', stage: 'review', description: 'Error handling patterns and resilience checks', tags: ['error-handling', 'resilience', 'exceptions'], source: 'built-in' },
|
|
17
|
+
{ name: 'logging-standards', stage: 'review', description: 'Structured logging and observability best practices', tags: ['logging', 'observability', 'monitoring'], source: 'built-in' },
|
|
18
|
+
{ name: 'ci-cd-pipeline', stage: 'deploy', description: 'CI/CD pipeline configuration and optimization', tags: ['ci-cd', 'github-actions', 'deployment'], source: 'built-in' },
|
|
19
|
+
{ name: 'containerization', stage: 'deploy', description: 'Dockerfile and container best practices', tags: ['docker', 'containerization', 'kubernetes'], source: 'built-in' },
|
|
20
|
+
{ name: 'frontend-patterns', stage: 'arch', description: 'Frontend architecture patterns and component design', tags: ['frontend', 'components', 'state-management'], source: 'built-in' },
|
|
21
|
+
{ name: 'mobile-dev', stage: 'arch', description: 'Mobile development patterns and platform guidelines', tags: ['mobile', 'ios', 'android'], source: 'built-in' },
|
|
22
|
+
{ name: 'code-quality', stage: 'review', description: 'Code quality metrics, linting, and style enforcement', tags: ['code-quality', 'linting', 'style'], source: 'built-in' },
|
|
23
|
+
{ name: 'refactoring', stage: 'review', description: 'Refactoring patterns and code smell detection', tags: ['refactoring', 'code-smells', 'clean-code'], source: 'built-in' },
|
|
24
|
+
{ name: 'dependency-audit', stage: 'review', description: 'Dependency vulnerability scanning and license checks', tags: ['security', 'dependencies', 'vulnerabilities'], source: 'built-in' },
|
|
25
|
+
{ name: 'api-testing', stage: 'test', description: 'API contract testing and response validation', tags: ['testing', 'api', 'contract-testing'], source: 'built-in' },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export function getRegistryPath() {
|
|
29
|
+
return resolve(getWorkflowDir(), REGISTRY_FILENAME);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getDefaultRegistry() {
|
|
33
|
+
return structuredClone(DEFAULT_REGISTRY);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getRegistry() {
|
|
37
|
+
const registryPath = getRegistryPath();
|
|
38
|
+
if (!existsSync(registryPath)) {
|
|
39
|
+
return getDefaultRegistry();
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const raw = readFileSync(registryPath, 'utf-8');
|
|
43
|
+
return JSON.parse(raw);
|
|
44
|
+
} catch {
|
|
45
|
+
return getDefaultRegistry();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function searchSkills(query) {
|
|
50
|
+
const registry = getRegistry();
|
|
51
|
+
const q = query.toLowerCase();
|
|
52
|
+
return registry.filter((skill) => {
|
|
53
|
+
const haystack = [
|
|
54
|
+
skill.name,
|
|
55
|
+
skill.description,
|
|
56
|
+
...(skill.tags || []),
|
|
57
|
+
].join(' ').toLowerCase();
|
|
58
|
+
return haystack.includes(q);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function filterByStage(stage) {
|
|
63
|
+
const registry = getRegistry();
|
|
64
|
+
const s = stage.toLowerCase();
|
|
65
|
+
return registry.filter((skill) => (skill.stage || '').toLowerCase() === s);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function addToRegistry(skillMeta) {
|
|
69
|
+
const registry = getRegistry();
|
|
70
|
+
const existing = registry.findIndex((s) => s.name === skillMeta.name);
|
|
71
|
+
if (existing !== -1) {
|
|
72
|
+
registry[existing] = { ...registry[existing], ...skillMeta };
|
|
73
|
+
} else {
|
|
74
|
+
registry.push(skillMeta);
|
|
75
|
+
}
|
|
76
|
+
const registryPath = getRegistryPath();
|
|
77
|
+
mkdirSync(dirname(registryPath), { recursive: true });
|
|
78
|
+
writeFileSync(registryPath, JSON.stringify(registry, null, 2), 'utf-8');
|
|
79
|
+
return registry;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function removeFromRegistry(skillName) {
|
|
83
|
+
const registry = getRegistry();
|
|
84
|
+
const filtered = registry.filter((s) => s.name !== skillName);
|
|
85
|
+
const registryPath = getRegistryPath();
|
|
86
|
+
mkdirSync(dirname(registryPath), { recursive: true });
|
|
87
|
+
writeFileSync(registryPath, JSON.stringify(filtered, null, 2), 'utf-8');
|
|
88
|
+
return filtered;
|
|
89
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State Aggregator — merges status from multiple AICC project instances.
|
|
3
|
+
*
|
|
4
|
+
* Provides functions to aggregate, diff, and format multi-project state.
|
|
5
|
+
* Also exports the StateAggregator class for internal hub use.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ─── StateAggregator class (used by project-poller.js) ─────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Maintains an in-memory Map of project states, updated by the poller.
|
|
12
|
+
* Broadcasts changes via a callback.
|
|
13
|
+
*/
|
|
14
|
+
export class StateAggregator {
|
|
15
|
+
constructor() {
|
|
16
|
+
/** @type {Map<string, object>} */
|
|
17
|
+
this._states = new Map();
|
|
18
|
+
this._listeners = [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Register a listener called on any state change */
|
|
22
|
+
onChange(fn) { this._listeners.push(fn); }
|
|
23
|
+
|
|
24
|
+
/** Update a single project's state */
|
|
25
|
+
update(projectName, status, health) {
|
|
26
|
+
const prev = this._states.get(projectName);
|
|
27
|
+
const next = {
|
|
28
|
+
name: projectName,
|
|
29
|
+
status: status || {},
|
|
30
|
+
health: health || {},
|
|
31
|
+
online: !!status,
|
|
32
|
+
lastPoll: Date.now(),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const changed = !prev
|
|
36
|
+
|| prev.status?.stage !== next.status?.stage
|
|
37
|
+
|| prev.status?.current_feature !== next.status?.current_feature
|
|
38
|
+
|| prev.online !== next.online
|
|
39
|
+
|| JSON.stringify(prev.status?._bannedModels) !== JSON.stringify(next.status?._bannedModels);
|
|
40
|
+
|
|
41
|
+
this._states.set(projectName, next);
|
|
42
|
+
|
|
43
|
+
if (changed) {
|
|
44
|
+
for (const fn of this._listeners) {
|
|
45
|
+
try { fn(projectName, next); } catch (e) { console.error('[Hub] listener error:', e.message); }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Mark a project as offline */
|
|
51
|
+
markOffline(projectName) {
|
|
52
|
+
const prev = this._states.get(projectName);
|
|
53
|
+
if (prev && prev.online) {
|
|
54
|
+
prev.online = false;
|
|
55
|
+
prev.lastPoll = Date.now();
|
|
56
|
+
for (const fn of this._listeners) {
|
|
57
|
+
try { fn(projectName, prev); } catch (e) { console.error('[Hub] listener error:', e.message); }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Get all project states as array */
|
|
63
|
+
getAll() {
|
|
64
|
+
return Array.from(this._states.values());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Get single project state */
|
|
68
|
+
get(name) {
|
|
69
|
+
return this._states.get(name) || null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Functional API ────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Merge state from multiple projects into a single aggregated object.
|
|
77
|
+
*
|
|
78
|
+
* @param {Array<{name:string, url:string, color?:string, icon?:string}>} projectConfigs
|
|
79
|
+
* @param {Array<{name:string, status:object|null, health:object|null, online:boolean}>} responses
|
|
80
|
+
* @returns {object} Aggregated state
|
|
81
|
+
*/
|
|
82
|
+
export function aggregateStates(projectConfigs, responses) {
|
|
83
|
+
const projects = {};
|
|
84
|
+
let active = 0;
|
|
85
|
+
let idle = 0;
|
|
86
|
+
let offline = 0;
|
|
87
|
+
|
|
88
|
+
for (const cfg of projectConfigs) {
|
|
89
|
+
const resp = responses.find(r => r.name === cfg.name) || { status: null, health: null, online: false };
|
|
90
|
+
const isOnline = resp.online;
|
|
91
|
+
|
|
92
|
+
projects[cfg.name] = {
|
|
93
|
+
name: cfg.name,
|
|
94
|
+
url: cfg.url,
|
|
95
|
+
color: cfg.color || '#888888',
|
|
96
|
+
icon: cfg.icon || '📦',
|
|
97
|
+
status: resp.status || {},
|
|
98
|
+
health: resp.health || {},
|
|
99
|
+
online: isOnline,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
if (!isOnline) {
|
|
103
|
+
offline++;
|
|
104
|
+
} else {
|
|
105
|
+
const stage = resp.status?.stage || '';
|
|
106
|
+
const activeStages = ['reviewing', 'implementing', 'deploying', 'testing', 'fixing'];
|
|
107
|
+
if (activeStages.some(s => stage.toLowerCase().includes(s))) {
|
|
108
|
+
active++;
|
|
109
|
+
} else {
|
|
110
|
+
idle++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
timestamp: Date.now(),
|
|
117
|
+
projects,
|
|
118
|
+
summary: {
|
|
119
|
+
total: projectConfigs.length,
|
|
120
|
+
active,
|
|
121
|
+
idle,
|
|
122
|
+
offline,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Compare two aggregated states and return an array of change events.
|
|
129
|
+
*
|
|
130
|
+
* @param {object} oldState — previous aggregated state
|
|
131
|
+
* @param {object} newState — current aggregated state
|
|
132
|
+
* @returns {Array<{project:string, type:string, from:*, to:*}>}
|
|
133
|
+
*/
|
|
134
|
+
export function detectChanges(oldState, newState) {
|
|
135
|
+
const changes = [];
|
|
136
|
+
if (!oldState || !newState) return changes;
|
|
137
|
+
|
|
138
|
+
for (const [name, newProject] of Object.entries(newState.projects)) {
|
|
139
|
+
const oldProject = oldState.projects?.[name];
|
|
140
|
+
|
|
141
|
+
if (!oldProject) {
|
|
142
|
+
changes.push({ project: name, type: 'added', from: null, to: newProject });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Online/offline transitions
|
|
147
|
+
if (oldProject.online !== newProject.online) {
|
|
148
|
+
changes.push({
|
|
149
|
+
project: name,
|
|
150
|
+
type: 'connectivity',
|
|
151
|
+
from: oldProject.online ? 'online' : 'offline',
|
|
152
|
+
to: newProject.online ? 'online' : 'offline',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Stage transitions
|
|
157
|
+
const oldStage = oldProject.status?.stage;
|
|
158
|
+
const newStage = newProject.status?.stage;
|
|
159
|
+
if (oldStage !== newStage) {
|
|
160
|
+
changes.push({ project: name, type: 'stage', from: oldStage, to: newStage });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Feature changes
|
|
164
|
+
const oldFeature = oldProject.status?.current_feature;
|
|
165
|
+
const newFeature = newProject.status?.current_feature;
|
|
166
|
+
if (oldFeature !== newFeature) {
|
|
167
|
+
changes.push({ project: name, type: 'feature', from: oldFeature, to: newFeature });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Detect removals
|
|
172
|
+
for (const name of Object.keys(oldState.projects || {})) {
|
|
173
|
+
if (!newState.projects[name]) {
|
|
174
|
+
changes.push({ project: name, type: 'removed', from: oldState.projects[name], to: null });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return changes;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Format aggregated state as a human-readable string.
|
|
183
|
+
*
|
|
184
|
+
* @param {object} aggregated — output of aggregateStates()
|
|
185
|
+
* @returns {string}
|
|
186
|
+
*/
|
|
187
|
+
export function formatHubStatus(aggregated) {
|
|
188
|
+
if (!aggregated) return 'Hub: no data';
|
|
189
|
+
|
|
190
|
+
const { summary, projects } = aggregated;
|
|
191
|
+
const lines = [
|
|
192
|
+
`🏢 Hub Status — ${summary.total} project(s): ${summary.active} active, ${summary.idle} idle, ${summary.offline} offline`,
|
|
193
|
+
'─'.repeat(50),
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
for (const p of Object.values(projects)) {
|
|
197
|
+
const statusIcon = p.online ? '🟢' : '🔴';
|
|
198
|
+
const stage = p.status?.stage || 'unknown';
|
|
199
|
+
const feature = p.status?.current_feature || '—';
|
|
200
|
+
lines.push(` ${statusIcon} ${p.icon} ${p.name} │ ${stage} │ ${feature}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return lines.join('\n');
|
|
204
|
+
}
|