Skip to content

Commit c3fb2d5

Browse files
fix: sandbox monitoring lifecycle bounds (#287)
1 parent 48ca9e9 commit c3fb2d5

File tree

10 files changed

+657
-155
lines changed

10 files changed

+657
-155
lines changed

src/__test__/unit/sandbox-lifecycle.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ describe('deriveSandboxLifecycleFromEvents', () => {
8888
expect(lifecycle.endedAt).toBeNull()
8989
})
9090

91-
it('uses first created event and latest killed event', () => {
91+
it('uses first created event and the last killed event', () => {
9292
const events: SandboxEventDTO[] = [
9393
createLifecycleEvent({
9494
id: '1',
@@ -118,6 +118,99 @@ describe('deriveSandboxLifecycleFromEvents', () => {
118118
expect(lifecycle.endedAt).toBe('2026-03-06T19:15:00.000Z')
119119
})
120120

121+
it('uses the last paused or killed event to constrain the lifecycle', () => {
122+
const events: SandboxEventDTO[] = [
123+
createLifecycleEvent({
124+
id: '1',
125+
type: 'sandbox.lifecycle.created',
126+
timestamp: '2026-03-06T19:00:00.000Z',
127+
}),
128+
createLifecycleEvent({
129+
id: '2',
130+
type: 'sandbox.lifecycle.paused',
131+
timestamp: '2026-03-06T19:05:00.000Z',
132+
}),
133+
createLifecycleEvent({
134+
id: '3',
135+
type: 'sandbox.lifecycle.paused',
136+
timestamp: '2026-03-06T19:06:00.000Z',
137+
}),
138+
createLifecycleEvent({
139+
id: '4',
140+
type: 'sandbox.lifecycle.killed',
141+
timestamp: '2026-03-06T19:07:00.000Z',
142+
}),
143+
createLifecycleEvent({
144+
id: '5',
145+
type: 'sandbox.lifecycle.resumed',
146+
timestamp: '2026-03-06T19:08:00.000Z',
147+
}),
148+
createLifecycleEvent({
149+
id: '6',
150+
type: 'sandbox.lifecycle.killed',
151+
timestamp: '2026-03-06T19:09:00.000Z',
152+
}),
153+
]
154+
155+
const lifecycle = deriveSandboxLifecycleFromEvents(events)
156+
157+
expect(lifecycle.createdAt).toBe('2026-03-06T19:00:00.000Z')
158+
expect(lifecycle.pausedAt).toBeNull()
159+
expect(lifecycle.endedAt).toBe('2026-03-06T19:09:00.000Z')
160+
})
161+
162+
it('does not constrain when the last event is resumed', () => {
163+
const events: SandboxEventDTO[] = [
164+
createLifecycleEvent({
165+
id: '1',
166+
type: 'sandbox.lifecycle.created',
167+
timestamp: '2026-03-06T19:00:00.000Z',
168+
}),
169+
createLifecycleEvent({
170+
id: '2',
171+
type: 'sandbox.lifecycle.paused',
172+
timestamp: '2026-03-06T19:05:00.000Z',
173+
}),
174+
createLifecycleEvent({
175+
id: '3',
176+
type: 'sandbox.lifecycle.resumed',
177+
timestamp: '2026-03-06T19:06:00.000Z',
178+
}),
179+
]
180+
181+
const lifecycle = deriveSandboxLifecycleFromEvents(events)
182+
183+
expect(lifecycle.createdAt).toBe('2026-03-06T19:00:00.000Z')
184+
expect(lifecycle.pausedAt).toBeNull()
185+
expect(lifecycle.endedAt).toBeNull()
186+
})
187+
188+
it('does not constrain when the last event is updated', () => {
189+
const events: SandboxEventDTO[] = [
190+
createLifecycleEvent({
191+
id: '1',
192+
type: 'sandbox.lifecycle.created',
193+
timestamp: '2026-03-06T19:00:00.000Z',
194+
}),
195+
createLifecycleEvent({
196+
id: '2',
197+
type: 'sandbox.lifecycle.paused',
198+
timestamp: '2026-03-06T19:05:00.000Z',
199+
}),
200+
createLifecycleEvent({
201+
id: '3',
202+
type: 'sandbox.lifecycle.updated',
203+
timestamp: '2026-03-06T19:06:00.000Z',
204+
}),
205+
]
206+
207+
const lifecycle = deriveSandboxLifecycleFromEvents(events)
208+
209+
expect(lifecycle.createdAt).toBe('2026-03-06T19:00:00.000Z')
210+
expect(lifecycle.pausedAt).toBeNull()
211+
expect(lifecycle.endedAt).toBeNull()
212+
})
213+
121214
it('ignores non-lifecycle events', () => {
122215
const events: SandboxEventDTO[] = [
123216
createLifecycleEvent({

src/__test__/unit/sandbox-monitoring-chart-model.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,189 @@ describe('buildMonitoringChartModel', () => {
215215
])
216216
})
217217

218+
it('treats killed periods as inactive gaps until the next resume', () => {
219+
const metrics: SandboxMetric[] = [
220+
{
221+
...baseMetric,
222+
timestampUnix: 10,
223+
cpuUsedPct: 10,
224+
memUsed: 100,
225+
diskUsed: 200,
226+
},
227+
{
228+
...baseMetric,
229+
timestampUnix: 50,
230+
cpuUsedPct: 50,
231+
memUsed: 500,
232+
diskUsed: 1_000,
233+
},
234+
]
235+
236+
const lifecycleEvents: SandboxEventDTO[] = [
237+
createLifecycleEvent({
238+
id: 'pause-1',
239+
type: 'sandbox.lifecycle.paused',
240+
timestamp: '1970-01-01T00:00:20.000Z',
241+
}),
242+
createLifecycleEvent({
243+
id: 'kill-1',
244+
type: 'sandbox.lifecycle.killed',
245+
timestamp: '1970-01-01T00:00:40.000Z',
246+
}),
247+
createLifecycleEvent({
248+
id: 'kill-2',
249+
type: 'sandbox.lifecycle.killed',
250+
timestamp: '1970-01-01T00:00:45.000Z',
251+
}),
252+
createLifecycleEvent({
253+
id: 'resume',
254+
type: 'sandbox.lifecycle.resumed',
255+
timestamp: '1970-01-01T00:00:55.000Z',
256+
}),
257+
]
258+
259+
const result = buildMonitoringChartModel({
260+
metrics,
261+
lifecycleEvents,
262+
startMs: 0,
263+
endMs: 60_000,
264+
})
265+
266+
expect(result.resourceSeries[0]?.data).toEqual([
267+
[10_000, 10, null],
268+
[50_000, null, null],
269+
])
270+
expect(result.resourceSeries[0]?.connectors).toEqual([
271+
{
272+
from: [10_000, 10],
273+
to: [20_000, 10],
274+
},
275+
])
276+
})
277+
278+
it('draws a synthetic dashed connector across an active lifecycle window when no metrics were collected', () => {
279+
const lifecycleEvents: SandboxEventDTO[] = [
280+
createLifecycleEvent({
281+
id: 'created',
282+
type: 'sandbox.lifecycle.created',
283+
timestamp: '1970-01-01T00:00:01.000Z',
284+
}),
285+
createLifecycleEvent({
286+
id: 'paused',
287+
type: 'sandbox.lifecycle.paused',
288+
timestamp: '1970-01-01T00:00:04.000Z',
289+
}),
290+
createLifecycleEvent({
291+
id: 'resumed',
292+
type: 'sandbox.lifecycle.resumed',
293+
timestamp: '1970-01-01T00:00:08.000Z',
294+
}),
295+
]
296+
297+
const result = buildMonitoringChartModel({
298+
metrics: [
299+
{
300+
...baseMetric,
301+
timestampUnix: 10,
302+
cpuUsedPct: 50,
303+
memUsed: 500,
304+
diskUsed: 1_000,
305+
},
306+
],
307+
lifecycleEvents,
308+
startMs: 0,
309+
endMs: 12_000,
310+
})
311+
312+
expect(result.resourceSeries[0]?.data).toEqual([[10_000, 50, null]])
313+
expect(result.resourceSeries[0]?.connectors).toEqual([
314+
{
315+
from: [8_000, 50],
316+
to: [10_000, 50],
317+
},
318+
{
319+
from: [1_000, 0],
320+
to: [4_000, 0],
321+
isSynthetic: true,
322+
},
323+
])
324+
})
325+
326+
it('draws a dashed connector from created to the first metric when the range starts at created', () => {
327+
const lifecycleEvents: SandboxEventDTO[] = [
328+
createLifecycleEvent({
329+
id: 'created',
330+
type: 'sandbox.lifecycle.created',
331+
timestamp: '1970-01-01T00:00:01.000Z',
332+
}),
333+
]
334+
335+
const result = buildMonitoringChartModel({
336+
metrics: [
337+
{
338+
...baseMetric,
339+
timestampUnix: 10,
340+
cpuUsedPct: 50,
341+
memUsed: 500,
342+
diskUsed: 1_000,
343+
},
344+
],
345+
lifecycleEvents,
346+
startMs: 1_000,
347+
endMs: 12_000,
348+
})
349+
350+
expect(result.resourceSeries[0]?.data).toEqual([[10_000, 50, null]])
351+
expect(result.resourceSeries[0]?.connectors).toEqual([
352+
{
353+
from: [1_000, 50],
354+
to: [10_000, 50],
355+
},
356+
])
357+
})
358+
359+
it('draws a dashed connector from created to the first metric when the range starts before created', () => {
360+
const lifecycleEvents: SandboxEventDTO[] = [
361+
createLifecycleEvent({
362+
id: 'created',
363+
type: 'sandbox.lifecycle.created',
364+
timestamp: '1970-01-01T00:00:01.000Z',
365+
}),
366+
createLifecycleEvent({
367+
id: 'killed',
368+
type: 'sandbox.lifecycle.killed',
369+
timestamp: '1970-01-01T00:00:20.000Z',
370+
}),
371+
]
372+
373+
const result = buildMonitoringChartModel({
374+
metrics: [
375+
{
376+
...baseMetric,
377+
timestampUnix: 10,
378+
cpuUsedPct: 50,
379+
memUsed: 500,
380+
diskUsed: 1_000,
381+
},
382+
],
383+
lifecycleEvents,
384+
startMs: 0,
385+
endMs: 22_000,
386+
})
387+
388+
expect(result.resourceSeries[0]?.data).toEqual([[10_000, 50, null]])
389+
expect(result.resourceSeries[0]?.connectors).toEqual([
390+
{
391+
from: [10_000, 50],
392+
to: [20_000, 50],
393+
},
394+
{
395+
from: [1_000, 50],
396+
to: [10_000, 50],
397+
},
398+
])
399+
})
400+
218401
it('builds visible lifecycle event markers for created, paused, resumed, and killed only', () => {
219402
const lifecycleEvents: SandboxEventDTO[] = [
220403
createLifecycleEvent({

0 commit comments

Comments
 (0)