Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,59 @@ jobs:

- name: Build frontend
run: npm run build

synthetic-browser-smoke:
name: Synthetic browser smoke
needs:
- backend
- frontend
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.11"

- name: Set up uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: "0.7.3"
enable-cache: true

- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
cache: npm
cache-dependency-path: client/package-lock.json

- name: Fetch pinned PyTorch Connectomics runtime
run: bash scripts/setup_pytorch_connectomics.sh

- name: Install Python dependencies
run: uv sync --frozen --python 3.11 --group dev

- name: Install frontend dependencies
working-directory: client
run: npm ci --fetch-retries=5 --fetch-retry-maxtimeout=120000

- name: Install Chromium
run: uv run playwright install --with-deps chromium

- name: Run deterministic synthetic browser smoke
run: bash scripts/run_browser_synthetic_core_smoke_ci.sh

- name: Upload browser smoke diagnostics
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: synthetic-browser-smoke-${{ github.run_attempt }}
path: .ci/synthetic-browser-smoke/
if-no-files-found: warn
retention-days: 14
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ uploads
pytorch_connectomics
server_api/chatbot/faiss_index/
.logs/
.pytc/

# Local deployment evidence (may contain transient viewer URLs)
demo-proofread-3d.png
Expand Down
51 changes: 51 additions & 0 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-virtual": "^3.13.12",
"antd": "^5.18.1",
"axios": "^1.7.2",
"buffer": "^6.0.3",
Expand Down
41 changes: 41 additions & 0 deletions client/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@
text-align: center;
}

.app-error-boundary {
align-items: center;
background: var(--seg-bg-canvas, #f7f4ed);
display: flex;
justify-content: center;
min-height: 100vh;
padding: 24px;
}

.app-error-boundary .ant-result {
max-width: 680px;
}

:root {
--seg-bg-canvas: #f7f4ed;
--seg-bg-panel: #fffdfa;
Expand Down Expand Up @@ -192,6 +205,34 @@
padding-right: 2px;
}

.pytc-top-nav,
.pytc-top-menu {
min-width: 0;
}

.pytc-top-nav {
max-width: 100vw;
overflow: hidden;
}

.pytc-top-nav-action {
flex: 0 0 auto;
}

@media (max-width: 720px) {
.pytc-top-menu .ant-menu-item {
padding-inline: 10px 14px !important;
}

.pytc-top-nav-action {
padding-inline: 8px;
}

.pytc-top-nav-action__label {
display: none;
}
}

.app-logo {
height: 40vmin;
pointer-events: none;
Expand Down
74 changes: 41 additions & 33 deletions client/src/api.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import axios from "axios";
import yaml from "js-yaml";
import { logClientEvent } from "./logging/appEventLog";
import { attachApiError, normalizeApiError } from "./errors/apiError";
import {
detectConfigDiagnostics,
summarizeConfigText,
Expand Down Expand Up @@ -117,6 +118,9 @@ const apiDebugLog = (...args) => {
export const apiClient = axios.create({
baseURL: BASE_URL,
withCredentials: true,
headers: {
Accept: "application/json, application/problem+json",
},
});

const summarizePayload = (payload) => {
Expand Down Expand Up @@ -190,6 +194,7 @@ const attachApiLogging = (instance, source) => {
return response;
},
(error) => {
attachApiError(error);
const config = error.config || {};
const startedAt = config.metadata?.startedAt;
const endedAt =
Expand All @@ -209,6 +214,8 @@ const attachApiLogging = (instance, source) => {
? Number((endedAt - startedAt).toFixed(2))
: null,
detail: error.response?.data?.detail || null,
errorCode: error.apiError?.code || null,
requestId: error.apiError?.requestId || null,
},
});
return Promise.reject(error);
Expand All @@ -232,33 +239,6 @@ const buildFilePath = (file) => {

const hasBrowserFile = (file) => file && file.originFileObj instanceof File;

const getErrorDetailMessage = (detail) => {
if (!detail) return "";
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
return detail.map(getErrorDetailMessage).filter(Boolean).join("; ");
}
if (typeof detail === "object") {
if (detail.user_message) {
return getErrorDetailMessage(detail.user_message);
}
const nestedUpstream =
detail.upstream_body !== undefined
? getErrorDetailMessage(detail.upstream_body)
: "";
return [
detail.message,
detail.detail,
detail.reason,
nestedUpstream,
detail.error,
]
.filter(Boolean)
.join(" | ");
}
return String(detail);
};

export async function getNeuroglancerViewer(
image,
label,
Expand Down Expand Up @@ -392,13 +372,11 @@ export async function checkFile(file) {

function handleError(error) {
if (error.response) {
const detail = error.response.data?.detail;
const detailMessage = getErrorDetailMessage(detail);
throw new Error(
`${error.response.status}: ${detailMessage || error.response.statusText}`,
);
const apiError = normalizeApiError(error);
error.apiError = apiError;
error.message = `${error.response.status}: ${apiError.message}`;
}
throw error;
throw attachApiError(error);
}

export async function makeApiRequest(url, method, data = null) {
Expand Down Expand Up @@ -973,6 +951,36 @@ export async function listWorkflowEvents(workflowId) {
}
}

export async function listWorkflowOperations(workflowId, { limit = 12 } = {}) {
try {
const res = await apiClient.get(
canonicalizeApiPath(`/api/workflows/${workflowId}/operations`),
{ params: { limit } },
);
return res.data;
} catch (error) {
handleError(error);
}
}

export async function cancelWorkflowOperation(
workflowId,
operationId,
reason = "",
) {
try {
const res = await apiClient.post(
canonicalizeApiPath(
`/api/workflows/${workflowId}/operations/${operationId}/cancel`,
),
reason ? { reason } : undefined,
);
return res.data;
} catch (error) {
handleError(error);
}
}

export async function getWorkflowHotspots(workflowId) {
try {
const res = await apiClient.get(
Expand Down
31 changes: 31 additions & 0 deletions client/src/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ describe("api canonicalization", () => {
expect(url).toBe("https://demo.example/api/files?parent=root");
});

it("requests structured Problem Details responses", () => {
const { axiosMock } = loadApiModule(BASE_WITH_API_PREFIX);

expect(axiosMock.create).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
Accept: "application/json, application/problem+json",
},
}),
);
});

it("canonicalizes training approval/action paths for base URLs with /api/workflows", () => {
const { api, apiClientMock } = loadApiModule(
"https://demo.example/api/workflows",
Expand All @@ -92,4 +104,23 @@ describe("api canonicalization", () => {
"/99/commands/321/run",
);
});

it("uses canonical durable operation list and cancellation paths", async () => {
const { api, apiClientMock } = loadApiModule(BASE_WITH_API_PREFIX);
apiClientMock.get.mockResolvedValue({ data: [] });
apiClientMock.post.mockResolvedValue({
data: { id: 8, status: "cancelled" },
});

await api.listWorkflowOperations(42, { limit: 6 });
expect(apiClientMock.get).toHaveBeenCalledWith("/workflows/42/operations", {
params: { limit: 6 },
});

await api.cancelWorkflowOperation(42, 8, "No longer needed");
expect(apiClientMock.post).toHaveBeenCalledWith(
"/workflows/42/operations/8/cancel",
{ reason: "No longer needed" },
);
});
});
Loading
Loading