From d324d4918e131656a4ada95c059c08c15519e0d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 21 Sep 2026 23:20:52 +0100 Subject: [PATCH] fix: restore canvas availability on task entry (#3846) --- .../download-artifact-retry/action.yml | 102 ++++ .../e2e-tests-workflow-contract_test.py | 35 ++ .github/workflows/e2e-tests.yml | 33 +- .../internal/plugins/webapp/runtime.go | 4 +- .../internal/plugins/webapp/runtime_test.go | 58 ++- .../settings/canvas-host-route.test.tsx | 5 + .../components/settings/canvas-host-route.tsx | 19 + .../task/dockview-add-panel-items.test.tsx | 4 + .../task/dockview-add-panel-items.tsx | 33 +- .../task/dockview-canvas-activation.test.tsx | 390 ++++++++++++++- .../task/dockview-canvas-activation.ts | 445 +++++++++++++++--- .../task/dockview-canvas-reconciliation.ts | 234 +++++++++ apps/web/components/task/task-layout.tsx | 16 +- .../web/components/task/task-page-content.tsx | 7 +- apps/web/components/task/task-page-inner.tsx | 8 +- .../canvas-authenticated-proxy-fixture.ts | 88 +++- .../canvas/canvas-authenticated-proxy.spec.ts | 41 +- .../mobile-canvas-authenticated-proxy.spec.ts | 2 +- .../tests/canvas/mobile-plugin-canvas.spec.ts | 44 ++ .../e2e/tests/canvas/plugin-canvas.spec.ts | 105 +++++ .../domains/task/use-task-canvases.test.ts | 125 ++++- .../hooks/domains/task/use-task-canvases.ts | 115 ++++- .../canvas-presentation-storage.tab.test.ts | 50 ++ .../lib/canvas-presentation-storage.test.ts | 57 +++ apps/web/lib/canvas-presentation-storage.ts | 184 ++++++++ .../canvas-runtime-entry-recovery/plan.md | 190 ++++++++ .../task-01-runtime-responses.md | 112 +++++ .../task-02-task-entry.md | 173 +++++++ docs/public/canvases.md | 12 + .../requirements/agent-authored-web-apps.md | 19 +- .../system-design/agent-authored-web-apps.md | 5 +- .../system-design/task-entry-presentation.md | 61 +++ .../isolated-web-app-contributions.md | 11 +- .../isolated-web-app-contributions.md | 7 +- .../runtime-response-preservation.md | 37 ++ 35 files changed, 2666 insertions(+), 165 deletions(-) create mode 100644 .github/actions/download-artifact-retry/action.yml create mode 100644 apps/web/components/task/dockview-canvas-reconciliation.ts create mode 100644 apps/web/lib/canvas-presentation-storage.tab.test.ts create mode 100644 apps/web/lib/canvas-presentation-storage.test.ts create mode 100644 apps/web/lib/canvas-presentation-storage.ts create mode 100644 docs/plans/canvas-runtime-entry-recovery/plan.md create mode 100644 docs/plans/canvas-runtime-entry-recovery/task-01-runtime-responses.md create mode 100644 docs/plans/canvas-runtime-entry-recovery/task-02-task-entry.md create mode 100644 docs/specs/canvases/system-design/task-entry-presentation.md create mode 100644 docs/specs/plugins/system-design/runtime-response-preservation.md 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): boolean { - return DISCOVERABLE_TASK_CANVAS_STATUSES.has(canvas.status); -} - type ReviewMenuIdentity = Pick & Partial>; @@ -255,6 +248,7 @@ function PluginTaskPanelMenuItems({ function TaskCanvasMenuItems({ groupId, taskId }: { groupId: string; taskId: string | null }) { const enabled = useFeature("canvases"); const workspaceId = useAppStore((state) => state.workspaces.activeId); + const userId = useAppStore((state) => canvasPresentationUserId(state.auth)); const canvases = useTaskCanvases(taskId, workspaceId, enabled).filter(isDiscoverableTaskCanvas); const api = useDockviewStore((s) => s.api); @@ -266,12 +260,21 @@ function TaskCanvasMenuItems({ groupId, taskId }: { groupId: string; taskId: str - api?.addPanel({ - id: `${CANVAS_PANEL_ID_PREFIX}${canvas.id}`, - component: "canvas", - title: canvas.title, - params: { canvasId: canvas.id }, - position: { referenceGroup: groupId }, + api && + activateCanvasPanel(api, canvas, groupId, { + focusExisting: true, + presentation: + userId && taskId + ? { + identity: { + userId, + workspaceId: canvas.workspace_id, + taskId, + canvasId: canvas.id, + }, + reason: "manual", + } + : undefined, }) } className={MENU_ITEM_CLASS} diff --git a/apps/web/components/task/dockview-canvas-activation.test.tsx b/apps/web/components/task/dockview-canvas-activation.test.tsx index 3d4db726b9..5b15428e57 100644 --- a/apps/web/components/task/dockview-canvas-activation.test.tsx +++ b/apps/web/components/task/dockview-canvas-activation.test.tsx @@ -1,21 +1,63 @@ -import { renderHook } from "@testing-library/react"; +import { cleanup, renderHook } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Canvas } from "@/lib/api/domains/canvas-api"; import type { CanvasLifecycleHint } from "@/lib/canvas-lifecycle"; -const { mockGetCanvas, mockRouterPush, mockGetHints, mockState, mockValues } = vi.hoisted(() => ({ - mockGetCanvas: vi.fn(), - mockRouterPush: vi.fn(), - mockGetHints: vi.fn(), - mockState: { current: { api: null as unknown } }, - mockValues: { revision: 1, featureEnabled: true }, -})); +const { + mockGetCanvas, + mockListTaskCanvases, + mockRouterPush, + mockRouter, + mockGetHints, + mockState, + mockValues, + mockAppState, + mockDockviewStore, + mockAppStoreApi, +} = vi.hoisted(() => { + const mockRouterPush = vi.fn(); + const mockState = { + current: { + api: null as unknown, + isRestoringLayout: false, + currentLayoutEnvId: null as string | null, + }, + }; + const mockAppState = { + auth: { mode: "disabled", user: null }, + connection: { status: "connected" }, + environmentIdBySessionId: { "session-1": "env-1", "session-2": "env-2" }, + taskSessionsByTask: { + itemsByTaskId: {} as Record>, + }, + taskSessions: { + items: {} as Record, + }, + }; + const mockDockviewStore = Object.assign( + (selector: (state: typeof mockState.current) => unknown) => selector(mockState.current), + { getState: () => mockState.current }, + ); + const mockAppStoreApi = { getState: () => mockAppState }; + return { + mockGetCanvas: vi.fn(), + mockListTaskCanvases: vi.fn().mockResolvedValue({ canvases: [] }), + mockRouterPush, + mockRouter: { push: mockRouterPush }, + mockGetHints: vi.fn(), + mockState, + mockValues: { revision: 1, featureEnabled: true }, + mockAppState, + mockDockviewStore, + mockAppStoreApi, + }; +}); vi.mock("@/lib/api/domains/canvas-api", async () => { const actual = await vi.importActual( "@/lib/api/domains/canvas-api", ); - return { ...actual, getCanvas: mockGetCanvas }; + return { ...actual, getCanvas: mockGetCanvas, listTaskCanvases: mockListTaskCanvases }; }); vi.mock("@/hooks/domains/features/use-feature", () => ({ useFeature: () => mockValues.featureEnabled, @@ -30,18 +72,25 @@ vi.mock("@/lib/canvas-lifecycle", async () => { }; }); vi.mock("@/lib/routing/client-router", () => ({ - useRouter: () => ({ push: mockRouterPush }), + useRouter: () => mockRouter, })); vi.mock("@/lib/state/dockview-store", () => ({ - useDockviewStore: (selector: (state: { api: unknown }) => unknown) => selector(mockState.current), + useDockviewStore: mockDockviewStore, +})); +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState), + useAppStoreApi: () => mockAppStoreApi, })); import { activateCanvasPanel, canvasLifecycleActivationDecision, + isTaskCanvasPresentationEligible, + reconcileTaskCanvasPanels, shouldActivateCanvasForTask, useTaskCanvasLifecycleActivation, } from "./dockview-canvas-activation"; +import { wasCanvasPresented } from "@/lib/canvas-presentation-storage"; const TASK_ID = "task-1"; const WORKSPACE_ID = "workspace-1"; @@ -73,6 +122,15 @@ function hint(overrides: Partial = {}): CanvasLifecycleHint }; } +function presentedIdentity(canvasId: string, taskId = TASK_ID) { + return { + userId: "anonymous", + workspaceId: WORKSPACE_ID, + taskId, + canvasId, + }; +} + function lifecycleApi() { const addPanel = vi.fn(); const api = { @@ -81,19 +139,209 @@ function lifecycleApi() { groups: [{ id: "center" }], panels: [], }; + mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-1" }; return { api, addPanel }; } afterEach(() => { + cleanup(); mockGetCanvas.mockReset(); + mockListTaskCanvases.mockReset().mockResolvedValue({ canvases: [] }); mockRouterPush.mockReset(); mockGetHints.mockReset(); + mockGetHints.mockReturnValue([]); mockValues.revision += 1; mockValues.featureEnabled = true; - mockState.current = { api: null }; + mockState.current = { api: null, isRestoringLayout: false, currentLayoutEnvId: null }; + mockAppState.environmentIdBySessionId = { "session-1": "env-1", "session-2": "env-2" }; + mockAppState.taskSessionsByTask.itemsByTaskId = {}; + mockAppState.taskSessions.items = {}; + mockAppState.connection.status = "connected"; + window.sessionStorage.clear(); vi.useRealTimers(); }); +describe("task-entry inventory filtering", () => { + it("opens a published inventory canvas when lifecycle hints are empty", async () => { + const { addPanel } = lifecycleApi(); + mockGetHints.mockReturnValue([]); + mockListTaskCanvases.mockResolvedValue({ + canvases: [ + canvas({ + created_at: "2026-09-21T10:00:00Z", + active_release_id: "release-1", + active_release_status: "valid", + }), + ], + }); + + renderHook(() => + useTaskCanvasLifecycleActivation({ + taskId: TASK_ID, + workspaceId: WORKSPACE_ID, + sessionId: "session-1", + isMobile: false, + }), + ); + + await vi.waitFor(() => expect(addPanel).toHaveBeenCalledTimes(1)); + expect(addPanel).toHaveBeenCalledWith( + expect.objectContaining({ + id: "canvas:canvas-1", + component: "canvas", + params: { canvasId: "canvas-1" }, + position: { referenceGroup: "center" }, + }), + ); + }); + + it("filters inventory to eligible canvases in the current task", async () => { + const { addPanel } = lifecycleApi(); + const eligible = canvas({ + id: "canvas-eligible", + active_release_id: "release-eligible", + active_release_status: "valid", + }); + const permissionReview = canvas({ + id: "canvas-permission", + status: "pending", + pending_release: { id: "release-pending", validation_status: "pending_permission" }, + }); + const wrongTask = canvas({ + id: "canvas-other-task", + task_id: "task-other", + active_release_id: "release-other", + active_release_status: "valid", + }); + const archived = canvas({ + id: "canvas-archived", + status: "archived", + active_release_id: "release-archived", + active_release_status: "valid", + }); + mockListTaskCanvases.mockResolvedValue({ + canvases: [eligible, permissionReview, wrongTask, archived], + }); + + renderHook(() => + useTaskCanvasLifecycleActivation({ + taskId: TASK_ID, + workspaceId: WORKSPACE_ID, + sessionId: "session-1", + isMobile: false, + }), + ); + + await vi.waitFor(() => expect(addPanel).toHaveBeenCalledTimes(2)); + expect(addPanel.mock.calls.map(([options]) => options.id)).toEqual([ + "canvas:canvas-eligible", + "canvas:canvas-permission", + ]); + expect(isTaskCanvasPresentationEligible(wrongTask, TASK_ID, WORKSPACE_ID)).toBe(false); + }); +}); + +describe("task-entry layout ownership", () => { + it("uses a hydrated task session environment when no session is selected", async () => { + const { addPanel } = lifecycleApi(); + const canvasWithoutSelectedSession = canvas({ + active_release_id: "release-no-session", + active_release_status: "valid", + }); + mockState.current.currentLayoutEnvId = "env-3"; + mockAppState.taskSessionsByTask.itemsByTaskId[TASK_ID] = [ + { id: "session-3", task_environment_id: "env-3" }, + ]; + + renderHook(() => + useTaskCanvasLifecycleActivation({ + taskId: TASK_ID, + workspaceId: WORKSPACE_ID, + sessionId: null, + isMobile: false, + taskCanvases: [canvasWithoutSelectedSession], + taskCanvasesStatus: "success", + }), + ); + + await vi.waitFor(() => expect(addPanel).toHaveBeenCalledTimes(1)); + }); + + it("waits for the target task environment before recording a presentation", async () => { + const { api, addPanel } = lifecycleApi(); + const taskB = "task-2"; + const canvasB = canvas({ + id: "canvas-b", + task_id: taskB, + active_release_id: "release-b", + active_release_status: "valid", + }); + const props = { + taskId: taskB, + workspaceId: WORKSPACE_ID, + sessionId: "session-2", + isMobile: false, + taskCanvases: [canvasB], + taskCanvasesStatus: "success" as const, + }; + + const hook = renderHook(() => useTaskCanvasLifecycleActivation(props)); + await Promise.resolve(); + expect(addPanel).not.toHaveBeenCalled(); + expect(wasCanvasPresented(presentedIdentity(canvasB.id, taskB))).toBe(false); + + mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-2" }; + hook.rerender(); + + await vi.waitFor(() => expect(addPanel).toHaveBeenCalledTimes(1)); + expect(wasCanvasPresented(presentedIdentity(canvasB.id, taskB))).toBe(true); + }); + + it("rechecks layout ownership after an authoritative hint lookup", async () => { + const { api, addPanel } = lifecycleApi(); + const taskB = "task-2"; + const canvasB = canvas({ + id: "canvas-b", + task_id: taskB, + active_release_id: "release-b", + active_release_status: "valid", + }); + mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-2" }; + mockGetHints.mockReturnValue([ + hint({ + revision: 70, + payload: { canvas_id: canvasB.id, task_id: taskB, workspace_id: WORKSPACE_ID }, + }), + ]); + let resolveCanvas!: (value: Canvas) => void; + mockGetCanvas.mockReturnValue( + new Promise((resolve) => { + resolveCanvas = resolve; + }), + ); + + const props = { + taskId: taskB, + workspaceId: WORKSPACE_ID, + sessionId: "session-2", + isMobile: false, + taskCanvases: [] as Canvas[], + taskCanvasesStatus: "success" as const, + }; + const hook = renderHook(() => useTaskCanvasLifecycleActivation(props)); + await vi.waitFor(() => expect(mockGetCanvas).toHaveBeenCalledTimes(1)); + + mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-1" }; + hook.rerender(); + resolveCanvas(canvasB); + await Promise.resolve(); + await Promise.resolve(); + + expect(addPanel).not.toHaveBeenCalled(); + expect(wasCanvasPresented(presentedIdentity(canvasB.id, taskB))).toBe(false); + }); +}); + describe("canvas lifecycle matching", () => { it("matches only a release event for the active task and workspace", () => { expect(shouldActivateCanvasForTask(hint(), TASK_ID, WORKSPACE_ID)).toBe(true); @@ -153,6 +401,66 @@ describe("canvas lifecycle panels", () => { expect(addPanel).not.toHaveBeenCalled(); expect(setActive).not.toHaveBeenCalled(); }); + + it("adds a batch in creation order and focuses only the final panel", () => { + const addedIds = new Set(); + const addPanel = vi.fn((options: { id: string }) => addedIds.add(options.id)); + const api = { + addPanel, + getPanel: vi.fn((id: string) => + addedIds.has(id) ? { api: { setActive: vi.fn() } } : undefined, + ), + groups: [{ id: "center" }], + panels: [], + }; + const older = canvas({ + id: "canvas-older", + created_at: "2026-09-21T10:00:00Z", + active_release_id: "release-older", + active_release_status: "valid", + }); + const newer = canvas({ + id: "canvas-newer", + created_at: "2026-09-21T11:00:00Z", + active_release_id: "release-newer", + active_release_status: "valid", + }); + + reconcileTaskCanvasPanels(api as never, [newer, older], { + userId: "anonymous", + workspaceId: WORKSPACE_ID, + taskId: TASK_ID, + }); + + expect(addPanel).toHaveBeenCalledTimes(2); + expect(addPanel.mock.calls.map(([options]) => options.id)).toEqual([ + "canvas:canvas-older", + "canvas:canvas-newer", + ]); + expect(addPanel.mock.calls[0][0]).toMatchObject({ inactive: true }); + expect(addPanel.mock.calls[1][0]).not.toHaveProperty("inactive"); + expect(wasCanvasPresented(presentedIdentity(older.id))).toBe(true); + expect(wasCanvasPresented(presentedIdentity(newer.id))).toBe(true); + }); + + it("records a restored receipt for a canvas already open in dockview", () => { + const addPanel = vi.fn(); + const api = { + addPanel, + getPanel: vi.fn().mockReturnValue({ api: { setActive: vi.fn() } }), + groups: [{ id: "center" }], + panels: [], + }; + + reconcileTaskCanvasPanels(api as never, [canvas({ id: "canvas-restored" })], { + userId: "anonymous", + workspaceId: WORKSPACE_ID, + taskId: TASK_ID, + }); + + expect(addPanel).not.toHaveBeenCalled(); + expect(wasCanvasPresented(presentedIdentity("canvas-restored"))).toBe(true); + }); }); describe("canvas lifecycle decisions", () => { @@ -194,7 +502,7 @@ describe("canvas lifecycle decisions", () => { describe("canvas lifecycle asynchronous activation", () => { it("does not replay an archived or rejected canvas from a retained hint", async () => { const { api, addPanel } = lifecycleApi(); - mockState.current = { api }; + mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-1" }; mockGetHints.mockReturnValue([ hint({ revision: 20 }), hint({ @@ -223,6 +531,7 @@ describe("canvas lifecycle asynchronous activation", () => { useTaskCanvasLifecycleActivation({ taskId: TASK_ID, workspaceId: WORKSPACE_ID, + sessionId: "session-1", isMobile: false, }), ); @@ -233,20 +542,20 @@ describe("canvas lifecycle asynchronous activation", () => { it("does not add a deferred result after the active task changes", async () => { const { api, addPanel } = lifecycleApi(); - mockState.current = { api }; + mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-1" }; mockGetHints.mockReturnValue([hint({ revision: 30 })]); let resolveCanvas!: (value: Canvas) => void; - mockGetCanvas.mockReturnValue( - new Promise((resolve) => { - resolveCanvas = resolve; - }), - ); + const deferredCanvas = new Promise((resolve) => { + resolveCanvas = resolve; + }); + mockGetCanvas.mockReturnValue(deferredCanvas); const hook = renderHook( ({ taskId }: { taskId: string }) => useTaskCanvasLifecycleActivation({ taskId, workspaceId: WORKSPACE_ID, + sessionId: "session-1", isMobile: false, }), { initialProps: { taskId: TASK_ID } }, @@ -257,6 +566,36 @@ describe("canvas lifecycle asynchronous activation", () => { await vi.waitFor(() => expect(mockGetCanvas).toHaveBeenCalledTimes(1)); expect(addPanel).not.toHaveBeenCalled(); }); + + it("retries a hint lookup when a newer generation invalidates the pending one", async () => { + const { api, addPanel } = lifecycleApi(); + mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-1" }; + mockGetHints.mockReturnValue([hint({ revision: 31 })]); + let resolveFirst!: (value: Canvas) => void, resolveSecond!: (value: Canvas) => void; + const first = new Promise((resolve) => (resolveFirst = resolve)); + const second = new Promise((resolve) => (resolveSecond = resolve)); + mockGetCanvas.mockReturnValueOnce(first).mockReturnValueOnce(second); + + const hook = renderHook(() => + useTaskCanvasLifecycleActivation({ + taskId: TASK_ID, + workspaceId: WORKSPACE_ID, + sessionId: "session-1", + isMobile: false, + taskCanvases: [], + taskCanvasesStatus: "success", + }), + ); + await vi.waitFor(() => expect(mockGetCanvas).toHaveBeenCalledTimes(1)); + + mockValues.revision += 1; + hook.rerender(); + await vi.waitFor(() => expect(mockGetCanvas).toHaveBeenCalledTimes(2)); + + resolveSecond(canvas({ active_release_id: "release-1", active_release_status: "valid" })); + await vi.waitFor(() => expect(addPanel).toHaveBeenCalledTimes(1)); + resolveFirst(canvas({ active_release_id: "release-1", active_release_status: "valid" })); + }); }); describe("canvas lifecycle mobile and retry activation", () => { @@ -270,18 +609,24 @@ describe("canvas lifecycle mobile and retry activation", () => { useTaskCanvasLifecycleActivation({ taskId: TASK_ID, workspaceId: WORKSPACE_ID, + sessionId: "session-1", isMobile: true, }), ); expect(mockRouterPush).not.toHaveBeenCalled(); - await vi.waitFor(() => expect(mockRouterPush).toHaveBeenCalledWith("/canvases/canvas-1")); + await vi.waitFor(() => + expect(mockRouterPush).toHaveBeenCalledWith( + "/canvases/canvas-1", + expect.objectContaining({ onNavigated: expect.any(Function) }), + ), + ); }); it("retries a transient authoritative lookup", async () => { vi.useFakeTimers(); const { api, addPanel } = lifecycleApi(); - mockState.current = { api }; + mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-1" }; mockGetHints.mockReturnValue([hint({ revision: 50 })]); mockGetCanvas .mockRejectedValueOnce(new Error("temporary transport failure")) @@ -293,6 +638,7 @@ describe("canvas lifecycle mobile and retry activation", () => { useTaskCanvasLifecycleActivation({ taskId: TASK_ID, workspaceId: WORKSPACE_ID, + sessionId: "session-1", isMobile: false, }), ); @@ -302,6 +648,6 @@ describe("canvas lifecycle mobile and retry activation", () => { vi.advanceTimersByTime(500); await vi.waitFor(() => expect(mockGetCanvas).toHaveBeenCalledTimes(2)); - expect(addPanel).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(addPanel).toHaveBeenCalledTimes(1)); }); }); diff --git a/apps/web/components/task/dockview-canvas-activation.ts b/apps/web/components/task/dockview-canvas-activation.ts index 555c9632e9..0d9d983daf 100644 --- a/apps/web/components/task/dockview-canvas-activation.ts +++ b/apps/web/components/task/dockview-canvas-activation.ts @@ -1,17 +1,39 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import type { DockviewApi } from "dockview-react"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useFeature } from "@/hooks/domains/features/use-feature"; -import { canvasHref, getCanvas, type Canvas } from "@/lib/api/domains/canvas-api"; +import type { Canvas } from "@/lib/api/domains/canvas-api"; import { getCanvasLifecycleHints, type CanvasLifecycleHint, useCanvasLifecycleRevision, } from "@/lib/canvas-lifecycle"; -import { useRouter } from "@/lib/routing/client-router"; +import { + canvasPresentationUserId, + recordCanvasPresentation, + type CanvasPresentationIdentity, + type CanvasPresentationReason, + wasCanvasPresented, +} from "@/lib/canvas-presentation-storage"; +import { useRouter, type AppRouter } from "@/lib/routing/client-router"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { fallbackGroupPosition, focusOrAddPanel } from "@/lib/state/dockview-layout-builders"; +import { parseStrictRfc3339Timestamp } from "@/lib/utils/strict-timestamp"; +import { + useTaskCanvasesState, + type TaskCanvasesLoadStatus, +} from "@/hooks/domains/task/use-task-canvases"; +import type { AppState } from "@/lib/state/store"; +import { + readCanvasInventory, + reconcileTaskCanvasCandidates, + type CanvasInventoryRef, + type DockviewLayoutSnapshot, + type ProvidedCanvasInventoryRef, + type ReconcileRef, +} from "./dockview-canvas-reconciliation"; const CANVAS_PANEL_ID_PREFIX = "canvas:"; const CANVAS_ACTIVATION_RETRY_DELAY_MS = 500; @@ -20,6 +42,11 @@ const CANVAS_RELEASE_ACTIONS = new Set([ "canvas.release.activated", "canvas.release.permission_required", ]); +const DISCOVERABLE_TASK_CANVAS_STATUSES = new Set(["active", "pending", "error"]); + +export function isDiscoverableTaskCanvas(canvas: Pick): boolean { + return DISCOVERABLE_TASK_CANVAS_STATUSES.has(canvas.status); +} export function shouldActivateCanvasForTask( hint: CanvasLifecycleHint, @@ -42,6 +69,82 @@ function canvasPanelGroupId(api: DockviewApi): string | undefined { return fallbackGroupPosition(api)?.referenceGroup; } +function readDockviewLayout(): DockviewLayoutSnapshot { + const state = useDockviewStore.getState(); + return { + api: state.api, + isRestoringLayout: state.isRestoringLayout, + currentLayoutEnvId: state.currentLayoutEnvId, + }; +} + +function layoutOwnsTask(layout: DockviewLayoutSnapshot, taskEnvironmentId: string | null): boolean { + return ( + layout.api !== null && + !layout.isRestoringLayout && + taskEnvironmentId !== null && + layout.currentLayoutEnvId === taskEnvironmentId + ); +} + +export function isTaskCanvasPresentationEligible( + canvas: Canvas, + taskId: string, + workspaceId: string, +): boolean { + if ( + !isDiscoverableTaskCanvas(canvas) || + canvas.scope_kind !== "task" || + canvas.task_id !== taskId || + canvas.workspace_id !== workspaceId + ) { + return false; + } + if ( + canvas.status === "active" && + canvas.active_release_id && + canvas.active_release_status === "valid" + ) { + return true; + } + return ( + (canvas.status === "pending" || canvas.status === "active") && + (canvas.pending_release?.validation_status === "pending_permission" || + (!canvas.pending_release && canvas.active_release_status === "pending_permission")) + ); +} + +function compareCanvasCreation(a: Canvas, b: Canvas): number { + const aCreated = parseStrictRfc3339Timestamp(a.created_at); + const bCreated = parseStrictRfc3339Timestamp(b.created_at); + if (aCreated !== null && bCreated !== null && aCreated !== bCreated) { + return aCreated < bCreated ? -1 : 1; + } + if (aCreated !== null && bCreated === null) return -1; + if (aCreated === null && bCreated !== null) return 1; + return a.id.localeCompare(b.id); +} + +export function sortTaskCanvasPresentationCandidates(canvases: readonly Canvas[]): Canvas[] { + return [...canvases].sort(compareCanvasCreation); +} + +function canvasIdentity( + identity: Omit, + canvasId: string, +): CanvasPresentationIdentity { + return { ...identity, canvasId }; +} + +export type CanvasPanelPresentationOptions = { + focus?: boolean; + focusExisting?: boolean; + presentation?: { + identity: CanvasPresentationIdentity; + reason: CanvasPresentationReason; + }; +}; + type CanvasLifecycleActivationDecision = "eligible" | "retry" | "stale"; function canvasMatchesLifecycleHint(hint: CanvasLifecycleHint, canvas: Canvas): boolean { @@ -111,20 +214,86 @@ export function activateCanvasPanel( api: DockviewApi, canvas: Pick, groupId?: string, + options: CanvasPanelPresentationOptions = {}, ): boolean { const id = canvasPanelId(canvas.id); - if (api.getPanel(id)) return false; + const existing = api.getPanel(id); + if (existing) { + if (options.focusExisting) existing.api.setActive(); + if (options.presentation) { + recordCanvasPresentation(options.presentation.identity, options.presentation.reason); + } + return false; + } - focusOrAddPanel(api, { - id, - component: "canvas", - title: canvas.title, - params: { canvasId: canvas.id }, - ...(groupId ? { position: { referenceGroup: groupId } } : {}), - }); + focusOrAddPanel( + api, + { + id, + component: "canvas", + title: canvas.title, + params: { canvasId: canvas.id }, + ...(groupId ? { position: { referenceGroup: groupId } } : {}), + }, + options.focus === false, + ); + if (options.presentation) { + recordCanvasPresentation(options.presentation.identity, options.presentation.reason); + } return true; } +export function reconcileTaskCanvasPanels( + api: DockviewApi, + canvases: readonly Canvas[], + identity: Omit, +): Canvas[] { + const candidates = sortTaskCanvasPresentationCandidates(canvases); + const groupId = canvasPanelGroupId(api); + const newCanvases = candidates.filter((canvas) => !api.getPanel(canvasPanelId(canvas.id))); + const added: Canvas[] = []; + newCanvases.forEach((canvas, index) => { + try { + if ( + activateCanvasPanel(api, canvas, groupId, { + focus: index === newCanvases.length - 1, + presentation: { + identity: canvasIdentity(identity, canvas.id), + reason: "automatic", + }, + }) + ) { + added.push(canvas); + } + } catch { + // A panel that fails to insert has not been presented and remains eligible. + } + }); + const addedIds = new Set(added.map((canvas) => canvas.id)); + candidates.forEach((canvas) => { + if (!addedIds.has(canvas.id) && api.getPanel(canvasPanelId(canvas.id))) { + recordCanvasPresentation(canvasIdentity(identity, canvas.id), "restored"); + } + }); + return added; +} + +export type MobileCanvasPresentationDecision = { + canvas: Canvas; + offered: Canvas[]; +}; + +export function selectMobileCanvasPresentation( + canvases: readonly Canvas[], + identity: Omit, +): MobileCanvasPresentationDecision | null { + const unseen = sortTaskCanvasPresentationCandidates(canvases).filter( + (canvas) => !wasCanvasPresented(canvasIdentity(identity, canvas.id)), + ); + const canvas = unseen.at(-1); + return canvas ? { canvas, offered: unseen } : null; +} + const handledHints = new Set(); const MAX_HANDLED_HINTS = 128; @@ -149,84 +318,218 @@ function markHandled(hint: CanvasLifecycleHint, taskId: string): void { type CanvasLifecycleActivationProps = { taskId: string | null; workspaceId: string | null; + sessionId?: string | null; isMobile: boolean; + userId?: string | null; + taskCanvases?: readonly Canvas[]; + taskCanvasesStatus?: TaskCanvasesLoadStatus; }; +type CanvasActivationEffectOptions = { + enabled: boolean; + taskId: string | null; + workspaceId: string | null; + sessionId?: string | null; + taskEnvironmentId: string | null; + isMobile: boolean; + userId?: string | null; + defaultUserId: string | null; + layoutApi: DockviewApi | null; + layoutRestoring: boolean; + layoutEnvId: string | null; + revision: number; + router: AppRouter; + inventoryRef: CanvasInventoryRef; + providedInventoryRef: ProvidedCanvasInventoryRef; + readTaskEnvironmentId: () => string | null; + inFlightRef: { current: Set }; + generationRef: { current: number }; + mobileNavigationRef: { current: string | null }; + reconcileRef: ReconcileRef; +}; + +function useCanvasActivationEffect(options: CanvasActivationEffectOptions): void { + useEffect(() => { + const { taskId, workspaceId } = options; + if (!options.enabled || !taskId || !workspaceId) return; + const presentationUserId = + options.userId === undefined ? options.defaultUserId : options.userId; + if (!presentationUserId) return; + + const generation = ++options.generationRef.current; + let disposed = false; + const retryTimers = new Set>(); + const isCurrent = () => !disposed && options.generationRef.current === generation; + const identity = { userId: presentationUserId, workspaceId, taskId }; + const retryReconcile: ReconcileRef = { current: null }; + const scheduleRetry = (hint: CanvasLifecycleHint, attempt: number) => { + if (!isCurrent() || attempt >= MAX_CANVAS_ACTIVATION_RETRIES) return; + const timer = setTimeout(() => { + retryTimers.delete(timer); + retryReconcile.current?.([hint], attempt + 1); + }, CANVAS_ACTIVATION_RETRY_DELAY_MS); + retryTimers.add(timer); + }; + const runReconcile = (hints: CanvasLifecycleHint[], attempt: number) => { + void reconcileTaskCanvasCandidates( + { + hints, + attempt, + generation, + taskId, + workspaceId, + identity, + readTaskEnvironmentId: options.readTaskEnvironmentId, + readDockviewLayout, + isMobile: options.isMobile, + router: options.router, + mobileNavigationRef: options.mobileNavigationRef, + inventoryRef: options.inventoryRef, + providedInventoryRef: options.providedInventoryRef, + inFlight: options.inFlightRef.current, + isCurrent, + scheduleRetry, + }, + { + hintKey, + isHandled, + markHandled, + layoutOwnsTask, + isTaskCanvasPresentationEligible, + sortTaskCanvasPresentationCandidates, + canvasIdentity, + canvasLifecycleActivationDecision, + selectMobileCanvasPresentation, + reconcileTaskCanvasPanels, + }, + ); + }; + retryReconcile.current = runReconcile; + options.reconcileRef.current = runReconcile; + runReconcile( + getCanvasLifecycleHints().filter((hint) => + shouldActivateCanvasForTask(hint, taskId, workspaceId), + ), + 0, + ); + + return () => { + disposed = true; + options.generationRef.current += 1; + if (options.reconcileRef.current === runReconcile) options.reconcileRef.current = null; + retryTimers.forEach((timer) => clearTimeout(timer)); + retryTimers.clear(); + }; + }, [ + options.defaultUserId, + options.enabled, + options.isMobile, + options.layoutApi, + options.layoutEnvId, + options.layoutRestoring, + options.readTaskEnvironmentId, + options.revision, + options.router, + options.sessionId, + options.taskEnvironmentId, + options.taskId, + options.userId, + options.workspaceId, + ]); +} + +function resolveTaskEnvironmentId( + state: Pick, + taskId: string | null, + sessionId: string | null | undefined, +): string | null { + if (sessionId) { + const mapped = state.environmentIdBySessionId[sessionId]; + if (mapped) return mapped; + const sessionEnvironment = state.taskSessions.items[sessionId]?.task_environment_id; + if (sessionEnvironment) return sessionEnvironment; + } + + const sessions = taskId ? (state.taskSessionsByTask.itemsByTaskId[taskId] ?? []) : []; + const candidates = sessionId ? sessions.filter((session) => session.id === sessionId) : sessions; + return candidates.find((session) => session.task_environment_id)?.task_environment_id ?? null; +} + /** React to retained or newly-arrived release events for the active task. * Metadata remains authoritative, so the event only identifies which canvas * to refetch and open. */ export function useTaskCanvasLifecycleActivation({ taskId, workspaceId, + sessionId, isMobile, + userId, + taskCanvases, + taskCanvasesStatus, }: CanvasLifecycleActivationProps): void { const enabled = useFeature("canvases"); const revision = useCanvasLifecycleRevision(); + const inventory = useTaskCanvasesState( + taskId, + workspaceId, + enabled && taskCanvases === undefined, + ); const api = useDockviewStore((state) => state.api); + const isRestoringLayout = useDockviewStore((state) => state.isRestoringLayout); + const currentLayoutEnvId = useDockviewStore((state) => state.currentLayoutEnvId); + const defaultUserId = useAppStore((state) => canvasPresentationUserId(state.auth)); + const appStoreApi = useAppStoreApi(); + const taskEnvironmentId = useAppStore((state) => + resolveTaskEnvironmentId(state, taskId, sessionId), + ); + const readTaskEnvironmentId = useCallback( + () => resolveTaskEnvironmentId(appStoreApi.getState(), taskId, sessionId), + [appStoreApi, sessionId, taskId], + ); const router = useRouter(); const inFlightRef = useRef(new Set()); const generationRef = useRef(0); + const mobileNavigationRef = useRef(null); + const inventoryRef = useRef(inventory); + inventoryRef.current = inventory; + const providedInventoryRef = useRef<{ + canvases?: readonly Canvas[]; + status?: TaskCanvasesLoadStatus; + }>({ canvases: taskCanvases, status: taskCanvasesStatus }); + providedInventoryRef.current = { canvases: taskCanvases, status: taskCanvasesStatus }; + const reconcileRef = useRef(null); + + useCanvasActivationEffect({ + enabled, + taskId, + workspaceId, + sessionId, + taskEnvironmentId, + isMobile, + userId, + defaultUserId, + layoutApi: api, + layoutRestoring: isRestoringLayout, + layoutEnvId: currentLayoutEnvId, + revision, + router, + inventoryRef, + providedInventoryRef, + readTaskEnvironmentId, + inFlightRef, + generationRef, + mobileNavigationRef, + reconcileRef, + }); useEffect(() => { - if (!enabled || !taskId || !workspaceId) return; - - const generation = ++generationRef.current; - let disposed = false; - const retryTimers = new Set>(); - const isCurrent = () => !disposed && generationRef.current === generation; - - const matchingHints = getCanvasLifecycleHints().filter((hint) => - shouldActivateCanvasForTask(hint, taskId, workspaceId), + const snapshot = readCanvasInventory(inventoryRef, providedInventoryRef); + if (snapshot.status !== "success") return; + reconcileRef.current?.( + getCanvasLifecycleHints().filter((hint) => + taskId && workspaceId ? shouldActivateCanvasForTask(hint, taskId, workspaceId) : false, + ), + 0, ); - - const scheduleRetry = (hint: CanvasLifecycleHint, attempt: number) => { - if (!isCurrent() || attempt >= MAX_CANVAS_ACTIVATION_RETRIES) return; - const timer = setTimeout(() => { - retryTimers.delete(timer); - void fetchAndActivate(hint, attempt + 1); - }, CANVAS_ACTIVATION_RETRY_DELAY_MS); - retryTimers.add(timer); - }; - - const fetchAndActivate = async (hint: CanvasLifecycleHint, attempt: number) => { - const key = hintKey(hint, taskId); - if (isHandled(hint, taskId) || inFlightRef.current.has(key)) return; - if (!isMobile && !api) return; - - inFlightRef.current.add(key); - try { - const canvas = await getCanvas(hint.payload.canvas_id); - if (!isCurrent()) return; - - const decision = canvasLifecycleActivationDecision(hint, canvas); - if (decision === "retry") { - scheduleRetry(hint, attempt); - return; - } - markHandled(hint, taskId); - if (decision !== "eligible") return; - - if (isMobile) { - router.push(canvasHref(canvas.id)); - return; - } - const panelId = canvasPanelId(canvas.id); - if (api?.getPanel(panelId)) return; - if (api) activateCanvasPanel(api, canvas, canvasPanelGroupId(api)); - } catch { - if (isCurrent()) scheduleRetry(hint, attempt); - } finally { - inFlightRef.current.delete(key); - } - }; - - for (const hint of matchingHints) void fetchAndActivate(hint, 0); - - return () => { - disposed = true; - generationRef.current += 1; - retryTimers.forEach((timer) => clearTimeout(timer)); - retryTimers.clear(); - }; - }, [api, enabled, isMobile, revision, router, taskId, workspaceId]); + }, [inventory.canvases, inventory.status, taskCanvases, taskCanvasesStatus, taskId, workspaceId]); } diff --git a/apps/web/components/task/dockview-canvas-reconciliation.ts b/apps/web/components/task/dockview-canvas-reconciliation.ts new file mode 100644 index 0000000000..3716bbca61 --- /dev/null +++ b/apps/web/components/task/dockview-canvas-reconciliation.ts @@ -0,0 +1,234 @@ +import type { DockviewApi } from "dockview-react"; +import { canvasHref, getCanvas, type Canvas } from "@/lib/api/domains/canvas-api"; +import type { CanvasLifecycleHint } from "@/lib/canvas-lifecycle"; +import { + recordCanvasPresentation, + type CanvasPresentationIdentity, + wasCanvasPresented, +} from "@/lib/canvas-presentation-storage"; +import { type AppRouter } from "@/lib/routing/client-router"; +import type { + TaskCanvasesLoadStatus, + TaskCanvasesState, +} from "@/hooks/domains/task/use-task-canvases"; + +export type DockviewLayoutSnapshot = { + api: DockviewApi | null; + isRestoringLayout: boolean; + currentLayoutEnvId: string | null; +}; + +export type CanvasInventoryRef = { current: TaskCanvasesState }; +export type ProvidedCanvasInventoryRef = { + current: { canvases?: readonly Canvas[]; status?: TaskCanvasesLoadStatus }; +}; +export type ReconcileRef = { + current: ((hints: CanvasLifecycleHint[], attempt: number) => void) | null; +}; + +type CanvasInventorySnapshot = { + canvases: readonly Canvas[]; + status: TaskCanvasesLoadStatus; +}; + +export function readCanvasInventory( + inventoryRef: CanvasInventoryRef, + providedRef: ProvidedCanvasInventoryRef, +): CanvasInventorySnapshot { + const provided = providedRef.current; + if (provided.canvases !== undefined) { + return { canvases: provided.canvases, status: provided.status ?? "success" }; + } + return inventoryRef.current; +} + +type ResolveHintOptions = { + hint: CanvasLifecycleHint; + attempt: number; + generation: number; + taskId: string; + workspaceId: string; + inventoryRef: CanvasInventoryRef; + providedInventoryRef: ProvidedCanvasInventoryRef; + inFlight: Set; + isCurrent: () => boolean; + hintKey: (hint: CanvasLifecycleHint, taskId: string) => string; + canvasLifecycleActivationDecision: ( + hint: CanvasLifecycleHint, + canvas: Canvas, + ) => "eligible" | "retry" | "stale"; + markHandled: (hint: CanvasLifecycleHint, taskId: string) => void; + isHandled: (hint: CanvasLifecycleHint, taskId: string) => boolean; + scheduleRetry: (hint: CanvasLifecycleHint, attempt: number) => void; +}; + +async function resolveTaskCanvasHint(options: ResolveHintOptions): Promise { + const { hint, taskId, workspaceId } = options; + const key = `${options.generation}\u0000${options.hintKey(hint, taskId)}`; + if (options.isHandled(hint, taskId) || options.inFlight.has(key)) return null; + + options.inFlight.add(key); + try { + const snapshot = readCanvasInventory(options.inventoryRef, options.providedInventoryRef); + const listed = + snapshot.status === "success" + ? snapshot.canvases.find((canvas) => canvas.id === hint.payload.canvas_id) + : undefined; + const canvas = listed ?? (await getCanvas(hint.payload.canvas_id)); + if (!options.isCurrent()) return null; + if (canvas.workspace_id !== workspaceId) { + options.markHandled(hint, taskId); + return null; + } + + const decision = options.canvasLifecycleActivationDecision(hint, canvas); + if (decision === "retry") { + options.scheduleRetry(hint, options.attempt); + return null; + } + options.markHandled(hint, taskId); + return decision === "eligible" ? canvas : null; + } catch { + if (options.isCurrent()) options.scheduleRetry(hint, options.attempt); + return null; + } finally { + options.inFlight.delete(key); + } +} + +export type ReconcileTaskCanvasOptions = { + hints: CanvasLifecycleHint[]; + attempt: number; + generation: number; + taskId: string; + workspaceId: string; + identity: Omit; + readTaskEnvironmentId: () => string | null; + readDockviewLayout: () => DockviewLayoutSnapshot; + isMobile: boolean; + router: AppRouter; + mobileNavigationRef: { current: string | null }; + inventoryRef: CanvasInventoryRef; + providedInventoryRef: ProvidedCanvasInventoryRef; + inFlight: Set; + isCurrent: () => boolean; + scheduleRetry: (hint: CanvasLifecycleHint, attempt: number) => void; +}; + +export type ReconcileTaskCanvasDependencies = { + hintKey: (hint: CanvasLifecycleHint, taskId: string) => string; + isHandled: (hint: CanvasLifecycleHint, taskId: string) => boolean; + markHandled: (hint: CanvasLifecycleHint, taskId: string) => void; + layoutOwnsTask: (layout: DockviewLayoutSnapshot, taskEnvironmentId: string | null) => boolean; + isTaskCanvasPresentationEligible: ( + canvas: Canvas, + taskId: string, + workspaceId: string, + ) => boolean; + sortTaskCanvasPresentationCandidates: (canvases: readonly Canvas[]) => Canvas[]; + canvasIdentity: ( + identity: Omit, + canvasId: string, + ) => CanvasPresentationIdentity; + canvasLifecycleActivationDecision: ( + hint: CanvasLifecycleHint, + canvas: Canvas, + ) => "eligible" | "retry" | "stale"; + selectMobileCanvasPresentation: ( + canvases: readonly Canvas[], + identity: Omit, + ) => { canvas: Canvas; offered: Canvas[] } | null; + reconcileTaskCanvasPanels: ( + api: DockviewApi, + canvases: readonly Canvas[], + identity: Omit, + ) => Canvas[]; +}; + +export async function reconcileTaskCanvasCandidates( + options: ReconcileTaskCanvasOptions, + dependencies: ReconcileTaskCanvasDependencies, +): Promise { + if (!options.isCurrent()) return; + if ( + !options.isMobile && + !dependencies.layoutOwnsTask(options.readDockviewLayout(), options.readTaskEnvironmentId()) + ) { + return; + } + + const snapshot = readCanvasInventory(options.inventoryRef, options.providedInventoryRef); + const listedCandidates = + snapshot.status === "success" + ? snapshot.canvases.filter((canvas) => + dependencies.isTaskCanvasPresentationEligible( + canvas, + options.taskId, + options.workspaceId, + ), + ) + : []; + const hintedCandidates = ( + await Promise.all( + options.hints.map((hint) => + resolveTaskCanvasHint({ + ...options, + hint, + hintKey: dependencies.hintKey, + isHandled: dependencies.isHandled, + markHandled: dependencies.markHandled, + canvasLifecycleActivationDecision: dependencies.canvasLifecycleActivationDecision, + }), + ), + ) + ).filter((canvas): canvas is Canvas => canvas !== null); + if (!options.isCurrent()) return; + + const currentLayout = options.readDockviewLayout(); + const currentTaskEnvironmentId = options.readTaskEnvironmentId(); + if (!options.isMobile && !dependencies.layoutOwnsTask(currentLayout, currentTaskEnvironmentId)) { + return; + } + + const uniqueCanvases = new Map(); + [...listedCandidates, ...hintedCandidates].forEach((canvas) => { + if ( + dependencies.isTaskCanvasPresentationEligible(canvas, options.taskId, options.workspaceId) + ) { + uniqueCanvases.set(canvas.id, canvas); + } + }); + const candidates = dependencies + .sortTaskCanvasPresentationCandidates([...uniqueCanvases.values()]) + .filter( + (canvas) => !wasCanvasPresented(dependencies.canvasIdentity(options.identity, canvas.id)), + ); + if (candidates.length === 0) return; + + if (options.isMobile) { + const decision = dependencies.selectMobileCanvasPresentation(candidates, options.identity); + if (!decision || options.mobileNavigationRef.current) return; + options.mobileNavigationRef.current = decision.canvas.id; + try { + options.router.push(canvasHref(decision.canvas.id), { + onNavigated: () => { + if (!options.isCurrent()) return; + decision.offered.forEach((canvas) => + recordCanvasPresentation( + dependencies.canvasIdentity(options.identity, canvas.id), + "automatic", + ), + ); + options.mobileNavigationRef.current = null; + }, + }); + } catch { + options.mobileNavigationRef.current = null; + } + return; + } + + if (currentLayout.api) { + dependencies.reconcileTaskCanvasPanels(currentLayout.api, candidates, options.identity); + } +} diff --git a/apps/web/components/task/task-layout.tsx b/apps/web/components/task/task-layout.tsx index 7a8e7234cb..670883afcd 100644 --- a/apps/web/components/task/task-layout.tsx +++ b/apps/web/components/task/task-layout.tsx @@ -14,6 +14,7 @@ import type { Layout } from "react-resizable-panels"; import { useTaskCanvasLifecycleActivation } from "./dockview-canvas-activation"; import { statusSummaryTaskError } from "@/lib/task-status-summary"; import { useTaskLaunchErrorContext } from "./task-launch-error-context"; +import type { TaskCanvasesLoadStatus } from "@/hooks/domains/task/use-task-canvases"; // Re-export for backwards compatibility export type { SelectedDiff } from "@/hooks/use-session-layout-state"; @@ -49,6 +50,7 @@ type TaskLayoutProps = { isArchived?: boolean; onTaskUnarchived?: (taskId: string) => void; taskCanvases?: Canvas[]; + taskCanvasesStatus?: TaskCanvasesLoadStatus; }; export const TaskLayout = memo(function TaskLayout(props: TaskLayoutProps) { @@ -91,12 +93,20 @@ const ResponsiveTaskLayout = memo(function ResponsiveTaskLayout({ initialLayout, isArchived, onTaskUnarchived, - taskCanvases = [], + taskCanvases, + taskCanvasesStatus, }: TaskLayoutProps) { const { isMobile, usesDesktopWorkbench, isFullDesktop } = useResponsiveBreakpoint(); const launchErrorContext = useTaskLaunchErrorContext(); const hasSharedTaskError = Boolean(statusSummaryTaskError(launchErrorContext?.statusSummary)); - useTaskCanvasLifecycleActivation({ taskId, workspaceId, isMobile }); + useTaskCanvasLifecycleActivation({ + taskId, + workspaceId, + sessionId, + isMobile, + taskCanvases, + taskCanvasesStatus, + }); const router = useRouter(); const onOpenCanvas = useCallback( (canvasId: string) => router.push(canvasHref(canvasId)), @@ -122,7 +132,7 @@ const ResponsiveTaskLayout = memo(function ResponsiveTaskLayout({ remoteStatusError={remoteStatusError} isArchived={isArchived} onTaskUnarchived={onTaskUnarchived} - taskCanvases={taskCanvases} + taskCanvases={taskCanvases ?? []} onOpenCanvas={onOpenCanvas} hasSharedTaskError={hasSharedTaskError} /> diff --git a/apps/web/components/task/task-page-content.tsx b/apps/web/components/task/task-page-content.tsx index 841029a947..1ac80ad644 100644 --- a/apps/web/components/task/task-page-content.tsx +++ b/apps/web/components/task/task-page-content.tsx @@ -21,7 +21,7 @@ import { linkToTaskOverview } from "@/lib/links"; import { useTasks } from "@/hooks/use-tasks"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useFeature } from "@/hooks/domains/features/use-feature"; -import { useTaskCanvasesForTask } from "@/hooks/domains/task/use-task-canvases"; +import { useTaskCanvasesStateForTask } from "@/hooks/domains/task/use-task-canvases"; import { useForegroundRefresh } from "@/hooks/use-foreground-refresh"; import type { Layout } from "react-resizable-panels"; import { @@ -360,7 +360,7 @@ function TaskPageContentLive({ onTaskUnarchived, refreshTask, } = useTaskPageData(initialTask, initialTaskId, sessionId, initialRepositories); - const taskCanvases = useTaskCanvasesForTask(task, isMobile, canvasesEnabled); + const taskCanvasesState = useTaskCanvasesStateForTask(task, canvasesEnabled); useExternalVcsFileLinkHydration(task, repositories); const workflowSteps = useWorkflowStepsMapped(); @@ -414,7 +414,8 @@ function TaskPageContentLive({ officeTaskHref={officeTaskHref} ensureSession={ensureSession} onTaskUnarchived={onTaskUnarchived} - taskCanvases={taskCanvases} + taskCanvases={taskCanvasesState.canvases} + taskCanvasesStatus={taskCanvasesState.status} /> ); } diff --git a/apps/web/components/task/task-page-inner.tsx b/apps/web/components/task/task-page-inner.tsx index 79ef14ec78..e94c8241a9 100644 --- a/apps/web/components/task/task-page-inner.tsx +++ b/apps/web/components/task/task-page-inner.tsx @@ -47,6 +47,7 @@ import type { } from "./task-page-content"; import { useTranslation } from "react-i18next"; import type { Canvas } from "@/lib/api/domains/canvas-api"; +import type { TaskCanvasesLoadStatus } from "@/hooks/domains/task/use-task-canvases"; export type TaskPageInnerProps = { task: Task | null; @@ -70,6 +71,7 @@ export type TaskPageInnerProps = { ensureSession: UseEnsureTaskSessionResult; onTaskUnarchived: (taskId: string) => void; taskCanvases?: Canvas[]; + taskCanvasesStatus?: TaskCanvasesLoadStatus; }; type RemoteExecutorStatus = { @@ -168,6 +170,7 @@ function buildTaskLayoutProps(params: { initialLayout?: string | null; onTaskUnarchived: (taskId: string) => void; taskCanvases?: Canvas[]; + taskCanvasesStatus?: TaskCanvasesLoadStatus; }) { const { taskProps, repository, effectiveSessionId, initialScripts, initialTerminals } = params; return { @@ -180,7 +183,8 @@ function buildTaskLayoutProps(params: { initialTerminals, defaultLayouts: params.defaultLayouts, initialLayout: params.initialLayout, - taskCanvases: params.taskCanvases ?? [], + taskCanvases: params.taskCanvases, + taskCanvasesStatus: params.taskCanvasesStatus, taskTitle: taskProps.taskTitle, repositoryLabel: taskProps.repositoryLabel, baseBranch: taskProps.baseBranch, @@ -307,6 +311,7 @@ function useTaskPageDerivedProps({ officeTaskHref, onTaskUnarchived, taskCanvases, + taskCanvasesStatus, }: TaskPageInnerProps) { const workspaceRepositories = useAppStore((state) => selectWorkspaceRepositories(state.repositories.itemsByWorkspaceId, task?.workspace_id), @@ -355,6 +360,7 @@ function useTaskPageDerivedProps({ initialLayout, onTaskUnarchived, taskCanvases, + taskCanvasesStatus, }); return { taskProps, debugEntries, topBarProps, layoutProps }; diff --git a/apps/web/e2e/tests/canvas/canvas-authenticated-proxy-fixture.ts b/apps/web/e2e/tests/canvas/canvas-authenticated-proxy-fixture.ts index 03f27069c0..bf571bdb96 100644 --- a/apps/web/e2e/tests/canvas/canvas-authenticated-proxy-fixture.ts +++ b/apps/web/e2e/tests/canvas/canvas-authenticated-proxy-fixture.ts @@ -34,19 +34,29 @@ export type CanvasAuthenticatedProxy = { count: (kind: RuntimeRequestKind, authenticated?: boolean) => number; observations: () => CanvasProxyObservation[]; activeUpstreamRequests: () => number; + injectedRuntimeDocuments: () => number; close: () => Promise; }; +export type CanvasAuthenticatedProxyOptions = { + /** Ask the proxy to try an HTML transformation when the origin permits it. */ + injectRuntimeHtml?: boolean; + /** Simulate a proxy that removes the origin's no-transform directive. */ + stripNoTransform?: boolean; +}; + const runtimePrefix = "/api/v1/plugins/web-apps/runtime/"; export async function startCanvasAuthenticatedProxy( backendURL: string, + options: CanvasAuthenticatedProxyOptions = {}, ): Promise { const certificate = createCertificate(); const backend = new URL(backendURL); const observations: CanvasProxyObservation[] = []; const upstreamRequests = new Set(); const upstreamResponses = new Set(); + let injectedRuntimeDocumentCount = 0; const server = createTLSServer( { key: fs.readFileSync(certificate.keyPath), @@ -95,14 +105,44 @@ export async function startCanvasAuthenticatedProxy( upstreamResponse = response; upstreamResponses.add(response); response.once("close", () => upstreamResponses.delete(response)); - outgoing.writeHead(response.statusCode ?? 502, response.headers); - outgoing.flushHeaders(); + const headers = proxyResponseHeaders(response.headers, options.stripNoTransform === true); + const shouldTransform = + options.injectRuntimeHtml === true && + kind === "runtime-entry" && + isHtmlContentType(headers["content-type"]) && + !hasNoTransformDirective(headers["cache-control"]) && + !headers["content-encoding"]; + if (shouldTransform) { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer | string) => chunks.push(Buffer.from(chunk))); + response.once("end", () => { + if (outgoing.destroyed) return; + const original = Buffer.concat(chunks); + const transformed = injectRuntimeHtml(original); + if (transformed.equals(original)) { + outgoing.writeHead(response.statusCode ?? 502, headers); + outgoing.end(original); + return; + } + injectedRuntimeDocumentCount += 1; + const transformedHeaders = { + ...headers, + "content-length": String(transformed.length), + }; + delete transformedHeaders["transfer-encoding"]; + outgoing.writeHead(response.statusCode ?? 502, transformedHeaders); + outgoing.end(transformed); + }); + } else { + outgoing.writeHead(response.statusCode ?? 502, headers); + outgoing.flushHeaders(); + response.pipe(outgoing); + } response.once("error", (error) => { if (!outgoing.destroyed) outgoing.destroy(error); destroyUpstream(error); }); response.once("aborted", () => destroyUpstream()); - response.pipe(outgoing); }, ); upstreamRequests.add(upstream); @@ -176,6 +216,7 @@ export async function startCanvasAuthenticatedProxy( ).length, observations: () => observations.map((observation) => ({ ...observation })), activeUpstreamRequests: () => upstreamRequests.size, + injectedRuntimeDocuments: () => injectedRuntimeDocumentCount, close: async () => { await closeServer(server, sockets, upstreamRequests, upstreamResponses); fs.rmSync(certificate.directory, { recursive: true, force: true }); @@ -183,6 +224,47 @@ export async function startCanvasAuthenticatedProxy( }; } +function proxyResponseHeaders( + source: IncomingMessage["headers"], + stripNoTransform: boolean, +): IncomingMessage["headers"] { + const headers = { ...source }; + if (!stripNoTransform) return headers; + const cacheControl = headerString(headers["cache-control"]); + if (!cacheControl) return headers; + const directives = cacheControl + .split(",") + .map((directive) => directive.trim()) + .filter((directive) => directive.toLowerCase() !== "no-transform"); + if (directives.length === 0) delete headers["cache-control"]; + else headers["cache-control"] = directives.join(", "); + return headers; +} + +function headerString(value: string | string[] | undefined): string { + return Array.isArray(value) ? value.join(", ") : (value ?? ""); +} + +function hasNoTransformDirective(value: string | string[] | undefined): boolean { + return headerString(value) + .split(",") + .some((directive) => directive.trim().toLowerCase() === "no-transform"); +} + +function isHtmlContentType(value: string | string[] | undefined): boolean { + return headerString(value).toLowerCase().startsWith("text/html"); +} + +function injectRuntimeHtml(document: Buffer): Buffer { + const source = document.toString("utf8"); + const script = ''; + const closingHead = /<\/head\s*>/i.exec(source); + const transformed = closingHead + ? `${source.slice(0, closingHead.index)}${script}${source.slice(closingHead.index)}` + : `${source}${script}`; + return Buffer.from(transformed, "utf8"); +} + function runtimeRequestKind(pathname: string, method: string): RuntimeRequestKind | null { if (!pathname.startsWith(runtimePrefix)) return null; const tokenAndPath = pathname.slice(runtimePrefix.length); diff --git a/apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts b/apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts index 6fc129f160..9a2094f7e9 100644 --- a/apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts +++ b/apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts @@ -50,7 +50,7 @@ test.describe("authenticated same-origin canvas runtime", () => { try { const seeded = await seedTaskCanvas(testPage, apiClient, seedData); canvasId = seeded.canvas.id; - proxy = await startCanvasAuthenticatedProxy(backend.baseUrl); + proxy = await startCanvasAuthenticatedProxy(backend.baseUrl, { injectRuntimeHtml: true }); const opened = await openAuthenticatedCanvas(browser, proxy, canvasId); context = opened.context; const page = opened.page; @@ -71,6 +71,7 @@ test.describe("authenticated same-origin canvas runtime", () => { await expect.poll(() => proxy?.count("context", true) ?? 0).toBeGreaterThan(0); await expect.poll(() => proxy?.count("data", true) ?? 0).toBeGreaterThan(0); await expect.poll(() => proxy?.count("events", true) ?? 0).toBeGreaterThan(0); + expect(proxy?.injectedRuntimeDocuments()).toBe(0); expect(proxy?.observations().every((observation) => observation.host === proxy?.host)).toBe( true, ); @@ -91,6 +92,44 @@ test.describe("authenticated same-origin canvas runtime", () => { } }); + test("keeps the existing recoverable failure when a proxy ignores no-transform", async ({ + browser, + testPage, + apiClient, + backend, + seedData, + }) => { + test.setTimeout(180_000); + const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); + let canvasId: string | undefined; + let proxy: CanvasAuthenticatedProxy | undefined; + let context: BrowserContext | undefined; + try { + const seeded = await seedTaskCanvas(testPage, apiClient, seedData); + canvasId = seeded.canvas.id; + proxy = await startCanvasAuthenticatedProxy(backend.baseUrl, { + injectRuntimeHtml: true, + stripNoTransform: true, + }); + const opened = await openAuthenticatedCanvas(browser, proxy, canvasId); + context = opened.context; + + await expect(opened.page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", { + timeout: 30_000, + }); + await expect(opened.page.getByTestId("web-app-frame")).toHaveCount(0); + await expect( + opened.page.getByRole("button", { name: "Try again", exact: true }), + ).toBeVisible(); + expect(proxy.injectedRuntimeDocuments()).toBeGreaterThan(0); + } finally { + await context?.close(); + await proxy?.close(); + if (canvasId) await removeCanvas(apiClient, canvasId); + await releaseFeature(); + } + }); + test("releases a disconnected event stream before reconnecting", async ({ browser, testPage, diff --git a/apps/web/e2e/tests/canvas/mobile-canvas-authenticated-proxy.spec.ts b/apps/web/e2e/tests/canvas/mobile-canvas-authenticated-proxy.spec.ts index 3461cabf0d..bc4b5fc3cc 100644 --- a/apps/web/e2e/tests/canvas/mobile-canvas-authenticated-proxy.spec.ts +++ b/apps/web/e2e/tests/canvas/mobile-canvas-authenticated-proxy.spec.ts @@ -49,7 +49,7 @@ test.describe("authenticated same-origin canvas runtime on mobile", () => { try { const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true); canvasId = seeded.canvas.id; - proxy = await startCanvasAuthenticatedProxy(backend.baseUrl); + proxy = await startCanvasAuthenticatedProxy(backend.baseUrl, { injectRuntimeHtml: true }); const opened = await openMobileCanvas(browser, proxy, canvasId); context = opened.context; const page = opened.page; diff --git a/apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts b/apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts index 536a90804d..2dab6bcddf 100644 --- a/apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts +++ b/apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts @@ -483,4 +483,48 @@ test.describe("Plugin-backed canvases on mobile", () => { await releaseFeature(); } }); + + test("opens one published task canvas after returning and keeps Back usable", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + test.setTimeout(180_000); + const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); + let canvasId: string | undefined; + let taskId: string | undefined; + let authoringPage: Page | undefined; + try { + await testPage.goto("/"); + authoringPage = await testPage.context().newPage(); + const seeded = await seedTaskCanvas(authoringPage, apiClient, seedData, true); + canvasId = seeded.canvas.id; + taskId = seeded.taskId; + await authoringPage.close(); + authoringPage = undefined; + + await testPage.reload(); + await testPage.goto(`/t/${encodeURIComponent(seeded.taskId)}`); + await expect(testPage).toHaveURL(new RegExp(`${canvasHref(seeded.canvas.id)}$`), { + timeout: 30_000, + }); + await expect(testPage.getByTestId("canvas-host-route")).toBeVisible({ timeout: 20_000 }); + await expect(testPage.getByTestId("web-app-frame")).toHaveAttribute( + "data-frame-state", + "ready", + { timeout: 20_000 }, + ); + + await testPage.goBack(); + await expect(testPage).toHaveURL(new RegExp(`/t/${seeded.taskId}(?:[?]|$)`)); + await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible({ timeout: 20_000 }); + await expect(testPage.getByTestId("canvas-host-route")).toHaveCount(0); + } finally { + await authoringPage?.close(); + if (canvasId) await removeCanvas(apiClient, canvasId); + if (taskId) await apiClient.deleteTask(taskId).catch(() => undefined); + await releaseFeature(); + } + }); }); diff --git a/apps/web/e2e/tests/canvas/plugin-canvas.spec.ts b/apps/web/e2e/tests/canvas/plugin-canvas.spec.ts index 316220d563..dab666cc9d 100644 --- a/apps/web/e2e/tests/canvas/plugin-canvas.spec.ts +++ b/apps/web/e2e/tests/canvas/plugin-canvas.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "../../fixtures/test-base"; +import type { Page } from "@playwright/test"; import { waitForHttp } from "../../helpers/causal-waits"; import { waitForSessionDone } from "../../helpers/session"; import { resizeColumnViaSplitview } from "../../helpers/dockview-resize"; @@ -360,6 +361,110 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => { } }); + test("reconciles a published task canvas after returning to the task", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + test.setTimeout(180_000); + const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); + let canvasId: string | undefined; + let taskId: string | undefined; + let authoringPage: Page | undefined; + try { + await testPage.goto("/"); + authoringPage = await testPage.context().newPage(); + const seeded = await seedTaskCanvas(authoringPage, apiClient, seedData); + canvasId = seeded.canvas.id; + taskId = seeded.taskId; + await authoringPage.close(); + authoringPage = undefined; + + await testPage.reload(); + await testPage.goto(`/t/${encodeURIComponent(seeded.taskId)}`); + await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible(); + await expect + .poll( + () => + testPage.evaluate((id) => { + const api = ( + window as unknown as { + __dockviewApi__?: { + getPanel: (panelId: string) => { group?: { id?: string } } | undefined; + panels?: Array<{ id: string }>; + }; + } + ).__dockviewApi__; + const panel = api?.getPanel(`canvas:${id}`); + return { + count: + api?.panels?.filter((candidate) => candidate.id === `canvas:${id}`).length ?? 0, + canvasGroup: panel?.group?.id ?? null, + }; + }, seeded.canvas.id), + { timeout: 30_000 }, + ) + .toEqual({ count: 1, canvasGroup: "group-center" }); + await expect(testPage.getByTestId("canvas-host-state")).toHaveText("Ready", { + timeout: 20_000, + }); + + await testPage.evaluate((id) => { + const api = ( + window as unknown as { + __dockviewApi__?: { + getPanel: (panelId: string) => { api: { close: () => void } } | undefined; + }; + __persistDockviewLayout__?: () => void; + } + ).__dockviewApi__; + api?.getPanel(`canvas:${id}`)?.api.close(); + ( + window as unknown as { __persistDockviewLayout__?: () => void } + ).__persistDockviewLayout__?.(); + }, seeded.canvas.id); + await expect + .poll(() => + testPage.evaluate((id) => { + const api = ( + window as unknown as { __dockviewApi__?: { getPanel: (panelId: string) => unknown } } + ).__dockviewApi__; + return api?.getPanel(`canvas:${id}`) !== undefined; + }, seeded.canvas.id), + ) + .toBe(false); + + const taskInventory = waitForHttp( + testPage, + "GET", + new RegExp(`/api/v1/tasks/${encodeURIComponent(seeded.taskId)}/canvases$`), + ); + await testPage.reload(); + await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible(); + await taskInventory; + await expect(testPage.getByTestId("dockview-task-layout")).toHaveAttribute( + "aria-busy", + "false", + ); + await expect + .poll(() => + testPage.evaluate((id) => { + const api = ( + window as unknown as { __dockviewApi__?: { getPanel: (panelId: string) => unknown } } + ).__dockviewApi__; + return api?.getPanel(`canvas:${id}`) !== undefined; + }, seeded.canvas.id), + ) + .toBe(false); + } finally { + await authoringPage?.close(); + if (canvasId) await removeCanvas(apiClient, canvasId); + if (taskId) await apiClient.deleteTask(taskId).catch(() => undefined); + await releaseFeature(); + } + }); + test("shows recoverable startup failure and retries with a fresh runtime", async ({ testPage, apiClient, diff --git a/apps/web/hooks/domains/task/use-task-canvases.test.ts b/apps/web/hooks/domains/task/use-task-canvases.test.ts index 6bbd045352..b6b610e7c6 100644 --- a/apps/web/hooks/domains/task/use-task-canvases.test.ts +++ b/apps/web/hooks/domains/task/use-task-canvases.test.ts @@ -1,6 +1,8 @@ import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Canvas } from "@/lib/api/domains/canvas-api"; +import { invalidateCanvasLifecycle } from "@/lib/canvas-lifecycle"; +import type { Task } from "@/lib/types/http"; const listTaskCanvasesMock = vi.hoisted(() => vi.fn()); @@ -11,7 +13,7 @@ vi.mock("@/lib/api/domains/canvas-api", () => ({ listTaskCanvases: listTaskCanvasesMock, })); -import { useTaskCanvases } from "./use-task-canvases"; +import { useTaskCanvases, useTaskCanvasesForTask, useTaskCanvasesState } from "./use-task-canvases"; const canvas: Canvas = { id: "canvas-1", @@ -24,6 +26,15 @@ const canvas: Canvas = { status: "active", }; +const connectionState = { status: "connected" }; +const authState = { mode: "disabled", user: null as { id: string } | null }; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: ( + selector: (state: { connection: typeof connectionState; auth: typeof authState }) => unknown, + ) => selector({ connection: connectionState, auth: authState }), +})); + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((resolvePromise) => { @@ -34,11 +45,14 @@ function deferred() { beforeEach(() => { listTaskCanvasesMock.mockReset().mockResolvedValue({ canvases: [canvas] }); + connectionState.status = "connected"; + authState.mode = "disabled"; + authState.user = null; }); afterEach(cleanup); -describe("useTaskCanvases", () => { +describe("useTaskCanvases loading", () => { it("loads canvases for the task and workspace", async () => { const { result } = renderHook(() => useTaskCanvases(TASK_ID, WORKSPACE_ID)); @@ -56,6 +70,40 @@ describe("useTaskCanvases", () => { expect(listTaskCanvasesMock).not.toHaveBeenCalled(); }); + it("keeps an empty successful inventory distinct from a failed request", async () => { + listTaskCanvasesMock.mockResolvedValueOnce({ canvases: [] }); + const successful = renderHook(() => useTaskCanvasesState(TASK_ID, WORKSPACE_ID)); + + await waitFor(() => expect(successful.result.current.status).toBe("success")); + expect(successful.result.current.canvases).toEqual([]); + successful.unmount(); + + listTaskCanvasesMock.mockRejectedValueOnce(new Error("inventory unavailable")); + const failed = renderHook(() => useTaskCanvasesState(TASK_ID, WORKSPACE_ID)); + + await waitFor(() => expect(failed.result.current.status).toBe("error")); + expect(failed.result.current.canvases).toEqual([]); + }); + + it("loads the task inventory for desktop task entry", async () => { + const { result } = renderHook(() => + useTaskCanvasesForTask( + { + id: TASK_ID as Task["id"], + workspace_id: WORKSPACE_ID as Task["workspace_id"], + }, + false, + true, + ), + ); + + await waitFor(() => expect(result.current).toEqual([canvas])); + expect(listTaskCanvasesMock).toHaveBeenCalledWith(TASK_ID, { + workspaceId: WORKSPACE_ID, + cache: "no-store", + }); + }); + it("ignores a stale request after the task changes", async () => { const first = deferred<{ canvases: Canvas[] }>(); const second = deferred<{ canvases: Canvas[] }>(); @@ -84,3 +132,76 @@ describe("useTaskCanvases", () => { }); }); }); + +describe("useTaskCanvases request lifecycle", () => { + it("starts a fresh shared request after lifecycle invalidation", async () => { + const first = deferred<{ canvases: Canvas[] }>(); + const second = deferred<{ canvases: Canvas[] }>(); + listTaskCanvasesMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + + const firstHook = renderHook(() => useTaskCanvasesState(TASK_ID, WORKSPACE_ID)); + const secondHook = renderHook(() => useTaskCanvasesState(TASK_ID, WORKSPACE_ID)); + await waitFor(() => expect(listTaskCanvasesMock).toHaveBeenCalledTimes(1)); + + act(() => invalidateCanvasLifecycle()); + await waitFor(() => expect(listTaskCanvasesMock).toHaveBeenCalledTimes(2)); + + second.resolve({ canvases: [canvas] }); + await waitFor(() => expect(firstHook.result.current.canvases).toEqual([canvas])); + expect(secondHook.result.current.canvases).toEqual([canvas]); + + first.resolve({ canvases: [] }); + await first.promise; + expect(firstHook.result.current.canvases).toEqual([canvas]); + }); + + it("does not reuse a connected request after reconnect invalidates it", async () => { + const beforeDisconnect = deferred<{ canvases: Canvas[] }>(); + const duringDisconnect = deferred<{ canvases: Canvas[] }>(); + const afterReconnect = deferred<{ canvases: Canvas[] }>(); + listTaskCanvasesMock + .mockReturnValueOnce(beforeDisconnect.promise) + .mockReturnValueOnce(duringDisconnect.promise) + .mockReturnValueOnce(afterReconnect.promise); + + const hook = renderHook(() => useTaskCanvasesState(TASK_ID, WORKSPACE_ID)); + await waitFor(() => expect(listTaskCanvasesMock).toHaveBeenCalledTimes(1)); + + connectionState.status = "disconnected"; + hook.rerender(); + await waitFor(() => expect(listTaskCanvasesMock).toHaveBeenCalledTimes(2)); + duringDisconnect.resolve({ canvases: [] }); + await duringDisconnect.promise; + + connectionState.status = "connected"; + hook.rerender(); + await waitFor(() => expect(listTaskCanvasesMock).toHaveBeenCalledTimes(3)); + + afterReconnect.resolve({ canvases: [canvas] }); + await waitFor(() => expect(hook.result.current.canvases).toEqual([canvas])); + beforeDisconnect.resolve({ canvases: [] }); + await beforeDisconnect.promise; + }); + + it("does not share a pending inventory across authenticated identities", async () => { + const firstIdentity = deferred<{ canvases: Canvas[] }>(); + const secondIdentity = deferred<{ canvases: Canvas[] }>(); + listTaskCanvasesMock + .mockReturnValueOnce(firstIdentity.promise) + .mockReturnValueOnce(secondIdentity.promise); + + const hook = renderHook(() => useTaskCanvasesState(TASK_ID, WORKSPACE_ID)); + await waitFor(() => expect(listTaskCanvasesMock).toHaveBeenCalledTimes(1)); + + authState.mode = "enabled"; + authState.user = { id: "user-2" }; + hook.rerender(); + await waitFor(() => expect(listTaskCanvasesMock).toHaveBeenCalledTimes(2)); + + secondIdentity.resolve({ canvases: [canvas] }); + await waitFor(() => expect(hook.result.current.canvases).toEqual([canvas])); + firstIdentity.resolve({ canvases: [] }); + await firstIdentity.promise; + expect(hook.result.current.canvases).toEqual([canvas]); + }); +}); diff --git a/apps/web/hooks/domains/task/use-task-canvases.ts b/apps/web/hooks/domains/task/use-task-canvases.ts index 2de6649bf2..132adf96af 100644 --- a/apps/web/hooks/domains/task/use-task-canvases.ts +++ b/apps/web/hooks/domains/task/use-task-canvases.ts @@ -1,56 +1,135 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { useAppStore } from "@/components/state-provider"; import { listTaskCanvases, type Canvas } from "@/lib/api/domains/canvas-api"; import type { Task } from "@/lib/types/http"; import { useCanvasLifecycleRevision } from "@/lib/canvas-lifecycle"; const EMPTY_CANVASES: Canvas[] = []; +export type TaskCanvasesLoadStatus = "idle" | "loading" | "success" | "error"; + +export type TaskCanvasesState = { + key: string | null; + canvases: Canvas[]; + status: TaskCanvasesLoadStatus; +}; + +type InFlightTaskCanvasesRequest = { + fingerprint: string; + promise: Promise; +}; + +const inFlightRequests = new Map(); + +function taskCanvasesKey(workspaceId: string, taskId: string, authIdentity: string): string { + return `${workspaceId}\u0000${taskId}\u0000${authIdentity}`; +} + +function requestTaskCanvases( + taskId: string, + workspaceId: string, + authIdentity: string, + invalidationFingerprint: string, +): Promise { + const key = taskCanvasesKey(workspaceId, taskId, authIdentity); + const existing = inFlightRequests.get(key); + if (existing?.fingerprint === invalidationFingerprint) return existing.promise; + + const request = listTaskCanvases(taskId, { workspaceId, cache: "no-store" }).then((response) => { + if (!response || !Array.isArray(response.canvases)) throw new TypeError(); + return response.canvases; + }); + const entry = { fingerprint: invalidationFingerprint, promise: request }; + inFlightRequests.set(key, entry); + request.then( + () => { + if (inFlightRequests.get(key) === entry) inFlightRequests.delete(key); + }, + () => { + if (inFlightRequests.get(key) === entry) inFlightRequests.delete(key); + }, + ); + return request; +} + +const EMPTY_STATE: TaskCanvasesState = { + key: null, + canvases: EMPTY_CANVASES, + status: "idle", +}; /** Loads the canvases that can be opened from a task's mobile Panels picker. */ -export function useTaskCanvases( +export function useTaskCanvasesState( taskId: string | null | undefined, workspaceId: string | null | undefined, enabled = true, -): Canvas[] { - const [loaded, setLoaded] = useState<{ key: string | null; canvases: Canvas[] }>({ - key: null, - canvases: EMPTY_CANVASES, - }); +): TaskCanvasesState { + const connectionStatus = useAppStore((state) => state.connection.status); + const authIdentity = useAppStore( + (state) => `${state.auth.mode}\u0000${state.auth.user?.id ?? ""}`, + ); + const [loaded, setLoaded] = useState(EMPTY_STATE); const requestRef = useRef(0); const lifecycleRevision = useCanvasLifecycleRevision(); useEffect(() => { const requestId = ++requestRef.current; if (!enabled || !taskId || !workspaceId) { - setLoaded({ key: null, canvases: EMPTY_CANVASES }); + setLoaded(EMPTY_STATE); return; } - const key = `${workspaceId}\u0000${taskId}`; - setLoaded((current) => (current.key === key ? current : { key, canvases: EMPTY_CANVASES })); - listTaskCanvases(taskId, { workspaceId, cache: "no-store" }) - .then((response) => { + const key = taskCanvasesKey(workspaceId, taskId, authIdentity); + const invalidationFingerprint = `${connectionStatus}\u0000${lifecycleRevision}`; + setLoaded((current) => + current.key === key + ? { ...current, status: "loading" } + : { key, canvases: EMPTY_CANVASES, status: "loading" }, + ); + requestTaskCanvases(taskId, workspaceId, authIdentity, invalidationFingerprint) + .then((canvases) => { if (requestRef.current !== requestId) return; - setLoaded({ key, canvases: response?.canvases ?? EMPTY_CANVASES }); + setLoaded({ key, canvases, status: "success" }); }) .catch(() => { - if (requestRef.current === requestId) setLoaded({ key, canvases: EMPTY_CANVASES }); + if (requestRef.current === requestId) { + setLoaded((current) => ({ + key, + canvases: current.key === key ? current.canvases : EMPTY_CANVASES, + status: "error", + })); + } }); return () => { if (requestRef.current === requestId) requestRef.current += 1; }; - }, [enabled, lifecycleRevision, taskId, workspaceId]); + }, [authIdentity, connectionStatus, enabled, lifecycleRevision, taskId, workspaceId]); - const key = taskId && workspaceId ? `${workspaceId}\u0000${taskId}` : null; - return loaded.key === key ? loaded.canvases : EMPTY_CANVASES; + const key = taskId && workspaceId ? taskCanvasesKey(workspaceId, taskId, authIdentity) : null; + return loaded.key === key ? loaded : { key, canvases: EMPTY_CANVASES, status: "idle" }; +} + +export function useTaskCanvases( + taskId: string | null | undefined, + workspaceId: string | null | undefined, + enabled = true, +): Canvas[] { + return useTaskCanvasesState(taskId, workspaceId, enabled).canvases; } export function useTaskCanvasesForTask( task: Pick | null | undefined, - isMobile: boolean, + _isMobile: boolean, canvasesEnabled: boolean, ): Canvas[] { - return useTaskCanvases(task?.id ?? null, task?.workspace_id ?? null, isMobile && canvasesEnabled); + return useTaskCanvasesStateForTask(task, canvasesEnabled).canvases; +} + +export function useTaskCanvasesStateForTask( + task: Pick | null | undefined, + canvasesEnabled: boolean, +): TaskCanvasesState { + return useTaskCanvasesState(task?.id ?? null, task?.workspace_id ?? null, canvasesEnabled); } diff --git a/apps/web/lib/canvas-presentation-storage.tab.test.ts b/apps/web/lib/canvas-presentation-storage.tab.test.ts new file mode 100644 index 0000000000..849cc53824 --- /dev/null +++ b/apps/web/lib/canvas-presentation-storage.tab.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const identity = { + userId: "user-1", + workspaceId: "workspace-1", + taskId: "task-1", + canvasId: "canvas-1", +}; + +const uuidValues: string[] = []; + +vi.doMock("@/lib/uuid", () => ({ + generateUUID: () => uuidValues.shift() ?? "uuid-fallback", +})); + +beforeEach(() => { + window.sessionStorage.clear(); + window.localStorage.clear(); + uuidValues.length = 0; + vi.resetModules(); +}); + +describe("canvas presentation tab identity", () => { + it("rotates a copied session namespace while keeping the source receipt isolated", async () => { + uuidValues.push("source-page", "source-tab", "duplicate-page", "duplicate-tab"); + const source = await import("./canvas-presentation-storage"); + source.markCanvasPresented(identity); + const sourceKey = source.canvasPresentationKey(identity); + + vi.resetModules(); + const duplicate = await import("./canvas-presentation-storage"); + + expect(duplicate.wasCanvasPresented(identity)).toBe(false); + duplicate.markCanvasPresented(identity); + expect(duplicate.wasCanvasPresented(identity)).toBe(true); + expect(window.sessionStorage.getItem(sourceKey)).toBe("1"); + }); + + it("reuses the tab identity after a normal reload releases its page owner", async () => { + uuidValues.push("source-page", "source-tab", "reloaded-page"); + const source = await import("./canvas-presentation-storage"); + source.markCanvasPresented(identity); + window.dispatchEvent(new PageTransitionEvent("pagehide", { persisted: false })); + + vi.resetModules(); + const reloaded = await import("./canvas-presentation-storage"); + + expect(reloaded.wasCanvasPresented(identity)).toBe(true); + }); +}); diff --git a/apps/web/lib/canvas-presentation-storage.test.ts b/apps/web/lib/canvas-presentation-storage.test.ts new file mode 100644 index 0000000000..46287e5718 --- /dev/null +++ b/apps/web/lib/canvas-presentation-storage.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + canvasPresentationKey, + markCanvasPresented, + wasCanvasPresented, + type CanvasPresentationIdentity, +} from "./canvas-presentation-storage"; + +const identity: CanvasPresentationIdentity = { + userId: "user-1", + workspaceId: "workspace-1", + taskId: "task-1", + canvasId: "canvas-1", +}; + +beforeEach(() => { + window.sessionStorage.clear(); +}); + +describe("canvas presentation receipts", () => { + it("keys receipts by the complete user, workspace, task, and canvas identity", () => { + expect(canvasPresentationKey(identity)).not.toBe( + canvasPresentationKey({ ...identity, userId: "user-2" }), + ); + expect(canvasPresentationKey(identity)).not.toBe( + canvasPresentationKey({ ...identity, workspaceId: "workspace-2" }), + ); + expect(canvasPresentationKey(identity)).not.toBe( + canvasPresentationKey({ ...identity, taskId: "task-2" }), + ); + expect(canvasPresentationKey(identity)).not.toBe( + canvasPresentationKey({ ...identity, canvasId: "canvas-2" }), + ); + }); + + it("persists a receipt for the browser tab", () => { + expect(wasCanvasPresented(identity)).toBe(false); + + markCanvasPresented(identity); + + expect(wasCanvasPresented(identity)).toBe(true); + expect(wasCanvasPresented({ ...identity, canvasId: "canvas-2" })).toBe(false); + }); + + it("falls back to tab memory when sessionStorage cannot be written", () => { + const blocked = { ...identity, canvasId: "canvas-blocked" }; + const setItem = window.sessionStorage.setItem; + window.sessionStorage.setItem = () => { + throw new Error("storage blocked"); + }; + + markCanvasPresented(blocked); + + window.sessionStorage.setItem = setItem; + expect(wasCanvasPresented(blocked)).toBe(true); + }); +}); diff --git a/apps/web/lib/canvas-presentation-storage.ts b/apps/web/lib/canvas-presentation-storage.ts new file mode 100644 index 0000000000..4c13f63828 --- /dev/null +++ b/apps/web/lib/canvas-presentation-storage.ts @@ -0,0 +1,184 @@ +import { generateUUID } from "@/lib/uuid"; + +export type CanvasPresentationIdentity = { + userId: string; + workspaceId: string; + taskId: string; + canvasId: string; +}; + +export type CanvasPresentationReason = "existing" | "restored" | "manual" | "automatic"; + +const CANVAS_PRESENTATION_PREFIX = "kandev.canvas-presentation.v2"; +const TAB_INSTANCE_KEY = `${CANVAS_PRESENTATION_PREFIX}.tab`; +const TAB_OWNER_PREFIX = `${CANVAS_PRESENTATION_PREFIX}.owner.`; +// i18n-exempt: stable browser-storage identity, not user-facing copy. +const ANONYMOUS_CANVAS_PRESENTATION_IDENTITY = "anonymous"; +const SERVER_TAB_IDENTITY = "server"; +const memoryReceipts = new Set(); + +type TabOwnerRecord = { + pageId: string; + active: boolean; +}; + +type TabIdentity = { + tabId: string; + pageId: string; +}; + +let tabIdentity: TabIdentity | null = null; + +export function canvasPresentationUserId(auth: { + mode: "disabled" | "setup" | "enabled"; + user: { id: string } | null; +}): string | null { + if (auth.user?.id) return auth.user.id; + return auth.mode === "disabled" ? ANONYMOUS_CANVAS_PRESENTATION_IDENTITY : null; +} + +function sessionStorageOrNull(): Storage | null { + if (typeof window === "undefined") return null; + try { + return window.sessionStorage; + } catch { + return null; + } +} + +function localStorageOrNull(): Storage | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +function readTabOwner(storage: Storage, tabId: string): TabOwnerRecord | null { + try { + const raw = storage.getItem(`${TAB_OWNER_PREFIX}${tabId}`); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.pageId !== "string" || typeof parsed.active !== "boolean") return null; + return { pageId: parsed.pageId, active: parsed.active }; + } catch { + return null; + } +} + +function writeTabOwner(storage: Storage, tabId: string, record: TabOwnerRecord): void { + try { + storage.setItem(`${TAB_OWNER_PREFIX}${tabId}`, JSON.stringify(record)); + } catch { + // Ignore private browsing, blocked storage, and quota failures. + } +} + +function writeSessionTabId(storage: Storage | null, tabId: string): void { + if (!storage) return; + try { + storage.setItem(TAB_INSTANCE_KEY, tabId); + } catch { + // Receipts still use the in-memory fallback when session storage is blocked. + } +} + +/** + * Return a stable identity for this tab while detecting a duplicated + * sessionStorage namespace. A reload releases the old page owner first, while + * a duplicated tab sees the live owner and receives a fresh namespace. + */ +function canvasPresentationTabId(): string { + if (typeof window === "undefined") return SERVER_TAB_IDENTITY; + + const session = sessionStorageOrNull(); + let storedTabId: string | null = null; + try { + storedTabId = session?.getItem(TAB_INSTANCE_KEY) ?? null; + } catch { + storedTabId = null; + } + if (tabIdentity && storedTabId === tabIdentity.tabId) return tabIdentity.tabId; + + const pageId = generateUUID(); + let tabId = storedTabId ?? generateUUID(); + const local = localStorageOrNull(); + if (local) { + const owner = readTabOwner(local, tabId); + if (owner?.active && owner.pageId !== pageId) { + tabId = generateUUID(); + writeSessionTabId(session, tabId); + } else if (!storedTabId) { + writeSessionTabId(session, tabId); + } + writeTabOwner(local, tabId, { pageId, active: true }); + window.addEventListener( + "pagehide", + (event) => { + if (event.persisted) return; + if (readTabOwner(local, tabId)?.pageId === pageId) { + writeTabOwner(local, tabId, { pageId, active: false }); + } + }, + { once: true }, + ); + } else if (!storedTabId) { + writeSessionTabId(session, tabId); + } + + tabIdentity = { tabId, pageId }; + return tabId; +} + +/** Build the tab-local identity for one presented task canvas. */ +export function canvasPresentationKey(identity: CanvasPresentationIdentity): string { + return [ + CANVAS_PRESENTATION_PREFIX, + canvasPresentationTabId(), + identity.userId, + identity.workspaceId, + identity.taskId, + identity.canvasId, + ] + .map(encodeURIComponent) + .join("."); +} + +/** Whether a task canvas was presented in this browser tab. */ +export function wasCanvasPresented(identity: CanvasPresentationIdentity): boolean { + const key = canvasPresentationKey(identity); + const storage = sessionStorageOrNull(); + if (!storage) return memoryReceipts.has(key); + + try { + return storage.getItem(key) === "1" || memoryReceipts.has(key); + } catch { + return memoryReceipts.has(key); + } +} + +/** Record a successful task canvas presentation in this browser tab. */ +export function markCanvasPresented(identity: CanvasPresentationIdentity): void { + const key = canvasPresentationKey(identity); + const storage = sessionStorageOrNull(); + if (!storage) { + memoryReceipts.add(key); + return; + } + + try { + storage.setItem(key, "1"); + } catch { + memoryReceipts.add(key); + } +} + +/** Shared seam for manual, restored, and automatic host presentation paths. */ +export function recordCanvasPresentation( + identity: CanvasPresentationIdentity, + reason: CanvasPresentationReason, +): void { + void reason; + markCanvasPresented(identity); +} diff --git a/docs/plans/canvas-runtime-entry-recovery/plan.md b/docs/plans/canvas-runtime-entry-recovery/plan.md new file mode 100644 index 0000000000..218ee61ac6 --- /dev/null +++ b/docs/plans/canvas-runtime-entry-recovery/plan.md @@ -0,0 +1,190 @@ +--- +created: 2026-09-21 +status: done +requirements: + - REQ-PLUGINS-ISOLATED-WEB-APPS-012 + - REQ-PLUGINS-ISOLATED-WEB-APPS-013 + - REQ-CANVASES-AGENT-WEB-APPS-001 + - REQ-CANVASES-AGENT-WEB-APPS-006 +system_design: + - ../../specs/plugins/system-design/isolated-web-app-contributions.md + - ../../specs/plugins/system-design/runtime-response-preservation.md + - ../../specs/canvases/system-design/agent-authored-web-apps.md + - ../../specs/canvases/system-design/task-entry-presentation.md +legacy_specs: [] +--- + +# Implementation Plan: Canvas runtime and task-entry recovery + +## Overview + +Prevent automatic proxy injection into canvas responses. Then show unseen +published canvases when users return to their tasks. Both changes reuse the +existing runtime and host surfaces. + +## Evidence and diagnosis + +The incident involved task `f6136adf-46a6-422f-831c-5a35caf04b3d` and canvas +`09549bca-9ad4-4169-9d4f-2913e8bfc1b3`. The author reported active release +`21bf0113-73fe-42c8-8b11-9bbf4ca0db27` as valid. + +Backend logs recorded `canvas.release.activated` at 10:19:28 UTC on September 21. +Browser logs recorded task restoration without its canvas panel at 13:14:02 UTC. +The user confirmed that they returned after publication. + +The screenshot showed Canvas unavailable. The reported console error identified +a CSP-blocked Cloudflare beacon. The exact embedded startup JavaScript reproduced +the failure mechanism in an isolated Node VM. Without a resource error, it +requested context once and reported Ready. A simulated beacon script error +reported `failed/document_error` and made no context request. + +This is a source-level reproduction, not a capture of the live frame handshake. +The diagnostic archive was partial and lacked publication-time browser events. +The source nonetheless establishes both relevant paths: + +- `hostRuntimeBootstrap` rejects resource errors. `setRuntimeHeaders` currently + sends only `no-store`, leaving no explicit prohibition on proxy transformation. +- `useTaskCanvasLifecycleActivation` depends on volatile lifecycle hints. Task + entry does not reconcile the published HTTP inventory with the workbench. + +The runtime design already prohibits proxy HTML injection. Preserve that +contract. Cloudflare documents the transformation directive in its +[Web Analytics FAQ](https://developers.cloudflare.com/web-analytics/faq/). +The first-release requirement covers live publication. New criteria +`AC-CANVASES-AGENT-WEB-APPS-001.13` through `.16` define later task entry. + +## Scope + +### In scope + +- Runtime `no-transform` response policy and realistic HTTPS proxy coverage. +- HTTP inventory reconciliation on task entry, reload, and reconnect. +- Main editor placement, one focus change, and browser-tab dismissal memory. +- Phone route behavior and prevention of Back-navigation loops. +- Operator and user documentation in `docs/public/canvases.md`. + +### Out of scope + +- Live Cloudflare configuration changes or live canvas republishing. +- CSP relaxation, ignored script errors, new grants, or new runtime flags. +- Cross-device presentation history or changes to canvas package storage. +- Claims that this investigation verified the live deployment after correction. + +## Technical approach + +Task 01 changes `setRuntimeHeaders` in the shared plugin runtime. Extend the +existing authenticated proxy fixture with directive-aware HTML injection. +Its negative control removes `no-transform` and demonstrates the existing failure. +Keep package bytes, capability checks, cookie forwarding, and SSE intact. + +Task 02 shares the task-page canvas inventory with the activation hook. +Reconcile only after the target environment finishes layout restoration. +Use current HTTP metadata for every candidate. Record offered canvases after +presentation, following the existing PR-panel session-storage pattern. +Receipt identity includes user, workspace, task, and canvas, not release. +Manual opening and restored panels also record presentation. + +Desktop adds every eligible unseen canvas and focuses only the deterministic +last candidate. Existing panels never move. Phones open one candidate and mark +the batch offered after confirmed navigation. Other candidates remain in the picker. +The nearest phone exemplars are `task-layout.tsx` and `CanvasHostFrame`. +Their full-height route suits primary content and retains existing scroll ownership, +safe areas, accessible controls, and touch targets. + +Related completed packages remain historical evidence: +[same-origin authentication](../canvas-same-origin-auth/plan.md) and +[first-release UX](../plugin-backed-canvases-ux-follow-up/plan.md). +Their existing happy-path scenarios must remain covered alongside these regressions. +No new ADR is needed: this preserves the runtime boundary and extends the existing +tab-local offered-marker pattern. Specifications retain the local rationale. + +## ASCII UI preview + +UI-01: Returning to a task after publication, desktop. + +```text +Before: main editor [Agent] [Plan] [+] + Canvas requires manual selection. + +After: main editor [Agent] [Plan] [Task coordinator *] [+] + +-------------------------------------------+ + | Existing canvas toolbar | + | Canvas application fills remaining space | + +-------------------------------------------+ +``` + +UI-02: Returning to the same task, phone. + +```text ++--------------------------------+ +| Existing navigation | Canvas | +| Existing host actions | +| | +| Focused canvas application | +| One content scroll region | ++--------------------------------+ +Back -> task, without redirecting again +``` + +The main editor group, one focused canvas, and existing phone route are required. +Spacing and titles are illustrative. No new toolbar or visible copy is required. +Permission-review canvases show the existing review state before execution. +Real runtime failures keep the existing unavailable state and Retry action. + +## Tests + +- `AC-PLUGINS-ISOLATED-WEB-APPS-013.8`: extend `runtime_test.go` with + `TestRuntimeNoTransformHeaders` for entry HTML, nested assets, and bootstrap. +- `AC-PLUGINS-ISOLATED-WEB-APPS-012.1`: preserve startup error tests and the + injected-script negative control. +- `AC-CANVASES-AGENT-WEB-APPS-001.2`, `.13` through `.16`: extend + `dockview-canvas-activation.test.tsx`, `use-task-canvases.test.ts`, and a + focused receipt-storage test. Include mixed eligible and ineligible inventory. + +## E2E tests + +- Desktop and phone authenticated-proxy specs prove retained runtime startup + with transformation prevention, plus the negative control on desktop. +- Desktop and phone plugin-canvas specs prove publication before navigation, + reload, receipt persistence, manual reopening, and no focus/navigation loops. +- Desktop also proves main editor placement after delayed layout restoration. +- Feature-disabled, wrong-task, unavailable, and permission-review paths retain + focused unit/component coverage and existing rendered scenarios. + +## Work orders + +- [x] [Task 01: Preserve runtime responses through proxies](task-01-runtime-responses.md) +- [x] [Task 02: Reconcile canvases on task entry](task-02-task-entry.md) + +Execute sequentially. Each work order contains exact commands and owns its tests. +No delegation is authorized. + +## Verification results + +Planning: the isolated bootstrap probe reproduced the failure mechanism. +Implementation and rendered verification are complete. Backend runtime tests, +focused web tests, full web lint, i18n checks, web typecheck, and the managed +desktop and phone E2E suites passed. `make -C apps/backend build` and +`pnpm --filter @kandev/web build:vite` passed. The authenticated proxy suites +passed with 5 desktop and 2 phone tests; the task-entry suites passed with 6 +desktop and 4 phone tests. The focused component and hook suite passed 61 tests, +including deferred request invalidation, authenticated identity, mixed inventory, +delayed layout ownership, batch focus, restored-panel receipts, and duplicated-tab +receipt isolation. Full web lint +passed with zero warnings. `pnpm run i18n:check` passed; it reported the +repository's existing orphan catalog warnings without failing. Public-doc validation passed 62 tests and +validated 47 pages. `python3 scripts/list-docs.py validate` passed with 294 +decisions and 1068 specifications, `python3 scripts/lint-spec-files.py --all` +passed, and `python3 scripts/lint-spec-files.test.py` passed all 36 tests. +`git diff --check` passed. + +## Risks + +- A proxy can override origin headers. The real deployment still needs an + operator smoke check, and can require a runtime-path analytics exclusion. +- Old canvases have no presentation receipts. The first corrected task visit + can offer them once. Existing panels count as already presented. +- Blocked session storage falls back to memory. Reload persistence then cannot + be guaranteed, but manual access must continue to work. +- Async restoration and task switches can misplace panels unless generation + and environment checks occur immediately before insertion. diff --git a/docs/plans/canvas-runtime-entry-recovery/task-01-runtime-responses.md b/docs/plans/canvas-runtime-entry-recovery/task-01-runtime-responses.md new file mode 100644 index 0000000000..a6a379147e --- /dev/null +++ b/docs/plans/canvas-runtime-entry-recovery/task-01-runtime-responses.md @@ -0,0 +1,112 @@ +--- +id: "01-runtime-responses" +title: "Preserve runtime responses through proxies" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-PLUGINS-ISOLATED-WEB-APPS-012 + - REQ-PLUGINS-ISOLATED-WEB-APPS-013 +acceptance_criteria: + - AC-PLUGINS-ISOLATED-WEB-APPS-012.1 + - AC-PLUGINS-ISOLATED-WEB-APPS-013.2 + - AC-PLUGINS-ISOLATED-WEB-APPS-013.8 +system_design: + - ../../specs/plugins/system-design/isolated-web-app-contributions.md + - ../../specs/plugins/system-design/runtime-response-preservation.md +--- + +# Task 01: Preserve runtime responses through proxies + +## Summary + +Add `no-transform` to the existing no-store runtime policy. Prove that a +directive-aware proxy preserves working retained canvases on desktop and phone. + +## In scope + +- Write `TestRuntimeNoTransformHeaders` first. It must fail because current + entry, asset, and bootstrap responses lack the directive. +- Change `setRuntimeHeaders` to send `no-store, no-transform`. +- Extend `canvas-authenticated-proxy-fixture.ts` with optional runtime-HTML + injection. Respect the parsed directive, buffer only HTML, and update length. + Forward cookies and preserve real SSE streaming and cancellation. +- Add a negative control that removes the directive and injects a blocked + external script before startup settles. It must produce the unavailable state. +- Add desktop and phone positive cases that reach Ready and read real scoped + task data with injection enabled but prevented by the directive. +- Document runtime-path analytics exclusion and Retry in `docs/public/canvases.md`. + Describe proxy overrides as an operational limitation, not a missing release. + +## Out of scope + +CSP changes, error suppression, Cloudflare account mutations, release republishing, +and new browser UI are excluded. + +## Acceptance + +1. All successful runtime documents, assets, and bootstrap responses contain + both directives. Retained package bytes and digests remain unchanged. +2. Positive proxy cases reach Ready on desktop and phone. The negative control + still fails through the existing recoverable state. +3. Existing authentication, expired-token, denied-write, and event-stream + scenarios in the affected suites continue to pass. + +## Verification + +Run from the repository root. Install workspace dependencies before pnpm commands +if this worktree has no install. Managed E2E commands rebuild required artifacts. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/backend && go test ./internal/plugins/webapp -count=1) +(cd apps/web && pnpm exec vitest run components/plugins/web-app-frame.test.tsx) +(cd apps/web && pnpm e2e:run --project chromium tests/canvas/canvas-authenticated-proxy.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/canvas/mobile-canvas-authenticated-proxy.spec.ts) +git diff --check +``` + +## Files likely touched + +- `apps/backend/internal/plugins/webapp/runtime.go` +- `apps/backend/internal/plugins/webapp/runtime_test.go` +- `apps/web/e2e/tests/canvas/canvas-authenticated-proxy-fixture.ts` +- `apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts` +- `apps/web/e2e/tests/canvas/mobile-canvas-authenticated-proxy.spec.ts` +- `docs/public/canvases.md` + +## Dependencies + +None. + +## Risks + +The fixture demonstrates a conforming proxy, not every Cloudflare account setting. +Do not add `public` from the vendor example to capability-bound runtime responses. + +## Parallelism + +`sequential` + +## Inputs + +- Plugin runtime design: Runtime startup protocol and Same-origin transport and trust. +- Existing runtime header tests and authenticated-proxy fixture. +- [Plan evidence and scope](plan.md). + +## Results + +Implemented the shared runtime response policy and the directive-aware +authenticated proxy fixture. Runtime documents, packaged assets, host +bootstrap responses, and protocol responses now retain `no-store` and add +`no-transform`. The positive proxy cases exercise injection attempts that are +blocked by the origin response, while the negative control removes the +directive and preserves the existing recoverable startup failure. + +Recorded verification: + +- `cd apps/backend && go test ./internal/plugins/webapp -count=1` passed. +- `pnpm e2e:run --project chromium tests/canvas/canvas-authenticated-proxy.spec.ts` passed, 5 tests. +- `pnpm e2e:run --project mobile-chrome tests/canvas/mobile-canvas-authenticated-proxy.spec.ts` passed, 2 tests. +- `make -C apps/backend build` passed. diff --git a/docs/plans/canvas-runtime-entry-recovery/task-02-task-entry.md b/docs/plans/canvas-runtime-entry-recovery/task-02-task-entry.md new file mode 100644 index 0000000000..ded5387df6 --- /dev/null +++ b/docs/plans/canvas-runtime-entry-recovery/task-02-task-entry.md @@ -0,0 +1,173 @@ +--- +id: "02-task-entry" +title: "Reconcile canvases on task entry" +status: done +wave: 2 +depends_on: + - "01-runtime-responses" +plan: "plan.md" +requirements: + - REQ-CANVASES-AGENT-WEB-APPS-001 + - REQ-CANVASES-AGENT-WEB-APPS-006 +acceptance_criteria: + - AC-CANVASES-AGENT-WEB-APPS-001.2 + - AC-CANVASES-AGENT-WEB-APPS-001.13 + - AC-CANVASES-AGENT-WEB-APPS-001.14 + - AC-CANVASES-AGENT-WEB-APPS-001.15 + - AC-CANVASES-AGENT-WEB-APPS-001.16 + - AC-CANVASES-AGENT-WEB-APPS-006.1 +system_design: + - ../../specs/canvases/system-design/agent-authored-web-apps.md + - ../../specs/canvases/system-design/task-entry-presentation.md +--- + +# Task 02: Reconcile canvases on task entry + +## Summary + +Show unseen published task canvases from the HTTP inventory after task entry. +Keep live publication working and respect closed panels across same-tab reloads. + +## In scope + +- First add a failing hook test: empty lifecycle hints, one published HTTP + canvas, settled target layout, and no receipt must open the main editor panel. +- Enable the task-page inventory query on desktop and share it with activation. + Refresh after reconnect. Keep failed or stale requests distinct from valid data. +- Filter every candidate by current scope, task, workspace, status, and release. + Reuse existing pending-permission and active-release metadata rules. +- Wait for `isRestoringLayout` to clear and the target environment to match. + Recheck generations before insertion and never mark an abandoned attempt offered. +- Add tab-local presentation receipts using the PR-panel offered-marker pattern. + Include user/workspace/task/canvas identity, detect duplicated session-storage + namespaces, and keep a memory fallback for storage errors. + Record manual opening, existing restored panels, and successful automatic opening. +- Add a batch once, in creation-time/ID order, and focus the last new panel once. + Preserve existing panel placement and active state. +- On phones, confirm route entry before recording the candidate batch offered. + Back must return to the task without another automatic redirect. Keep all + other canvases available in the existing picker. +- Update public task-entry documentation and relevant historical plan links. + +## Out of scope + +Cross-device history, new navigation controls, new translated copy, automatic +workspace-canvas opening, and backend canvas persistence changes are excluded. + +## Acceptance + +1. Publication before navigation and publication during an open task both present + eligible canvases automatically. Reload and reconnect require no retained event. +2. Closed or existing panels never cause repeated focus changes. Wrong-task, + stale, disabled, draft-only, archived, removed, and invalid candidates do not open. +3. Desktop uses the main editor group after restoration. Phone navigation occurs + once and Back remains usable. Manual access remains available after dismissal. + +## ASCII UI preview + +UI-01 and UI-02, from the [full preview](plan.md#ascii-ui-preview): + +```text +Desktop: [Agent] [Plan] [Task coordinator *] [+] + Existing toolbar, then full-height canvas + +Phone: Existing header and host actions + Focused canvas route + Back -> task without redirect loop +``` + +These views implement `AC-CANVASES-AGENT-WEB-APPS-001.13` through `.16` and +`AC-CANVASES-AGENT-WEB-APPS-006.1`. Use existing host geometry and touch controls. + +## Verification + +Add focused tests for mixed eligible/ineligible inventory, duplicate hints, +multiple candidates, stale HTTP results, failed requests, restoration races, +manual reopening, reload receipts, reconnect, storage failures, and identity changes. +Tests must cover pending permission without mounting an executable iframe. +Rendered tests seed publication before navigation, then prove panel placement +and phone Back behavior. Preserve existing live-publication scenarios. + +Run from the repository root after Task 01 installs dependencies: + +```bash +(cd apps/web && pnpm exec vitest run components/task/dockview-canvas-activation.test.tsx hooks/domains/task/use-task-canvases.test.ts lib/canvas-presentation-storage.test.ts lib/canvas-presentation-storage.tab.test.ts components/settings/canvas-host-route.test.tsx components/task/dockview-add-panel-items.test.tsx) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm e2e:run --project chromium tests/canvas/plugin-canvas.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/canvas/mobile-plugin-canvas.spec.ts) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +## Files likely touched + +- `apps/web/components/task/task-page-content.tsx` +- `apps/web/components/task/task-layout.tsx` +- `apps/web/components/task/dockview-canvas-activation.ts` +- `apps/web/components/task/dockview-canvas-reconciliation.ts` (new) +- `apps/web/components/task/dockview-canvas-activation.test.tsx` +- `apps/web/hooks/domains/task/use-task-canvases.ts` +- `apps/web/hooks/domains/task/use-task-canvases.test.ts` +- `apps/web/lib/canvas-presentation-storage.ts` (new) +- `apps/web/lib/canvas-presentation-storage.test.ts` (new) +- `apps/web/lib/canvas-presentation-storage.tab.test.ts` (new) +- `apps/web/components/settings/canvas-host-route.tsx` +- `apps/web/e2e/tests/canvas/plugin-canvas.spec.ts` +- `apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts` +- `docs/public/canvases.md` + +## Dependencies + +Task 01 establishes runtime recovery and the workspace install. There is no +schema dependency, but this package runs sequentially. + +## Risks + +Old canvases have no receipts and can appear once on the first corrected visit. +Record only successful presentation. An interrupted navigation must not consume +an offer. New tabs intentionally have independent presentation history, including +when a browser duplicates a session-storage namespace. + +## Parallelism + +`sequential` + +## Inputs + +- Canvas design: Task-entry reconciliation and Mobile design contract. +- `useTaskCanvases`, `canvasLifecycleActivationDecision`, and `fallbackGroupPosition`. +- PR-panel receipt pattern in `apps/web/lib/local-storage.ts`. +- Existing task and canvas host mobile surfaces and E2E fixtures. + +## Results + +Implemented authoritative task inventory reconciliation for desktop and phone +entry, tab-local presentation receipts, delayed main-group placement, manual +presentation recording, and one-way phone navigation. Existing panels retain +their placement and active state. Failed inventories remain distinct from an +authoritative empty inventory, and reconnect/lifecycle revisions trigger a +fresh inventory request. Pending inventory requests are scoped by lifecycle +generation and authenticated identity, so an invalidated request cannot satisfy +a later entry. Desktop insertion rechecks the current task environment and +layout ownership after asynchronous hint lookups, so a canvas cannot enter a +previous task's settled layout. Hydrated task-session metadata supplies the +environment during the interval before the active-session mapping is available. +Hint lookups are generation-scoped, and duplicated session-storage namespaces +rotate their tab identity before receipts are read. The shared discoverable +canvas status predicate now serves both the task panel menu and reconciliation. + +Recorded verification: + +- Focused Vitest suite passed: 6 files, 61 tests, including deferred + reconnect/lifecycle invalidation, authenticated identity, mixed inventory, + delayed restoration, ownership recheck, batch focus, restored-panel receipts, + duplicated-tab receipt isolation, and reload persistence. +- `pnpm run lint` passed with zero warnings. +- `pnpm run i18n:check` passed. +- `pnpm run typecheck` passed. +- `pnpm e2e:run --project chromium tests/canvas/plugin-canvas.spec.ts` passed, 6 tests, + including the inventory/restoration boundary before the no-reopen assertion. +- `pnpm e2e:run --project mobile-chrome tests/canvas/mobile-plugin-canvas.spec.ts` passed, 4 tests. +- `pnpm --filter @kandev/web build:vite` passed. +- `python3 scripts/list-docs.py validate` and `python3 scripts/lint-spec-files.py --all` passed. diff --git a/docs/public/canvases.md b/docs/public/canvases.md index 8d979d00d7..20064302d3 100644 --- a/docs/public/canvases.md +++ b/docs/public/canvases.md @@ -107,6 +107,18 @@ startup; it does not certify application business health. Kandev calculates effective access from the package declaration, instance grant, trusted task or workspace scope, and current caller authorization. A release receives only the intersection of those permissions. See [Security and trust](security.md#isolated-web-applications) for the security boundary. +### Return to a task after publication + +When you return to a task, Kandev checks the current task canvas inventory. It can show an eligible canvas published while the task page was closed, reloaded, or disconnected. Kandev remembers a canvas that was already presented in the current browser tab, including a canvas that you closed. Use the existing task panel or phone canvas picker to open it again. + +On a phone, Kandev opens one new canvas route at a time. Use Back to return to the task. The route does not open again until a new browser tab offers it. + +### Reverse proxy requirements + +Keep the runtime document, packaged assets, and host bootstrap responses unmodified. The responses include `Cache-Control: no-store, no-transform`. Exclude the runtime path `/api/v1/plugins/web-apps/runtime/` from analytics injection, HTML rewriting, and similar response transforms. Keep the existing content security policy and capability checks. + +If a proxy strips or ignores `no-transform`, canvas startup can fail with **Canvas unavailable** and **Try again**. Restore the proxy rule, then select **Try again**. Republish only when the release itself changed. + ## Promote a task canvas Only a user can promote a task canvas. An agent cannot promote, demote, grant permissions, approve a release, archive, restore, roll back, or remove a canvas. diff --git a/docs/specs/canvases/requirements/agent-authored-web-apps.md b/docs/specs/canvases/requirements/agent-authored-web-apps.md index 505f0e0f8e..39779ac9fb 100644 --- a/docs/specs/canvases/requirements/agent-authored-web-apps.md +++ b/docs/specs/canvases/requirements/agent-authored-web-apps.md @@ -6,7 +6,7 @@ system: canvases owners: - canvases created: 2026-08-26 -last_updated: 2026-09-19 +last_updated: 2026-09-21 --- # Agent-authored web-app canvases Requirements @@ -77,6 +77,22 @@ the task, so that the interface matches the work. - **AC-CANVASES-AGENT-WEB-APPS-001.12:** Authoring instructions shall distinguish an active release, a release awaiting permission review, and an unsuccessful publish. Local files or a successful build shall not imply publication. +- **AC-CANVASES-AGENT-WEB-APPS-001.13:** When a user returns after publication, + the task shall show eligible task canvases not previously presented in that + browser tab. This shall also work after reload or a missed publication event. + Eligible canvases have an active valid release or await permission review. + Draft-only, archived, disabled, removed, invalid, foreign-task, and workspace + canvases shall not open automatically. +- **AC-CANVASES-AGENT-WEB-APPS-001.14:** Desktop shall add new canvas panels to + the main editor group and focus one new panel. Existing panels shall retain + their placement. Repeated discovery shall not duplicate panels or steal focus. +- **AC-CANVASES-AGENT-WEB-APPS-001.15:** Closing a presented canvas shall prevent + automatic reopening in that browser tab, including after reload or publication + of another release. Manual reopening shall remain available. +- **AC-CANVASES-AGENT-WEB-APPS-001.16:** On phones, automatic presentation shall + open one focused canvas route. Returning to the task shall not redirect to + that canvas again. Other eligible canvases shall remain accessible through + the existing picker. ### REQ-CANVASES-AGENT-WEB-APPS-002: Durable source and releases @@ -324,6 +340,7 @@ including missing references, lookup failures, and passthrough exclusions. ## Implementation plans +- [Canvas runtime and task-entry recovery](../../../plans/canvas-runtime-entry-recovery/plan.md) - [Direct canvas creation and saved prompt](../../../plans/canvas-direct-creation/plan.md) ## Out of scope diff --git a/docs/specs/canvases/system-design/agent-authored-web-apps.md b/docs/specs/canvases/system-design/agent-authored-web-apps.md index b40046d3ea..8303031951 100644 --- a/docs/specs/canvases/system-design/agent-authored-web-apps.md +++ b/docs/specs/canvases/system-design/agent-authored-web-apps.md @@ -6,7 +6,7 @@ system: canvases owners: - canvases created: 2026-08-26 -last_updated: 2026-09-19 +last_updated: 2026-09-21 requirements: - REQ-CANVASES-AGENT-WEB-APPS-001 - REQ-CANVASES-AGENT-WEB-APPS-002 @@ -568,6 +568,9 @@ state, Edit, Promote for task canvases, Open full canvas, and an overflow menu. Release, permission, promotion, and disabled controls have pointer and keyboard help. The mobile action drawer shows equivalent descriptions without hover. +Task-entry and reconnect discovery follow the +[task-entry presentation design](task-entry-presentation.md). + The Dockview canvas renderer provides a full-height flex boundary around the persistent portal. The shared host and iframe then fill the area below host chrome. This change does not alter the direct route or phone viewport formula. diff --git a/docs/specs/canvases/system-design/task-entry-presentation.md b/docs/specs/canvases/system-design/task-entry-presentation.md new file mode 100644 index 0000000000..6e0ec76525 --- /dev/null +++ b/docs/specs/canvases/system-design/task-entry-presentation.md @@ -0,0 +1,61 @@ +--- +id: canvases-task-entry-presentation-design +title: Task-entry canvas presentation +status: draft +system: canvases +owners: + - canvases +created: 2026-09-21 +last_updated: 2026-09-21 +requirements: + - REQ-CANVASES-AGENT-WEB-APPS-001 + - REQ-CANVASES-AGENT-WEB-APPS-006 +--- + +# Task-entry canvas presentation + +Lifecycle hints accelerate discovery but cannot establish that discovery is +complete. They disappear on reload and can expire from the bounded hint list. +`useTaskCanvases` supplies the authorized HTTP inventory on task entry and +lifecycle refresh. Enable the existing task-page query for desktop as well as +phones, and pass its result to `useTaskCanvasLifecycleActivation`. +Refresh the inventory after WebSocket reconnection. Do not create a polling loop. + +Filter each candidate by task scope, exact task/workspace identity, and current +release eligibility. Reuse the metadata rules in +`canvasLifecycleActivationDecision`, including first-release permission review. +A failed list request is not an empty authoritative inventory. Never record +presentation or remove saved panels because a fetch failed. + +On desktop, wait for the current task environment and layout restoration to +settle. Recheck task, environment, and request generation before insertion. +Use the main editor group resolved by the existing layout helpers. Add all +eligible unseen canvases in creation-time order, with canvas ID as a tie-breaker. +Focus only the last added panel once per reconciliation batch. An existing +panel counts as presented without changing its placement or active state. + +Keep presentation receipts in host-owned `sessionStorage`, keyed by user, +workspace, task, canvas, and a stable tab identity. Reuse the PR-panel +offered-marker pattern from `lib/local-storage.ts`. Detect a duplicated +`sessionStorage` namespace through the live page owner in `localStorage` and +rotate the tab identity before reading receipts. Store receipts only after +successful insertion or confirmed mobile route entry. Manual opening and +restored panels also record receipts. A receipt suppresses reopening after +close and survives reload in the same tab. A new tab can offer the canvas +again. Release IDs are not part of receipt identity. No database migration or +cross-device preference is needed. Storage errors use a tab-memory fallback +and never block manual access. + +On phones, select the same deterministic last candidate and navigate once to +`canvasHref`. Record the other eligible candidates as offered only after that +navigation succeeds. The existing picker exposes them without repeated redirects +on Back. Reuse `task-layout.tsx` and `CanvasHostFrame` for the focused route, +safe areas, scrolling, and touch controls. Drafts and permission-review hosts +must retain their existing distinction. + +The [recovery work package](../../../plans/canvas-runtime-entry-recovery/plan.md) +owns implementation and regression coverage for this reconciliation. + +This design extends the [canvas lifecycle design](agent-authored-web-apps.md). +It implements first-presentation criteria under requirement 001 and the focused +phone route under requirement 006. Canvas metadata remains authoritative. diff --git a/docs/specs/plugins/requirements/isolated-web-app-contributions.md b/docs/specs/plugins/requirements/isolated-web-app-contributions.md index 3597d2ff2b..c45d1f0536 100644 --- a/docs/specs/plugins/requirements/isolated-web-app-contributions.md +++ b/docs/specs/plugins/requirements/isolated-web-app-contributions.md @@ -6,7 +6,7 @@ system: plugins owners: - kandev created: 2026-08-26 -last_updated: 2026-09-19 +last_updated: 2026-09-21 --- # Plugin web-application contributions Requirements @@ -336,6 +336,15 @@ reverse proxy without a canvas-specific authentication bypass. - **AC-PLUGINS-ISOLATED-WEB-APPS-013.7:** Cookie-authenticated relative writes and event subscriptions shall work through the same public origin. External origins shall not gain cookie forwarding, CORS access, or framing permission. +- **AC-PLUGINS-ISOLATED-WEB-APPS-013.8:** Runtime document, packaged asset, and + host bootstrap responses shall prohibit intermediary transformation while + retaining their no-store policy. A proxy that honors this prohibition shall + deliver a working retained release without injected analytics or republishing. + Startup errors and CSP restrictions shall retain their existing behavior. + +## Implementation plans + +- [Canvas runtime and task-entry recovery](../../../plans/canvas-runtime-entry-recovery/plan.md) ## Out of scope diff --git a/docs/specs/plugins/system-design/isolated-web-app-contributions.md b/docs/specs/plugins/system-design/isolated-web-app-contributions.md index dc5a233339..911df1bdcc 100644 --- a/docs/specs/plugins/system-design/isolated-web-app-contributions.md +++ b/docs/specs/plugins/system-design/isolated-web-app-contributions.md @@ -6,7 +6,7 @@ system: plugins owners: - kandev created: 2026-08-26 -last_updated: 2026-09-19 +last_updated: 2026-09-21 requirements: - REQ-PLUGINS-ISOLATED-WEB-APPS-001 - REQ-PLUGINS-ISOLATED-WEB-APPS-002 @@ -623,8 +623,9 @@ Operators must exclude runtime responses from Cloudflare RUM and other injected scripts that violate policy. Do not allow remote analytics scripts or suppress document errors to conceal this problem. -The UI layout, translated states, Retry action, and phone navigation stay the -same. Test Ready, data, a permitted write, and an event through a real local +See [response preservation](runtime-response-preservation.md). + +UI behavior stays unchanged. Test Ready, data, a permitted write, and an event through a real local cookie gate. Test missing cookies, reauthentication and Retry, invalid tokens, and unchanged package bytes. Record desktop and phone results separately. diff --git a/docs/specs/plugins/system-design/runtime-response-preservation.md b/docs/specs/plugins/system-design/runtime-response-preservation.md new file mode 100644 index 0000000000..5b8545cb8b --- /dev/null +++ b/docs/specs/plugins/system-design/runtime-response-preservation.md @@ -0,0 +1,37 @@ +--- +id: plugins-runtime-response-preservation-design +title: Runtime response preservation +status: draft +system: plugins +owners: + - kandev +created: 2026-09-21 +last_updated: 2026-09-21 +requirements: + - REQ-PLUGINS-ISOLATED-WEB-APPS-012 + - REQ-PLUGINS-ISOLATED-WEB-APPS-013 +--- + +# Runtime response preservation + +This design extends [same-origin runtime transport](isolated-web-app-contributions.md#same-origin-transport-and-trust). +Requirement 013 owns proxy-compatible response delivery. Requirement 012 retains +startup failure detection. This change adds no trust-boundary exception. + +`setRuntimeHeaders` shall send `Cache-Control: no-store, no-transform` for +runtime documents, assets, and the host bootstrap. The directive applies to +served representations, including retained releases. Stored package bytes and +digests remain unchanged. Do not add `public` or weaken capability validation. +Cloudflare documents `no-transform` as preventing automatic beacon injection +([Web Analytics FAQ](https://developers.cloudflare.com/web-analytics/faq/)). +An explicit proxy rule can still require operator correction if the proxy +ignores origin directives. Kandev does not change external proxy settings. + +Extend the cookie-authenticated HTTPS test proxy with optional HTML injection +that honors the response directive. Inject only into runtime HTML and preserve +streaming for events. A control that removes the directive must reproduce the +blocked-script startup failure. With the directive present, the real host must +reach Ready on desktop and phone. This fixture proves the HTTP contract, not +the configuration of a particular Cloudflare account. + +See the [recovery work package](../../../plans/canvas-runtime-entry-recovery/plan.md).