diff --git a/.github/actions/download-artifact-retry/action.yml b/.github/actions/download-artifact-retry/action.yml
new file mode 100644
index 0000000000..3c5741a194
--- /dev/null
+++ b/.github/actions/download-artifact-retry/action.yml
@@ -0,0 +1,102 @@
+name: Download artifact with retry
+description: Retry GitHub artifact downloads across transient service and network failures
+
+inputs:
+ name:
+ description: Artifact name to download
+ required: false
+ default: ""
+ path:
+ description: Destination directory for the artifact
+ required: true
+ pattern:
+ description: Optional artifact name pattern
+ required: false
+ default: ""
+ merge-multiple:
+ description: Merge matching artifacts into one directory
+ required: false
+ default: "false"
+ repository:
+ description: Optional repository containing the artifact
+ required: false
+ default: ""
+ run-id:
+ description: Optional workflow run containing the artifact
+ required: false
+ default: ""
+ github-token:
+ description: Optional token for cross-run or cross-repository downloads
+ required: false
+ default: ""
+
+runs:
+ using: composite
+ steps:
+ - name: Download artifact (attempt 1/3)
+ id: download_1
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: ${{ inputs.name }}
+ path: ${{ inputs.path }}
+ pattern: ${{ inputs.pattern }}
+ merge-multiple: ${{ inputs.merge-multiple }}
+ repository: ${{ inputs.repository }}
+ run-id: ${{ inputs.run-id }}
+ github-token: ${{ inputs.github-token }}
+
+ - name: Wait before artifact download retry (attempt 2/3)
+ if: steps.download_1.outcome == 'failure'
+ shell: bash
+ env:
+ DOWNLOAD_PATH: ${{ inputs.path }}
+ run: |
+ set -euo pipefail
+ rm -rf -- "$DOWNLOAD_PATH"
+ sleep 10
+
+ - name: Download artifact (attempt 2/3)
+ id: download_2
+ if: steps.download_1.outcome == 'failure'
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: ${{ inputs.name }}
+ path: ${{ inputs.path }}
+ pattern: ${{ inputs.pattern }}
+ merge-multiple: ${{ inputs.merge-multiple }}
+ repository: ${{ inputs.repository }}
+ run-id: ${{ inputs.run-id }}
+ github-token: ${{ inputs.github-token }}
+
+ - name: Wait before artifact download retry (attempt 3/3)
+ if: steps.download_2.outcome == 'failure'
+ shell: bash
+ env:
+ DOWNLOAD_PATH: ${{ inputs.path }}
+ run: |
+ set -euo pipefail
+ rm -rf -- "$DOWNLOAD_PATH"
+ sleep 30
+
+ - name: Download artifact (attempt 3/3)
+ id: download_3
+ if: steps.download_2.outcome == 'failure'
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: ${{ inputs.name }}
+ path: ${{ inputs.path }}
+ pattern: ${{ inputs.pattern }}
+ merge-multiple: ${{ inputs.merge-multiple }}
+ repository: ${{ inputs.repository }}
+ run-id: ${{ inputs.run-id }}
+ github-token: ${{ inputs.github-token }}
+
+ - name: Fail after artifact download retries
+ if: steps.download_3.outcome == 'failure'
+ shell: bash
+ run: |
+ echo "::error::Artifact download failed after 3 attempts."
+ exit 1
diff --git a/.github/scripts/e2e-tests-workflow-contract_test.py b/.github/scripts/e2e-tests-workflow-contract_test.py
index 381008520b..6b487c46c7 100644
--- a/.github/scripts/e2e-tests-workflow-contract_test.py
+++ b/.github/scripts/e2e-tests-workflow-contract_test.py
@@ -13,6 +13,9 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
DOCKERFILE = REPO_ROOT / ".github" / "docker" / "ci-base" / "Dockerfile"
IMAGE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "ci-base-image.yml"
E2E_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "e2e-tests.yml"
+DOWNLOAD_ARTIFACT_RETRY_ACTION = (
+ REPO_ROOT / ".github" / "actions" / "download-artifact-retry" / "action.yml"
+)
BACKEND_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "backend-tests.yml"
FRONTEND_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "frontend-tests.yml"
LINT_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.yml"
@@ -281,6 +284,38 @@ cat "${FAKE_DOCKER_MANIFEST}"
self.assertIn('artifact.name === "e2e-timing-profile"', workflow)
self.assertIn("!artifact.expired", workflow)
+ def test_e2e_artifact_downloads_retry_transient_service_failures(self) -> None:
+ workflow = E2E_WORKFLOW.read_text(encoding="utf-8")
+ self.assertTrue(DOWNLOAD_ARTIFACT_RETRY_ACTION.exists())
+ self.assertNotIn("uses: actions/download-artifact@", workflow)
+ self.assertGreaterEqual(
+ workflow.count("uses: ./.github/actions/download-artifact-retry"),
+ 12,
+ )
+
+ action = DOWNLOAD_ARTIFACT_RETRY_ACTION.read_text(encoding="utf-8")
+ self.assertEqual(action.count("uses: actions/download-artifact@"), 3)
+ self.assertEqual(action.count("continue-on-error: true"), 3)
+ self.assertIn('default: "false"', action)
+ for input_name in (
+ "name",
+ "path",
+ "pattern",
+ "merge-multiple",
+ "repository",
+ "run-id",
+ "github-token",
+ ):
+ self.assertEqual(
+ action.count(f"{input_name}: ${{{{ inputs.{input_name} }}}}"),
+ 3,
+ input_name,
+ )
+ self.assertIn("sleep 10", action)
+ self.assertIn("sleep 30", action)
+ self.assertIn('rm -rf -- "$DOWNLOAD_PATH"', action)
+ self.assertIn("exit 1", action)
+
# @covers AC-PLATFORM-E2E-DURATION-AWARE-SHARDING-002.1
# @covers AC-PLATFORM-E2E-DURATION-AWARE-SHARDING-002.2
def test_container_job_reuses_verified_browser_cache_with_fallback(self) -> None:
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index 464a93b853..d58f14550b 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -111,6 +111,7 @@ jobs:
.github/scripts/resolve-image-digest.sh
.github/workflows/ci-base-image.yml
.github/workflows/e2e-tests.yml
+ .github/actions/download-artifact-retry/**
.github/scripts/changed-paths.py
!**/*.md
EVENT_NAME: ${{ github.event_name }}
@@ -340,7 +341,7 @@ jobs:
- name: Download selected main timing profile
if: steps.main-timing-run.outputs.run_id != ''
continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
with:
name: e2e-timing-profile
path: e2e/previous-profile
@@ -465,7 +466,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Download backend build
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
with:
name: e2e-backend-build
path: apps/backend/bin/
@@ -474,13 +475,13 @@ jobs:
run: chmod +x apps/backend/bin/*
- name: Download E2E fixture plugin package
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
with:
name: e2e-plugin-package
path: apps/backend/.build/
- name: Download web build
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
with:
name: e2e-web-build
path: apps/web/
@@ -494,7 +495,7 @@ jobs:
run: echo "E2E_FAIL_ON_FLAKY=1" >> "$GITHUB_ENV"
- name: Download E2E shard manifests
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
with:
name: e2e-shard-manifests
path: e2e-manifests
@@ -811,7 +812,7 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
- name: Download backend build
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
with:
name: e2e-backend-build
path: apps/backend/bin/
@@ -820,13 +821,13 @@ jobs:
run: chmod +x apps/backend/bin/*
- name: Download E2E fixture plugin package
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
with:
name: e2e-plugin-package
path: apps/backend/.build/
- name: Download web build
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
with:
name: e2e-web-build
path: apps/web/
@@ -840,7 +841,7 @@ jobs:
run: echo "E2E_FAIL_ON_FLAKY=1" >> "$GITHUB_ENV"
- name: Download E2E shard manifests
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
with:
name: e2e-shard-manifests
path: e2e-manifests
@@ -1007,7 +1008,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Download backend build
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
with:
name: e2e-backend-build
path: apps/backend/bin/
@@ -1016,13 +1017,13 @@ jobs:
run: chmod +x apps/backend/bin/*
- name: Download E2E fixture plugin package
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
with:
name: e2e-plugin-package
path: apps/backend/.build/
- name: Download web build
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
with:
name: e2e-web-build
path: apps/web/
@@ -1213,21 +1214,21 @@ jobs:
fi
- name: Download blob reports
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
with:
path: apps/web/e2e/blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Download E2E shard manifests
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
continue-on-error: true
with:
name: e2e-shard-manifests
path: e2e-manifests
- name: Download selected main timing profile
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
continue-on-error: true
with:
name: e2e-selected-main-timing-profile
@@ -1456,7 +1457,7 @@ jobs:
- name: Download previous flake history
if: always() && steps.flake-history-run.outputs.run_id != ''
continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ uses: ./.github/actions/download-artifact-retry
with:
name: e2e-flake-history
path: e2e/previous-flake-history
diff --git a/apps/backend/internal/plugins/webapp/runtime.go b/apps/backend/internal/plugins/webapp/runtime.go
index aafd3fb0db..52d2c6f449 100644
--- a/apps/backend/internal/plugins/webapp/runtime.go
+++ b/apps/backend/internal/plugins/webapp/runtime.go
@@ -255,7 +255,7 @@ func runtimeProtocolPath(requestPath string) (string, bool) {
func setRuntimeHeaders(w http.ResponseWriter, policy, origin string) {
w.Header().Set("Content-Security-Policy", policy)
- w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Cache-Control", "no-store, no-transform")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
@@ -269,7 +269,7 @@ func setRuntimeHeaders(w http.ResponseWriter, policy, origin string) {
// responses. It is exported for the protocol adapter, while asset responses
// additionally receive the response CSP from setRuntimeHeaders.
func SetProtocolHeaders(w http.ResponseWriter, origin string) {
- w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Cache-Control", "no-store, no-transform")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
diff --git a/apps/backend/internal/plugins/webapp/runtime_test.go b/apps/backend/internal/plugins/webapp/runtime_test.go
index 17552c6629..ed59426242 100644
--- a/apps/backend/internal/plugins/webapp/runtime_test.go
+++ b/apps/backend/internal/plugins/webapp/runtime_test.go
@@ -63,7 +63,7 @@ func TestRuntimeServesEntryWithSecurityHeadersAndCookiesDoNotReplaceCapability(t
t.Fatalf("CSP = %q", got)
}
for key, want := range map[string]string{
- "Cache-Control": "no-store",
+ "Cache-Control": "no-store, no-transform",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Cross-Origin-Resource-Policy": "cross-origin",
@@ -78,6 +78,62 @@ func TestRuntimeServesEntryWithSecurityHeadersAndCookiesDoNotReplaceCapability(t
}
}
+// @covers AC-PLUGINS-ISOLATED-WEB-APPS-013.8
+func TestRuntimeNoTransformHeaders(t *testing.T) {
+ archive := canvasArchive(t, map[string]string{
+ "manifest.yaml": staticManifestYAML,
+ "ui/index.html": "
entry",
+ "ui/app.js": "window.__asset = true;",
+ })
+ pkg, err := ValidatePackage(bytes.NewReader(archive))
+ if err != nil {
+ t.Fatalf("ValidatePackage: %v", err)
+ }
+ artifacts, err := NewArtifactStore(filepath.Join(t.TempDir(), "artifacts"))
+ if err != nil {
+ t.Fatalf("NewArtifactStore: %v", err)
+ }
+ artifact, err := artifacts.Put(pkg)
+ if err != nil {
+ t.Fatalf("Put: %v", err)
+ }
+ manager := NewTokenManager(nil)
+ token, err := manager.Issue(CapabilityBinding{
+ UserID: "user-1", InstanceID: "instance-1", ReleaseID: "release-1", WebAppKey: "main",
+ Placement: "task-canvas", Artifact: artifact, Entry: "ui/index.html",
+ }, 0)
+ if err != nil {
+ t.Fatalf("Issue: %v", err)
+ }
+ runtime := NewRuntime(manager, artifacts, nil, nil)
+ runtime.SetProtocolHandler(func(w http.ResponseWriter, _ *http.Request, _ string, _ CapabilityBinding, _ string) {
+ SetProtocolHeaders(w, "")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"ok":true}`))
+ })
+
+ for _, test := range []struct {
+ name string
+ path string
+ }{
+ {name: "entry HTML", path: ""},
+ {name: "nested asset", path: "app.js"},
+ {name: "host bootstrap", path: "_kandev/host-runtime.js"},
+ {name: "protocol", path: "_kandev/v1/context"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ response := httptest.NewRecorder()
+ runtime.Serve(response, httptest.NewRequest(http.MethodGet, "/", nil), token, test.path)
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %q", response.Code, response.Body.String())
+ }
+ if got := response.Header().Get("Cache-Control"); got != "no-store, no-transform" {
+ t.Errorf("Cache-Control = %q, want %q", got, "no-store, no-transform")
+ }
+ })
+ }
+}
+
func TestRuntimeCookieDoesNotReplaceCapability(t *testing.T) {
runtime := NewRuntime(NewTokenManager(nil), nil, nil, nil)
request := httptest.NewRequest(http.MethodGet, "/", nil)
diff --git a/apps/web/components/settings/canvas-host-route.test.tsx b/apps/web/components/settings/canvas-host-route.test.tsx
index 0c95aa668c..2bf3035d53 100644
--- a/apps/web/components/settings/canvas-host-route.test.tsx
+++ b/apps/web/components/settings/canvas-host-route.test.tsx
@@ -99,6 +99,11 @@ vi.mock("@/lib/routing/client-router", () => ({
useRouter: () => ({ push: mockPush }),
}));
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (state: unknown) => unknown) =>
+ selector({ auth: { mode: "disabled", user: null } }),
+}));
+
import { CanvasHostRoute } from "./canvas-host-route";
const canvas: Canvas = {
diff --git a/apps/web/components/settings/canvas-host-route.tsx b/apps/web/components/settings/canvas-host-route.tsx
index b54f27acc6..82ffa57158 100644
--- a/apps/web/components/settings/canvas-host-route.tsx
+++ b/apps/web/components/settings/canvas-host-route.tsx
@@ -15,6 +15,11 @@ import {
} from "@/lib/api/domains/canvas-api";
import { canvasErrorMessage } from "@/lib/api/domains/canvas-error-copy";
import { useCanvasLifecycleRevision } from "@/lib/canvas-lifecycle";
+import { useAppStore } from "@/components/state-provider";
+import {
+ canvasPresentationUserId,
+ recordCanvasPresentation,
+} from "@/lib/canvas-presentation-storage";
import { useCanvasHostCanvases } from "./canvas-host-picker";
import { type CanvasHostState } from "./canvas-host-components";
import { CanvasHostRouteView } from "./canvas-host-route-view";
@@ -393,6 +398,7 @@ export function CanvasHostRoute({
const { t } = useTranslation();
const router = useRouter();
const { isMobile } = useResponsiveBreakpoint();
+ const presentationUserId = useAppStore((state) => canvasPresentationUserId(state.auth));
const {
canvas,
runtimeUrl,
@@ -410,6 +416,19 @@ export function CanvasHostRoute({
const [shareOpen, setShareOpen] = useState(false);
const [editing, setEditing] = useState(false);
+ useEffect(() => {
+ if (!canvas || canvas.scope_kind !== "task" || !canvas.task_id || !presentationUserId) return;
+ recordCanvasPresentation(
+ {
+ userId: presentationUserId,
+ workspaceId: canvas.workspace_id,
+ taskId: canvas.task_id,
+ canvasId: canvas.id,
+ },
+ "manual",
+ );
+ }, [canvas, presentationUserId]);
+
const edit = () =>
editCanvasFromHost({
canvas,
diff --git a/apps/web/components/task/dockview-add-panel-items.test.tsx b/apps/web/components/task/dockview-add-panel-items.test.tsx
index 0de4a55c26..db8d719872 100644
--- a/apps/web/components/task/dockview-add-panel-items.test.tsx
+++ b/apps/web/components/task/dockview-add-panel-items.test.tsx
@@ -32,6 +32,7 @@ const mockDockviewStore = vi.hoisted(() => ({
api: null as null | {
getPanel: (id: string) => { params?: Record } | undefined;
addPanel: typeof mockAddCanvasPanel;
+ groups: Array<{ id: string }>;
},
centerGroupId: "group-center",
addBrowserPanel: vi.fn(),
@@ -56,6 +57,7 @@ const mockNormalizedReviews = vi.hoisted(() => ({
const mockCanvasFeature = vi.hoisted(() => ({ enabled: true }));
const mockAppState = vi.hoisted(() => ({
+ auth: { mode: "disabled", authenticated: false, user: null },
workspaceId: "workspace-1",
workspaces: { activeId: "workspace-1" },
tasks: { activeSessionId: "session-1", activeTaskId: null },
@@ -195,6 +197,7 @@ function setOpenPanels(panels: Record>) {
mockDockviewStore.api = {
getPanel: (id) => (Object.hasOwn(panels, id) ? { params: panels[id] } : undefined),
addPanel: mockAddCanvasPanel,
+ groups: [{ id: CENTER_GROUP_ID }, { id: SECONDARY_GROUP_ID }],
};
}
@@ -238,6 +241,7 @@ beforeEach(() => {
mockDockviewStore.api = {
getPanel: () => undefined,
addPanel: mockAddCanvasPanel,
+ groups: [{ id: CENTER_GROUP_ID }, { id: SECONDARY_GROUP_ID }],
};
mockNormalizedReviews.reviews = [];
mockListTaskCanvases.mockReset();
diff --git a/apps/web/components/task/dockview-add-panel-items.tsx b/apps/web/components/task/dockview-add-panel-items.tsx
index afc47fd202..009f44a05f 100644
--- a/apps/web/components/task/dockview-add-panel-items.tsx
+++ b/apps/web/components/task/dockview-add-panel-items.tsx
@@ -34,7 +34,8 @@ import type { TaskMR } from "@/lib/types/gitlab";
import { useAppStore } from "@/components/state-provider";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { useTaskCanvases } from "@/hooks/domains/task/use-task-canvases";
-import type { Canvas } from "@/lib/api/domains/canvas-api";
+import { activateCanvasPanel, isDiscoverableTaskCanvas } from "./dockview-canvas-activation";
+import { canvasPresentationUserId } from "@/lib/canvas-presentation-storage";
import { mrTaskKey } from "@/components/gitlab/mr-detail-panel";
import { RepositoryScriptsMenuItems } from "./repository-scripts-menu";
import { SessionReopenMenuItems } from "./session-reopen-menu";
@@ -67,14 +68,6 @@ export const MENU_ICON_CLASS = "h-3.5 w-3.5 mr-1.5 shrink-0";
export const MENU_ITEM_CLASS = "cursor-pointer text-xs";
const PR_SUBMENU_TEST_ID = "add-panel-pr-submenu";
-// i18n-exempt: Dockview panel identity prefix, not user-facing copy.
-const CANVAS_PANEL_ID_PREFIX = "canvas:";
-const DISCOVERABLE_TASK_CANVAS_STATUSES = new Set(["active", "pending", "error"]);
-
-export function isDiscoverableTaskCanvas(canvas: Pick