Skip to content

feat(console): installable desktop PWA with an offline app shell and a push-ready Service Worker - #29326

Merged
os-zhuang merged 10 commits into
mainfrom
claude/project-thread-detgab
Nov 2, 2026
Merged

os-zhuang merged 10 commits into
mainfrom
claude/project-thread-detgab

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steedos · project thread
Fixes #29155
Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:Console 生产构建现在在根目录产出 service-worker.js,并在 Console 自己的挂载路径(/_console/)下注册。它缓存应用外壳(HTML 入口 + 入口脚本),离线冷启动显示 Console 而不是浏览器错误页;/api/ 请求一律直连网络,不读不写缓存。Worker 带 push / notificationclick 处理器(点击只打开 /_console/ 内的路由),但不订阅推送。CLI 文档的 CSP 增加 manifest-src 'self' blob:,让带品牌的安装清单能加载,Console 因此可安装。另有三处构建配置:tsconfig.node.json 加一行 include、turbo.json 三个任务各加一个输入、.github/workflows/ci.yml 的 core: 路径过滤加一行 ui/packages/mobile/src/serviceWorkerSource.ts(只加过滤行,不改任何 job、step 或触发器)。
  • 为什么改:桌面用户在浏览器里用 Console(产品决定不做原生桌面端)。此前没有任何代码注册 Service Worker,装成应用后离线打开是错误页,也没有 worker 承接 Web Push;实测 CLI 的 CSP 还拒绝了 Console 注入的 blob 清单,线上 Console 根本不可安装。Console 的 vite.config.ts 从源码读取 mobile 包的 worker 生成器,所以 turbo 必须把这个文件算进 build/test/type-check 的缓存输入;check:ci-filter-parity 要求每个这样的 turbo 输入在 ci.yml 的 core: 过滤里有一行,否则只改这个文件的 PR 不会触发 Build Core / Test Core。改用包的子路径导出(不碰 ci.yml)已实测不可行:Test UI 和 Live E2E 两个 CI 作业不构建 mobile 的 dist,vitest 启动即失败。
  • 风险与回滚:新构建上线后的下一次导航即运行新构建(HTML 走网络优先,worker 用 skipWaiting 接管并删除旧构建缓存),旧外壳不会钉住用户。在原生壳(getNativeHost())里不注册 worker,并注销之前注册过的。CSP 只多了 manifest 一条,blob URL 只能由已通过 script-src 的脚本创建。ci.yml 那一行只让更多 PR 触发核心作业,不会少跑。回滚即 revert 本 PR。
  • 先看:ui/packages/mobile/src/serviceWorkerSource.ts 的 appShellSource()(bypasses() 与 fetch 分支),然后 ui/apps/console/src/main.tsx 的 syncServiceWorker(),再看 packages/cli/src/utils/console.ts 的 manifest-src 一行和 ci.yml 的新增过滤行。
  • 两分钟验证:pnpm --dir ui exec playwright test e2e/console-service-worker.spec.ts --project=chromium(需先以 VITE_BASE_PATH=/console/ 构建 Console),两条用例通过:离线刷新显示登录页的「无法连接服务器」面板,模拟部署后一次导航即换到新构建、旧缓存被删。
  • 需要你做的:本 PR 触及受管文件 .github/workflows/ci.yml,需要维护者批准并合并;席位不会自行翻 ready 或入队。

What changes

File Change
ui/packages/mobile/src/serviceWorkerSource.ts getServiceWorkerSource gains strategy: 'offline-first' | 'app-shell', build and notifications. The default 'offline-first' output is byte-identical to main (checked for the default and a fully customised options object). 'app-shell' needs build; build under 'offline-first' is refused, since that worker would ignore it.
ui/packages/mobile/src/serviceWorker.ts cacheLoadedResources(worker): posts the page's resource-timing URLs to an 'app-shell' worker, which caches only same-origin, in-scope, non-API files.
ui/packages/mobile/src/index.ts Exports cacheLoadedResources, SERVICE_WORKER_CACHE_URLS_MESSAGE, ServiceWorkerStrategy.
ui/apps/console/vite.config.ts emitServiceWorker() emits service-worker.js at the build root with the build id and the precache list.
ui/apps/console/src/main.tsx syncServiceWorker() registers the worker under the Console's mount in production builds, or unregisters it in the native shell. The generated manifest's start_url, scope and icon are absolute URLs.
packages/cli/src/utils/console.ts The Console document's policy adds manifest-src 'self' blob:; the comment that said default-src covered the manifest is corrected.
packages/cli/src/utils/console.content-security-policy.test.ts Pins the directive.
ui/packages/mobile/src/__tests__/serviceWorkerSource.test.ts 25 cases running the generated worker against a fake worker global.
ui/e2e/console-service-worker.spec.ts Offline reload and update pickup on the production build.
ui/apps/console/tsconfig.node.json Lists serviceWorkerSource.ts, which vite.config.ts imports from source (TS6307 otherwise).
turbo.json Hashes ui/packages/mobile/src/serviceWorkerSource.ts in the build, test and type-check inputs, so a generator change never replays a stale cache.
.github/workflows/ci.yml One row in the core: paths filter for the same file, which check:ci-filter-parity requires for every turbo build and test input. No job, step or trigger changes.
ui/packages/mobile/README.md, .changeset/29155-console-desktop-pwa.md Docs and changeset.

Surface amendments (seat rulings on the card). The claimed surface was the mobile worker files, main.tsx, vite.config.ts, the manifest, a Playwright spec and a changeset. Four amendments widened it: the CLI policy and its pin (packages/cli/src/utils/console.ts, console.content-security-policy.test.ts, comment 6573999866), the tsconfig.node.json include (comment 6574807547), the three turbo.json input entries (comment 6575431847), and the one ci.yml filter row (comment 6575983994). Importing the generator through a dependency-free subpath export of @object-ui/mobile instead was measured and refused: Test UI and Live E2E UI build no @object-ui dist, and the root vitest config loads the Console's vite.config.ts, so every shard would fail at startup with "Failed to initialize projects … Cannot find module …/@object-ui/mobile/dist/serviceWorkerSource.js". ui/apps/console/public/manifest.json is unchanged.

The 'app-shell' worker.

  • Scope. Served from the Console's base directory, so its scope is /_console/, never /. Every request outside the scope, and every request under /api/, is network-only: the worker returns before respondWith, so it never answers, reads or writes the cache for it, and no cookie is stored.
  • Cache versioning by build. The cache is objectui-console::SCOPE_PATH::BUILD. Activation deletes every cache with the same objectui-console::SCOPE_PATH:: prefix except its own, and nothing else on the origin. The build id is the console build name (VITE_APP_VERSION or the git sha) plus a 12-hex digest of every emitted file name and the HTML entry; hashed names change with their bytes, so any changed chunk gives a byte-different worker.
  • Precache choice: Vite's emitted entry assets, plus cache-on-fetch. The precache is the entry chunk, its static-import closure and their CSS (101 files on this build), plus the scope root (the HTML entry). Lazy chunks are cached on fetch, and after registration the page posts the files it loaded before the worker controlled it (cacheLoadedResources), so the first visit's route chunks are there for an offline start.
  • Strategies. Navigations: network-first, the precached entry offline. Hashed assets/ files: cache-first. Other in-scope files (favicon, manifest): network-first with the cached copy offline.
  • Update flow: skipWaiting + clients.claim, no reload prompt. The HTML entry is network-first, so the next navigation after a deploy loads the new document and its new hashed chunks even before the new worker activates. The new worker installs (precaching the new build), activates at once and deletes the old build's cache. No shell can pin a user.
  • Notifications. push shows { title, body?, route?, tag? } with route in the notification's data. notificationclick resolves route against the scope: it must pass the same in-app check as isInAppRoute (one leading /, no //, no backslash, no control character), and the URL-normalised result must still start with the scope path. A refused route only closes the notification. An open, controlled Console window is navigated and focused; otherwise a window is opened.

Why

Desktop users run the Console in a browser. Nothing registered a Service Worker, so an installed Console showed a browser error page when started offline, and Web Push had no worker to reach. The served Console was also not installable: measured before this change, Chromium refused the Console's branded blob: manifest under the CLI's default-src 'self' (see the security header note below).

Security-header widening (manifest-src 'self' blob:). Measured with CDP Page.getInstallabilityErrors / Page.getAppManifest in full Chromium 1243 (new headless, persistent profile) on a default-base build served the way the CLI serves it (an injected base tag, base href="/_console/", nonce-stamped scripts, the policy's exact directive list):

Policy Console message Installability errors Manifest
main (no manifest-src) Loading a manifest from 'blob:…' violates … "default-src 'self'". Note that 'manifest-src' was not explicitly set manifest-parsing-or-network-error, no-manifest none
with manifest-src 'self' blob: none none name ObjectOS, start_url and scope http://127.0.0.1:PORT/_console/, icon …/_console/favicon.svg

A blob: URL can only be minted by a script the page already runs, which script-src governs; no other directive changes. A screenshot of this check is on the card thread.

Tests

Measured on the head of this PR (b5658de9, main at a19759ac merged in):

  • node scripts/pm/dispatch-gates.mjs --ran FILE: 98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN, every exit code 0. The 33 families the ci.yml row adds (workflow lints, filter parity, step-name checks) were run at b0ef42a0; check:ci-filter-parity, check:type-check-debt, check-sdui-manifest and pnpm --dir ui exec vitest run scripts/ (169 files, 4903 tests) were re-run after the merge.
  • @object-ui/mobile vitest: 115 passed (25 new in serviceWorkerSource.test.ts).
  • @objectstack/cli content-security-policy suite: 50 passed.
  • turbo run type-check for @object-ui/mobile and @object-ui/console with their dependencies: 72 of 72 tasks green; Console tsc + vite build green.
  • Playwright, full chromium project on the production Console build (at e4e73277, before the config rows): 66 passed, 2 skipped (both skips pre-existing), including the two new cases in console-service-worker.spec.ts.

Ablations (each restored byte-identical afterwards):

Removed Result
the apiPrefix bypass in bypasses() 2 tests red (including the root-mount case, where the scope bypass cannot cover /api/)
the scope-prefix check on the click route 2 tests red
activation keeps only caches with this scope's prefix (made to delete every cache) 1 test red

Acceptance notes

  • Offline reload after one load shows the Console shell, and data calls fail visibly as offline: console-service-worker.spec.ts case 1. The offline document is fromServiceWorker(), the sign-in screen's auth-server-unreachable panel renders, a direct /api/v1/auth/get-session fetch rejects with TypeError, and no /api/ URL is in the worker's cache.
  • A new build is picked up within one navigation: case 2. A server switches to a "next" build (marked document, worker with a new build id); one navigation shows the marked document, and the caches end as exactly one, the new build's.
  • Unit tests cover cache versioning, the API bypass, and the handlers, including an escaping route: 25 cases, ablations in Tests above.
  • Chrome installability check passes on the served Console: the table above.
  • The e2e spec serves the build itself, the way the CLI does (with a base tag), not through vite preview: without a base tag, the router's basename is / and the app moves to /login, outside the worker's scope.
  • The push payload shape (title, body, route, tag) is provisional; the delivery contract is Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041's. No push subscription, device-registry call or push vendor is added here.
  • A getNativeHost() bridge that lands after boot leaves that page load's worker registered; the next load, which sees the bridge at boot, unregisters it.

Verification

  • Offline start and update pickup: the two Playwright cases above, on a production build served with the CLI's base tag.
  • Installability: CDP before/after in the security-header table above; the report and a screenshot of the served Console are on the card thread.
  • Default worker unchanged: getServiceWorkerSource() with no options, and with a fully customised 'offline-first' options object, emits the same bytes as main.
  • Native shell: syncServiceWorker() returns after unregistering when getNativeHost() is set; it never registers there.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (not touched)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (CI runs it)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (not touched)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…API and push handlers

getServiceWorkerSource gains strategy 'app-shell' (build-versioned cache,
network-only API, network-first HTML entry, cache-first hashed assets) and
notifications (push and notificationclick, in-app routes only). The default
'offline-first' output is byte-identical. The Console's vite config emits the
worker at the build root, named after the build.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt
…and serve the e2e like the CLI

The Console links its branded web app manifest as a blob: URL, which
default-src 'self' refused, so the served Console was not installable.
The Playwright service-worker spec serves the build with the CLI's base
tag, so routes stay inside the worker's scope.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt
…-detgab

# Conflicts:
#	ui/packages/mobile/README.md
…e's mount

Production builds register service-worker.js with the Console's own scope,
and post the files the first load fetched so an offline start finds them.
In the native shell no worker is registered and an earlier one is removed.
The generated manifest's start_url, scope and icon are absolute URLs, since
a Blob URL is no base to resolve ./ against.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt
vite.config.ts imports the dependency-free generator from source, and the
composite tsconfig.node.json refuses a file outside its include (TS6307).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt
… inputs

The Console's vite config reads ui/packages/mobile/src/serviceWorkerSource.ts
from outside the package; without it in the inputs, turbo replays a stale
verdict when the generator changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt
turbo.json now hashes ui/packages/mobile/src/serviceWorkerSource.ts as a
build and test input, and check:ci-filter-parity requires every such input
to start the core jobs on a PR. Filter row only; no job, step or trigger
change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt
@github-actions github-actions Bot added size/xl ci/cd documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 35 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2f7977cd2f25d878f31942dae6e229af9ff316d3 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from cee6c24c77936486901feadeb276f02537c67cd2 — the merge of head 55e3e5e501fa69124b14cfa9e536b78fd5845afd into base 2f7977cd2f25d878f31942dae6e229af9ff316d3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cee6c24c77936486901feadeb276f02537c67cd2 && git checkout cee6c24c77936486901feadeb276f02537c67cd2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2f7977cd2f25d878f31942dae6e229af9ff316d3 55e3e5e501fa69124b14cfa9e536b78fd5845afd && git checkout -B drift-repro 2f7977cd2f25d878f31942dae6e229af9ff316d3 && git merge --no-ff 55e3e5e501fa69124b14cfa9e536b78fd5845afd

node scripts/docs-audit/affected-docs.mjs --json 2f7977cd2f25d878f31942dae6e229af9ff316d3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

hotlong commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Consumer acceptance (mobile shell side), head b5658de9

Verdict: accepted with two should-fixes. Not mergeable until CI on the head is green. At review time, Build & E2E UI, the only job that runs the new Playwright spec, was still in progress. The PR touches .github/workflows/ci.yml, so it is Tier H and a human merges it.

All five card items are met:

  • the app-shell precache drops the old caches;
  • no API responses are cached: /api/, cross-origin, out-of-scope and non-GET requests bypass the worker, and navigations are never stored;
  • the update flow uses a network-first document with skipWaiting and clients.claim;
  • push and notificationclick resolve route against the scope, which matches the shell's consoleUrl;
  • the console is installable with default branding.

Nothing escapes the Console's same-origin scope.

Should-fix (this PR)

1. No test covers the native-shell guard. syncServiceWorker() skips registration and unregisters any earlier worker when getNativeHost() is set (ui/apps/console/src/main.tsx:229-233). That one line protects the mobile shell's offline fallback, and the Verification section claims it, but no test exercises it. Suggested Playwright cases:

  • an addInitScript defines window.ObjectStackNative, then the test asserts that getRegistrations() is empty;
  • register a worker first, reload with the bridge defined, and assert that the worker is gone.

2. Bridge detection is a single read and can lose a race on Android.

  • react-native-webview injects the bridge at onPageStarted, which can land after the Console's scripts. The shell's docs/HYBRID-SHELL.md (Known limits) asks the Console not to rely on one read.
  • If the read loses, the worker registers inside the Android WebView. On the next offline cold start it serves the cached shell, so the WebView's onError never fires. The user then sees the Console's unreachable panel instead of the shell's Retry / Change server screen.
  • iOS is unaffected: without App-Bound Domains, WKWebView has no navigator.serviceWorker.
  • Cheap fix: re-check getNativeHost() after whenActive(registration) and unregister if it is now set.

Follow-ups (not this PR)


Generated by Claude Code

… the worker docs

The new serviceWorkerSource test carried an eslint-disable for no-new-func
that the mobile lint config never reports, and the README's app-shell
snippet read process.env, which the doc snippet type check compiles
without node types. Both turned Lint UI red.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt

Copy link
Copy Markdown
Collaborator

director#1 · zhuangjianguo · session_01QsR2jV5sBSzCYNEDgpYLJw

Contract-review-claim: session_01QsR2jV5sBSzCYNEDgpYLJw · head 55e3e5e · 2026-11-02T12:55Z


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 55e3e5e501fa69124b14cfa9e536b78fd5845afd
Local-runs: none

Face: declaration, for the diff outside the governed hit. The PR body and the changeset declare Clause-②: yes (widening) (claim amended 6573560502). The one governed path, .github/workflows/ci.yml, owes its own CONTRACT_REVIEW_TIER record from the director seat, which has been asked separately. This record covers the rest of the diff, as a standalone PR of those files would be reviewed.

① Derived judgments

  • @object-ui/mobile widens, and nothing narrows.

    • ServiceWorkerSourceOptions gains the optional strategy ('offline-first' | 'app-shell'), build and notifications.
    • The entry gains cacheLoadedResources, SERVICE_WORKER_CACHE_URLS_MESSAGE and the type ServiceWorkerStrategy.
    • The default 'offline-first' output is byte-identical to main, pinned by the generator suite, so every existing caller gets the same worker.

    Correct, and a widening.

  • API requests are network-only, as ruled. The 'app-shell' worker's fetch handler returns before respondWith for:

    • any non-GET request;
    • a URL under the API prefix;
    • another origin, or anything outside the worker's scope (/_console/).

    Such a request is never answered from, read from or written to the cache. Navigations are network-first and fall back to the cached shell. Hashed assets are cache-first.

  • No stale shell. The cache name carries the build id, and activation deletes only this scope's older caches. Install calls skipWaiting(), so the next navigation after a deploy runs the new build. The e2e spec pins both offline reload and the update pickup.

  • No worker in native shell mode, as ruled. syncServiceWorker() in main.tsx checks getNativeHost() first, and inside the mobile shell it unregisters a worker this Console registered earlier. It registers only in production builds, under the Console's own mount. It builds on feat(mobile): native shell mode — useNativeHost, typed bridge calls, and the Console wired to the mobile shell #29140's shell detection, now on main.

  • The CSP widening is narrow. packages/cli/src/utils/console.ts adds manifest-src 'self' blob: (option A, amendment 6573999866), pinned by its test. A blob: manifest can only be minted by a script that already passed script-src, and the stale comment that said default-src covered the manifest is corrected.

  • Notifications only route inside the app. notificationclick opens or navigates only to a route that resolves inside the Console's scope. The worker does not subscribe to push.

  • The build plumbing is what the gates require. These are the tsconfig.node.json include (6574807547) and the three turbo.json inputs (6575431847). Each is one line of the pattern already in those files for ui/scripts/vite-*.ts.

  • No new npm dependency. The lockfile is untouched.

② Semver level

The changeset entries are patch for @object-ui/mobile, @object-ui/console and @objectstack/cli, with Metadata-protocol: none. AGENTS.md Post-Task Checklist step 3: a non-protocol change, widening or breaking, ships patch. Consistent.

③ Boundary flags

  • Tier H. The ci.yml row (6575983994) makes the whole PR governed. The maintainer's decision is pending on the seat thread, and the seat will not flip ready or enqueue it before an authorized approval or a human merge.
  • Lint UI fixed. The red on b5658de9 was fixed inside the surface: an unused ESLint directive, and a doc snippet that read process. It is green on this head.
  • The PR author is objectstack-fleet[bot], so there is no off-relay writer to name.

Implemented-by: claude/project-thread-detgab
Reviewed-by: session_01QsR2jR7ZFkpqmm4U8S1f9y

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

director#1 · zhuangjianguo · session_01QsR2jV5sBSzCYNEDgpYLJw

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 55e3e5e501fa69124b14cfa9e536b78fd5845afd
Local-runs: none
Scope: .github/workflows/ci.yml + turbo.json (the three $TURBO_ROOT$/ui/packages/mobile/src/serviceWorkerSource.ts inputs), scripts/check-ci-filter-parity.mjs (the build-inputs and test-inputs subjects, BUILD_FILTER/TEST_REMEDY_FILTER = 'core'), ui/apps/console/vite.config.ts (emitServiceWorker, the source import), ui/apps/console/tsconfig.node.json, packages/cli/src/utils/console.ts (manifest-src), .changeset/29155-console-desktop-pwa.md, card #29155 and its amendments 6573999866 / 6574807547 / 6575431847 / 6575983994, AGENTS.md checklist item on declaration lines and levels

Read-only review of the committed tree at the head; the check-runs on it (56 success, 7 skipped, 0 failure) are the gate verdicts. The hits face is the one core: paths-filter row in ci.yml; the rest of the diff is the lane seat's TIER_DEFAULT ACCEPT and is read here only where the row makes a claim about it.

① Derived judgments

  • The row widens scheduling only. ci.yml L372–375 adds the literal ui/packages/mobile/src/serviceWorkerSource.ts to the core: filter with a three-line comment naming the reason and the card. No job, step, trigger, if: or output changes. The effect is that a diff confined to that file now starts Build Core and Test Core on the PR; nothing that ran before runs less. Right.
  • The row is derived, not picked. turbo.json declares the file as a $TURBO_ROOT$ input of the generic build, test and type-check tasks. check-ci-filter-parity's third subject (Build Core against turbo.json's build inputs, BUILD_FILTER = 'core') and its test-inputs subject (TEST_REMEDY_FILTER = 'core') each require a core: row for such an input, under the pure-string coverage rule: a literal path, or a <prefix>/** of its leading literal segments. The row is the exact literal, so it is covered under both subjects, and the gate's own doc (L219) says a test input belongs in core:, never crosspkg:. Right.
  • Why the input exists is real on the tree. vite.config.ts L31 imports the generator from source (../../packages/mobile/src/serviceWorkerSource.ts), and emitServiceWorker (L767–810) runs it at generateBundle, so the console build's output does depend on that file; the root ui vitest config loads the console's vite.config.ts, so every package's test program reads it too. Hashing it in turbo is right, and once hashed the parity row is mandatory. Right.
  • UI jobs unaffected. The ui: filter already carries ui/**, turbo.json and .github/workflows/**, so the five * UI jobs' scheduling does not move with this row. Right.
  • Public surface (seat's tier, noted for ②): @object-ui/mobile adds strategy/build/notifications options, cacheLoadedResources, SERVICE_WORKER_CACHE_URLS_MESSAGE, ServiceWorkerStrategy; @objectstack/cli adds manifest-src 'self' blob: to the Console policy. Both are widenings of a published surface, as declared. No security-relevant finding on the hits face; the CSP widening is one directive whose blob: can only be minted by a script script-src already admits, and the dev's CDP before/after is on the card thread. Nothing withheld from this record.

② Semver level

  • Changeset 29155-console-desktop-pwa.md: @object-ui/mobile patch, @object-ui/console patch, @objectstack/cli patch; Clause-②: yes (widening) and Metadata-protocol: none at line start in the changeset and the PR body. Under the repo's level rule (AGENTS.md: a non-protocol change, widening or breaking, ships patch; none takes patch), patch is the required grade, and check-changeset-no-major would refuse a minor here. Right.
  • turbo.json and ci.yml publish nothing; the tsconfig.node.json include is build-only. No entry owed for them. Right.

③ Boundary flags

  • Tier H by .github/workflows/ci.yml (check-governed-merges --pr read GOVERNED, one hit). The seat has not flipped ready, enqueued or armed auto-merge; the maintainer holds the decision. The 维护者速读 on the body names the row and the Tier H ask.
  • Option E (a @object-ui/mobile subpath export, no root inputs, no ci.yml row) was measured failing by the dev (Test UI's 16 shards and Live E2E UI's console build start without an @object-ui dist, and the root vitest config loads the console's vite.config.ts). Not re-measured here (read-only); the reasoning is consistent with ci.yml's ui jobs as written, and fixing it would itself be a larger ci.yml change than one filter row. Accepted as the seat ruled in 6575983994.
  • Dev deviations: the e2e serves the dist itself with an injected base tag (so the app stays inside the worker's /_console/ scope) — right for the scenario; the four surface amendments were granted on the card before code. No open questions; none escalated.
  • Should-fix, not on the hits face (carrier: the next touch of turbo.json, or a p3 follow-up card): the file is hashed into the generic build and type-check tasks, so every @object-ui/* package's build and type-check cache moves on a generator change while only @object-ui/console#build / #type-check read it (the generic test input is genuinely shared via the root vitest config). turbo.json already carries per-package <package>#build overrides; scoping the two inputs to the console's tasks would keep the parity row (the gate reads <package>#build inputs too) and stop re-building the rest of the ui tree. Cache scope only; no correctness effect; not a FAIL.

④ Design judgment

.github/workflows/ci.yml — judged against check-ci-filter-parity.mjs and the turbo.json inputs it serves.

No must-fix found. One should-fix under ③, outside the hits face.

Implemented-by: claude/project-thread-detgab
Reviewed-by: session_01QsR2jV5sBSzCYNEDgpYLJw

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y

维护者速读

  • 改了什么:Console 生产构建产出并注册 Service Worker(只在 /_console/ 范围内),缓存应用外壳,离线打开显示 Console 而不是浏览器错误页;/api/ 请求一律直连网络、不进缓存。CLI 给 Console 的 CSP 加 manifest-src 'self' blob:,带品牌的安装清单能加载,Console 因此可安装为桌面应用。为让构建缓存认得 worker 生成器,另加三处配置:tsconfig.node.json 一行、turbo.json 三个输入、.github/workflows/ci.yml 的 core: 路径过滤一行。
  • 为什么改:产品决定桌面端用浏览器里的 Console;此前没有 Service Worker,装成应用后离线打开是错误页,且 CLI 的 CSP 拒绝了 Console 注入的清单,线上根本不可安装。ci.yml 那一行是门禁 check:ci-filter-parity 的硬要求;不碰 ci.yml 的替代方案(包子路径导出)已实测会让 Test UI 16 个分片启动即失败。
  • 风险与回滚:部署后下一次导航即换新构建,旧缓存按构建号删除,不会钉住旧外壳;在移动原生壳里不注册 worker,并注销旧的。CSP 只多一条 manifest 来源。ci.yml 只加过滤行,只会让更多 PR 跑核心作业,不改任何 job/step/触发器。回滚即 revert。
  • 先看:.github/workflows/ci.yml 新增的 4 行;然后 ui/packages/mobile/src/serviceWorkerSource.ts 的 bypasses() 与 fetch 分支(API 不走缓存);再看 ui/apps/console/src/main.tsx 的 syncServiceWorker()。
  • 两分钟验证:CI 里 Build & E2E UI 已跑 e2e/console-service-worker.spec.ts 两条用例(离线刷新显示登录页的「无法连接服务器」面板;模拟部署后一次导航即换到新构建、旧缓存被删),本 head 全绿。
  • 需要你做的:批准(Approve)或直接合并本 PR。

席位意见:建议合并。总监合同复审 PASS(6577669391),席位 TIER_DEFAULT 复审 PASS(6577635035),必需检查全绿;总监提的一条 should-fix(turbo 输入应只挂在 console 自己的任务上,避免其他 ui 包缓存失效)不阻塞,已另立 p3 跟进卡。

席位核对 · ACCEPT · 2026-11-02T12:56Z

Head-sha: 55e3e5e501fa69124b14cfa9e536b78fd5845afd

  • Shape: draft → main, Fixes #29155 in the body only. 14 files, +1089 / −29 (1118 lines ≤ 3000). Governed, Tier H: check-governed-merges --pr 29326 hits 1 of 14 paths, .github/workflows/** (ci.yml). Author objectstack-fleet[bot]. Clause-②: yes (widening) and Metadata-protocol: none are line-initial in the body and the changeset.
  • Premise measured: on a default-base build served the way the CLI serves it, CDP Page.getInstallabilityErrors reported no-manifest and manifest-parsing-or-network-error on main. With the directive added, it reports none. No code registered a worker on main.
  • Reviews on this head:
    • CONTRACT_REVIEW_TIER by the director, PASS 6577669391, scope ci.yml plus the hunks it cites;
    • the seat's TIER_DEFAULT, PASS 6577635035, for the rest of the diff.
  • Gates (check-runs on this head): 56 success, 7 skipped, 0 failed or pending, including all 16 Test UI shards and the e2e spec in Build & E2E UI. All ten required contexts succeeded. The dev's local sweep showed 98 derived gates, all exit 0, check:ci-filter-parity green, and ui vitest run scripts/ 4903 tests.
  • Accepted deviations (surface amendments):
    • 6573999866: the CLI CSP and its pin;
    • 6574807547: the tsconfig.node.json include;
    • 6575431847: the three turbo.json inputs;
    • 6575983994: the one ci.yml filter row, with the measured refusal of a package-subpath alternative.
  • Out of scope: the director's should-fix is to scope the generator's build and type-check inputs to the console's per-package turbo tasks, so other @object-ui/* caches stop moving. It does not block, and it goes to a p3 follow-up card. Skills-docs: none.
  • Landing: Tier H. needs-user-decision goes on this PR, and review is requested from GOVERNED_APPROVERS. The PR stays a draft. On an authorized approval the seat runs the pre-landing checks, clears the label, flips ready and arms auto-merge. A maintainer's direct merge is itself the review record.

Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review November 2, 2026 13:00
@os-zhuang
os-zhuang enabled auto-merge November 2, 2026 13:00
@os-zhuang
os-zhuang added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit b772078 Nov 2, 2026
69 checks passed
@os-zhuang
os-zhuang deleted the claude/project-thread-detgab branch November 2, 2026 13:36
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y

Landed · 2026-11-02T13:43Z · PR #29326 (Tier H) merged by os-zhuang at 13:36:52Z as b7720782. Record: Tier H ACCEPT 6577690271 at 55e3e5e5 (Clause-②: yes (widening), seat TIER_DEFAULT record 6577635035 PASS, director CONTRACT_REVIEW_TIER PASS 6577669391 for the .github/workflows/ci.yml row).

probe before (b7720782~1) now (origin/main)
import { getServiceWorkerSource } from '../../packages/mobile/src/serviceWorkerSource.ts'; in ui/apps/console/vite.config.ts (the build emits the worker) 0 1
const registration = await registerServiceWorker({ in ui/apps/console/src/main.tsx (the Console registers it) 0 1
- 'ui/packages/mobile/src/serviceWorkerSource.ts' in .github/workflows/ci.yml (the core: filter row) 0 1

The card closed through Fixes. pm:dispatched comes off here and needs-user-decision comes off the PR with this note. Follow-ups now unblocked: #29361 (director should-fix, seat 4) and #29332 (seat 3).


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Console as an installable PWA on desktop: register the Service Worker for the app shell, and prepare a web_push delivery path

4 participants