Skip to content

feat(scenarios): a gallery exporter writes verified, generic scenario apps to a local directory in the layout of objectstack-ai/gallery - #30930

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-30349-gallery-exporter
Nov 6, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-30349-gallery-exporter

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Requested by 田騏 · project thread
Part of #30349
Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:新增「画廊导出器」export-gallery:读本地跑分器写下的跑分记录和项目目录,把每一半都通过的场景应用按 objectstack-ai/gallery 的布局写到本地目录:应用源码、跑分记录与计分卡、每个角色的问数问答记录(Markdown 加 JSON),以及 Playwright 对启动后应用截的列表、看板/日历(卡片打了标签才截)、记录页和仪表盘截图,外加一份经 zod 校验、排序稳定的 index.json。重导会先删旧目录;不再通过的场景从目录和索引里移除。
  • 为什么改:画廊是主要引流渠道,内容必须是验证过、每次发版重建的应用;导出器是从跑分结果到画廊内容的那一步。
  • 风险与回滚:只动未发布的私有包 @objectstack/scenarios(QA 工具),只写本地目录,不向 gallery 仓库写任何东西。卡片目前没有「通用」标记,所以导出器现在拒绝所有卡片,画廊保持为空,直到你决定标记字段。回滚即还原本 PR。
  • 第二轮:按决定 4,记录里只要有一个「未测量」的轮次(种子行被拒,或记录标为 not measured 的任一问答或操作),这个应用就不导出,即使各半的结论都是 pass;已导出的旧版本会被移除。PR 改为 Part of #30349。
  • 第三轮(合同评审 FAIL 后):用 --work 时,记录里原机器的工作目录(evidenceDir)和 --work 两者都替换成 $WORK,输出里不再出现任何一方的绝对路径;页面没真正显示(非 2xx,或 Console 自己的「未找到」视图)的截图被拒绝并报告;种子写入中途抛错时,已写入/被拒的部分计数留在记录里(与 main 一致);--runs 里没有任何跑分记录而旧索引列着应用时拒绝执行、什么都不删;--no-screenshots 不会用无截图的条目替换原本有截图的条目,旧目录和条目保留,摘要里标为 kept。
  • 先看:packages/qa/scenarios/src/export/generic.ts(只导出通用卡的守卫);packages/qa/scenarios/src/export/pass.ts(「每一半都通过」怎么从记录里读);packages/qa/scenarios/src/export/exporter.ts(删除与替换的规则)。
  • 两分钟验证:pnpm --filter @objectstack/scenarios exec vitest run test/export,预期 44 个用例全部通过,其中 "generic only" 用例断言默认守卫拒绝开放库全部 9 张卡。
  • 需要你做的:在报告评论的 write_path_options 里选一条写入 gallery 的路径,并决定卡片的「通用」标记字段。

What changes

A gallery exporter in @objectstack/scenarios (src/export/, CLI pnpm --filter @objectstack/scenarios export-gallery --runs DIR --out DIR, in the style of src/runner/cli.ts). It reads the run records and project directories the local runner writes and writes verified apps to --out in the layout of objectstack-ai/gallery. ⛔ It writes nothing to objectstack-ai/gallery and adds no workflow; the write path is a design item for the maintainer (the write_path_options in the report comment).

Which record, and "passed every half" (pass.ts). The newest record of each card decides (by date). Its per-half verdicts are read from the record's results, the rows runRecord writes, and nothing is re-scored. A half passes only on pass: partial, fail, blocked, not-run and a missing row are not a pass. The record must hold scratch mode, because that is the project the gallery ships. Scratch mode owes all six halves (Build, Run-问数, Run-operations, iterate Build, iterate Run-问数, iterate Run-operations), so a run with --no-iterate-run does not pass. Template mode, when present, owes its three iterate halves. Its first Build and Run rows are not-run by construction and are not asked. ⛔ A record with any not-measured turn does not pass, even when every half reads pass (round 2, decision 4). The scorecard counts a turn that rests on a refused seed row in neither passed nor total, so its half can still read pass. judgeRecord therefore reads every Run half the record holds (scratch Run, both modes' iterate Run) and names each question or operation marked notMeasured as a reason.

Generic only (generic.ts). A card is exported only when it is explicitly marked generic. The card schema has no such marker: provenance is free prose, and the open library's directory is not a marker because the loader reads any directory, the private overlay included. So NO_GENERIC_MARKER, the guard the CLI uses, refuses every card, and the CLI has no flag to pass another guard. The exporter takes the guard as a parameter so its tests can run the export path with a double. schema.ts and loader.ts are untouched (#30846 is in flight there).

The apps/SCENARIO/ layout (exporter.ts APP_LAYOUT): README.md (title, requirement, the verdict of each owed half, the scorecard table, links), source/ (the authored metadata source: the allow-list objectstack.config.ts, objectstack.manifest.json, package.json, tsconfig.json and the src/ tree; never node_modules, dist, .agents/.claude skills, .env or scaffold boilerplate; a symbolic link refuses the export), run-record.json (the record as read, with every work directory that names the run replaced by $WORK, see below), scorecard.json and scorecard.md, transcript.json and transcript.md, and screenshots/KIND.png.

No local path (record.ts redactWorkDir, exporter.ts workDirsOf). A record names the work directory of the machine that ran it: evidenceDir, every build command tail, any error text. --work DIR names where the projects are read on this machine. Both, and the real path of each that exists here, are replaced by $WORK in run-record.json, scorecard.json, scorecard.md, transcript.json, transcript.md and README.md. The longest is replaced first, and only at a path-name boundary, so /tmp never rewrites /tmpfoo or /x/tmp. The authored source/ files are copied as written.

The transcript (transcript.ts). The record keeps each turn's grade but not its reply. The reply is in the run's recording (run-scenarios --record F, passed as --recording F). Every recorded reply is graded again with gradeCardAnswer, exactly as the runner grades it, and a grade that differs from the record's refuses the export. A recording from another run therefore never reaches the gallery as this run's answers. An asked turn with no recorded reply also refuses the export.

Screenshots (screenshots.ts). This is a separate step that --no-screenshots skips. It boots the scratch project with the runner's own bootDev, seeds it with the runner's own seed loop, and signs in through the Console's sign-in door. planShots chooses the views from the card and the build alone:

  • list: the first nav-target object the build declares;
  • kanban or calendar: only when a card capability tag names one (e.g. records-forms.kanban-drag-persistence), showing a view of that type the build declares, read through the spec's deriveViewContainerObject and expandViewContainer;
  • record: the list object's first seed row;
  • dashboard: one the card expects, otherwise the build's first.

A tagged view the build lacks, or a refused seed row, is a skipped shot with its reason. A page the Console did not show is never proof (pageRefusal): a navigation that answers no response or a status outside 2xx (a 304 revalidation counts as shown), or a page that draws one of the Console's own not-found views (CONSOLE_NOT_FOUND: the object-not-found, object-package-disabled, record-page-not-found and form-not-found test ids, and the router's Page not found title), is a skipped shot with the reason, and its file is removed. A screenshot step that fails refuses the app instead of exporting it without its proof.

Re-export replaces (exporter.ts). Each app is written whole into a staging directory and swapped in after the old directory is deleted. The exporter owns what the previous index.json lists. An app it listed that does not export this time is deleted and leaves the index, and the summary says why. That covers an app whose newest record no longer passes, an app whose card is gone or not generic, and an app whose project is gone. A directory the index never listed is left alone and named. An invalid previous index stops the export before anything is touched. So does a runs directory with no scenario record while the previous index lists apps: that is a wrong --runs, and the CLI exits non-zero. An export with --no-screenshots never replaces an app whose previous export had screenshots: the old directory and entry are kept and the summary lists the app as kept. An app that no longer passes is still removed.

index.json (manifest.ts, zod, strict at every level, validated before it is written). It holds schemaVersion, plus one entry per app, sorted by id. Each entry has id, code, namespace, path, function (the card's category), title (en from the card's title; zh is null because the card schema has no Chinese title), summary (prompt.short), capabilities, screenshots, qa (questions, turns, personas), operations, framework (version is the @objectstack/spec pin of the generated project; runner is the record's env.framework), runDate and run. There is no export timestamp, so a re-export with no change is a no-op diff.

Runner touch-ups, no behaviour change. orchestrate.ts: the seed loop moves out of runHalf into an exported seedApp (the screenshot step reuses it), which tallies into the half's own seed so an insert that throws still leaves the partial tally in the record, as on main, and modeDir(card, mode) names the CARD-scratch directory the exporter finds the project in. runner/cli.ts: ADMIN and findUp are exported. package.json: the ./export entry, the export-gallery script, and playwright-core 1.63.0, the version already in the lockfile for dogfood and service-print. The lockfile change is the three-line importer entry.

Tests

  • test/export/exporter.test.ts (28 tests) runs on a real runCard run of the real crm_account_with_contacts card, in both modes. Its fixture (test/export/fixture.ts) writes real project directories under a temp work directory with in-memory app doubles, wrapped in the runner's own RecordingDriver. It pins:
    • the exact layout;
    • the source allow-list, which leaves out node_modules, .agents, .claude, .objectstack, .env, README.md and Dockerfile;
    • the $WORK redaction;
    • the full index entry;
    • the transcript, with 9 asked turns per Run half, each with its reply and grade, and the three Run halves;
    • a re-export that is byte-identical, file for file, and leaves the index unwritten;
    • stale files gone after a re-export;
    • a newer failing record that removes the app from apps/ and the index, while an unowned directory is kept;
    • the newest record deciding;
    • an invalid previous index refusing with the tree unchanged;
    • an unreadable record refusing the whole export, while non-runner JSON is ignored;
    • the default guard refusing every card, both in exportGallery and end to end through the CLI over the real card directory;
    • a missing recording, and a recording whose reply grades differently, each refused;
    • a missing project and a missing card, each refused;
    • the screenshot step's shots reaching the index and README, and a failing screenshot step refusing the app;
    • the index schema refusing unsorted, duplicate, wrong-path and unknown-key input;
    • the pass rule on clean, not-run, partial, fail, missing-row and no-scratch records;
    • under workDir, a record whose strings name another machine's directory: neither that directory, nor workDir, nor its real path appears in any file under --out, and the redaction's path-name boundary;
    • an empty runs directory over an index that lists apps refusing, through exportGallery and the CLI, with the tree unchanged, while an empty runs directory with no index writes an empty index;
    • --no-screenshots keeping an app that had shots, byte for byte, and reporting it as kept, while a newer failing record still removes it;
    • a not-measured turn: a run whose app refuses one seed row (contact ct4) keeps every half at pass and still does not pass, with one reason per not-measured question and operation in each Run half; an operation marked not measured is its own reason; and exportGallery refuses the app and removes its earlier export from apps/ and the index.
  • test/export/screenshots.test.ts (16 tests) tests view selection with a double:
    • untagged cards get no kanban or calendar;
    • tagged cards get the declared view of that type;
    • a missing view or dashboard is skipped with its reason;
    • a build with no app plans no shots;
    • a container is read in the shape os build writes;
    • captureScreenshots with boot, harness and browser doubles runs boot, then seed, then shots, then close, then stop;
    • a refused record row is skipped;
    • a browser that writes no file raises an error, and the app is still stopped;
    • pageRefusal on 2xx, 304, no response, other statuses and the Console's not-found views, and captureScreenshots skipping a 500 page and a not-found page with their reasons, their files removed.
  • test/runner/orchestrate.test.ts (one more test): a seed insert that throws mid-loop leaves the partial tally (2 inserted, 1 refused) in the half next to its error. The same test fails against the round-2 seedApp.

Acceptance notes

  • The exporter refuses every card today because no card is marked generic. The marker field and the Chinese title are open questions in the report comment, so a merged PR leaves the gallery empty until the marker lands.
  • PNG bytes are not stable across boots, because user ids and relative times differ. index.json and every text file are stable; a re-export with screenshots rewrites the PNGs.
  • The gallery's apps/README.md says it "is replaced when the first export lands". The gallery's AGENTS.md lists it as hand-written, so the exporter does not write it. The layout above is what it should describe.
  • On the released 18.3 Console, an administrator's first app page opens a default-currency dialog, once per browser session. The adapter visits the first shot's page once without shooting it. That dialog is retired on main (the first-run checklist changeset).

Verification

Real boot reading: create-objectstack 18.3.0, a hand-written fixture app, Chromium

No model was run, so no generated app exists here. Instead, a project was scaffolded with npx create-objectstack@latest democrm (18.3.0). A fixture app was written into it by hand: account and contact objects, a kanban list view, a dataset and dashboard, and app democrm_crm. os build passed. The project was placed at WORK/crm-account-with-contacts-scratch/democrm, and exportGallery ran with the real captureScreenshots (bootDev, HttpApp, playwrightBrowser on /opt/pw-browsers/chromium), using the crm card with a kanban tag added. Result: exported, four shots, none skipped — /_console/apps/democrm_crm/democrm_account, …/democrm_account/view/democrm_account.board, …/democrm_account/record/ac1, …/dashboard/democrm_overview. The PNGs show the seeded grid, the kanban grouped by industry, the Acme record page with its two contacts, and the dashboard with 3 accounts by industry. The first reading found the currency dialog over the list shot; the warm-up visit was added and the reading repeated clean. A first attempt from inside vitest got 401 on the admin sign-in, because vitest's NODE_ENV=test reached objectstack dev, so no dev admin was seeded. The CLI runs under tsx without it. This reading is a one-off and is not a permanent test.

Gates (local subset, derived by dispatch-gates for the final diff)

Head 30360bf2c6 (round 3). node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 64 commands, the same list as rounds 1 and 2; each exit code is captured before any pipe.

node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-adoption.mjs :: exit 0
node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-console-intercept-disarm.mjs :: exit 0
node scripts/check-console-intercept-disarm.mjs --self-test :: exit 0
node scripts/check-dts-emitted.mjs --self-test :: exit 0
node scripts/check-issue-citations.mjs :: exit 0
node scripts/check-osv-exemptions.mjs :: exit 0
node scripts/check-osv-exemptions.mjs --self-test :: exit 0
node scripts/check-plugin-teardown-shape.mjs :: exit 0
node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0
node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0
node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0
node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0
node scripts/docs-audit/check-affected-docs.mjs :: exit 0
node scripts/docs-audit/check-drift-comment.mjs :: exit 0
node scripts/osv-base-relative.mjs --self-test :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm --filter @objectstack/spec run check:llms-txt :: exit 0
pnpm check:changeset-fixed :: exit 0
pnpm check:ci-filter-parity :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:dispatcher-error-vocabulary :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:dts-closure :: exit 0
pnpm check:dual-build-cjs-loads :: exit 0
pnpm check:engine-double-contract :: exit 0
pnpm check:error-status-conformance :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:issue-citations :: exit 0
pnpm check:keyed-text-bounds :: exit 0
pnpm check:lean-entry-closure :: exit 0
pnpm check:logger-receiver-detach :: exit 0
pnpm check:manifest-repository-directory :: exit 0
pnpm check:merge-driver :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:objectql-double-limit :: exit 0
pnpm check:org-identifier :: exit 0
pnpm check:override-consistency :: exit 0
pnpm check:page-declaration-shape :: exit 0
pnpm check:platform-object-tenancy-census :: exit 0
pnpm check:pnpm-flag-spellings :: exit 0
pnpm check:published-files :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:registry-log-declared :: exit 0
pnpm check:rest-log-spy-declared :: exit 0
pnpm check:slot-lookup :: exit 0
pnpm check:sourcemap-no-sources-content :: exit 0
pnpm check:system-context-census :: exit 0
pnpm check:test-source-alias :: exit 0
pnpm check:tier-file-adoption :: exit 0
pnpm check:turbo-task-graph :: exit 0
pnpm check:type-check-coverage :: exit 0
pnpm check:type-check-debt :: exit 0
pnpm check:undeclared-dep-imports :: exit 0
pnpm check:vendor-export-contract-resolve :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0
pnpm check:workspace-manifest-cycles :: exit 0

--ran reconciliation: ✓ dispatch-gates --ran: 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED (a DERIVED zero — all 64 recorded an exit code and none of them is 3).

Package: pnpm --filter @objectstack/scenarios tsc --noEmit exit 0; vitest run exit 0, Test Files 20 passed (20), Tests 290 passed (290). test:repo is not run locally; it belongs to CI.

🤖 Generated with Claude Code

https://claude.ai/code/session_017ihL2ebbZkr2QAEG1x7UC8

@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Nov 6, 2026
@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file tests labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/scenarios, touching 129 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/qa/scenarios/package.json, packages/qa/scenarios/src/export/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

49 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 33043bd8a2b34ed2efdc188f38173c1c44ca3f36.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/qa/scenarios/package.json, packages/qa/scenarios/src/export/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: objectstack.config.ts (literal, 38 pages)
  • 74 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 222 client-bound route-ledger rows — the other 159 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 159: 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; 104 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 — 0 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 33043bd8a2b34ed2efdc188f38173c1c44ca3f36 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 41fa412d8f583717d5bec7badeb02bcb76e16a1f — the merge of head 30360bf2c66a057d02fe1e8dee157d425953f7e6 into base 33043bd8a2b34ed2efdc188f38173c1c44ca3f36, 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 41fa412d8f583717d5bec7badeb02bcb76e16a1f && git checkout 41fa412d8f583717d5bec7badeb02bcb76e16a1f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 33043bd8a2b34ed2efdc188f38173c1c44ca3f36 30360bf2c66a057d02fe1e8dee157d425953f7e6 && git checkout -B drift-repro 33043bd8a2b34ed2efdc188f38173c1c44ca3f36 && git merge --no-ff 30360bf2c66a057d02fe1e8dee157d425953f7e6

node scripts/docs-audit/affected-docs.mjs --json 33043bd8a2b34ed2efdc188f38173c1c44ca3f36

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 33043bd8a2b34ed2efdc188f38173c1c44ca3f36 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…asured turn

The scorecard counts a turn that rests on a refused seed row in neither
passed nor total, so its half can still read `pass`. The exporter now
reads every Run half's questions and operations and refuses the app
when any of them is marked not measured, naming each such turn. A
previous export of that app is removed like any other no-longer-passing
app.

Pinned by tests: a refused seed row (crm contact ct4) keeps every half
verdict at `pass` and still fails judgeRecord; an operation marked not
measured is its own reason; exportGallery refuses the app and removes
its earlier export.

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

Copy link
Copy Markdown
Contributor Author

domain:devx#2 · marchtian · session_017ihL2ebbZkr2QAEG1x7UC8

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c78ea5a418693a925b325a8ab492d4d175608ad0
Local-runs: none
Scope: packages/qa/scenarios/src/export/cli.ts, exporter.ts, generic.ts, index.ts, manifest.ts, pass.ts, record.ts, screenshots.ts, source.ts, transcript.ts, packages/qa/scenarios/src/runner/orchestrate.ts, packages/qa/scenarios/src/runner/cli.ts, packages/qa/scenarios/package.json, pnpm-lock.yaml, packages/qa/scenarios/test/export/exporter.test.ts, fixture.ts, screenshots.test.ts + packages/qa/scenarios/src/runner/scorecard.ts, project.ts, app.ts, driver.ts, namespace.ts, prompts.ts (read, not reviewed), objectstack-ai/gallery AGENTS.md, README.md, apps/README.md (the rules the output is held to). reviewFace returns no hits on this file list (no published schema, no governed path, Clause-②: no without an arm); the brief orders the contract tier, so the whole diff is in scope.

Inputs: card #30349 body and all 10 comments (the claim 6683477231 and the two dev reports read as claims), follow-up #30932, PR #30930 body, labels and file list, the net diff origin/main...c78ea5a4 (merge-base 1697d5c; 17 files, +2226/−37), the files read at the head, the gallery clone at 09ce9b0, and the 68 check-runs on the head.

① Derived judgments

Gallery rule 1 — only scenarios that passed every half. pass.ts judgeRecord: the newest record per card (record.ts readRunRecords, date compared as ISO text, file-name order breaks a tie) is read from its results rows, nothing re-scored. Scratch mode is required and owes six halves; template mode, when the record holds it, owes its three iterate halves; a verdict other than pass (so not-run, partial, fail, blocked) and a missing row are each a reason; a skipped template mode fails on its not-run rows. Then every Run half the record holds is walked and each question or operation carrying notMeasured is a reason of its own, so a half that reads pass over a refused seed row does not pass (the scorecard's askTally/opsTally leave such turns out of both counts — confirmed in scorecard.ts). An errored turn counts in total and not in passed, so it cannot pass either. Held. Pinned by 5 judgeRecord tests on a real runCard run, including the ct4 refused-seed case that keeps every verdict pass and still refuses.

Gallery rule 2 — nothing customer-derived. generic.ts NO_GENERIC_MARKER refuses every card; exporter.ts 159 opts.isGeneric ?? NO_GENERIC_MARKER; cli.ts never sets isGeneric, parseExportArgs throws on any unknown flag (tested with --generic), ExportCliDeps carries only screenshots, and no process.env read anywhere in src/export/ touches the guard (the only env read is PLAYWRIGHT_BROWSERS_PATH for the Chromium path). No bypass outside tests. The exportGallery parameter is the test seam, in a private package. Held.

Gallery rule 3 — no secret, no local path. Persona API keys never reach the record (runHalf strips apiKey); the recording stores ParsedTurns only, never the mcp.apiKey; Recording.files (authoring sources) is not exported. redactWorkDir replaces every occurrence of the work directory in run-record.json, scorecard.json and transcript.json. MUST-FIX M1: the replacement is incomplete when --work DIR is given. exporter.ts 233 sets workDir = opts.workDir ?? record.evidenceDir and 240/250/270 redact with that value alone, but the record's strings — evidenceDir itself, every build command tail (ran in ..., the fixture shows the shape), any error text — carry the ORIGINAL machine's evidenceDir. With --work (whose own doc is "records moved off the machine that ran them") that absolute path of another machine is written verbatim into all three JSON files. The test not.toContain(fx.work) runs only without workDir. Fix: redact record.evidenceDir and opts.workDir both, and pin it under workDir. (Also noted, not blocking: v.split(workDir) has no path boundary, so a short work dir such as /tmp rewrites /tmpfoo; and an absolute path outside the work dir in a command tail is not redacted — unlikely on a passing build.)

Gallery rule 3 — the source/ list. source.ts is an allow-list: objectstack.config.ts (required), objectstack.manifest.json, package.json, tsconfig.json, and the src/ tree, regular files only, node_modules/dist/.objectstack/.git never, a symbolic link anywhere refuses. .env, .agents, .claude, README.md, Dockerfile are outside the list; the fixture plants each and the layout test asserts the 12-file tree. Held.

② The transcript. transcript.ts buildTranscript: for every graded question turn the recording is looked up by stepKey (card/mode/phase/step/persona — the same key the RecordingDriver wrote), a missing turn refuses, and the recorded reply is re-graded with gradeCardAnswer under the same inputs the runner used (namespaced scenario, hiddenValues for the persona, citationLabels(original) — compared against runHalf 455-460, identical), a canonical-JSON mismatch refuses. mergeRecordings refuses a key recorded twice with different turns. Fail-closed: a missing recording refuses (test), a partial one refuses on the first graded turn without a reply, a turn with error or notMeasured has no grade check but such a record never passes rule 1, so it is never exported. Sound.

③ Re-export. Each app is written whole to apps/.staging-ID, then rmSync(apps/ID) and renameSync (exporter.ts 186-188); a refused app's staging is removed. Previous index.json is parsed with the strict schema BEFORE anything is touched and an invalid one throws (test: tree unchanged). Every app the previous index listed that did not export this time is deleted and leaves the index with its reason; a directory the index never listed is left alone and named in unowned (isDirectory() filter, so the gallery's hand-written apps/README.md survives). A stale or hand-made directory with an exporting card's id is overwritten by the swap — the exporter owns apps/ID for the ids it exports, acceptable. index.json: serializeIndex sorts by id, parses through GalleryIndexSchema (strictObject at every level, refine: sorted and unique ids, path equals apps/ID, screenshots under it), no timestamp, written only when the bytes differ (tested byte-stable, file for file). SHOULD-FIX S3: an app leaves the gallery whenever the runs directory holds no record of it (199-203), so an empty or wrong --runs deletes every app and the index entries with exit 0 — consider refusing when the previous index is non-empty and the runs directory holds zero scenario records.

④ Screenshots. captureScreenshots boots with the runner's bootDev and HttpApp (wired in cli.ts 97), signs in as ADMIN, seeds with seedApp(namespacedScenario(scenario), app, []) — the runner's loop on the runner's namespaced card — then shoots, closes the browser and stops the app in finally. The seed-loop move (orchestrate.ts 386-414 vs main 379-406) is textually the same loop with seed for half.seed; modeDir yields the same two directory names main spelled inline. SHOULD-FIX S2: one behaviour difference in the exception path: on main half.seed was mutated in place, so an insertRow throw left the partial inserted/refused tally in the record next to half.error; now half.seed = await seedApp(...) never assigns and the record shows inserted: 0, refused: []. Verdicts are unchanged (the half is not-run or blocked either way); the scorecard's seed cell is not. The 18.3 currency-dialog handling (screenshots.ts 254-263) visits the first shot's page once unshot, then every shot is still taken and existence-checked; a goto error propagates, so it hides no failure. SHOULD-FIX S1: page.goto does not throw on a non-2xx response, and nothing reads the response or the page, so a Console not-found or error view is shot and exported as proof; read (await page.goto(...))?.ok() and refuse the shot otherwise. A skipped shot is reported in ScreenshotResult.skipped, the CLI summary, the README (no KIND screenshot: REASON) and is absent from the index — never silent. A step that throws refuses the app and, if it was listed before, removes it (fail closed, as designed). SHOULD-FIX S4: --no-screenshots yields an index entry with screenshots: [] indistinguishable from a real export; the gallery's proof set includes screenshots, so the summary should say loudly that the output is not publishable, or the flag should refuse to replace an entry that had shots.

⑤ The landing. Part of #30349 is honest: the write path, the first real export and the marker (#30932) remain on the card; check-partof-closing-keyword and the PR-body guards are green. skip-changeset is right: @objectstack/scenarios is "private": true, the diff publishes nothing from a released package. playwright-core 1.63.0: the exact pin packages/qa/dogfood and packages/services/service-print already hold; the lockfile diff is 3 added lines in the packages/qa/scenarios importer, the playwright-core@1.63.0 package and snapshot entries pre-exist, no new transitive package. Clause-②: no and Metadata-protocol: none match a tree that touches no packages/spec file. The diff touches none of loader.ts, schema.ts, docs/qa/scenarios/*.json, the package README or refusals.test.ts (verified against the file list and the tree), no .github/**, no changeset. No write path to the gallery: src/export/ has no child_process, fetch, git or GitHub call; the only spawn is the runner's bootDev; writes go under --out only. PR body vs tree: the 24 + 12 test counts, the layout, the allow-list, the refusal order, the $WORK claim (true only without --work, see M1), the runner touch-ups and the lockfile description all match the tree.

Check-runs on c78ea5a: 68 runs, all completed, none in progress; conclusions success or skipped (Check Changeset skipped by the label; Dogfood opt-in shards, Temporal, UI Published Artifacts, 18.x harness, packed-tarball skipped by their filters), none failure. Lint & Repo Gates, TypeScript Type Check, Test Core 8/8, Test UI 16/16, Build & E2E UI, Live E2E UI, Governed Surface Queue Guard, Validate Package Dependencies, PR-body guards: success.

② Semver level

None owed. @objectstack/scenarios is private and unpublished; no released package moves. skip-changeset is the correct label. Nothing an author can write changes: Metadata-protocol: none holds.

③ Boundary flags

  • No governed surface in the file list; no workflow; no spec, loader, schema or card change; no release act.
  • New third-party runtime dependency in a private QA package (playwright-core 1.63.0, already in the lockfile twice); no new package in the lockfile.
  • Local side effects only: the exporter spawns objectstack dev --fresh on the run's scratch project and launches headless Chromium when screenshots are on; it writes under --out and nowhere else.
  • The CLI exits 0 whatever it refused (refusals go to stderr); a pipeline cannot read "nothing exported" off the exit code. Noted, not a finding.
  • Recording text (model replies and tool outputs) is written to the gallery as the transcript; it is seed-derived and synthetic by the gallery's own rule, and the re-grade proves the join.

④ Design judgment

Against the gallery's rules (AGENTS.md: exporter-only writes to apps/** and index.json, every half passed, generic only, no secrets; README: function, title, screenshots, question count, framework version, run date in the index), the module holds where it can and refuses where it cannot show compliance: the refuse-all guard is the right default while the card schema has no origin marker, title.zh: null is an honest gap tracked on #30932 rather than a sentence passed off as a title, and the hand-written apps/README.md is left to the maintainer as the gallery's AGENTS.md lists it. The staging-and-swap re-export, the strict pre-validated index and the proven transcript join are sound. The one place the rules are not held is the --work redaction gap (M1): the flag exists for records moved between machines, and in that exact case the other machine's absolute work directory is written into three gallery files. A one-line fix plus one test; everything else on this head stands.

Must-fix

  • M1 — exporter.ts 233/240/250/270: with --work DIR, record.evidenceDir and every record string naming it are written unredacted into run-record.json, scorecard.json, transcript.json. Redact both the record's evidenceDir and opts.workDir; pin under workDir.

Should-fix

  • S1 — screenshots.ts 245/262: a failed navigation (non-2xx, Console error view) is shot and exported; check the goto response and refuse.
  • S2 — orchestrate.ts 437: the seed tally is lost from the record when insertRow throws mid-loop (main kept the partial counts); assign progressively or catch inside seedApp.
  • S3 — exporter.ts 199-203: an empty or wrong --runs removes every listed app with exit 0; refuse when the previous index is non-empty and no scenario record was found.
  • S4 — --no-screenshots output is indistinguishable from a real export in index.json; mark it in the summary or refuse to replace an entry that had shots.

Implemented-by: claude/issue-30349-gallery-exporter
Reviewed-by: session_017ihL2ebbZkr2QAEG1x7UC8

VERDICT: FAIL

…ed pages, seed tally, empty runs, shots kept

M1. With --work, the record still names the work directory of the machine
that ran it (evidenceDir, every build tail and error text), and only --work
was redacted. Every directory that names the run (the record's evidenceDir,
--work, and the real path of each that exists here) is now replaced by
$WORK in run-record.json, scorecard.json/.md, transcript.json/.md and the
README, longest first and only at a path-name boundary, so /tmp never
rewrites /tmpfoo.

S1. A shot whose navigation answers no response or a status outside 2xx
(304 counts as shown), or whose page draws one of the Console's own
not-found views, is refused: skipped with the reason, its file removed,
never exported as proof.

S2. seedApp tallies into the half's own seed again, so an insert that throws
leaves the partial inserted/refused tally in the record next to the error,
as on main.

S3. A runs directory with no scenario record, over an index that lists
apps, throws (the CLI exits non-zero) and touches nothing.

S4. An export without screenshots never replaces an app whose previous
export had them: the old directory and entry are kept and the summary
reports it as kept. An app that no longer passes is still removed.

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

Copy link
Copy Markdown
Contributor Author

domain:devx#2 · marchtian · session_017ihL2ebbZkr2QAEG1x7UC8

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 30360bf2c66a057d02fe1e8dee157d425953f7e6
Local-runs: none
Scope: delta c78ea5a418..30360bf2c6 (one commit, 8 files, +317/−34, all under packages/qa/scenarios): src/export/cli.ts, exporter.ts, record.ts, screenshots.ts, src/runner/orchestrate.ts, test/export/exporter.test.ts, test/export/screenshots.test.ts, test/runner/orchestrate.test.ts + the live PR body, the dev's delta report 6685891798 (read as claims), the FAIL record 6685278277 this delta answers, and the Console source at the released tag @objectstack/console@18.3.0 (commit 83d7e4a) and on main (read to judge the not-found markers). Nothing outside the package moved: package.json, pnpm-lock.yaml, loader, schema, cards, README, workflows untouched in the delta. The round-2 record's judgments on the rest of the tree stand; this record re-judges what the delta changed.

① Derived judgments

M1 — $WORK redaction, was must-fix. Fixed. exporter.ts workDirsOf(record.evidenceDir, opts.workDir) yields the record's evidenceDir, --work when given, and realpathSync of each that exists on this machine (a missing one is skipped silently, which is the moved-record case). exportOne builds one redact over that set and applies it to the transcript (so transcript.json and transcript.md), the scorecard (so scorecard.json and scorecard.md), run-record.json (rf.raw) and now README.md; source/ is copied as written, which is right (authored files, not record text). record.ts redactWorkDir takes one or many directories, strips trailing slashes, drops / and the empty string, sorts longest first, escapes for the regex and matches only at a path-name boundary: not after [A-Za-z0-9._~/-], not before [A-Za-z0-9_~-] or a . that starts a suffix; a following / is allowed, so /w/x under /w becomes $WORK/x, and $$WORK in String.replace is the literal $WORK. Whole-name matching holds (/tmp leaves /tmpfoo, /tmp.d/x, /var/tmp/y, x/tmp), nested directories replace longest first.
Test: exporter.test.ts rewrites a real runCrm record so every string naming its work directory names /srv/ci-runner-7/scenario-work, asserts the build tails name it too, exports with workDir set to the real project directory, then reads every file under --out and asserts none contains the other machine's path, workDir or its real path, and that evidenceDir reads $WORK; a second test pins the boundary rules and the nested and /-skip cases. This test fails against the round-2 code (only --work redacted; the other path survives). Note, not a finding: it does not fail if only evidenceDir were redacted, because no output string derives from --work (the project path is read, never written), so the --work and realpath redaction is defence in depth rather than a pinned failure mode; and a path written in file:///... form is preceded by /, so the boundary leaves it (no record field produces that form today). Complete for every file the exporter writes.

S1 — failed pages, was should-fix. Fixed, and the markers hold on the released Console. playwrightBrowser.visit now returns a PageReading (the navigation response status or null, and which of CONSOLE_NOT_FOUND's test ids or titles the page draws); pageRefusal refuses no response, any status outside 2xx other than 304, then any not-found view; capture returns shown: true or shown: false, reason, and captureScreenshots removes the file, drops the shot and records a skipped shot with the Console path and reason. The existence check for a shown shot remains. 304 as loaded is sound: each shot's page is visited twice (the warm-up), so the second navigation can legitimately revalidate the document; a 304 is the browser rendering the document it holds, not a missing page, and a client-side not-found is caught by the marker check that runs regardless of status. The four test ids (object-not-found, object-package-disabled in objectViewChrome.tsx, record-page-not-found in RecordDetailView.tsx, form-not-found in FormPage.tsx) and the router catch-all console.notFound.title with default Page not found in AppContent.tsx all exist at tag @objectstack/console@18.3.0 (83d7e4a), the released Console the screenshots run against, as well as on main. The title is the one text-matched marker and is localised (zh: 页面不存在); the browser context is locale: 'en-US' and the Console's pickInitialLanguage reads navigator.language, so the English title is what renders. Not a should-fix; a hardening note: adding the zh title (or a test id on RouteNotFound upstream) would make that one marker locale-independent.
Tests: pageRefusal on 200/204/304, null, 301/401/403/404/500/502 and a not-found view; the marker list pinned; captureScreenshots with a double answering 500 for the list page and record-page-not-found for the record page: both skipped with their reasons, both files gone, the dashboard kept, close then stop. Pinned.

S2 — seed tally on a throw, was should-fix. Fixed. seedApp takes the tally to fill (seed parameter, default a fresh object per call) and runHalf passes half.seed, so an insertRow throw leaves inserted, refused and handlesAsAdmin as far as they got, next to half.error, as on main. The screenshot step's call (seedApp(ns, app, [])) keeps the default and the return value. Test: orchestrate.test.ts ThrowingApp refuses ac2 then throws on ct1; asserts half.error, half.seed equals 2 inserted, ac2 refused, handlesAsAdmin: ['u_tia'], no questions asked, scorecard seed 2/3 with 1 refused. Fails against the round-2 shape. Pinned.

S3 — empty runs over a non-empty index, was should-fix. Fixed. exportGallery throws before apps/ is touched when newest.size === 0 and the previous index lists apps, naming both; the CLI's top-level handler exits 1. Test: after an export, a runs directory holding only non-runner JSON rejects through exportGallery and through main, the tree byte-identical both times; with no previous index an empty runs directory still writes an empty index. Pinned. By design and acceptable: a runs directory that holds records for some cards but not a listed one still removes that one (the exporter owns what the index lists).

S4 — --no-screenshots over an export that had shots, was should-fix. Fixed. In exportGallery, when screenshots === false, the previous entry has shots and apps/ID exists, the fresh staging is discarded, the previous entry is pushed as-is, summary.kept names it with the reason, and renderSummary prints a kept line on stderr and counts it; an app whose newest record fails is still removed (the keep applies only after exportOne succeeded). Test: export with one shot, then screenshots: false: nothing exported or removed, kept names the app, index unchanged, tree byte-identical, both summary lines pinned; then a newer failing record removes it. Pinned. Consequence worth knowing, not a finding: the kept entry keeps the previous run's date and record even when a newer run passed.

Anything else touched. cli.ts: the summary gains the kept line and the stderr routing regex; the docblock gains the --work and --no-screenshots notes and the empty-runs paragraph — nit: that paragraph is inserted between --no-screenshots and --chromium, so the flag list is interrupted (prose only). No other behaviour changed; no file outside the package; package.json and the lockfile unchanged in the delta; still no write path to the gallery and no workflow.

PR body vs tree. The live body (head 30360bf) describes round 3 in the maintainer brief, the No local path paragraph (both directories and their real paths, longest first, path-name boundary, six files, source/ as written), pageRefusal with the four test ids and the title, the empty-runs refusal with non-zero exit, kept, and the seed tally; test counts 28 + 16 + one orchestrate test match the tree (28, 16, 14 it blocks). Part of #30349, Clause-②: no, Metadata-protocol: none, skip-changeset unchanged and still right.

Check-runs on 30360bf: every run completed, none in progress; conclusions success or skipped (Check Changeset by the label; Dogfood opt-in shards, Dogfood Verify CLI, Temporal, UI Published Artifacts, 18.x harness, packed-tarball, schedule gate by their filters; duplicate Auto Label and Check PR Size runs skipped by trigger); none failure. Lint & Repo Gates, TypeScript Type Check, Test Core 8/8, Test UI 16/16, Build & E2E UI, Live E2E UI, Dogfood Regression Gate, Governed Surface Queue Guard, Validate Package Dependencies, PR-body guards: success.

② Semver level

Unchanged: none owed, private unpublished package, skip-changeset correct, Metadata-protocol: none holds.

③ Boundary flags

  • No governed surface, no workflow, no spec or card change, no new dependency in the delta.
  • New local side effect: none beyond round 2 (the browser now also reads DOM locators on each visited page).
  • The CLI still exits 0 when it refused apps; the S3 refusal is the one case that exits non-zero. Noted, as before.

④ Design judgment

The delta closes the one rule the round-2 tree broke: with --work, the output now names no machine's work directory in any file the exporter writes, and the redaction is whole-name so it cannot over-reach into unrelated paths. The four should-fixes are each answered by the smallest change that pins the behaviour: a page the Console did not show is reported and never exported, the runner's record is faithful again on a mid-seed throw, a wrong --runs cannot empty the gallery, and a screenshot-less run cannot downgrade an entry that had proof. The not-found markers were checked against the released 18.3.0 Console source, not only main, and hold there. Nothing in the delta widens the exporter's reach or weakens the generic guard, the pass rule or the transcript proof.

Must-fix: none.

Should-fix: none. (Notes above: the --work side of the redaction is defence in depth rather than a pinned failure; the zh title would make the one text marker locale-independent; the cli.ts docblock paragraph interrupts the flag list.)

Implemented-by: claude/issue-30349-gallery-exporter
Reviewed-by: session_017ihL2ebbZkr2QAEG1x7UC8

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#2 · marchtian · session_017ihL2ebbZkr2QAEG1x7UC8

席位核对 — ACCEPT · domain:devx#2 · Head-sha 30360bf2c66a057d02fe1e8dee157d425953f7e6 · 2026-11-06T13:31Z

  • Shape: 18 files, +2509/−37, all under packages/qa/scenarios, plus a three-line lockfile importer hunk. Read from the diff, not the report.
    • New src/export/ and the export-gallery CLI.
    • The seed loop moved into a shared seedApp, and on a throw it keeps main's partial tally.
    • playwright-core 1.63.0, the pin the lockfile already holds, so no new package.
  • What it does: the exporter writes the objectstack-ai/gallery layout (apps/SCENARIO/ and index.json) into a local --out directory only.
    • It exports only a card whose newest record passed every owed half.
    • It refuses a run with any not-measured turn.
    • It re-grades every recorded reply and refuses on any mismatch.
    • It redacts every work path as $WORK.
    • It copies only an allow-listed authored source/.
    • It refuses screenshots of a page that did not load.
    • It keeps old shots under --no-screenshots.
    • It refuses to wipe the index when --runs is empty.
    • It validates index.json with zod, sorted and stable.
  • Exports nothing today, on purpose. The guard refuses every card, because the card format has no generic marker yet (the D1 ruling). The marker and a {zh, en} title are filed as Scenario cards: an explicit origin marker (generic / customer-derived / held-out) and a zh+en title, so the gallery exporter can export anything #30932, to start after fix(scenarios): the loader refuses an operation its persona cannot reach, and two cards stop writing hidden fields #30846.
  • Nothing is written to objectstack-ai/gallery, and no workflow is added. The write path is the maintainer's decision. It is open in a separate analysis thread, and no cross-repo write happens before his ruling.
  • Review: independent contract review in two rounds.
    • Round 1 on c78ea5a418 FAILed (6685278277). M1 was the $WORK redaction under --work; there were four should-fixes on page-load checks, the seed tally on a throw, an empty --runs, and --no-screenshots.
    • The dev closed all five in 30360bf2c6. The delta review PASSed on this head (6685995945) with nothing open. It also confirmed the Console not-found markers exist at @objectstack/console@18.3.0.
  • Gates:
    • Head checks: 57 success, 11 skipped, none red, mergeable clean.
    • check-expected-skips: OK, all 11 skips are in main's roster (exit 0).
    • check-governed-merges: NOT governed, 0 of 18 paths (exit 0). 2546 changed lines, which is ≤ 3000.
    • Dev's dispatch-gates --commands: 64 of 64 exit 0, and --ran reconciled. Package tests pass 290 of 290.
  • Out of scope: Skills-docs: n/a, Clause-②: no, Metadata-protocol: none, skip-changeset (the package is private: true).
  • Landing: non-governed. Ready, then auto-merge through the merge queue.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 6, 2026 13:32
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 6, 2026 13:32
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 6, 2026
Merged via the queue into main with commit c571165 Nov 6, 2026
69 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-30349-gallery-exporter branch November 6, 2026 14:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant