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,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline Event Bus — central nervous system for web UI + Telegram.
|
|
3
|
+
*
|
|
4
|
+
* Watches .ai-workflow/status.json for changes (1s poll) and emits events
|
|
5
|
+
* that WebSocket clients and the Telegram bot listen to.
|
|
6
|
+
*
|
|
7
|
+
* ZERO changes to existing terminal CLI code — this watches the file on disk.
|
|
8
|
+
*/
|
|
9
|
+
import { EventEmitter } from 'events';
|
|
10
|
+
import { existsSync, readFileSync, unwatchFile, watchFile } from 'fs';
|
|
11
|
+
import { dirname, resolve } from 'path';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Find the project root by walking up from cwd until aicc.config.js is found.
|
|
18
|
+
* Mirrors the same logic in utils/pipeline.js — MUST stay in sync.
|
|
19
|
+
*/
|
|
20
|
+
function findProjectRoot() {
|
|
21
|
+
let dir = process.cwd();
|
|
22
|
+
while (true) {
|
|
23
|
+
if (existsSync(resolve(dir, 'aicc.config.js'))) return dir;
|
|
24
|
+
const parent = dirname(dir);
|
|
25
|
+
if (parent === dir) break;
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
// Fallback: package dir (works when package IS the project)
|
|
29
|
+
return resolve(__dirname, '../../..');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ROOT = findProjectRoot();
|
|
33
|
+
const STATUS_FILE = resolve(ROOT, '.ai-workflow/status.json');
|
|
34
|
+
|
|
35
|
+
class PipelineBus extends EventEmitter {
|
|
36
|
+
constructor() {
|
|
37
|
+
super();
|
|
38
|
+
this._lastStatus = null;
|
|
39
|
+
this._lastStatusJson = '';
|
|
40
|
+
this._watching = false;
|
|
41
|
+
this._lastStageChangeTime = Date.now();
|
|
42
|
+
this._stallWatchdog = null;
|
|
43
|
+
this._stallCount = 0; // consecutive stall detections for recovery
|
|
44
|
+
this._stallMaxRetries = 2;
|
|
45
|
+
this._stallThresholdMs = 300_000; // 5 minutes per stall detection
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Start watching status.json for changes */
|
|
49
|
+
startWatching() {
|
|
50
|
+
if (this._watching) return;
|
|
51
|
+
this._watching = true;
|
|
52
|
+
|
|
53
|
+
// Initial read
|
|
54
|
+
this._lastStatus = this._readStatus();
|
|
55
|
+
this._lastStatusJson = JSON.stringify(this._lastStatus);
|
|
56
|
+
|
|
57
|
+
// Poll every 1s — negligible cost, works for any writer (terminal, web, telegram)
|
|
58
|
+
watchFile(STATUS_FILE, { interval: 1000 }, () => {
|
|
59
|
+
const newStatus = this._readStatus();
|
|
60
|
+
const newJson = JSON.stringify(newStatus);
|
|
61
|
+
|
|
62
|
+
if (newJson !== this._lastStatusJson) {
|
|
63
|
+
const oldStage = this._lastStatus?.stage;
|
|
64
|
+
this._lastStatus = newStatus;
|
|
65
|
+
this._lastStatusJson = newJson;
|
|
66
|
+
|
|
67
|
+
// Reset stall timer, idle notify timer, and stall counter when the stage changes
|
|
68
|
+
if (newStatus?.stage !== oldStage) {
|
|
69
|
+
this._lastStageChangeTime = Date.now();
|
|
70
|
+
this._lastIdleNotifyTime = Date.now(); // suppress immediate idle heartbeat after stage change
|
|
71
|
+
this._stallCount = 0; // reset consecutive stall counter
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.emit('status', {
|
|
75
|
+
status: newStatus,
|
|
76
|
+
previousStage: oldStage,
|
|
77
|
+
timestamp: new Date().toISOString(),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Stall watchdog: notify user if an active pipeline stage hasn't moved in 5 minutes
|
|
83
|
+
const STALL_THRESHOLD_MS = 5 * 60 * 1000;
|
|
84
|
+
const ACTIVE_STAGES = new Set(['spec', 'arch', 'impl', 'review', 'fix', 'deploy']);
|
|
85
|
+
this._lastIdleNotifyTime = Date.now(); // suppress idle notification on first boot
|
|
86
|
+
this._stallWatchdog = setInterval(() => {
|
|
87
|
+
const s = this._lastStatus;
|
|
88
|
+
if (!s?.stage) return;
|
|
89
|
+
|
|
90
|
+
// Active stage stall detection
|
|
91
|
+
if (ACTIVE_STAGES.has(s.stage)) {
|
|
92
|
+
const stalledMs = Date.now() - this._lastStageChangeTime;
|
|
93
|
+
if (stalledMs >= STALL_THRESHOLD_MS) {
|
|
94
|
+
this._stallCount++;
|
|
95
|
+
this.emitEvent('pipeline_stalled', {
|
|
96
|
+
stage: s.stage,
|
|
97
|
+
feature: s.current_feature,
|
|
98
|
+
minutesElapsed: Math.round(stalledMs / 60000),
|
|
99
|
+
stallCount: this._stallCount,
|
|
100
|
+
message: `⚠️ <b>Pipeline stall detected</b>\n\nStage <code>${s.stage}</code> has been running for <b>${Math.round(stalledMs / 60000)} minutes</b> with no progress.\n\nFeature: <code>${s.current_feature || 'unknown'}</code>\n\nThe AI may still be working on a large response — or something may have gone wrong. Use /status to check.`,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// After maxRetries consecutive stalls (10 min total), auto-recover
|
|
104
|
+
if (this._stallCount >= this._stallMaxRetries) {
|
|
105
|
+
this._stallCount = 0;
|
|
106
|
+
this._lastStageChangeTime = Date.now(); // prevent immediate re-trigger
|
|
107
|
+
this.emitEvent('pipeline_stall_recovery', {
|
|
108
|
+
action: 'retry_stage',
|
|
109
|
+
stage: s.stage,
|
|
110
|
+
feature: s.current_feature,
|
|
111
|
+
message: `🔄 <b>Auto-recovery</b>\n\nPipeline stalled for ${Math.round(stalledMs / 60000)}min on <code>${s.stage}</code>. Auto-retrying...`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Long-idle heartbeat: if pipeline has been idle/deployed for 30+ min,
|
|
119
|
+
// send a single periodic status update so users know the bot is alive.
|
|
120
|
+
// Also covers waiting-for-action stages: review_complete (approve/reject needed), inbox (feature queued).
|
|
121
|
+
const IDLE_STAGES = new Set(['deployed', 'approved', 'rejected', 'review_complete', 'inbox']);
|
|
122
|
+
const IDLE_NOTIFY_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
|
123
|
+
if (IDLE_STAGES.has(s.stage)) {
|
|
124
|
+
const sinceLastNotify = Date.now() - (this._lastIdleNotifyTime || 0);
|
|
125
|
+
if (sinceLastNotify >= IDLE_NOTIFY_INTERVAL_MS) {
|
|
126
|
+
this._lastIdleNotifyTime = Date.now();
|
|
127
|
+
const idleMin = Math.round((Date.now() - this._lastStageChangeTime) / 60000);
|
|
128
|
+
const stageEmoji = { idle: '💤', deployed: '🚀', approved: '✅', rejected: '❌', review_complete: '🔍', inbox: '📬' };
|
|
129
|
+
this.emitEvent('idle_heartbeat', {
|
|
130
|
+
stage: s.stage,
|
|
131
|
+
feature: s.current_feature,
|
|
132
|
+
minutesIdle: idleMin,
|
|
133
|
+
message: `${stageEmoji[s.stage] || '📋'} <b>Status check</b> — pipeline is <code>${s.stage}</code> for ${idleMin} min.${s.current_feature ? `\nFeature: <code>${s.current_feature}</code>` : ''}\n\nBot is online and ready. Send a message or /help to get started.`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}, 2 * 60 * 1000); // check every 2 minutes
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Stop watching */
|
|
141
|
+
stopWatching() {
|
|
142
|
+
if (!this._watching) return;
|
|
143
|
+
unwatchFile(STATUS_FILE);
|
|
144
|
+
clearInterval(this._stallWatchdog);
|
|
145
|
+
this._stallWatchdog = null;
|
|
146
|
+
this._watching = false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Get current pipeline status */
|
|
150
|
+
getStatus() {
|
|
151
|
+
return this._readStatus();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Emit a pipeline event (review done, deploy result, etc.) */
|
|
155
|
+
emitEvent(event, data = {}) {
|
|
156
|
+
this.emit('pipeline-event', {
|
|
157
|
+
event,
|
|
158
|
+
data,
|
|
159
|
+
timestamp: new Date().toISOString(),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Emit a log line */
|
|
164
|
+
emitLog(agent, message, type = 'info') {
|
|
165
|
+
this.emit('log', {
|
|
166
|
+
agent,
|
|
167
|
+
message,
|
|
168
|
+
type,
|
|
169
|
+
timestamp: new Date().toISOString(),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Read status.json safely */
|
|
174
|
+
_readStatus() {
|
|
175
|
+
try {
|
|
176
|
+
if (!existsSync(STATUS_FILE)) return { stage: 'idle', current_feature: null };
|
|
177
|
+
return JSON.parse(readFileSync(STATUS_FILE, 'utf8'));
|
|
178
|
+
} catch {
|
|
179
|
+
return { stage: 'idle', current_feature: null };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export const bus = new PipelineBus();
|
|
185
|
+
export const ROOT_DIR = ROOT;
|
package/lib/slack/bot.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Control Center Slack Bot — pipeline control + notifications via Slack Web API.
|
|
3
|
+
*
|
|
4
|
+
* Uses native fetch() to call Slack Web API (no @slack/bolt dependency).
|
|
5
|
+
* Listens on the event bus and posts updates to a configured channel.
|
|
6
|
+
* Supports slash commands for pipeline control.
|
|
7
|
+
*
|
|
8
|
+
* Setup:
|
|
9
|
+
* 1. Create a Slack App at https://api.slack.com/apps
|
|
10
|
+
* 2. Add Bot Token Scopes: chat:write, commands
|
|
11
|
+
* 3. Install to workspace, get Bot Token
|
|
12
|
+
* 4. Set env vars:
|
|
13
|
+
* export SLACK_BOT_TOKEN=xoxb-...
|
|
14
|
+
* export SLACK_SIGNING_SECRET=...
|
|
15
|
+
* 5. Configure in aicc.config.js:
|
|
16
|
+
* slack: { enabled: true, channel: '#ai-pipeline' }
|
|
17
|
+
*/
|
|
18
|
+
import { getConfig } from '../config.js';
|
|
19
|
+
import * as actions from '../shared/action-runner.js';
|
|
20
|
+
import { bus } from '../shared/event-bus.js';
|
|
21
|
+
import { formatCostSummary, getCostSummary, getActiveBudget } from '../utils/cost-tracker.js';
|
|
22
|
+
|
|
23
|
+
const SLACK_API = 'https://slack.com/api';
|
|
24
|
+
|
|
25
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Call a Slack Web API method via native fetch.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} method — Slack API method (e.g. 'chat.postMessage')
|
|
31
|
+
* @param {string} token — Bot token
|
|
32
|
+
* @param {object} body — JSON payload
|
|
33
|
+
* @returns {Promise<object>}
|
|
34
|
+
*/
|
|
35
|
+
async function slackApiCall(method, token, body) {
|
|
36
|
+
const res = await fetch(`${SLACK_API}/${method}`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
'Authorization': `Bearer ${token}`,
|
|
40
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify(body),
|
|
43
|
+
});
|
|
44
|
+
const data = await res.json();
|
|
45
|
+
if (!data.ok) {
|
|
46
|
+
throw new Error(`Slack API ${method} failed: ${data.error || 'unknown error'}`);
|
|
47
|
+
}
|
|
48
|
+
return data;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Module-level state ────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
let _bot = null;
|
|
54
|
+
|
|
55
|
+
// ─── SlackBot class ────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Slack bot that bridges pipeline events to Slack channels.
|
|
59
|
+
*/
|
|
60
|
+
export class SlackBot {
|
|
61
|
+
/**
|
|
62
|
+
* @param {object} config
|
|
63
|
+
* @param {string} config.token — Slack Bot Token (xoxb-...)
|
|
64
|
+
* @param {string} [config.signingSecret] — Slack Signing Secret
|
|
65
|
+
* @param {string} [config.channel] — Default channel to post to
|
|
66
|
+
*/
|
|
67
|
+
constructor(config = {}) {
|
|
68
|
+
this._token = config.token;
|
|
69
|
+
this._signingSecret = config.signingSecret || '';
|
|
70
|
+
this._channel = config.channel || '#ai-pipeline';
|
|
71
|
+
this._subscriptions = [];
|
|
72
|
+
this._running = false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Start the bot — subscribe to pipeline events and post to Slack */
|
|
76
|
+
start() {
|
|
77
|
+
if (this._running) return;
|
|
78
|
+
this._running = true;
|
|
79
|
+
|
|
80
|
+
const handler = (event) => this._handleEvent(event);
|
|
81
|
+
bus.on('pipeline-event', handler);
|
|
82
|
+
bus.on('status', handler);
|
|
83
|
+
this._subscriptions.push(
|
|
84
|
+
{ event: 'pipeline-event', handler },
|
|
85
|
+
{ event: 'status', handler },
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
console.log(` 🔔 Slack bot started → ${this._channel}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Stop the bot — unsubscribe from all events */
|
|
92
|
+
stop() {
|
|
93
|
+
this._running = false;
|
|
94
|
+
for (const { event, handler } of this._subscriptions) {
|
|
95
|
+
bus.removeListener(event, handler);
|
|
96
|
+
}
|
|
97
|
+
this._subscriptions = [];
|
|
98
|
+
console.log(' 🔕 Slack bot stopped');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Handle a pipeline event and post to Slack.
|
|
103
|
+
* @param {object} event
|
|
104
|
+
*/
|
|
105
|
+
async _handleEvent(event) {
|
|
106
|
+
if (!this._running || !this._token) return;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
// Room messages — forward agent chat to Slack
|
|
110
|
+
const pipelineEvent = event.event;
|
|
111
|
+
if (pipelineEvent === 'room:message' && event.data) {
|
|
112
|
+
const { formatForSlack } = await import('../roleplay/agent-messenger.js');
|
|
113
|
+
const { isRoleplayEnabled } = await import('../roleplay/roleplay-config.js');
|
|
114
|
+
if (isRoleplayEnabled()) {
|
|
115
|
+
const payload = formatForSlack(event.data.role, event.data.message, event.data.type);
|
|
116
|
+
await slackApiCall('chat.postMessage', this._token, { channel: this._channel, ...payload });
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const blocks = this._eventToBlocks(event);
|
|
122
|
+
if (blocks) {
|
|
123
|
+
await slackApiCall('chat.postMessage', this._token, {
|
|
124
|
+
channel: this._channel,
|
|
125
|
+
blocks,
|
|
126
|
+
text: event.type || 'Pipeline update',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
} catch (e) {
|
|
130
|
+
console.error(`[Slack] Failed to post event: ${e.message}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Convert a pipeline event to Slack Block Kit blocks */
|
|
135
|
+
_eventToBlocks(event) {
|
|
136
|
+
const type = event.type || event.event;
|
|
137
|
+
if (!type) return null;
|
|
138
|
+
|
|
139
|
+
const emoji = {
|
|
140
|
+
review_done: '📝', deploy_result: '🚀', feature_start: '✨',
|
|
141
|
+
stage_change: '🔄', error: '❌', approval_needed: '⏳',
|
|
142
|
+
idle: '💤', stall: '⚠️',
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const icon = emoji[type] || 'ℹ️';
|
|
146
|
+
const header = `${icon} ${type.replace(/_/g, ' ').toUpperCase()}`;
|
|
147
|
+
|
|
148
|
+
const blocks = [
|
|
149
|
+
{ type: 'header', text: { type: 'plain_text', text: header } },
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
if (event.data?.message || event.message) {
|
|
153
|
+
blocks.push({
|
|
154
|
+
type: 'section',
|
|
155
|
+
text: { type: 'mrkdwn', text: event.data?.message || event.message },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (event.data?.stage || event.stage) {
|
|
160
|
+
blocks.push({
|
|
161
|
+
type: 'context',
|
|
162
|
+
elements: [{ type: 'mrkdwn', text: `*Stage:* ${event.data?.stage || event.stage}` }],
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return blocks;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Format pipeline status as Slack Block Kit.
|
|
171
|
+
* @param {object} status
|
|
172
|
+
* @returns {Array}
|
|
173
|
+
*/
|
|
174
|
+
_formatStatus(status) {
|
|
175
|
+
if (!status) return [{ type: 'section', text: { type: 'mrkdwn', text: '⚠️ No status data available' } }];
|
|
176
|
+
|
|
177
|
+
const stageEmoji = {
|
|
178
|
+
idle: '💤', reviewing: '📝', implementing: '🔧',
|
|
179
|
+
deploying: '🚀', testing: '🧪', fixing: '🔨',
|
|
180
|
+
};
|
|
181
|
+
const icon = stageEmoji[status.stage] || '❓';
|
|
182
|
+
|
|
183
|
+
return [
|
|
184
|
+
{ type: 'header', text: { type: 'plain_text', text: `${icon} Pipeline Status` } },
|
|
185
|
+
{
|
|
186
|
+
type: 'section',
|
|
187
|
+
fields: [
|
|
188
|
+
{ type: 'mrkdwn', text: `*Stage:*\n${status.stage || 'unknown'}` },
|
|
189
|
+
{ type: 'mrkdwn', text: `*Feature:*\n${status.current_feature || '—'}` },
|
|
190
|
+
{ type: 'mrkdwn', text: `*Auto-pilot:*\n${status.auto_pilot ? '✅ On' : '❌ Off'}` },
|
|
191
|
+
{ type: 'mrkdwn', text: `*Checkpoint:*\n${status.checkpoint || '—'}` },
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Format health data as Slack Block Kit.
|
|
199
|
+
* @param {object} health
|
|
200
|
+
* @returns {Array}
|
|
201
|
+
*/
|
|
202
|
+
_formatHealth(health) {
|
|
203
|
+
if (!health) return [{ type: 'section', text: { type: 'mrkdwn', text: '⚠️ No health data available' } }];
|
|
204
|
+
|
|
205
|
+
const overallIcon = health.status === 'healthy' ? '🟢' : health.status === 'degraded' ? '🟡' : '🔴';
|
|
206
|
+
|
|
207
|
+
const blocks = [
|
|
208
|
+
{ type: 'header', text: { type: 'plain_text', text: `${overallIcon} System Health` } },
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
if (health.checks && typeof health.checks === 'object') {
|
|
212
|
+
const lines = Object.entries(health.checks).map(([name, check]) => {
|
|
213
|
+
const icon = check.status === 'ok' ? '✅' : check.status === 'warn' ? '⚠️' : '❌';
|
|
214
|
+
return `${icon} *${name}:* ${check.message || check.status}`;
|
|
215
|
+
});
|
|
216
|
+
blocks.push({ type: 'section', text: { type: 'mrkdwn', text: lines.join('\n') } });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return blocks;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Slash command handler ─────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Process a slash command and return a response.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} command — the command name (e.g. 'status', 'deploy')
|
|
229
|
+
* @param {string} args — additional arguments
|
|
230
|
+
* @param {string} userId — Slack user ID
|
|
231
|
+
* @returns {Promise<{text:string, blocks?:Array}>}
|
|
232
|
+
*/
|
|
233
|
+
export async function handleSlashCommand(command, args, userId) {
|
|
234
|
+
const cmd = (command || '').trim().toLowerCase();
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
switch (cmd) {
|
|
238
|
+
case 'status': {
|
|
239
|
+
const status = await actions.getStatusData();
|
|
240
|
+
const bot = new SlackBot({});
|
|
241
|
+
return { text: 'Pipeline status', blocks: bot._formatStatus(status) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case 'feature': {
|
|
245
|
+
if (!args?.trim()) return { text: '❌ Usage: `/aicc feature <description>`' };
|
|
246
|
+
const result = await actions.runNewFeature(args.trim());
|
|
247
|
+
return { text: `✨ Feature started: ${args.trim()}`, blocks: [
|
|
248
|
+
{ type: 'section', text: { type: 'mrkdwn', text: `✨ *Feature started:* ${args.trim()}` } },
|
|
249
|
+
] };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case 'deploy': {
|
|
253
|
+
await actions.runDeploy();
|
|
254
|
+
return { text: '🚀 Deploy initiated' };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
case 'approve': {
|
|
258
|
+
await actions.runApprove();
|
|
259
|
+
return { text: '✅ Approved' };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
case 'reject': {
|
|
263
|
+
const reason = args?.trim() || 'Rejected via Slack';
|
|
264
|
+
await actions.runReject(reason);
|
|
265
|
+
return { text: `❌ Rejected: ${reason}` };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
case 'review': {
|
|
269
|
+
await actions.runReview();
|
|
270
|
+
return { text: '📝 Review triggered' };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
case 'health': {
|
|
274
|
+
const health = await actions.getHealthData();
|
|
275
|
+
const bot = new SlackBot({});
|
|
276
|
+
return { text: 'System health', blocks: bot._formatHealth(health) };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
case 'cost': {
|
|
280
|
+
const summary = getCostSummary();
|
|
281
|
+
const text = formatCostSummary(summary);
|
|
282
|
+
return { text, blocks: [
|
|
283
|
+
{ type: 'section', text: { type: 'mrkdwn', text: `\`\`\`\n${text}\n\`\`\`` } },
|
|
284
|
+
] };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
case 'retry': {
|
|
288
|
+
const fresh = args?.trim().toLowerCase() === 'fresh';
|
|
289
|
+
await actions.retryFromCheckpoint(fresh);
|
|
290
|
+
return { text: `🔄 Retry initiated${fresh ? ' (fresh)' : ''}` };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
default:
|
|
294
|
+
return {
|
|
295
|
+
text: '🤖 *AICC Commands:*\n' +
|
|
296
|
+
'• `status` — pipeline status\n' +
|
|
297
|
+
'• `feature <desc>` — create feature\n' +
|
|
298
|
+
'• `deploy` — deploy\n' +
|
|
299
|
+
'• `approve` — approve\n' +
|
|
300
|
+
'• `reject <reason>` — reject\n' +
|
|
301
|
+
'• `review` — trigger review\n' +
|
|
302
|
+
'• `health` — health check\n' +
|
|
303
|
+
'• `cost` — cost breakdown\n' +
|
|
304
|
+
'• `retry [fresh]` — retry from checkpoint',
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
} catch (e) {
|
|
308
|
+
return { text: `❌ Error: ${e.message}` };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── Public API ────────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Send a message to a Slack channel.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} channel — Channel name or ID
|
|
318
|
+
* @param {string} text — Fallback text
|
|
319
|
+
* @param {Array} [blocks] — Slack Block Kit blocks
|
|
320
|
+
*/
|
|
321
|
+
export async function sendSlackMessage(channel, text, blocks) {
|
|
322
|
+
let token;
|
|
323
|
+
try {
|
|
324
|
+
const cfg = getConfig();
|
|
325
|
+
token = cfg.slack?.token || process.env.SLACK_BOT_TOKEN;
|
|
326
|
+
} catch {
|
|
327
|
+
token = process.env.SLACK_BOT_TOKEN;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!token) throw new Error('Slack bot token not configured');
|
|
331
|
+
|
|
332
|
+
const body = { channel, text };
|
|
333
|
+
if (blocks) body.blocks = blocks;
|
|
334
|
+
return slackApiCall('chat.postMessage', token, body);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Start the Slack bot.
|
|
339
|
+
*
|
|
340
|
+
* @param {object} [options]
|
|
341
|
+
* @param {string} [options.token] — Slack Bot Token
|
|
342
|
+
* @param {string} [options.signingSecret] — Slack Signing Secret
|
|
343
|
+
* @param {string} [options.channel] — Default channel
|
|
344
|
+
* @returns {SlackBot}
|
|
345
|
+
*/
|
|
346
|
+
export function startSlackBot(options = {}) {
|
|
347
|
+
if (_bot) { _bot.stop(); }
|
|
348
|
+
|
|
349
|
+
let slackConfig = {};
|
|
350
|
+
try {
|
|
351
|
+
const cfg = getConfig();
|
|
352
|
+
slackConfig = cfg.slack || {};
|
|
353
|
+
} catch { /* use options only */ }
|
|
354
|
+
|
|
355
|
+
const config = {
|
|
356
|
+
token: options.token || slackConfig.token || process.env.SLACK_BOT_TOKEN,
|
|
357
|
+
signingSecret: options.signingSecret || slackConfig.signingSecret || process.env.SLACK_SIGNING_SECRET,
|
|
358
|
+
channel: options.channel || slackConfig.channel || '#ai-pipeline',
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
if (!config.token) {
|
|
362
|
+
console.error(' ✗ Slack bot token not configured.');
|
|
363
|
+
console.error(' Set SLACK_BOT_TOKEN env var or slack.token in aicc.config.js');
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
_bot = new SlackBot(config);
|
|
368
|
+
_bot.start();
|
|
369
|
+
return _bot;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Stop the Slack bot */
|
|
373
|
+
export function stopSlackBot() {
|
|
374
|
+
if (_bot) {
|
|
375
|
+
_bot.stop();
|
|
376
|
+
_bot = null;
|
|
377
|
+
}
|
|
378
|
+
}
|