Skip to content

Commit ee433dd

Browse files
V48 (impl-only): Surface real sandbox host/pipeline failures in UI telemetry
Do not mislabel host-command failures as Validation zero-options fail-closed. Always collect evidence/telemetry on failed host runs; throw formatDepositHostFailure from stderr/evidence; stream command-completed and pipeline telemetry events to execution status so the operator sees the actual error.
1 parent 0c78f3e commit ee433dd

4 files changed

Lines changed: 270 additions & 21 deletions

File tree

packages/generic-hosts/VercelSandbox/src/vercel-sandbox-host.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,13 +149,27 @@ export class VercelSandboxPipelineHost {
149149

150150
if (command.required !== false && commandResult.exitCode !== 0) {
151151
outcome = 'failed';
152+
// Keep running artifact collection so UI/telemetry can show stderr
153+
// and evidence.error — do not leave the operator with a blank fail.
152154
break;
153155
}
154156
}
155157

156-
if (outcome === 'completed') {
157-
evidence = await this.readJsonArtifact(sandbox, plan.artifactPaths.evidence);
158-
telemetry = await this.readTextArtifact(sandbox, plan.artifactPaths.telemetry);
158+
// Collect artifacts on completed AND failed runs. Previously only
159+
// completed runs read evidence/telemetry, so command failures returned
160+
// empty artifacts and the product layer mislabeled them as Validation
161+
// zero-options fail-closed.
162+
if (outcome === 'completed' || outcome === 'failed') {
163+
try {
164+
evidence = await this.readJsonArtifact(sandbox, plan.artifactPaths.evidence);
165+
} catch {
166+
evidence = null;
167+
}
168+
try {
169+
telemetry = await this.readTextArtifact(sandbox, plan.artifactPaths.telemetry);
170+
} catch {
171+
telemetry = null;
172+
}
159173
await this.emit({
160174
type: 'artifacts-read',
161175
timestamp: new Date().toISOString(),

uapi/app/api/deposit/synthesize-options/dispatch-deposit-synthesis.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,34 @@ export async function runDepositOptionSynthesis(
208208
void emitStatus(
209209
`sandbox: created id=${event.sandboxId ?? event.name ?? 'unknown'} image=${event.image ?? 'none'}`,
210210
);
211+
} else if (event.type === 'command-started') {
212+
void emitStatus(`sandbox: command-started ${event.label}`);
213+
} else if (event.type === 'command-completed') {
214+
const ok = event.exitCode === 0;
215+
void emitStatus(
216+
`sandbox: command-completed ${event.label} exit=${event.exitCode ?? 'null'}` +
217+
(ok ? '' : ' (FAILED)'),
218+
);
219+
} else if (event.type === 'artifacts-read') {
220+
void emitStatus(
221+
`sandbox: artifacts-read evidence=${event.evidencePresent ? 'yes' : 'no'} telemetry=${event.telemetryPresent ? 'yes' : 'no'}`,
222+
);
223+
} else if (event.type === 'telemetry-artifact-event') {
224+
// Surface in-box pipeline progress when the runner writes telemetry.jsonl
225+
const te = event.telemetryEvent as {
226+
type?: string;
227+
stage?: string;
228+
message?: string;
229+
error?: { message?: string };
230+
} | null;
231+
const kind = te?.type || 'event';
232+
const stage = te?.stage ? ` stage=${te.stage}` : '';
233+
const msg = te?.error?.message || te?.message;
234+
void emitStatus(
235+
`pipeline: ${kind}${stage}${msg ? ` — ${String(msg).slice(0, 280)}` : ''}`,
236+
);
237+
} else if (event.type === 'sandbox-cancelled') {
238+
void emitStatus(`sandbox: cancelled ${event.reason || ''}`.trim());
211239
} else {
212240
void emitStatus(`sandbox: ${event.type}`);
213241
}
@@ -225,6 +253,8 @@ export async function runDepositOptionSynthesis(
225253
if (hostResult.outcome === 'cancelled') {
226254
throw new ExecutionCancelledError(runId);
227255
}
256+
// Host failures throw from runDepositInBoxHost with the real command/pipeline
257+
// error — never fall through to Validation zero-options fail-closed.
228258
rawOptions = hostResult.options as Parameters<typeof validateDepositSynthesisOptions>[0];
229259
inventoryPaths = [
230260
...new Set((rawOptions || []).flatMap((option: any) => option?.coveredSourcePaths || [])),
@@ -236,6 +266,9 @@ export async function runDepositOptionSynthesis(
236266
totalPathCount: inventoryPaths.length,
237267
excludedPathCount: 0,
238268
};
269+
await emitStatus(
270+
`Sandbox host completed with ${Array.isArray(rawOptions) ? rawOptions.length : 0} depositOptions; running fail-closed validation…`,
271+
);
239272
} catch (sandboxError) {
240273
const message =
241274
sandboxError instanceof Error ? sandboxError.message : String(sandboxError);
@@ -244,8 +277,10 @@ export async function runDepositOptionSynthesis(
244277
repositoryFullName,
245278
runId,
246279
image: sandboxImage,
247-
message: message.slice(0, 600),
280+
message: message.slice(0, 800),
248281
});
282+
// Always surface the real host/pipeline error in the execution stream (UI).
283+
await emitStatus(`sandbox: failed — ${message.slice(0, 900)}`);
249284
throw sandboxError instanceof Error
250285
? sandboxError
251286
: new Error(message);

uapi/lib/deposit-source-provisioning.ts

Lines changed: 144 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,92 @@ export interface DepositInBoxHostResult {
135135
options: unknown[];
136136
sandboxId: string | null;
137137
outcome: PipelineHostRunResult["outcome"];
138+
/** Operator-safe failure summary when outcome is failed (no secrets). */
139+
failureMessage?: string | null;
140+
}
141+
142+
const HOST_FAILURE_SNIPPET = 800;
143+
144+
/**
145+
* Build an operator-visible failure message from a sandbox host run.
146+
* Prefer command stderr, then evidence.error / resultReasons — never invent
147+
* Validation "zero options" when the host/pipeline never completed.
148+
*/
149+
export function formatDepositHostFailure(result: {
150+
outcome?: string | null;
151+
sandboxId?: string | null;
152+
commands?: Array<{
153+
label?: string;
154+
exitCode?: number | null;
155+
stderr?: string;
156+
stdout?: string;
157+
}>;
158+
artifacts?: {
159+
evidence?: unknown | null;
160+
telemetry?: string | null;
161+
} | null;
162+
}): string {
163+
const parts: string[] = [];
164+
const failedCmd = [...(result.commands || [])]
165+
.reverse()
166+
.find((c) => c.exitCode != null && c.exitCode !== 0 && c.exitCode !== 130);
167+
if (failedCmd) {
168+
parts.push(
169+
`host command "${failedCmd.label || 'unknown'}" exited ${failedCmd.exitCode}`,
170+
);
171+
const errTail = (failedCmd.stderr || failedCmd.stdout || "").trim();
172+
if (errTail) {
173+
parts.push(errTail.slice(-HOST_FAILURE_SNIPPET));
174+
}
175+
}
176+
177+
const evidence = result.artifacts?.evidence as {
178+
error?: { message?: string; name?: string } | null;
179+
resultReasons?: unknown;
180+
resultState?: unknown;
181+
depositOptions?: unknown;
182+
} | null;
183+
184+
if (evidence?.error?.message) {
185+
parts.push(
186+
`pipeline error: ${String(evidence.error.name || "Error")}: ${String(evidence.error.message).slice(0, 400)}`,
187+
);
188+
}
189+
if (Array.isArray(evidence?.resultReasons) && evidence!.resultReasons!.length > 0) {
190+
parts.push(
191+
`resultReasons: ${evidence!.resultReasons!.slice(0, 4).map(String).join("; ")}`.slice(
192+
0,
193+
500,
194+
),
195+
);
196+
}
197+
if (evidence?.resultState) {
198+
parts.push(`resultState=${String(evidence.resultState)}`);
199+
}
200+
201+
// Last telemetry lines often hold the real Setup/pipeline stack.
202+
const telemetry = result.artifacts?.telemetry;
203+
if (typeof telemetry === "string" && telemetry.trim()) {
204+
const lines = telemetry.trim().split(/\r?\n/).filter(Boolean);
205+
const tail = lines.slice(-3);
206+
for (const line of tail) {
207+
try {
208+
const parsed = JSON.parse(line) as { type?: string; message?: string; error?: { message?: string } };
209+
const msg =
210+
parsed.error?.message ||
211+
parsed.message ||
212+
(parsed.type ? `telemetry:${parsed.type}` : null);
213+
if (msg) parts.push(String(msg).slice(0, 240));
214+
} catch {
215+
parts.push(line.slice(0, 240));
216+
}
217+
}
218+
}
219+
220+
if (parts.length === 0) {
221+
return `Sandbox deposit host run failed (outcome=${result.outcome || "failed"}, sandboxId=${result.sandboxId || "none"}). No command stderr or evidence was returned — rebuild Pipeliner image if Setup in-box clone is missing.`;
222+
}
223+
return `Sandbox deposit host failed: ${parts.join(" | ")}`.slice(0, 1800);
138224
}
139225

140226
export type DepositSandboxGitRevisionStrategy = GitWorkingTreeStrategy;
@@ -295,30 +381,79 @@ export async function runDepositInBoxHost(input: {
295381
});
296382
throw error;
297383
}
384+
const evidenceRaw = result?.artifacts?.evidence as {
385+
depositOptions?: unknown;
386+
error?: { message?: string } | null;
387+
resultState?: unknown;
388+
} | null;
389+
const optionCount = Array.isArray(evidenceRaw?.depositOptions)
390+
? evidenceRaw!.depositOptions!.length
391+
: null;
392+
const failedCommands = (result?.commands || []).filter(
393+
(c) => c.exitCode != null && c.exitCode !== 0 && c.exitCode !== 130,
394+
);
395+
298396
bitcodeServerTelemetry("info", "deposit-sandbox-host", "run-complete", {
299397
...createSummary,
300398
outcome: result?.outcome ?? null,
301399
sandboxId: result?.sandboxId ?? null,
302-
optionCount: Array.isArray(
303-
(result?.artifacts?.evidence as { depositOptions?: unknown[] } | null)?.depositOptions,
304-
)
305-
? (result!.artifacts!.evidence as { depositOptions: unknown[] }).depositOptions.length
400+
optionCount,
401+
failedCommandLabels: failedCommands.map((c) => c.label).slice(0, 8),
402+
failedExitCodes: failedCommands.map((c) => c.exitCode).slice(0, 8),
403+
evidenceResultState: evidenceRaw?.resultState ?? null,
404+
evidenceError: evidenceRaw?.error?.message
405+
? String(evidenceRaw.error.message).slice(0, 300)
306406
: null,
407+
hasEvidence: evidenceRaw != null,
408+
hasTelemetry: Boolean(result?.artifacts?.telemetry),
307409
});
410+
308411
if (result?.outcome === "cancelled") {
309412
return {
310413
options: [],
311414
sandboxId: result.sandboxId ?? null,
312415
outcome: "cancelled",
313416
};
314417
}
315-
const evidence = result?.artifacts?.evidence as {
316-
depositOptions?: unknown;
317-
} | null;
418+
419+
if (result?.outcome === "failed") {
420+
const failureMessage = formatDepositHostFailure(result);
421+
bitcodeServerTelemetry("error", "deposit-sandbox-host", "host-outcome-failed", {
422+
...createSummary,
423+
sandboxId: result.sandboxId ?? null,
424+
message: failureMessage.slice(0, 800),
425+
failedCommandLabels: failedCommands.map((c) => c.label).slice(0, 8),
426+
});
427+
const err = new Error(failureMessage) as Error & { hostOutcome?: string };
428+
err.hostOutcome = "failed";
429+
throw err;
430+
}
431+
318432
const options =
319-
evidence && Array.isArray(evidence.depositOptions)
320-
? evidence.depositOptions
433+
evidenceRaw && Array.isArray(evidenceRaw.depositOptions)
434+
? evidenceRaw.depositOptions
321435
: [];
436+
437+
// Completed host with no options is a pipeline/product miss — not Validation
438+
// path-admission (that path only applies when options were produced).
439+
if (options.length === 0) {
440+
const detail = formatDepositHostFailure({
441+
...result,
442+
outcome: "completed-empty-options",
443+
});
444+
const message =
445+
`Sandbox deposit pipeline completed with zero depositOptions. ${detail}`.slice(
446+
0,
447+
1800,
448+
);
449+
bitcodeServerTelemetry("error", "deposit-sandbox-host", "empty-deposit-options", {
450+
...createSummary,
451+
sandboxId: result?.sandboxId ?? null,
452+
message: message.slice(0, 800),
453+
});
454+
throw new Error(message);
455+
}
456+
322457
return {
323458
options,
324459
sandboxId: result.sandboxId ?? null,

uapi/tests/lib/depositSourceProvisioning.test.ts

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import {
55
buildDepositSandboxGitSource,
66
createDepositLocalHostCloneForRun,
7+
formatDepositHostFailure,
78
provisionDepositCheckout,
89
provisionDepositSourceInventory,
910
resolveDepositPipelineHost,
@@ -248,22 +249,86 @@ describe('runDepositInBoxHost (#25)', () => {
248249
expect(receivedPlan.createOptions.name).toMatch(/^bitcode-deposit-/);
249250
});
250251

251-
it('returns empty options when the evidence has no depositOptions', async () => {
252+
it('throws a host/pipeline error (not Validation zero-options) when outcome is failed', async () => {
252253
const fakeHost = {
253254
runHostPlan: async () => ({
254-
sandboxId: null,
255-
artifacts: { evidence: {}, telemetry: null },
255+
sandboxId: 'sbx_fail',
256+
artifacts: {
257+
evidence: {
258+
error: { name: 'Error', message: 'Setup clone failed: Host git clone failed' },
259+
resultReasons: ['AssetPack pipeline execution did not produce admissible result evidence.'],
260+
},
261+
telemetry: null,
262+
},
263+
outcome: 'failed',
264+
stopped: true,
265+
manifest: {},
266+
commands: [
267+
{
268+
label: 'asset-pack-pipeline-run',
269+
exitCode: 1,
270+
stderr: 'Error: Setup clone failed: Host git clone failed\n',
271+
stdout: '',
272+
},
273+
],
274+
}),
275+
};
276+
await expect(
277+
runDepositInBoxHost({
278+
repositoryFullName: 'o/r',
279+
revision: 'main',
280+
branch: 'main',
281+
commit: null,
282+
obfuscations: null,
283+
forcedExclusions: [],
284+
demandContext: [],
285+
hostFactory: async () => fakeHost,
286+
}),
287+
).rejects.toThrow(/Sandbox deposit host failed|Setup clone failed|asset-pack-pipeline-run/);
288+
});
289+
290+
it('throws when host completed with zero depositOptions (distinct from Validation)', async () => {
291+
const fakeHost = {
292+
runHostPlan: async () => ({
293+
sandboxId: 'sbx_empty',
294+
artifacts: { evidence: { depositOptions: [], resultState: 'blocked_readiness' }, telemetry: null },
256295
outcome: 'completed',
257296
stopped: true,
258297
manifest: {},
259298
commands: [],
260299
}),
261300
};
262-
const result = await runDepositInBoxHost({
263-
repositoryFullName: 'o/r', revision: 'main', branch: 'main', commit: null,
264-
obfuscations: null, forcedExclusions: [], demandContext: [], hostFactory: async () => fakeHost,
301+
await expect(
302+
runDepositInBoxHost({
303+
repositoryFullName: 'o/r',
304+
revision: 'main',
305+
branch: 'main',
306+
commit: null,
307+
obfuscations: null,
308+
forcedExclusions: [],
309+
demandContext: [],
310+
hostFactory: async () => fakeHost,
311+
}),
312+
).rejects.toThrow(/zero depositOptions/);
313+
});
314+
});
315+
316+
describe('formatDepositHostFailure', () => {
317+
it('prefers failed command stderr and evidence.error', () => {
318+
const msg = formatDepositHostFailure({
319+
outcome: 'failed',
320+
sandboxId: 'sbx1',
321+
commands: [
322+
{ label: 'runtime-readiness', exitCode: 0, stderr: '', stdout: '' },
323+
{ label: 'asset-pack-pipeline-run', exitCode: 1, stderr: 'boom stack', stdout: '' },
324+
],
325+
artifacts: {
326+
evidence: { error: { name: 'Error', message: 'clone failed' }, resultReasons: ['no tree'] },
327+
},
265328
});
266-
expect(result.options).toEqual([]);
267-
expect(result.outcome).toBe('completed');
329+
expect(msg).toMatch(/asset-pack-pipeline-run/);
330+
expect(msg).toMatch(/boom stack/);
331+
expect(msg).toMatch(/clone failed/);
332+
expect(msg).not.toMatch(/Validation absolutes/);
268333
});
269334
});

0 commit comments

Comments
 (0)