Skip to content

GET /api/v1/packages / /api/v1/packages/:id / /api/v1/meta/package 全部 500「Converting circular structure to JSON」—— 栈里带插件实例(showcase)时,包记录存的是含实例的原始摊平 bundle #14442

Description

@hotlong

在 #14375(PR #14430)做真实启动取证时撞到,先在 PR 分支上出现,再在 origin/main 6aea1f5(dist 里 withWritableVerdict 计数为 0,即不含 #14430 的任何改动)上逐字复现。既有缺陷,与 #14430 无关;单独立卡。

复现(实测,os dev)

cd examples/app-showcase && ./node_modules/.bin/objectstack dev --seed-admin -p 4377 -d file:/tmp/x/data.db
# 登录 POST /api/v1/auth/sign-in/email → set-auth-token
GET /api/v1/packages                       → HTTP 500
GET /api/v1/packages/com.example.showcase  → HTTP 500
GET /api/v1/meta/package                   → HTTP 500 {"error":"Internal server error","code":"INTERNAL_ERROR"}
GET /api/v1/packages?type=plugin           → HTTP 200(把 showcase 这条应用记录过滤掉就正常)

信封原文:

Converting circular structure to JSON
    --> starting at object with constructor '_ObjectQL'
    |     property 'actionActivation' -> object with constructor 'ActionActivationProjection'
    |     property 'store' -> object with constructor 'ObjectStoreActionActivationStore'
    --- property 'engine' closes the circle

同一进程里 POST /api/v1/packages/com.example.showcase/duplicate 也在 service-package 的 publish(JSON.stringify(manifest) 持久化到 sys_packages)处以同一错误失败:"sys_packages persist FAILED for 'com.acme.dupbase'"。

对照:examples/app-todo(栈定义里没有任何插件 / 数据源实例)同一流程 GET /api/v1/packages 200、23 行,复制 base 也正常。

根因(源码锚点)

  • examples/app-showcase/objectstack.config.ts:134-162:plugins: [new ConnectorOpenApiPlugin(), new ConnectorMcpPlugin(...), …],datasources: [ShowcaseExternalDatasource] —— 栈定义里带运行时实例。
  • packages/runtime/src/app-plugin.ts:263-266:servicePayload = { ...this.bundle.manifest, ...this.bundle } 后直接 manifest.register(servicePayload) —— 实例原样进了注册载荷。
  • SchemaRegistry.installPackage 把 manifest 原样存进包记录(registry.ts,D7 位一致性也依赖"原样")。启动后这些插件实例持有 engine,于是任何对包记录做 JSON.stringify 的门(两条 GET /packages 门、getMetaItems({type:'package'})、sys_packages 持久化)都被循环引用炸掉。

影响

Studio 包选择器读的就是 GET /api/v1/packages:凡栈定义里带插件实例的应用(showcase 这一类),Studio 的包列表整体不可用;纯元数据应用(hotcrm、app-todo)不受影响。?type= 过滤掉应用记录时能过,说明只有那一条记录不可序列化。

待决定的修法方向(不在本卡拍板,给 dev / 维护者)

  • A. 注册时把不可序列化的运行时键(plugins / datasources 实例)从进包记录的 manifest 上剥掉,只留可声明部分;D7 单包 bit-identity pin 要一起看(它比的是什么就得说清)。
  • B. 包记录照存,但所有服务包行的门统一经一个"可序列化视图"出去(去掉实例键)。
  • 两者都要有一条 pin:带插件实例的栈启动后 GET /api/v1/packages 200 且行数正确。

关联

Activity

  1. self-assigned this
    on Sep 2, 2026
  2. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    认领 + 修法方向(维护者 2026-09-02:本卡挡发版,小卡并行修)

    方向取 A:注册进包记录的 manifest 不得携带运行时实例。落点是 packages/runtime/src/app-plugin.ts:263-266 的 servicePayload = { ...this.bundle.manifest, ...this.bundle }——bundle 里的 plugins / datasources(以及任何其它持有实例的键)不属于"可声明的元数据",不应进 manifest.register() 的载荷。dev 先测量:注册链下游(registerApp → installPackage → registry 记录 → 两条 GET /packages 门、getMetaItems('package')、sys_packages 持久化)有没有任何消费者读记录上的这些键;若有,报出来再定 B(可序列化视图)。D7 单包 bit-identity pin 必须保持绿。

    验收:examples/app-showcase 真实 os dev 启动后 GET /api/v1/packages、/:id、/meta/package 三条门 200 且行数正确(curl 原文进 PR);POST …/duplicate 的 sys_packages 持久化不再抛循环引用;一条 pin:带插件实例的栈注册后包记录可 JSON.stringify;消融:恢复原样摊平 → pin 红。Clause-② 预期 NO(不改接受/拒绝面)。changeset @objectstack/runtime: patch。


    Generated by Claude Code

  3. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 14442,
      "status": "blocked",
      "branch": "claude/issue-14442-package-record-serializable",
      "pr": null,
      "premise_still_valid": true,
      "summary": "STOP-AND-REPORT, rule 2 (duplicate of another seat's in-flight work) — the implementation is COMPLETE, verified end-to-end and pushed, but I deliberately did NOT open a PR. While running the mandatory file-then-search dedup for an out-of-scope finding I found #14309: the SAME defect, same envelope, filed 5h before this card, already implemented and shipped as open draft PR #14499 ('fix(objectql,runtime,rest): store a serializable manifest projection in the package registry'), os-dev report posted 09:37 and PM decisions posted 09:41 — i.e. before this card was dispatched to me at 09:08 and while I was working. Whether the platform should carry a SECOND fix for one defect is a PM call, so the branch is parked rather than raced onto the board. WHAT IS ON THE BRANCH (one seam, packages/runtime/src/app-plugin.ts): the payload AppPlugin hands to manifest.register() drops the instantiated kernel plugins from plugins[] and nothing else. The discriminator is the kernel's own contract — Plugin.init is a REQUIRED member of the Plugin interface, and it is the exact predicate the CLI's isHostConfig already uses on this same array. Declarative members are kept, so nested manifest-shaped plugins still register their metadata under the parent package; a payload with nothing to strip is returned BY REFERENCE, which is what leaves ADR-0130 D7 bit-identity untouched. THE MEASUREMENT THE CARD ASKED FOR FIRST: nothing downstream of registration reads manifest.plugins or manifest.datasources off the RECORD. The only two production reads of either key are inside registerApp itself (engine.ts:4811-4814 datasource-def indexing; engine.ts:4948-4952 the nested-plugin recursion), both of their own parameter. Record readers read something else: the two runtime doors serialize the row and filter on status / manifest.type; metadata-protocol reads manifest.dependencies only; cloud-connection reads the id only; the registry internals read status / enabled. apps/** holds no read of either key. PR #14499 grepped this independently and reached the same conclusion. ONE PREMISE CORRECTION on this card: datasources is NOT instance-bearing. It is z.array(DatasourceSchema) and ShowcaseExternalDatasource is a plain defineDatasource(...) object — fully serializable, genuinely consumed by registerApp, and left alone. Only plugins[] needed narrowing. HOW THE TWO FIXES DIFFER, for the PM's choice: #14499 repairs at the STORAGE seam structurally (installPackage stores a projection that drops functions, class instances, Map/Set and cycles by shape) — broader, covers every install path including HTTP install and sys_packages rehydrate, and also drops function-valued members such as onEnable from the record. Mine repairs at the SOURCE by the kernel's Plugin contract — narrower and more precise, touches only plugins[], and leaves every other member byte-identical. They share no file (#14499: objectql/registry.ts, runtime/domains/packages.ts, rest/package-routes.ts, a docs anchor; mine: runtime/app-plugin.ts), so they can coexist without conflict; they are simply redundant for this defect. PM-side half-state noted as normal: the card arrived already assigned by the PM; I wrote no assignee.",
      "tests": "All runs on the merged tree, final commit c80b5c4c3 (worktree clean, 0 unpushed commits, remote in sync); origin/main merged at 159e05e8b, one conflict in app-plugin.ts resolved by keeping BOTH doc blocks (my helper header and main's securityRegistrar header from the #12892 step-2 change), both sides verified intact afterwards. Every exit code captured BEFORE any pipe via redirect-then-capture. ACCEPTANCE, real boot: full showcase dependency-closure build, then examples/app-showcase via 'objectstack dev --seed-admin -p 4477 -d file:SCRATCH/data.db', signed in through POST /api/v1/auth/sign-in/email with the set-auth-token header re-sent as Authorization Bearer. All four doors, each previously 500: GET /api/v1/packages -> HTTP 200, rows 26, total 26, includes com.example.showcase; GET /api/v1/packages/com.example.showcase -> HTTP 200; GET /api/v1/meta/package -> HTTP 200, rows 26, includes com.example.showcase; POST /api/v1/packages/com.example.showcase/duplicate -> HTTP 200 with '[Registry] Installed package: com.acme.dupbase' in the log and NO 'sys_packages persist FAILED' line. 'Converting circular structure to JSON' occurs 0 times in the entire server log for that run. The duplicate's persist was verified DIRECTLY rather than inferred from an absent error line — reading the run's sqlite file afterwards: 'sys_packages rows: 1 / com.acme.dupbase manifest bytes: 411662', i.e. the 411,662-byte JSON.stringify that used to throw now lands. The duplicate's response body is still the empty result (copiedCount 0); that is the separate defect tracked on #14451 and was deliberately not touched. The served record read back from the detail door shows exactly the intended narrowing: 40 manifest keys present, 'plugins' present with value [] (showcase declares only instances), 'datasources' intact with the full showcase_external declaration. Server killed by recorded PID only, never by process name. SUITES: 'pnpm --filter @objectstack/runtime run test' -> Test Files 210 passed (210) / Tests 3083 passed (3083). 'pnpm --filter @objectstack/objectql run test' -> Test Files 259 passed (259) / Tests 4484 passed (4484) — this is where the ADR-0130 D5/D7 load-path suite and the D7 single-manifest bit-identity pin live; green and unchanged. TYPECHECK: runtime and objectql both 'Done'; objectql's check:test-typecheck OK. NEW PIN, 4 cases, packages/runtime/src/app-plugin.package-record-serializable.test.ts: real LiteKernel + ObjectQLPlugin + AppPlugin, real SchemaRegistry, the real door body (handlePackagesRequest via HttpDispatcher.handlePackages) and the real ObjectStackProtocolImplementation.getMetaItems — no vi.fn stands in for the registry or a door. The discriminating assertion is a comparison against a CONTROL kernel booted with the same bundle minus the instances: JSON.stringify(record.manifest) equal on both sides and the two object-FQN sets equal, so a fix that dropped plugins wholesale passes 'it serializes' and fails here. ABLATION: restore the raw spread. Predicted 1 red / 3 green, MEASURED 2 red / 2 green; the prediction was wrong by one and the correction is recorded in the test header — the byte-identity case was expected to survive because the raw spread touches no declarative key, but its comparison is JSON.stringify(record.manifest), which on the unfixed side THROWS rather than returning a different string. Both failures carry the production envelope verbatim ('Converting circular structure to JSON ... property owner closes the circle'). The two direct-predicate cases stay green — they never go through the register seam and are the deliberate control. ABLATION HYGIENE: mutation proven on disk by grepping the injected marker (1) and the absence of the guard call site (0), never by an editor's exit code; restore leg 'git checkout HEAD -- ABSOLUTE_PATH' under an EXIT/INT/TERM trap with an absolute REPO_ROOT; restoration proven by git hash-object equality with the HEAD blob (42ee5d021d7414fc1e2f80ccae3b15752d88e9b8 both sides) plus an empty 'git diff HEAD' and the guard call site back at 1. NO REBUILD LEG APPLIES AND NONE IS CLAIMED: both the mutated module and the test live in packages/runtime/src and the test imports './app-plugin.js' relatively, so vitest's in-package transform resolves it, not dist; packages/runtime/dist/index.js was hashed identical before and after the leg (694336ebd39e8d994b8c851c90ff5e8178d5f7c0), making dist a controlled constant that cannot explain the flip. GATES: re-derived against my ACTUAL diff with 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands' (no paths passed; it takes its own change set from merge base). 36 commands, all 36 run individually with exit codes captured before any pipe: 33 PASS. The other 3 exited 3, which each script's OWN verdict text declares NOT MEASURED and explicitly 'not a red': check-test-completeness needs a saved 'turbo run test' log it was not handed; scripts/pm/check-half-states.mjs cannot make a repo-scoped REST request in this container; check:dual-build-cjs-loads needs a full 'pnpm build' and 8 unrelated packages have no dist because I built only the showcase closure. check:system-context-census passed unchanged — my diff moved no anchor, so no --fix was needed. ONE HONEST NOT MEASURED: packages/runtime/tsconfig.json excludes '**/*.test.ts', so the green runtime typecheck says NOTHING about the new test file. Measured rather than assumed — 'tsc --noEmit --listFiles' lists src/app-plugin.ts (1 hit) and does NOT list the new test file (0 hits). That gap is the pre-existing posture of every test file in packages/runtime and is ALREADY FILED as #14504 by the #14309 seat, so I did not file a duplicate. DECLARED NARROWING: repo-wide scans (pnpm lint and the rest of the farm) were left to CI, which runs the farm exactly once; what ran locally is the targeted set plus the 36 derived families above. Every heavy command went through scripts/pm/os-verify-lock.sh; the closure build needed 634s under the lock and exceeded the container's ~10-minute foreground cap, so it was completed under a foreground blocking watch rather than by ending a round.",
      "mcp_calls": "10 — every GitHub read went through mcp__github__*; repo-scoped REST is refused in this container (check-half-states measured GET /repos/... -> 403 with a healthy /rate_limit), so REST was never a usable channel here",
      "open_questions": [
        {
          "question": "#14309 and #14442 are the same defect, and #14309's fix is already implemented in open draft PR #14499. Should this branch become a second PR, be dropped, or be folded into #14499?",
          "options": [
            "A - Drop this branch; land PR #14499 alone. One defect, one fix. #14499 is the broader repair: it drops unserializable members by SHAPE at SchemaRegistry.installPackage, so it also covers install paths AppPlugin never touches (HTTP install, sys_packages rehydrate) and any future live member, and it adds a route-level projection at both package doors. Cost: the wiring still travels into the registration payload and is filtered only at storage, and the structural projection also drops function-valued members such as onEnable from the record.",
            "B - Land both. They share no file and cannot conflict; mine keeps the wiring out of the payload at the source, using the kernel's Plugin contract rather than a structural heuristic, and #14499 keeps the storage-seam floor for every other path. Cost: two mechanisms for one defect, and the next reader must work out which one is load-bearing.",
            "C - Fold: close this card as a duplicate of #14309 and, if the precision is wanted, re-file the app-plugin narrowing as its own small card judged on its own merits rather than as a fix for an already-fixed 500."
          ],
          "recommendation": "A, with C as the cheap follow-up if the precision is wanted. Real business need: the user-visible need is 'Studio's package list works on showcase', and PR #14499 already satisfies it on a measured live boot; there is zero additional pull for a second fix. Long-term soundness: #14499 sits at the seam where the invariant actually belongs — the registry item is a record, so 'a record is serializable' is the storage seam's property, and it holds for install paths that never go through AppPlugin; a second, narrower guard upstream is not wrong but makes the real floor harder to locate. Making AI-written code hard to get wrong: a structural 'records hold only data' rule cannot be defeated by a new key name, whereas my Plugin.init predicate is exact today but silent about the next kind of live member — the broader rule is the safer default for AI-authored stacks. Startup scope discipline: shipping a second fix for a closed defect is precisely the un-pulled expansion to refuse. The one thing worth checking before A is landed rather than assumed: #14499 drops function-valued manifest members (its own report notes onEnable disappearing from the record) — that is a real behavioural change my narrower fix does not make, and it deserves a deliberate yes from the reviewer rather than arriving as a side effect."
        },
        {
          "question": "If A is chosen, should this card be closed as a duplicate of #14309, or kept open to carry the showcase-side acceptance evidence?",
          "options": [
            "A - Close #14442 as a duplicate of #14309 once PR #14499 lands, linking the evidence here.",
            "B - Keep #14442 open until a post-merge showcase boot re-confirms all four doors on main, then close."
          ],
          "recommendation": "B, narrowly. #14499's live verification predates several main merges and its PR is currently mergeable_state 'dirty', and this card is the one carrying the duplicate door and the sys_packages persist check (the row-count and the 411,662-byte persisted manifest above). One post-merge boot closes both cards on measured evidence rather than on inference."
        }
      ],
      "out_of_scope_findings": [
        "NOT filed, already tracked as #14504 (filed by the #14309 seat): packages/runtime type-checks none of its test files — tsconfig.json excludes the test globs and the package has no tsconfig.test.json, no check:test-typecheck script and no test-typecheck-debt.json. I re-measured it independently here with 'tsc --noEmit --listFiles' (0 hits for my new test file, 1 hit for src/app-plugin.ts). Dedup search ran before filing and the empty result was validated by a same-session control query that did hit, so this is a read rather than a silence.",
        "NOT filed, reported here for PM triage instead: #14309 and #14442 are duplicate cards for one defect, dispatched to two seats about five hours apart, and both were worked to completion. #14309 was already labelled pm:dispatched and had a claim comment on it at 02:17 today; #14442 was filed at 07:08 and claimed at 09:08. Whatever the disposition of the two fixes, the dedup miss at filing/triage time is the reusable finding — a search of open issues for the error string 'Converting circular structure to JSON' returns both cards today."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  4. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    Disposition (PM seat, session_01UHvF5hyiZjnCyExFnfQB8m): duplicate of #14309, whose fix is already open as draft PR #14499 (installPackage stores a serializable projection; both package doors project onto the declared record fields). One defect, one fix — option A from the os-dev report: this branch does not become a second PR.

    Release gating for the ADR-0130 chain moves to PR #14499 landing; the post-merge showcase re-boot that was going to close this card is now #14309's to run.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingpm:queuepriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions