Skip to content

Commit a4dee99

Browse files
alari76claude
andauthored
feat(deployments): registry, deterministic probes, breach signals to Joe (#606)
Phase C2 of the Joe value-expansion plan: Joe watches production. - ~/.codekin/deployments.json registry (0600): deployments with http / pm2 / disk probes, optional repoPath link back to the source repo - DeploymentMonitor: probes are plain code (no LLM in the hot path), sampled every 5 min via the engine's new registerTickTask — periodic work rides the dispatch tick instead of adding interval loops - deployment_samples table in runs.db (30-day retention, pruned at boot) - Breach detection on transitions: ok→breached publishes one durable probe-breach signal (not one per sample), breached→ok publishes probe-recovered; pm2 restart-count increases publish one-off probe-event signals; all deduped while pending and delivered to Joe as notifications through the at-least-once queue - Probe failures (pm2 absent, df unparseable) are breaches themselves — a broken probe is visible, never silent - Sudo-free by policy: fetch, pm2 jlist, df as the unprivileged user - Discovery proposes pm2 processes, never auto-enrolls - REST: /api/deployments CRUD + /discover + /samples - MCP: list_deployments + get_deployment_samples (template v6) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 45904d8 commit a4dee99

12 files changed

Lines changed: 981 additions & 1 deletion

docs/DEPLOYMENTS.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Deployment Monitoring
2+
3+
Codekin can watch the apps you deploy — not just the repos that build them. The design is two-layered: **deterministic probes** sample cheaply on the trigger engine's tick with no LLM anywhere in the hot path, and **the agent enters only on a breach**, delivered as a durable signal into the orchestrator's notification stream.
4+
5+
Everything runs sudo-free as the Codekin server user: HTTP requests, `pm2 jlist`, and `df`. Anything that would need elevated privileges is out of scope for probes by policy.
6+
7+
## Registry
8+
9+
Deployments live in `~/.codekin/deployments.json`:
10+
11+
```json
12+
{
13+
"deployments": [
14+
{
15+
"id": "codekin-prod",
16+
"name": "Codekin Production",
17+
"repoPath": "/srv/repos/codekin",
18+
"enabled": true,
19+
"probes": [
20+
{ "type": "http", "url": "https://app.example.com/api/health", "checkTls": true },
21+
{ "type": "pm2", "processName": "codekin", "memoryLimitMb": 1024 },
22+
{ "type": "disk", "path": "/", "minFreePct": 10 }
23+
]
24+
}
25+
]
26+
}
27+
```
28+
29+
`repoPath` links a deployment back to its source repo, connecting incidents to recent merges.
30+
31+
`GET /api/deployments/discover` proposes monitorable pm2 processes (marking ones already configured). Discovery never auto-enrolls — the operator (or the agent, under trust) confirms via `POST /api/deployments`.
32+
33+
## Probes
34+
35+
| Type | Samples | Breach conditions |
36+
| --- | --- | --- |
37+
| `http` | status, latency, TLS days-remaining (`checkTls`) | non-expected status (default: ≥400), unreachable/timeout, certificate < 14 days |
38+
| `pm2` | status, restart count, memory | process missing, status ≠ `online`, memory > `memoryLimitMb`; a restart-count increase publishes a one-off event |
39+
| `disk` | free % | free % < `minFreePct` (default 10) |
40+
41+
Probe *failures* (pm2 absent, `df` unparseable) are breaches too — a broken probe is visible, never silent.
42+
43+
## Sampling & signals
44+
45+
Sampling rides the trigger engine's tick (`registerTickTask`, every 5 minutes) — no dedicated interval loop. Samples persist to the `deployment_samples` table in `runs.db` (30-day retention, pruned at boot).
46+
47+
Breach detection fires on **transitions**, not on every breached sample: `ok → breached` publishes a `probe-breach` signal (once), `breached → ok` publishes `probe-recovered`. Both flow through the durable signal queue (at-least-once, deduped while pending) and land in the orchestrator's notifications. The orchestrator can then inspect current state (`list_deployments`) and history (`get_deployment_samples`) before deciding whether to act.
48+
49+
## API
50+
51+
- `GET /api/deployments` — registry with each probe's latest sample
52+
- `POST /api/deployments` / `PATCH /api/deployments/:id` / `DELETE /api/deployments/:id`
53+
- `GET /api/deployments/discover` — pm2 process proposals
54+
- `GET /api/deployments/samples?probeKey=&limit=` — sample history, newest first

server/codekin-mcp-api.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,18 @@ export class CodekinApi {
119119
return this.request('GET', '/api/workflows/repo-activity')
120120
}
121121

122+
// --- deployments ----------------------------------------------------------
123+
124+
listDeployments(): Promise<unknown> {
125+
return this.request('GET', '/api/deployments')
126+
}
127+
128+
getDeploymentSamples(probeKey: string, limit?: number): Promise<unknown> {
129+
const params = new URLSearchParams({ probeKey })
130+
if (limit) params.set('limit', String(limit))
131+
return this.request('GET', `/api/deployments/samples?${params.toString()}`)
132+
}
133+
122134
// --- trust ----------------------------------------------------------------
123135

124136
getTrustLevel(opts: { action: string; category: string; severity?: string; repo?: string }): Promise<unknown> {

server/codekin-mcp-server.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,25 @@ export function buildCodekinMcpServer(api: CodekinApi): McpServer {
9898
() => run(() => api.getRepoActivity()),
9999
)
100100

101+
server.registerTool(
102+
'list_deployments',
103+
{
104+
description:
105+
'Monitored deployments with each probe\'s latest sample (http status/latency/TLS, pm2 status/restarts/memory, disk free). Breaches arrive as notifications; use this for current state.',
106+
inputSchema: {},
107+
},
108+
() => run(() => api.listDeployments()),
109+
)
110+
111+
server.registerTool(
112+
'get_deployment_samples',
113+
{
114+
description: 'Sample history for one probe (newest first) — use to see when a breach started or whether a metric is trending.',
115+
inputSchema: { probeKey: z.string().describe('Probe key from list_deployments'), limit: z.number().int().positive().optional() },
116+
},
117+
({ probeKey, limit }) => run(() => api.getDeploymentSamples(probeKey, limit)),
118+
)
119+
101120
server.registerTool(
102121
'list_runs',
103122
{

server/deployment-config.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Deployment registry persistence.
3+
*
4+
* The list of deployed apps Codekin monitors, stored as JSON at
5+
* ~/.codekin/deployments.json. Each deployment carries a set of deterministic
6+
* probes (http / pm2 / disk) sampled by the DeploymentMonitor. The registry is
7+
* meant to be bootstrapped by discovery (pm2 process list) and confirmed by
8+
* the operator — never silently auto-enrolled.
9+
*/
10+
11+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
12+
import { homedir } from 'os'
13+
import { join } from 'path'
14+
15+
// ---------------------------------------------------------------------------
16+
// Types
17+
// ---------------------------------------------------------------------------
18+
19+
export interface HttpProbeConfig {
20+
type: 'http'
21+
url: string
22+
/** Expected status code. Default: any 2xx/3xx. */
23+
expectStatus?: number
24+
timeoutMs?: number
25+
/** Also check TLS certificate days-remaining (https URLs). */
26+
checkTls?: boolean
27+
}
28+
29+
export interface Pm2ProbeConfig {
30+
type: 'pm2'
31+
processName: string
32+
/** Breach when resident memory exceeds this (MB). Unset = no memory check. */
33+
memoryLimitMb?: number
34+
}
35+
36+
export interface DiskProbeConfig {
37+
type: 'disk'
38+
path: string
39+
/** Breach when free space drops below this percentage. Default 10. */
40+
minFreePct?: number
41+
}
42+
43+
export type ProbeConfig = HttpProbeConfig | Pm2ProbeConfig | DiskProbeConfig
44+
45+
export interface DeploymentConfig {
46+
id: string
47+
name: string
48+
/** Optional link back to the source repo (connects incidents to recent merges). */
49+
repoPath?: string
50+
enabled: boolean
51+
probes: ProbeConfig[]
52+
}
53+
54+
export interface DeploymentsFile {
55+
deployments: DeploymentConfig[]
56+
}
57+
58+
/** Stable identity of one probe within a deployment — the sample/breach-state key. */
59+
export function probeKey(deployment: DeploymentConfig, probe: ProbeConfig): string {
60+
const target = probe.type === 'http' ? probe.url : probe.type === 'pm2' ? probe.processName : probe.path
61+
return `${deployment.id}::${probe.type}:${target}`
62+
}
63+
64+
// ---------------------------------------------------------------------------
65+
// Load / Save
66+
// ---------------------------------------------------------------------------
67+
68+
const CONFIG_DIR = join(homedir(), '.codekin')
69+
const CONFIG_PATH = join(CONFIG_DIR, 'deployments.json')
70+
71+
export function loadDeployments(): DeploymentsFile {
72+
try {
73+
if (existsSync(CONFIG_PATH)) {
74+
const raw = readFileSync(CONFIG_PATH, 'utf-8')
75+
return JSON.parse(raw) as DeploymentsFile
76+
}
77+
} catch (err) {
78+
console.error('[deployments] Failed to load config:', err)
79+
}
80+
return { deployments: [] }
81+
}
82+
83+
export function saveDeployments(config: DeploymentsFile): void {
84+
if (!existsSync(CONFIG_DIR)) {
85+
mkdirSync(CONFIG_DIR, { recursive: true })
86+
}
87+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { encoding: 'utf-8', mode: 0o600 })
88+
}
89+
90+
// ---------------------------------------------------------------------------
91+
// CRUD helpers
92+
// ---------------------------------------------------------------------------
93+
94+
export function upsertDeployment(deployment: DeploymentConfig): DeploymentsFile {
95+
const config = loadDeployments()
96+
const idx = config.deployments.findIndex(d => d.id === deployment.id)
97+
if (idx >= 0) config.deployments[idx] = deployment
98+
else config.deployments.push(deployment)
99+
saveDeployments(config)
100+
return config
101+
}
102+
103+
export function removeDeployment(id: string): DeploymentsFile {
104+
const config = loadDeployments()
105+
config.deployments = config.deployments.filter(d => d.id !== id)
106+
saveDeployments(config)
107+
return config
108+
}
109+
110+
export function updateDeployment(id: string, patch: Partial<DeploymentConfig>): DeploymentsFile {
111+
const config = loadDeployments()
112+
const idx = config.deployments.findIndex(d => d.id === id)
113+
if (idx < 0) throw new Error(`Deployment not found: ${id}`)
114+
config.deployments[idx] = { ...config.deployments[idx], ...patch, id }
115+
saveDeployments(config)
116+
return config
117+
}

server/deployment-monitor.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Tests for the DeploymentMonitor — sampling, breach/recovery transition
3+
* signals (alert once, not per sample), one-off events, and sample queries.
4+
* Uses a real in-memory SQLite database with injected probe runners and a
5+
* captured signal publisher.
6+
*/
7+
import { describe, it, expect, afterEach } from 'vitest'
8+
import { DeploymentMonitor, type ProbeResult, type ProbeMetrics } from './deployment-monitor.js'
9+
import type { DeploymentsFile } from './deployment-config.js'
10+
11+
const NOW = new Date('2026-08-30T12:00:00.000Z')
12+
13+
const okResult = (metrics: ProbeMetrics = {}): ProbeResult => ({ ok: true, breaches: [], events: [], metrics })
14+
const breachedResult = (breach: string, metrics: ProbeMetrics = {}): ProbeResult =>
15+
({ ok: false, breaches: [breach], events: [], metrics })
16+
17+
function makeConfig(): DeploymentsFile {
18+
return {
19+
deployments: [{
20+
id: 'app-1',
21+
name: 'Codekin Prod',
22+
enabled: true,
23+
probes: [{ type: 'http', url: 'https://example.test/health' }],
24+
}],
25+
}
26+
}
27+
28+
describe('DeploymentMonitor', () => {
29+
let monitor: DeploymentMonitor
30+
let published: Array<{ kind: string; payload?: Record<string, unknown>; dedupeKey?: string }>
31+
32+
function makeMonitor(httpResults: ProbeResult[], config: DeploymentsFile = makeConfig()) {
33+
published = []
34+
let call = 0
35+
monitor = new DeploymentMonitor({
36+
dbPath: ':memory:',
37+
publish: (input) => published.push(input),
38+
loadConfig: () => config,
39+
runners: {
40+
http: async () => httpResults[Math.min(call++, httpResults.length - 1)],
41+
},
42+
})
43+
return monitor
44+
}
45+
46+
afterEach(() => {
47+
monitor.close()
48+
})
49+
50+
it('samples every enabled probe and records metrics', async () => {
51+
makeMonitor([okResult({ status: 200, latencyMs: 45 })])
52+
await monitor.sampleAll(NOW)
53+
54+
const samples = monitor.latestSamples()
55+
expect(samples).toHaveLength(1)
56+
expect(samples[0]).toMatchObject({
57+
deploymentId: 'app-1',
58+
probeKey: 'app-1::http:https://example.test/health',
59+
ok: true,
60+
metrics: { status: 200, latencyMs: 45 },
61+
})
62+
expect(published).toHaveLength(0)
63+
})
64+
65+
it('skips disabled deployments', async () => {
66+
const config = makeConfig()
67+
config.deployments[0].enabled = false
68+
makeMonitor([okResult()], config)
69+
await monitor.sampleAll(NOW)
70+
expect(monitor.latestSamples()).toHaveLength(0)
71+
})
72+
73+
it('signals a breach only on the ok → breached transition, and recovery on the way back', async () => {
74+
makeMonitor([
75+
okResult(),
76+
breachedResult('http 502'),
77+
breachedResult('http 502'),
78+
okResult(),
79+
])
80+
81+
await monitor.sampleAll(NOW) // ok — nothing
82+
await monitor.sampleAll(new Date(NOW.getTime() + 5 * 60_000)) // breach → signal
83+
await monitor.sampleAll(new Date(NOW.getTime() + 10 * 60_000)) // still breached → silent
84+
await monitor.sampleAll(new Date(NOW.getTime() + 15 * 60_000)) // recovered → signal
85+
86+
expect(published.map(p => p.kind)).toEqual(['probe-breach', 'probe-recovered'])
87+
expect(published[0].payload).toMatchObject({
88+
deploymentId: 'app-1',
89+
deploymentName: 'Codekin Prod',
90+
breaches: ['http 502'],
91+
})
92+
expect(published[0].dedupeKey).toBe('probe-breach::app-1::http:https://example.test/health')
93+
})
94+
95+
it('signals a breach immediately when the first-ever sample is breached', async () => {
96+
makeMonitor([breachedResult('unreachable: ECONNREFUSED')])
97+
await monitor.sampleAll(NOW)
98+
expect(published.map(p => p.kind)).toEqual(['probe-breach'])
99+
})
100+
101+
it('publishes one-off events regardless of breach state', async () => {
102+
published = []
103+
const config: DeploymentsFile = {
104+
deployments: [{
105+
id: 'app-1', name: 'Codekin Prod', enabled: true,
106+
probes: [{ type: 'pm2', processName: 'codekin' }],
107+
}],
108+
}
109+
let call = 0
110+
monitor = new DeploymentMonitor({
111+
dbPath: ':memory:',
112+
publish: (input) => published.push(input),
113+
loadConfig: () => config,
114+
runners: {
115+
pm2: async (_probe, previous) => {
116+
call++
117+
const restarts = call === 1 ? 3 : 4
118+
const events: string[] = []
119+
const prev = typeof previous?.restarts === 'number' ? previous.restarts : null
120+
if (prev !== null && restarts > prev) events.push(`restarted (${prev}${restarts})`)
121+
return { ok: true, breaches: [], events, metrics: { status: 'online', restarts, memoryMb: 120 } }
122+
},
123+
},
124+
})
125+
126+
await monitor.sampleAll(NOW)
127+
await monitor.sampleAll(new Date(NOW.getTime() + 5 * 60_000))
128+
129+
expect(published.map(p => p.kind)).toEqual(['probe-event'])
130+
expect(published[0].payload).toMatchObject({ event: 'restarted (3 → 4)' })
131+
})
132+
133+
it('returns sample history newest-first and prunes by retention', async () => {
134+
makeMonitor([okResult({ status: 200 })])
135+
await monitor.sampleAll(NOW)
136+
await monitor.sampleAll(new Date(NOW.getTime() + 5 * 60_000))
137+
138+
const key = 'app-1::http:https://example.test/health'
139+
const history = monitor.listSamples({ probeKey: key })
140+
expect(history).toHaveLength(2)
141+
expect(history[0].id).toBeGreaterThan(history[1].id)
142+
143+
// Both samples are in the past relative to a far-future cutoff → pruned.
144+
monitor.pruneSamples(-24 * 60 * 60 * 1000)
145+
expect(monitor.listSamples({ probeKey: key })).toHaveLength(0)
146+
})
147+
})

0 commit comments

Comments
 (0)