Skip to content

Commit 0cf36ee

Browse files
alari76claude
andcommitted
Merge main into fix/orchestrator-monitor-getall-ordering
Resolves the test-file conflict: #615 rewrote orchestrator-monitor.test.ts as lifecycle-only coverage, dropping the hasEnabledWorkflowForRepo, discoverRepoPathsUnder, and handleGoalRunEvent suites even though all three functions survive on main. Restores those suites alongside the new lifecycle tests, keeping this PR's deterministic-ordering assertions and millisecond-boundary regression test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 parents 4f92819 + 1ce01c7 commit 0cf36ee

24 files changed

Lines changed: 1805 additions & 53 deletions

docs/DEPLOYMENTS.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,15 @@ Deployments live in `~/.codekin/deployments.json`:
3434

3535
| Type | Samples | Breach conditions |
3636
| --- | --- | --- |
37-
| `http` | status, latency, TLS days-remaining (`checkTls`), security headers (`checkHeaders`) | non-expected status (default: ≥400), unreachable/timeout, certificate < 14 days, missing HSTS/CSP headers |
37+
| `http` | status, latency (+ learned p95 baseline), TLS days-remaining and protocol (`checkTls`), security headers (`checkHeaders`) | non-expected status (default: ≥400), unreachable/timeout, certificate < 14 days, TLS protocol below 1.2, missing HSTS/CSP headers, latency > 4× learned p95 (min 750 ms; engages after ~a day of healthy samples) |
3838
| `pm2` | status, restart count, memory | process missing, status ≠ `online`, memory > `memoryLimitMb`; a restart-count increase publishes a one-off event |
3939
| `disk` | free % | free % < `minFreePct` (default 10) |
40+
| `log` | new error-pattern lines per window (`errorPattern` regex, default error/exception/fatal) | more than `maxErrorsPerWindow` (default 10) new matches since the last sample; missing file / bad pattern |
4041
| `host` | memory available %, load per core, apt upgradable/security counts (cached 6h), reboot-required | memory < `minMemAvailablePct` (10), load/core > `maxLoadPerCore` (3), pending security updates, reboot required |
4142

42-
Probe *failures* (pm2 absent, `df` unparseable) are breaches too — a broken probe is visible, never silent.
43+
Probe *failures* (pm2 absent, `df` unparseable, log file missing) are breaches too — a broken probe is visible, never silent.
44+
45+
The log probe's read offset travels in the sample metrics, making its state exactly as durable as the samples table: the first sample baselines at end-of-file (history is never scanned), a shrunken file is treated as rotation, and reads are capped at 5 MB per window.
4346

4447
## Sampling & signals
4548

docs/LOOPS-REWRITE-SPEC.md

Lines changed: 662 additions & 0 deletions
Large diffs are not rendered by default.

server/dependency-audit.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/** Tests for the dependency-audit sweep — change gating, alert policy, state handling. Uses injected IO and a temp state file. */
2+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
3+
import { mkdtempSync, rmSync, readFileSync } from 'fs'
4+
import { tmpdir } from 'os'
5+
import { join } from 'path'
6+
import { runDependencyAuditSweep, shouldAlert, parseAuditCounts, type AuditCounts, type DependencyAuditIo } from './dependency-audit.js'
7+
8+
const REPO = '/fake/repo'
9+
const CLEAN: AuditCounts = { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }
10+
const BAD: AuditCounts = { ...CLEAN, high: 2, critical: 1 }
11+
12+
describe('shouldAlert', () => {
13+
it('alerts on first sight of actionable exposure and on changes, stays quiet otherwise', () => {
14+
expect(shouldAlert(undefined, BAD)).toBe(true)
15+
expect(shouldAlert(undefined, CLEAN)).toBe(false)
16+
expect(shouldAlert(BAD, BAD)).toBe(false) // unchanged
17+
expect(shouldAlert(BAD, { ...BAD, critical: 2 })).toBe(true) // grew
18+
expect(shouldAlert(BAD, { ...CLEAN, high: 1 })).toBe(true) // shrank but still exposed
19+
expect(shouldAlert(BAD, CLEAN)).toBe(false) // fully resolved — no alert
20+
})
21+
})
22+
23+
describe('parseAuditCounts', () => {
24+
it('reads npm audit metadata and defaults missing severities to 0', () => {
25+
expect(parseAuditCounts(JSON.stringify({ metadata: { vulnerabilities: { high: 3 } } })))
26+
.toEqual({ info: 0, low: 0, moderate: 0, high: 3, critical: 0 })
27+
expect(parseAuditCounts('not json')).toBeNull()
28+
expect(parseAuditCounts('{}')).toBeNull()
29+
})
30+
})
31+
32+
describe('runDependencyAuditSweep', () => {
33+
let dir: string
34+
let statePath: string
35+
let published: Array<{ kind: string; payload?: Record<string, unknown>; dedupeKey?: string }>
36+
37+
beforeEach(() => {
38+
dir = mkdtempSync(join(tmpdir(), 'dep-audit-'))
39+
statePath = join(dir, 'state.json')
40+
published = []
41+
})
42+
43+
afterEach(() => {
44+
rmSync(dir, { recursive: true, force: true })
45+
})
46+
47+
function makeIo(overrides: Partial<DependencyAuditIo> = {}): Partial<DependencyAuditIo> {
48+
return {
49+
headSha: () => 'sha-1',
50+
changedFiles: () => ['src/app.ts'],
51+
hasLockfile: () => true,
52+
runNpmAudit: vi.fn(async () => BAD),
53+
...overrides,
54+
}
55+
}
56+
57+
const sweep = (io: Partial<DependencyAuditIo>) =>
58+
runDependencyAuditSweep({ publish: (i) => published.push(i), io, statePath, repoPaths: [REPO] })
59+
60+
it('audits on first sight, publishes on actionable exposure, and persists state', async () => {
61+
const io = makeIo()
62+
await sweep(io)
63+
64+
expect(io.runNpmAudit).toHaveBeenCalledTimes(1)
65+
expect(published).toHaveLength(1)
66+
expect(published[0].kind).toBe('dependency-audit')
67+
expect(published[0].dedupeKey).toBe(`dependency-audit::${REPO}::sha-1`)
68+
expect(published[0].payload).toMatchObject({ repoPath: REPO, counts: BAD })
69+
70+
const state = JSON.parse(readFileSync(statePath, 'utf-8')) as Record<string, { sha: string }>
71+
expect(state[REPO].sha).toBe('sha-1')
72+
})
73+
74+
it('skips entirely when HEAD has not moved', async () => {
75+
const io = makeIo()
76+
await sweep(io)
77+
await sweep(io)
78+
expect(io.runNpmAudit).toHaveBeenCalledTimes(1)
79+
})
80+
81+
it('advances the sha without auditing when no manifest changed', async () => {
82+
const io = makeIo()
83+
await sweep(io)
84+
85+
const io2 = makeIo({ headSha: () => 'sha-2', changedFiles: () => ['src/other.ts', 'README.md'] })
86+
await sweep(io2)
87+
expect(io2.runNpmAudit).not.toHaveBeenCalled()
88+
89+
// And the new sha is recorded — the untouched-manifest commit is settled.
90+
const io3 = makeIo({ headSha: () => 'sha-2' })
91+
await sweep(io3)
92+
expect(io3.runNpmAudit).not.toHaveBeenCalled()
93+
})
94+
95+
it('audits again when the lockfile changed, but stays quiet while counts are unchanged', async () => {
96+
const io = makeIo()
97+
await sweep(io)
98+
published = []
99+
100+
const io2 = makeIo({ headSha: () => 'sha-2', changedFiles: () => ['package-lock.json'] })
101+
await sweep(io2)
102+
expect(io2.runNpmAudit).toHaveBeenCalledTimes(1)
103+
expect(published).toHaveLength(0) // same high+critical as before
104+
})
105+
106+
it('errs toward auditing when the diff fails (rewritten history)', async () => {
107+
const io = makeIo()
108+
await sweep(io)
109+
110+
const io2 = makeIo({ headSha: () => 'sha-2', changedFiles: () => null })
111+
await sweep(io2)
112+
expect(io2.runNpmAudit).toHaveBeenCalledTimes(1)
113+
})
114+
115+
it('leaves state untouched when the audit fails, so the next sweep retries', async () => {
116+
const io = makeIo({ runNpmAudit: vi.fn(async () => null) })
117+
await sweep(io)
118+
119+
const io2 = makeIo()
120+
await sweep(io2)
121+
expect(io2.runNpmAudit).toHaveBeenCalledTimes(1)
122+
expect(published).toHaveLength(1)
123+
})
124+
125+
it('records the sha without auditing when there is no lockfile', async () => {
126+
const io = makeIo({ hasLockfile: () => false })
127+
await sweep(io)
128+
expect(io.runNpmAudit).not.toHaveBeenCalled()
129+
expect(published).toHaveLength(0)
130+
})
131+
})

server/dependency-audit.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* Dependency audit on dependency change.
3+
*
4+
* Activity-triggered, not wall-clock (expansion plan §4.3): a repo is audited
5+
* only when its dependency manifest (package.json / package-lock.json)
6+
* actually changed since the last audited commit. The sweep itself rides the
7+
* engine tick every 6 hours, but a repo whose manifests are untouched costs
8+
* one `git rev-parse` — `npm audit` runs only on real dependency movement.
9+
*
10+
* Alerts flow as durable `dependency-audit` signals when high/critical
11+
* counts change; remediation stays operator/child-run (`npm audit fix` is
12+
* proposed, never executed here).
13+
*/
14+
15+
import { execFile, execFileSync } from 'child_process'
16+
import { promisify } from 'util'
17+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
18+
import { homedir } from 'os'
19+
import { join } from 'path'
20+
import { loadWorkflowConfig } from './workflow-config.js'
21+
22+
const execFileAsync = promisify(execFile)
23+
24+
// ---------------------------------------------------------------------------
25+
// Types
26+
// ---------------------------------------------------------------------------
27+
28+
export interface AuditCounts {
29+
info: number
30+
low: number
31+
moderate: number
32+
high: number
33+
critical: number
34+
}
35+
36+
interface RepoAuditState {
37+
/** Last audited (or examined-and-skipped) HEAD sha. */
38+
sha: string
39+
at: string
40+
counts?: AuditCounts
41+
}
42+
43+
type AuditStateFile = Record<string, RepoAuditState>
44+
45+
/** IO seams — real git/npm in production, injected in tests. */
46+
export interface DependencyAuditIo {
47+
headSha: (repoPath: string) => string | null
48+
/** Files changed between two commits; `null` when the diff fails (rewritten history) → treat as changed. */
49+
changedFiles: (repoPath: string, fromSha: string) => string[] | null
50+
hasLockfile: (repoPath: string) => boolean
51+
/** Vulnerability counts from `npm audit`; `null` when the audit itself fails. */
52+
runNpmAudit: (repoPath: string) => Promise<AuditCounts | null>
53+
}
54+
55+
export type AuditSignalPublisher = (input: { kind: string; payload?: Record<string, unknown>; dedupeKey?: string; ttlMs?: number }) => void
56+
57+
const STATE_PATH = join(homedir(), '.codekin', 'dependency-audit.json')
58+
const MANIFEST_RE = /(^|\/)package(-lock)?\.json$/
59+
const SIGNAL_TTL_MS = 24 * 60 * 60 * 1000
60+
61+
// ---------------------------------------------------------------------------
62+
// Default IO
63+
// ---------------------------------------------------------------------------
64+
65+
function gitOut(repoPath: string, args: string[]): string | null {
66+
try {
67+
return execFileSync('git', args, { cwd: repoPath, timeout: 10_000 }).toString().trim()
68+
} catch {
69+
return null
70+
}
71+
}
72+
73+
export function parseAuditCounts(stdout: string): AuditCounts | null {
74+
try {
75+
const parsed = JSON.parse(stdout) as { metadata?: { vulnerabilities?: Partial<AuditCounts> } }
76+
const v = parsed.metadata?.vulnerabilities
77+
if (!v) return null
78+
return { info: v.info ?? 0, low: v.low ?? 0, moderate: v.moderate ?? 0, high: v.high ?? 0, critical: v.critical ?? 0 }
79+
} catch {
80+
return null
81+
}
82+
}
83+
84+
const DEFAULT_IO: DependencyAuditIo = {
85+
headSha: (repoPath) => gitOut(repoPath, ['rev-parse', 'HEAD']),
86+
changedFiles: (repoPath, fromSha) => {
87+
const out = gitOut(repoPath, ['diff', '--name-only', `${fromSha}..HEAD`])
88+
return out === null ? null : out.split('\n').filter(Boolean)
89+
},
90+
hasLockfile: (repoPath) => existsSync(join(repoPath, 'package-lock.json')),
91+
runNpmAudit: async (repoPath) => {
92+
try {
93+
const { stdout } = await execFileAsync('npm', ['audit', '--omit=dev', '--json'], {
94+
cwd: repoPath, timeout: 120_000, maxBuffer: 20 * 1024 * 1024,
95+
})
96+
return parseAuditCounts(stdout)
97+
} catch (err) {
98+
// npm audit exits non-zero when vulnerabilities exist — the JSON is still on stdout.
99+
const stdout = (err as { stdout?: string }).stdout
100+
return stdout ? parseAuditCounts(stdout) : null
101+
}
102+
},
103+
}
104+
105+
// ---------------------------------------------------------------------------
106+
// State
107+
// ---------------------------------------------------------------------------
108+
109+
function loadState(statePath: string): AuditStateFile {
110+
try {
111+
if (existsSync(statePath)) return JSON.parse(readFileSync(statePath, 'utf-8')) as AuditStateFile
112+
} catch (err) {
113+
console.error('[dependency-audit] Failed to load state:', err)
114+
}
115+
return {}
116+
}
117+
118+
function saveState(statePath: string, state: AuditStateFile): void {
119+
const dir = join(statePath, '..')
120+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
121+
writeFileSync(statePath, JSON.stringify(state, null, 2), { encoding: 'utf-8', mode: 0o600 })
122+
}
123+
124+
/** Alert when actionable (high+critical) exposure exists and has changed. */
125+
export function shouldAlert(previous: AuditCounts | undefined, current: AuditCounts): boolean {
126+
const actionable = current.high + current.critical
127+
if (actionable === 0) return false
128+
const prevActionable = previous ? previous.high + previous.critical : -1
129+
return actionable !== prevActionable
130+
}
131+
132+
// ---------------------------------------------------------------------------
133+
// Sweep
134+
// ---------------------------------------------------------------------------
135+
136+
export async function runDependencyAuditSweep(opts: {
137+
publish: AuditSignalPublisher
138+
io?: Partial<DependencyAuditIo>
139+
statePath?: string
140+
repoPaths?: string[]
141+
}): Promise<void> {
142+
const io: DependencyAuditIo = { ...DEFAULT_IO, ...opts.io }
143+
const statePath = opts.statePath ?? STATE_PATH
144+
const repoPaths = opts.repoPaths ?? [...new Set(loadWorkflowConfig().reviewRepos.map(r => r.repoPath))]
145+
const state = loadState(statePath)
146+
let dirty = false
147+
148+
for (const repoPath of repoPaths) {
149+
try {
150+
const sha = io.headSha(repoPath)
151+
if (!sha) continue
152+
153+
const prior = state[repoPath]
154+
if (prior?.sha === sha) continue // nothing moved since last look
155+
156+
// Only audit when the dependency manifests actually changed. A failed
157+
// diff (rewritten history) counts as changed — err toward auditing.
158+
if (prior) {
159+
const changed = io.changedFiles(repoPath, prior.sha)
160+
if (changed !== null && !changed.some(f => MANIFEST_RE.test(f))) {
161+
state[repoPath] = { ...prior, sha, at: new Date().toISOString() }
162+
dirty = true
163+
continue
164+
}
165+
}
166+
167+
if (!io.hasLockfile(repoPath)) {
168+
state[repoPath] = { sha, at: new Date().toISOString() }
169+
dirty = true
170+
continue
171+
}
172+
173+
const counts = await io.runNpmAudit(repoPath)
174+
if (!counts) {
175+
console.error(`[dependency-audit] npm audit failed for ${repoPath} — will retry next sweep`)
176+
continue // state untouched: retried on the next sweep
177+
}
178+
179+
if (shouldAlert(prior?.counts, counts)) {
180+
const repoName = repoPath.split('/').pop() ?? repoPath
181+
opts.publish({
182+
kind: 'dependency-audit',
183+
payload: { repoPath, repoName, sha, counts, previous: prior?.counts ?? null },
184+
dedupeKey: `dependency-audit::${repoPath}::${sha}`,
185+
ttlMs: SIGNAL_TTL_MS,
186+
})
187+
}
188+
189+
state[repoPath] = { sha, at: new Date().toISOString(), counts }
190+
dirty = true
191+
} catch (err) {
192+
console.error(`[dependency-audit] Sweep error for ${repoPath}:`, err)
193+
}
194+
}
195+
196+
if (dirty) saveState(statePath, state)
197+
}

server/deployment-config.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ export interface DiskProbeConfig {
4242
minFreePct?: number
4343
}
4444

45+
export interface LogProbeConfig {
46+
type: 'log'
47+
/** Absolute path of the log file to watch. */
48+
path: string
49+
/** Regex matched per line. Default: error | exception | fatal (case-insensitive). */
50+
errorPattern?: string
51+
/** Breach when more matches than this arrive within one sampling window. Default 10. */
52+
maxErrorsPerWindow?: number
53+
}
54+
4555
/**
4656
* Host probe config lives in host-probe.ts; re-declared here structurally to
4757
* keep this module dependency-free. `type: 'host'` monitors the machine
@@ -55,7 +65,7 @@ export interface HostProbeConfigRef {
5565
alertOnRebootRequired?: boolean
5666
}
5767

58-
export type ProbeConfig = HttpProbeConfig | Pm2ProbeConfig | DiskProbeConfig | HostProbeConfigRef
68+
export type ProbeConfig = HttpProbeConfig | Pm2ProbeConfig | DiskProbeConfig | LogProbeConfig | HostProbeConfigRef
5969

6070
export interface DeploymentConfig {
6171
id: string
@@ -81,7 +91,7 @@ export interface DeploymentsFile {
8191
export function probeKey(deployment: DeploymentConfig, probe: ProbeConfig): string {
8292
const target = probe.type === 'http' ? probe.url
8393
: probe.type === 'pm2' ? probe.processName
84-
: probe.type === 'disk' ? probe.path
94+
: probe.type === 'disk' || probe.type === 'log' ? probe.path
8595
: 'system'
8696
return `${deployment.id}::${probe.type}:${target}`
8797
}

0 commit comments

Comments
 (0)