twinclaw 1.0.0
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/README.md +66 -0
- package/bin/npm-twinclaw.js +17 -0
- package/bin/run-twinbot-cli.js +36 -0
- package/bin/twinbot.js +4 -0
- package/bin/twinclaw.js +4 -0
- package/dist/api/handlers/browser.js +160 -0
- package/dist/api/handlers/callback.js +80 -0
- package/dist/api/handlers/config-validate.js +19 -0
- package/dist/api/handlers/health.js +117 -0
- package/dist/api/handlers/local-state-backup.js +118 -0
- package/dist/api/handlers/persona-state.js +59 -0
- package/dist/api/handlers/skill-packages.js +94 -0
- package/dist/api/router.js +278 -0
- package/dist/api/runtime-event-producer.js +99 -0
- package/dist/api/shared.js +82 -0
- package/dist/api/websocket-hub.js +305 -0
- package/dist/config/config-loader.js +2 -0
- package/dist/config/env-schema.js +202 -0
- package/dist/config/env-validator.js +223 -0
- package/dist/config/identity-bootstrap.js +115 -0
- package/dist/config/json-config.js +344 -0
- package/dist/config/workspace.js +186 -0
- package/dist/core/channels-cli.js +77 -0
- package/dist/core/cli.js +119 -0
- package/dist/core/context-assembly.js +33 -0
- package/dist/core/doctor.js +365 -0
- package/dist/core/gateway-cli.js +323 -0
- package/dist/core/gateway.js +416 -0
- package/dist/core/heartbeat.js +54 -0
- package/dist/core/install-cli.js +320 -0
- package/dist/core/lane-executor.js +134 -0
- package/dist/core/logs-cli.js +70 -0
- package/dist/core/onboarding.js +760 -0
- package/dist/core/pairing-cli.js +78 -0
- package/dist/core/secret-vault-cli.js +204 -0
- package/dist/core/types.js +1 -0
- package/dist/index.js +404 -0
- package/dist/interfaces/dispatcher.js +214 -0
- package/dist/interfaces/telegram_handler.js +82 -0
- package/dist/interfaces/tui-dashboard.js +53 -0
- package/dist/interfaces/whatsapp_handler.js +94 -0
- package/dist/release/cli.js +97 -0
- package/dist/release/mvp-gate-cli.js +118 -0
- package/dist/release/twinbot-config-schema.js +162 -0
- package/dist/release/twinclaw-config-schema.js +162 -0
- package/dist/services/block-chunker.js +174 -0
- package/dist/services/browser-service.js +334 -0
- package/dist/services/context-lifecycle.js +314 -0
- package/dist/services/db.js +1055 -0
- package/dist/services/delivery-tracker.js +110 -0
- package/dist/services/dm-pairing.js +245 -0
- package/dist/services/embedding-service.js +125 -0
- package/dist/services/file-watcher.js +125 -0
- package/dist/services/inbound-debounce.js +92 -0
- package/dist/services/incident-manager.js +516 -0
- package/dist/services/job-scheduler.js +176 -0
- package/dist/services/local-state-backup.js +682 -0
- package/dist/services/mcp-client-adapter.js +291 -0
- package/dist/services/mcp-server-manager.js +143 -0
- package/dist/services/model-router.js +927 -0
- package/dist/services/mvp-gate.js +845 -0
- package/dist/services/orchestration-service.js +422 -0
- package/dist/services/persona-state.js +256 -0
- package/dist/services/policy-engine.js +92 -0
- package/dist/services/proactive-notifier.js +94 -0
- package/dist/services/queue-service.js +146 -0
- package/dist/services/release-pipeline.js +652 -0
- package/dist/services/runtime-budget-governor.js +415 -0
- package/dist/services/secret-vault.js +704 -0
- package/dist/services/semantic-memory.js +249 -0
- package/dist/services/skill-package-manager.js +806 -0
- package/dist/services/skill-registry.js +122 -0
- package/dist/services/streaming-output.js +75 -0
- package/dist/services/stt-service.js +39 -0
- package/dist/services/tts-service.js +44 -0
- package/dist/skills/builtin.js +250 -0
- package/dist/skills/shell.js +87 -0
- package/dist/skills/types.js +1 -0
- package/dist/types/api.js +1 -0
- package/dist/types/context-budget.js +1 -0
- package/dist/types/doctor.js +1 -0
- package/dist/types/file-watcher.js +1 -0
- package/dist/types/incident.js +1 -0
- package/dist/types/local-state-backup.js +1 -0
- package/dist/types/mcp.js +1 -0
- package/dist/types/messaging.js +1 -0
- package/dist/types/model-routing.js +1 -0
- package/dist/types/mvp-gate.js +2 -0
- package/dist/types/orchestration.js +1 -0
- package/dist/types/persona-state.js +22 -0
- package/dist/types/policy.js +1 -0
- package/dist/types/reasoning-graph.js +1 -0
- package/dist/types/release.js +1 -0
- package/dist/types/reliability.js +1 -0
- package/dist/types/runtime-budget.js +1 -0
- package/dist/types/scheduler.js +1 -0
- package/dist/types/secret-vault.js +1 -0
- package/dist/types/skill-packages.js +1 -0
- package/dist/types/websocket.js +14 -0
- package/dist/utils/logger.js +57 -0
- package/dist/utils/retry.js +61 -0
- package/dist/utils/secret-scan.js +208 -0
- package/mcp-servers.json +179 -0
- package/package.json +81 -0
- package/skill-packages.json +92 -0
- package/skill-packages.lock.json +5 -0
- package/src/skills/builtin.ts +275 -0
- package/src/skills/shell.ts +118 -0
- package/src/skills/types.ts +30 -0
- package/src/types/api.ts +252 -0
- package/src/types/blessed-contrib.d.ts +4 -0
- package/src/types/context-budget.ts +76 -0
- package/src/types/doctor.ts +29 -0
- package/src/types/file-watcher.ts +26 -0
- package/src/types/incident.ts +57 -0
- package/src/types/local-state-backup.ts +121 -0
- package/src/types/mcp.ts +106 -0
- package/src/types/messaging.ts +35 -0
- package/src/types/model-routing.ts +61 -0
- package/src/types/mvp-gate.ts +99 -0
- package/src/types/orchestration.ts +65 -0
- package/src/types/persona-state.ts +61 -0
- package/src/types/policy.ts +27 -0
- package/src/types/reasoning-graph.ts +58 -0
- package/src/types/release.ts +115 -0
- package/src/types/reliability.ts +43 -0
- package/src/types/runtime-budget.ts +85 -0
- package/src/types/scheduler.ts +47 -0
- package/src/types/secret-vault.ts +62 -0
- package/src/types/skill-packages.ts +81 -0
- package/src/types/sqlite-vec.d.ts +5 -0
- package/src/types/websocket.ts +122 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { PersonaConflictError, PersonaValidationError, } from '../../types/persona-state.js';
|
|
2
|
+
import { sendError, sendOk } from '../shared.js';
|
|
3
|
+
function sendPersonaDiagnosticError(res, status, message, diagnostics) {
|
|
4
|
+
const correlationId = res.locals.correlationId;
|
|
5
|
+
const body = {
|
|
6
|
+
ok: false,
|
|
7
|
+
error: message,
|
|
8
|
+
data: diagnostics,
|
|
9
|
+
correlationId,
|
|
10
|
+
timestamp: new Date().toISOString(),
|
|
11
|
+
};
|
|
12
|
+
res.status(status).json(body);
|
|
13
|
+
}
|
|
14
|
+
export function handlePersonaStateGet(deps) {
|
|
15
|
+
return async (_req, res) => {
|
|
16
|
+
try {
|
|
17
|
+
const state = await deps.personaStateService.getState();
|
|
18
|
+
sendOk(res, state);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
sendError(res, `Failed to load persona state: ${message}`, 500);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function handlePersonaStateUpdate(deps) {
|
|
27
|
+
return async (req, res) => {
|
|
28
|
+
const body = (req.body ?? {});
|
|
29
|
+
const input = {
|
|
30
|
+
expectedRevision: body.expectedRevision,
|
|
31
|
+
soul: body.soul,
|
|
32
|
+
identity: body.identity,
|
|
33
|
+
user: body.user,
|
|
34
|
+
};
|
|
35
|
+
try {
|
|
36
|
+
const result = await deps.personaStateService.updateState(input);
|
|
37
|
+
sendOk(res, result);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error instanceof PersonaValidationError) {
|
|
41
|
+
sendPersonaDiagnosticError(res, 400, error.message, {
|
|
42
|
+
hints: error.hints,
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (error instanceof PersonaConflictError) {
|
|
47
|
+
sendPersonaDiagnosticError(res, 409, error.message, {
|
|
48
|
+
hints: error.hints,
|
|
49
|
+
latestRevision: error.latestRevision,
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
54
|
+
sendPersonaDiagnosticError(res, 500, `Failed to update persona state: ${message}`, {
|
|
55
|
+
hints: ['Retry the operation. If it persists, inspect local filesystem permissions.'],
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { sendError, sendOk } from '../shared.js';
|
|
2
|
+
function readPackageName(body) {
|
|
3
|
+
if (!body || typeof body !== 'object' || !('name' in body)) {
|
|
4
|
+
throw new Error("Request body must include 'name'.");
|
|
5
|
+
}
|
|
6
|
+
const candidate = body.name;
|
|
7
|
+
if (typeof candidate !== 'string' || candidate.trim().length === 0) {
|
|
8
|
+
throw new Error("Field 'name' must be a non-empty string.");
|
|
9
|
+
}
|
|
10
|
+
return candidate.trim();
|
|
11
|
+
}
|
|
12
|
+
function readVersionRange(body) {
|
|
13
|
+
if (!body || typeof body !== 'object' || !('versionRange' in body)) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const candidate = body.versionRange;
|
|
17
|
+
if (candidate === undefined || candidate === null) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
if (typeof candidate !== 'string') {
|
|
21
|
+
throw new Error("Field 'versionRange' must be a string when provided.");
|
|
22
|
+
}
|
|
23
|
+
const trimmed = candidate.trim();
|
|
24
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
25
|
+
}
|
|
26
|
+
function mapPackageErrorStatus(message) {
|
|
27
|
+
if (message.includes('must be') ||
|
|
28
|
+
message.includes('required') ||
|
|
29
|
+
message.includes('Invalid')) {
|
|
30
|
+
return 400;
|
|
31
|
+
}
|
|
32
|
+
if (message.includes('No compatible version') ||
|
|
33
|
+
message.includes('Compatibility gate blocked') ||
|
|
34
|
+
message.includes('Cannot uninstall')) {
|
|
35
|
+
return 409;
|
|
36
|
+
}
|
|
37
|
+
if (message.includes('not installed')) {
|
|
38
|
+
return 404;
|
|
39
|
+
}
|
|
40
|
+
return 500;
|
|
41
|
+
}
|
|
42
|
+
export function handleSkillPackageDiagnostics(deps) {
|
|
43
|
+
return async (_req, res) => {
|
|
44
|
+
try {
|
|
45
|
+
const diagnostics = await deps.mcpManager.getSkillPackageDiagnostics();
|
|
46
|
+
sendOk(res, diagnostics);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
50
|
+
sendError(res, message, mapPackageErrorStatus(message));
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function handleSkillPackageInstall(deps) {
|
|
55
|
+
return async (req, res) => {
|
|
56
|
+
try {
|
|
57
|
+
const packageName = readPackageName(req.body);
|
|
58
|
+
const versionRange = readVersionRange(req.body);
|
|
59
|
+
const result = await deps.mcpManager.installSkillPackage(packageName, versionRange);
|
|
60
|
+
sendOk(res, result, result.changed ? 201 : 200);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
64
|
+
sendError(res, message, mapPackageErrorStatus(message));
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function handleSkillPackageUpgrade(deps) {
|
|
69
|
+
return async (req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
const packageName = readPackageName(req.body);
|
|
72
|
+
const versionRange = readVersionRange(req.body);
|
|
73
|
+
const result = await deps.mcpManager.upgradeSkillPackage(packageName, versionRange);
|
|
74
|
+
sendOk(res, result);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
78
|
+
sendError(res, message, mapPackageErrorStatus(message));
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function handleSkillPackageUninstall(deps) {
|
|
83
|
+
return async (req, res) => {
|
|
84
|
+
try {
|
|
85
|
+
const packageName = readPackageName(req.body);
|
|
86
|
+
const result = await deps.mcpManager.uninstallSkillPackage(packageName);
|
|
87
|
+
sendOk(res, result);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
91
|
+
sendError(res, message, mapPackageErrorStatus(message));
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { handleHealth, handleLiveness, handleReadiness } from './handlers/health.js';
|
|
4
|
+
import { handleBrowserSnapshot, handleBrowserClick } from './handlers/browser.js';
|
|
5
|
+
import { handleWebhookCallback } from './handlers/callback.js';
|
|
6
|
+
import { handleSkillPackageDiagnostics, handleSkillPackageInstall, handleSkillPackageUpgrade, handleSkillPackageUninstall, } from './handlers/skill-packages.js';
|
|
7
|
+
import { handleLocalStateBackupDiagnostics, handleLocalStateCreateSnapshot, handleLocalStateRestoreSnapshot, } from './handlers/local-state-backup.js';
|
|
8
|
+
import { handlePersonaStateGet, handlePersonaStateUpdate, } from './handlers/persona-state.js';
|
|
9
|
+
import { handleConfigValidate } from './handlers/config-validate.js';
|
|
10
|
+
import { requestLogger, requireSignature, sendError } from './shared.js';
|
|
11
|
+
import { BrowserService } from '../services/browser-service.js';
|
|
12
|
+
import { getPersonaStateService } from '../services/persona-state.js';
|
|
13
|
+
import { logThought } from '../utils/logger.js';
|
|
14
|
+
import { getCallbackOutcomeCounts } from '../services/db.js';
|
|
15
|
+
import { sendOk } from './shared.js';
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { getConfigValue } from '../config/config-loader.js';
|
|
19
|
+
const DEFAULT_PORT = 18789;
|
|
20
|
+
/**
|
|
21
|
+
* Create and start the Control Plane HTTP API server.
|
|
22
|
+
*
|
|
23
|
+
* Endpoints:
|
|
24
|
+
* GET /health — System health snapshot
|
|
25
|
+
* GET /config/validate — Runtime config and env-key validation report
|
|
26
|
+
* GET /backup/diagnostics — Local backup + restore diagnostics
|
|
27
|
+
* POST /backup/snapshot — Trigger manual local-state snapshot
|
|
28
|
+
* POST /backup/restore — Restore local-state snapshot (dry-run supported)
|
|
29
|
+
* GET /skills/packages — Skill package diagnostics and compatibility state
|
|
30
|
+
* POST /skills/packages/* — Install/upgrade/uninstall skill packages
|
|
31
|
+
* GET /reliability — Delivery reliability metrics
|
|
32
|
+
* GET /budget/state — Runtime budget state snapshot
|
|
33
|
+
* GET /budget/events — Recent budget policy events
|
|
34
|
+
* POST /budget/profile — Manual profile override (signed)
|
|
35
|
+
* POST /budget/reset — Reset budget policy state (signed)
|
|
36
|
+
* GET /routing/telemetry — Model routing telemetry snapshot
|
|
37
|
+
* POST /routing/mode — Update model routing fallback mode (signed)
|
|
38
|
+
* GET /incidents/current — Active incident + safe-mode snapshot
|
|
39
|
+
* GET /incidents/history — Historical incidents and timeline events
|
|
40
|
+
* POST /incidents/evaluate — Force immediate incident detection cycle
|
|
41
|
+
* GET /persona/state — Read persona source-of-truth state
|
|
42
|
+
* PUT /persona/state — Safely update persona source-of-truth state
|
|
43
|
+
* POST /browser/snapshot — Take a browser screenshot + accessibility tree
|
|
44
|
+
* POST /browser/click — Click an element by selector or coordinates
|
|
45
|
+
* POST /callback/webhook — Ingest external task completion events (authenticated)
|
|
46
|
+
*/
|
|
47
|
+
export function startApiServer(deps) {
|
|
48
|
+
const app = express();
|
|
49
|
+
const port = Number(getConfigValue('API_PORT')) || DEFAULT_PORT;
|
|
50
|
+
const server = createServer(app);
|
|
51
|
+
// Attach WebSocket hub if provided
|
|
52
|
+
if (deps.wsHub) {
|
|
53
|
+
deps.wsHub.attach(server);
|
|
54
|
+
}
|
|
55
|
+
// ── Global Middleware ───────────────────────────────────────────────────────
|
|
56
|
+
app.use(express.json());
|
|
57
|
+
app.use(requestLogger);
|
|
58
|
+
// ── Shared Services ─────────────────────────────────────────────────────────
|
|
59
|
+
const browserService = new BrowserService();
|
|
60
|
+
const healthDeps = {
|
|
61
|
+
heartbeat: deps.heartbeat,
|
|
62
|
+
skillRegistry: deps.skillRegistry,
|
|
63
|
+
mcpManager: deps.mcpManager,
|
|
64
|
+
budgetGovernor: deps.budgetGovernor,
|
|
65
|
+
localStateBackup: deps.localStateBackup,
|
|
66
|
+
modelRouter: deps.modelRouter,
|
|
67
|
+
};
|
|
68
|
+
const browserDeps = { browserService };
|
|
69
|
+
const callbackDeps = { gateway: deps.gateway };
|
|
70
|
+
const skillPackageDeps = { mcpManager: deps.mcpManager };
|
|
71
|
+
const localStateBackupDeps = { backupService: deps.localStateBackup };
|
|
72
|
+
const personaStateDeps = {
|
|
73
|
+
personaStateService: getPersonaStateService(),
|
|
74
|
+
};
|
|
75
|
+
// ── Routes ──────────────────────────────────────────────────────────────────
|
|
76
|
+
app.get('/health', handleHealth(healthDeps));
|
|
77
|
+
app.get('/health/live', handleLiveness());
|
|
78
|
+
app.get('/health/ready', handleReadiness(healthDeps));
|
|
79
|
+
app.get('/config/validate', handleConfigValidate());
|
|
80
|
+
app.get('/backup/diagnostics', handleLocalStateBackupDiagnostics(localStateBackupDeps));
|
|
81
|
+
app.post('/backup/snapshot', handleLocalStateCreateSnapshot(localStateBackupDeps));
|
|
82
|
+
app.post('/backup/restore', handleLocalStateRestoreSnapshot(localStateBackupDeps));
|
|
83
|
+
app.get('/skills/packages', handleSkillPackageDiagnostics(skillPackageDeps));
|
|
84
|
+
app.post('/skills/packages/install', handleSkillPackageInstall(skillPackageDeps));
|
|
85
|
+
app.post('/skills/packages/upgrade', handleSkillPackageUpgrade(skillPackageDeps));
|
|
86
|
+
app.post('/skills/packages/uninstall', handleSkillPackageUninstall(skillPackageDeps));
|
|
87
|
+
app.get('/reliability', (_req, res) => {
|
|
88
|
+
const queueMetrics = deps.dispatcher?.queue.getStats() ?? null;
|
|
89
|
+
const callbackCounts = getCallbackOutcomeCounts();
|
|
90
|
+
const callbackMetrics = {
|
|
91
|
+
totalAccepted: callbackCounts.accepted,
|
|
92
|
+
totalDuplicate: callbackCounts.duplicate,
|
|
93
|
+
totalRejected: callbackCounts.rejected,
|
|
94
|
+
};
|
|
95
|
+
sendOk(res, {
|
|
96
|
+
queue: queueMetrics,
|
|
97
|
+
callbacks: callbackMetrics
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
app.get('/budget/state', (req, res) => {
|
|
101
|
+
if (!deps.budgetGovernor) {
|
|
102
|
+
sendError(res, 'Runtime budget governor not initialized.', 503);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : undefined;
|
|
106
|
+
sendOk(res, deps.budgetGovernor.getSnapshot(sessionId));
|
|
107
|
+
});
|
|
108
|
+
app.get('/budget/events', (req, res) => {
|
|
109
|
+
if (!deps.budgetGovernor) {
|
|
110
|
+
sendError(res, 'Runtime budget governor not initialized.', 503);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const requestedLimit = Number(req.query.limit ?? 100);
|
|
114
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
|
|
115
|
+
? Math.min(500, Math.floor(requestedLimit))
|
|
116
|
+
: 100;
|
|
117
|
+
sendOk(res, { events: deps.budgetGovernor.getRecentEvents(limit) });
|
|
118
|
+
});
|
|
119
|
+
app.post('/budget/profile', requireSignature, (req, res) => {
|
|
120
|
+
if (!deps.budgetGovernor) {
|
|
121
|
+
sendError(res, 'Runtime budget governor not initialized.', 503);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const rawProfile = req.body?.profile;
|
|
125
|
+
if (rawProfile !== null && rawProfile !== undefined && typeof rawProfile !== 'string') {
|
|
126
|
+
sendError(res, 'Invalid profile. Expected one of: economy, balanced, performance, null.', 400);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (typeof rawProfile === 'string' && !['economy', 'balanced', 'performance'].includes(rawProfile)) {
|
|
130
|
+
sendError(res, 'Invalid profile. Expected one of: economy, balanced, performance, null.', 400);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const profile = typeof rawProfile === 'string'
|
|
134
|
+
? rawProfile
|
|
135
|
+
: null;
|
|
136
|
+
deps.budgetGovernor.setManualProfile(profile, typeof req.body?.sessionId === 'string' ? req.body.sessionId : undefined);
|
|
137
|
+
sendOk(res, {
|
|
138
|
+
message: profile
|
|
139
|
+
? `Manual budget profile set to '${profile}'.`
|
|
140
|
+
: 'Manual budget profile override cleared.',
|
|
141
|
+
snapshot: deps.budgetGovernor.getSnapshot(typeof req.body?.sessionId === 'string' ? req.body.sessionId : undefined),
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
app.post('/budget/reset', requireSignature, (req, res) => {
|
|
145
|
+
if (!deps.budgetGovernor) {
|
|
146
|
+
sendError(res, 'Runtime budget governor not initialized.', 503);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const sessionId = typeof req.body?.sessionId === 'string' ? req.body.sessionId : undefined;
|
|
150
|
+
deps.budgetGovernor.resetPolicyState(sessionId);
|
|
151
|
+
sendOk(res, {
|
|
152
|
+
message: 'Runtime budget policy state reset.',
|
|
153
|
+
snapshot: deps.budgetGovernor.getSnapshot(sessionId),
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
app.get('/routing/telemetry', (_req, res) => {
|
|
157
|
+
if (!deps.modelRouter) {
|
|
158
|
+
sendError(res, 'Model router not initialized.', 503);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
sendOk(res, deps.modelRouter.getHealthSnapshot());
|
|
162
|
+
});
|
|
163
|
+
app.post('/routing/mode', requireSignature, (req, res) => {
|
|
164
|
+
if (!deps.modelRouter) {
|
|
165
|
+
sendError(res, 'Model router not initialized.', 503);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const mode = req.body?.mode;
|
|
169
|
+
if (typeof mode !== 'string' || !['intelligent_pacing', 'aggressive_fallback'].includes(mode)) {
|
|
170
|
+
sendError(res, 'Invalid mode. Expected one of: intelligent_pacing, aggressive_fallback.', 400);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const snapshot = deps.modelRouter.setFallbackMode(mode);
|
|
174
|
+
sendOk(res, {
|
|
175
|
+
message: `Routing fallback mode set to '${mode}'.`,
|
|
176
|
+
snapshot,
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
app.get('/logs', async (_req, res) => {
|
|
180
|
+
try {
|
|
181
|
+
const dateIso = new Date().toISOString().slice(0, 10);
|
|
182
|
+
const logPath = path.resolve('memory', `${dateIso}.md`);
|
|
183
|
+
const content = await readFile(logPath, 'utf8').catch(() => 'No logs found for today.');
|
|
184
|
+
// Simple parsing to turn markdown into a list of "entries"
|
|
185
|
+
// Entries are separated by ## title @ timestamp
|
|
186
|
+
const sections = content.split(/\n## /).filter(Boolean);
|
|
187
|
+
const entries = sections.map(s => {
|
|
188
|
+
const [header, ...bodyLines] = s.split('\n');
|
|
189
|
+
const [type, timestamp] = header.split(' @ ');
|
|
190
|
+
return {
|
|
191
|
+
timestamp: timestamp || new Date().toISOString(),
|
|
192
|
+
level: type.toUpperCase(),
|
|
193
|
+
message: bodyLines.join('\n').trim()
|
|
194
|
+
};
|
|
195
|
+
}).reverse().slice(0, 100); // Return last 100 entries
|
|
196
|
+
sendOk(res, entries);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
sendError(res, 'Failed to read logs.', 500);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
app.post('/reliability/replay/:id', (_req, res) => {
|
|
203
|
+
const id = _req.params.id;
|
|
204
|
+
if (!deps.dispatcher) {
|
|
205
|
+
sendError(res, 'Dispatcher not active.', 500);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
deps.dispatcher.queue.requeueDeadLetter(id);
|
|
210
|
+
sendOk(res, { message: `Queued replay for message ${id}` });
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
sendError(res, `Failed to requeue: ${err instanceof Error ? err.message : String(err)}`, 500);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
app.get('/incidents/current', (_req, res) => {
|
|
217
|
+
if (!deps.incidentManager) {
|
|
218
|
+
sendError(res, 'Incident manager not initialized.', 503);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
sendOk(res, {
|
|
222
|
+
safeMode: deps.incidentManager.isSafeModeEnabled(),
|
|
223
|
+
incidents: deps.incidentManager.getCurrentIncidents(),
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
app.get('/incidents/history', (req, res) => {
|
|
227
|
+
if (!deps.incidentManager) {
|
|
228
|
+
sendError(res, 'Incident manager not initialized.', 503);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const requestedLimit = Number(req.query.limit ?? 200);
|
|
232
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
|
|
233
|
+
? Math.min(500, Math.floor(requestedLimit))
|
|
234
|
+
: 200;
|
|
235
|
+
sendOk(res, {
|
|
236
|
+
incidents: deps.incidentManager.getIncidentHistory(limit),
|
|
237
|
+
timeline: deps.incidentManager.getIncidentTimeline(limit),
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
app.post('/incidents/evaluate', (_req, res) => {
|
|
241
|
+
if (!deps.incidentManager) {
|
|
242
|
+
sendError(res, 'Incident manager not initialized.', 503);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const incidents = deps.incidentManager.evaluateNow();
|
|
246
|
+
sendOk(res, {
|
|
247
|
+
safeMode: deps.incidentManager.isSafeModeEnabled(),
|
|
248
|
+
incidents,
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
app.get('/persona/state', handlePersonaStateGet(personaStateDeps));
|
|
252
|
+
app.put('/persona/state', handlePersonaStateUpdate(personaStateDeps));
|
|
253
|
+
app.post('/browser/snapshot', handleBrowserSnapshot(browserDeps));
|
|
254
|
+
app.post('/browser/click', handleBrowserClick(browserDeps));
|
|
255
|
+
app.post('/callback/webhook', requireSignature, handleWebhookCallback(callbackDeps));
|
|
256
|
+
app.post('/system/halt', requireSignature, (_req, res) => {
|
|
257
|
+
void logThought('[API] Received /system/halt request from Control Plane GUI. Halting node process.');
|
|
258
|
+
sendOk(res, { message: 'Agent halting...' });
|
|
259
|
+
// Give the response a moment to flush before killing process
|
|
260
|
+
setTimeout(() => process.exit(0), 500);
|
|
261
|
+
});
|
|
262
|
+
app.get('/ws/metrics', (_req, res) => {
|
|
263
|
+
if (!deps.wsHub) {
|
|
264
|
+
sendError(res, 'WebSocket hub not initialized.', 503);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
sendOk(res, deps.wsHub.getMetrics());
|
|
268
|
+
});
|
|
269
|
+
// ── Catch-all 404 ──────────────────────────────────────────────────────────
|
|
270
|
+
app.use((_req, res) => {
|
|
271
|
+
sendError(res, 'Not found.', 404);
|
|
272
|
+
});
|
|
273
|
+
// ── Start ──────────────────────────────────────────────────────────────────
|
|
274
|
+
server.listen(port, () => {
|
|
275
|
+
console.log(`[TwinBot API] Control plane listening on http://localhost:${port}`);
|
|
276
|
+
void logThought(`[API] HTTP server started on port ${port}.`);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { logThought } from '../utils/logger.js';
|
|
2
|
+
import { getCallbackOutcomeCounts } from '../services/db.js';
|
|
3
|
+
const DEFAULT_PUBLISH_INTERVAL_MS = 5_000;
|
|
4
|
+
/**
|
|
5
|
+
* Collects state from existing runtime services on a fixed interval and
|
|
6
|
+
* publishes typed event envelopes through the WebSocket hub.
|
|
7
|
+
*
|
|
8
|
+
* Also responds to new-subscription callbacks from the hub by dispatching an
|
|
9
|
+
* immediate full-state snapshot so newly connected clients receive current
|
|
10
|
+
* state without waiting for the next polling cycle.
|
|
11
|
+
*/
|
|
12
|
+
export class RuntimeEventProducer {
|
|
13
|
+
#hub;
|
|
14
|
+
#deps;
|
|
15
|
+
#intervalMs;
|
|
16
|
+
#timer = null;
|
|
17
|
+
constructor(deps, config = {}) {
|
|
18
|
+
const { hub, ...rest } = deps;
|
|
19
|
+
this.#hub = hub;
|
|
20
|
+
this.#deps = rest;
|
|
21
|
+
this.#intervalMs = config.publishIntervalMs ?? DEFAULT_PUBLISH_INTERVAL_MS;
|
|
22
|
+
// When a client subscribes, immediately send a full snapshot
|
|
23
|
+
this.#hub.onSubscribe = (clientId, topics) => {
|
|
24
|
+
this.#dispatchSnapshotTo(clientId, topics);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
start() {
|
|
28
|
+
if (this.#timer)
|
|
29
|
+
return;
|
|
30
|
+
this.#timer = setInterval(() => {
|
|
31
|
+
this.#publishAll();
|
|
32
|
+
}, this.#intervalMs);
|
|
33
|
+
void logThought('[RuntimeEventProducer] Started periodic event publishing.');
|
|
34
|
+
}
|
|
35
|
+
stop() {
|
|
36
|
+
if (this.#timer) {
|
|
37
|
+
clearInterval(this.#timer);
|
|
38
|
+
this.#timer = null;
|
|
39
|
+
}
|
|
40
|
+
void logThought('[RuntimeEventProducer] Stopped.');
|
|
41
|
+
}
|
|
42
|
+
// ── Snapshot ───────────────────────────────────────────────────────────────
|
|
43
|
+
/**
|
|
44
|
+
* Build a full-state snapshot keyed by topic for the given set of topics.
|
|
45
|
+
* Used for initial snapshot on subscription and for testing.
|
|
46
|
+
*/
|
|
47
|
+
collectSnapshot(topics) {
|
|
48
|
+
const all = !topics || topics.length === 0;
|
|
49
|
+
const include = (t) => all || topics.includes(t);
|
|
50
|
+
const snapshot = {};
|
|
51
|
+
if (include('incidents') && this.#deps.incidentManager) {
|
|
52
|
+
snapshot.incidents = {
|
|
53
|
+
safeMode: this.#deps.incidentManager.isSafeModeEnabled(),
|
|
54
|
+
current: this.#deps.incidentManager.getCurrentIncidents(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (include('routing') && this.#deps.modelRouter) {
|
|
58
|
+
snapshot.routing = this.#deps.modelRouter.getHealthSnapshot();
|
|
59
|
+
}
|
|
60
|
+
if (include('reliability') && this.#deps.dispatcher) {
|
|
61
|
+
snapshot.reliability = this.#buildReliabilityPayload();
|
|
62
|
+
}
|
|
63
|
+
return snapshot;
|
|
64
|
+
}
|
|
65
|
+
// ── Private Helpers ────────────────────────────────────────────────────────
|
|
66
|
+
#publishAll() {
|
|
67
|
+
if (this.#deps.incidentManager) {
|
|
68
|
+
this.#hub.publish('incidents', {
|
|
69
|
+
safeMode: this.#deps.incidentManager.isSafeModeEnabled(),
|
|
70
|
+
current: this.#deps.incidentManager.getCurrentIncidents(),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (this.#deps.modelRouter) {
|
|
74
|
+
this.#hub.publish('routing', this.#deps.modelRouter.getHealthSnapshot());
|
|
75
|
+
}
|
|
76
|
+
if (this.#deps.dispatcher) {
|
|
77
|
+
this.#hub.publish('reliability', this.#buildReliabilityPayload());
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
#buildReliabilityPayload() {
|
|
81
|
+
const queueMetrics = this.#deps.dispatcher.queue.getStats() ?? null;
|
|
82
|
+
const callbackCounts = getCallbackOutcomeCounts();
|
|
83
|
+
return {
|
|
84
|
+
queue: queueMetrics,
|
|
85
|
+
callbacks: {
|
|
86
|
+
totalAccepted: callbackCounts.accepted,
|
|
87
|
+
totalDuplicate: callbackCounts.duplicate,
|
|
88
|
+
totalRejected: callbackCounts.rejected,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
#dispatchSnapshotTo(clientId, subscribedTopics) {
|
|
93
|
+
const snapshot = this.collectSnapshot(subscribedTopics);
|
|
94
|
+
if (Object.keys(snapshot).length === 0)
|
|
95
|
+
return;
|
|
96
|
+
this.#hub.sendSnapshotTo(clientId, snapshot);
|
|
97
|
+
void logThought(`[RuntimeEventProducer] Dispatched initial snapshot to client ${clientId}.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual, randomUUID } from 'node:crypto';
|
|
2
|
+
import { logThought, scrubSensitiveText } from '../utils/logger.js';
|
|
3
|
+
import { getSecretVaultService } from '../services/secret-vault.js';
|
|
4
|
+
// ── Response Helpers ────────────────────────────────────────────────────────
|
|
5
|
+
/** Send a successful JSON response using the standard envelope. */
|
|
6
|
+
export function sendOk(res, data, status = 200) {
|
|
7
|
+
const correlationId = res.locals.correlationId;
|
|
8
|
+
const body = {
|
|
9
|
+
ok: true,
|
|
10
|
+
data,
|
|
11
|
+
correlationId,
|
|
12
|
+
timestamp: new Date().toISOString(),
|
|
13
|
+
};
|
|
14
|
+
res.status(status).json(body);
|
|
15
|
+
}
|
|
16
|
+
/** Send an error JSON response using the standard envelope. */
|
|
17
|
+
export function sendError(res, message, status = 400) {
|
|
18
|
+
const correlationId = res.locals.correlationId;
|
|
19
|
+
const redactedMessage = scrubSensitiveText(message);
|
|
20
|
+
const body = {
|
|
21
|
+
ok: false,
|
|
22
|
+
error: redactedMessage,
|
|
23
|
+
correlationId,
|
|
24
|
+
timestamp: new Date().toISOString(),
|
|
25
|
+
};
|
|
26
|
+
res.status(status).json(body);
|
|
27
|
+
}
|
|
28
|
+
// ── Auth Middleware ──────────────────────────────────────────────────────────
|
|
29
|
+
/**
|
|
30
|
+
* Validate the `X-Signature` header on incoming webhook callbacks.
|
|
31
|
+
*
|
|
32
|
+
* Expected format: `sha256=<hex digest of HMAC-SHA256(body, API_SECRET)>`
|
|
33
|
+
*
|
|
34
|
+
* If API_SECRET is not configured, all callback requests are rejected.
|
|
35
|
+
*/
|
|
36
|
+
export function requireSignature(req, res, next) {
|
|
37
|
+
const apiSecret = getSecretVaultService().readSecret('API_SECRET') ?? '';
|
|
38
|
+
if (!apiSecret) {
|
|
39
|
+
void logThought('[API] Webhook rejected — API_SECRET not configured.');
|
|
40
|
+
sendError(res, 'Webhook endpoint not configured (missing API_SECRET).', 503);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const signatureHeader = req.headers['x-signature'];
|
|
44
|
+
if (typeof signatureHeader !== 'string' || !signatureHeader.startsWith('sha256=')) {
|
|
45
|
+
void logThought('[API] Webhook rejected — missing or malformed X-Signature header.');
|
|
46
|
+
sendError(res, 'Missing or malformed X-Signature header.', 401);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const providedHex = signatureHeader.slice('sha256='.length);
|
|
50
|
+
const rawBody = JSON.stringify(req.body);
|
|
51
|
+
const expectedHex = createHmac('sha256', apiSecret).update(rawBody).digest('hex');
|
|
52
|
+
const provided = Buffer.from(providedHex, 'hex');
|
|
53
|
+
const expected = Buffer.from(expectedHex, 'hex');
|
|
54
|
+
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
|
|
55
|
+
void logThought('[API] Webhook rejected — signature mismatch.');
|
|
56
|
+
sendError(res, 'Invalid signature.', 403);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
next();
|
|
60
|
+
}
|
|
61
|
+
// ── Error Mapping ───────────────────────────────────────────────────────────
|
|
62
|
+
/** Map a caught error to a status code and message. */
|
|
63
|
+
export function mapError(err) {
|
|
64
|
+
if (err instanceof Error) {
|
|
65
|
+
if (err.message.includes('not initialized') || err.message.includes('not connected')) {
|
|
66
|
+
return { status: 503, message: scrubSensitiveText(err.message) };
|
|
67
|
+
}
|
|
68
|
+
return { status: 500, message: scrubSensitiveText(err.message) };
|
|
69
|
+
}
|
|
70
|
+
return { status: 500, message: scrubSensitiveText(String(err)) };
|
|
71
|
+
}
|
|
72
|
+
// ── Logging Middleware ───────────────────────────────────────────────────────
|
|
73
|
+
/** Log every incoming request and inject a correlation ID. */
|
|
74
|
+
export function requestLogger(req, res, next) {
|
|
75
|
+
const correlationId = randomUUID();
|
|
76
|
+
res.locals.correlationId = correlationId;
|
|
77
|
+
const method = req.method;
|
|
78
|
+
const path = req.path;
|
|
79
|
+
console.log(`[API] [${correlationId}] ${method} ${path}`);
|
|
80
|
+
void logThought(`[API] [${correlationId}] ${method} ${path}`);
|
|
81
|
+
next();
|
|
82
|
+
}
|