Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5945db4
H-6364: Add artifacts proxy rewrite for page images
lunelson Mar 23, 2026
2aac90f
H-6364: Add SSE proxy API route for ingest events
lunelson Mar 23, 2026
230fb07
H-6364: Align IngestRunView types with discovery contract
lunelson Mar 24, 2026
effc3af
H-6364: Continuous-scroll document viewer with scrollToPage
lunelson Mar 24, 2026
64dbc96
H-6364: Entity cards with assertion windows in results panel
lunelson Mar 24, 2026
112718f
H-6364: Two-panel inputs view with extraction mode toggle
lunelson Mar 24, 2026
9c5a327
H-6364: Code review fixes across ingest module
lunelson Mar 25, 2026
3172a57
H-6364: Fix temporal container healthcheck to not depend on namespace
lunelson Mar 25, 2026
715dd07
H-6364: Align proxy routes with ingest-runs API rename
lunelson Mar 25, 2026
db963c4
H-6364: Human-readable progress labels for SSE streaming
lunelson Mar 25, 2026
125e3f7
H-6364: Fix results panel β€” claims fallback, scroll containment, sele…
lunelson Mar 25, 2026
9185412
H-6364: Fix results layout β€” independent scroll containers
lunelson Mar 25, 2026
28850fa
H-6364: Move 'New Upload' button to fixed footer below scroll panels
lunelson Mar 25, 2026
f8de5d4
H-6364: Add evidence panel header with document stats
lunelson Mar 25, 2026
bf1e9ea
H-6364: Fix ingest review issues
lunelson Mar 25, 2026
8709ff3
H-6364: Tighten SSE proxy contract
lunelson Mar 25, 2026
e9aaaf6
feat: harden ingest recovery and claim grouping
lunelson Mar 27, 2026
535ae85
feat: persist ingest run id in url
lunelson Mar 27, 2026
e73de5e
feat: rehydrate ingest runs from url
lunelson Mar 27, 2026
84e99b1
feat: stabilize ingest progress and sidebar state
lunelson Mar 27, 2026
2539f74
feat: clean up stale ingest run urls
lunelson Mar 27, 2026
5b78edf
feat: stabilize ingest stream handling
lunelson Mar 27, 2026
9c3bd2f
feat: narrow ingest run state variants
lunelson Mar 27, 2026
899090d
feat: unify ingest page navigation policy
lunelson Mar 27, 2026
d8d3cbf
feat: add ingest page navigation regression tests
lunelson Mar 27, 2026
188fe8d
feat: read ingest streams without event whitelists
lunelson Mar 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions apps/hash-external-services/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,9 @@ services:
[
"CMD",
"temporal",
"workflow",
"list",
"--namespace",
"HASH",
"operator",
"cluster",
"health",
"--address",
"temporal:7233",
]
Expand Down
15 changes: 12 additions & 3 deletions apps/hash-frontend/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,24 @@ export default withSentryConfig(
// Ingest pipeline proxy β†’ Mastra API
{
source: "/api/ingest",
destination: `${mastraApiOrigin}/discovery-runs`,
destination: `${mastraApiOrigin}/ingest-runs`,
},
{
source: "/api/ingest/:path*",
destination: `${mastraApiOrigin}/discovery-runs/:path*`,
destination: `${mastraApiOrigin}/ingest-runs/:path*`,
},
{
source: "/api/ingest-fixtures/:path*",
destination: `${mastraApiOrigin}/discovery-fixtures/:path*`,
destination: `${mastraApiOrigin}/ingest-fixtures/:path*`,
},
{
source: "/api/ingest-artifacts/:path*",
destination: `${mastraApiOrigin}/artifacts/:path*`,
},
// Page images are referenced as /artifacts/... in discovery view data
{
source: "/artifacts/:path*",
destination: `${mastraApiOrigin}/artifacts/:path*`,
},
{
source: "/pages",
Expand Down
6 changes: 5 additions & 1 deletion apps/hash-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@
"build:docker": "docker buildx build --tag hash-frontend --file ../../infra/docker/frontend/prod/Dockerfile ../../ --load --build-arg FRONTEND_URL=\"$FRONTEND_URL\" --build-arg API_ORIGIN=\"$API_ORIGIN\"",
"codegen": "rimraf './src/**/*.gen.*'; graphql-codegen --config codegen.config.ts",
"dev": "next dev",
"dev:test": "vitest",
"fix:eslint": "eslint --fix .",
"fix:format": "biome format --write",
"lint:eslint": "eslint --report-unused-disable-directives .",
"lint:tsc": "tsc --noEmit",
"start": "next start",
"start:healthcheck": "wait-on --timeout 1200000 http://localhost:3000",
"start:test": "next start",
"start:test:healthcheck": "wait-on --timeout 600000 http://localhost:3000"
"start:test:healthcheck": "wait-on --timeout 600000 http://localhost:3000",
"test:unit": "vitest --run"
},
"dependencies": {
"@apollo/client": "3.10.5",
Expand Down Expand Up @@ -153,12 +155,14 @@
"@types/react-dom": "19.2.3",
"@types/react-window": "1.8.8",
"@types/url-regex-safe": "1.0.2",
"@vitest/coverage-istanbul": "3.2.4",
"@welldone-software/why-did-you-render": "10.0.1",
"eslint": "9.39.3",
"graphology-types": "0.24.8",
"rimraf": "6.1.3",
"sass": "1.93.2",
"typescript": "5.9.3",
"vitest": "3.2.4",
"wait-on": "9.0.1",
"webpack": "5.104.1"
}
Expand Down
227 changes: 227 additions & 0 deletions apps/hash-frontend/src/pages/api/ingest/[runId]/events.api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { EventEmitter } from "node:events";

import { afterEach, describe, expect, it, vi } from "vitest";

import handler from "./events.api";

const encoder = new TextEncoder();
const decodeChunks = (chunks: Uint8Array[]) =>
Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8");

class MockRequest extends EventEmitter {
public method = "GET";
public query: Record<string, string | string[] | undefined>;
public headers: Record<string, string | string[] | undefined> = {};

constructor(query: Record<string, string | string[] | undefined>) {
super();
this.query = query;
}
}

class MockResponse extends EventEmitter {
public headersSent = false;
public writableEnded = false;
public statusCode = 200;
public readonly headers: Record<string, string> = {};
public readonly writes: Uint8Array[] = [];
public jsonBody: unknown;

setHeader(name: string, value: string) {
this.headers[name] = value;
}

status(code: number) {
this.statusCode = code;
return this;
}

json(body: unknown) {
this.jsonBody = body;
this.headersSent = true;
this.writableEnded = true;
return this;
}

writeHead(statusCode: number, headers: Record<string, string>) {
this.statusCode = statusCode;
Object.assign(this.headers, headers);
this.headersSent = true;
}

flushHeaders() {
this.headersSent = true;
}

write(chunk: Uint8Array) {
this.writes.push(chunk);
return true;
}

end(chunk?: Uint8Array) {
if (chunk) {
this.write(chunk);
}

this.writableEnded = true;
this.emit("finish");
}
}

const flushAsync = () =>
new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});

describe("ingest events api route", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("streams live SSE chunks through to the client response", async () => {
let enqueueChunk: (chunk: string) => void = (_chunk: string) => {
throw new Error("Expected stream controller to be initialized");
};
let closeStream: () => void = () => {
throw new Error("Expected stream controller to be initialized");
};

vi.stubGlobal(
"fetch",
vi.fn(
() =>
new Response(
new ReadableStream<Uint8Array>({
start(streamController) {
enqueueChunk = (chunk) => {
streamController.enqueue(encoder.encode(chunk));
};
closeStream = () => {
streamController.close();
};
},
}),
{
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
},
),
),
);

const req = new MockRequest({ runId: "run-123" });
const res = new MockResponse();

const handlerPromise = handler(req as never, res as never);
await flushAsync();

enqueueChunk('event: phase-start\ndata: {"status":"running"}\n\n');
await flushAsync();

expect(decodeChunks(res.writes)).toContain("event: phase-start");
expect(res.writableEnded).toBe(false);

enqueueChunk('event: run-succeeded\ndata: {"status":"succeeded"}\n\n');
closeStream();

await handlerPromise;

expect(decodeChunks(res.writes)).toContain("event: run-succeeded");
expect(res.statusCode).toBe(200);
expect(res.headers["Content-Type"]).toContain("text/event-stream");
expect(res.writableEnded).toBe(true);
});

it("keeps the upstream stream alive through request close until upstream completes", async () => {
let enqueueChunk: (chunk: string) => void = (_chunk: string) => {
throw new Error("Expected stream controller to be initialized");
};
let closeStream: () => void = () => {
throw new Error("Expected stream controller to be initialized");
};
const cancelSpy = vi.fn();

vi.stubGlobal(
"fetch",
vi.fn(
() =>
new Response(
new ReadableStream<Uint8Array>({
start(streamController) {
enqueueChunk = (chunk) => {
streamController.enqueue(encoder.encode(chunk));
};
closeStream = () => {
streamController.close();
};
},
cancel(reason) {
cancelSpy(reason);
},
}),
{
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
},
),
),
);

const req = new MockRequest({ runId: "run-123" });
const res = new MockResponse();

const handlerPromise = handler(req as never, res as never);
await flushAsync();

req.emit("close");
await flushAsync();

enqueueChunk('event: run-succeeded\ndata: {"status":"succeeded"}\n\n');
closeStream();

await handlerPromise;

expect(cancelSpy).not.toHaveBeenCalled();
expect(decodeChunks(res.writes)).toContain("event: run-succeeded");
expect(res.writableEnded).toBe(true);
});

it("aborts the upstream stream when the client disconnects before completion", async () => {
let cancelResolve!: () => void;
const cancelPromise = new Promise<void>((resolve) => {
cancelResolve = resolve;
});

vi.stubGlobal(
"fetch",
vi.fn(
() =>
new Response(
new ReadableStream<Uint8Array>({
start() {},
cancel() {
cancelResolve();
},
}),
{
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
},
),
),
);

const req = new MockRequest({ runId: "run-123" });
const res = new MockResponse();

const handlerPromise = handler(req as never, res as never);
await flushAsync();

res.emit("close");

await cancelPromise;
await handlerPromise;

expect(res.writableEnded).toBe(true);
});
});
Loading
Loading