Skip to content

Commit 1f1f803

Browse files
authored
Merge pull request #3399 from trycompai/main
[comp] Production Deploy
2 parents cbe329f + 6033674 commit 1f1f803

11 files changed

Lines changed: 1532 additions & 62 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import {
2+
collapsedStatus,
3+
MAX_ATTEMPTS,
4+
RETRY_GRACE_MS,
5+
} from './pentest-lineage.util';
6+
7+
describe('collapsedStatus', () => {
8+
const now = 1_000_000_000_000;
9+
10+
it('passes through completed', () => {
11+
expect(
12+
collapsedStatus({
13+
activeStatus: 'completed',
14+
attemptNumber: 2,
15+
failedAtMs: null,
16+
nowMs: now,
17+
}),
18+
).toBe('completed');
19+
});
20+
21+
it.each(['provisioning', 'cloning', 'running'] as const)(
22+
'passes through in-progress status %s',
23+
(status) => {
24+
expect(
25+
collapsedStatus({
26+
activeStatus: status,
27+
attemptNumber: 1,
28+
failedAtMs: null,
29+
nowMs: now,
30+
}),
31+
).toBe(status);
32+
},
33+
);
34+
35+
it('never masks cancelled (staff cancel is terminal, never retried)', () => {
36+
expect(
37+
collapsedStatus({
38+
activeStatus: 'cancelled',
39+
attemptNumber: 1,
40+
failedAtMs: now - 1000,
41+
nowMs: now,
42+
}),
43+
).toBe('cancelled');
44+
});
45+
46+
describe('failed', () => {
47+
it('masks a fresh failed non-final attempt as provisioning', () => {
48+
expect(
49+
collapsedStatus({
50+
activeStatus: 'failed',
51+
attemptNumber: 1,
52+
failedAtMs: now - 5_000, // well within grace
53+
nowMs: now,
54+
}),
55+
).toBe('provisioning');
56+
});
57+
58+
it('masks the second attempt too', () => {
59+
expect(
60+
collapsedStatus({
61+
activeStatus: 'failed',
62+
attemptNumber: 2,
63+
failedAtMs: now - 5_000,
64+
nowMs: now,
65+
}),
66+
).toBe('provisioning');
67+
});
68+
69+
it('reveals failed once the lineage is exhausted (final attempt)', () => {
70+
expect(
71+
collapsedStatus({
72+
activeStatus: 'failed',
73+
attemptNumber: MAX_ATTEMPTS,
74+
failedAtMs: now - 5_000,
75+
nowMs: now,
76+
}),
77+
).toBe('failed');
78+
});
79+
80+
it('reveals failed after the grace window elapses without a retry', () => {
81+
expect(
82+
collapsedStatus({
83+
activeStatus: 'failed',
84+
attemptNumber: 1,
85+
failedAtMs: now - (RETRY_GRACE_MS + 1),
86+
nowMs: now,
87+
}),
88+
).toBe('failed');
89+
});
90+
91+
it('reveals failed exactly at the grace-window boundary', () => {
92+
expect(
93+
collapsedStatus({
94+
activeStatus: 'failed',
95+
attemptNumber: 1,
96+
failedAtMs: now - RETRY_GRACE_MS,
97+
nowMs: now,
98+
}),
99+
).toBe('failed');
100+
});
101+
102+
it('reveals failed when the failure time is unknown (cannot bound the mask)', () => {
103+
expect(
104+
collapsedStatus({
105+
activeStatus: 'failed',
106+
attemptNumber: 1,
107+
failedAtMs: null,
108+
nowMs: now,
109+
}),
110+
).toBe('failed');
111+
});
112+
113+
it('reveals failed immediately when not retry-eligible (e.g. no scan params)', () => {
114+
expect(
115+
collapsedStatus({
116+
activeStatus: 'failed',
117+
attemptNumber: 1,
118+
retryEligible: false,
119+
failedAtMs: now - 5_000, // well within grace, but can't retry
120+
nowMs: now,
121+
}),
122+
).toBe('failed');
123+
});
124+
});
125+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Pure helpers for the pentest retry-lineage feature.
3+
*
4+
* A scan is a "lineage": the original run plus up to `MAX_ATTEMPTS - 1`
5+
* automatic retries, all sharing one root run id. Customers only ever see the
6+
* root; our API resolves it to the active (highest-numbered) attempt and only
7+
* reports a terminal failure once the whole lineage is exhausted.
8+
*/
9+
10+
export type PentestRunStatus =
11+
| 'provisioning'
12+
| 'cloning'
13+
| 'running'
14+
| 'completed'
15+
| 'failed'
16+
| 'cancelled';
17+
18+
/** Original attempt + 2 automatic retries. */
19+
export const MAX_ATTEMPTS = 3;
20+
21+
/**
22+
* How long a failed non-final attempt is masked as in-progress while its
23+
* retry is spawned. Comfortably covers provider webhook-delivery latency; if a
24+
* retry never materializes within this window the real failure is revealed so
25+
* the run can't hang on a fake "running" state forever.
26+
*/
27+
export const RETRY_GRACE_MS = 10 * 60 * 1000;
28+
29+
/**
30+
* Computes the customer-facing status for a lineage from its active (highest)
31+
* attempt. The active attempt is authoritative because we only ever retry
32+
* `failed` runs — so no earlier attempt can be `completed`/`running`.
33+
*
34+
* - `completed` / in-progress statuses pass through unchanged.
35+
* - `cancelled` is terminal and never retried → always revealed.
36+
* - `failed`:
37+
* - not retry-eligible (lineage exhausted, or the run can't actually be
38+
* re-run — e.g. a pre-feature row with no stored scan params) → revealed
39+
* immediately, so it's never masked as in-progress pointlessly.
40+
* - otherwise masked as `provisioning` (a retry is expected) while inside
41+
* the grace window; revealed once the window elapses without a retry
42+
* appearing, or when the failure time is unknown (can't bound the mask).
43+
*/
44+
export function collapsedStatus(params: {
45+
activeStatus: PentestRunStatus;
46+
attemptNumber: number;
47+
failedAtMs: number | null;
48+
nowMs: number;
49+
/** Whether this run can actually be auto-retried (has re-runnable params). */
50+
retryEligible?: boolean;
51+
maxAttempts?: number;
52+
graceMs?: number;
53+
}): PentestRunStatus {
54+
const { activeStatus, attemptNumber, failedAtMs, nowMs } = params;
55+
const maxAttempts = params.maxAttempts ?? MAX_ATTEMPTS;
56+
const graceMs = params.graceMs ?? RETRY_GRACE_MS;
57+
const retryEligible = params.retryEligible ?? true;
58+
59+
// Only `failed` triggers an auto-retry, so only `failed` is ever masked.
60+
if (activeStatus !== 'failed') {
61+
return activeStatus;
62+
}
63+
64+
// Reveal immediately if no retry can happen — lineage exhausted or the run
65+
// isn't re-runnable — so a failure that will never retry isn't masked.
66+
if (attemptNumber >= maxAttempts || !retryEligible) {
67+
return 'failed';
68+
}
69+
if (failedAtMs === null || nowMs - failedAtMs >= graceMs) {
70+
return 'failed';
71+
}
72+
return 'provisioning';
73+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { toCustomerFacingError } from './pentest-run-error.util';
2+
3+
describe('toCustomerFacingError', () => {
4+
const infraMessagePrefix =
5+
'The scan was interrupted by a temporary infrastructure issue';
6+
7+
describe('infra / transient failures', () => {
8+
const infraErrors = [
9+
'Sandbox container deleted by Daytona infrastructure (age 35m). Desired-state=started but container gone — likely Daytona backup/restore race condition.',
10+
'Run timed out — sandbox became unresponsive',
11+
'Timed out with no sandbox after 9m',
12+
'Hard timeout: 4m (cap 120m); Daytona unreachable',
13+
];
14+
15+
it.each(infraErrors)(
16+
'maps infra error to the transient message: %s',
17+
(raw) => {
18+
expect(toCustomerFacingError(raw)).toContain(infraMessagePrefix);
19+
},
20+
);
21+
22+
it('never echoes the raw provider text', () => {
23+
const raw =
24+
'Sandbox container deleted by Daytona infrastructure (age 35m). backup/restore race condition.';
25+
const message = toCustomerFacingError(raw);
26+
expect(message.toLowerCase()).not.toContain('daytona');
27+
expect(message.toLowerCase()).not.toContain('sandbox');
28+
expect(message.toLowerCase()).not.toContain('container');
29+
});
30+
});
31+
32+
describe('genuine runtime cap', () => {
33+
it('humanizes a 720m cap as 12 hours', () => {
34+
const message = toCustomerFacingError(
35+
'Hard timeout: workflow ran 722m (cap 720m)',
36+
);
37+
expect(message).toContain('maximum runtime of 12 hours');
38+
expect(message).toContain("won't be charged");
39+
});
40+
41+
it('humanizes a 120m cap as 2 hours', () => {
42+
const message = toCustomerFacingError(
43+
'Hard timeout: workflow ran 130m (cap 120m)',
44+
);
45+
expect(message).toContain('maximum runtime of 2 hours');
46+
});
47+
48+
it('renders a single-hour cap without pluralizing', () => {
49+
const message = toCustomerFacingError(
50+
'Hard timeout: workflow ran 61m (cap 60m)',
51+
);
52+
expect(message).toContain('maximum runtime of 1 hour');
53+
expect(message).not.toContain('1 hours');
54+
});
55+
56+
it('falls back to minutes for a non-hour cap', () => {
57+
const message = toCustomerFacingError(
58+
'Hard timeout: workflow ran 95m (cap 90m)',
59+
);
60+
expect(message).toContain('maximum runtime of 90 minutes');
61+
});
62+
63+
it('renders a single-minute cap without pluralizing', () => {
64+
const message = toCustomerFacingError(
65+
'Hard timeout: workflow ran 2m (cap 1m)',
66+
);
67+
expect(message).toContain('maximum runtime of 1 minute');
68+
expect(message).not.toContain('1 minutes');
69+
});
70+
71+
it('falls back to the generic message for a malformed zero cap', () => {
72+
const message = toCustomerFacingError(
73+
'Hard timeout: workflow ran 0m (cap 0m)',
74+
);
75+
expect(message).not.toContain('maximum runtime');
76+
expect(message).toContain("The scan couldn't be completed");
77+
});
78+
79+
it('falls back to the generic message for an absurd oversized cap', () => {
80+
const message = toCustomerFacingError(
81+
'Hard timeout: workflow ran 1m (cap 999999999999999999999999m)',
82+
);
83+
expect(message).not.toContain('maximum runtime');
84+
expect(message).toContain("The scan couldn't be completed");
85+
});
86+
87+
it('classifies the "; Daytona unreachable" variant as infra, not runtime cap', () => {
88+
const message = toCustomerFacingError(
89+
'Hard timeout: 4m (cap 120m); Daytona unreachable',
90+
);
91+
expect(message).toContain(infraMessagePrefix);
92+
expect(message).not.toContain('maximum runtime');
93+
});
94+
});
95+
96+
describe('unknown / empty', () => {
97+
const genericPrefix = "The scan couldn't be completed";
98+
99+
it('returns the generic message for an unrecognized error', () => {
100+
expect(
101+
toCustomerFacingError('some brand new error we have not seen'),
102+
).toContain(genericPrefix);
103+
});
104+
105+
it('returns the generic message for null / undefined / empty', () => {
106+
expect(toCustomerFacingError(null)).toContain(genericPrefix);
107+
expect(toCustomerFacingError(undefined)).toContain(genericPrefix);
108+
expect(toCustomerFacingError('')).toContain(genericPrefix);
109+
});
110+
});
111+
});

0 commit comments

Comments
 (0)