Add P2 SaaS CI, P4 scheduled discovery, P6 agent bridge (Redis pub/sub), Caddyfile

- P2: Gitea Actions CI for SaaS backend (separate from Go agent CI)
- P4: ScheduledDiscovery with Redis distributed lock to prevent concurrent scans
- P6: AgentBridge — Redis pub/sub for SaaS↔agent communication (approvals + step results)
- Caddyfile: self-hosted reverse proxy with auto-TLS for all 6 products
This commit is contained in:
2026-03-01 03:16:33 +00:00
parent bbbea3519e
commit 2ceeac1a11
4 changed files with 242 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
import pino from 'pino';
import Redis from 'ioredis';
const logger = pino({ name: 'agent-ws' });
/**
* Agent communication via Redis pub/sub.
* SaaS publishes approval decisions, agent subscribes.
* Agent publishes step results, SaaS subscribes.
*
* Channel pattern: dd0c:run:{tenantId}:{executionId}:{direction}
* - direction: 'to_agent' | 'from_agent'
*/
export class AgentBridge {
private pub: Redis;
private sub: Redis;
constructor(redisUrl: string) {
this.pub = new Redis(redisUrl);
this.sub = new Redis(redisUrl);
}
/**
* Send approval decision to agent.
*/
async sendApproval(tenantId: string, executionId: string, stepId: string, decision: 'approve' | 'reject'): Promise<void> {
const channel = `dd0c:run:${tenantId}:${executionId}:to_agent`;
const message = JSON.stringify({ type: 'approval', stepId, decision, timestamp: Date.now() });
await this.pub.publish(channel, message);
logger.info({ tenantId, executionId, stepId, decision }, 'Approval sent to agent');
}
/**
* Subscribe to step results from agent.
*/
async onStepResult(
tenantId: string,
executionId: string,
callback: (result: StepResultMessage) => void,
): Promise<void> {
const channel = `dd0c:run:${tenantId}:${executionId}:from_agent`;
await this.sub.subscribe(channel);
this.sub.on('message', (ch, message) => {
if (ch !== channel) return;
try {
const parsed = JSON.parse(message) as StepResultMessage;
callback(parsed);
} catch (err) {
logger.warn({ error: (err as Error).message }, 'Invalid message from agent');
}
});
}
/**
* Publish step result from agent side.
*/
async publishStepResult(tenantId: string, executionId: string, result: StepResultMessage): Promise<void> {
const channel = `dd0c:run:${tenantId}:${executionId}:from_agent`;
await this.pub.publish(channel, JSON.stringify(result));
}
/**
* Subscribe to approval decisions from SaaS side (agent-side).
*/
async onApproval(
tenantId: string,
executionId: string,
callback: (decision: { stepId: string; decision: 'approve' | 'reject' }) => void,
): Promise<void> {
const channel = `dd0c:run:${tenantId}:${executionId}:to_agent`;
await this.sub.subscribe(channel);
this.sub.on('message', (ch, message) => {
if (ch !== channel) return;
try {
const parsed = JSON.parse(message);
if (parsed.type === 'approval') {
callback({ stepId: parsed.stepId, decision: parsed.decision });
}
} catch (err) {
logger.warn({ error: (err as Error).message }, 'Invalid approval message');
}
});
}
async close(): Promise<void> {
await this.sub.quit();
await this.pub.quit();
}
}
export interface StepResultMessage {
type: 'step_result';
stepIndex: number;
command: string;
exitCode: number;
status: 'success' | 'failed' | 'timed_out' | 'rejected' | 'skipped';
durationMs: number;
stdoutHash?: string;
stderrHash?: string;
timestamp: number;
}