Compare commits

...

18 Commits

Author SHA1 Message Date
Christian Byrne
0178003156 Merge branch 'main' into refactor/consolidate-image-upload 2026-05-04 13:46:18 -07:00
Alexander Brown
dac3396de8 test: add selection paste, rename, and batch rename browser tests (#11367)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds `browser_tests/tests/selectionPasteRename.spec.ts` covering the
untested `pasteSelection()` and `renameSelection()` paths in
`useSelectionOperations.ts`.

### Coverage gaps filled
- `pasteSelection()` — copy → paste creates new nodes
- `renameSelection()` single node path — opens title editor
- `renameSelection()` batch path — prompt dialog with sequential naming
- Empty selection → toast warning

### References
- Follows patterns from `selectionToolboxMoreActions.spec.ts` (More
Options menu, `selectNodeWithPan`)
- Follows `browser_tests/AGENTS.md` directory structure
- Follows `browser_tests/FLAKE_PREVENTION_RULES.md` assertion patterns

### Verification
- TypeScript: clean
- ESLint: clean
- oxlint: clean
- oxfmt: formatted

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11367-test-add-selection-paste-rename-and-batch-rename-browser-tests-3466d73d3650812194a4d8bfbed3dee7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 20:30:42 +00:00
Christian Byrne
d253d87c92 Revert "feat: add bug-dump-ingest skill (#11460)" — GitHub squash-commit incident recovery (#11629)
*PR Created by the Glary-Bot Agent*

---

## Step 1 of 2 — GitHub squash-commit incident recovery for #11460

GitHub flagged the squash commit for #11460
(`559922eaa5c129767c22275c206c6877931ac15c`) as affected by the
squash-commit incident that produced non-deterministic merges. This PR
is **step 1 of 2** in the recovery procedure GitHub asked us to follow.

### What this PR does

Reverts `559922eaa5c129767c22275c206c6877931ac15c` (the affected squash
commit for #11460, "feat: add bug-dump-ingest skill") on top of current
`main`.

### Diff

5 files removed, all under `.claude/skills/bug-dump-ingest/`:
- `.claude/skills/bug-dump-ingest/SKILL.md`
- `.claude/skills/bug-dump-ingest/reference/examples.md`
- `.claude/skills/bug-dump-ingest/reference/linear-api.md`
- `.claude/skills/bug-dump-ingest/reference/schema.md`
- `.claude/skills/bug-dump-ingest/reference/verify-commands.md`

`git revert` applied cleanly with no conflicts.

### Recovery procedure

Per GitHub's instructions:

1. **This PR (step 1)** — Revert the affected squash commit. Removes
both the original changes and any unintended changes that the incident
may have introduced.
2. **Next PR (step 2)** — Re-apply the original PR's changes from
`refs/pull/11460/head`, rebased onto post-revert `main`. Will be opened
as a separate "repair" PR.

### Review notes

- This is a pure revert; please confirm the diff is exactly the inverse
of #11460.
- After this PR is merged, the companion repair PR will land the
original changes back on `main` from a clean source ref, restoring the
intended state of the codebase.
- Branch name is `glary/revert-pr-11460` (the `glary/` prefix is
required by the tool that opened this PR — it's otherwise equivalent to
GitHub's suggested `revert-pr-11460`).
- Code review: Oracle reviewed and found 0 issues
(critical/warning/suggestion all 0). Ready to merge.

Refs: #11460

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11629-Revert-feat-add-bug-dump-ingest-skill-11460-GitHub-squash-commit-incident-recove-34d6d73d3650810f9ed3f6068e4f1511)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 13:17:28 -07:00
Marwan Ahmed
4033dde983 refactor(website): replace UseCaseSection scroll override with hover on desktop (#11696)
## Summary

Per product feedback, scroll-jacking the page to step through
'Industries that create with ComfyUI' categories feels bad on desktop.
This PR removes the desktop scroll override and switches to hover-driven
imagery on lg+, while preserving the existing pin/scrub interaction on
mobile/touch breakpoints.

## Changes

- **What**:
- Gate `usePinScrub` setup to `(max-width: 1023px)` so the pin/scrub
only runs on touch breakpoints; desktop never engages it.
- Wire `@mouseenter` and `@focus` on each category button to update the
active category on desktop. Click still works on both modes via the
existing `scrollToIndex` (which falls through to a direct ref set when
no ScrollTrigger instance is present).
- Pass the same `(max-width: 1023px)` to `useParallax` via its existing
`mediaQuery` option so parallax doesn't run against the no-longer-pinned
section on desktop.
- Apply the section's `lg:h-[calc(100vh+60px)]` unconditionally on lg+
since pin no longer drives it; mobile height is still managed
dynamically by `usePinScrub`'s `cacheLayout`.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

- Mobile path inside `usePinScrub.onMounted` is byte-identical to main —
only the early-return condition gained one extra clause
(`!window.matchMedia('(max-width: 1023px)').matches`), which mobile
evaluates to `false` so it falls through to the existing setup.
- `onCategoryHover` early-returns when `isEnabled` is true, making it a
no-op on mobile (where pin is engaged), so a tap doesn't accidentally
fight the scrub.
- `@focus` is wired alongside `@mouseenter` so keyboard tab navigation
also previews the imagery.
- The previous Lenis-on-macOS workaround from this branch is reverted —
it was only needed because the scroll override existed.

## Screenshots (if applicable)

N/A — interaction change. Test on desktop (≥1024px) by hovering category
labels — imagery should swap with no scroll-jacking. Test on mobile
(<1024px) by scrolling the section; pin/scrub should engage as before.

---------

Co-authored-by: Marwan Ahmed <marwan@Marwans-MacBook-Pro.local>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-04 13:16:34 -07:00
Christian Byrne
61a444ed99 test: combine duplicated undo/redo and settings dialog E2E tests with test.step (#11835)
## Summary

Refactor E2E tests added in #11210 that repeated full prior-test bodies
as setup, combining duplicate pairs into single tests with named
`test.step()` blocks.

## Changes

- **What**: In
[`browser_tests/tests/keyboardShortcutActions.spec.ts`](../blob/batch-dispatch/cr-11556/browser_tests/tests/keyboardShortcutActions.spec.ts):
- Merge `Ctrl+Z undoes` + `Ctrl+Shift+Z redoes` → single test with two
`test.step()` blocks.
- Merge `Ctrl+, opens settings dialog` + `Escape closes settings dialog`
→ single test with two `test.step()` blocks.
- **What**: In
[`browser_tests/tests/topbarMenuCommands.spec.ts`](../blob/batch-dispatch/cr-11556/browser_tests/tests/topbarMenuCommands.spec.ts):
- Merge `Edit > Undo` + `Edit > Redo` → single test with two
`test.step()` blocks.

The redo step now reuses the post-undo state from its preceding step
instead of re-creating and re-undoing the node, removing the duplicated
setup the reviewer flagged.

## Review Focus

- Naming of combined tests and `test.step()` labels.
- Note: per @AustinMroz's [comment
thread](https://github.com/Comfy-Org/ComfyUI_frontend/pull/11210#discussion_r3113526265),
location 2 in the issue refers to the `Escape closes settings dialog`
test (which duplicated the `Ctrl+,` test body), not the `Delete` test
(which has unique logic). Treated accordingly.

Fixes #11556

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11835-test-combine-duplicated-undo-redo-and-settings-dialog-E2E-tests-with-test-step-3546d73d365081689df3c56bfbb6f4e4)
by [Unito](https://www.unito.io)
2026-05-04 12:54:52 -07:00
Christian Byrne
385a1d421d [chore] Update Comfy Registry API types from comfy-api@84a4468 (#11910)
## Automated API Type Update

This PR updates the Comfy Registry API types from the latest comfy-api
OpenAPI specification.

- API commit: 84a4468
- Generated on: 2026-05-04T15:45:35Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11910-chore-Update-Comfy-Registry-API-types-from-comfy-api-84a4468-3566d73d365081ef8771cb77b5de6119)
by [Unito](https://www.unito.io)

Co-authored-by: bigcat88 <13381981+bigcat88@users.noreply.github.com>
2026-05-04 19:27:33 +00:00
Christian Byrne
341fef46a9 refactor: replace unsafe as Error assertions with type guards (#11845)
## Summary

Replaces all 7 production `as Error` type assertions with proper
`instanceof Error` narrowing or a new `toError()` helper, and adds an
ESLint rule to prevent new ones. First slice of #11429 (the `as Error`
category — 9 total occurrences, 7 production + 2 in a test file left
untouched).

## Changes

- **What**:
- New `src/utils/errorUtil.ts` exporting `toError(value: unknown):
Error` and `getErrorMessage(value: unknown): string | undefined`.
`toError` returns the value unchanged if already an `Error`, otherwise
wraps it (handles strings, `undefined`, JSON-serializable objects, and
circular refs via `String()` fallback).
  - Refactored 7 production call sites:
- `src/services/gateway/registrySearchGateway.ts` — `toError(error)` for
`lastError` assignment in fallback loop
- `src/platform/cloud/onboarding/auth.ts` (×2) — `toError(error)` for
`captureApiError` Sentry calls
-
`src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder.ts`
— `toError(err)` before forwarding to `options.onError`
- `src/extensions/core/load3d/LoaderManager.ts` — replaced `error as
Error & { response?: ... }` cast inside `isNotFoundError` with
`'response' in error` + nested narrowing
- `apps/desktop-ui/src/stores/maintenanceTaskStore.ts` — inline `error
instanceof Error ? error.message : String(error)`
- `apps/desktop-ui/src/components/maintenance/TaskListPanel.vue` —
inline `error instanceof Error ? error.message : undefined`
- New ESLint rule (`no-restricted-syntax` block named
`comfy/no-unsafe-error-assertion`) banning `TSAsExpression
TSTypeReference[typeName.name='Error']` in `src/**` and `apps/*/src/**`,
with test files (`*.test.ts`, `*.spec.ts`) excluded.
  - 12 unit tests for the new helpers in `src/utils/errorUtil.test.ts`.
- **Breaking**: none
- **Dependencies**: none

## Review Focus

- The lint rule is scoped to non-test source files. Test files retain
freedom to use `as Error` for fixture construction; only 2 occurrences
exist (in `teamWorkspaceStore.test.ts` and `errorDialog.spec.ts`) and
they're intentional.
- `toError` is duplicated as inline `instanceof` narrowing in
`apps/desktop-ui/` rather than imported, since the desktop-ui workspace
doesn't share `@/utils/` with the main app and adding a path mapping for
one helper felt heavier than two inline guards.
- Remaining `as`-on-DOM categories (HTMLElement ×133, HTMLInputElement
×55, HTMLCanvasElement ×36, KeyboardEvent ×7, Element ×3, MouseEvent ×2,
Event ×2) are intentionally left for follow-up PRs to keep this one
reviewable.

Refs #11429

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11845-refactor-replace-unsafe-as-Error-assertions-with-type-guards-3546d73d36508137a015c4f9e8708f23)
by [Unito](https://www.unito.io)
2026-05-04 11:40:28 -07:00
bymyself
f9899fccaa test: add Load3dUtils upload coverage to lift PR patch coverage 2026-05-04 10:59:59 -07:00
bymyself
027d21817d test: add uploadService coverage for endpoint forwarding and Blob conversion 2026-05-04 10:43:38 -07:00
Yourz
24b548aebc fix: route footer Support link to Zendesk help center (#11904)
*PR Created by the Glary-Bot Agent*

---

## Summary

The "Support" link in the marketing site footer (Contact column) was
reusing
the Discord external link. Update it to point at the Zendesk help center
at
`https://support.comfy.org/hc/en-us`, as requested in the
`#website-and-docs` Slack thread.

## Changes

- `apps/website/src/config/routes.ts` — add `support` entry to
`externalLinks`
  pointing at `https://support.comfy.org/hc/en-us`.
- `apps/website/src/components/common/SiteFooter.vue` — use
  `externalLinks.support` for the Contact > Support entry instead of
  `externalLinks.discord`.

## Verification

- `pnpm format` and `pnpm exec eslint` clean on both files.
- `pnpm typecheck` passes.
- Verified locally with `pnpm dev` (Astro on `localhost:4321`); the
rendered
footer Support link now resolves to `https://support.comfy.org/hc/en-us`
  (screenshot below).

## Notes

Reviewer flagged that `/hc/en-us` forces English and bypasses Zendesk
locale
negotiation. The exact URL was explicitly requested by the user in the
Slack
thread, so it is preserved here. Switching to a locale-neutral
`https://support.comfy.org/` can be done as a follow-up if desired.


## Screenshots

![Marketing site footer with Support link pointing to
support.comfy.org/hc/en-us](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/7cb1cde676098ecfc7a07ab2b8d341ba402b097e134b5eaaf42572e925bd6d40/pr-images/1777906238675-00158842-4368-478a-ae6e-c91d536a7986.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11904-fix-route-footer-Support-link-to-Zendesk-help-center-3566d73d36508189abcff34ae766d3c4)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 17:28:39 +00:00
Christian Byrne
6ea278da30 [chore] Update Comfy Registry API types from comfy-api@9ec8c25 (#11906)
## Automated API Type Update

This PR updates the Comfy Registry API types from the latest comfy-api
OpenAPI specification.

- API commit: 9ec8c25
- Generated on: 2026-05-04T15:11:04Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11906-chore-Update-Comfy-Registry-API-types-from-comfy-api-9ec8c25-3566d73d365081e394a6c13c0e30499b)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
2026-05-04 16:32:34 +00:00
bymyself
5e632ed274 chore: drop unused api import from Load3dUtils
Now that all upload logic goes through uploadMedia, the direct api
client is no longer used in this file.
2026-05-04 01:16:05 -07:00
bymyself
258af63618 refactor: use uploadMedia in useWidgetSelectActions
Pivot consolidation target: main extracted upload logic from
WidgetSelectDropdown.vue into useWidgetSelectActions composable
(PR #10966 VTL migration). Refactor that composable to use the
shared uploadMedia service, eliminating the remaining duplicate
upload path.
2026-05-04 01:15:58 -07:00
bymyself
e57b6a52aa fix: polish uploadService based on code review feedback
- M2: Blob MIME type now prefers source.type over default parameter
- M3: Removed double toast on failed uploads
- m1: Converted WidgetSelectDropdown.vue to Vue 3.5 reactive props
- m3: Created createMockResponse() helper in tests
- m4: Added test for empty batch edge case
- m5: Removed verbose JSDoc comments

Amp-Thread-ID: https://ampcode.com/threads/T-019c265d-f2f5-7028-95b4-5e031e447bd3
2026-05-04 01:12:53 -07:00
bymyself
142c372823 fix: address CodeRabbit review comments
- Remove data URL content from error messages (security)
- Replace 'as any' casts with proper type assertions in tests
- Use uploadMediaBatch in WidgetSelectDropdown for single store update
- Localize error strings with i18n (uploadFailed, tempUploadFailed)

Amp-Thread-ID: https://ampcode.com/threads/T-019c0daa-eb18-72eb-bc87-90f09afc3d3a
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 01:12:49 -07:00
bymyself
e1dca0bcb2 Fix type errors after rebase
- Add back api import to Load3dUtils (needed by fileExists and uploadThumbnail)
- Add null check for result.response in uploadTempImage
- Ensures type safety after rebase on main
2026-05-04 01:12:40 -07:00
bymyself
82935499d8 Address CodeRabbit review comments
- Import ImageRef from maskEditorDataStore instead of duplicating
- Replace 'any' with proper UploadApiResponse type
- Add validation for dataURL strings
- Fix test mocking: use vi.spyOn for global.fetch
- Fix uploadMediaBatch test to use distinct response mocks
- Add test for invalid dataURL rejection
- Fix uploadMultipleFiles to return array of successful paths
- Optimize file size test to avoid timeout
2026-05-04 01:12:40 -07:00
bymyself
9ef78eea0c Consolidate image upload implementations into shared service
Creates core uploadService to eliminate ~60-70 LOC duplication across multiple implementations.

Changes:
- Add src/platform/assets/services/uploadService.ts with uploadMedia() and uploadMediaBatch()
- Refactor Load3dUtils to use uploadService (eliminates 50+ LOC)
- Refactor WidgetSelectDropdown to use uploadService (eliminates 20+ LOC)
- Add comprehensive unit tests for uploadService
- Maintain backward compatibility for all existing APIs

Benefits:
- Single source of truth for upload logic
- Consistent error handling
- Type-safe interfaces
- Easier to test and maintain
2026-05-04 01:12:40 -07:00
34 changed files with 1170 additions and 1422 deletions

View File

@@ -1,695 +0,0 @@
---
name: bug-dump-ingest
description: 'Syncs the #bug-dump Slack channel into Linear as the system of record AND auto-fixes verified real bugs via red-green-fix. Every Linear operation (create, search, link, label) is performed by posting an @Linear mention in the bug-dump thread — no Linear MCP, no API key. Flow: fetch → mandatory dedupe gate (@Linear search + gh PR search) → false-defect verification → post @Linear create in thread (tool call) → parse bot card for FE-NNNN + URL → post :white_check_mark: confirmation reply → if candidate is a verified real bug with no dedupe hit and no open PR, invoke red-green-fix automatically to produce failing test + fix + PR. Respects team emoji scheme (:white_check_mark: ticket created, :pr-open: PR open, :question: needs context, :repeat: duplicate). Use when asked to sync #bug-dump to Linear, triage slack bugs, run a bug-dump sweep, or ingest bug reports. Triggers on: bug-dump, sync bug-dump, ingest bugs, triage slack bugs, bug sweep.'
---
# Bug Dump Ingest
**Primary job: sync `#bug-dump` (Slack: `C0A4XMHANP3`) into Linear as the source of truth, then auto-fix the verified real bugs.** Linear is where status, labels, and follow-up triage happen — this skill gets every bug into Linear with enough context that a downstream agent or human can work from Linear alone. **Every Linear action is performed by mentioning `@Linear` in the bug-dump thread**; there is no Linear MCP and no API key path. When pre-flight verification confirms a candidate is a real bug (not dedupe, not already in a PR, not out of scope), the skill then invokes `red-green-fix` automatically.
```text
fetch → pre-flight dedupe gate (@Linear search + gh) → verify false defects → present approvals
→ POST "@Linear create ..." thread reply via slack_send_message (mandatory tool call)
→ poll slack_read_thread → parse Linear bot card for FE-NNNN + URL
→ POST :white_check_mark: confirmation thread reply via slack_send_message
→ if verification = "real bug" AND no dedupe AND no open PR:
invoke Skill(skill="red-green-fix") → POST :pr-open: thread reply
```
### Non-negotiable rules
1. **Linear actions are Slack tool calls.** The skill MUST drive Linear by calling `mcp__plugin_slack_slack__slack_send_message` with `thread_ts` set and text that mentions `@Linear`. There is no MCP-direct path and no API-key path. Printing `@Linear create ...` into the Claude CLI response is NOT a substitute — the Slack thread reply is what triggers the Linear bot, and its card is the canonical receipt.
2. **Dedupe is a gate, not a suggestion.** No candidate is proposed for creation until `@Linear search` AND `gh pr` search have been run and recorded. A hit short-circuits creation to `L` (link) or `pr-open`.
3. **Auto-fix real bugs.** When the dedupe gate is clean AND false-defect verification is clean AND the candidate isn't on the handoff-exclusion list (see § Handoff conditions), after Linear creation the skill invokes `red-green-fix` via the `Skill` tool — without waiting for an extra human prompt.
### What the skill cannot do
The Slack MCP exposes no `reactions.add` tool, so the skill cannot put a `:white_check_mark:` reaction on the parent message. The thread reply with the leading `:white_check_mark:` emoji is the skill's canonical marker; a human can additionally add the parent reaction for channel visibility (see § Parent reaction — optional visibility nudge). Both are respected by Processed Detection.
## Team emoji scheme
| Emoji | Meaning | Who adds it | Skill behavior |
| -------------------- | ------------------ | ------------------------------------------------------ | ---------------------------------------------- |
| `:white_check_mark:` | Ticket created | Human on parent (after skill files); also in bot reply | Skip in future sweeps |
| `:pr-open:` | PR open | Human | Skip creation; include PR link in approval row |
| `:question:` | Needs more context | Human | Skip creation; agent may ask for clarification |
| `:repeat:` | Duplicate | Human | Skip creation; link existing Linear issue |
## Design Priority
Optimize for **coverage, label quality, and proven fixes** over fix-path cleverness. Linear is the downstream triage surface — once every bug is there with status, labels, and context, agents and humans can work from Linear alone. A Linear ticket with a wrong severity is cheap to fix; a Slack-only bug is invisible to downstream tooling; a "filed but not fixed" real regression wastes a human turn that the skill could have spent on a red-green PR.
## Quick Start
1. **Scope** — default window: messages in the last 48h. Override with `--since YYYY-MM-DD` or a Slack permalink list.
2. **Fetch**`slack_read_channel` for `C0A4XMHANP3`; `slack_read_thread` per message with replies.
3. **Filter** — drop already-processed (see Processed Detection).
4. **Classify** — bug / discussion / meta (see Classification Rules).
5. **Pre-flight dedupe gate (MANDATORY)** — for every bug candidate, run `@Linear search` AND `gh pr` search BEFORE proposing (see § Pre-flight Dedupe Gate). A hit means the candidate goes into the batch as `L` (link) or `pr-open`, not as a new create.
6. **Verify false defects** — per candidate, run quick checks before proposing (see False-Defect Verification).
7. **Extract** — normalize to ticket schema (see Ticket Schema).
8. **Human approval** — batch table, collect Y/N/?/S/L/R per candidate (see Interactive Approval). Default recommendation for clean candidates is `Y` (file + auto-fix).
9. **Post `@Linear create` thread reply — MANDATORY TOOL CALL** — for each approved `Y`/`L` row, call `mcp__plugin_slack_slack__slack_send_message` with `channel_id=C0A4XMHANP3`, `thread_ts=<parent-ts>`, and text starting with `@Linear create` (see § Linear Slack Bot Integration). Do NOT print the command into chat as a substitute.
10. **Capture the Linear bot card** — poll `slack_read_thread` up to 3× with ~3s spacing, parse the first Linear-app reply for the `FE-NNNN` identifier and `https://linear.app/...` URL. No URL = not ingested; never fabricate one.
11. **Post `:white_check_mark:` confirmation reply — MANDATORY TOOL CALL** — call `slack_send_message` again with text starting with `:white_check_mark: Filed to Linear: <URL>` so future sweeps can detect the marker via `has::white_check_mark: from:me`. Record both `ts` values in the session log.
12. **Auto-fix (clean candidates only)** — if dedupe gate is clean AND false-defect verification is clean AND the candidate isn't on the Handoff-Exclusion list, immediately invoke the `red-green-fix` skill via the `Skill` tool. See § Fix Workflow for the exact call contract.
13. **Log** — append to session log; update `processed.json`.
## System Context
| Item | Value |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| Source channel | `#bug-dump` (`C0A4XMHANP3`) |
| Destination | Linear `Frontend Engineering` team, via the Linear Slack app (`@Linear`). Team is named in every `@Linear create` message. |
| Default state | `Triage` — every `@Linear create` message includes `Status: Triage` |
| State dir | `~/temp/bug-dump-ingest/` |
| Processed registry | `~/temp/bug-dump-ingest/processed.json` |
| Session log | `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md` |
| Drafts (failure) | `~/temp/bug-dump-ingest/drafts/*.md` — written only when `@Linear` never replies, so the human can retry manually |
## Label Taxonomy
Every created Linear issue MUST get the following labels, passed as a comma-separated list in the `Labels:` line of the `@Linear create` message. The Linear Slack app creates missing labels on first use:
| Label kind | Values | Source |
| ------------ | ------------------------------------------------------------------------------ | ------------------------- |
| `source:` | `source:bug-dump` | Always (marks Slack sync) |
| `area:` | `area:ui`, `area:node-system`, `area:workflow`, `area:cloud`, `area:templates` | Area Heuristics |
| `env:` | `env:cloud-prod`, `env:cloud-dev`, `env:local`, `env:electron` | Env Heuristics |
| `severity:` | `sev:high`, `sev:medium`, `sev:low` | Severity Heuristics |
| `reporter:` | `reporter:<slack-handle>` (kebab-case) | From message author |
| Status flags | `needs-repro`, `needs-backend`, `regression`, `pr-open` | When applicable |
Label rules:
- Always include `source:bug-dump`, exactly one `area:`, at least one `env:` (or `env:unknown`), exactly one `severity:`, exactly one `reporter:`.
- `needs-repro` — set when repro steps were ambiguous; signals "human should confirm before fix".
- `needs-backend` — set when fix is clearly in ComfyUI backend, not this frontend repo.
- `regression` — set when the bug mentions a version/upgrade correlation.
- `pr-open` — set instead of creating a fresh ticket when a fix PR already exists; the Linear issue becomes a tracker.
Labels are the primary affordance for downstream triage — invest in getting them right, not just in the title.
## Processed Detection
A top-level message is considered already-handled (skip creation) if ANY of:
- Its timestamp appears in `processed.json`.
- It carries a `:white_check_mark:` reaction on the parent — ticket already created.
- It carries a `:pr-open:` reaction — fix PR is open; skill records the PR link in the session log rather than creating a fresh Linear issue.
- It carries a `:repeat:` reaction — duplicate; skill attempts to find the original Linear issue and link it in the session log.
- It carries a `:question:` reaction — needs more context; skill skips creation and records for follow-up.
- Its thread contains a reply with a `https://linear.app/` URL (fetch via `slack_read_thread`).
- Its thread contains a reply starting with `:white_check_mark:` from the skill's bot user.
- It is a system/meta message (`has joined the channel`, bot-only message).
- Its thread already contains resolution confirmation (`"solved"`, `"resolved"`, `:done:` reaction from the reporter) AND has no fix PR referenced — treat as "resolved without ticket, skip".
Never re-ingest a message already marked in any of the above ways.
Filter query for Slack search-based sweeps:
```text
in:<#C0A4XMHANP3> -has::white_check_mark: -has::pr-open: -has::repeat: -has::question: after:YYYY-MM-DD
```
## False-Defect Verification
Before a candidate hits the approval batch, run cheap checks to demote obvious non-bugs. Goal: keep the approval table high-signal. This is not a full repro — just fast heuristics that catch the top false-positive classes.
| Check | Command / Signal | Demote-to |
| ---------------------------------------- | ---------------------------------------------------------------- | ---------- |
| Reporter self-resolved in same msg | "no action needed", "solved", "nvm", "fixed it" | `resolved` |
| Reporter self-resolved in thread | `slack_read_thread` → reporter's last reply contains "solved" | `resolved` |
| Fix PR merged on main | `gh search prs "in:title <keyword>" --state merged --limit 3` | `fixed` |
| Fix PR open (already-filed) | `gh search prs "<keyword>" --state open --limit 3` | `pr-open` |
| Linear issue exists (open) | Linear `searchIssues` on title keywords → any open match | `dedupe` |
| Behavior is documented / intended | grep `docs/` and `src/locales/en/*.json` for the feature | `expected` |
| Not reproducible — feature doesn't exist | grep `src/` for mentioned component/feature → 0 hits | `stale` |
| Env drift only (local setup issue) | Thread contains "my machine", "my setup", "proxy" without others | `env` |
For each demoted candidate, record the demotion reason in the approval table as `Verify: <tag>` so the human can override if they disagree. Never hard-skip based on verification alone — always show the row with the demotion.
### Recommended verify commands
```bash
# 1. Search recent PRs for the feature in question
gh search prs "<keyword>" --repo Comfy-Org/ComfyUI_frontend --limit 5
# 2. Grep for the feature / component mentioned
rg -l "<ComponentOrFeatureName>" src/ apps/
# 3. Check if it's a known i18n / documented setting
rg "<setting-key>" src/locales/en/ docs/
```
Keep verification under ~30s per candidate. If it takes longer, propose a ticket and let the human decide — don't let verification become the bottleneck.
## Classification Rules
For each unprocessed top-level message, decide:
| Class | Signal | Action |
| ----------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------- |
| **bug** | Describes unexpected behavior, visual glitch, error, regression, crash. Usually has repro steps or media. | Propose Linear ticket |
| **discussion** | Design question, rollout thoughts, team chatter, PR planning (e.g. "how about we make a PR to do...") | Skip |
| **question** | User asking if something is expected or known | Skip unless answered = bug |
| **meta** | Channel joins, bot messages, cross-posts without content | Skip |
| **already-filed** | Thread shows PR already open OR existing Linear link | Skip, log with existing link |
When ambiguous, default to **bug** and let the human decide in the approval batch.
## Ticket Schema
Normalize each bug to this shape before presenting:
```json
{
"slack_ts": "1776639963.837519",
"slack_permalink": "https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776639963837519",
"reporter": "Ali Ranjah (wavey)",
"title": "Unet model dropdown missing selected model",
"description": "Body with repro steps, env, attachments list, thread summary",
"env": ["cloud prod"],
"severity": "low | medium | high",
"area": "ui | node-system | workflow | cloud | templates | unknown",
"attachments": [{ "name": "...", "id": "F...", "type": "image/png" }],
"thread_resolution": "solved | open | none"
}
```
Keep descriptions copy-paste friendly: lead with repro bullets, then env, then "See Slack: <permalink>". Attach thread summary only if it adds context beyond the top-level message.
### Severity Heuristics
- **high** — crash, data loss, blocks a template or core feature, affects paying users broadly (e.g. "job ends in 30m on Pro", "widget values reset").
- **medium** — visible regression, template error, wrong pricing, broken UX on a common path.
- **low** — cosmetic, single-template edge case, minor tooltip/boundary issue.
When unsure, mark `medium` and flag for human in the approval batch.
### Area Heuristics
- `ui` — visual glitches, palette issues, popover clipping, dropdown styling.
- `node-system` — canvas perf, reroute, node drag, widget rendering, undo.
- `workflow` — template failures, save/load, refresh regressions.
- `cloud` — jobs, pricing, assets, auth, queue.
- `templates` — specific template errors.
## Pre-flight Dedupe Gate (MANDATORY)
Before any candidate enters the approval table, run BOTH checks below and record the result in the row's `Dedup` and `PR` columns. This is a hard gate — no candidate may be proposed for creation without a verdict.
### Check 1 — Open Linear issues (via `@Linear search`)
Extract 3-5 keyword terms from the proposed title (strip stopwords). Post a search command to the bug-dump thread — use a scratch thread if no parent `ts` is available yet, but prefer the candidate's own parent thread so the search card becomes part of that thread's audit trail:
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
})
```
Poll `slack_read_thread` for up to 10s; parse the Linear app's card reply for `FE-NNNN` identifiers and URLs. Run the search twice with different keyword subsets if the first returns zero hits — reworded titles are the top false-negative class.
If `@Linear search` is not supported by the workspace's Linear app version, fall back to a Slack search for prior `@Linear` card replies in the channel:
```text
mcp__plugin_slack_slack__slack_search_public({
query: "in:<#C0A4XMHANP3> from:@Linear <keyword-1> <keyword-2>"
})
```
This scans past Linear bot replies in the channel — any reply containing a matching `FE-NNNN` URL is a candidate duplicate. Record which dedupe path was used in the session log.
Treat a hit as a duplicate if any of:
- Title overlap ≥ 80% (after lowercasing + stopword removal)
- Same reporter + same component reference in description
- Same stack trace or error code
**Verdict:** set `Dedup: FE-NNNN` and default recommendation to `L` (link, don't create). The human may still override to `Y` to file a separate ticket.
### Check 2 — Open or merged fix PRs on GitHub
```bash
# Open PRs matching title keywords
gh pr list --repo Comfy-Org/ComfyUI_frontend --state open \
--search "<keyword-1> <keyword-2>" --limit 5 \
--json number,title,url,createdAt
# Recent merged fixes (last 30d) — catches "already fixed, waiting to ship"
gh pr list --repo Comfy-Org/ComfyUI_frontend --state merged \
--search "<keyword-1> <keyword-2> merged:>=<YYYY-MM-DD>" --limit 5 \
--json number,title,url,mergedAt
```
Treat a hit as a match if the PR title/body mentions the same component or bug phrase and the PR is unmerged or merged within the window covering the reporter's observation.
**Verdict:**
- Open PR match → set `PR: #NNNN (open)`, recommendation `pr-open` (file Linear with `pr-open` label linking the PR, skip auto-fix).
- Merged PR match → set `PR: #NNNN (merged)`, recommendation `fixed` (demote in verify, usually skip; human can override if the reporter claims the fix didn't land).
### Failure handling
If either check errors (Linear Slack app silent or not in channel, `gh` auth expired), DO NOT proceed to proposal — stop the sweep, report the failure to the user, and let them decide whether to re-run or manually dedupe. A silent skip of dedupe is never acceptable; it's the single biggest source of duplicate tickets.
Log each dedupe query + top hits in `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md` under a per-candidate `Dedup trace:` block so the human can audit.
## Interactive Approval
Present candidates in batches of 5-10. Table format (10 columns):
```text
# | Slack (author, time) | Proposed title | Env | Sev | Area | Dedup | PR | Verify | Rec
----+------------------------+-----------------------------------------+------------+------+------------+------------+---------------+-------------+-----
1 | wavey, 04-20 08:06 | Unet dropdown missing selected model | cloud prod | low | ui | - | - | resolved | N
2 | Denys, 04-18 05:45 | Pro plan jobs end at 30 minutes | cloud prod | high | cloud | - | - | clean | Y
3 | Terry Jia, 04-18 12:52 | Nodes 2.0 canvas lag on large workflows | - | high | node-system| FE-4521 | - | clean | L
4 | Pablo, 04-17 08:52 | Multi-asset delete popup shows hashes | cloud prod | low | ui | - | #11402 (open) | clean | pr-open
```
Each row MUST show: Slack author + date, proposed title, env tags, severity, area, **dedupe status from the Pre-flight Dedupe Gate**, **open/merged PR hit from the Pre-flight Dedupe Gate**, verify tag (from False-Defect Verification), and agent recommendation.
### Default recommendation logic
The skill computes `Rec` deterministically from the gate results:
- `L` — Dedupe hit on open Linear issue.
- `pr-open` — Open GitHub PR hit.
- `fixed` — Merged PR hit within the reporter's observation window.
- `N` — Verify tag is `resolved`, `expected`, `stale`, or `env` only.
- `?` — Repro incomplete or classification ambiguous.
- `Y` — Everything clean AND candidate is not on the § Handoff-Exclusion list. This is the "file + auto-fix" path.
- `Y (file-only)` — Clean but on the handoff-exclusion list (e.g. touches LGraphNode, needs backend). File Linear, skip auto-fix.
### Response format
- `Y` — default path: create Linear ticket, post `:white_check_mark:` thread reply, AND if the candidate is eligible (dedupe clean, verify clean, not on handoff-exclusion list), immediately invoke `red-green-fix` via the `Skill` tool. See § Fix Workflow.
- `S`**skip auto-fix** for this row: create Linear ticket + thread reply only, do NOT run red-green-fix. Use when the human knows a specific person is already investigating or wants to batch fixes.
- `N` — skip entirely (log reason in session file).
- `?` — mark as needs-context; skill posts a thread reply asking for repro details and prompts the human to add `:question:` to the parent.
- `L` — link to existing Linear issue instead of creating (skill asks which one if the Pre-flight Dedupe Gate didn't return an exact match).
- `R` — duplicate of another bug-dump message; skill links the two and prompts the human for `:repeat:` on the parent.
- `E` — edit proposed title/description before creating (skill shows draft for inline tweaks).
- Bulk responses accepted: `1 N, 2 Y, 3 L FE-4521, 4 pr-open #11402, 5 ?` — any row omitted from the response is treated as its computed `Rec` default.
Do not post any `@Linear create` messages until all candidates in the batch have a terminal decision. Auto-fix invocations run sequentially AFTER every `@Linear create` has produced a parsed `FE-NNNN`, so every `red-green-fix` call has a `Fixes FE-NNNN` to put in the PR body.
## Linear Slack Bot Integration (@Linear)
Every Linear action — create, search, link, label, status change — is performed by posting a message to the candidate's thread in `#bug-dump` that mentions `@Linear`. The Linear Slack app parses the mention and responds with a card in the same thread. There is no Linear MCP path and no `LINEAR_API_KEY` path; see `reference/linear-api.md` § "Why no direct API path" for the rationale.
### Prerequisites
- The Comfy Slack workspace already has the Linear Slack app installed (this is how humans add `@Linear` mentions today).
- Channel `C0A4XMHANP3` is connected to the `Frontend Engineering` Linear team.
- No per-machine setup. If a `@Linear` invocation produces no bot reply, the app is not in the channel — surface to the human, do NOT retry silently.
### Create an issue
For each approved `Y` candidate, call:
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear create\nTeam: Frontend Engineering\nTitle: <title>\nStatus: Triage\nLabels: source:bug-dump, area:<area>, env:<env>, sev:<severity>, reporter:<handle>\n\n<description>\n\nSource: <slack-permalink>"
})
```
Rules:
- First line MUST be `@Linear create` — this is the command token.
- `Team: Frontend Engineering` is required on every create — without it the bot falls back to the workspace default, which may route to a different team.
- `Status: Triage` pins the initial state (per § System Context).
- `Labels:` — comma-separated, full `source:bug-dump, area:*, env:*, sev:*, reporter:*` set per § Label Taxonomy. Missing labels are auto-created by the Linear Slack app on first use.
- Description body is markdown — see `reference/linear-api.md` § "Description body template" and `reference/schema.md` for per-field extraction.
- Use real newlines (not literal `\n`) when constructing the text.
After the tool call returns, poll `slack_read_thread` for the Linear app's reply card (up to 3× with ~3s spacing). Parse the card for:
- An `FE-NNNN` identifier
- A `https://linear.app/<org>/issue/FE-NNNN` URL
The URL is the ingested receipt. The skill then posts the `:white_check_mark:` confirmation reply (§ Slack Thread Reply).
### Search (dedupe)
See § Pre-flight Dedupe Gate § Check 1 for the search command shape and handling of the bot's reply. The search is a tool call in the candidate's thread — not a chat aside.
### Link an existing issue (`L` response)
When the human picks `L FE-4521` for a row, do NOT post `@Linear create`. Instead:
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear link FE-4521"
})
```
The bot replies with the linked issue card. Then post the `:white_check_mark:` confirmation reply (adjusted to say `Linked to Linear:` rather than `Filed to Linear:`) so Processed Detection still matches.
### Label / status updates
When a later sweep needs to flip a ticket (e.g. a PR opened after initial ingest, so add `pr-open` and link):
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear FE-4521 add-labels pr-open"
})
```
Status changes are rarely driven by this skill directly — Linear auto-moves issues to `In Review` when a PR with `Fixes FE-NNNN` is opened, and the `red-green-fix` skill handles that PR body.
### Captured fields per create
Every successful create must produce, via the Linear bot's reply card:
- `identifier` — e.g. `FE-4710`, used in `Fixes <LIN-ID>` references and session log
- `url``https://linear.app/.../issue/FE-4710`, included verbatim in the `:white_check_mark:` reply
- `ts` of the Linear bot's card reply — recorded in session log for audit
If the card is missing the URL or identifier, fall through to the failure path below — do NOT fabricate either value.
### Failure path
If the Linear bot does not reply within the poll window, OR replies with a parse error (`couldn't parse`, `no team matched`, `failed`):
1. Write a draft markdown file to `~/temp/bug-dump-ingest/drafts/NN-short-slug.md` containing the full `@Linear create` text that was sent plus any partial bot reply.
2. Post a thread reply that is explicit about the failure — do NOT include `:white_check_mark:` or a fake Linear URL:
```text
:warning: bug-dump-ingest: @Linear did not respond. Drafted at ~/temp/bug-dump-ingest/drafts/<slug>.md — please file manually and reply with the FE-NNNN.
```
3. Skip auto-fix for this candidate (no Linear ID = no `Fixes` reference).
4. Log the failure in the session log.
Never invent a Linear URL. Never post `:white_check_mark: Filed to Linear: ...` without a real URL parsed from a real Linear bot card.
## Slack Thread Reply (Ingested Marker) — MANDATORY TOOL CALL
Every approved candidate produces **two** mandatory `slack_send_message` calls in the parent thread:
1. The `@Linear create` (or `@Linear link`) command — see § Linear Slack Bot Integration.
2. The `:white_check_mark:` confirmation reply described below, posted after a real `FE-NNNN` + URL have been parsed from the Linear bot's card.
The second reply is what future sweeps grep for via `has::white_check_mark: from:me`. Even though the Linear bot's own card already contains the URL, the `:white_check_mark:` prefix is the canonical Processed Detection marker — without it, a future sweep may re-ingest the same bug.
The skill is not done with a candidate until BOTH calls have succeeded. If either fails, do not claim the candidate is ingested.
### Required call shape
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-message-ts>", // dotted form, e.g. "1776714531.990509"
text: ":white_check_mark: Filed to Linear: <LINEAR_URL>\nReporter: <@USER_ID>\nSev: <severity> • Area: <area>"
})
```
Rules:
- `thread_ts` MUST be the parent message ts — never the channel ts, never omitted. An omitted `thread_ts` posts at channel level, which pollutes `#bug-dump` and breaks Processed Detection.
- The text MUST start with `:white_check_mark:` followed by a space and `Filed to Linear:`. This exact prefix is what future sweeps grep for via `has::white_check_mark: from:me`.
- The Linear URL MUST be present. No URL = not ingested; future sweeps will re-file the same bug.
- Plain text only — no markdown tables, no bold, no code fences. Slack renders the emoji shortcode into a real `:white_check_mark:` only when the message is plain text.
- Capture the returned `ts` and record it in the session log for audit.
### NEVER-do list (common failure mode)
- **Do NOT** print `@Linear create ...` or `:white_check_mark: Filed to Linear: <URL>` into the Claude CLI chat response as a substitute for calling `slack_send_message`. The CLI output is not seen by Slack. If you find yourself typing either into a plain assistant message, stop and issue the tool call instead.
- **Do NOT** claim the thread reply was posted until the `slack_send_message` tool call has returned a success with a `ts`. If the tool call errors, surface the error and halt the batch — do not fabricate a reply.
- **Do NOT** use any other tool (e.g. `slack_schedule_message`, `slack_send_message_draft`) as a substitute. Only an immediate `slack_send_message` with `thread_ts` set counts — the Linear Slack app does not trigger on scheduled/draft messages.
- **Do NOT** substitute any direct Linear API call (MCP, GraphQL, curl) for the `@Linear` mention. The Slack thread is intentionally the single audit trail.
### Fix-path reply (after red-green-fix opens a PR)
When `red-green-fix` returns a PR URL for an auto-fixed candidate, the skill MUST post a second thread reply on the same parent — again via `slack_send_message`:
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<same parent ts>",
text: ":pr-open: Fix PR: <PR_URL>\nRed-green verified: <unit|e2e> test proves the regression.\nFixes <LIN-ID>"
})
```
Same "tool call, not chat output" rule applies.
### Parent reaction — optional visibility nudge (not on critical path)
The Slack MCP does not expose `reactions.add`, so the skill cannot set a `:white_check_mark:` reaction on the parent. The thread reply above is sufficient for Processed Detection; the parent reaction is a human-only "visible in channel" nudge. At the end of the run, the skill MAY print a compact list for the human:
```text
Optional: add :white_check_mark: to parent messages for in-channel visibility.
LIN-4710 → <permalink>
LIN-4711 → <permalink>
```
This is a convenience, not a deliverable — a missing parent reaction does not cause re-ingestion.
## Fix Workflow (auto-invoke red-green-fix)
For every `Y` row whose `Rec` resolved to auto-fix (dedupe clean, verify clean, not on handoff-exclusion list), the skill MUST — after Linear creation and the `:white_check_mark:` thread reply — invoke the `red-green-fix` skill via the `Skill` tool. This is a real tool call, not a narrative handoff.
### Required Skill tool call
```text
Skill({
skill: "red-green-fix",
args: "<composed prompt — see below>"
})
```
Compose `args` as a single self-contained prompt so the sub-invocation has everything it needs without re-reading the Linear issue:
```text
Bug: <title>
Linear: <LIN-ID> (<LINEAR_URL>)
Source: Slack <permalink>
Reporter: <display-name>
Env: <env tags>
Area: <area>
Branch: fix/<lin-id-lowercase>-<short-slug>
Repro:
1. <step>
2. <step>
Expected: <expected behavior>
Actual: <actual behavior>
Test layer (inferred from area):
- ui → Vitest colocated + Playwright e2e tagged @regression
- node-system → Playwright e2e primarily
- workflow / templates → Playwright e2e
- cloud → Vitest if client-side; otherwise STOP and label the Linear issue "needs-backend"
Test naming:
- describe('<LIN-ID>: <one-line bug summary>', ...)
- Playwright test title must include the LIN-ID.
PR body must include:
- "Fixes <LIN-ID>"
- "Source: Slack <permalink>"
Follow the red-green-fix two-commit sequence exactly. Do NOT skip the red commit.
```
The skill MUST wait for `red-green-fix` to return before moving to the next candidate. Process one auto-fix at a time so branch state is deterministic.
### Verifying the invocation ran
After the `Skill` call returns, the skill MUST confirm at least one of:
1. A new git branch named `fix/<lin-id>-*` exists (`git branch --list "fix/<lin-id>-*"`).
2. A PR URL is present in `red-green-fix`'s return payload.
If neither is true, the invocation silently no-op'd. Log the failure to the session log as `auto-fix skipped: invocation returned without branch or PR` and continue — do NOT post the `:pr-open:` thread reply.
### Inputs summary
- **Bug description** — the Linear description (includes repro, env, source permalink).
- **Linear ID** — inserted into the PR body as `Fixes <LIN-ID>`.
- **Branch name** — `fix/<lin-id>-<short-slug>` (e.g. `fix/lin-4711-pro-plan-30min-timeout`).
- **Test layer** — inferred from `area`:
- `ui` → unit (Vitest) + e2e (Playwright)
- `node-system` → e2e primarily; unit if isolable
- `workflow` / `templates` → e2e
- `cloud` → unit if client-side logic, otherwise flag "backend — out of scope for this repo"
### Handoff-Exclusion list (do NOT auto-invoke red-green-fix)
These rows still get a Linear ticket + `:white_check_mark:` thread reply, but the skill MUST skip the `Skill(skill="red-green-fix")` call and instead post a thread nudge explaining why:
- Repro steps are incomplete (no clear numbered steps, no env) — reply in thread: "Need clearer repro before I can write a failing test. What's the shortest path to reproduce?"
- Fix requires backend / ComfyUI repo changes (not frontend) — label Linear `needs-backend`.
- Linear ticket was dedupe-linked rather than newly created — existing owner may already be fixing.
- Severity is cosmetic AND reporter hasn't asked for a fix — file ticket only.
- Fix would touch `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph` god-objects (ADR-0003/0008 — always human decision).
- Pre-flight Dedupe Gate found an open PR (`pr-open`) or a matching merged PR (`fixed`).
When a row is excluded, record the reason in the session log under `auto-fix excluded: <reason>`.
### Test authoring rules
Both tests MUST be written in the "red" commit BEFORE any fix code (per red-green-fix). Rules specific to bug-dump ingestion:
- **Unit test (Vitest)** — colocated next to the implementation, `<file>.test.ts`. Exercise the specific logic path reproduced by the reporter. One `describe` block named after the Linear ID:
```typescript
// src/components/node/UnetDropdown.test.ts
describe('LIN-4710: unet dropdown missing selected model', () => {
it('includes the currently-selected model in the list even when not in available models', () => {
// ...
})
})
```
- **E2E test (Playwright)** — under `browser_tests/tests/`, follow `writing-playwright-tests` skill. Tag with `@regression` and include the Linear ID in the test title:
```typescript
test.describe(
'LIN-4710 unet dropdown regression',
{ tag: ['@regression'] },
() => {
test('keeps selected model visible in the dropdown', async ({
comfyPage
}) => {
// ...
})
}
)
```
- **Mock data types** — follow `docs/guidance/playwright.md`: mock responses typed from `packages/ingest-types`, `packages/registry-types`, `src/schemas/` — never `as any`.
(The Handoff-Exclusion list above governs when `red-green-fix` is NOT invoked.)
### PR body template
The red-green-fix skill's PR template is extended with a `Source` line:
```markdown
## Summary
<Root cause>
- Fixes LIN-NNN
- Source: Slack <permalink>
## Red-Green Verification
| Commit | CI Status | Purpose |
| ------------------------------------------ | -------------------- | ------------------------------- |
| `test: LIN-NNN add failing test for <bug>` | :red_circle: Red | Proves the test catches the bug |
| `fix: <bug summary>` | :green_circle: Green | Proves the fix resolves the bug |
## Test Plan
- [ ] Unit regression test passes locally
- [ ] E2E regression test passes locally (if UI)
- [ ] Manual repro no longer reproduces
- [ ] Linear ticket linked
```
After the PR merges, post the second thread reply on Slack (see Slack Thread Reply § Fix-path reply).
## Emoji Reaction Hints (read-only)
The agent cannot add reactions, but respects human-set reactions when filtering. The canonical team scheme (primary):
| Reaction | Meaning | Action |
| -------------------- | ------------------ | -------------------------------------------------------- |
| `:white_check_mark:` | Ticket created | Skip — already ingested |
| `:pr-open:` | PR open | Skip creation; record PR link in session log |
| `:question:` | Needs more context | Skip creation; agent may post a thread reply asking |
| `:repeat:` | Duplicate | Skip creation; link existing Linear issue in session log |
Incidental reactions observed in the channel — treat as soft hints only, do NOT skip solely on these:
| Reaction | Meaning | Action |
| -------- | ------------------- | -------------------------------------------------- |
| `:eyes:` | Someone is triaging | Still ingestable |
| `:done:` | Reporter resolved | Demote to `resolved` in verify, but still show row |
| `:+1:` | Acknowledged | Ignore |
Approval-table response code `R` (new) corresponds to `:repeat:` — if you pick `R`, the skill treats it as duplicate and asks for the target Linear ID.
## Session Log
Append to `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md`:
```text
Bug Dump Ingest Session -- 2026-04-20 11:40 KST
Window: 2026-04-18 00:00 — 2026-04-20 12:00 KST
Scanned: 28 top-level messages
Skipped (meta/discussion/processed): 14
Proposed: 14
Approved: 11
Created in Linear: 10
Draft-only (creation failed): 1
Linked-only (dedupe): 1
Thread replies posted: 11
Created:
- LIN-4710 Unet model dropdown missing selected model -- wavey -- low/ui
- LIN-4711 Pro plan jobs end at 30 minutes -- Denys -- high/cloud
- ...
Skipped with reason:
- 1776592837.616399 -- design discussion in thread, not a bug
- ...
```
## Gotchas
### Thread summaries, not raw dumps
Pulling the full thread often adds noise. Summarize replies to: (a) confirmed reproductions by other users, (b) env/version details added in replies, (c) links to related PRs/commits. Drop emojis-only replies, joined-channel notifications, and off-topic chatter.
### Cross-posts are not bugs
When the top-level message is just a link to a Slack message in another channel (e.g. "X posting" with a URL and nothing else), follow the link to the original source and ingest from there — do NOT create a ticket from the cross-post itself.
### Resolved-in-thread messages
If the reporter replies `"No action needed, this is solved"` (see wavey 2026-04-20 08:06), mark the ticket for SKIP in the approval table, not auto-skip. The human may still want a regression test ticket.
### Permalinks
Construct Slack permalinks as:
```text
https://comfy-organization.slack.com/archives/{CHANNEL_ID}/p{TS_WITH_DOT_REMOVED}
```
E.g. `1776510375.473579` → `p1776510375473579`.
### Attachment handling
Slack file IDs (e.g. `F0AT...`) are private. Do NOT link them directly in Linear. Instead, list the filename and type in the Linear description and include the Slack permalink — anyone with Slack access can see the attachments from the thread.
### No auto-create without approval
Never create Linear issues without a human `Y`. This is a hard rule — the skill exists to reduce human toil, not to replace triage judgment.
## Reference Files
- `reference/linear-api.md` — `@Linear` Slack bot command reference (create, search, link, labels, status).
- `reference/schema.md` — full ticket schema with field-by-field extraction notes.
- `reference/examples.md` — worked examples drawn from real #bug-dump messages.
- `reference/verify-commands.md` — cookbook of false-defect verification commands per bug class.
## Related Skills
- `red-green-fix` — auto-invoked via the `Skill` tool for every eligible `Y` candidate to produce a failing test + fix + PR with the red-green CI proof.
- `writing-playwright-tests` — used by red-green-fix when an e2e test is needed.
- `hardening-flaky-e2e-tests` — if the e2e test added in the fix PR starts flaking, jump to this skill.

View File

@@ -1,123 +0,0 @@
# Worked Examples
Real #bug-dump messages (2026-04-17 → 2026-04-20) normalized through the skill.
## Example 1 — Clean bug with repro
**Source message** (wavey, 2026-04-20 08:06):
> unet model dropdown doesnt display all available models, think this is part of a larger issue with model dropdowns..
>
> • open flux.2 klein 4b image edit template
> • open unet drop down --> notice selected model isnt present in the list, even though its selected
> • execute (to check if it flags the model as missing) --> notice it still runs
> No action needed, this is solved
**Thread resolution**: "No action needed, this is solved" — reporter resolved it in the same message.
**Classification**: bug, but `thread_resolution = solved`. Flag for human.
**Approval row**:
```text
1 | wavey, 04-20 08:06 | Unet dropdown missing selected model | cloud | low | ui | N | N (reporter marked solved)
```
Default recommendation: `N`. If human overrides to `Y`, file with a "Regression test" label so QA still tracks it.
---
## Example 2 — Clear high-severity cloud bug
**Source message** (Denys Puziak, 2026-04-18 05:45):
> I see two reports about jobs ending in 30 minutes while the user is on the Pro plan
> cc @Hunter
> https://discord.com/channels/.../1494078128971055145
**Classification**: bug, `env: [cloud prod]` (Pro plan = cloud), `severity: high` (paying users), `area: cloud`.
**Proposed title**: `Pro plan jobs end at 30 minutes`
**Description** (excerpt):
```markdown
**Reporter:** Denys Puziak
**Env:** cloud prod
**Severity (proposed):** high
**Area:** cloud
## Repro
1. User on Pro plan submits a job
2. Job ends at 30 minutes instead of the Pro plan limit
## Notes
- Two user reports aggregated by Denys
- cc'd @Hunter
## Source
Slack: <permalink>
Discord thread: https://discord.com/channels/.../1494078128971055145
```
---
## Example 3 — Not a bug (discussion)
**Source message** (Christian Byrne, 2026-04-19 19:00):
> @Glary-Bot okay option A is clearly superior and I feel embarrassed I didn't see that line myself...
**Classification**: discussion (design review chatter). Skip. Log reason in session file.
---
## Example 4 — Meta-action / PR planning
**Source message** (Christian Byrne, 2026-04-19 09:30):
> @Glary-Bot how about we make a PR to do:
>
> 1. Audit the rest of the codebase...
> 2. Create a helper in src/base...
**Classification**: discussion (PR-plan proposal). Skip.
---
## Example 5 — Performance regression
**Source message** (Terry Jia, 2026-04-18 12:52):
> With Nodes 2.0, large workflows (hundreds of nodes) make the canvas extremely laggy and unusable for actual work — switching tabs takes several seconds or more. Switching back to Litegraph, performance is significantly better.
**Classification**: bug, `area: node-system`, `severity: high`.
**Dedupe**: Post `@Linear search nodes 2.0 performance canvas lag` (Team: Frontend Engineering, Status: open) in the candidate's thread. Likely matches exist — flag `Dedup? ?` and ask human which ticket to link to.
---
## Example 6 — Reporter says it's a question, not a report
**Source message** (Luke, 2026-04-17 08:27):
> Is NodeInfo supposed to show information or docs about the node? It just brings up the node sidebar
**Classification**: question → ambiguous. Read thread. If replies confirm "that's unexpected, should show docs", upgrade to bug. If "yes that's intended", skip.
Default recommendation in the approval batch: `?` (needs expansion).
---
## Example 7 — Bug with PR already in flight
**Source message** (Pablo, 2026-04-17 08:52):
> when deleting multiple assets on cloud -> the confirmation popup still has the assets hashes as names instead of the display name
**Reaction**: `pr-open (1)` — someone's opened a PR.
**Classification**: `already-filed` branch. Skip creation; in the session log, note "PR already open". If the human wants a tracking Linear ticket anyway, still fileable with a link to the PR.

View File

@@ -1,160 +0,0 @@
# Linear Slack Bot (@Linear) Reference
The skill drives Linear exclusively through the Linear Slack app (`@Linear`). **There is no Linear MCP, no `LINEAR_API_KEY`, no GraphQL.** Every Linear read/write happens as a Slack message that mentions `@Linear` in the `#bug-dump` thread, and the Linear Slack app performs the action and posts a reply card containing the issue URL.
## Why Slack-only
- The `#bug-dump` thread is already the source of truth; keeping the entire lifecycle (report → ticket → PR → resolution) in one thread means Processed Detection can grep the thread instead of a separate registry.
- No API key rotation, no MCP server install, no OAuth browser flow — works on any machine that already has the Slack MCP configured.
- The Linear Slack app's reply card (with issue URL, title, status, and assignee) IS the canonical receipt; the skill records its `ts` in the session log.
## Prerequisites (one-time, per workspace)
The Comfy Slack workspace must already have the Linear Slack app installed (it is — that's how humans use `@Linear` reactions today) and `#bug-dump` (channel `C0A4XMHANP3`) must have Linear enabled for the `Frontend Engineering` team. Nothing else to configure. If a `@Linear` invocation silently does nothing, the bot isn't present in the channel — surface that to the human rather than re-trying.
## Supported operations
Every operation is a `mcp__plugin_slack_slack__slack_send_message` call with `channel_id=C0A4XMHANP3` and `thread_ts=<parent-ts>`. The `text` is a natural-language instruction to the Linear bot. Keep the text concise — Linear parses the first line as the command intent.
### 1. Create an issue from the thread
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear create\nTeam: Frontend Engineering\nTitle: <title>\nStatus: Triage\nLabels: source:bug-dump, area:<area>, env:<env>, sev:<severity>, reporter:<handle>\n\n<description body>\n\nSource: <slack-permalink>"
})
```
Rules:
- Start with `@Linear create` on its own line — this is the command token the bot keys on.
- Always specify `Team: Frontend Engineering`. Without it, the bot falls back to the Slack workspace's default team, which may not be FE.
- `Status: Triage` pins the initial workflow state.
- `Labels:` — comma-separated. If a label doesn't exist yet in Linear, the bot creates it on first use (verified in Linear workspace settings). Keep the taxonomy exactly as SKILL.md § Label Taxonomy.
- `<description body>` — markdown per `reference/schema.md` Description Template. Use real newlines, not literal `\n`.
- End with `Source: <slack-permalink>` so the Linear issue body links back even if the auto-attachment of the parent message fails.
The Linear bot replies in the same thread with a card that contains:
- The Linear URL (`https://linear.app/comfy-org/issue/FE-NNNN`)
- Status, assignee (initially unassigned), and applied labels
- A "View in Linear" button
Parse the URL out of the bot's reply text (or attachments). If no card reply appears within ~10s of polling `slack_read_thread`, treat it as a creation failure — do NOT proceed to the `:white_check_mark:` confirmation reply.
### 2. Search existing open issues (dedupe)
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
})
```
The bot replies with a card listing up to ~5 matching open issues. Parse identifier (`FE-NNNN`) and URL per row. Treat a hit as a duplicate per SKILL.md § Pre-flight Dedupe Gate § Check 1.
If `@Linear search` is not supported in the installed Slack app version, fall back to Slack-native search across the `#bug-dump` thread replies (previous `@Linear` cards contain title + URL — grep those for the same keywords). Record which path was used in the session log so the human can see dedupe coverage.
### 3. Link an existing issue (dedupe: `L` response)
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear link FE-4521"
})
```
The bot replies with the linked issue card. The skill then posts its own `:white_check_mark: Linked to Linear: <URL>` confirmation reply (see SKILL.md § Slack Thread Reply).
### 4. Add labels to an existing issue
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear FE-4521 add-labels pr-open"
})
```
Used when an open PR is discovered after ticket creation and the Linear issue should flip to `pr-open`.
### 5. Change status
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear FE-4521 status In Progress"
})
```
Rarely used by the skill directly — usually status changes come from the `red-green-fix` PR lifecycle (Linear auto-moves to `In Review` when a PR references `Fixes FE-4521`).
## Description body template
The text that follows the command headers is rendered verbatim as the Linear issue description (markdown). Use this template — see `reference/schema.md` for field-by-field extraction notes:
```markdown
**Reporter:** <slack-display-name>
**Env:** cloud prod / local / electron / ...
**Severity (proposed):** high/medium/low
**Area:** ui / node-system / workflow / cloud / templates
## Repro
1. ...
2. ...
## Expected
...
## Actual
...
## Attachments (in Slack thread)
- image.png (png, 315 KB)
- Screen Recording.mov (mov, 37 MB)
## Source
Slack: <permalink>
Thread summary: <1-3 bullets if thread adds context>
```
The Slack permalink is load-bearing — it's the canonical route to attachments, reporter, and any follow-up discussion. Do NOT embed Slack file IDs (`F0AT...`) directly; they're permissioned.
## Parsing the bot's reply
After each `slack_send_message` that mentions `@Linear`, poll `slack_read_thread` (with `channel_id=C0A4XMHANP3`, `thread_ts=<parent-ts>`) up to 3 times, ~3s apart. Scan replies authored by the Linear Slack app user for:
- Any `https://linear.app/<org>/issue/FE-\d+` URL → capture as the issue URL.
- The `FE-NNNN` identifier pattern → capture as the issue identifier.
- An error phrase (`couldn't`, `failed`, `not found`, `no team matched`) → treat as failure; surface the full bot text to the human.
Record the bot reply's `ts` alongside the captured URL and identifier in the session log.
## Failure modes & handling
| Symptom | Likely cause | Handling |
| ------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| No bot reply within 10s | Linear app not in channel, or bot outage | Halt the batch, surface to human, do NOT fabricate a Linear URL. Remaining approved candidates stay queued for re-run. |
| Bot replies with "no team matched" | Team name typo or Linear workspace drift | Re-send with the exact team name from the Linear workspace (default: `Frontend Engineering`). If it still fails, ask the human to verify. |
| Bot replies with "couldn't parse labels" | One of the labels has syntax the bot rejects | Drop the offending label, re-send; log the partial-label failure so the human can patch after. |
| Bot creates the issue but reply lacks the URL | Rare bot format change | Re-fetch the thread after ~5s; if URL still absent, open Linear search via `@Linear search <title>` and recover the identifier + URL. |
| Multiple `@Linear` replies match (duplicate card) | The skill retried without polling first | Keep the earliest card's URL; log the extras. Never re-issue `@Linear create` for the same candidate without confirming the first card failed. |
Never retry `@Linear create` without first running `@Linear search` for the same title keywords — a duplicate card is worse than an initial failure because the human has to close one of them manually.
## Why no direct API path
- The Linear MCP (official or community) would require either OAuth setup or `LINEAR_API_KEY` in env — both are per-machine hurdles the skill should not depend on.
- Direct GraphQL against `api.linear.app` has the same key-management cost and bypasses the Slack thread as the audit trail.
- Routing every action through `@Linear` in the thread gives humans full visibility in the channel (the bot's card is the receipt) and Processed Detection becomes a simple Slack thread read.
If a future need requires capabilities the `@Linear` Slack app doesn't expose (bulk operations, private field edits, webhooks), stop and surface the limitation to the human rather than quietly adding an API-key path — the "Slack-only" constraint is intentional.

View File

@@ -1,94 +0,0 @@
# Ticket Schema — Extraction Notes
Field-by-field guidance for normalizing a Slack #bug-dump message into a ticket.
## `slack_ts`
The top-level message timestamp from `slack_read_channel` response (`Message TS:` field). Always store the dotted form (`1776510375.473579`). This is the ingestion identity used in `processed.json`.
## `slack_permalink`
Construct:
```text
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p<ts-without-dot>
```
Example: `1776510375.473579``.../p1776510375473579`.
## `reporter`
The display name + parenthetical nickname if present. Examples from the channel:
- `Ali Ranjah (wavey)`
- `Denys Puziak`
- `Christian Byrne`
Do NOT use the Slack user ID (`U087MJCDHHC`) in Linear — names are more readable.
## `title`
Rules:
- Start with a verb or noun phrase describing the observed defect, not the reporter.
- ≤ 80 chars.
- Include env qualifier ("cloud prod", "local dev", "electron") only if ambiguous.
- Strip emoji and reactions from the original message when extracting.
Transformations:
| Slack message (excerpt) | Title |
| ----------------------------------------------------------------------- | --------------------------------------------------- |
| "unet model dropdown doesnt display all available models..." | Unet dropdown missing selected model |
| "Dates are broken on Settings -> Secrets. Cloud Prod" | Settings → Secrets dates broken on cloud prod |
| "LTX-2: Audio to VIdeo template results in the "RuntimeError..." error" | LTX-2 Audio-to-Video template RuntimeError on cloud |
## `description`
Structure — see `linear-api.md` § "Description body template". Key rules:
- Lead with **Repro** numbered list. Extract from the message body; if no steps are given, write "Repro: [Slack message body quoted verbatim]" and flag for human in approval.
- Preserve the reporter's own words in the Repro section when they include "step 1 / step 2" markers.
- Collapse multi-paragraph asides into "Notes" at the end.
## `env`
Detect from message text using these terms:
| Text in message | Tag |
| -------------------------- | ---------------------- |
| `cloud prod`, `prod cloud` | `cloud prod` |
| `cloud dev` | `cloud dev` |
| `cloud` | `cloud` (unqual.) |
| `local`, `localhost` | `local` |
| `electron`, `desktop` | `electron` |
| `nodes 2.0`, `LG` | (feature tag, not env) |
A message can have multiple env tags. If none are detectable, set `env: []` and flag "env unclear" in the approval row.
## `severity`
Heuristics in SKILL.md. When uncertain, mark `medium` and note in approval table: `Sev: medium (flag)`.
## `area`
Single tag. Use the one that best fits; tiebreak toward the more actionable team:
- `cloud` > `workflow` when the reported behavior is specific to cloud-hosted features (billing, queue, jobs)
- `node-system` > `ui` when the defect is canvas interaction, not just visual
- `templates` only when a named template is the subject
## `attachments`
From `slack_read_channel` message `Files:` field. Parse name, ID, type. Never include the Slack file ID in the Linear description — those are permissioned — just the filename and type.
## `thread_resolution`
Fetch via `slack_read_thread`. Scan replies for:
- `solved`, `resolved`, `fixed`, `no action needed``solved`
- A `:done:` reaction from the reporter → `solved`
- A `https://github.com/Comfy-Org/ComfyUI_frontend/pull/` URL in a reply → `pr-open` (keep but note in description)
- Otherwise → `open`
If `solved` and no PR merged, flag in approval table: reporter marked solved — confirm before filing.

View File

@@ -1,99 +0,0 @@
# Verify Commands Cookbook
One-shot commands for each False-Defect Verification class. Keep each under ~30s.
## 1. Check for existing fix PR
```bash
# By keyword in title
gh search prs --repo Comfy-Org/ComfyUI_frontend "<keyword>" --state merged --limit 5
# By keyword in body
gh pr list --repo Comfy-Org/ComfyUI_frontend --search "<keyword>" --state all --limit 5
# Recent closing PRs near the reported date
gh pr list --repo Comfy-Org/ComfyUI_frontend --state merged \
--search "merged:>=<YYYY-MM-DD> <keyword>" --limit 10
```
Verify tag: `fixed` if a merged PR explicitly matches; `pr-open` if an open PR matches.
## 2. Check for existing open Linear issue
```text
# Primary: @Linear search in the candidate's bug-dump thread
# mcp__plugin_slack_slack__slack_send_message({
# channel_id: "C0A4XMHANP3",
# thread_ts: "<parent-ts>",
# text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
# })
# → poll slack_read_thread, parse the Linear app's reply card for FE-NNNN matches.
#
# Fallback: grep past @Linear bot replies in the channel for prior ingested titles
# mcp__plugin_slack_slack__slack_search_public({
# query: "in:<#C0A4XMHANP3> from:@Linear <keyword-1> <keyword-2>"
# })
```
Verify tag: `dedupe` with the `FE-NNNN` identifier in the approval row. See `reference/linear-api.md` § "Search existing open issues (dedupe)" for full handling.
## 3. Feature actually exists in codebase
```bash
# Find the component / feature mentioned
rg -l "<ComponentOrFeatureName>" src/ apps/ --type vue --type ts
# Find a setting key
rg "<setting-key>" src/locales/en/ src/stores/settingStore.ts
# Find a store action
rg "<actionName>" src/stores/ --type ts
```
Verify tag: `stale` if 0 hits AND the feature name is specific (not a generic word).
## 4. Intended behavior check
```bash
# Check docs and release notes
rg -l "<feature keyword>" docs/ CHANGELOG.md
# Check if behavior is asserted in an existing test (green today)
rg "<observed behavior>" src/**/*.test.ts browser_tests/
```
Verify tag: `expected` if docs describe this as the intended behavior, or a test asserts it.
## 5. Reporter self-resolution
Already gathered via `slack_read_thread`. Look for reporter's own replies containing:
- "solved", "resolved", "fixed", "no action needed", "nvm", "my bad"
- A `:done:` reaction from the reporter
- A `:white_check_mark:` reaction
Verify tag: `resolved`.
## 6. Env-specific / local setup
If the message mentions "my machine", "my proxy", "my docker", "my cache" AND no other reporter has confirmed in-thread:
```bash
# Check thread for cross-user confirmations
# slack_read_thread → count distinct users replying with "same", "repro'd", "+1"
```
Verify tag: `env` if only the reporter is affected.
## 7. Cross-post (X posting)
If the top-level message is just a link + "X posting":
```bash
# Follow the link — use slack_search_public to find the original thread
# slack_search_public({ query: "<in:channel from:@reporter> <before:date>" })
```
If the original is already ingestable, ingest from the original's permalink. If it's a GitHub issue, prefer linking that GitHub issue to the Linear ticket instead of creating two entries.
Verify tag: `cross-post` with the resolved source permalink.

View File

@@ -76,7 +76,7 @@ const executeTask = async (task: MaintenanceTask) => {
message = t('maintenance.error.taskFailed')
} catch (error) {
message = (error as Error)?.message
message = error instanceof Error ? error.message : undefined
}
toast.add({

View File

@@ -66,7 +66,7 @@ class MaintenanceTaskRunner {
this.error = undefined
return true
} catch (error) {
this.error = (error as Error)?.message
this.error = error instanceof Error ? error.message : String(error)
throw error
} finally {
this.executing = false

View File

@@ -88,7 +88,7 @@ const contactColumn = {
{ label: t('footer.sales', locale), href: routes.contact },
{
label: t('footer.support', locale),
href: externalLinks.discord,
href: externalLinks.support,
external: true
},
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }

View File

@@ -106,6 +106,11 @@ function onNavKeydown(event: KeyboardEvent) {
navButtons()?.[next]?.focus({ preventScroll: true })
}
function onCategoryHover(index: number) {
if (isEnabled.value) return
activeCategory.value = index
}
function travelRange(el: HTMLElement) {
if (window.matchMedia('(min-width: 1024px)').matches) return 150
@@ -116,31 +121,29 @@ function travelRange(el: HTMLElement) {
}
const pinScrubEnd = `+=${categories.length * VH_PER_ITEM}%`
const parallaxMediaQuery = '(max-width: 1023px)'
useParallax([rightImgRef], {
trigger: sectionRef,
fromY: (el) => -travelRange(el),
y: (el) => travelRange(el),
start: 'top top',
end: pinScrubEnd
end: pinScrubEnd,
mediaQuery: parallaxMediaQuery
})
useParallax([leftImgRef], {
trigger: sectionRef,
fromY: (el) => travelRange(el),
y: (el) => -travelRange(el),
start: 'top top',
end: pinScrubEnd
end: pinScrubEnd,
mediaQuery: parallaxMediaQuery
})
</script>
<template>
<section
ref="sectionRef"
:class="
cn(
'bg-primary-comfy-ink relative isolate overflow-x-clip pt-20 lg:py-24',
isEnabled && 'lg:h-[calc(100vh+60px)]'
)
"
class="bg-primary-comfy-ink relative isolate overflow-x-clip pt-20 lg:h-[calc(100vh+60px)] lg:py-24"
>
<svg class="absolute size-0" width="0" height="0" aria-hidden="true">
<defs>
@@ -202,6 +205,8 @@ useParallax([leftImgRef], {
"
:aria-current="index === activeCategory ? 'true' : undefined"
@click="scrollToIndex(index)"
@mouseenter="onCategoryHover(index)"
@focus="onCategoryHover(index)"
>
{{ category.label }}
</button>

View File

@@ -20,6 +20,9 @@ interface PinScrubOptions {
/** Viewport-height percentage each category occupies in the scroll distance. */
export const VH_PER_ITEM = 20
/** Pin/scrub is mobile-only — desktop uses hover-based category switching. */
const PIN_SCRUB_MEDIA_QUERY = '(max-width: 1023px)'
function interpolateY(
index: number,
buttonCenters: number[],
@@ -66,7 +69,8 @@ export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) {
!refs.section.value ||
!refs.content.value ||
!refs.nav.value ||
prefersReducedMotion()
prefersReducedMotion() ||
!window.matchMedia(PIN_SCRUB_MEDIA_QUERY).matches
)
return
const section: HTMLElement = refs.section.value

View File

@@ -35,6 +35,7 @@ export const externalLinks = {
docsApi: 'https://docs.comfy.org/api-reference/cloud',
github: 'https://github.com/Comfy-Org/ComfyUI',
platform: 'https://platform.comfy.org',
support: 'https://support.comfy.org/hc/en-us',
workflows: 'https://comfy.org/workflows',
youtube: 'https://www.youtube.com/@ComfyOrg'
} as const

View File

@@ -0,0 +1,34 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const GROUP_TITLE_CLICK_OFFSET_X = 50
const GROUP_TITLE_CLICK_OFFSET_Y = 15
/**
* Returns the client-space position of a group's title bar (for clicking).
*/
export async function getGroupTitlePosition(
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number }> {
const pos = await comfyPage.page.evaluate(
({ title, offsetX, offsetY }) => {
const app = window.app!
const group = app.graph.groups.find(
(g: { title: string }) => g.title === title
)
if (!group) return null
const clientPos = app.canvasPosToClientPos([
group.pos[0] + offsetX,
group.pos[1] + offsetY
])
return { x: clientPos[0], y: clientPos[1] }
},
{
title,
offsetX: GROUP_TITLE_CLICK_OFFSET_X,
offsetY: GROUP_TITLE_CLICK_OFFSET_Y
}
)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}

View File

@@ -0,0 +1,20 @@
import type { Locator } from '@playwright/test'
import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
/**
* Opens the selection toolbox "More Options" menu and returns the menu
* locator so callers can scope follow-up queries to it.
*/
export async function openMoreOptions(comfyPage: ComfyPage): Promise<Locator> {
await expect(comfyPage.selectionToolbox).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu.getByText('Copy', { exact: true })).toBeVisible()
return menu
}

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
test.afterEach(async ({ comfyPage }) => {
@@ -12,15 +13,7 @@ test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
}) => {
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
const titlePos = await comfyPage.page.evaluate(() => {
const app = window.app!
const group = app.graph.groups[0]
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
})
const titlePos = await getGroupTitlePosition(comfyPage, 'Group')
await comfyPage.canvas.click({ position: titlePos })
await comfyPage.nextFrame()

View File

@@ -2,29 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
/**
* Returns the client-space position of a group's title bar (for clicking).
*/
async function getGroupTitlePosition(
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number }> {
const pos = await comfyPage.page.evaluate((title) => {
const app = window.app!
const group = app.graph.groups.find(
(g: { title: string }) => g.title === title
)
if (!group) return null
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
}, title)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
/**
* Returns {selectedNodeCount, selectedGroupCount, selectedItemCount}

View File

@@ -13,45 +13,35 @@ test.describe('Keyboard shortcut actions', { tag: '@keyboard' }, () => {
await comfyPage.setup()
})
test('Ctrl+Z undoes the last graph change', async ({ comfyPage }) => {
test('Ctrl+Z undoes and Ctrl+Shift+Z redoes the last graph change', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
await test.step('Ctrl+Z undoes the last graph change', async () => {
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
})
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
await comfyPage.canvas.click()
await comfyPage.page.keyboard.press('ControlOrMeta+z')
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
})
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
await comfyPage.canvas.click()
await comfyPage.page.keyboard.press('ControlOrMeta+z')
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
})
test('Ctrl+Shift+Z redoes after undo', async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
await test.step('Ctrl+Shift+Z redoes after undo', async () => {
await comfyPage.page.keyboard.press('ControlOrMeta+Shift+z')
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
})
await comfyPage.nextFrame()
await comfyPage.canvas.click()
await comfyPage.page.keyboard.press('ControlOrMeta+z')
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
await comfyPage.page.keyboard.press('ControlOrMeta+Shift+z')
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
})
test('Ctrl+S opens save dialog', async ({ comfyPage }) => {
@@ -62,25 +52,23 @@ test.describe('Keyboard shortcut actions', { tag: '@keyboard' }, () => {
await expect(saveDialog).toBeVisible()
})
test('Ctrl+, opens settings dialog', async ({ comfyPage }) => {
await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta')
test('Ctrl+, opens and Escape closes settings dialog', async ({
comfyPage
}) => {
const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
await expect(settingsDialog).toBeVisible()
})
test('Escape closes settings dialog', async ({ comfyPage }) => {
await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta')
await test.step('Ctrl+, opens settings dialog', async () => {
await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta')
const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
await expect(settingsDialog).toBeVisible()
await expect(settingsDialog).toBeVisible()
})
await comfyPage.page.keyboard.press('Escape')
await expect(settingsDialog).toBeHidden()
await test.step('Escape closes settings dialog', async () => {
await comfyPage.page.keyboard.press('Escape')
await expect(settingsDialog).toBeHidden()
})
})
test('Delete key removes selected nodes', async ({ comfyPage }) => {

View File

@@ -2,21 +2,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
async function openMoreOptions(comfyPage: ComfyPage) {
await expect(comfyPage.selectionToolbox).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
// Wait for the context menu to appear by checking for 'Copy', which is
// always present regardless of single or multi-node selection.
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu.getByText('Copy', { exact: true })).toBeVisible()
}
import { openMoreOptions } from '@e2e/fixtures/utils/selectionToolbox'
test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
test.describe('Single node actions', () => {
@@ -34,14 +20,14 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
await expect(nodeRef).not.toBePinned()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Pin', { exact: true }).click()
let menu = await openMoreOptions(comfyPage)
await menu.getByText('Pin', { exact: true }).click()
await comfyPage.nextFrame()
await expect(nodeRef).toBePinned()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Unpin', { exact: true }).click()
menu = await openMoreOptions(comfyPage)
await menu.getByText('Unpin', { exact: true }).click()
await comfyPage.nextFrame()
await expect(nodeRef).not.toBePinned()
@@ -57,14 +43,14 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
await expect(nodeRef).not.toBeCollapsed()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Minimize Node', { exact: true }).click()
let menu = await openMoreOptions(comfyPage)
await menu.getByText('Minimize Node', { exact: true }).click()
await comfyPage.nextFrame()
await expect(nodeRef).toBeCollapsed()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Expand Node', { exact: true }).click()
menu = await openMoreOptions(comfyPage)
await menu.getByText('Expand Node', { exact: true }).click()
await comfyPage.nextFrame()
await expect(nodeRef).not.toBeCollapsed()
@@ -78,8 +64,8 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Copy', { exact: true }).click()
const menu = await openMoreOptions(comfyPage)
await menu.getByText('Copy', { exact: true }).click()
await comfyPage.nextFrame()
// Paste the copied node
@@ -99,8 +85,8 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Duplicate', { exact: true }).click()
const menu = await openMoreOptions(comfyPage)
await menu.getByText('Duplicate', { exact: true }).click()
await comfyPage.nextFrame()
await expect

View File

@@ -0,0 +1,96 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
import { openMoreOptions } from '@e2e/fixtures/utils/selectionToolbox'
test.describe('Selection toolbox rename', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
})
test.describe('Single rename', () => {
test('Rename via More Options opens title editor for single node', async ({
comfyPage
}) => {
const nodeRef = (
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
await comfyPage.nodeOps.selectNodeWithPan(nodeRef)
const menu = await openMoreOptions(comfyPage)
await menu.getByText('Rename', { exact: true }).click()
await expect(comfyPage.page.getByTestId('node-title-input')).toHaveValue(
'KSampler'
)
})
test('Rename shows prompt dialog for group', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'LiteGraph.Group.SelectChildrenOnClick',
false
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerGroupPos = await getGroupTitlePosition(
comfyPage,
'Outer Group'
)
await comfyPage.canvas.click({ position: outerGroupPos })
const menu = await openMoreOptions(comfyPage)
await menu.getByText('Rename', { exact: true }).click()
await expect(comfyPage.nodeOps.promptDialogInput).toBeVisible()
await comfyPage.nodeOps.promptDialogInput.fill('Renamed Group')
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.nodeOps.promptDialogInput).toBeHidden()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
return window.app!.graph.groups.some(
(g) => g.title === 'Renamed Group'
)
})
)
.toBe(true)
})
})
test.describe('Batch rename', () => {
test('Batch rename multiple selected nodes', async ({ comfyPage }) => {
const ksampler = (
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
const emptyLatent = (
await comfyPage.nodeOps.getNodeRefsByTitle('Empty Latent Image')
)[0]
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
const menu = await openMoreOptions(comfyPage)
await menu.getByText('Rename', { exact: true }).click()
await expect(comfyPage.nodeOps.promptDialogInput).toBeVisible()
await comfyPage.nodeOps.promptDialogInput.fill('TestNode')
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.nodeOps.promptDialogInput).toBeHidden()
await expect
.poll(async () => {
const titles = await Promise.all([
ksampler.getProperty<string>('title'),
emptyLatent.getProperty<string>('title')
])
return [...titles].sort()
})
.toEqual(['TestNode 1', 'TestNode 2'])
})
})
})

View File

@@ -22,44 +22,35 @@ test.describe('Topbar menu commands', { tag: '@ui' }, () => {
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
test('Edit > Undo undoes the last action', async ({ comfyPage }) => {
test('Edit > Undo undoes and Edit > Redo restores the last action', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
await test.step('Edit > Undo undoes the last action', async () => {
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
})
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
})
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
})
test('Edit > Redo restores an undone action', async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
await test.step('Edit > Redo restores an undone action', async () => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Redo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
})
await comfyPage.nextFrame()
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Redo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
})
test('File > Save opens save dialog', async ({ comfyPage }) => {

View File

@@ -230,6 +230,31 @@ export default defineConfig([
]
}
},
{
name: 'comfy/no-unsafe-error-assertion',
files: [
'src/**/*.ts',
'src/**/*.tsx',
'src/**/*.vue',
'apps/*/src/**/*.ts',
'apps/*/src/**/*.tsx',
'apps/*/src/**/*.vue'
],
ignores: ['**/*.test.ts', '**/*.spec.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
// Bans `value as Error` and `value as Error & { ... }`.
// Use `error instanceof Error` narrowing or `toError()` from
// @/utils/errorUtil instead — see issue #11429.
selector: "TSAsExpression TSTypeReference[typeName.name='Error']",
message:
'Do not use `as Error` assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.'
}
]
}
},
{
files: ['**/*.spec.ts'],
ignores: ['browser_tests/tests/**/*.spec.ts', 'apps/*/e2e/**/*.spec.ts'],

View File

@@ -4014,6 +4014,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/proxy/seedance/visual-validate/groups": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List the caller's completed visual-validation groups
* @description Returns the caller's completed visual-validation groups (real-person H5 verification). Used to power the group selector in client UIs. Excludes virtual-library (AIGC) groups, which are not part of the public API surface.
*/
get: operations["seedanceListVisualValidationGroups"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/seedance/visual-validate/sessions/{session_id}": {
parameters: {
query?: never;
@@ -4037,7 +4057,11 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
/**
* List the caller's assets across all owned groups
* @description Fans out to BytePlus ListAssets across the caller's completed verification groups, denormalizes the group label into each row, and returns a single flat list. Result is post-filtered by asset_type. Optional group_id narrows to one group. Hard caps: 5 pages × 100 assets per group, 1000 total assets.
*/
get: operations["seedanceListUserAssets"];
put?: never;
post: operations["seedanceCreateAsset"];
delete?: never;
@@ -13569,7 +13593,7 @@ export interface components {
stream: boolean | null;
};
/** @enum {string} */
OpenAIModels: "gpt-4" | "gpt-4-0314" | "gpt-4-0613" | "gpt-4-32k" | "gpt-4-32k-0314" | "gpt-4-32k-0613" | "gpt-4-0125-preview" | "gpt-4-turbo" | "gpt-4-turbo-2024-04-09" | "gpt-4-turbo-preview" | "gpt-4-1106-preview" | "gpt-4-vision-preview" | "gpt-3.5-turbo" | "gpt-3.5-turbo-16k" | "gpt-3.5-turbo-0301" | "gpt-3.5-turbo-0613" | "gpt-3.5-turbo-1106" | "gpt-3.5-turbo-0125" | "gpt-3.5-turbo-16k-0613" | "gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" | "gpt-4.1-2025-04-14" | "gpt-4.1-mini-2025-04-14" | "gpt-4.1-nano-2025-04-14" | "o1" | "o1-mini" | "o1-preview" | "o1-pro" | "o1-2024-12-17" | "o1-preview-2024-09-12" | "o1-mini-2024-09-12" | "o1-pro-2025-03-19" | "o3" | "o3-mini" | "o3-2025-04-16" | "o3-mini-2025-01-31" | "o4-mini" | "o4-mini-2025-04-16" | "gpt-4o" | "gpt-4o-mini" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" | "gpt-4o-2024-05-13" | "gpt-4o-mini-2024-07-18" | "gpt-4o-audio-preview" | "gpt-4o-audio-preview-2024-10-01" | "gpt-4o-audio-preview-2024-12-17" | "gpt-4o-mini-audio-preview" | "gpt-4o-mini-audio-preview-2024-12-17" | "gpt-4o-search-preview" | "gpt-4o-mini-search-preview" | "gpt-4o-search-preview-2025-03-11" | "gpt-4o-mini-search-preview-2025-03-11" | "computer-use-preview" | "computer-use-preview-2025-03-11" | "gpt-5" | "gpt-5-mini" | "gpt-5-nano" | "chatgpt-4o-latest";
OpenAIModels: "gpt-4" | "gpt-4-0314" | "gpt-4-0613" | "gpt-4-32k" | "gpt-4-32k-0314" | "gpt-4-32k-0613" | "gpt-4-0125-preview" | "gpt-4-turbo" | "gpt-4-turbo-2024-04-09" | "gpt-4-turbo-preview" | "gpt-4-1106-preview" | "gpt-4-vision-preview" | "gpt-3.5-turbo" | "gpt-3.5-turbo-16k" | "gpt-3.5-turbo-0301" | "gpt-3.5-turbo-0613" | "gpt-3.5-turbo-1106" | "gpt-3.5-turbo-0125" | "gpt-3.5-turbo-16k-0613" | "gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" | "gpt-4.1-2025-04-14" | "gpt-4.1-mini-2025-04-14" | "gpt-4.1-nano-2025-04-14" | "o1" | "o1-mini" | "o1-preview" | "o1-pro" | "o1-2024-12-17" | "o1-preview-2024-09-12" | "o1-mini-2024-09-12" | "o1-pro-2025-03-19" | "o3" | "o3-mini" | "o3-2025-04-16" | "o3-mini-2025-01-31" | "o4-mini" | "o4-mini-2025-04-16" | "gpt-4o" | "gpt-4o-mini" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" | "gpt-4o-2024-05-13" | "gpt-4o-mini-2024-07-18" | "gpt-4o-audio-preview" | "gpt-4o-audio-preview-2024-10-01" | "gpt-4o-audio-preview-2024-12-17" | "gpt-4o-mini-audio-preview" | "gpt-4o-mini-audio-preview-2024-12-17" | "gpt-4o-search-preview" | "gpt-4o-mini-search-preview" | "gpt-4o-search-preview-2025-03-11" | "gpt-4o-mini-search-preview-2025-03-11" | "computer-use-preview" | "computer-use-preview-2025-03-11" | "gpt-5" | "gpt-5-mini" | "gpt-5-nano" | "gpt-5.5" | "gpt-5.5-pro" | "chatgpt-4o-latest";
MoonvalleyTextToVideoInferenceParams: {
/**
* @description Height of the generated video in pixels
@@ -14442,6 +14466,10 @@ export interface components {
total_tokens?: number;
};
};
SeedanceCreateVisualValidateSessionRequest: {
/** @description Optional human-readable label for the asset group that will be created by this verification. Stored locally and returned by seedanceListVisualValidationGroups so users can identify their groups in selectors. */
name?: string;
};
SeedanceCreateVisualValidateSessionResponse: {
/**
* Format: uuid
@@ -14451,6 +14479,37 @@ export interface components {
/** @description BytePlus-issued H5 liveness link. Open in a browser with camera access. Valid for ~120 seconds. */
h5_link: string;
};
SeedanceListVisualValidationGroupsResponse: {
groups: components["schemas"]["SeedanceVisualValidationGroup"][];
};
SeedanceListUserAssetsResponse: {
assets: components["schemas"]["SeedanceUserAsset"][];
/** @description True if the global per-request asset cap was hit and older results were dropped. */
truncated: boolean;
};
SeedanceUserAsset: {
asset_id: string;
name?: string | null;
/** @description BytePlus access URL (~12h validity). Refreshed on each list call. */
url?: string | null;
group_id: string;
/** @description Display label of the source group, denormalized for client-side search. */
group_name: string;
/** @enum {string} */
asset_type: "Image" | "Video" | "Audio";
/** @enum {string} */
status: "Active" | "Processing" | "Failed";
/** Format: date-time */
create_time: string;
};
SeedanceVisualValidationGroup: {
/** @description BytePlus-issued asset group id. */
group_id: string;
/** @description Display label. Caller-supplied at creation time when available; otherwise a server-generated fallback derived from the creation date. */
name: string;
/** Format: date-time */
created_at: string;
};
SeedanceGetVisualValidateSessionResponse: {
/** Format: uuid */
session_id: string;
@@ -14458,6 +14517,8 @@ export interface components {
status: "pending" | "completed" | "failed";
/** @description Populated only when status == completed. This is the BytePlus Asset Group ID the user will upload assets into. */
group_id?: string | null;
/** @description Optional human-readable label provided when the session was created. */
name?: string | null;
error_code?: string | null;
error_message?: string | null;
};
@@ -30275,7 +30336,11 @@ export interface operations {
path?: never;
cookie?: never;
};
requestBody?: never;
requestBody?: {
content: {
"application/json": components["schemas"]["SeedanceCreateVisualValidateSessionRequest"];
};
};
responses: {
/** @description Verification session created */
201: {
@@ -30297,6 +30362,35 @@ export interface operations {
};
};
};
seedanceListVisualValidationGroups: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Visual-validation groups owned by the caller */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SeedanceListVisualValidationGroupsResponse"];
};
};
/** @description Error 4xx/5xx */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
seedanceGetVisualValidateSession: {
parameters: {
query?: never;
@@ -30329,6 +30423,40 @@ export interface operations {
};
};
};
seedanceListUserAssets: {
parameters: {
query: {
/** @description Asset type to return. */
asset_type: "Image" | "Video";
/** @description Narrow the listing to one group. Caller must own it. */
group_id?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Assets owned by the caller */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SeedanceListUserAssetsResponse"];
};
};
/** @description Error 4xx/5xx */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
seedanceCreateAsset: {
parameters: {
query?: never;

View File

@@ -658,8 +658,10 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
: '3d'
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
sceneConfig.value.backgroundImage = uploadedPath
await load3d?.setBackgroundImage(uploadedPath)
if (uploadedPath) {
sceneConfig.value.backgroundImage = uploadedPath
await load3d?.setBackgroundImage(uploadedPath)
}
}
const handleExportModel = async (format: string) => {

View File

@@ -1,6 +1,20 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { uploadMedia } from '@/platform/assets/services/uploadService'
import { useToastStore } from '@/platform/updates/common/toastStore'
vi.mock('@/platform/assets/services/uploadService', () => ({
uploadMedia: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
getRandParam: () => '?rand=123'
}
}))
describe('Load3dUtils.mapSceneLightIntensityToHdri', () => {
it('maps scene slider low end to a small positive HDRI intensity', () => {
@@ -23,3 +37,158 @@ describe('Load3dUtils.mapSceneLightIntensityToHdri', () => {
expect(Load3dUtils.mapSceneLightIntensityToHdri(3, 5, 5)).toBe(0.25)
})
})
describe('Load3dUtils.uploadFile', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
it('returns the uploaded path on success', async () => {
vi.mocked(uploadMedia).mockResolvedValue({
success: true,
path: 'subfolder/file.png',
name: 'file.png',
subfolder: 'subfolder',
response: { name: 'file.png', subfolder: 'subfolder' }
})
const file = new File(['x'], 'file.png')
const result = await Load3dUtils.uploadFile(file, 'subfolder')
expect(result).toBe('subfolder/file.png')
expect(uploadMedia).toHaveBeenCalledWith(
{ source: file },
{ subfolder: 'subfolder', maxSizeMB: Load3dUtils.MAX_UPLOAD_SIZE_MB }
)
})
it('shows file-too-large toast when size exceeds maximum', async () => {
vi.mocked(uploadMedia).mockResolvedValue({
success: false,
path: '',
name: '',
subfolder: '',
error: 'File size 200MB exceeds maximum 100MB',
response: null
})
const file = new File(['x'], 'big.png')
Object.defineProperty(file, 'size', {
value: 200 * 1024 * 1024,
writable: false
})
const result = await Load3dUtils.uploadFile(file, '3d')
expect(result).toBeUndefined()
const toastStore = useToastStore()
expect(toastStore.addAlert).toHaveBeenCalledOnce()
})
it('shows generic alert toast on other upload failures', async () => {
vi.mocked(uploadMedia).mockResolvedValue({
success: false,
path: '',
name: '',
subfolder: '',
error: 'Network error',
response: null
})
const result = await Load3dUtils.uploadFile(
new File(['x'], 'fail.png'),
'3d'
)
expect(result).toBeUndefined()
const toastStore = useToastStore()
expect(toastStore.addAlert).toHaveBeenCalledWith('Network error')
})
})
describe('Load3dUtils.uploadTempImage', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
it('returns the upload response on success', async () => {
const response = { name: 'thumb_123.png', subfolder: 'threed' }
vi.mocked(uploadMedia).mockResolvedValue({
success: true,
path: 'threed/thumb_123.png',
name: 'thumb_123.png',
subfolder: 'threed',
response
})
const result = await Load3dUtils.uploadTempImage(
'data:image/png;base64,abc',
'thumb'
)
expect(result).toEqual(response)
expect(uploadMedia).toHaveBeenCalledWith(
expect.objectContaining({ source: 'data:image/png;base64,abc' }),
{ subfolder: 'threed', type: 'temp' }
)
})
it('throws and shows alert on failure', async () => {
vi.mocked(uploadMedia).mockResolvedValue({
success: false,
path: '',
name: '',
subfolder: '',
error: 'boom',
response: null
})
await expect(
Load3dUtils.uploadTempImage('data:image/png;base64,abc', 'thumb')
).rejects.toThrow()
const toastStore = useToastStore()
expect(toastStore.addAlert).toHaveBeenCalledOnce()
})
})
describe('Load3dUtils.uploadMultipleFiles', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
it('filters out failed uploads', async () => {
vi.mocked(uploadMedia)
.mockResolvedValueOnce({
success: true,
path: '3d/a.png',
name: 'a.png',
subfolder: '3d',
response: { name: 'a.png' }
})
.mockResolvedValueOnce({
success: false,
path: '',
name: '',
subfolder: '',
error: 'failed',
response: null
})
const files = [new File(['1'], 'a.png'), new File(['2'], 'b.png')]
const fileList = {
length: files.length,
item: (i: number) => files[i] ?? null,
[Symbol.iterator]: function* () {
for (const f of files) yield f
}
} as unknown as FileList
const results = await Load3dUtils.uploadMultipleFiles(fileList)
expect(results).toEqual(['3d/a.png'])
})
})

View File

@@ -1,6 +1,6 @@
import { t } from '@/i18n'
import { uploadMedia } from '@/platform/assets/services/uploadService'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
class Load3dUtils {
@@ -9,84 +9,54 @@ class Load3dUtils {
prefix: string,
fileType: string = 'png'
) {
const blob = await fetch(imageData).then((r) => r.blob())
const name = `${prefix}_${Date.now()}.${fileType}`
const file = new File([blob], name, {
type: fileType === 'mp4' ? 'video/mp4' : 'image/png'
})
const filename = `${prefix}_${Date.now()}.${fileType}`
const result = await uploadMedia(
{ source: imageData, filename },
{ subfolder: 'threed', type: 'temp' }
)
const body = new FormData()
body.append('image', file)
body.append('subfolder', 'threed')
body.append('type', 'temp')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
const err = `Error uploading temp file: ${resp.status} - ${resp.statusText}`
if (!result.success || !result.response) {
const err = t('toastMessages.tempUploadFailed', {
error: result.error || ''
})
useToastStore().addAlert(err)
throw new Error(err)
}
return await resp.json()
return result.response
}
static readonly MAX_UPLOAD_SIZE_MB = 100
static async uploadFile(file: File, subfolder: string) {
let uploadPath
const result = await uploadMedia(
{ source: file },
{ subfolder, maxSizeMB: this.MAX_UPLOAD_SIZE_MB }
)
const fileSizeMB = file.size / 1024 / 1024
if (fileSizeMB > this.MAX_UPLOAD_SIZE_MB) {
const message = t('toastMessages.fileTooLarge', {
size: fileSizeMB.toFixed(1),
maxSize: this.MAX_UPLOAD_SIZE_MB
})
console.warn(
'[Load3D] uploadFile: file too large',
fileSizeMB.toFixed(2),
'MB'
)
useToastStore().addAlert(message)
if (!result.success) {
if (result.error?.includes('exceeds maximum')) {
const fileSizeMB = file.size / 1024 / 1024
const message = t('toastMessages.fileTooLarge', {
size: fileSizeMB.toFixed(1),
maxSize: this.MAX_UPLOAD_SIZE_MB
})
console.warn(
'[Load3D] uploadFile: file too large',
fileSizeMB.toFixed(2),
'MB'
)
useToastStore().addAlert(message)
} else {
console.error('[Load3D] uploadFile: exception', result.error)
useToastStore().addAlert(
result.error || t('toastMessages.fileUploadFailed')
)
}
return undefined
}
try {
const body = new FormData()
body.append('image', file)
body.append('subfolder', subfolder)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status === 200) {
const data = await resp.json()
let path = data.name
if (data.subfolder) {
path = data.subfolder + '/' + path
}
uploadPath = path
} else {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
}
} catch (error) {
console.error('[Load3D] uploadFile: exception', error)
useToastStore().addAlert(
error instanceof Error
? error.message
: t('toastMessages.fileUploadFailed')
)
}
return uploadPath
return result.path
}
static getFilenameExtension(url: string): string | undefined {
@@ -124,12 +94,16 @@ class Load3dUtils {
return `/view?${params}`
}
static async uploadMultipleFiles(files: FileList, subfolder: string = '3d') {
static async uploadMultipleFiles(
files: FileList,
subfolder: string = '3d'
): Promise<string[]> {
const uploadPromises = Array.from(files).map((file) =>
this.uploadFile(file, subfolder)
)
await Promise.all(uploadPromises)
const results = await Promise.all(uploadPromises)
return results.filter((path): path is string => path !== undefined)
}
static mapSceneLightIntensityToHdri(

View File

@@ -23,8 +23,15 @@ import type {
*/
function isNotFoundError(error: unknown): boolean {
if (!(error instanceof Error)) return false
const withResponse = error as Error & { response?: { status?: number } }
if (withResponse.response?.status === 404) return true
if (
'response' in error &&
typeof error.response === 'object' &&
error.response !== null &&
'status' in error.response &&
error.response.status === 404
) {
return true
}
return /\b404\b/.test(error.message)
}

View File

@@ -2074,6 +2074,8 @@
"pleaseSelectNodesToGroup": "Please select the nodes (or other groups) to create a group for",
"emptyCanvas": "Empty canvas",
"fileUploadFailed": "File upload failed",
"uploadFailed": "Upload failed",
"tempUploadFailed": "Error uploading temp file: {error}",
"fileTooLarge": "File too large ({size} MB). Maximum supported size is {maxSize} MB",
"unableToGetModelFilePath": "Unable to get model file path",
"couldNotDetermineFileType": "Could not determine file type",

View File

@@ -0,0 +1,253 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { uploadMedia, uploadMediaBatch } from './uploadService'
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn()
}
}))
function createMockResponse(
status: number,
data?: { name: string; subfolder?: string }
) {
return {
status,
statusText: status === 200 ? 'OK' : 'Error',
json: vi.fn().mockResolvedValue(data ?? {})
} as unknown as Response
}
describe('uploadService', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('uploadMedia', () => {
it('uploads File successfully', async () => {
const mockFile = new File(['content'], 'test.png', { type: 'image/png' })
vi.mocked(api.fetchApi).mockResolvedValue(
createMockResponse(200, { name: 'test.png', subfolder: 'uploads' })
)
const result = await uploadMedia({ source: mockFile })
expect(result.success).toBe(true)
expect(result.path).toBe('uploads/test.png')
expect(result.name).toBe('test.png')
expect(result.subfolder).toBe('uploads')
})
it('uploads Blob successfully', async () => {
const mockBlob = new Blob(['content'], { type: 'image/png' })
vi.mocked(api.fetchApi).mockResolvedValue(
createMockResponse(200, { name: 'upload-123.png', subfolder: '' })
)
const result = await uploadMedia({ source: mockBlob })
expect(result.success).toBe(true)
expect(result.path).toBe('upload-123.png')
})
it('uploads dataURL successfully', async () => {
const dataURL = 'data:image/png;base64,iVBORw0KGgo='
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({
blob: () => Promise.resolve(new Blob(['content']))
} as Response)
vi.mocked(api.fetchApi).mockResolvedValue(
createMockResponse(200, { name: 'upload-456.png', subfolder: '' })
)
try {
const result = await uploadMedia({ source: dataURL })
expect(result.success).toBe(true)
} finally {
fetchSpy.mockRestore()
}
})
it('rejects invalid dataURL', async () => {
const invalidURL = 'not-a-data-url'
const result = await uploadMedia({ source: invalidURL })
expect(result.success).toBe(false)
expect(result.error).toContain('Invalid data URL')
})
it('includes subfolder in FormData', async () => {
const mockFile = new File(['content'], 'test.png')
vi.mocked(api.fetchApi).mockResolvedValue(
createMockResponse(200, { name: 'test.png' })
)
await uploadMedia(
{ source: mockFile },
{ subfolder: 'custom', type: 'input' }
)
const formData = vi.mocked(api.fetchApi).mock.calls[0][1]
?.body as FormData
expect(formData.get('subfolder')).toBe('custom')
expect(formData.get('type')).toBe('input')
})
it('validates file size', async () => {
// Create a file that reports as 200MB without actually allocating that much memory
const largeFile = new File(['content'], 'large.png')
Object.defineProperty(largeFile, 'size', {
value: 200 * 1024 * 1024,
writable: false
})
const result = await uploadMedia(
{ source: largeFile },
{ maxSizeMB: 100 }
)
expect(result.success).toBe(false)
expect(result.error).toContain('exceeds maximum')
})
it('handles upload errors', async () => {
const mockFile = new File(['content'], 'test.png')
vi.mocked(api.fetchApi).mockResolvedValue({
status: 500,
statusText: 'Internal Server Error'
} as unknown as Response)
const result = await uploadMedia({ source: mockFile })
expect(result.success).toBe(false)
expect(result.error).toBe('500 - Internal Server Error')
})
it('handles exceptions', async () => {
const mockFile = new File(['content'], 'test.png')
vi.mocked(api.fetchApi).mockRejectedValue(new Error('Network error'))
const result = await uploadMedia({ source: mockFile })
expect(result.success).toBe(false)
expect(result.error).toBe('Network error')
})
it('includes originalRef for mask uploads', async () => {
const mockFile = new File(['content'], 'mask.png')
vi.mocked(api.fetchApi).mockResolvedValue(
createMockResponse(200, { name: 'mask.png' })
)
const originalRef = {
filename: 'original.png',
subfolder: 'images',
type: 'input'
}
await uploadMedia(
{ source: mockFile },
{ endpoint: '/upload/mask', originalRef }
)
const formData = vi.mocked(api.fetchApi).mock.calls[0][1]
?.body as FormData
expect(formData.get('original_ref')).toBe(JSON.stringify(originalRef))
})
it('forwards the configured endpoint to api.fetchApi', async () => {
const mockFile = new File(['content'], 'mask.png')
vi.mocked(api.fetchApi).mockResolvedValue(
createMockResponse(200, { name: 'mask.png' })
)
await uploadMedia({ source: mockFile }, { endpoint: '/upload/mask' })
expect(api.fetchApi).toHaveBeenCalledWith(
'/upload/mask',
expect.objectContaining({ method: 'POST' })
)
})
it('defaults to /upload/image endpoint when not specified', async () => {
const mockFile = new File(['content'], 'test.png')
vi.mocked(api.fetchApi).mockResolvedValue(
createMockResponse(200, { name: 'test.png' })
)
await uploadMedia({ source: mockFile })
expect(api.fetchApi).toHaveBeenCalledWith(
'/upload/image',
expect.objectContaining({ method: 'POST' })
)
})
it('converts Blob source to a File preserving mime type and fallback name', async () => {
const mockBlob = new Blob(['content'], { type: 'image/png' })
vi.mocked(api.fetchApi).mockResolvedValue(
createMockResponse(200, { name: 'upload-123.png', subfolder: '' })
)
await uploadMedia({ source: mockBlob })
const formData = vi.mocked(api.fetchApi).mock.calls[0][1]
?.body as FormData
const uploadedFile = formData.get('image')
expect(uploadedFile).toBeInstanceOf(File)
expect((uploadedFile as File).type).toBe('image/png')
expect((uploadedFile as File).name).toMatch(/^upload-\d+\.png$/)
})
it('uses provided filename when converting a Blob', async () => {
const mockBlob = new Blob(['content'], { type: 'image/png' })
vi.mocked(api.fetchApi).mockResolvedValue(
createMockResponse(200, { name: 'custom.png', subfolder: '' })
)
await uploadMedia({ source: mockBlob, filename: 'custom.png' })
const formData = vi.mocked(api.fetchApi).mock.calls[0][1]
?.body as FormData
const uploadedFile = formData.get('image') as File
expect(uploadedFile.name).toBe('custom.png')
})
})
describe('uploadMediaBatch', () => {
it('returns empty array for empty input', async () => {
const results = await uploadMediaBatch([])
expect(results).toHaveLength(0)
expect(api.fetchApi).not.toHaveBeenCalled()
})
it('uploads multiple files', async () => {
const mockFiles = [
new File(['1'], 'file1.png'),
new File(['2'], 'file2.png')
]
vi.mocked(api.fetchApi)
.mockResolvedValueOnce(
createMockResponse(200, { name: 'file1.png', subfolder: '' })
)
.mockResolvedValueOnce(
createMockResponse(200, { name: 'file2.png', subfolder: '' })
)
const results = await uploadMediaBatch(
mockFiles.map((source) => ({ source }))
)
expect(results).toHaveLength(2)
expect(results[0].success).toBe(true)
expect(results[1].success).toBe(true)
})
})
})

View File

@@ -0,0 +1,155 @@
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import type { ImageRef } from '@/stores/maskEditorDataStore'
interface UploadInput {
source: File | Blob | string
filename?: string
}
interface UploadConfig {
subfolder?: string
type?: ResultItemType
endpoint?: '/upload/image' | '/upload/mask'
originalRef?: ImageRef
maxSizeMB?: number
}
interface UploadApiResponse {
name: string
subfolder?: string
type?: string
}
interface UploadResult {
success: boolean
path: string
name: string
subfolder: string
error?: string
response: UploadApiResponse | null
}
function isDataURL(str: string): boolean {
return typeof str === 'string' && str.startsWith('data:')
}
async function convertToFile(
input: UploadInput,
mimeType: string = 'image/png'
): Promise<File> {
const { source, filename } = input
if (source instanceof File) {
return source
}
if (source instanceof Blob) {
const name = filename || `upload-${Date.now()}.png`
return new File([source], name, { type: source.type || mimeType })
}
// dataURL string
if (!isDataURL(source)) {
throw new Error('Invalid data URL')
}
try {
const blob = await fetch(source).then((r) => r.blob())
const name = filename || `upload-${Date.now()}.png`
return new File([blob], name, { type: mimeType })
} catch (error) {
throw new Error(
`Failed to convert data URL to file: ${error instanceof Error ? error.message : String(error)}`
)
}
}
function validateFileSize(file: File, maxSizeMB?: number): string | null {
if (!maxSizeMB) return null
const fileSizeMB = file.size / 1024 / 1024
if (fileSizeMB > maxSizeMB) {
return `File size ${fileSizeMB.toFixed(1)}MB exceeds maximum ${maxSizeMB}MB`
}
return null
}
export async function uploadMedia(
input: UploadInput,
config: UploadConfig = {}
): Promise<UploadResult> {
const {
subfolder,
type,
endpoint = '/upload/image',
originalRef,
maxSizeMB
} = config
try {
const file = await convertToFile(input)
const sizeError = validateFileSize(file, maxSizeMB)
if (sizeError) {
return {
success: false,
path: '',
name: '',
subfolder: '',
error: sizeError,
response: null
}
}
const body = new FormData()
body.append('image', file)
if (subfolder) body.append('subfolder', subfolder)
if (type) body.append('type', type)
if (originalRef) body.append('original_ref', JSON.stringify(originalRef))
const resp = await api.fetchApi(endpoint, {
method: 'POST',
body
})
if (resp.status !== 200) {
return {
success: false,
path: '',
name: '',
subfolder: '',
error: `${resp.status} - ${resp.statusText}`,
response: null
}
}
const data: UploadApiResponse = await resp.json()
const path = data.subfolder ? `${data.subfolder}/${data.name}` : data.name
return {
success: true,
path,
name: data.name,
subfolder: data.subfolder || '',
response: data
}
} catch (error) {
return {
success: false,
path: '',
name: '',
subfolder: '',
error: error instanceof Error ? error.message : String(error),
response: null
}
}
}
export async function uploadMediaBatch(
inputs: UploadInput[],
config: UploadConfig = {}
): Promise<UploadResult[]> {
return Promise.all(inputs.map((input) => uploadMedia(input, config)))
}

View File

@@ -2,6 +2,7 @@ import * as Sentry from '@sentry/vue'
import { isEmpty } from 'es-toolkit/compat'
import { api } from '@/scripts/api'
import { toError } from '@/utils/errorUtil'
interface UserCloudStatus {
status: 'active'
@@ -80,7 +81,7 @@ export async function getUserCloudStatus(): Promise<UserCloudStatus> {
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to get user:')) {
captureApiError(error as Error, '/user', 'network_error')
captureApiError(toError(error), '/user', 'network_error')
}
throw error
}
@@ -176,7 +177,7 @@ export async function submitSurvey(
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to submit survey:')) {
captureApiError(
error as Error,
toError(error),
'/settings',
'network_error',
undefined,

View File

@@ -2,6 +2,7 @@ import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recor
import { onUnmounted, ref } from 'vue'
import { useAudioService } from '@/services/audioService'
import { toError } from '@/utils/errorUtil'
interface AudioRecorderOptions {
onRecordingComplete?: (audioBlob: Blob) => Promise<void>
@@ -62,7 +63,7 @@ export function useAudioRecorder(options: AudioRecorderOptions = {}) {
isRecording.value = true
} catch (err) {
if (options.onError) {
options.onError(err as Error)
options.onError(toError(err))
}
throw err
}

View File

@@ -2,11 +2,12 @@ import { toValue } from 'vue'
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { uploadMedia } from '@/platform/assets/services/uploadService'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -40,33 +41,26 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
isPasted: boolean = false,
formFields: Partial<{ type: ResultItemType }> = {}
) {
const body = new FormData()
body.append('image', file)
if (isPasted) body.append('subfolder', 'pasted')
else {
const subfolder = toValue(options.uploadSubfolder)
if (subfolder) body.append('subfolder', subfolder)
}
if (formFields.type) body.append('type', formFields.type)
const subfolder = isPasted
? 'pasted'
: (toValue(options.uploadSubfolder) ?? undefined)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
const result = await uploadMedia(
{ source: file },
{ subfolder, type: formFields.type }
)
if (resp.status !== 200) {
toastStore.addAlert(resp.status + ' - ' + resp.statusText)
if (!result.success) {
toastStore.addAlert(result.error ?? t('toastMessages.uploadFailed'))
return null
}
const data = await resp.json()
if (formFields.type === 'input' || (!formFields.type && !isPasted)) {
const assetsStore = useAssetsStore()
await assetsStore.updateInputs()
}
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
return result.path
}
async function uploadFiles(files: File[]): Promise<string[]> {
@@ -85,7 +79,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
const uploadedPaths = await uploadFiles(files)
if (uploadedPaths.length === 0) {
toastStore.addAlert('File upload failed')
toastStore.addAlert(t('toastMessages.fileUploadFailed'))
return
}

View File

@@ -6,6 +6,7 @@ import type {
NodePackSearchProvider,
SearchPacksResult
} from '@/types/searchServiceTypes'
import { toError } from '@/utils/errorUtil'
type RegistryNodePack = components['schemas']['Node']
@@ -152,7 +153,7 @@ export const useRegistrySearchGateway = (): NodePackSearchProvider => {
recordSuccess(providerState)
return result
} catch (error) {
lastError = error as Error
lastError = toError(error)
const providerState = providers[activeProviderIndex]
recordFailure(providerState, lastError)
console.warn(

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { getErrorMessage, toError } from './errorUtil'
describe('toError', () => {
it('returns the same Error instance when given an Error', () => {
const err = new Error('boom')
expect(toError(err)).toBe(err)
})
it('preserves Error subclasses', () => {
class CustomError extends Error {}
const err = new CustomError('subclass')
expect(toError(err)).toBe(err)
expect(toError(err)).toBeInstanceOf(CustomError)
})
it('wraps a string as an Error message', () => {
const result = toError('plain string')
expect(result).toBeInstanceOf(Error)
expect(result.message).toBe('plain string')
})
it('wraps a number by stringifying it', () => {
const result = toError(42)
expect(result).toBeInstanceOf(Error)
expect(result.message).toBe('42')
})
it('wraps an object via JSON.stringify', () => {
const result = toError({ code: 'EBOOM', detail: 'nope' })
expect(result).toBeInstanceOf(Error)
expect(result.message).toBe('{"code":"EBOOM","detail":"nope"}')
})
it('falls back to String() when JSON.stringify throws (circular)', () => {
const obj: Record<string, unknown> = {}
obj.self = obj
const result = toError(obj)
expect(result).toBeInstanceOf(Error)
expect(result.message).toBe('[object Object]')
})
it('handles null and undefined', () => {
expect(toError(null).message).toBe('null')
expect(toError(undefined).message).toBe('undefined')
})
})
describe('getErrorMessage', () => {
it('returns the message of an Error', () => {
expect(getErrorMessage(new Error('boom'))).toBe('boom')
})
it('returns the value when given a string', () => {
expect(getErrorMessage('text')).toBe('text')
})
it('returns the message field of a plain object', () => {
expect(getErrorMessage({ message: 'from object' })).toBe('from object')
})
it('returns undefined for objects without a string message', () => {
expect(getErrorMessage({ code: 1 })).toBeUndefined()
expect(getErrorMessage({ message: 42 })).toBeUndefined()
})
it('returns undefined for null, undefined, numbers, booleans', () => {
expect(getErrorMessage(null)).toBeUndefined()
expect(getErrorMessage(undefined)).toBeUndefined()
expect(getErrorMessage(42)).toBeUndefined()
expect(getErrorMessage(true)).toBeUndefined()
})
})

37
src/utils/errorUtil.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Narrow an unknown caught value to an Error.
*
* Replaces unsafe `value as Error` assertions. When `value` is not already
* an Error instance, wraps it in a new Error whose message is the stringified
* input so downstream consumers (loggers, Sentry, toasts) always receive a
* usable Error object instead of `undefined.message`.
*/
export function toError(value: unknown): Error {
if (value instanceof Error) return value
if (typeof value === 'string') return new Error(value)
if (value === undefined) return new Error('undefined')
try {
const serialised = JSON.stringify(value)
return new Error(serialised ?? String(value))
} catch {
return new Error(String(value))
}
}
/**
* Extract a message from an unknown caught value without asserting its type.
* Returns `undefined` when the value carries no usable message.
*/
export function getErrorMessage(value: unknown): string | undefined {
if (value instanceof Error) return value.message
if (typeof value === 'string') return value
if (
typeof value === 'object' &&
value !== null &&
'message' in value &&
typeof (value as { message: unknown }).message === 'string'
) {
return (value as { message: string }).message
}
return undefined
}