fix(runner): stabilize local paid E2E recovery (#12836)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paid runner E2E tests verify the complete runner, control-plane, and UI path. > - A server restart could load a fresh task page while Playwright still waited on an unsettled Vite navigation lifecycle. > - The current server also ignored the isolated Vite cache path and skipped Vite's per-request HTML transform from the known-green runner snapshot. > - A one-cell paid run then exposed that download-artifact v8 removes the artifact-name directory for one pattern match. > - This pull request restores the Vite contract, proves a fresh document after restart, and accepts only the exact singleton artifact layout. > - The benefit is reliable local runner qualification without weaker UI, source, or artifact checks. ## Linked Issues or Issue Description Refs #12769 Refs #12828 Refs #12829 Refs #12833 **What happened?** The structured-question restart test could time out after the replacement server returned the task route and rendered the durable pending interaction. A focused one-cell rerun passed the paid test but failed aggregation because download-artifact v8 flattened its single artifact. **Expected behavior** The test must prove that a new document loaded after the server restart and that the same pending interaction survived. The aggregate must accept the exact documented singleton download layout while it continues to reject ambiguous or foreign artifacts. **Steps to reproduce** 1. Run the local ACPX-Codex structured-question restart-resume cell. 2. Restart the isolated server while the question waits for an answer. 3. Observe that the route and task UI can reload before Playwright settles the navigation promise. 4. Run a paid campaign with one selected cell. 5. Observe download-artifact v8 extract the sole campaign directory directly into the requested path. **Paperclip version or commit** The local campaign reproduced the navigation failure at `3586956a1b794b3cb4a9c5f57ffb7355e2b0c46d`. The one-cell aggregate reproduced the singleton layout at `f487660c0a06ba06ca140b57386f21ed39f13120`. This fix is `de4ccceff453a4b39436bf9a2eb8f03924151af7`. **Deployment mode** Local development and paid GitHub Actions. **Installation method** Built from source. **Agent adapter(s) involved** ACPX-Codex. The Vite and aggregate fixes are provider-neutral. ## What Changed - Prove a new post-restart browser document with an in-memory sentinel. - Tolerate only Playwright's navigation timeout before the exact UI and API checks run. - Honor `PAPERCLIP_VITE_CACHE_DIR` in the embedded Vite server. - Limit dependency optimization to the real UI entry. - Run `vite.transformIndexHtml` for each request while caching only the branded source template. - Accept download-artifact v8's flattened layout only for one expected cell with one unique recognized campaign. - Keep source SHA, source ref, workflow URL, execution ID, attempt, and unexpected-entry validation. - Add focused positive and negative regressions for Vite rendering and singleton artifact selection. ## Verification - Exact 45-cell local campaign https://github.com/paperclipai/paperclip/actions/runs/33888939013 passed 44/45. Its only failure was the post-restart navigation false negative fixed here. - Exact focused rerun https://github.com/paperclipai/paperclip/actions/runs/33891207957 passed the ACPX-Codex restart cell first attempt with the same session, two durable runs, the terminal marker once, and cleanup complete. - The focused Vite renderer suite passed 2/2 tests. - The focused rerun-artifact selector suite passed 12/12 tests. - Prettier and `git diff --check` passed. - An exact-head 45-cell confirmation is pending. ## Risks Low to medium risk. The Vite change restores known-green per-request transforms and isolated cache behavior. It can affect all development UI loads. The paid matrix and ordinary CI will verify that behavior. The singleton selector remains fail-closed for ambiguous layouts and validates every result source. ## Model Used OpenAI Codex, `gpt-5.6-sol`, extended reasoning, tool use, code execution, and parallel focused agents. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
184b014c25
commit
b84964e5a2
|
|
@ -1,7 +1,7 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createCachedViteHtmlRenderer, type ViteWatcherHost } from "../vite-html-renderer.js";
|
||||
|
||||
function createWatcher() {
|
||||
|
|
@ -32,7 +32,7 @@ describe("createCachedViteHtmlRenderer", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("reuses the injected dev html shell until index.html changes", async () => {
|
||||
it("caches the branded template until index.html changes while transforming every request", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-"));
|
||||
tempDirs.push(tempDir);
|
||||
const indexPath = path.join(tempDir, "index.html");
|
||||
|
|
@ -43,25 +43,36 @@ describe("createCachedViteHtmlRenderer", () => {
|
|||
);
|
||||
|
||||
const watcher = createWatcher();
|
||||
const transformIndexHtml = vi.fn(async (_url: string, html: string) =>
|
||||
html.replace(
|
||||
'<script type="module" src="/src/main.tsx"></script>',
|
||||
'<script type="module" src="/@vite/client"></script>\n<script type="module" src="/src/main.tsx"></script>',
|
||||
),
|
||||
);
|
||||
const brandHtml = vi.fn((html: string) => html.replace("<body>", '<body data-brand="paperclip">'));
|
||||
const vite: ViteWatcherHost = {
|
||||
watcher,
|
||||
transformIndexHtml,
|
||||
};
|
||||
|
||||
const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir });
|
||||
const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir, brandHtml });
|
||||
|
||||
await expect(renderer.render("/")).resolves.toContain("/@vite/client");
|
||||
await expect(renderer.render("/")).resolves.toContain('"/@react-refresh"');
|
||||
const first = await renderer.render("/");
|
||||
const second = await renderer.render("/issues");
|
||||
expect(first).toBe(second);
|
||||
expect(first).toContain('data-brand="paperclip"');
|
||||
expect(first.match(/\/@vite\/client/g)?.length).toBe(1);
|
||||
expect(first).toContain("window.$RefreshReg$");
|
||||
expect(brandHtml).toHaveBeenCalledTimes(1);
|
||||
expect(transformIndexHtml).toHaveBeenCalledTimes(3);
|
||||
expect(transformIndexHtml).toHaveBeenLastCalledWith("/issues", expect.stringContaining("v1"));
|
||||
|
||||
const sourcePath = path.join(tempDir, "src", "main.tsx");
|
||||
fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
|
||||
fs.writeFileSync(sourcePath, "export {};\n", "utf8");
|
||||
watcher.emit("change", sourcePath);
|
||||
expect(await renderer.render("/")).toBe(first);
|
||||
expect(brandHtml).toHaveBeenCalledTimes(1);
|
||||
|
||||
fs.writeFileSync(
|
||||
indexPath,
|
||||
|
|
@ -71,27 +82,33 @@ describe("createCachedViteHtmlRenderer", () => {
|
|||
watcher.emit("change", indexPath);
|
||||
|
||||
await expect(renderer.render("/")).resolves.toContain("v2");
|
||||
expect(brandHtml).toHaveBeenCalledTimes(2);
|
||||
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("does not duplicate the vite client tag or react refresh preamble when already present", async () => {
|
||||
it("runs Vite's HTML transform on every render so HMR entry timestamps stay current", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-"));
|
||||
tempDirs.push(tempDir);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, "index.html"),
|
||||
'<html><head><script type="module">import { injectIntoGlobalHook } from "/@react-refresh";injectIntoGlobalHook(window);window.$RefreshReg$ = () => {};window.$RefreshSig$ = () => (type) => type;</script></head><body><script type="module" src="/@vite/client"></script><script type="module" src="/src/main.tsx"></script></body></html>',
|
||||
'<script type="module" src="/src/main.tsx"></script>',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
let timestamp = 0;
|
||||
const transformIndexHtml = vi.fn(async (_url: string, html: string) =>
|
||||
html.replace("/src/main.tsx", `/src/main.tsx?t=${++timestamp}`),
|
||||
);
|
||||
const vite: ViteWatcherHost = {
|
||||
watcher: createWatcher(),
|
||||
transformIndexHtml,
|
||||
};
|
||||
|
||||
const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir });
|
||||
|
||||
const html = await renderer.render("/");
|
||||
expect(html.match(/\/@vite\/client/g)?.length).toBe(1);
|
||||
expect(html.match(/\/@react-refresh/g)?.length).toBe(1);
|
||||
await expect(renderer.render("/")).resolves.toContain("/src/main.tsx?t=1");
|
||||
await expect(renderer.render("/issues/ISS-1")).resolves.toContain("/src/main.tsx?t=2");
|
||||
expect(transformIndexHtml).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -774,9 +774,18 @@ export async function createApp(
|
|||
res.end("Upgrade Required");
|
||||
});
|
||||
const { createServer: createViteServer } = await import("vite");
|
||||
const configuredViteCacheDir = process.env.PAPERCLIP_VITE_CACHE_DIR?.trim();
|
||||
const vite = await createViteServer({
|
||||
root: uiRoot,
|
||||
...(configuredViteCacheDir
|
||||
? { cacheDir: path.resolve(configuredViteCacheDir) }
|
||||
: {}),
|
||||
appType: "custom",
|
||||
// Vite otherwise discovers every HTML entry below the UI root. Generated
|
||||
// Storybook output can reference dependencies that are intentionally not
|
||||
// part of the application install, poisoning a clean embedded dev-server
|
||||
// cache before the browser opens. The embedded UI has one real entry.
|
||||
optimizeDeps: { entries: [path.resolve(uiRoot, "index.html")] },
|
||||
server: {
|
||||
// Listener binding and browser HMR hostname are deliberately separate:
|
||||
// exposed branch runtimes stay loopback-only while the browser uses the
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import path from "node:path";
|
|||
type ViteWatcherEvent = "add" | "change" | "unlink";
|
||||
|
||||
export interface ViteWatcherHost {
|
||||
transformIndexHtml(url: string, html: string): Promise<string>;
|
||||
watcher?: {
|
||||
on?: (event: ViteWatcherEvent, listener: (file: string) => void) => unknown;
|
||||
off?: (event: ViteWatcherEvent, listener: (file: string) => void) => unknown;
|
||||
|
|
@ -16,28 +17,6 @@ export interface CachedViteHtmlRenderer {
|
|||
}
|
||||
|
||||
const WATCHER_EVENTS: ViteWatcherEvent[] = ["add", "change", "unlink"];
|
||||
const MAIN_ENTRY_TAG = '<script type="module" src="/src/main.tsx"></script>';
|
||||
const VITE_CLIENT_TAG = '<script type="module" src="/@vite/client"></script>';
|
||||
const REACT_REFRESH_PREAMBLE = `<script type="module">
|
||||
import { injectIntoGlobalHook } from "/@react-refresh";
|
||||
injectIntoGlobalHook(window);
|
||||
window.$RefreshReg$ = () => {};
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
</script>`;
|
||||
|
||||
function injectViteDevPreamble(html: string): string {
|
||||
let injectedHtml = html;
|
||||
if (!injectedHtml.includes('"/@react-refresh"') && !injectedHtml.includes("'/@react-refresh'")) {
|
||||
injectedHtml = injectedHtml.includes("</head>")
|
||||
? injectedHtml.replace("</head>", ` ${REACT_REFRESH_PREAMBLE}\n </head>`)
|
||||
: `${REACT_REFRESH_PREAMBLE}\n${injectedHtml}`;
|
||||
}
|
||||
if (injectedHtml.includes(VITE_CLIENT_TAG)) return injectedHtml;
|
||||
if (injectedHtml.includes(MAIN_ENTRY_TAG)) {
|
||||
return injectedHtml.replace(MAIN_ENTRY_TAG, `${VITE_CLIENT_TAG}\n ${MAIN_ENTRY_TAG}`);
|
||||
}
|
||||
return injectedHtml.replace("</body>", ` ${VITE_CLIENT_TAG}\n </body>`);
|
||||
}
|
||||
|
||||
export function createCachedViteHtmlRenderer(opts: {
|
||||
vite: ViteWatcherHost;
|
||||
|
|
@ -47,18 +26,18 @@ export function createCachedViteHtmlRenderer(opts: {
|
|||
const uiRoot = path.resolve(opts.uiRoot);
|
||||
const templatePath = path.resolve(uiRoot, "index.html");
|
||||
const brandHtml = opts.brandHtml ?? ((html: string) => html);
|
||||
let cachedHtml: string | null = null;
|
||||
let cachedTemplate: string | null = null;
|
||||
|
||||
function loadHtml(): string {
|
||||
if (cachedHtml === null) {
|
||||
function loadTemplate(): string {
|
||||
if (cachedTemplate === null) {
|
||||
const rawTemplate = fs.readFileSync(templatePath, "utf-8");
|
||||
cachedHtml = injectViteDevPreamble(brandHtml(rawTemplate));
|
||||
cachedTemplate = brandHtml(rawTemplate);
|
||||
}
|
||||
return cachedHtml;
|
||||
return cachedTemplate;
|
||||
}
|
||||
|
||||
function invalidate(): void {
|
||||
cachedHtml = null;
|
||||
cachedTemplate = null;
|
||||
}
|
||||
|
||||
function onWatchEvent(filePath: string): void {
|
||||
|
|
@ -73,8 +52,13 @@ export function createCachedViteHtmlRenderer(opts: {
|
|||
}
|
||||
|
||||
return {
|
||||
render(): Promise<string> {
|
||||
return Promise.resolve(loadHtml());
|
||||
render(url): Promise<string> {
|
||||
// Vite's transform does more than inject the dev client and React
|
||||
// refresh preamble. It also keeps entry-module timestamps aligned with
|
||||
// the module graph after an HMR invalidation. Serving the raw entry tag
|
||||
// can otherwise evaluate main.tsx twice (unversioned + timestamped),
|
||||
// creating two React roots in the same container.
|
||||
return opts.vite.transformIndexHtml(url, loadTemplate());
|
||||
},
|
||||
|
||||
dispose(): void {
|
||||
|
|
|
|||
|
|
@ -1024,14 +1024,25 @@ for (const execution of executions) {
|
|||
requestId: restartRequestId,
|
||||
deadlineAt,
|
||||
});
|
||||
await page.goto(
|
||||
`/${encodeURIComponent(issuePrefix)}/issues/${encodeURIComponent(issue.identifier ?? issue.id)}`,
|
||||
// A restarted Vite dev server may keep loading its fresh module
|
||||
// graph after the task UI is already usable. Bind navigation only
|
||||
// to the committed canonical route, then let the explicit UI and
|
||||
// API assertions below prove readiness and preserved state.
|
||||
{ waitUntil: "commit" },
|
||||
const documentSentinel = `__paperclip_runner_restart_${nonce.replaceAll("-", "_")}`;
|
||||
await page.evaluate(
|
||||
(key) => Reflect.set(window, key, true),
|
||||
documentSentinel,
|
||||
);
|
||||
try {
|
||||
await page.goto(
|
||||
`/${encodeURIComponent(issuePrefix)}/issues/${encodeURIComponent(issue.identifier ?? issue.id)}`,
|
||||
// The replacement Vite server can commit and render a fresh
|
||||
// document while its navigation lifecycle remains unsettled.
|
||||
// The sentinel and explicit assertions below prove the new
|
||||
// document and durable state even when Playwright times out.
|
||||
{ waitUntil: "commit" },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || error.name !== "TimeoutError") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await expect(
|
||||
page
|
||||
.getByRole("radio", {
|
||||
|
|
@ -1040,6 +1051,12 @@ for (const execution of executions) {
|
|||
})
|
||||
.last(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
expect(
|
||||
await page.evaluate(
|
||||
(key) => Reflect.get(window, key) === true,
|
||||
documentSentinel,
|
||||
),
|
||||
).toBe(false);
|
||||
const reloadedInteractions = await api.get<InteractionRecord[]>(
|
||||
`/api/issues/${issue.id}/interactions`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -57,7 +57,9 @@ async function addArtifact(input: {
|
|||
workflowAttempt: number;
|
||||
status: "passed" | "failed";
|
||||
sourceSha?: string;
|
||||
resultExecutionId?: string;
|
||||
campaignName?: string;
|
||||
flattened?: boolean;
|
||||
}) {
|
||||
const artifactName = `runner-e2e-${RUN_ID}-${input.workflowAttempt}-${input.executionId}`;
|
||||
const campaignName =
|
||||
|
|
@ -65,13 +67,16 @@ async function addArtifact(input: {
|
|||
`gha-${RUN_ID}-${input.workflowAttempt}-${input.executionId}`;
|
||||
const directory = path.join(
|
||||
input.root,
|
||||
artifactName,
|
||||
...(input.flattened ? [] : [artifactName]),
|
||||
campaignName,
|
||||
"results",
|
||||
"attempt-1",
|
||||
);
|
||||
await mkdir(directory, { recursive: true });
|
||||
const value = result(input.executionId, input.status);
|
||||
const value = result(
|
||||
input.resultExecutionId ?? input.executionId,
|
||||
input.status,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "result.json"),
|
||||
JSON.stringify({
|
||||
|
|
@ -142,7 +147,148 @@ function selectionInput(paths: Awaited<ReturnType<typeof fixture>>) {
|
|||
};
|
||||
}
|
||||
|
||||
function singletonSelectionInput(paths: Awaited<ReturnType<typeof fixture>>) {
|
||||
const input = selectionInput(paths);
|
||||
return {
|
||||
...input,
|
||||
jobs: {
|
||||
...input.jobs,
|
||||
jobs: input.jobs.jobs.filter((job) => job.name === RERUN),
|
||||
},
|
||||
expectedExecutionIds: [RERUN],
|
||||
};
|
||||
}
|
||||
|
||||
describe("runner E2E workflow rerun artifact selection", () => {
|
||||
it("accepts the v8 flattened layout for one expected artifact", async () => {
|
||||
const paths = await fixture();
|
||||
const latest = await addArtifact({
|
||||
root: paths.artifactRoot,
|
||||
executionId: RERUN,
|
||||
workflowAttempt: 2,
|
||||
status: "passed",
|
||||
flattened: true,
|
||||
});
|
||||
|
||||
const selected = await selectRerunArtifacts(singletonSelectionInput(paths));
|
||||
|
||||
expect(selected).toEqual([
|
||||
{
|
||||
executionId: RERUN,
|
||||
workflowAttempt: 2,
|
||||
artifactName: latest.artifactName,
|
||||
},
|
||||
]);
|
||||
const selectedResult = JSON.parse(
|
||||
await readFile(
|
||||
path.join(
|
||||
paths.selectedRoot,
|
||||
latest.artifactName,
|
||||
latest.campaignName,
|
||||
"results",
|
||||
"attempt-1",
|
||||
"result.json",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
expect(selectedResult.status).toBe("passed");
|
||||
});
|
||||
|
||||
it("does not let an older flattened campaign mask a latest missing artifact", async () => {
|
||||
const paths = await fixture();
|
||||
await addArtifact({
|
||||
root: paths.artifactRoot,
|
||||
executionId: RERUN,
|
||||
workflowAttempt: 1,
|
||||
status: "passed",
|
||||
flattened: true,
|
||||
});
|
||||
|
||||
const selected = await selectRerunArtifacts(singletonSelectionInput(paths));
|
||||
|
||||
expect(selected).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a flattened campaign when multiple artifacts are expected", async () => {
|
||||
const paths = await fixture();
|
||||
await addArtifact({
|
||||
root: paths.artifactRoot,
|
||||
executionId: RERUN,
|
||||
workflowAttempt: 2,
|
||||
status: "passed",
|
||||
flattened: true,
|
||||
});
|
||||
|
||||
await expect(selectRerunArtifacts(selectionInput(paths))).rejects.toThrow(
|
||||
/downloaded unexpected runner artifact/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a flattened campaign beside another root entry", async () => {
|
||||
const paths = await fixture();
|
||||
await addArtifact({
|
||||
root: paths.artifactRoot,
|
||||
executionId: RERUN,
|
||||
workflowAttempt: 2,
|
||||
status: "passed",
|
||||
flattened: true,
|
||||
});
|
||||
await writeFile(path.join(paths.artifactRoot, "unexpected.txt"), "no");
|
||||
|
||||
await expect(
|
||||
selectRerunArtifacts(singletonSelectionInput(paths)),
|
||||
).rejects.toThrow(/downloaded unexpected runner artifact/u);
|
||||
});
|
||||
|
||||
it("rejects an unrecognized flattened campaign", async () => {
|
||||
const paths = await fixture();
|
||||
await addArtifact({
|
||||
root: paths.artifactRoot,
|
||||
executionId: RERUN,
|
||||
workflowAttempt: 2,
|
||||
status: "passed",
|
||||
campaignName: `gha-another-run-2-${RERUN}`,
|
||||
flattened: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
selectRerunArtifacts(singletonSelectionInput(paths)),
|
||||
).rejects.toThrow(/downloaded unexpected runner artifact/u);
|
||||
});
|
||||
|
||||
it("applies source validation to a flattened campaign", async () => {
|
||||
const paths = await fixture();
|
||||
await addArtifact({
|
||||
root: paths.artifactRoot,
|
||||
executionId: RERUN,
|
||||
workflowAttempt: 2,
|
||||
status: "passed",
|
||||
sourceSha: "ffffffffffffffffffffffffffffffffffffffff",
|
||||
flattened: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
selectRerunArtifacts(singletonSelectionInput(paths)),
|
||||
).rejects.toThrow("contains result from another source");
|
||||
});
|
||||
|
||||
it("applies execution validation to a flattened campaign", async () => {
|
||||
const paths = await fixture();
|
||||
await addArtifact({
|
||||
root: paths.artifactRoot,
|
||||
executionId: RERUN,
|
||||
resultExecutionId: RETAINED,
|
||||
workflowAttempt: 2,
|
||||
status: "passed",
|
||||
flattened: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
selectRerunArtifacts(singletonSelectionInput(paths)),
|
||||
).rejects.toThrow(`contains result for ${RETAINED}`);
|
||||
});
|
||||
|
||||
it("combines retained successes with the latest rerun artifact", async () => {
|
||||
const paths = await fixture();
|
||||
await addArtifact({
|
||||
|
|
|
|||
|
|
@ -154,14 +154,27 @@ export async function selectRerunArtifacts(input: SelectRerunArtifactsInput) {
|
|||
string,
|
||||
{ executionId: string; workflowAttempt: number }
|
||||
>();
|
||||
const recognizedCampaignNames = new Map<
|
||||
string,
|
||||
Array<{
|
||||
artifactName: string;
|
||||
executionId: string;
|
||||
workflowAttempt: number;
|
||||
}>
|
||||
>();
|
||||
for (const executionId of expected) {
|
||||
for (const workflowAttempt of attemptsByExecution
|
||||
.get(executionId)!
|
||||
.keys()) {
|
||||
recognizedArtifactNames.set(
|
||||
`runner-e2e-${runId}-${workflowAttempt}-${executionId}`,
|
||||
{ executionId, workflowAttempt },
|
||||
);
|
||||
const artifactName = `runner-e2e-${runId}-${workflowAttempt}-${executionId}`;
|
||||
recognizedArtifactNames.set(artifactName, {
|
||||
executionId,
|
||||
workflowAttempt,
|
||||
});
|
||||
const campaignName = `gha-${runId}-${workflowAttempt}-${executionId}`;
|
||||
const identities = recognizedCampaignNames.get(campaignName) ?? [];
|
||||
identities.push({ artifactName, executionId, workflowAttempt });
|
||||
recognizedCampaignNames.set(campaignName, identities);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -171,16 +184,41 @@ export async function selectRerunArtifacts(input: SelectRerunArtifactsInput) {
|
|||
if (error.code === "ENOENT") return [];
|
||||
throw error;
|
||||
});
|
||||
const artifactDirectories = new Map<string, string>();
|
||||
for (const entry of artifactEntries) {
|
||||
const identity = recognizedArtifactNames.get(entry.name);
|
||||
if (!identity || !entry.isDirectory()) {
|
||||
throw new Error(`downloaded unexpected runner artifact ${entry.name}`);
|
||||
const artifactDirectories = new Map<
|
||||
string,
|
||||
| { layout: "wrapped"; directory: string }
|
||||
| { layout: "flattened"; directory: string; campaignName: string }
|
||||
>();
|
||||
const singletonEntry = artifactEntries[0];
|
||||
const singletonCampaignIdentities = singletonEntry
|
||||
? recognizedCampaignNames.get(singletonEntry.name)
|
||||
: undefined;
|
||||
// download-artifact v8 flattens a single pattern match into the requested
|
||||
// path. Accept that shape only when the expected set and campaign identity
|
||||
// make the missing artifact-name wrapper unambiguous.
|
||||
if (
|
||||
expected.length === 1 &&
|
||||
artifactEntries.length === 1 &&
|
||||
singletonEntry?.isDirectory() &&
|
||||
singletonCampaignIdentities?.length === 1
|
||||
) {
|
||||
const identity = singletonCampaignIdentities[0]!;
|
||||
artifactDirectories.set(identity.artifactName, {
|
||||
layout: "flattened",
|
||||
directory: path.join(input.artifactRoot, singletonEntry.name),
|
||||
campaignName: singletonEntry.name,
|
||||
});
|
||||
} else {
|
||||
for (const entry of artifactEntries) {
|
||||
const identity = recognizedArtifactNames.get(entry.name);
|
||||
if (!identity || !entry.isDirectory()) {
|
||||
throw new Error(`downloaded unexpected runner artifact ${entry.name}`);
|
||||
}
|
||||
artifactDirectories.set(entry.name, {
|
||||
layout: "wrapped",
|
||||
directory: path.join(input.artifactRoot, entry.name),
|
||||
});
|
||||
}
|
||||
artifactDirectories.set(
|
||||
entry.name,
|
||||
path.join(input.artifactRoot, entry.name),
|
||||
);
|
||||
}
|
||||
|
||||
const selections: Array<{
|
||||
|
|
@ -197,19 +235,29 @@ export async function selectRerunArtifacts(input: SelectRerunArtifactsInput) {
|
|||
if (!artifactDirectory) continue;
|
||||
|
||||
const campaignName = `gha-${runId}-${workflowAttempt}-${executionId}`;
|
||||
const topLevelEntries = await readdir(artifactDirectory, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
if (
|
||||
topLevelEntries.length !== 1 ||
|
||||
topLevelEntries[0]?.name !== campaignName ||
|
||||
!topLevelEntries[0].isDirectory()
|
||||
) {
|
||||
throw new Error(
|
||||
`${artifactName} must contain only its exact campaign ${campaignName}`,
|
||||
);
|
||||
let campaignDirectory: string;
|
||||
if (artifactDirectory.layout === "flattened") {
|
||||
if (artifactDirectory.campaignName !== campaignName) {
|
||||
throw new Error(
|
||||
`${artifactName} must contain only its exact campaign ${campaignName}`,
|
||||
);
|
||||
}
|
||||
campaignDirectory = artifactDirectory.directory;
|
||||
} else {
|
||||
const topLevelEntries = await readdir(artifactDirectory.directory, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
if (
|
||||
topLevelEntries.length !== 1 ||
|
||||
topLevelEntries[0]?.name !== campaignName ||
|
||||
!topLevelEntries[0].isDirectory()
|
||||
) {
|
||||
throw new Error(
|
||||
`${artifactName} must contain only its exact campaign ${campaignName}`,
|
||||
);
|
||||
}
|
||||
campaignDirectory = path.join(artifactDirectory.directory, campaignName);
|
||||
}
|
||||
const campaignDirectory = path.join(artifactDirectory, campaignName);
|
||||
const resultFiles = (await walk(campaignDirectory)).filter(
|
||||
(file) => path.basename(file) === "result.json",
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue