Skip to content

feat: add marketplace skill catalog and align API contracts - #6

Merged
Jerry-Xin merged 66 commits into
Mininglamp-OSS:mainfrom
LarsonLian:main
Jul 21, 2026
Merged

feat: add marketplace skill catalog and align API contracts#6
Jerry-Xin merged 66 commits into
Mininglamp-OSS:mainfrom
LarsonLian:main

Conversation

@LarsonLian

@LarsonLian LarsonLian commented Jul 19, 2026

Copy link
Copy Markdown

Summary

  • Add the marketplace Skill catalog surface, including public listing/detail/mine flows, category/tag support, upload/parse/download/reupload flows, and bot publish support.
  • Add admin Skill and category operations, SuperAdmin-backed admin authentication, safe package rewrite/storage handling, and soft-delete artifact cleanup.
  • Add Redis-backed metrics tracking with durable flush recovery, marketplace storage/config hardening, generated OpenAPI output, and tests for the load-bearing parse, metrics, tag, delete, and auth paths.

Linked Spec

  • Closes Track marketplace skill catalog and API contract alignment #8
  • .octospec/tasks/marketplace-openapi/brief.md
  • .octospec/tasks/mcp-catalog-v1/brief.md
  • .octospec/tasks/admin-auth-superadmin/brief.md
  • .octospec/tasks/metrics-durable-flush/brief.md
  • .octospec/tasks/skill-soft-delete/brief.md
  • .octospec/tasks/global-skill-tags/brief.md

How verified

  • go test ./...
  • make openapi-check
  • docker compose --env-file /dev/null config >/dev/null
  • Rebased local main onto Mininglamp-OSS/octo-marketplace:main before pushing the PR head branch.

COMPREHENSION

  1. What does this change actually do to the load-bearing path?
    It turns the marketplace scaffold into a working Skill marketplace backend. Before this change, Skill package ingestion, parsing, catalog persistence, download metadata, admin mutation, bot publish, tags/categories, metrics persistence, and OpenAPI coverage were incomplete or absent. After this change, uploaded packages are parsed into parse tasks, committed into immutable Skill versions with rewritten package metadata, exposed through public and authenticated catalog APIs, managed through SuperAdmin-gated admin APIs, counted through Redis-to-DB metrics flushing, and documented through generated OpenAPI.

  2. What could break because of it (dependents + failure mode)?
    API clients depend on the generated OpenAPI request/response envelopes, especially Skill list pagination, tag fields, upload/parse states, admin auth, and error codes. Octo Web and octo-cli depend on stable upload/download URLs, parse task lifecycle transitions, and catalog filters. Operators depend on migrations, Redis flush recovery, storage configuration, and soft-delete cleanup. The main failure modes are contract drift, cross-Space authorization mistakes, parse tasks stuck outside terminal states, orphaned storage objects, dropped metrics, and config defaults that behave differently between local and production.

  3. How do you know it works (specific test/repro/trace)?
    The full Go test suite passes with go test ./..., covering parse task recovery, upload validation, Skill create/update/delete behavior, admin mutations, tag resolution, storage rewrite behavior, metrics flush recovery, middleware/auth paths, and API integration tests. make openapi-check passes coverage, generation/drift verification, and Spectral lint for docs/openapi/swagger.yaml. docker compose --env-file /dev/null config >/dev/null verifies the committed compose file remains renderable without requiring local secrets.

@github-actions github-actions Bot added size/XL PR size: XL dependencies-changed This PR modifies dependency files labels Jul 19, 2026
@github-actions

Copy link
Copy Markdown

Dependency Changes Detected

This PR modifies dependency files. Please review whether these changes are intentional.

Changed files:

  • go.mod
  • go.sum

Maintainer checklist:

  • Confirm dependency changes are intentional
  • Review package delta if lockfile changed

@lml2468 lml2468 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at head a16e3d46 — ✅ APPROVE

大型多特性 PR(90 文件 +12678/−588,~40 commits:bot publish 主线 + metrics/categories/lazy-recovery/admin CRUD 等)。按 blast-radius 深核最高风险面(bot 发布授权/隔离)+ 跑全量 build/test 门。

Gate 1 — 规格符合度 ✅

匹配描述:creator_id/creator_nameowner_id 分离(owner=账号做查询/权限,creator=实际发布者含 bot);基于既有 presigned upload 的 bot 发布流(init→PUT→publish by upload/parse-task);首版 changelog + 响应暴露 creator 元数据。

Gate 2 — 代码质量(重点:发布授权 + 隔离 + 迁移)✅

  • creator/owner 全服务端派生,不信 body:Create handler 只从 middleware.Identity(c)UserID/UserName,CreateRequest body 无 creator 字段;bot 路径(upload/handler.go:180-193)OwnerID=identity.UID(已验证人类账号)、CreatorID=bot.BotUID(来自 middleware.BotIdentity 验证过的 bot token)、SpaceID=bot.SpaceID——均服务端来源
  • dev spoof 面已 prod-gate:X-Dev-Bot-Uid 覆盖只在 devBotMode = !authEnabled(router.go:182,AUTH_ENABLED=false 本地)可达;prod bot 身份只来自验证 token。非生产伪造向量。
  • 发布授权 gate 正确:service Create 校验 pt.OwnerID != p.UserID(不能用他人 parse-task/upload 发布)+ pt.SpaceID != p.SpaceID(跨 space 隔离)+ pt.Status=="success" + parse-task 消费幂等(ErrParseTaskConsumed)。
  • 迁移安全:本 PR 新迁移(20260717-*)up 段纯附加(ADD COLUMN ... DEFAULT '')、backfill 幂等(WHERE creator_id='' / current_version_id='');DROP 仅在 -- +migrate Down 回滚段(sql-migrate 约定);20260714-* 的 DROP 属 pre-existing、非本 PR。
  • 验证:go build ./... + go vet ./... exit 0;go test 覆盖 parse/skill/upload/handler/metrics 全 ok(parse 61s、metrics 10s 真跑)。
  • 无泄露:diff 密钥扫描无真凭证(占位符/minioadmin/env 读取)。

覆盖范围与盲点(诚实标注)

深核了 bot-publish 授权/空间隔离(最高 blast radius)+ 迁移安全 + 全量 build/test。因 PR 跨 ~8 个特性域、1.5w 行,未逐行审计每个周边特性(metrics flush worker、lazy-recovery 多 pod、admin CRUD 细节)——这些有各自单测覆盖且 test 门绿。若需对某一子系统(如 metrics Redis→DB flush 的并发/一致性)做专项深审,可单独再拉一轮。

对抗检查(未发现问题)

  • 客户端能否伪造 creator/owner → 否(body 无字段,服务端派生;dev 覆盖仅 !authEnabled)。
  • 能否借他人 upload/parse-task 发布或跨 space → 否(OwnerID + SpaceID 双校验)。
  • 迁移 apply 是否破坏现有数据 → 否(up 纯附加 + 幂等 backfill,DROP 仅 down)。

Verdict: APPROVE — bot 发布授权服务端权威、parse-task 所有权 + space 隔离双 gate、迁移向后兼容、build/vet/test 全绿、无泄露。可合并。已标注:大 PR 的周边特性未逐行审计,依赖其单测 + 绿门。

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR is relevant to octo-marketplace, but availability and required verification failures block merging.

🔴 Blocking

  • 🔴 Critical — Bot publishing bypasses the configured worker pool. ParseUploadSync calls worker.process directly, while concurrency limiting is only applied in Submit. Concurrent bot requests can therefore start unlimited archive downloads, extraction, and parsing, exhausting CPU, memory, disk, and database connections. The work also uses a background context rather than the request context at worker.go:89. Route synchronous work through the semaphore and preserve cancellation/timeout behavior.

  • 🔴 Critical — The required OpenAPI gate fails. BotPublishSkill, AdminDownload, and AdminCreate lack @Router annotations; the new /bot/skills/publish route registered at handler.go:56 is consequently absent from the committed specification. make openapi-check reports failed coverage, spec drift, and 19 lint errors. Add the missing contracts and resolve all gate errors.

  • 🔴 Critical — The full test suite fails. go test ./... fails TestAdminDeleteCategoryEmpty and TestAdminDeleteCategoryNotFound. The implementation now performs a soft-delete UPDATE at category/admin.go:35, but the integration tests still expect DELETE FROM categories at integration_test.go:189 and integration_test.go:224. Update and verify the tests before merge.

💬 Non-blocking

  • 🟡 Warning — This PR contains several large unrelated feature sets—metrics, admin APIs, category soft deletion, parse recovery, and UI prototypes. Splitting these would make migration, security, and rollback review substantially safer.

✅ Highlights

  • Bot identity separates the owning account from the displayed creator.
  • Skill creation verifies both parse-task owner and Space.
  • Initial version creation and parse-task consumption are transactional.
  • Focused publish, ownership, storage, and creator-response tests were added.

@lml2468 lml2468 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

修正:撤回上一轮 APPROVE → REQUEST_CHANGES(head a16e3d46)

我上一轮只跑了 PR 描述点名的 5 个包、没跑全量 go test ./... 也没跑 make openapi-check——漏了两条真 blocker。Jerry-Xin 抓得对,我 byte 复核确认,公开纠正。这是我的 build-verification 门失误。

🔴 测试失败(自动 blocker)—— internal/api/integration 2 条红

go test ./... 实跑:

  • TestAdminDeleteCategoryEmpty:integration_test.go:195 status=500 want=200
  • TestAdminDeleteCategoryNotFound:integration_test.go:230 status=500 want=404
    失败路径是本 PR 引入的 admin category 软删除(internal/repository/category/admin.go:32-35 Delete 软删 + service 层)。空分类删除应 200、不存在应 404,现都返回 INTERNAL_ERROR 500。按 review 纪律:测试挂 = 自动 REQUEST_CHANGES

🔴 openapi-check 失败(合同门红)

make openapi-check:coverage ❌ + verify(gen+drift)❌。根因:swag 解析 internal/service/skill.SkillItemjson.RawMessage 类型定义找不到(admin.go 注解)→ openapi-gen 报错 → 合同覆盖/漂移门红。lint 仅 1 warning(tags.get 缺 404)。这是 required-looking 合同门,红着不能合。

🔴(Jerry-Xin #1)bot 发布绕过 worker-pool 并发限制

Jerry-Xin 报 bot publish 路径绕开 parse worker 池的并发上限、可能资源耗尽。这条我未独立深追到底(上轮聚焦授权/隔离面)——据其描述成立、属可修的可靠性 blocker,建议作者连同上面两条一并确认;我下轮会 byte 复核这条的具体绕过点。

🟡 拆分建议

PR 跨 ~8 特性域 1.5w 行(bot publish + metrics + categories + lazy-recovery + admin CRUD…),建议拆分便于审查/回滚——也正是这种巨型 PR 让我上轮漏跑全量测试。

我上轮 APPROVE 为何错

我按 PR 描述的 "verified" 列表只跑了 parse/skill/upload/handler/metrics 5 包(全绿),没跑 go test ./...——integration 包不在那 5 个里,恰好是红的;也没跑 make openapi-check。授权/隔离/迁移那部分我核得没错,但 build 门只做了一半。这是硬失误,收回 APPROVE。

Verdict: REQUEST_CHANGES — 全量测试 2 红(本 PR 的 admin 软删除)+ openapi-check 合同门红(json.RawMessage 注解)+ Jerry-Xin 的 worker-pool 并发绕过。修法:修 admin 软删除 DELETE 的 500(空/不存在应 200/404)、修 openapi 注解让 gen 通过、加 bot 发布并发限制;并建议拆分。修好复审。

致谢 Jerry-Xin 抓到我漏跑的全量测试 + openapi 门。

@yujiawei yujiawei left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #6 (octo-marketplace)

Reviewed at head SHA a16e3d46698acce3534864d7cc6dbebd718ebe13 against main (merge-base bc4e940). go build ./... is clean and the test packages named in the PR description pass locally.

Summary

This PR is titled "add bot skill publish flow" but the branch carries ~39 commits and touches 90 files (+12.7k/-588): category UUID + soft-delete, a Redis→DB metrics flush worker, parse "lazy recovery", admin CRUD, skill versioning, tags, and the bot publish flow itself. The review below focuses on the new publish path and spot-checks the larger bundled surface; see the coverage note at the end for what was not exhaustively verified.

The named feature is implemented as described: creator_id/creator_name separation from owner_id, and a one-step bot publish (presigned init → PUT → publish by skill_upload_id/upload_url). Ownership and space isolation are enforced correctly (ParseUploadSync checks task.OwnerID == identity.UID; skill.Create re-checks parse-task owner + SpaceID), so the upload_url-derived skill_upload_id in uploadIDFromLink is not an IDOR — an attacker-supplied id still fails the owner check. The dev-only botIdentity fallback is correctly gated to AUTH_ENABLED=false (SetDevBotMode(!authEnabled)), so it is not a production bypass.

There is one blocking concern (P1) on the new endpoint. Requesting changes.

Blocking

P1 — /bot/skills/publish runs parsing unbounded and synchronously, ignoring request cancellation

Service.ParseUploadSync calls worker.process(...) directly (internal/service/parse/service.go:242), bypassing the bounded worker pool. The semaphore that limits concurrent parses is acquired only in Worker.Submit (internal/service/parse/worker.go:76), never in process. As a result the publish endpoint has no concurrency bound: N concurrent publishes perform N simultaneous object downloads + full in-memory zip reads (skill.Create does io.ReadAll of the archive, up to the max upload size — internal/service/skill/service.go:271) + extraction and rewrite. A handful of parallel large publishes from a bot (or retries) can exhaust memory/CPU/storage connections and degrade the whole API.

This is compounded by process deriving its timeout from context.Background() (internal/service/parse/worker.go:89) rather than the request context threaded into ParseUploadSync. Consequences:

  • A disconnected client does not cancel the in-flight parse — work continues for up to parseTimeout (default 1m).
  • The HTTP handler blocks synchronously for that full duration, so the unbounded fan-out above also ties up request goroutines.

Suggested fix: route the synchronous publish through the same bounded pool (acquire the pool semaphore before process, or add a dedicated bounded path), and derive the parse context from c.Request.Context() with a timeout so client disconnect and shutdown propagate.

Non-blocking

P2 — Metrics flush worker is not awaited on shutdown

cmd/marketplace-api/main.go launches go fw.Start(flushCtx) (:127) but on SIGTERM only calls flushCancel() then publicServer.Shutdown(ctx) (:171-:174) and returns — it never waits for the flush goroutine to finish. If a flush is mid-batch when the process exits, the in-flight batch can be lost. Impact is limited (metrics deltas are best-effort and failed members are re-queued via an independent context), but a sync.WaitGroup/done-channel join before returning would close the window cleanly.

P2 — scanSkillRow column-count branching is fragile

scanSkillRow selects the legacy scan layout when len(cols) == 25 and otherwise assumes the 27-column layout (internal/repository/skill/list.go:253). Any future query that selects a different column count (e.g. a 26-column compatibility/projection query) silently takes the wrong branch and fails every scan with an arg-count/type mismatch. Prefer keying the layout on the presence of the creator_id/creator_name columns by name, or unify the SELECT lists so a single scan shape is always used.

Coverage note

Given the 90-file scope, I verified the bot publish path, its auth/ownership/space checks, the creator_* migration and repository scan changes, and the flush-worker shutdown lifecycle end-to-end. I did not exhaustively audit: the metrics resolver/repository internals and Redis transaction integrity under contention, the category soft-delete/UUID migration data-path against existing rows, the parse lazy-recovery race under real multi-pod load, or the version-storage rewrite for all archive shapes. The two migrations sharing the -09- sequence number (20260717-09 and 20260719-09) sort deterministically by full filename under the sql-migrate embed source, so ordering is fine.

@lml2468 lml2468 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at live head 9f284033REQUEST_CHANGES(维持)

新 commit 9f284033(feat(parse): accept skill package uploads,加 .skill 扩展名)没有修我上轮的三条 🔴、反而又加了新 scope。三条 blocker byte 复核仍在:

🔴 #3(未修)测试仍失败 — internal/api/integration

go test ./... 实跑仍红:

  • TestAdminDeleteCategoryEmpty:integration_test.go:195 status=500 want=200
  • TestAdminDeleteCategoryNotFound:integration_test.go:230 status=500 want=404
    新 commit 动了 integration_test.go 但没对齐 admin 软删除的实现/期望——两条依旧 FAIL。

🔴 #2(未修)openapi-check 仍红

make openapi-check:coverage ❌(handler 缺 @Router)+ verify ❌(SkillItemjson.RawMessage 找不到类型定义 → gen 报错)。annotations 本轮未触。

🔴 #1(未修)bot 发布绕过 worker-pool 并发限制

parse/service.go:242 ParseUploadSync 仍直接 s.worker.process(task.ID, ...),绕过只有 Submit(:207)才走的 sem 信号量池 → 并发发布无上限。与 yujiawei 的 CR 一致。

🟡 拆分(承前)

巨型多特性 PR 建议拆分。

Verdict: REQUEST_CHANGES(维持) — 新 commit 未修任何一条 blocker(测试 2 红 / openapi 门红 / 并发绕过),且增了 .skill scope。三条修法不变:修 admin 软删除 DELETE 的 500→200/404、补 @Router+修 json.RawMessage 注解、bot 发布走 sem 池 + 请求 ctx。修好复审。三方(我 + Jerry-Xin + yujiawei)在并发面收敛。

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at head 9f284033

New commit since my last review (a16e3d46): a single commit feat(parse): accept skill package uploads (ahead_by:1, behind_by:0 — not a rebase). The true delta is 3 files: internal/service/parse/service.go, internal/service/parse/service_test.go, internal/api/integration/integration_test.go. It only adds .skill as an accepted upload extension alongside .zip.

That new feature is fine on its own (its tests pass, go vet clean, and extraction still opens the payload as a zip via zip.OpenReader regardless of extension). However, none of the three blocking issues from the previous review were addressed. All three are re-verified as still broken at this head.

🔴 Blocking (all still open)

  • 🔴 Integration tests still fail (soft-delete vs. mock mismatch). internal/repository/category/admin.go Delete still soft-deletes (UPDATE categories SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL), but internal/api/integration/integration_test.go:189,224 still mock DELETE FROM categories. Re-running at this head:

    • TestAdminDeleteCategoryEmpty: got 500 want 200 — FAIL
    • TestAdminDeleteCategoryNotFound: got 500 want 404 — FAIL

    Root cause is unchanged in production code: the empty-category and not-found delete paths return INTERNAL_ERROR (500) instead of 200/404. Fix the handler so RowsAffected() == 0 maps to not-found (404) and an empty category deletes successfully (200); the mocks must then match the real UPDATE statement. This must be fixed in the production path, not by massaging the test to expect 500.

  • 🔴 OpenAPI docs incomplete / spec not regenerated. Handler.BotPublishSkill (internal/api/handler/upload/handler.go:103-105) still has only a plain comment with no @Router/@Summary annotations, so /bot/skills/publish is still absent from the generated docs/ swagger. json.RawMessage fields (e.g. internal/repository/skill/*, internal/model/skill.go Tags) that break spec generation are unchanged. Add the godoc annotations for the new endpoint, resolve the json.RawMessage typing for the generator, and regenerate/commit the spec so the docs pipeline is green.

  • 🔴 Unbounded concurrency in the bot-publish sync path. Byte-verified unchanged: internal/service/parse/service.go:242 ParseUploadSync still calls s.worker.process(task.ID, task.FileURL, maxBytes) directly, bypassing the sem semaphore that is only acquired in Worker.Submit (internal/service/parse/worker.go). Concurrent bot publishes can therefore start unbounded parallel downloads/extractions. Route this path through the bounded worker (acquire sem) and thread the request context.Context through instead of relying on the direct call.

💬 Non-blocking

  • The stale doc comment on ErrInvalidFileName (internal/service/parse/service.go:54-55) still says "safe .zip basename" even though .skill is now accepted — update the wording.
  • Prior 🟡 still stands: this PR bundles several unrelated feature sets (metrics / categories / lazy-recovery / admin CRUD / publish); splitting would make review and rollback safer.

✅ Highlights

  • The .skill extension addition is clean: isSupportedSkillPackageName is well-scoped, unsafe-name rejection is extended (skill.tar.gz correctly rejected), and the new table-driven tests pass.
  • Previously-clean surfaces (authz derivation, zip/upload hardening, additive migrations) were not touched by this delta and remain fine.

Keeping this as Request changes until the three blocking items above are resolved with tests and the OpenAPI pipeline green.

@yujiawei yujiawei left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #6 (octo-marketplace)

Reviewed at head SHA 9f284033476d54cea9f0b49788284aa15233d86c against main (merge-base bc4e9405). go build ./... and the touched test packages (service/skill, service/parse, api/handler/upload, api/handler/skill, service/metrics) all pass locally.

Verdict: CHANGES_REQUESTED — two blocking correctness/security issues in the publish path (artifact immutability + a re-validation gap), plus several advisory items.


1. Scope check

The PR does what the description claims (creator vs owner separation, bot presigned publish flow, initial changelog, creator metadata in responses). Note that the diff is substantially broader than the summary suggests — it also lands category soft-delete + a UUID re-keying migration, a Space-scoped tag system, a resource-metrics table with a Redis flush worker, new list sort modes, an admin download route, and stale-parse recovery (12.7k additions / 90 files). None of that is wrong on its face, but please expand the PR description so reviewers and future archaeologists know these subsystems shipped here. This is a request to document, not a blocker.

2. Blocking issues (P1)

P1-1 — Reupload can overwrite an immutable artifact and then delete it

internal/service/skill/service.go:475-621

On the reupload (PUT) path, version is taken from the request/parse result (:475) with no check that it collides with an existing version. The rewritten zip is uploaded to the deterministic key skills/{id}/v{version}/skill.zip before the version row is inserted:

zipObjectKey := fmt.Sprintf("skills/%s/v%s/skill.zip", id, version) // :550
if err := s.store.PutObject(ctx, zipObjectKey, ...); err != nil { ... } // :554
...
err = s.repo.UpdateSkillAndConsumeTask(ctx, id, repoParams, ..., &model.SkillVersion{...}) // :595
if err != nil {
    go func() {
        _ = s.store.DeleteObject(context.Background(), zipObjectKey)      // :606
        _ = s.store.DeleteObject(context.Background(), skillMdObjectKey)
    }()

skill_versions has UNIQUE KEY uk_skill_version (skill_id, version) (migrations/sql/20260715-03-skill-versions.sql:10). If a caller reuploads with a version that already exists:

  1. PutObject overwrites the already-published version's object (violates the repo's "published artifacts are immutable" rule).
  2. The version-row insert fails on the unique key.
  3. The error-cleanup goroutine then DeleteObjects the shared keys — deleting the surviving version's artifact.

Net effect: an existing, published version's file is corrupted or lost. Two concurrent same-version publishes race the same way.

Fix: reject an already-existing (skill_id, version) before uploading; upload to a request-unique staging key and promote only after the DB commit; ensure cleanup only ever deletes keys unique to the failed request.

P1-2 — Publish re-reads the object without re-validating (TOCTOU + unbounded read)

internal/service/skill/service.go:267-271 (create) and :502-506 (update)

Content validation (SKILL.md presence, name/description rules, size limits, name uniqueness) runs during parse, in ExtractZip/the parse worker. But at publish time the service re-downloads the object and rewrites it:

zipReader, err := s.store.GetObject(ctx, pt.FileURL)
...
zipData, err := io.ReadAll(zipReader)      // :271 / :506  — unbounded

RewriteZipPackage only locates SKILL.md and rewrites frontmatter; it does not re-run any of the parse-time validation, and neither Create nor Update re-verifies the file_sha256 that the parse worker computed. In the multi-step (non-bot) flow — InitUpload → PUT → TriggerParse → poll → Create — the presigned PUT URL issued at InitUpload stays valid for an hour, so a caller can re-PUT a different archive to the same key after parse succeeds and before Create runs. The swapped archive is then published as-is. Because io.ReadAll here is unbounded (the maxMB cap is enforced only by the parse-time LimitReader, not on this re-download), a swapped oversized archive is also an OOM vector; the whole zip and the rewritten copy are held in memory.

Fix: fail closed on a sha256 mismatch between the parse-recorded digest and the re-downloaded bytes; wrap the re-download in a LimitReader bounded by maxMB; and preferably freeze the upload object at parse success (e.g. move/rename it out of the caller-writable key) so it cannot be replaced between parse and publish.

3. Advisory issues (P2)

P2-1 — name/version overrides bypass validation and flow into storage keys

internal/service/skill/service.go:225-238, :305

Request-supplied Name/Version override the validated parse results, and version is interpolated straight into the object key skills/%s/v%s/skill.zip with no validation (there is no version validator anywhere in internal/). A version containing /, \, .., or control characters produces arbitrary/nested object keys and inconsistent DB records. Apply the same canonical name check and a semver grammar check to the overrides before building keys.

P2-2 — Category unique-name vs soft-delete are mutually incompatible

migrations/sql/20260717-08-category-name-unique.sql:2, migrations/sql/20260719-09-category-soft-delete-uuid.sql, internal/repository/category/admin.go

UNIQUE INDEX uk_categories_name (name) is global, but soft-deleted rows keep their name (delete only sets deleted_at). After a category is soft-deleted, re-creating one with the same name fails the unique key forever and surfaces as ErrCategoryAlreadyExists, even though the name is invisible in every active query. Make uniqueness apply to active rows only (e.g. a generated column that is NULL when deleted, uniquely indexed), or restore/rename the soft-deleted row on re-create.

P2-3 — Backfill of current_version_id is nondeterministic

migrations/sql/20260717-06-backfill-current-version.sql:5-14

WHERE created_at = (SELECT MAX(created_at) ...) can match multiple rows because TIMESTAMP is second-precision; the join then yields several version_ids per skill and the chosen current_version_id is arbitrary. Use a deterministic tiebreaker, e.g. ROW_NUMBER() OVER (PARTITION BY skill_id ORDER BY created_at DESC, id DESC) = 1.

P2-4 — Flush worker is not awaited on shutdown; Redis client not closed

cmd/marketplace-api/main.go:110-133, :171

flushCancel() is called at shutdown but the code does not wait for the fw.Start goroutine to return before the process exits, so an in-flight flush can be cut mid-cycle. The worker's independent-context requeue/restore logic (internal/service/metrics/flush_worker.go:135-145, :188-192) means this is not the permanent metric loss it might first appear to be — but a crash in the narrow window between GETSET and requeue still drops a batch. Add a sync.WaitGroup/done channel and wait (bounded) after flushCancel(), and defer rdb.Close().

P2-5 — Invalid REDIS_URL fails open silently

cmd/marketplace-api/main.go:117-123

If REDIS_URL is set but unparseable, the worker is silently disabled and only a log line is emitted; metrics increments then accumulate in Redis with nothing draining them. Consider failing startup (or degrading readiness) when REDIS_URL is present but invalid, rather than booting as if metrics were off.

4. Suggestions (non-blocking)

  • ParseUploadSync (internal/service/parse/service.go:242) calls w.process(...) directly, bypassing the worker-pool semaphore. Under many concurrent bot publishes this runs unbounded parse+download+decompress work inline; consider routing through the bounded pool (or a separate bounded path).
  • The stale-parse recovery (TryRecoverStaleParsing) uses a correct atomic guarded UPDATE — good. Worth a targeted test for the "two pods race, exactly one wins" case if not already covered.

5. Coverage / blind spots

  • Verified by reading the diff + surrounding code and running go build and the touched test packages. Did not run the migrations against a live MySQL, exercise the OSS/S3 runtime, or drive the end-to-end presigned-PUT → publish flow against real storage.
  • The category UUID re-keying migration (20260719-09) updates skills.category_id and categories.id inside a TEMPORARY TABLE map — assumes no other FK references the old slug IDs; not independently verified against the full schema.
  • Frontend prototypes (design/*.html), docs, and MCP code were only skimmed.

@lml2468 lml2468 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at live head 4c13ba05REQUEST_CHANGES(1 修 2 存)

新 commit(feat: improve skill marketplace admin taxonomy)修好了测试 🔴,但另两条 blocker 仍在。byte + 实跑核实:

🔴→✅ #3 集成测试已修

go test ./... 实跑:22 包全 ok、0 FAIL。上轮的 TestAdminDeleteCategoryEmpty(500→200)/TestAdminDeleteCategoryNotFound(500→404)现已对齐——admin 软删除返回码修对了。✅

🔴(仍在)#1 bot 发布绕过 worker-pool 并发限制

parse/service.go:242 ParseUploadSync直接 s.worker.process(...);而并发上限的信号量只在 Submit 里获取(worker.go:76-77 w.sem <- struct{}{} + defer 释放,sem cap=poolSize)。process 自身不 acquire → 同步发布路径绕过池上限,N 个并发发布 = N 个并发下载/解压,无 cap。parse/service.go 本轮未触。修:ParseUploadSync 也走 sem(或复用 Submit 的获取/释放语义)+ 传请求 ctx(现仍 context.Background())。

🔴(仍在)#2 openapi-check 仍红

make openapi-check:coverage ❌ + verify ❌。

  • BotPublishSkill(upload/handler.go:105)仍@Router/@summary → 100% 覆盖门 fail、/bot/skills/publish 不进 swagger。
  • SkillItemTags json.RawMessage(service.go:69/model/skill.go:32)仍让 swag gen 报 cannot find type definition: json.RawMessage → verify(gen)fail。本轮均未触。修:给 BotPublishSkill 补 swag 注解;json.RawMessage 字段加 swaggertype:"array,string"(或等价)让 gen 通过。

Claim B(Steve/yujiawei)——读已加界,DoS 半消

更正一下:发布重读的无界读其实已被 worker 兜住——downloadToFile(worker.go:183-201)有 io.LimitReader(rc, maxBytes+1) + n>maxBytes 溢出拒 + SHA256 计算。所以 CLAIM B 的内存爆(无界 ReadAll)这半不成立(读是有界的)。剩 TOCTOU 的 SHA 再校验(重读字节 == 解析记录 FileSHA256 的比对)建议作者确认是否在 sync-publish 边界做了;但因读已 bounded,我不把它单列 🔴、降为建议核实。

🟡 承前

拆分巨型 PR;ErrInvalidFileName 注释未反映 .skill

Verdict: REQUEST_CHANGES — 测试 🔴 已修 ✅,但 #1(sem 池绕过,ParseUploadSync 直调 process)+ #2(openapi 覆盖/gen 双红)仍是 blocker。修这两条 + 建议核 SHA 再校验即可复审。Claim B 的无界读已被 worker LimitReader 兜住、这半撤销。

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at head 4c13ba05 (2nd re-review). This is a rebase-free single-commit delta on top of the previously reviewed 9f284033 (compare: ahead_by=1, behind_by=0). The new commit ("improve skill marketplace admin taxonomy") is a focused taxonomy change and does not touch three of the four standing blockers. Full local build + go test ./... are green.

Standing blocker status (byte-verified / re-run at 4c13ba0)

🔴 #1 — Category delete tests — FIXED (legitimate).
go test ./internal/api/integration -run TestAdminDeleteCategory -v:

  • TestAdminDeleteCategoryEmpty → 200 PASS
  • TestAdminDeleteCategoryInUse → 409 PASS
  • TestAdminDeleteCategoryNotFound → 404 PASS

This was a broken test, not broken prod. Prod category.Service.Delete already soft-deletes and returns ErrCategoryNotFound when RowsAffected == 0 (→404), ErrCategoryInUse when count>0 (→409), nil otherwise (→200). The test mock was corrected from DELETE FROM categories to UPDATE categories, which is what the repository actually issues. Not test-massaging — it aligns the mock with real prod behavior. Resolved.

🔴 #2 — OpenAPI annotations — NOT FIXED.
internal/api/upload/handler.go is untouched in this delta. Handler.BotPublishSkill still has no @Router/@Summary godoc, so /bot/skills/publish remains absent from generated docs; json.RawMessage fields in the skill structs are still present (swag/openapi-gen hazard). (Byte-verified via delta scope + source, not via a make target — the repo has no openapi-check target.)

🔴 #3 — Unbounded parse concurrency — NOT FIXED.
internal/service/parse/service.go untouched. ParseUploadSync (line 242) still calls s.worker.process(task.ID, task.FileURL, maxBytes) directly, bypassing the sem semaphore that is only acquired inside Worker.Submit. TriggerParse (207) and the recovery path (373) both correctly go through Submit; the sync path does not. Concurrent bot publishes still spawn unbounded parallel downloads/extractions. Route it through Submit (or acquire sem explicitly) and thread the request context.

🔴 #4 — Publish re-read: unbounded read + TOCTOU — NOT FIXED (both halves open).
internal/service/skill/service.go untouched. Both re-read sites still do a bare io.ReadAll(zipReader) with no size cap:

  • publish path (:271)
  • admin reupload path (:506)

No io.LimitReader(maxBytes+1) + overflow check, and no re-verification of SHA256 against the parse-recorded FileSHA256 before RewriteZipPackage. The presigned-PUT window + non-deleted temp object still allow a post-parse swap to an oversized/altered zip → OOM DoS + validation bypass. Add both the LimitReader (erroring on overflow, not silently truncating) and the SHA256 recheck.

🟡 A — Blob-overwrite ordering — NOT FIXED. PutObject still precedes the unique-key DB insert; on unique-violation rollback the best-effort DeleteObject can destroy the prior version's artifact while its DB row survives (dangling ref / download 404). Check version-conflict before PutObject, or key by immutable versionID.

🟡 C — Stale comment — NOT FIXED. internal/service/parse/service.go:54-55 ErrInvalidFileName still says "safe .zip basename" though .skill is now accepted.

New delta review (taxonomy commit)

Clean and well-covered. ListTags now unions Space-local + global (space_id IN (?, ?) with GlobalTagSpaceID="") and dedups via ROW_NUMBER() OVER (PARTITION BY name ...) so the Space-local row wins; the extra prepended spaceID arg correctly binds the window CASE WHEN space_id = ? placeholder. Admin create/update/reupload now use GlobalTagSpaceID consistently and pass ownerID through UpdateWithTags/AdminUpdateSkillAndConsumeTask. New tests (tags_test.go +69, admin_test.go +72, integration updates) cover it. Taxonomy migrations (09/10/11) are data-remap + additive; DROPs are temp-table cleanup within Up or confined to Down sections. No new security/concurrency regression introduced.

Verdict

Blocker #1 is genuinely resolved. Blockers #2, #3, #4 (and 🟡A, 🟡C) are untouched by this commit and remain open. Requesting changes until the publish/parse hardening (#2/#3/#4) lands. go build ./... and go test ./... pass at this head.

@lml2468 lml2468 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

修正:我上一条错误撤销了 Claim B —— 它是真 🔴,恢复(head 4c13ba05)

我上一条说"发布重读已被 worker LimitReader 兜住、Claim B 内存爆半不成立"——那是核错了函数。我查的是 parse worker 的 downloadToFile(那条确实 bounded),但 Claim B 指的是 skill 服务的发布重读路径,是另一条、确实无界的代码。Jerry-Xin / yujiawei 对,我错,公开纠正。

🔴(恢复)Claim B — 发布重读无界 + 无 SHA 再校验

byte 实证(本 head):

  • service.go:267-271(Create 发布):s.store.GetObject(ctx, pt.FileURL)io.ReadAll(zipReader),LimitReader、无 size cap
  • service.go:502-506(Update/reupload):同款裸 io.ReadAll,同样无界。
  • 附近 SHA256 把重读字节与 pt.FileSHA256 比对(解析阶段算过 SHA,发布重读不复验)。
    两个真伤害:(a) 属主在预签名 PUT 的 TTL 窗口内把同 key 换成多 GB blob,发布 io.ReadAll OOM DoS;(b) 解析时传合法小 zip 过校验、发布前换内容 → TOCTOU 校验绕过,发布的不是解析过的字节。worker 的 downloadToFile(:196 有 LimitReader)是解析路径、盖不到这条服务层发布重读。修一处堵两头:发布重读 io.LimitReader(zipReader, maxBytes+1) + 溢出拒 + sha256(zipData) == pt.FileSHA256 不符即拒。

我为何撤错

上条我 grep 到 worker.go:196 的 LimitReader 就以为发布重读有界了——没区分 parse worker 读(bounded)与 skill service 发布重读(unbounded)是两条路径。这是我的核实失误,收回撤销。

其余状态不变(本 head 4c13ba05)

  • 🔴→✅ #3 测试已修(go test ./... 22 包 0 FAIL)。
  • 🔴 #1 并发绕过仍在(ParseUploadSync:242 直调 worker.process 绕过 sem 池)。
  • 🔴 #2 openapi 仍红(BotPublishSkill@Router + json.RawMessage gen 破)。
  • 🟡 拆分 / ErrInvalidFileName 注释 / Claim A 的 blob 覆盖顺序(悬垂,🟡)。

Verdict: REQUEST_CHANGES — 现四条 blocker:#1 sem 池绕过 + #2 openapi 双红 + Claim B 发布重读无界/无 SHA 复验(我纠回)。测试 🔴 已清。谢谢 Jerry-Xin/yujiawei 顶住 Claim B——我上条撤销是核错路径,以此为准。

@yujiawei yujiawei left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #6 (octo-marketplace)

Reviewed at head SHA 4c13ba055cc62585be1c99f8a079ced3e4ffef90 against merge-base bc4e940. This is a large change (94 files, +13,083/−627) that bundles the bot publish flow together with metrics/download tracking, admin skill & category CRUD, category UUID + soft-delete + scene taxonomy, and parse poll lazy-recovery. go build ./..., go vet ./..., and the skill/parse/metrics/upload package tests all pass locally.

Two blocking issues below (a publish-time TOCTOU and a category soft-delete/unique-index regression). Everything else is non-blocking.

Blocking

P1 — Publish re-downloads the upload object with no re-validation (TOCTOU) — internal/service/skill/service.go:267

The presigned PUT URL from InitUpload is valid for one hour (internal/service/parse/service.go:92, ExpiresIn: 3600) and the parse task's FileURL continues to point at that same writable upload key — parse does not relocate the object. At publish time, Create re-fetches that key and reads it fully into memory:

zipReader, err := s.store.GetObject(ctx, pt.FileURL)
...
zipData, err := io.ReadAll(zipReader) // unbounded; no size cap, no SHA check

Between successful parse and publish, the caller can PUT a different archive to the same URL. The consequences:

  1. Validation bypass. The parser (internal/service/parse/zip.go) enforces zip-slip rejection, symlink rejection, per-file and 50 MB total decompressed-size caps, and 1 MB SKILL.md cap. RewriteZipPackage (internal/service/skill/rewrite.go:102) copies every non-SKILL.md entry verbatim with no independent path/symlink/entry-count/decompressed-size checks. A replacement archive containing traversal entries, symlinks, or a decompression bomb is therefore published unchecked and then served to other consumers via the presigned download (GetDownloadInfoPresignGet, internal/service/skill/service.go), who extract it.
  2. Memory exhaustion. io.ReadAll on the re-downloaded object has no size limit, unlike the parse path which uses io.LimitReader(rc, maxBytes+1) (internal/service/parse/worker.go:196). A large replacement object is read entirely into the API process heap.

The same pattern exists on the admin/reupload path at internal/service/skill/service.go:502.

Suggested fix: verify the re-downloaded bytes against the SHA-256 recorded at parse time (the field exists — pt.FileSHA256) and reject on mismatch, and/or bound the read with io.LimitReader. Ideally run the same zip-entry validation used at parse time before copying entries verbatim, so the published artifact can never contain something the parser would have rejected.

P1 — Category soft-delete added without adjusting the global unique-name index — migrations/sql/20260719-09-category-soft-delete-uuid.sql + migrations/sql/20260717-08-category-name-unique.sql

20260717-08 adds a plain global unique index:

ALTER TABLE categories ADD UNIQUE INDEX uk_categories_name (name);

20260719-09 then introduces deleted_at soft-deletion, and all category read paths filter deleted_at IS NULL (internal/repository/category/admin.go). But the unique index still counts soft-deleted rows, so after an admin deletes a category, Create with the same name fails with a duplicate-key error surfaced as "category already exists" (internal/repository/category/repo.goErrCategoryNameTaken), even though the name is invisible everywhere in the UI. The name is permanently reserved.

This repo already solved exactly this problem for the MCP catalog using a generated live-name column (migrations/sql/20260714-05-mcp-uniqueness.sql: name_live = IF(deleted_at IS NULL, name, NULL) with a unique index over the live column). Applying the same pattern to categories.name would keep the constraint correct under soft-delete.

Non-blocking

P2 — Flush worker is not awaited on shutdown — cmd/marketplace-api/main.go:172

On SIGINT/SIGTERM, main calls flushCancel() and then returns as soon as publicServer.Shutdown(ctx) completes, without waiting for the go fw.Start(flushCtx) goroutine to finish its in-flight flush. The impact is bounded — the worker uses independent contexts for lock release, counter restore, and dirty-set requeue (internal/service/metrics/flush_worker.go), and the distributed lock has a 120s TTL — but the final in-progress batch can still be dropped on shutdown. Since these are best-effort view/download counters this is minor; a sync.WaitGroup awaited before returning would close the gap cleanly.

P2 — Invalid REDIS_URL fails open — cmd/marketplace-api/main.go:117-130

If REDIS_URL is set but goredis.ParseURL fails, the service logs a warning and boots with the flush worker disabled, silently persisting no metrics. When a URL is explicitly provided, an invalid value should be a fatal startup error rather than a silent degrade.

P2 — MarkRetryExhausted error discarded — internal/service/parse/service.go:354

if task.Attempts >= s.maxAttempts {
    _ = s.repo.MarkRetryExhausted(ctx, task.ID)
    result.Status = "failed"

If the DB write fails, the API still reports terminal failure while the row stays parsing, so a later poll can report a different state (and a still-running worker could overwrite it with success). Consider propagating the error and returning the current parsing status on failure.

P2 — Backfill picks a nondeterministic version under 1-second collisions — migrations/sql/20260717-06-backfill-current-version.sql:7

The "latest version" subquery matches on MAX(created_at), but created_at is second-precision. Multiple versions created within the same second all match, so the joined UPDATE can set skills.current_version_id to an arbitrary (non-current) version. Tie-break on id (or an explicit sequence) to make it deterministic. One-time migration, low likelihood, but worth hardening.

Notes / suggestions (non-blocking)

  • The offset-based list pagination (comprehensive/downloads/views) has no upper bound on offset (internal/api/handler/skill/handler.go:589), and the comprehensive sort orders by a non-indexable time-decay expression (internal/repository/skill/list.go:80). Deep pagination could get expensive on large tables; consider a max offset or keyset pagination.
  • The PR title ("add bot skill publish flow") substantially under-describes the change — it also lands metrics, admin CRUD, and a category taxonomy migration set. Splitting unrelated feature groups into separate PRs would make review and rollback far easier next time.

What was verified vs. not

Verified by reading code + running build/vet/tests: bot-publish ownership enforcement is correct (ParseUploadSync/GetParseStatus check task.OwnerID == ownerID; reupload checks skill.OwnerID == identity.UID); creator_id/creator_name are taken from the server-resolved bot token, not client input (internal/middleware/auth.go:120-130), so they cannot be spoofed (the X-Dev-Bot-* header fallback is gated behind dev-mode only); the stale-parse recovery SQL is atomic (TryRecoverStaleParsing, single conditional UPDATE); and the SKILL_PARSE_STALE_TIMEOUT > SKILL_PARSE_TIMEOUT invariant is enforced at startup (internal/config/config.go:179, called via ValidateAPI + log.Fatal).

Not exhaustively reviewed: the full metrics resolver layer, the admin category HTTP handlers, and areas of the 94-file diff unrelated to publish/metrics/taxonomy. Integration tests requiring MySQL/Redis/object storage were not executed in this environment.

@LarsonLian LarsonLian changed the title feat: add bot skill publish flow feat: add marketplace skill catalog and align API contracts Jul 20, 2026

@lml2468 lml2468 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at live head 6ba541a4REQUEST_CHANGES(2 修 2 存)

fix: align marketplace api contracts + public skills globally visible 修好了两条 blocker,另两条仍在。byte + 实跑核实:

✅ 已修

  • #1 并发绕过 → ✅:ParseUploadSync:242 改走新 worker.ProcessSync(ctx,...),它先获取 sem(w.sem <- struct{}{} + ctx-cancel select + defer 释放)再 process,并传请求 ctx。并发上限 + 可取消,修对。
  • #2 openapi-check → ✅:make openapi-check 四闸全过(coverage → gen → verify → lint)。BotPublishSkill 补了 @Routerjson.RawMessage 的 gen 破也解了(spec 与 baseline 一致)。
  • #3 集成测试 → ✅:go test ./... 全绿(承上轮)。

🔴(仍在)Claim B — 发布重读无界 + 无 SHA 再校验

service.go:271(Create)/:506(Update)仍是裸 io.ReadAll(GetObject(pt.FileURL)),附近无 LimitReader、无 sha256(zipData)==pt.FileSHA256 复验(grep 该段两项均空)。本轮 service.go 动了 63 行但没碰这条重读。→ 属主在预签名 TTL 内换大 blob → 发布 OOM;换内容 → TOCTOU 校验绕过。修:两处发布重读加 io.LimitReader(maxBytes+1) + 溢出拒 + SHA 复验。

🔴(仍在)分类软删除 vs 唯一索引

uk_categories_name(name)(20260717-08)仍是裸 name,无 (name, deleted_at) 复合/部分索引;repository/category/admin.go:9 Create 仍是纯 INSERT、不复活软删行、不排除已删行判重。→ 软删一个分类后同名重建撞唯一键 永久失败。修:唯一索引改 (name, deleted_at) 复合(或生成列部分唯一),或 Create 命中软删行就复活。

🟡 承前

ErrInvalidFileName 注释、拆分、Claim A blob 覆盖顺序。

Verdict: REQUEST_CHANGES#1 并发 + #2 openapi + #3 测试已修 ✅,但 Claim B(发布重读无界/无 SHA)+ 分类唯一索引冲突两条仍开。修这两条即可复审。进展明显、就差这两处。

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR is relevant to Octo Marketplace, but published artifacts can be corrupted during duplicate-version reuploads.

🔴 Blocking

  • 🔴 Critical — Reuploading an existing version overwrites the immutable published objects before the database detects the duplicate. The object keys are derived only from skill ID and version, then uploaded before inserting into skill_versions, whose (skill_id, version) unique constraint rejects duplicates. Error cleanup subsequently deletes those same published objects, leaving the current version broken. This affects both user reuploads at service.go and admin reuploads at admin.go. Reject duplicate versions before storage mutation or use immutable, content-addressed/version-record-specific object keys. Add regression tests covering both paths.

💬 Non-blocking

  • 🟡 Warning — visibility is cast without validation at service.go. Unsupported values reach the MySQL ENUM, producing a 500 in strict mode rather than a validation error. Validate public, space, and private at the API/service boundary.

  • 🟡 Warning — The metrics flush resets Redis counters before the database commit at flush_worker.go. A process crash between GETSET and the upsert permanently loses metrics. Consider an acknowledged queue or transactional pending-delta design.

✅ Highlights

  • Tenant visibility checks and parse-task consumption are generally explicit.
  • Archive rewriting and storage behavior have substantial test coverage.
  • go test ./... passes.

@Jerry-Xin
Jerry-Xin dismissed their stale review July 20, 2026 07:24

Superseded by a corrected review with repo-relative paths (same REQUEST_CHANGES verdict).

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at head 6ba541a4. Build is green and go test ./... passes, and three of the four standing blockers are now resolved — but two independent 🔴 issues remain (one standing, one newly surfaced), so this still can't merge.

Standing blocker status (byte-verified at this head)

  • 🔴 (A) Publish/reupload re-read is UNBOUNDED and NOT re-verified — STILL OPEN. The temp-zip re-read sinks in internal/service/skill/service.go remain bare io.ReadAll(GetObject(...)) with no size cap and no SHA-256 re-check against the parse-task digest:

    • internal/service/skill/service.go:271 (publish path)
    • internal/service/skill/service.go:506 (reupload path)
    • internal/service/skill/service.go:869 (SKILL.md read)
    • internal/service/skill/admin.go follows the same pattern (admin publish/reupload).

    There is no io.LimitReader anywhere in internal/service/skill/, and every ZipSHA256/FileSHA256 reference is either the freshly-computed digest of the rewritten archive or plain DB plumbing — none re-verifies the downloaded bytes against the expected FileSHA256. This leaves both an OOM vector (attacker-controlled/oversized object read fully into memory) and a TOCTOU gap (object mutated in storage between parse and publish is accepted without re-verification). Wrap each re-read in io.LimitReader(reader, maxBytes) and recompute + compare SHA-256 against the recorded parse-task digest before rewriting/publishing. (Note: internal/service/parse/worker.go is already bounded + verified — that is a different path; the publish/reupload sinks above are not.)

  • 🔴 (new) Duplicate-version reupload can corrupt the currently-published artifact. Object keys are derived only from skill ID + version, and the rewritten zip/SKILL.md are uploaded via PutObject before the DB insert whose (skill_id, version) unique constraint would reject the duplicate. On duplicate, the objects are already overwritten; the error-path cleanup then DeleteObjects those same keys, leaving the existing published version broken. Affects internal/service/skill/service.go:549 (user reupload) and internal/service/skill/admin.go:502 (admin reupload). Reject duplicate versions before any storage mutation, or use immutable, version-record-specific object keys. Add regression tests for both paths.

Resolved since prior review (verified)

  • (B) Bot publish no longer bypasses the worker pool. ParseUploadSync now routes through worker.ProcessSync (internal/service/parse/service.go:242), which acquires the same bounded semaphore via select { case w.sem <- struct{}{}: ...; case <-ctx.Done(): return ctx.Err() } and threads the request context into process(ctx, ...). Concurrency is now bounded on the synchronous path and cancellation/timeout is preserved.
  • (C) OpenAPI contract is in sync. make openapi-check passes all four gates at this head (coverage → gen → verify/drift → lint): "Generated spec matches committed baseline" and zero lint errors.
  • (D) Category name uniqueness enforced at DB level. migrations/sql/20260717-08-category-name-unique.sql adds UNIQUE INDEX uk_categories_name (name) and no later migration drops it; duplicate categories are prevented.

💬 Non-blocking

  • 🟡 visibility is cast to the DB ENUM without validation at internal/service/skill/service.go:682; an unsupported value yields a 500 in strict mode instead of a clean validation error. Validate public/space/private at the boundary.
  • 🟡 Metrics flush resets Redis counters before the DB commit at internal/service/metrics/flush_worker.go:207; a crash between GETSET and the upsert permanently loses metrics. Consider a transactional pending-delta or acknowledged-queue design.

✅ Highlights

  • Tenant visibility checks and parse-task consumption are explicit.
  • Archive rewriting and storage behavior have solid test coverage.
  • go build ./... and go test ./... are green; OpenAPI gate is clean.

Verdict: REQUEST_CHANGES — resolve (A) unbounded/unverified re-read and the duplicate-version reupload corruption before merge.

@yujiawei yujiawei left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #6 (octo-marketplace)

Reviewed at head SHA 6ba541a4f8dfded7290114510e3b0c3d959b6850 against merge-base bc4e9405. This is a large change (~14.5k lines, 98 files) adding the skill catalog, upload/parse/download pipeline, metrics counting (Redis + flush worker), admin skill/category CRUD, global skill tags, and OpenAPI contract alignment. Reviewed in two stages: spec compliance, then code quality. Verified locally: go build ./... passes and the metrics / skill / parse package tests pass.

1. Spec compliance

Spec: ✅

  • Missing scope: none. The linked issue's scope (catalog, upload/parse/download, metrics, admin CRUD, contract alignment) and the global-skill-tags brief are all implemented. Global tags land in the space_id = '' bucket (internal/repository/skill/tags.go:14), admin create/update/reupload sync into it (internal/repository/skill/admin.go:191, internal/service/skill/admin.go:226,302), user create/update tags stay Space-scoped (internal/repository/skill/create.go:72,170, update.go:142), and ListTags returns global + local with local-wins-over-global via a ROW_NUMBER() partition (tags.go:44-59). Matches the brief's load-bearing list exactly.
  • Out-of-scope additions: none blocking. skill_tags.space_id stays NOT NULL (empty string as the global bucket) rather than nullable, honoring the brief's "out of scope" note.
  • Deviations: none. Success/error envelopes, offset/cursor pagination, and the fixed error-code enum follow the documented contract; make openapi-check drift is expected because swagger.yaml is intentionally regenerated.

2. Code quality

Quality: Approved

No correctness or security defect rises to blocking (P0/P1). The security-sensitive paths hold up: archive extraction rejects zip-slip / absolute paths / symlinks and enforces size caps; Create re-checks pt.SpaceID == p.SpaceID before consuming a parse task (internal/service/skill/service.go:205); list queries scope by visibility + space; the flush worker's distributed lock uses a value-checked Lua release and restores counters to Redis on DB failure. The items below are advisory (P2) — worth a follow-up, not a merge blocker.

P2 — advisory

  • Parse trigger/poll authorize by owner only, not (owner, space)internal/service/parse/service.go:190,223,309. TriggerParse / ParseUploadSync / GetParseStatus check task.OwnerID != ownerID but do not also assert the caller's active Space matches task.SpaceID, even though the row carries it. This is not a cross-tenant leak — the task belongs to the same user, and the terminal Create re-checks pt.SpaceID. But a user who belongs to multiple Spaces could trigger/poll their own upload from a different active Space than the one it was initialized in. Recommend threading spaceID through and adding task.SpaceID == spaceID as defense-in-depth, consistent with the skill Get/Update paths.

  • Presigned PUT does not bind the declared sizeinternal/service/parse/service.go:79-92 + internal/storage/oss.go:91. InitUpload validates the client-declared file_size, but the generated presigned PUT carries no signed content-length / policy, so an authenticated caller can declare file_size: 1 and upload a multi-GB object. Parsing later rejects it (via LimitReader), but the oversized object is already stored and not cleaned up — a storage-cost / quota-exhaustion vector. Consider a content-length condition in the presign policy, or a best-effort delete of the temp object when parse rejects it for size. (The local-storage proxy path already caps via http.MaxBytesReader, local_proxy.go:29; only the OSS presign path is unbounded.)

  • Flush worker can drop a popped batch on shutdown, and additive UPSERT retry can double-countinternal/service/metrics/flush_worker.go:110-130,257-276. Two bounded edge cases: (1) SPopN removes a whole batch up front; if the context is cancelled mid-loop, unprocessed popped members are neither flushed nor re-added to metrics:dirty, so their counters stall until the next event re-SADDs them. (2) UpsertCounts is a non-idempotent count = count + VALUES(...); if MySQL commits but the response is lost, the retry adds the delta again. Both are consistent with the documented "v1 best-effort" metrics semantics and the impact is minor over-/under-count, so non-blocking — but worth a follow-up (e.g. re-SADD remaining popped members on ctx.Err(), and gate retries on a driver-level "unknown outcome" check).

  • No worker drain on shutdowncmd/marketplace-api/main.go:174-180. main cancels flushCtx and shuts the HTTP server but never waits for the flush-worker goroutine (or the parse worker pool, which has a Wait()). A flush in flight at SIGTERM is cut off. Minor given the restore/requeue logic, but adding a short drain would tighten graceful shutdown.

Nits (non-blocking)

  • main.go:122-137 / router.go:146-155 each construct their own Redis client from REDIS_URL. Consider sharing one validated (Ping-checked) client to avoid split init states. A malformed REDIS_URL currently disables metrics silently and still starts the server — acceptable for an optional dependency, but a startup log at WARN/ERROR (already present) is the right minimum.
  • .gitignore adds a bare octo-web entry — if that directory ever coexists with this repo it would be silently ignored; scope it (e.g. /octo-web/) if it's meant to ignore a sibling checkout only.

3. Overall verdict

APPROVE — Spec ✅ and Quality Approved. No blocking defects. The four P2 items (parse-path space assertion, presign size binding, flush shutdown/idempotency, worker drain) are good hardening follow-ups but do not gate merge, especially given the documented v1 best-effort metrics posture.

4. Suggestions

  • Add spaceID to the parse trigger/poll authorization checks to mirror the skill Get/Update space guard.
  • Bind upload size in the presign policy, or delete the temp object when parse rejects it for size.
  • On flush shutdown, re-SADD any popped-but-unprocessed members before returning.

5. Additional observations

  • Modifying an already-applied migration (migrations/sql/20260715-00-seed-categories.sql) is normally risky, but here it's safe: sql-migrate keys on filename, so existing deployments won't re-run it, and the later 20260719-10/-11 taxonomy migrations idempotently remap/rename categories and move skills off retired ones. New deployments get the new seed directly. No action needed — just flagging the pattern.
  • Admin download/get/update/delete correctly gate on visibility == "public", keeping admin surface off Space/private skills.

@yujiawei yujiawei left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #6 (octo-marketplace) — CORRECTED

This supersedes my earlier APPROVE on this same head SHA (6ba541a4). After cross-checking against other reviews landed at this head and re-verifying byte-by-byte, I confirm two blocking (P1) defects I missed on the first pass, plus one that others raised. My earlier approval was wrong and I am retracting it. Corrected verdict: REQUEST_CHANGES.

Verified locally at head 6ba541a4: go build ./... and the metrics/skill/parse package tests pass — but green tests do not cover the paths below.

1. Spec compliance

Spec: ✅ (unchanged) — catalog, upload/parse/download, metrics, admin CRUD, contract alignment, and the global-skill-tags brief are all implemented; global-vs-local tag precedence via ROW_NUMBER() matches the brief; no missing/extra/deviated scope. Spec passing does not clear the quality gate below.

2. Code quality

Quality: Changes-Requested

P1 — blocking

  • Publish/reupload re-read is unbounded and not integrity-verified. internal/service/skill/service.go:271 (create/publish) and :506 (reupload), plus internal/service/skill/admin.go (admin publish/reupload) and the SKILL.md read at service.go:869, all do a bare io.ReadAll(s.store.GetObject(pt.FileURL)). There is no io.LimitReader anywhere in internal/service/skill/ and no re-check of the downloaded bytes' SHA-256 against the parse task's recorded pt.FileSHA256 (grep confirms both absent). The parse worker path (internal/service/parse/worker.go) is bounded+hashed, but that is a different path — these publish sinks are not. Two consequences: (a) OOM / memory-exhaustion — the object is attacker-influenced and can be swapped for a multi-GB blob within the 1h presign TTL, read fully into RAM; (b) TOCTOU — the object can be mutated in storage between parse and publish and is accepted without re-verification, so the digest stored on the version record no longer describes the bytes that were parsed/validated. Fix: wrap each re-read in io.LimitReader(reader, maxBytes+1) with an overflow rejection, and recompute SHA-256 and compare to pt.FileSHA256 before RewriteZipPackage/publish.

  • Duplicate-version reupload corrupts the currently-published artifact. skill_versions has UNIQUE KEY uk_skill_version (skill_id, version) (migrations/sql/20260715-03-skill-versions.sql:10). In reupload (service.go:549-608, and admin admin.go), the object keys are derived only from skill id + version (skills/{id}/v{version}/skill.zip and .../SKILL.md), and the rewritten zip + SKILL.md are PutObject'd before the transactional insert whose unique constraint rejects the duplicate (skill_id, version). Because the keys are identical to the existing version's, the PutObject overwrites the live artifact; then the DB insert fails on the unique key, and the error path (service.go:605-608) DeleteObjects exactly those keys — leaving the previously-published version with a deleted/garbled artifact. Fix: reject a duplicate (skill_id, version) before any storage mutation, or key objects by the immutable per-version-record id so a rejected reupload cannot touch existing bytes. Add regression tests for both user and admin reupload.

  • Category soft-delete permanently collides with the bare-name unique index. uk_categories_name (name) is on bare name (migrations/sql/20260717-08-category-name-unique.sql); deleted_at was added later (20260719-09) without changing the index, and Repo.Create (internal/repository/category/admin.go:9) is a plain INSERT that neither excludes nor revives soft-deleted rows. So once a category is soft-deleted, re-creating a category with the same name fails forever on the unique key (ER_DUP_ENTRY). Fix: make the unique constraint (name, deleted_at) (or a generated-column partial unique), or have Create revive a matching soft-deleted row.

P2 — advisory (from my first pass, still valid)

  • Parse trigger/poll authorize by owner only, not (owner, space)internal/service/parse/service.go:190,223,309. Not a cross-tenant leak (same user; Create re-checks pt.SpaceID), but add the space assertion as defense-in-depth.
  • Presigned PUT does not bind the declared size (service.go:79-92 + storage/oss.go:91) — oversized objects can be stored and are not cleaned up. (Related to, but distinct from, the P1 re-read: this is the storage-cost angle; the P1 is the in-memory read + integrity angle.)
  • Flush worker SPopN can drop a popped batch on shutdown; additive UPSERT retry can double-count (flush_worker.go:110-130,257-276). Consistent with documented v1 best-effort metrics, but worth hardening. Note the counter-reset-before-DB-commit window (GETSET then upsert) can also lose a batch on crash.
  • No flush-worker drain on shutdown (cmd/marketplace-api/main.go:174-180).
  • visibility cast to DB ENUM without boundary validation (service.go:682) → 500 instead of a clean 400 on an unsupported value.

3. Overall verdict

REQUEST_CHANGES — Spec ✅ but Quality Changes-Requested. Three P1 defects (unbounded/unverified publish re-read; duplicate-version reupload artifact corruption; category soft-delete unique-index collision) must be fixed before merge. I explicitly retract my earlier APPROVE on this head SHA.

4. Suggestions

  • Bound and hash-verify every temp-zip / SKILL.md re-read in internal/service/skill/ against pt.FileSHA256.
  • Reject duplicate (skill_id, version) before any PutObject, or use immutable per-version object keys.
  • Change the category unique index to (name, deleted_at) or revive soft-deleted rows on Create.
  • Add the space assertion to parse trigger/poll; validate visibility at the boundary.

5. Additional observations

  • Modifying the already-applied seed migration (20260715-00) is safe here (sql-migrate keys on filename; 20260719-10/-11 idempotently remap), so no action there.
  • Good: tenant visibility checks, parse-task consumption as a lock, archive-rewrite test coverage, and the OpenAPI gate are all solid — the blockers are localized to the publish/reupload storage path and the category index.

@lml2468 lml2468 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at live head fa2e1970 — ✅ APPROVE(撤回 CR,全部 blocker 已修)

fix(skill): harden publish artifact handling + fix(category): allow recreating soft-deleted names 修好了我剩的两条 blocker,byte + 实跑核实。历轮五条 blocker 至此全清。

🔴→✅ Claim B — 发布重读无界 + 无 SHA 复验

新增 readVerifiedTempZip(service.go:873):pt.FileSize > s.maxArchiveBytes 预检 + readLimited(io.LimitReader(reader, maxBytes+1) + 溢出拒,:899)+ sha256(data) == pt.FileSHA256 复验(:891-894,不符即 sha256 mismatch 拒)。Create(:282)与 Update(:509)两条发布路径都改调它,grep 确认无残留 io.ReadAll(GetObject(pt.FileURL))。OOM + TOCTOU 两头堵死。回归:TestReadVerifiedTempZipRejectsDigestMismatch / ...RejectsOversizedObject

🔴→✅ 分类软删除唯一索引

新迁移 20260720-01:DROP 裸 uk_categories_name,加生成列 name_live = IF(deleted_at IS NULL, name, NULL) STORED + UNIQUE(name_live)。软删行 name_live=NULL(MySQL 唯一索引视 NULL 为相异)→ 同名可重建。正是我建议的生成列部分唯一方案。回归:category/repo_test.go

🟡→✅(bonus)Claim A — 重复版本覆盖已发布产物

TestUpdateDuplicateVersionDoesNotDeletePublishedObjects + admin 变体覆盖:重复版本 reupload 不再覆盖/删除已发布 artifact。

历轮 blocker 全清回顾

  1. 并发绕 sem → ✅(ProcessSync + sem)
  2. openapi-check → ✅(四闸全过)
  3. 集成测试 → ✅(全绿)
  4. Claim B 发布重读无界/无 SHA → ✅(readVerifiedTempZip)
  5. 分类软删除唯一索引 → ✅(name_live 生成列)

验证

go build ./... exit 0;go test ./... 全绿(含新 security_test.go 的 digest-mismatch / oversized / dup-version 用例)。

Verdict: APPROVE — 五条 blocker 全部 byte 消除、发布重读 bounded+SHA 复验、分类名 live-unique、dup-version 不损产物、build/test 全绿。可合并。这轮从 5 blocker 一路修到干净,作者收口到位。

(承前 🟡 非阻塞:PR 仍偏大建议拆、ErrInvalidFileName 注释——不拦合并。)

@mochashanyao mochashanyao left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Octo-Q · automated review]

Verdict: Request changes — blocking findings below (data-flow traced).


Code Review — PR #6 (octo-marketplace)

Reviewer: Octo-Q (automated review)
Head SHA: fa2e1970cf13af40e822cb9ef208cc01719029a8
Scope: feat: add marketplace skill catalog and align API contracts


Summary

Large feature PR introducing the full skill marketplace lifecycle: upload → parse → publish → download, plus admin CRUD, metrics tracking (Redis counters + flush worker), category taxonomy, and SQL migrations. The code is well-structured with clear separation of concerns (handler → service → repo). However, I found 2 P1 blocking issues in the metrics pipeline and admin download path, plus several P2 concerns.


Verification Conclusions

Transaction safety in skill creationCreateSkillAndConsumeTask and AdminUpdateSkillAndConsumeTask correctly use parse task status transition (successconsumed) as a pessimistic lock against duplicate creation/consumption. (internal/repository/skill/create.go, admin.go)

Visibility modelcanView() correctly implements 3-tier logic: public=anyone, space=same-space, private=owner+same-space. All user-facing read paths go through it. (internal/service/skill/service.go)

Admin auth — All admin routes mounted under adminAuth.Handler() middleware. Handler-level middleware.Identity(c) checks provide defense-in-depth. (internal/api/handler/skill/admin.go, internal/api/router/router.go)

Zip rewrite integrityreadVerifiedTempZip verifies SHA-256 before rewrite; RewriteZipPackage preserves vendor metadata while injecting canonical fields. (internal/service/skill/service.go:873-896, rewrite.go)

Graceful shutdown — Flush worker cancel before HTTP server shutdown ensures in-flight flush completes. (cmd/marketplace-api/main.go:148-153)

Error mapping — Handlers comprehensively map domain errors to HTTP status codes (404/400/409). (internal/api/handler/skill/admin.go)


Findings

P1 — Redis Pipeline non-atomic: metric deltas permanently lost on partial failure

File: internal/redis/client.go:49-53
Diff-scope: New code (this PR)

pipe := c.rdb.Pipeline()
pipe.Incr(ctx, counterKey)
pipe.SAdd(ctx, dirtySetKey, dirtyMember)
_, err := pipe.Exec(ctx)

Pipeline() sends commands as a batch but is NOT atomic — if the connection drops between INCR and SADD, the counter is incremented in Redis but the resource is never marked dirty. The flush worker (flush_worker.go) discovers work via SPOP from the dirty set, so this delta is permanently lost — never persisted to resource_metrics.

Impact: Under Redis connection instability (which is exactly when you need metrics to be reliable for dashboards), metric increments silently vanish. This is user-visible: download/view counts on the marketplace UI will undercount.

Fix: Use TxPipeline() (wraps in MULTI/EXEC) so both commands either succeed or fail together:

pipe := c.rdb.TxPipeline()

R1 check: Makes a working path (metric tracking) silently produce wrong data in production → P1 blocking.


P1 — AdminDownload skips download metrics tracking

File: internal/api/handler/upload/handler.goAdminDownload handler
Diff-scope: New code (this PR)

The user-facing Download handler tracks metrics:

// Download handler (user path)
if h.metricsSvc != nil {
    _ = h.metricsSvc.TrackDownload(ctx, "skill", skillID)
}

But AdminDownload only calls AdminGetDownloadInfo and returns the URL — no TrackDownload call. This means downloads initiated through admin tooling (which is the primary way octo-admin provisions and tests skills) are invisible to the metrics system.

Impact: Download counts — used in sort-by-popularity (SortDownloads) and the comprehensive ranking formula (downloads * 5 + views * 1 + decay) — are systematically undercounted. Skills heavily downloaded via admin will rank lower than they should.

R1 check: Produces wrong data visible to end users (marketplace rankings) → P1 blocking.


P2 — AdminCreate: partial object storage leak on SKILL.md upload failure

File: internal/service/skill/admin.go:~190-200
Diff-scope: New code (this PR)

// Upload zip
if err := s.store.PutObject(ctx, zipObjectKey, ...); err != nil {
    return nil, fmt.Errorf("upload zip: %w", err)  // ← no cleanup needed yet
}
// Upload SKILL.md
if err := s.store.PutObject(ctx, skillMdObjectKey, ...); err != nil {
    _ = s.store.DeleteObject(ctx, zipObjectKey)  // ✅ cleans up zip
    return nil, fmt.Errorf("upload skill md: %w", err)
}

This path is correct. However, compare with AdminReupload (line ~490-500) which has the same pattern — also correct. The concern is that the user-facing Create path (service.go) uses async goroutines for cleanup while admin uses synchronous cleanup. If the async goroutine in user Create panics or the server crashes between DB failure and cleanup, objects leak. This is pre-existing design, not introduced by this PR, but the inconsistency between user and admin paths suggests no unified cleanup strategy.

Recommendation: Consider extracting a cleanupObjects(keys ...string) helper used by both paths.


P2 — Flush worker: counter restoration can compound on repeated DB failures

File: internal/service/metrics/flush_worker.goprocessMember + restoreCounters
Diff-scope: New code (this PR)

Flow: getAndResetCounters (GETSET to 0) → upsert to DB → if DB fails → restoreCounters (INCRBY to add back). The restored counters re-dirty the member, so next flush cycle retries. If DB is down for N cycles, the counter accumulates N cycles of new increments on top of restored deltas.

This is correct behavior (additive accumulation), but: if a single counter is restored multiple times and new traffic keeps coming, the counter value in Redis grows without bound until DB recovers. No circuit breaker or DLQ — a permanently broken DB row will accumulate Redis memory indefinitely.

Recommendation: Add a max-retry counter per dirty member (e.g., give up after 10 consecutive failures and log an alert).


P2 — Migration 20260717-08 UNIQUE index on categories.name may fail on existing data

File: migrations/sql/20260717-08-category-name-unique.sql
Diff-scope: New migration (this PR)

This adds UNIQUE INDEX uk_categories_name (name) on the categories table. If any duplicate category names exist at the time this migration runs, it will fail hard and block all subsequent migrations.

This is especially risky because migration 20260719-10 (taxonomy remap) later renames categories to resolve duplicates — but by then, migration 08's unique constraint already blocks the path.

Recommendation: Either (a) merge the unique constraint into migration 10 after dedup, or (b) add a pre-check that deletes/merges duplicates before adding the constraint.


P2 — getAndResetCounters uses deprecated GETSET command

File: internal/service/metrics/flush_worker.gogetAndResetCounters
Diff-scope: New code (this PR)

pipe.GetSet(ctx, viewKey, 0)
pipe.GetSet(ctx, downloadKey, 0)
pipe.GetSet(ctx, installKey, 0)

GETSET is deprecated since Redis 6.2 in favor of SET key value GET. While go-redis still supports it, future Redis versions may remove it. Low urgency but easy fix:

pipe.SetGet(ctx, viewKey, 0)

P2 — AdminUpdateSkillAndConsumeTask doesn't explicitly set updated_at

File: internal/repository/skill/admin.go:152-155
Diff-scope: New code (this PR)

The transactional update builds SET clauses via buildUpdateSets(p) which only includes fields present in UpdateParams. If no fields change (e.g., only a new version is inserted), the UPDATE may touch zero columns and updated_at won't advance (assuming ON UPDATE CURRENT_TIMESTAMP).

If the skills table uses ON UPDATE CURRENT_TIMESTAMP (common MySQL pattern), this is auto-handled. Verify the DDL.


Nit — AdminList comprehensive sort formula: POW with potential negative base

File: internal/repository/skill/admin.go:83-86
Diff-scope: New code (this PR)

20 / POW(TIMESTAMPDIFF(HOUR, s.created_at, NOW()) / 24 + 2, 1.2)

If created_at is in the future (clock skew), TIMESTAMPDIFF returns negative. POW(negative, 1.2) returns NULL in MySQL (fractional power of negative). The COALESCE on the outer expression doesn't protect against this since it's inside the POW. Impact: skills with future created_at get NULL sort weight and sort unpredictably. Extremely unlikely in practice.


Nit — envInt rejects zero values

File: internal/config/config.go:193-198
Diff-scope: Modified (this PR)

func envInt(key string, fallback int) int {
    // ...
    if parsed <= 0 {  // rejects 0
        return fallback
    }

parsed <= 0 means setting METRICS_FLUSH_BATCH=0 falls back to 500 rather than using 0. This is intentional for most config values but prevents explicitly disabling batched flush. Minor.


Data-Flow Tracing

Parse Task → Skill Creation (AdminCreate)

  1. Handler receives ParseTaskID from request body
  2. Service: GetParseTask(ctx, p.ParseTaskID) → validates status == "success" and SkillID == "" (rejects reupload tasks) ✅
  3. Service: readVerifiedTempZip → downloads from object store, verifies SHA-256 ✅
  4. Service: RewriteZipPackage → injects canonical fields into SKILL.md frontmatter ✅
  5. Service: uploads rewritten zip + SKILL.md to versioned object keys ✅
  6. Repo: CreateSkillAndConsumeTask → transactional: consume parse task + INSERT skill + INSERT version + upsert tags ✅
  7. On DB failure: cleanup uploaded objects ✅

Gap: No validation that pt.ResultName is non-empty before using it as fallback name. If parse produced empty name and admin didn't override, name could be empty string → DB INSERT with empty name.

Metrics: Track → Flush → DB

  1. Handler calls mSvc.TrackView/TrackDownload/TrackInstall
  2. Service: validates resource exists + visible (view only) → Redis INCR + SADD via Pipeline ⚠️ (P1 above)
  3. Flush worker: ticker → acquireLock (SetNX) → SPOP batch → processMember
  4. processMember: parse "resourceType:resourceID"getAndResetCounters (GETSET → 0) → upsertWithRetry
  5. On DB failure: restoreCounters (INCRBY) → re-add to dirty set

Gap: If Redis Pipeline.Exec partially succeeds (INCR ok, SADD fails), delta is permanently lost (P1).

Admin Auth Flow

  1. Request hits adminAuth.Handler() middleware → validates X-Admin-Token (when auth enabled)
  2. Middleware sets admin identity in gin context
  3. Handler reads middleware.Identity(c) → uses UID/Name for audit fields

Verified: All admin routes (/api/v1/admin/skills/*, /api/v1/admin/uploads/*) are behind admin middleware. ✅


Blind-Spot Checklist

C1 — Dual-path parity:

  • User Create vs Admin AdminCreate: Different cleanup strategies (async goroutine vs synchronous). Not a parity bug but inconsistent patterns. ⚠️
  • User Download vs Admin AdminDownload: Missing metrics in admin path. HIT → P1 above.
  • User Update vs Admin AdminUpdate: Both use UpdateWithTags transactional path. ✅

C2 — Control-flow ordering / nesting:

  • RewriteZipPackage is called before DB write in both Create and Reupload paths. Ordering is correct — objects exist before DB references them. ✅
  • Flush worker acquireLock before SPOP prevents duplicate processing across pods. ✅

C3 — Authorization boundary:

  • Admin routes: adminAuth.Handler() middleware + middleware.Identity(c) handler check. ✅
  • User routes: authenticator.Handler() middleware + canView() service check. ✅
  • RegisterLocalProxy: only mounted when STORAGE_DRIVER=local and authEnabled=false. ✅

C4 — Authorization lifecycle: N/A (no container-member state cascade in this PR)

C5 — Build/runtime parity: N/A (no build artifacts, extensions, or packaging in this PR)

C6 — Governance/policy: N/A (no policy/governance docs in this PR)


Cross-Round Blocker Review

N/A — First review of this PR.


Things I Checked

  • All 8 highest-risk production files read in full (admin.go service+handler, flush_worker.go, service.go, admin.go repo, upload handler, router, config, main.go)
  • Subagent read: all migration SQL files, metrics service/resolver/redis client, session handler, metrics handler, rewrite.go, tags.go (service+repo), create.go, update.go, list.go, get.go, metrics repo, category admin, oss.go, parse service/worker, skill handler
  • Data-flow traced for: parse task → skill creation, metrics track → flush → DB, admin auth flow
  • Migration ordering analyzed for forward-safety
  • Redis atomicity verified for Pipeline vs TxPipeline semantics

[Octo-Q] verdict: REQUEST_CHANGES

Rationale: Two P1 findings block landing:

  1. Redis Pipeline() must become TxPipeline() — one-line fix, prevents permanent metric loss under connection instability.
  2. AdminDownload must call TrackDownload — add the same metrics tracking as the user-facing Download handler to prevent systematic undercounting in marketplace rankings.

Both are straightforward fixes. The rest of the PR (transactions, visibility, auth, zip rewrite, migrations) is solid.

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR is relevant to Octo Marketplace, but it introduces breaking pagination behavior and a metrics data-loss path.

🔴 Blocking

  • 🔴 Critical — Existing /skills cursor pagination is broken by the new default. When sort is omitted, it now defaults to comprehensive, ignores cursor, and returns an offset envelope. Existing clients using the previously documented cursor contract can repeatedly receive the first page. The generated OpenAPI still declares a cursor response, so it also disagrees with runtime behavior. Preserve cursor-compatible defaults or version the contract, and document the conditional response schema. handler.go handler.go

  • 🔴 Critical — Graceful shutdown can permanently remove unprocessed metric entries. SPopN removes an entire batch, but cancellation inside the inner loop simply breaks without re-adding the remaining members. Those counters remain nonzero but no longer appear in metrics:dirty, so later flushes never discover them. Requeue all unprocessed batch members and add a cancellation-mid-batch test. flush_worker.go flush_worker.go

💬 Non-blocking

  • 🟡 Warning — Download metric tracking is described as “fire-and-forget” but executes synchronously before responding. A slow Redis connection therefore delays download redirects. Use a tightly bounded independent context or asynchronous delivery. handler.go

  • 🟡 Warning — Admin create/reupload accepts any successful parse-task ID without checking its owner or provenance. This lets an administrator consume a tenant upload if its ID is obtained. Consider requiring tasks created through the admin upload surface. admin.go admin.go

✅ Highlights

  • The PR passes the repository relevance gate.
  • go test ./..., go vet ./..., and git diff --check pass.
  • Artifact rewriting and parse-task consumption are generally handled transactionally with substantial security and integration coverage.

@Jerry-Xin
Jerry-Xin dismissed their stale review July 20, 2026 08:13

Superseded: re-posting with repository-relative references.

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR is relevant to Octo Marketplace, but it introduces breaking pagination behavior and a metrics data-loss path. The three previously-flagged blockers (publish/reupload re-read bounding + SHA re-verify, duplicate-version reupload ordering, category soft-delete uniqueness) are resolved — but this revision adds two new critical issues in the changed code.

🔴 Blocking

  • 🔴 Critical — Existing /skills cursor pagination is broken by the new default. When sort is omitted it now defaults to comprehensive, which ignores cursor and returns an offset envelope, while only latest returns the cursor envelope. Existing clients using the previously documented cursor contract can repeatedly receive the first page, and the generated OpenAPI still declares a cursor response, so runtime and contract disagree. Preserve a cursor-compatible default or version the contract, and document the conditional response schema. (internal/api/handler/skill/handler.go:102 and :132)

  • 🔴 Critical — Graceful shutdown can permanently drop unprocessed metric entries. SPopN removes an entire batch from metrics:dirty, but cancellation inside the inner loop simply breaks without re-adding the remaining members of the already-popped batch. Those counters stay nonzero yet no longer appear in the dirty set, so later flushes never rediscover them. Requeue all unprocessed batch members on cancellation and add a mid-batch-cancel test. (internal/service/metrics/flush_worker.go:115 and :124)

💬 Non-blocking

  • 🟡 Download metric tracking is described as "fire-and-forget" but runs synchronously before responding, so a slow Redis delays the download redirect. Use a tightly bounded independent context or truly asynchronous delivery. (internal/api/handler/upload/handler.go:629)

  • 🟡 Admin create/reupload accepts any successful parse-task ID without checking owner/provenance, so an administrator could consume a tenant upload if its ID is obtained. Consider restricting to tasks created through the admin upload surface. (internal/service/skill/admin.go:87 and :395)

✅ Highlights

  • All three prior blockers are fixed.
  • go test ./..., go vet ./..., and git diff --check pass.
  • Artifact rewriting and parse-task consumption are handled transactionally with substantial security/integration coverage.

@yujiawei yujiawei left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #6 (octo-marketplace)

Reviewed at head fa2e1970cf13af40e822cb9ef208cc01719029a8 against merge-base bc4e9405d8927bdce5f0f14a013373bd8bb849a3. go build ./..., go vet ./..., and go test ./... all pass locally (parse/metrics/integration suites included).

This is a large, well-structured feature PR (+14.7k / 101 files). The metrics pipeline, migrations, and tag-scoping are carefully engineered with good test coverage. I'm approving it. The findings below are all non-blocking (P2 / follow-up) — none are correctness/security regressions that must be fixed before merge — but several are worth a fast-follow, especially the metrics-durability caveats.

Spec / scope

  • The global-skill-tags brief is fully satisfied: /skills/tags returns space_id IN (spaceID, '') with space-local winning on name collision (repository/skill/tags.go ListTags), admin create/update/reupload sync tags into the '' global bucket (service/skill/admin.go, repository/skill/admin.go:191), normal user create/update stay space-scoped, space_id stays NOT NULL (empty string, not NULL) — matching the "out of scope" constraints. Repo + service tests cover both paths.
  • The broader catalog/metrics/contract work matches the PR intent and issue #8; no obvious over-build or missing pieces surfaced.

Non-blocking findings

Metrics durability (best-effort by design — recommend tracking)

The Redis→DB flush pipeline is explicitly documented as best-effort/lossy for v1, and the data is approximate engagement counters, so these are acceptable to ship — but they will skew counts under failure and are worth hardening:

  1. SPOP removes the dirty marker before the counter is persisted (service/metrics/flush_worker.go:113-127). A crash between SPOP and the DB upsert (or after GETSET(...,"0")) drops that batch's increments with no marker to rediscover them. Consider a claim/ack pattern (e.g. move to an in-progress set, delete only after DB commit) or SPOP-with-reinsert on failure that survives process death.
  2. Pipeline GETSET is batched, not atomic (flush_worker.go:203-216). If Exec errors after one/two GETSETs already reset their keys, getAndResetCounters returns zero deltas and the successfully-reset counts are lost even though the member is requeued. A Lua script (read-and-reset atomically, or read-then-conditional-reset) would close this.
  3. Additive upsert is not idempotent under retry (flush_worker.go:184-194, upsertWithRetry). If MySQL commits count = count + delta but the ack is lost, the worker retries or restores+requeues and double-counts. An idempotency key / dedup token per flush batch would make retries safe.
  4. INCR can succeed while SADD fails in redis/client.go:46-49 (trackEvent). The counter is then incremented with no dirty marker, so it won't flush until a later event recreates the marker. Bounded and self-healing, but worth noting.

Graceful shutdown

  1. Flush worker is cancelled but not joined on shutdown (cmd/marketplace-api/main.go:177). flushCancel() fires, then the process proceeds to exit without waiting for an in-flight flush to finish its (independent-context) restore/requeue. The Redis lock self-heals via its 120s TTL and counters remain in Redis, so impact is small — but a WaitGroup/done-channel join before exit would make SIGTERM during a flush clean.

Parse stale-recovery

  1. Terminal-state write lacks a status guard (service/parse/repo.go UpdateSuccess, WHERE id = ? only). Recovery re-submits a stale task and can MarkRetryExhausted it to failed; a very-late original worker could then overwrite failedsuccess (or vice-versa) because UpdateSuccess doesn't constrain on status. In practice this is well-mitigated: process() bounds each run with parseTimeout (default 1m) and recovery only fires after staleTimeout (default 5m, invariant enforced in ValidateAPI), so the original worker is essentially always dead first. Adding AND status = 'parsing' to UpdateSuccess would make it robust regardless of timing/lease. A generation/lease token is the fuller fix if you expect frozen-then-resumed pods.

Dev bot auth

  1. devBotMode accepts any bf_-prefixed token with caller-controlled identity headers (api/handler/upload/handler.go:251-272). This is gated on AUTH_ENABLED=false (SetDevBotMode(!authEnabled)), so it can't reach production auth — but "local-only" isn't enforced by a loopback check. Ensure any staging/dev deployment with auth disabled is network-isolated, or add a loopback guard like the local storage proxy already uses.

Listing / pagination

  1. Comprehensive sort is an unindexed full scan + filesort (repository/skill/list.go:150-160). The time-decay score (POW(TIMESTAMPDIFF(...))) is computed per-row and can't use an index, plus a separate count scan per request. Fine at current catalog size; if the catalog grows, consider a materialized/periodically-refreshed score column or a rate bound. (Parameters are correctly bound and escapeLike neutralizes LIKE wildcards — no injection.)
  2. Malformed latest cursors are silently ignored (list.go:128-135), so a corrupted cursor silently returns page 1 again rather than a 400. Minor, but can produce duplicate/looping pagination for clients.

Migrations — looks correct

The category soft-delete + name_live generated-column unique key (20260720-01) is a clean solution: deleted rows project NULL, so MySQL treats them as distinct and recreating a soft-deleted name doesn't collide. The bare uk_categories_name remains enforced until 20260720-01 drops it, so at most one live row per name exists when the live-only unique index is added — no collision on creation. The taxonomy remap (20260719-10) converges existing DBs while the edited seed (20260715-00) covers fresh DBs; since sql-migrate tracks by ID (no checksum), editing the already-applied seed is inert on existing DBs and both paths land on the same 13 categories. (Note for the future: editing an applied migration is generally a smell even when — as here — a later migration reconciles it.)

Coverage / not deeply inspected

Generated docs/openapi/swagger.yaml and the design/*.html prototypes were skimmed, not line-audited. I did not exercise the archive-extraction/zip-rewrite paths against adversarial zip inputs (zip-slip/quota) beyond reading RewriteZipPackage and the readLimited/SHA256 verification (which look sound). Full runtime behavior of the flush worker under real Redis failure was reasoned about, not executed against a live Redis.

@yujiawei

Copy link
Copy Markdown

Follow-up on the concerns raised in the first-pass automated review — I re-verified each against the code at fa2e197. My formal approval stands; here is the terminal adjudication:

"Redis Pipeline() should be TxPipeline()" (raised as blocking): Correct that Pipeline() batches without MULTI/EXEC atomicity, so a connection drop between INCR and SADD can increment a counter with no dirty-set marker. However, this self-heals — the next track event on the same resource re-adds the marker and the accumulated counter flushes then. Combined with the fact that this is an explicitly best-effort, approximate engagement-counter subsystem, I assess it as a valuable but non-blocking improvement rather than a merge blocker. Switching to TxPipeline() is a clean one-line fast-follow and I'd encourage it.

"AdminDownload should call TrackDownload" (raised as blocking): It is true that AdminDownload (internal/api/handler/upload/handler.go) does not track a download, unlike the user-facing Download. I do not consider this a bug: the admin download route sits behind admin auth and is a provisioning/testing surface. Counting admin/test downloads toward the public popularity ranking (downloads*5 + views + decay) would pollute real user-engagement metrics, so excluding it is defensible as intended behavior. Recommend confirming the product intent, but it does not block.

New item worth acting on before deploy — migration 20260717-08: ADD UNIQUE INDEX uk_categories_name (name) will fail hard (and block all later migrations) if any target database already contains duplicate categories.name rows. Fresh DBs and the standard seed are safe (unique names), but a pre-existing environment where categories were created without a name-uniqueness constraint could have duplicates. Please verify no duplicate live category names exist in target DBs before rolling out, or add a dedup guard ahead of the index creation.

Minor: AdminCreate falls back to pt.ResultName for the name but does not reject an empty final name; a short non-empty guard before insert would be safer. The remaining first-pass items (GETSET deprecation, unbounded counter restore on prolonged DB outage, envInt rejecting 0, POW on future-dated created_at) are low-priority nits and are fine to defer.

@Jerry-Xin
Jerry-Xin dismissed their stale review July 21, 2026 11:24

Superseded by new commit; re-reviewing latest head. This entry contained internal workspace paths and is withdrawn.

@lml2468 lml2468 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review @ 1c5238c — APPROVE 维持 ✅

上一轮已在 c9cc88f 上确认全部阻断项修复并投 APPROVE。本轮针对新 head 1c5238c 复核增量。

增量:仅 1 个提交,仅 1 个文件

1c5238c fix: allow compose config without local env — 只改 docker-compose.yaml(相对其父提交 +9/-9)。Go 代码、迁移、swagger 与已完整验证的 c9cc88f 完全一致,此前所有 blocker 修复原样保留。

变更内容:将 minio 凭据从 ${MINIO_ROOT_USER:?必填} 改为带默认值 ${MINIO_ROOT_USER:-octo-local-admin} / ${MINIO_ROOT_PASSWORD:-octo-local-dev-password},使本地 compose 无需 .env 即可启动。

安全评估 — 无风险

  • docker-compose.yaml 顶部明确标注 DEV ONLY;所有基础设施端口已绑定 127.0.0.1 环回(上轮验证)。
  • 生产部署走 OCTO 部署仓库的轮转密钥与托管持久化,不使用此文件的默认值。
  • 默认凭据仅影响本地开发容器,不进入任何生产配置面。属合理的开发体验改进。

验证(本轮,本地 @ 1c5238c

  • go build ./... — PASS
  • go vet ./... + go vet -tags integration ./... — PASS
  • (Go 代码未变,沿用上轮 22 个受影响包 go test -short 全 PASS 的结果。)
  • 基础设施依赖的集成测试未跑,交 CI。

前轮已修阻断项(沿用,仍有效)

全局 tag 覆盖本地 tag ✅ · flush-ledger 保留期接线 ✅ · bot publish 503/UPSTREAM_UNAVAILABLE ✅ · tag 名 N+1 批量化 ✅ · dev bot 身份双重闸门 ✅ · 端口环回绑定 ✅

剩余非阻断 fast-follow(不阻断合并)

  • P2 — 指标 drain 的 double-count 时间窗(可自愈)。
  • P2 — flush 锁 120s TTL 不续期。
  • NitAdminDownload 有意跳过 TrackDownload,建议加一行注释。

结论

两道闸门均通过。Spec: ✅(完成 #8:tags / durable flush / 契约对齐)。Code quality: ✅。维持 APPROVE;两项 P2 为合理跟进项,非合并阻断。

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review against live head 1c5238c4. This is a rebase (ahead 67 / behind 73); I byte-isolated the true new-commit delta vs the previously-approved c9cc88f (~20 files, +636/-294). The prior REQUEST_CHANGES blockers stayed fixed and the admin-auth-superadmin migration is clean, but the new delta introduces a regression that violates the skill soft-delete specification.

🔴 Blocking

  • 🔴 Critical — Soft delete now permanently destroys stored artifacts. Both internal/service/skill/service.go (Delete, around line 767) and internal/service/skill/admin.go (AdminDelete, around line 334) enumerate every version's storage keys via collectDeleteObjectKeys(...) and call s.store.DeleteObject(...) after the row is marked deleted. internal/repository/skill/delete.go is a soft delete (UPDATE skills SET is_deleted = 1 ...), so skill_versions rows are retained but their object-storage artifacts are gone. This directly contradicts .octospec/tasks/skill-soft-delete/brief.md, whose load-bearing behavior states delete "does not delete skill_versions or object-storage artifacts," and whose Out-Of-Scope explicitly excludes "Artifact garbage collection for deleted Skills." The result is dangling version records pointing at missing objects, making restore/audit impossible. Remove the artifact deletion from both delete paths and update the tests that currently assert deletion.

💬 Non-blocking

  • 🟡 make openapi-diff could not establish a baseline because origin/main has no baseline docs/openapi/swagger.yaml; run it once a baseline exists.

✅ Highlights

  • Admin auth migration is well executed: X-Admin-Token shared-secret model fully removed (config fields, ValidateAPI guardrail, CORS header, AdminIdentity helper) and replaced by an Octo session-token + superAdmin role gate mirroring octo-server's CheckLoginRoleIsSuperAdmin. Shared resolveUserIdentity de-dupes public/admin validation with no public-path behavior change; nil-resolver panics at construction (fail-fast) with a defense-in-depth 503; the cross-repo role coupling is explicitly documented.
  • New admin middleware tests cover dev bypass, nil-resolver panic, resolver error (503), empty UID (401), missing context (503), non-superAdmin (403 FORBIDDEN), and superAdmin accept.
  • go test ./... and make openapi-check pass; API contract checks (envelopes, naming, error codes, auth, pagination) pass.

@lml2468 lml2468 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review @ 1c5238c — 🔴 REQUEST_CHANGES(撤回此前 APPROVE)

先纠正我自己:上一轮的 delta scoping 是错的。 分支被 rebase 过(commit hash 全变),我上一轮用 1c5238c~1..1c5238c 只看了最顶端那个 compose commit,据此报「只改了 docker-compose.yaml、Go 代码没动」——这是错误结论。正确做法是 two-dot 对比我实际审过的旧 head c9cc88f..1c5238c,真实增量约 20 文件 +636/-294,含一整套 admin 鉴权重构 一处会破坏软删语义的制品硬删。感谢 @Steve 的独立复核纠正。

🔴 阻断 — 删除路径物理销毁对象制品,违反 soft-delete spec(CONFIRMED)

本次 delta 新引入(在 c9cc88f 上两条 Delete 路径均为纯软删,无 collectDeleteObjectKeys/无 DeleteObject——已 byte 核对旧 head 源码确认):

  • internal/service/skill/service.go:755 Delete — 软删 DB 行后,对 FileURL + VersionStorage + 所有历史版本 storage 逐个 store.DeleteObjectservice.go:781)。
  • internal/service/skill/admin.go:325 AdminDelete — 同样模式(admin.go:348)。
  • repository/skill/delete.go 仍是软删:UPDATE skills SET is_deleted=1,行与 skill_versions 全部保留。
  • DeleteObjectstorage/local.go:175storage/oss.go:183 均为物理删除

与 spec 冲突.octospec/tasks/skill-soft-delete/brief.md 明写——

  • Load-Bearing:"Delete updates is_deleted and updated_at; it does not delete skill_versions or object-storage artifacts."
  • Out Of Scope:"Artifact garbage collection for deleted Skills."

后果:DB 保留软删行 + 版本记录,却指向已被物理销毁的制品 → 悬空引用。恢复/审计不可用;同名重建后旧版本历史指向空制品;若键复用还可能读到错误对象。属直接违反自身 spec 的正确性 + 数据完整性缺陷。

修法:从 DeleteAdminDelete 两条路径移除制品删除(collectDeleteObjectKeys + DeleteObject 循环),并同步修正断言删除制品的测试。制品 GC 若确有需要,应作为独立 spec 的后续任务。

✅ 同 delta 的 admin 鉴权重构 — 干净

internal/middleware/admin.go 等:X-Admin-Token 共享密钥 → Octo session token + superAdmin 角色闸(对齐 octo-server CheckLoginRoleIsSuperAdmin)。抽出公用 resolveUserIdentity、nil resolver 构造期 panic 快速失败、保留 503 UPSTREAM_UNAVAILABLE、测试补齐。核过无问题。

前轮 blocker 未被 rebase 带回退(复核 OK)

本地 tag 优先 ✅ · flush-ledger 保留期 ✅ · bot publish 503/UPSTREAM_UNAVAILABLE ✅ · tag 名 N+1 批量化 ✅ · dev-bot 双闸门 ✅ · 端口绑 127.0.0.1 ✅

验证(本地 @ 1c5238c

  • go build / go vet(含 integration tag)/ go test -short(middleware、service/skill)— 全 PASS。
  • ⚠ 构建测试通过 不代表 spec 符合:本缺陷是删除语义与 brief 冲突,闸门在 spec 层,非编译层。
  • openapi-diff 无 baseline,无法自动比对;DB/Redis/OSS 集成测试未跑,交 CI。

结论

Spec 闸门失败(删除路径违反 soft-delete brief)。Verdict: REQUEST_CHANGES。移除两条 Delete 路径的制品硬删并修正对应测试后,即可放行。

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed against the latest head. The soft-delete artifact-destruction blocker is resolved.

Verified fix (e97aa0f): internal/service/skill/service.go Delete and internal/service/skill/admin.go AdminDelete no longer call DeleteObject/collectDeleteObjectKeys. The helper functions are removed, and the assertions that expected object deletion are dropped from service_test.go / admin_test.go / integration_test.go. internal/repository/skill/delete.go remains a soft delete (SET is_deleted = 1), so soft-deleted rows and version records now keep their object-storage artifacts intact — consistent with the skill-soft-delete task brief (artifacts preserved; artifact GC is out of scope). The remaining DeleteObject calls elsewhere are pre-existing failure-path rollback (cleaning up just-created artifacts when an upload/parse fails), which is unrelated and correct.

The larger new delta on this branch (admin auth migration from a shared X-Admin-Token secret to an Octo session-token + superAdmin role gate) was verified earlier: shared resolveUserIdentity, fail-fast panic on nil resolver at construction, preserved UPSTREAM_UNAVAILABLE 503 path, and thorough tests. All previously requested blockers remain fixed.

Non-blocking: make openapi-diff still lacks a baseline for automated backward-compat comparison; verify the swagger tags change manually. CI is green.

Two gates pass — approving.

@lml2468 lml2468 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review @ e97aa0f — ✅ APPROVE(阻断项已修复)

上一条 REQUEST_CHANGES 我锚定在 e97aa0f,但审的是 1c5238c 的代码——审核期间 head 又前进了一次。现已对当前 live head e97aa0f 完整复核:我上一轮报的删除路径制品硬删阻断项,已在本 head 修复。

🔴→✅ 删除路径回归纯软删(CONFIRMED fixed)

1c5238c..e97aa0f 增量(admin.go -51 / service.go -9 / 测试)正是此修复:

  • service.go Deleteadmin.go AdminDelete 均回到纯软删:GetByID → repo.Delete(UPDATE is_deleted=1)不再 collectDeleteObjectKeys / DeleteObjectversionStorageStrings 一并移除。
  • 测试反转为断言不清理制品service_test.go:212admin_test.go:140want no artifact cleanup for soft delete
  • 残留的 DeleteObject 调用全部在 upload/reupload/失败清理路径(临时 zip / skillMd / parse-task 制品),与软删无关,正确保留。

现符合 .octospec/tasks/skill-soft-delete/brief.md:软删仅更新 is_deleted,保留 skill_versions 与对象制品;制品 GC 属 Out Of Scope。悬空引用风险消除。

✅ 其余项复核(沿用 1c5238c 结论,本 head 未回退)

  • admin 鉴权重构 — X-Admin-Token 共享密钥 → Octo session + superAdmin 角色闸;公用 resolveUserIdentity、nil resolver 构造期 panic、保留 503/UPSTREAM_UNAVAILABLE、测试补齐。干净。
  • 前轮 blocker — 本地 tag 优先 ✅ · flush-ledger 保留期 ✅ · bot publish 503/UPSTREAM_UNAVAILABLE ✅ · tag 名 N+1 ✅ · dev-bot 双闸门 ✅ · 端口绑 127.0.0.1 ✅

验证(本地 @ e97aa0f

  • go build / go vet(含 integration tag)/ go test -short(service/skill、middleware)— 全 PASS。
  • openapi-diff 无 baseline;DB/Redis/OSS 集成测试未跑,交 CI。

剩余非阻断 fast-follow

  • P2 指标 drain double-count 时间窗(可自愈)· P2 flush 锁 120s TTL 不续期 · Nit AdminDownload 有意跳过 TrackDownload 建议加注释。

结论

两道闸门均通过。Spec: ✅ · Code quality: ✅Verdict: APPROVE。两项 P2 为合理跟进项,非合并阻断。

@Jerry-Xin
Jerry-Xin merged commit b1bf69c into Mininglamp-OSS:main Jul 21, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies-changed This PR modifies dependency files size/XL PR size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Track marketplace skill catalog and API contract alignment

5 participants