spinuptui 1.3.0-alpha.4 → 1.3.0-alpha.5
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/CHANGELOG.md +20 -1
- package/README.md +8 -2
- package/package.json +1 -1
- package/src/config.ts +29 -0
- package/src/index.tsx +8 -0
- package/src/lib/beacon.test.ts +14 -0
- package/src/lib/beacon.ts +4 -1
- package/src/lib/beaconBoard.test.ts +69 -0
- package/src/lib/beaconBoard.ts +197 -0
- package/src/lib/beaconListen.test.ts +30 -1
- package/src/lib/beaconListen.ts +40 -17
- package/src/lib/beaconListenInstall.ts +13 -4
- package/src/lib/cliBeacon.ts +25 -1
- package/src/lib/cliIncidents.ts +13 -6
- package/src/lib/cliSentinel.ts +451 -0
- package/src/lib/heartbeat.ts +5 -0
- package/src/lib/schedule.ts +4 -1
- package/src/lib/scheduler.test.ts +88 -1
- package/src/lib/schedulerJobs.ts +4 -2
- package/src/lib/schedulerRun.ts +61 -15
- package/src/lib/schedulerTick.ts +35 -13
- package/src/lib/sentinelAttach.test.ts +45 -0
- package/src/lib/sentinelAttach.ts +98 -0
- package/src/lib/sentinelDigest.test.ts +82 -0
- package/src/lib/sentinelDigest.ts +261 -0
- package/src/lib/sentinelExpect.test.ts +82 -0
- package/src/lib/sentinelExpect.ts +197 -0
- package/src/lib/sentinelIncidents.test.ts +145 -0
- package/src/lib/sentinelIncidents.ts +194 -0
- package/src/lib/sentinelPrompt.ts +89 -0
- package/src/lib/sentinelReply.ts +36 -0
- package/src/lib/sentinelState.ts +246 -0
- package/src/lib/sentinelTick.test.ts +214 -0
- package/src/lib/sentinelTick.ts +265 -0
- package/src/lib/sentinelWorker.test.ts +170 -0
- package/src/lib/sentinelWorker.ts +291 -0
- package/src/modules/beacon.ts +3 -0
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +31 -8
- package/src/modules/sentinel.ts +90 -0
- package/src/ui/App.tsx +4 -0
- package/src/ui/store.tsx +5 -0
- package/src/ui/views/Modules.tsx +56 -3
- package/src/ui/views/SentinelSetup.tsx +171 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Sentinel's machine state and the shape of an incident
|
|
2
|
+
// (docs/2026-10-30_sentinel-spec.md). Everything here lives in the module's state
|
|
3
|
+
// directory and is replaced atomically, so the tick, the worker and the listener
|
|
4
|
+
// can each write without sharing a mutable file: one file per incident.
|
|
5
|
+
|
|
6
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
7
|
+
import { dirname, join } from "node:path"
|
|
8
|
+
import type { AppConfig } from "../config.ts"
|
|
9
|
+
import { moduleStateDir, moduleWorkDir } from "../modules/contract.ts"
|
|
10
|
+
import type { IncidentKind } from "./cliIncidents.ts"
|
|
11
|
+
import { expandHome } from "./schedulerJobs.ts"
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_ALLOW = "Read,Grep,Glob,Edit(sites/**),Write(sites/**),Bash(spinuptui ssh-exec *),Bash(spinuptui siblings *),Bash(spinuptui incidents *)"
|
|
14
|
+
const LOG_MAX_BYTES = 1_000_000
|
|
15
|
+
export const INCIDENT_KEEP_DAYS = 30
|
|
16
|
+
export const SEEN_KEEP_DAYS = 8
|
|
17
|
+
|
|
18
|
+
export interface SentinelSettings {
|
|
19
|
+
enabled: boolean
|
|
20
|
+
shadow: boolean
|
|
21
|
+
channel: string | null
|
|
22
|
+
reportTo: string | null
|
|
23
|
+
workspace: string
|
|
24
|
+
recurrenceHours: number
|
|
25
|
+
maxDiagnosesPerDay: number
|
|
26
|
+
agent: "claude" | "codex" | null
|
|
27
|
+
model: string | null
|
|
28
|
+
timeoutMin: number
|
|
29
|
+
allow: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function sentinelSettings(cfg: AppConfig): SentinelSettings {
|
|
33
|
+
const s = cfg.sentinel ?? {}
|
|
34
|
+
return {
|
|
35
|
+
enabled: !!s.enabled,
|
|
36
|
+
shadow: s.mode === "shadow",
|
|
37
|
+
channel: s.channel ?? null,
|
|
38
|
+
reportTo: s.reportTo ?? null,
|
|
39
|
+
workspace: s.workspace ? expandHome(s.workspace) : moduleWorkDir("sentinel"),
|
|
40
|
+
recurrenceHours: s.recurrenceHours ?? 6,
|
|
41
|
+
maxDiagnosesPerDay: s.maxDiagnosesPerDay ?? 15,
|
|
42
|
+
agent: s.diagnosis?.agent ?? null,
|
|
43
|
+
model: s.diagnosis?.model ?? null,
|
|
44
|
+
timeoutMin: s.diagnosis?.timeoutMin ?? 30,
|
|
45
|
+
allow: s.diagnosis?.allow ? `${DEFAULT_ALLOW},${s.diagnosis.allow}` : DEFAULT_ALLOW,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const sentinelDir = () => moduleStateDir("sentinel")
|
|
50
|
+
export const incidentsDir = () => join(sentinelDir(), "incidents")
|
|
51
|
+
export const transcriptsDir = () => join(sentinelDir(), "transcripts")
|
|
52
|
+
export const workerLockPath = () => join(sentinelDir(), "worker.lock")
|
|
53
|
+
export const expectationsPath = () => join(moduleWorkDir("sentinel"), "expectations.json")
|
|
54
|
+
export const trendsPath = () => join(moduleWorkDir("sentinel"), "trends.csv")
|
|
55
|
+
const statePath = (name: string) => join(sentinelDir(), name)
|
|
56
|
+
|
|
57
|
+
export function readJson<T>(path: string, fallback: T): T {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(readFileSync(path, "utf8")) as T
|
|
60
|
+
} catch {
|
|
61
|
+
return fallback
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function writeJson(path: string, value: unknown): void {
|
|
66
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
67
|
+
writeFileSync(`${path}.tmp`, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 })
|
|
68
|
+
renameSync(`${path}.tmp`, path)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- incidents -----------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
export type IncidentState =
|
|
74
|
+
| "queued"
|
|
75
|
+
| "diagnosing"
|
|
76
|
+
| "reported"
|
|
77
|
+
| "waiting_agent" // the login lapsed: pages once, retries, drains when it's back
|
|
78
|
+
| "blocked" // the agent couldn't do the work
|
|
79
|
+
| "failed" // after two attempts
|
|
80
|
+
| "recurrence" // covered by an earlier report; no diagnosis
|
|
81
|
+
| "shadow" // shadow mode: what would have been queued
|
|
82
|
+
|
|
83
|
+
export interface IncidentEvent {
|
|
84
|
+
domain: string
|
|
85
|
+
kind: IncidentKind
|
|
86
|
+
time: string
|
|
87
|
+
message: string
|
|
88
|
+
monitorId: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface Incident {
|
|
92
|
+
id: string
|
|
93
|
+
key: string // what groups it: the server's name, or the domain if unknown
|
|
94
|
+
server: string | null
|
|
95
|
+
state: IncidentState
|
|
96
|
+
createdAt: string
|
|
97
|
+
events: IncidentEvent[]
|
|
98
|
+
down: number[] // monitors still down
|
|
99
|
+
lastDownAt: string
|
|
100
|
+
closedAt?: string
|
|
101
|
+
attempts: number
|
|
102
|
+
startedAt?: string
|
|
103
|
+
retryAfter?: string
|
|
104
|
+
why?: string // blocked or failed: what's needed
|
|
105
|
+
refersTo?: string // a recurrence's earlier incident
|
|
106
|
+
evidence?: string
|
|
107
|
+
site?: string // the site the report is filed under
|
|
108
|
+
reportPath?: string
|
|
109
|
+
reportedAt?: string
|
|
110
|
+
summary?: string
|
|
111
|
+
// Basecamp has no idempotency key: "posting" with no id means the outcome is
|
|
112
|
+
// unknown, and nothing may repost it automatically.
|
|
113
|
+
post?: "posting" | "posted" | "failed" | "unknown"
|
|
114
|
+
postId?: string
|
|
115
|
+
postUrl?: string
|
|
116
|
+
postError?: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const incidentPath = (id: string) => join(incidentsDir(), `${id}.json`)
|
|
120
|
+
|
|
121
|
+
export function saveIncident(i: Incident): void {
|
|
122
|
+
writeJson(incidentPath(i.id), i)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function loadIncident(id: string): Incident | null {
|
|
126
|
+
return readJson<Incident | null>(incidentPath(id), null)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function deleteIncident(id: string): void {
|
|
130
|
+
try {
|
|
131
|
+
unlinkSync(incidentPath(id))
|
|
132
|
+
} catch {
|
|
133
|
+
// Already gone.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function loadIncidents(): Incident[] {
|
|
138
|
+
if (!existsSync(incidentsDir())) return []
|
|
139
|
+
const out: Incident[] = []
|
|
140
|
+
for (const f of readdirSync(incidentsDir())) {
|
|
141
|
+
if (!f.endsWith(".json")) continue
|
|
142
|
+
const i = readJson<Incident | null>(join(incidentsDir(), f), null)
|
|
143
|
+
if (i) out.push(i)
|
|
144
|
+
}
|
|
145
|
+
return out.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const isWaiting = (i: Incident) => ["queued", "diagnosing", "waiting_agent", "blocked", "failed"].includes(i.state)
|
|
149
|
+
|
|
150
|
+
// ---- the tick's own record ---------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
export interface TickState {
|
|
153
|
+
// Set by the first tick, which marks history as seen rather than paging for it.
|
|
154
|
+
started?: boolean
|
|
155
|
+
lastCleanAt?: string
|
|
156
|
+
lastTickAt?: string
|
|
157
|
+
lastError?: string
|
|
158
|
+
blindSince?: string
|
|
159
|
+
// Paged once each until the condition clears.
|
|
160
|
+
authPagedAt?: string
|
|
161
|
+
capPagedFor?: string
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const loadTickState = () => readJson<TickState>(statePath("tick-state.json"), {})
|
|
165
|
+
export const saveTickState = (s: TickState) => writeJson(statePath("tick-state.json"), s)
|
|
166
|
+
|
|
167
|
+
export type Seen = Record<string, string>
|
|
168
|
+
export const seenKey = (e: { monitorId: number; time: string; domain: string }) => `${e.monitorId}|${e.time}|${e.domain}`
|
|
169
|
+
export const loadSeen = () => readJson<Seen>(statePath("seen.json"), {})
|
|
170
|
+
export const saveSeen = (s: Seen) => writeJson(statePath("seen.json"), s)
|
|
171
|
+
|
|
172
|
+
export interface PauseState {
|
|
173
|
+
until: string
|
|
174
|
+
events?: number
|
|
175
|
+
}
|
|
176
|
+
export const loadPause = () => readJson<PauseState | null>(statePath("pause.json"), null)
|
|
177
|
+
export const savePause = (p: PauseState) => writeJson(statePath("pause.json"), p)
|
|
178
|
+
export function clearPause(): void {
|
|
179
|
+
try {
|
|
180
|
+
unlinkSync(statePath("pause.json"))
|
|
181
|
+
} catch {
|
|
182
|
+
// Not paused.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface FlagRecord {
|
|
187
|
+
firstSeen: string // YYYY-MM-DD
|
|
188
|
+
text: string
|
|
189
|
+
acked?: boolean
|
|
190
|
+
}
|
|
191
|
+
export type Flags = Record<string, FlagRecord>
|
|
192
|
+
export const loadFlags = () => readJson<Flags>(statePath("flags.json"), {})
|
|
193
|
+
export const saveFlags = (f: Flags) => writeJson(statePath("flags.json"), f)
|
|
194
|
+
|
|
195
|
+
export interface FleetEntry {
|
|
196
|
+
server: string
|
|
197
|
+
site: string // the site's primary domain
|
|
198
|
+
vanity?: boolean
|
|
199
|
+
}
|
|
200
|
+
export interface Fleet {
|
|
201
|
+
fetchedAt?: string
|
|
202
|
+
domains: Record<string, FleetEntry>
|
|
203
|
+
}
|
|
204
|
+
export const loadFleet = () => readJson<Fleet>(statePath("fleet.json"), { domains: {} })
|
|
205
|
+
export const saveFleet = (f: Fleet) => writeJson(statePath("fleet.json"), f)
|
|
206
|
+
|
|
207
|
+
// ---- logs ----------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
function appendLine(name: string, line: string): void {
|
|
210
|
+
try {
|
|
211
|
+
mkdirSync(sentinelDir(), { recursive: true })
|
|
212
|
+
const path = statePath(name)
|
|
213
|
+
if (existsSync(path) && statSync(path).size > LOG_MAX_BYTES) renameSync(path, `${path}.1`)
|
|
214
|
+
appendFileSync(path, line + "\n", { mode: 0o600 })
|
|
215
|
+
} catch {
|
|
216
|
+
// A log line must never fail a tick.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface TickLine {
|
|
221
|
+
at: string
|
|
222
|
+
ok: boolean
|
|
223
|
+
events: number
|
|
224
|
+
incidents: number
|
|
225
|
+
retries: number
|
|
226
|
+
error?: string
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export const logTick = (line: TickLine) => appendLine("tick.log", JSON.stringify(line))
|
|
230
|
+
export const logShadow = (line: string) => appendLine("shadow.log", `${new Date().toISOString()} ${line}`)
|
|
231
|
+
|
|
232
|
+
export function readTickLog(sinceMs: number): TickLine[] {
|
|
233
|
+
const out: TickLine[] = []
|
|
234
|
+
for (const name of ["tick.log.1", "tick.log"]) {
|
|
235
|
+
try {
|
|
236
|
+
for (const l of readFileSync(statePath(name), "utf8").split("\n")) {
|
|
237
|
+
if (!l) continue
|
|
238
|
+
const t = JSON.parse(l) as TickLine
|
|
239
|
+
if (Date.parse(t.at) >= sinceMs) out.push(t)
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
// Not there yet.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return out
|
|
246
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdtempSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import type { IncidentEvent, IncidentsResult } from "./cliIncidents.ts"
|
|
6
|
+
import { DEFAULT_ALLOW, loadIncidents, loadPause, loadSeen, loadTickState, saveFleet, saveIncident, savePause, saveTickState, type Incident, type SentinelSettings } from "./sentinelState.ts"
|
|
7
|
+
import { sentinelTick, type TickDeps } from "./sentinelTick.ts"
|
|
8
|
+
|
|
9
|
+
const settings = (over: Partial<SentinelSettings> = {}): SentinelSettings => ({ enabled: true, shadow: false, channel: null, reportTo: null, workspace: "/tmp/x", recurrenceHours: 6, maxDiagnosesPerDay: 15, agent: "claude", model: null, timeoutMin: 30, allow: DEFAULT_ALLOW, ...over })
|
|
10
|
+
const now = new Date("2026-10-30T14:00:00Z")
|
|
11
|
+
const down = (domain: string, monitorId: number, time = "2026-10-30 13:58:00.000"): IncidentEvent => ({ domain, kind: "health", status: "down", time, message: "", monitorId })
|
|
12
|
+
const ok = (events: IncidentEvent[], failedMonitors: string[] = []): IncidentsResult => ({ ok: true, windowHours: 1, events, failedMonitors, kumaAlerted: [] })
|
|
13
|
+
|
|
14
|
+
function harness(results: IncidentsResult[], over: Partial<TickDeps> = {}) {
|
|
15
|
+
const pages: string[] = []
|
|
16
|
+
const started: number[] = []
|
|
17
|
+
const slept: number[] = []
|
|
18
|
+
let call = 0
|
|
19
|
+
const deps: TickDeps = {
|
|
20
|
+
incidents: async () => results[Math.min(call++, results.length - 1)]!,
|
|
21
|
+
page: async (p) => (pages.push(p.text), "sent"),
|
|
22
|
+
refreshFleet: async () => null,
|
|
23
|
+
startWorker: () => started.push(1),
|
|
24
|
+
workerRunning: () => false,
|
|
25
|
+
sleep: async (ms) => slept.push(ms),
|
|
26
|
+
now: () => now,
|
|
27
|
+
...over,
|
|
28
|
+
}
|
|
29
|
+
return { deps, pages, started, slept }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "sentinel-t-"))
|
|
34
|
+
process.env.SPINUPTUI_HOME = mkdtempSync(join(tmpdir(), "sentinel-th-"))
|
|
35
|
+
saveFleet({ fetchedAt: now.toISOString(), domains: { "a.example.com": { server: "web1", site: "a.example.com" } } })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const begun = () => saveTickState({ started: true, lastCleanAt: "2026-10-30T13:55:00Z" })
|
|
39
|
+
|
|
40
|
+
describe("the first tick", () => {
|
|
41
|
+
test("marks history as seen rather than paging for it", async () => {
|
|
42
|
+
const h = harness([ok([down("a.example.com", 1)])])
|
|
43
|
+
expect(await sentinelTick(settings(), h.deps)).toMatchObject({ ok: true, firstRun: true })
|
|
44
|
+
expect(h.pages).toEqual([])
|
|
45
|
+
expect(loadIncidents()).toEqual([])
|
|
46
|
+
expect(Object.keys(loadSeen()).length).toBe(1)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe("a normal tick", () => {
|
|
51
|
+
test("a new down event pages, queues and starts the worker, once", async () => {
|
|
52
|
+
begun()
|
|
53
|
+
const h = harness([ok([down("a.example.com", 1)])])
|
|
54
|
+
expect(await sentinelTick(settings(), h.deps)).toMatchObject({ ok: true, events: 1, incidents: 1, started: true })
|
|
55
|
+
expect(h.pages.length).toBe(1)
|
|
56
|
+
expect(h.started.length).toBe(1)
|
|
57
|
+
expect(loadIncidents()[0]!.state).toBe("queued")
|
|
58
|
+
await sentinelTick(settings(), h.deps)
|
|
59
|
+
expect(h.pages.length).toBe(1)
|
|
60
|
+
expect(loadIncidents().length).toBe(1)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("doesn't start a second worker", async () => {
|
|
64
|
+
begun()
|
|
65
|
+
const h = harness([ok([down("a.example.com", 1)])], { workerRunning: () => true })
|
|
66
|
+
await sentinelTick(settings(), h.deps)
|
|
67
|
+
expect(h.started).toEqual([])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("a monitor that couldn't be read keeps the window open, and its event still arrives", async () => {
|
|
71
|
+
begun()
|
|
72
|
+
const late = down("b.example.com", 2, "2026-10-30 13:40:00.000")
|
|
73
|
+
const h = harness([ok([down("a.example.com", 1)], ["b.example.com health"]), ok([down("a.example.com", 1), late])])
|
|
74
|
+
await sentinelTick(settings(), h.deps)
|
|
75
|
+
expect(loadTickState().lastCleanAt).toBe("2026-10-30T13:55:00Z")
|
|
76
|
+
await sentinelTick(settings(), h.deps)
|
|
77
|
+
expect(loadIncidents().map((i) => i.key).sort()).toEqual(["b.example.com", "web1"])
|
|
78
|
+
expect(loadTickState().lastCleanAt).toBe(now.toISOString())
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe("when Kuma can't be read", () => {
|
|
83
|
+
const bad: IncidentsResult = { ok: false, reason: "kuma_error", message: "websocket error" }
|
|
84
|
+
|
|
85
|
+
test("a cold start recovers without saying anything", async () => {
|
|
86
|
+
begun()
|
|
87
|
+
const h = harness([bad, ok([])])
|
|
88
|
+
expect((await sentinelTick(settings(), h.deps)).ok).toBe(true)
|
|
89
|
+
expect(h.slept.length).toBe(1)
|
|
90
|
+
expect(h.pages).toEqual([])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("after three failures it's blind, and exits so Scheduler pages", async () => {
|
|
94
|
+
begun()
|
|
95
|
+
const h = harness([bad])
|
|
96
|
+
expect(await sentinelTick(settings(), h.deps)).toMatchObject({ ok: false, needsPerson: false })
|
|
97
|
+
expect(h.slept.length).toBe(2)
|
|
98
|
+
expect(loadTickState().blindSince).toBeTruthy()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("what's already queued still starts", async () => {
|
|
102
|
+
begun()
|
|
103
|
+
saveIncident({ id: "q", key: "k", server: null, state: "queued", createdAt: now.toISOString(), events: [], down: [], lastDownAt: now.toISOString(), attempts: 0 })
|
|
104
|
+
const h = harness([bad])
|
|
105
|
+
await sentinelTick(settings(), h.deps)
|
|
106
|
+
expect(h.started.length).toBe(1)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test("a configuration problem needs a person, and isn't retried", async () => {
|
|
110
|
+
begun()
|
|
111
|
+
const h = harness([{ ok: false, reason: "no_monitors_registered", message: "none" }])
|
|
112
|
+
expect(await sentinelTick(settings(), h.deps)).toMatchObject({ ok: false, needsPerson: true })
|
|
113
|
+
expect(h.slept).toEqual([])
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe("recovering work", () => {
|
|
118
|
+
const stale = (over: Partial<Incident>): Incident => ({ id: "x", key: "web1", server: "web1", state: "diagnosing", createdAt: now.toISOString(), events: [{ domain: "a.example.com", kind: "health", time: "t", message: "", monitorId: 1 }], down: [1], lastDownAt: now.toISOString(), attempts: 1, ...over })
|
|
119
|
+
|
|
120
|
+
test("a crashed worker's incident is requeued, then failed", async () => {
|
|
121
|
+
begun()
|
|
122
|
+
saveIncident(stale({}))
|
|
123
|
+
const h = harness([ok([])])
|
|
124
|
+
await sentinelTick(settings(), h.deps)
|
|
125
|
+
expect(loadIncidents()[0]!.state).toBe("queued")
|
|
126
|
+
expect(h.started.length).toBe(1)
|
|
127
|
+
saveIncident(stale({ attempts: 2 }))
|
|
128
|
+
await sentinelTick(settings(), h.deps)
|
|
129
|
+
expect(loadIncidents()[0]!.state).toBe("failed")
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("a live worker's incident is left alone", async () => {
|
|
133
|
+
begun()
|
|
134
|
+
saveIncident(stale({}))
|
|
135
|
+
await sentinelTick(settings(), harness([ok([])], { workerRunning: () => true }).deps)
|
|
136
|
+
expect(loadIncidents()[0]!.state).toBe("diagnosing")
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test("a login wait retries when it's due", async () => {
|
|
140
|
+
begun()
|
|
141
|
+
saveIncident(stale({ state: "waiting_agent", retryAfter: "2026-10-30T13:59:00Z" }))
|
|
142
|
+
await sentinelTick(settings(), harness([ok([])]).deps)
|
|
143
|
+
expect(loadIncidents()[0]!.state).toBe("queued")
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test("a post cut off midway isn't retried: it asks a person", async () => {
|
|
147
|
+
begun()
|
|
148
|
+
saveIncident(stale({ state: "reported", post: "posting" }))
|
|
149
|
+
const h = harness([ok([])])
|
|
150
|
+
await sentinelTick(settings(), h.deps)
|
|
151
|
+
expect(loadIncidents()[0]!.post).toBe("unknown")
|
|
152
|
+
expect(h.pages[0]).toContain("may or may not exist")
|
|
153
|
+
await sentinelTick(settings(), h.deps)
|
|
154
|
+
expect(h.pages.length).toBe(1)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe("the daily cap", () => {
|
|
159
|
+
test("pages once and doesn't start the worker", async () => {
|
|
160
|
+
begun()
|
|
161
|
+
saveIncident({ id: "d", key: "z", server: null, state: "reported", createdAt: now.toISOString(), events: [], down: [], lastDownAt: now.toISOString(), attempts: 1, startedAt: now.toISOString() })
|
|
162
|
+
const h = harness([ok([down("a.example.com", 1)])])
|
|
163
|
+
await sentinelTick(settings({ maxDiagnosesPerDay: 1 }), h.deps)
|
|
164
|
+
expect(h.started).toEqual([])
|
|
165
|
+
expect(h.pages.some((p) => p.includes("limit"))).toBe(true)
|
|
166
|
+
await sentinelTick(settings({ maxDiagnosesPerDay: 1 }), h.deps)
|
|
167
|
+
expect(h.pages.filter((p) => p.includes("limit")).length).toBe(1)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe("a pause", () => {
|
|
172
|
+
test("marks events seen without acting, and says so when it ends", async () => {
|
|
173
|
+
begun()
|
|
174
|
+
savePause({ until: "2026-10-30T15:00:00Z" })
|
|
175
|
+
const h = harness([ok([down("a.example.com", 1)])])
|
|
176
|
+
expect(await sentinelTick(settings(), h.deps)).toMatchObject({ paused: true })
|
|
177
|
+
expect(h.pages).toEqual([])
|
|
178
|
+
expect(loadIncidents()).toEqual([])
|
|
179
|
+
expect(loadPause()!.events).toBe(1)
|
|
180
|
+
const later = harness([ok([down("a.example.com", 1)])], { now: () => new Date("2026-10-30T15:05:00Z") })
|
|
181
|
+
await sentinelTick(settings(), later.deps)
|
|
182
|
+
expect(loadPause()).toBeNull()
|
|
183
|
+
expect(loadIncidents()).toEqual([])
|
|
184
|
+
expect(later.pages[0]).toContain("watching again")
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe("shadow mode", () => {
|
|
189
|
+
test("pages nothing, starts nothing, and records what it would do", async () => {
|
|
190
|
+
begun()
|
|
191
|
+
const h = harness([ok([down("a.example.com", 1)])])
|
|
192
|
+
await sentinelTick(settings({ shadow: true }), h.deps)
|
|
193
|
+
expect(h.pages).toEqual([])
|
|
194
|
+
expect(h.started).toEqual([])
|
|
195
|
+
expect(loadIncidents()[0]!.state).toBe("shadow")
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test("going blind in shadow mode doesn't page", async () => {
|
|
199
|
+
begun()
|
|
200
|
+
const h = harness([{ ok: false, reason: "kuma_error", message: "x" }])
|
|
201
|
+
expect((await sentinelTick(settings({ shadow: true }), h.deps)).ok).toBe(true)
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test("a dry run changes nothing", async () => {
|
|
206
|
+
begun()
|
|
207
|
+
const h = harness([ok([down("a.example.com", 1)])])
|
|
208
|
+
const before = loadTickState()
|
|
209
|
+
expect(await sentinelTick(settings(), h.deps, { dryRun: true })).toMatchObject({ ok: true, incidents: 1 })
|
|
210
|
+
expect(h.pages).toEqual([])
|
|
211
|
+
expect(loadIncidents()).toEqual([])
|
|
212
|
+
expect(loadTickState()).toEqual(before)
|
|
213
|
+
expect(loadSeen()).toEqual({})
|
|
214
|
+
})
|