Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b502dbf727 | ||
|
|
0a3e92234f | ||
|
|
3d18829e54 | ||
|
|
89c16624db | ||
|
|
12e5bb5b45 | ||
|
|
90f549259d | ||
|
|
7b0b207fea | ||
|
|
7476ee95c7 | ||
|
|
fa6c4a1612 | ||
|
|
d6521983c6 | ||
|
|
11da54aaa1 | ||
|
|
eecb38e85f | ||
|
|
a6a6ecf7d3 | ||
|
|
ede8645637 | ||
|
|
bc1d21e8aa | ||
|
|
7f6f5b7192 | ||
|
|
c6795e7b5e | ||
|
|
2fa04a2818 | ||
|
|
a821832663 | ||
|
|
79525b0789 | ||
|
|
693e978433 | ||
|
|
0bcca4ee46 | ||
|
|
9d3c6a9851 | ||
|
|
966ab4b1aa | ||
|
|
2f489002e1 | ||
|
|
51a859b095 | ||
|
|
9e05e1fb66 | ||
|
|
6942159d6a | ||
|
|
dee9fae328 | ||
|
|
c91299e684 | ||
|
|
9364eb84f5 | ||
|
|
4fb2c99771 | ||
|
|
11ac437fa3 | ||
|
|
bb88a8c324 | ||
|
|
45c2fa9bc4 | ||
|
|
cd8d23a750 | ||
|
|
c246360b97 | ||
|
|
f51b752fd7 | ||
|
|
e594bc2adf | ||
|
|
2e2ae15875 | ||
|
|
60f911a0f8 | ||
|
|
abae630cc7 | ||
|
|
a03ce72c81 | ||
|
|
975581e111 | ||
|
|
e4ab2186e3 | ||
|
|
1d13048982 | ||
|
|
84fc961fc2 | ||
|
|
df5d38d625 | ||
|
|
30fed89ce3 | ||
|
|
03d2b7bde5 | ||
|
|
678a0eb403 | ||
|
|
f1add58631 | ||
|
|
928e33cc5f | ||
|
|
80cc323f31 | ||
|
|
a5dfe52511 | ||
|
|
8e28b40655 | ||
|
|
370fdd8388 | ||
|
|
d84bd2d60b | ||
|
|
d50dd8d601 | ||
|
|
4c603a20db | ||
|
|
054a777ec1 | ||
|
|
6a5bbb40ef | ||
|
|
f18be1eaff | ||
|
|
02a526a53c | ||
|
|
0dcb562801 | ||
|
|
1b7576a55f | ||
|
|
8c03e72d69 | ||
|
|
a16c5324e2 | ||
|
|
bd58aa679a | ||
|
|
6dc860ea5c | ||
|
|
9d8a5ab00e | ||
|
|
8a1b820ab3 | ||
|
|
0722da2a98 | ||
|
|
133c8df491 | ||
|
|
19727ade47 | ||
|
|
fab8d0d20f | ||
|
|
e49df411d7 | ||
|
|
82b568efbb | ||
|
|
32f0513420 | ||
|
|
22c29aa090 | ||
|
|
5e8d0428d4 | ||
|
|
33542e1239 | ||
|
|
42f30efbe7 | ||
|
|
f3a10664ab | ||
|
|
76bcd3629d | ||
|
|
f5652f4286 | ||
|
|
1fc705d8da | ||
|
|
4cfe1f6d14 | ||
|
|
a20a7b058b | ||
|
|
8abab1ee3d | ||
|
|
af66ca263e | ||
|
|
79163fce79 | ||
|
|
86c36e0f52 | ||
|
|
7a7b632f06 | ||
|
|
d80fd24254 | ||
|
|
2917c04c38 | ||
|
|
7a4703546e | ||
|
|
82b6c6eb37 | ||
|
|
a12ee2087c | ||
|
|
51c17565b8 | ||
|
|
625cab7682 | ||
|
|
48ee214f96 | ||
|
|
89b7827534 | ||
|
|
9a98626dd7 | ||
|
|
f4bf3d4635 | ||
|
|
2eeb90052e | ||
|
|
6626aca3d7 | ||
|
|
da0daf47e0 | ||
|
|
f33ac4c980 | ||
|
|
9d183d6779 | ||
|
|
346e43caa1 | ||
|
|
c4ca4472ba | ||
|
|
746f3ad2db | ||
|
|
759626b914 | ||
|
|
6c29f738c5 | ||
|
|
3810a37348 | ||
|
|
e4aabe6313 | ||
|
|
c5d11c03ec | ||
|
|
52165471c0 | ||
|
|
03f61a2b5e | ||
|
|
79bdb457cb |
@@ -1,65 +0,0 @@
|
||||
# .comfyignore — files we KEEP in git but don't want shipped to the
|
||||
# ComfyUI Registry archive. Layers on top of .gitignore (anything
|
||||
# already gitignored is excluded automatically).
|
||||
#
|
||||
# Syntax matches .gitignore (gitignore-style globs).
|
||||
# Anything listed under [tool.comfy].includes in pyproject.toml is
|
||||
# force-included even if it matches here.
|
||||
|
||||
# --- dev-only source ---
|
||||
# The frontend Vite source is huge and irrelevant to registry users
|
||||
# (they only need the built artifacts under js/ + web/, which
|
||||
# [tool.comfy].includes force-includes).
|
||||
src/
|
||||
|
||||
# --- tests ---
|
||||
tests/
|
||||
conftest.py
|
||||
pytest.ini
|
||||
|
||||
# --- repo plumbing ---
|
||||
.github/
|
||||
.husky/
|
||||
.vscode/
|
||||
.idea/
|
||||
.tmp/
|
||||
|
||||
# --- design docs + per-contributor specs ---
|
||||
docs/superpowers/
|
||||
docs/wiki-staging/
|
||||
|
||||
# --- screenshots that bloat the archive ---
|
||||
# Only the per-node help docs under docs/help/ (force-included via
|
||||
# pyproject) actually need to ship. Everything else under public/
|
||||
# is used by the SPA build and gets baked into web/.
|
||||
public/images/docs/
|
||||
|
||||
# --- frontend config + tooling ---
|
||||
vite.config.mts
|
||||
vitest.config.ts
|
||||
tsconfig*.json
|
||||
eslint.config.*
|
||||
.eslintrc*
|
||||
.editorconfig
|
||||
.prettierrc*
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
.npmrc
|
||||
.nvmrc
|
||||
|
||||
# --- semantic-release + commitlint plumbing ---
|
||||
.releaserc.*
|
||||
release.config.*
|
||||
commitlint.config.*
|
||||
config/
|
||||
|
||||
# --- repo metadata ---
|
||||
CHANGELOG.md
|
||||
CONTRIBUTING.md
|
||||
CODEOWNERS
|
||||
CODE_OF_CONDUCT.md
|
||||
SECURITY.md
|
||||
|
||||
# --- node helper scripts (not run at install time) ---
|
||||
scripts/
|
||||
@@ -1,138 +0,0 @@
|
||||
name: 🐛 Bug report
|
||||
description: Something broke or misbehaves — repro + expected vs actual.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing! A clean repro saves us hours. Fill out what
|
||||
applies — fields marked optional really are optional.
|
||||
|
||||
**Before you file:** check the [Wiki](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki)
|
||||
and [open issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues?q=is%3Aissue).
|
||||
For quick questions, [Discord](https://discord.gg/BFYR9WQdVR) is faster.
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Wildcard Pipeline version
|
||||
description: Check the manager sidebar header, the `vX.Y.Z` chip next to the logo.
|
||||
placeholder: "v1.8.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: surface
|
||||
attributes:
|
||||
label: Where does it break?
|
||||
description: Pick the surface where the misbehaviour shows up.
|
||||
multiple: true
|
||||
options:
|
||||
- WP Context (canvas widget)
|
||||
- WP Context Loop
|
||||
- WP Context Injector
|
||||
- WP Seed List
|
||||
- WP Prompt Assembler
|
||||
- WP Prompt Cleaner
|
||||
- WP Debug
|
||||
- WP Var → Int / Float / Bool
|
||||
- Manager SPA — Library list views
|
||||
- Manager SPA — Module editor (wildcard / fixed / combine / derivation / constraint)
|
||||
- Manager SPA — Bundle editor
|
||||
- Manager SPA — Test Runner
|
||||
- Manager SPA — Import / Export
|
||||
- Manager SPA — Documentation tab
|
||||
- Engine (runtime — pure-Python resolution)
|
||||
- Build / install (manual install path)
|
||||
- Other (describe in repro)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: comfyui_version
|
||||
attributes:
|
||||
label: ComfyUI version
|
||||
description: Found in the ComfyUI About panel or the terminal banner on startup.
|
||||
placeholder: "0.3.x · commit deadbeef"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- Windows
|
||||
- Linux
|
||||
- macOS
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: python
|
||||
attributes:
|
||||
label: Python version
|
||||
description: "`python --version` in your ComfyUI venv."
|
||||
placeholder: "3.12.10"
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Exact clicks / keystrokes / wiring — assume the reader has the pack installed but knows nothing about your workflow.
|
||||
placeholder: |
|
||||
1. Drop a WP Context node, add a starter `subject` wildcard.
|
||||
2. Wire `context` into a WP Prompt Assembler with template `a $subject`.
|
||||
3. Press Queue.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behaviour
|
||||
placeholder: "Each Generate rolls a fresh `$subject` and the assembler renders `a <subject>`."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behaviour
|
||||
placeholder: "Assembler renders `a $subject` literally — the variable doesn't resolve."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: traceback
|
||||
attributes:
|
||||
label: Error traceback (if any)
|
||||
description: Paste the full Python or browser-console traceback. Auto-formatted as a shell block.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: workflow
|
||||
attributes:
|
||||
label: Workflow JSON (if applicable)
|
||||
description: A minimal repro workflow if the bug only shows up in a specific wiring. Drag the workflow tab → Save (API Format) or paste the JSON. Auto-formatted.
|
||||
render: json
|
||||
|
||||
- type: textarea
|
||||
id: warnings
|
||||
attributes:
|
||||
label: WP Debug warnings (if any)
|
||||
description: Wire a WP Debug node to the affected Context output, run, and paste the Warnings tab contents.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Sanity checks
|
||||
options:
|
||||
- label: I'm on the latest release (or specified the exact version above).
|
||||
required: true
|
||||
- label: I checked the wiki + existing issues before filing.
|
||||
required: true
|
||||
- label: I included steps to reproduce.
|
||||
required: true
|
||||
@@ -1,29 +0,0 @@
|
||||
# GitHub Issue Form chooser.
|
||||
#
|
||||
# `blank_issues_enabled: false` forces users through one of the
|
||||
# structured forms below — the bug-report / feature-request forms
|
||||
# both gather the right context up-front so triage isn't a back-and-
|
||||
# forth conversation. The `contact_links` give an off-ramp for
|
||||
# questions / discussions that aren't really issues.
|
||||
|
||||
blank_issues_enabled: false
|
||||
|
||||
contact_links:
|
||||
- name: 💬 Discord — chat + show-and-tell + quick questions
|
||||
url: https://discord.gg/BFYR9WQdVR
|
||||
about: |
|
||||
Fastest path to a response for "how do I…" questions, prompt
|
||||
sharing, brainstorming. The issue tracker is for things that
|
||||
need an action (bug fix, feature implementation).
|
||||
|
||||
- name: 💭 GitHub Discussions — long-form questions + design threads
|
||||
url: https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/discussions
|
||||
about: |
|
||||
Better than an issue when you want to think out loud or pitch
|
||||
something without committing to a feature request yet.
|
||||
|
||||
- name: 📖 Wiki — Quick Start / Nodes / Modules / Concepts
|
||||
url: https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki
|
||||
about: |
|
||||
Check the wiki first — most "how does X work" questions are
|
||||
already answered there.
|
||||
@@ -1,94 +0,0 @@
|
||||
name: ✨ Feature request
|
||||
description: A capability that's missing — propose what + why.
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Pitch what you want and what problem it solves. Concrete user
|
||||
stories beat abstract wishes — "I'm building X and currently
|
||||
have to Y because Z doesn't exist" lands faster than "it would
|
||||
be nice if…".
|
||||
|
||||
**Before you file:** check existing [issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues?q=is%3Aissue+label%3Aenhancement)
|
||||
and [discussions](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/discussions)
|
||||
— somebody may already be proposing the same thing.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What's the problem?
|
||||
description: The current pain point. Skip if you're proposing a brand-new capability with no current workaround.
|
||||
placeholder: |
|
||||
I'm building a portrait workflow where the mood needs to match
|
||||
the lighting. Constraints handle this in one direction, but I
|
||||
can't make the mood react to BOTH lighting AND time-of-day at
|
||||
the same time — only one source per constraint.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: What should happen? A rough sketch is fine — implementation details are not your job.
|
||||
placeholder: |
|
||||
Constraint module accepts multiple source wildcards (AND logic
|
||||
by default). Matrix becomes Source1 × Source2 → target weights.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: What else could solve the underlying problem? Workarounds you've tried?
|
||||
placeholder: |
|
||||
- Chain two constraints (only one fires — second is consumed already).
|
||||
- Derivation reading both vars (works but loses the per-option-weight precision constraints give).
|
||||
|
||||
- type: dropdown
|
||||
id: scope
|
||||
attributes:
|
||||
label: Which surface does this affect?
|
||||
multiple: true
|
||||
options:
|
||||
- New node (WP_*)
|
||||
- New module type
|
||||
- WP Context (canvas widget)
|
||||
- Constraint matrix / pairing
|
||||
- Loop / Seed List / iteration vars
|
||||
- Library / manager SPA
|
||||
- Bundles
|
||||
- Templates
|
||||
- Import / Export
|
||||
- Documentation
|
||||
- Other / cross-cutting
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: example
|
||||
attributes:
|
||||
label: Concrete example (workflow or pseudo-code)
|
||||
description: What would using this look like in practice? A mock prompt / module config / wired graph helps a lot.
|
||||
|
||||
- type: dropdown
|
||||
id: importance
|
||||
attributes:
|
||||
label: How important is it?
|
||||
options:
|
||||
- Nice to have — would polish a workflow
|
||||
- Important — currently working around it with ugly hacks
|
||||
- Blocker — can't ship the workflow I want without it
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Sanity checks
|
||||
options:
|
||||
- label: I checked existing issues + discussions before filing.
|
||||
required: true
|
||||
- label: I described the underlying problem, not just the solution.
|
||||
required: true
|
||||
@@ -1,59 +0,0 @@
|
||||
<!--
|
||||
Thanks for the PR! A few quick prompts to keep the review tight.
|
||||
None of the sections are required by the bot — write what's useful
|
||||
and skip the rest.
|
||||
-->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- One paragraph: what changed + WHY. Reviewers should be able to
|
||||
read this and reconstruct the motivation without opening the diff. -->
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Tick all that apply. Conventional Commits rules in commitlint.config
|
||||
mean the commit-type controls the next release version bump:
|
||||
- feat → minor version bump
|
||||
- fix → patch version bump
|
||||
- perf → patch
|
||||
- refactor → patch
|
||||
- docs/style/test/build/ci/chore → no release
|
||||
See .releaserc.js for the full filter. -->
|
||||
|
||||
- [ ] 🐛 `fix` — bug fix (patch release)
|
||||
- [ ] ✨ `feat` — new feature (minor release)
|
||||
- [ ] ⚡ `perf` — performance improvement (patch release)
|
||||
- [ ] ♻️ `refactor` — internal rework, no behavioural change (patch release)
|
||||
- [ ] 📖 `docs` — docs only (no release unless `docs(readme)`)
|
||||
- [ ] 🧪 `test` — tests only
|
||||
- [ ] 🛠️ `build` / `ci` — build system, CI workflows, tooling
|
||||
- [ ] 🧹 `chore` — anything that doesn't fit above (no release)
|
||||
|
||||
## Related issues
|
||||
|
||||
<!-- "Closes #123, fixes #456" wires the PR to issue lifecycle. Drop
|
||||
links to discussions / Discord threads if the discussion happened
|
||||
elsewhere. -->
|
||||
|
||||
## Local verification
|
||||
|
||||
<!-- The pre-commit hook already runs lint + typecheck + vitest +
|
||||
build + size + ruff + pytest. Confirm each landed green before push. -->
|
||||
|
||||
- [ ] `pnpm typecheck` clean
|
||||
- [ ] `pnpm test` clean (Vitest)
|
||||
- [ ] `pnpm build:extension && pnpm size` within budget
|
||||
- [ ] `pnpm build:manager` builds
|
||||
- [ ] `ruff check .` clean
|
||||
- [ ] `pytest -q` clean
|
||||
- [ ] If touching engine: parity test (`pytest tests/syntax/test_determinism.py`) still passes
|
||||
|
||||
## Screenshots / before-after
|
||||
|
||||
<!-- For visible changes (canvas widgets, SPA views, docs pages) drop
|
||||
in a before / after pair. The reviewer thanks you. -->
|
||||
|
||||
## Anything that could go wrong
|
||||
|
||||
<!-- Migration concerns? Workflow back-compat? A widget rename users
|
||||
might trip over? Surface it here so the release notes can flag it. -->
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Publish to ComfyUI Registry
|
||||
|
||||
# Mirrors what the ComfyUI Manager search results read from. Fires on
|
||||
# every published GitHub release so the registry listing stays in sync
|
||||
# with semantic-release tags. Manual dispatch left available for
|
||||
# re-publishing if a release ever needs to be force-pushed.
|
||||
#
|
||||
# Calls `comfy node publish` directly (via comfy-cli) instead of the
|
||||
# wrapper `Comfy-Org/publish-node-action@main`. The wrapper runs its
|
||||
# own internal `actions/checkout@v4` step before zipping, which wipes
|
||||
# the working directory back to a fresh git state — and since `js/`
|
||||
# and `web/` are gitignored build outputs, they get nuked between our
|
||||
# `pnpm run build` step and the zip step. The result was a published
|
||||
# zip with empty js/ + web/ directories (canvas widgets + manager SPA
|
||||
# never loaded for end users). Calling comfy-cli directly preserves
|
||||
# the build artifacts on disk into the publish call.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: pnpm
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Full build = extension chunk (js/) + manager SPA (web/).
|
||||
# Both directories must be on disk before `comfy node publish`
|
||||
# runs so the registry archive ships actual frontend code instead
|
||||
# of empty stubs. [tool.comfy].includes = ["js", "web"] in
|
||||
# pyproject.toml force-includes them past .gitignore.
|
||||
- run: pnpm run build
|
||||
# Sanity gate — fail fast if build didn't actually produce the
|
||||
# shippable trees. Without this guard a broken build would
|
||||
# silently push empty directories to the registry (we already
|
||||
# shipped one such broken release; this prevents repeats).
|
||||
- name: Verify build output
|
||||
run: |
|
||||
test -d js && test "$(ls -A js)" || (echo "::error::js/ is empty after build" && exit 1)
|
||||
test -d web && test "$(ls -A web)" || (echo "::error::web/ is empty after build" && exit 1)
|
||||
echo "js/ contents:" && ls -la js/
|
||||
echo "web/ contents:" && ls -la web/
|
||||
- run: pip install comfy-cli
|
||||
- name: Publish to ComfyUI Registry
|
||||
run: |
|
||||
comfy --skip-prompt --no-enable-telemetry node publish \
|
||||
--token ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
+3
-28
@@ -12,30 +12,13 @@ node_modules/
|
||||
pnpm-debug.log
|
||||
.pnpm-store/
|
||||
|
||||
# Vite per-contributor env overrides (e.g. VITE_WPC_API_URL pointing
|
||||
# at a dev community host). Never commit — leaks dev URLs into the
|
||||
# tree and makes `pnpm build` produce divergent bundles per checkout.
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Build outputs
|
||||
js/
|
||||
web/
|
||||
web/*
|
||||
!web/index.html
|
||||
coverage/
|
||||
.coverage
|
||||
.vitest-cache/
|
||||
|
||||
# Scratch dir for screenshots, lighthouse reports, and other artifacts
|
||||
# generated during local QA. Never belongs in the tree.
|
||||
.tmp/
|
||||
|
||||
# Archives never belong in the repo (caught a stray src.zip
|
||||
# hand-dropped into the tree on Apr 26)
|
||||
*.zip
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -48,17 +31,9 @@ Thumbs.db
|
||||
# Backups dropped by /caveman:compress next to compressed files
|
||||
*.original.md
|
||||
|
||||
# Docs — ignore everything except per-node help pages + canonical contracts
|
||||
# Docs — ignore everything except per-node help pages
|
||||
/docs/*
|
||||
!/docs/help/
|
||||
!/docs/import-export-contract.md
|
||||
|
||||
# Semantic release
|
||||
.releaserc.local.js
|
||||
|
||||
# Throwaway design / scratch artifacts (mockups, screenshots, demos)
|
||||
/_temp/
|
||||
|
||||
# DB location control — per-contributor sidecar config + root-location DB dir
|
||||
db-config.json
|
||||
/db/
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
pnpm exec commitlint --edit "$1" --config config/commitlint.config.js
|
||||
pnpm exec commitlint --edit "$1"
|
||||
|
||||
+6
-119
@@ -1,95 +1,8 @@
|
||||
/**
|
||||
* semantic-release config.
|
||||
*
|
||||
* Two non-default behaviours that matter for release-notes readability:
|
||||
*
|
||||
* 1. Use the `conventionalcommits` preset (not the default `angular`).
|
||||
* Lets us configure per-type rendering via `presetConfig.types`.
|
||||
*
|
||||
* 2. Hide every type that isn't a user-facing change. `feat`, `fix`,
|
||||
* `perf`, `refactor` render in the GitHub release notes;
|
||||
* `chore`, `docs`, `style`, `test`, `build`, `ci`, `revert` are
|
||||
* marked `hidden: true` so they don't clutter the changelog
|
||||
* (commits still count for version bumps via the commit-analyzer
|
||||
* `releaseRules` below).
|
||||
*
|
||||
* Without these the angular preset rendered EVERY conventional commit
|
||||
* since the last tag — 1000+ entries for v1.8.0. With the filter the
|
||||
* notes shrink to just the meaningful changes (typically <50 entries
|
||||
* per release).
|
||||
*/
|
||||
export default {
|
||||
branches: ["main"],
|
||||
plugins: [
|
||||
[
|
||||
"@semantic-release/commit-analyzer",
|
||||
{
|
||||
preset: "conventionalcommits",
|
||||
releaseRules: [
|
||||
{ type: "feat", release: "minor" },
|
||||
{ type: "fix", release: "patch" },
|
||||
{ type: "perf", release: "patch" },
|
||||
{ type: "refactor", release: "patch" },
|
||||
{ type: "revert", release: "patch" },
|
||||
// `docs(readme)` is user-facing — bump patch so the new
|
||||
// install instructions / quick-start trigger a release.
|
||||
// Other docs scopes (in-app help, internal design docs)
|
||||
// don't move the needle.
|
||||
{ type: "docs", scope: "readme", release: "patch" },
|
||||
{ type: "docs", release: false },
|
||||
{ type: "style", release: false },
|
||||
{ type: "test", release: false },
|
||||
{ type: "build", release: false },
|
||||
{ type: "ci", release: false },
|
||||
{ type: "chore", release: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
"@semantic-release/release-notes-generator",
|
||||
{
|
||||
preset: "conventionalcommits",
|
||||
presetConfig: {
|
||||
// Section labels rendered in the GitHub release body +
|
||||
// CHANGELOG.md. The emoji prefixes scan well on the GitHub
|
||||
// release page where each section becomes a collapsible
|
||||
// heading.
|
||||
types: [
|
||||
{ type: "feat", section: "✨ Features" },
|
||||
{ type: "fix", section: "🐛 Bug Fixes" },
|
||||
{ type: "perf", section: "⚡ Performance" },
|
||||
{ type: "refactor", section: "♻️ Refactoring" },
|
||||
{ type: "revert", section: "⏪ Reverts" },
|
||||
{ type: "docs", hidden: true },
|
||||
{ type: "style", hidden: true },
|
||||
{ type: "test", hidden: true },
|
||||
{ type: "build", hidden: true },
|
||||
{ type: "ci", hidden: true },
|
||||
{ type: "chore", hidden: true },
|
||||
],
|
||||
},
|
||||
writerOpts: {
|
||||
// headerPartial puts a fixed "TL;DR + useful links" block at
|
||||
// the very top of every release body, so the first ~600
|
||||
// chars GitHub renders above the "Read more" fold on the
|
||||
// releases list view stay useful no matter how long the
|
||||
// changelog gets. {{version}} expands to the new tag.
|
||||
headerPartial:
|
||||
"## 🎉 Wildcard Pipeline {{version}}\n\n" +
|
||||
"📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · " +
|
||||
"💬 [Discord](https://discord.gg/BFYR9WQdVR) · " +
|
||||
"📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · " +
|
||||
"🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)\n\n" +
|
||||
"<details>\n" +
|
||||
"<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>\n\n",
|
||||
// footerPartial closes the <details> wrapper so the wall of
|
||||
// commits stays collapsed by default. GitHub renders
|
||||
// <details> natively in markdown — readers click to expand
|
||||
// when they actually need the per-commit log.
|
||||
footerPartial: "\n</details>",
|
||||
},
|
||||
},
|
||||
],
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
[
|
||||
"@semantic-release/changelog",
|
||||
{ changelogFile: "CHANGELOG.md" },
|
||||
@@ -97,44 +10,18 @@ export default {
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
// 1. Stamp version into package.json + pyproject.toml.
|
||||
// 2. Pack the deployable file set into a zip so the
|
||||
// @semantic-release/github plugin can attach it to the
|
||||
// release. Zip is built AFTER versioning so the file lands
|
||||
// with the right version in its name.
|
||||
prepareCmd:
|
||||
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node scripts/pack-release.mjs ${nextRelease.version}",
|
||||
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\"",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
assets: ["CHANGELOG.md", "package.json", "pyproject.toml"],
|
||||
// Plain message — `${nextRelease.notes}` here would expand to
|
||||
// the entire changelog, which on a divergent history (where
|
||||
// semantic-release can't find the last tag in main's first-
|
||||
// parent chain) blows past the OS arg-list limit and `git
|
||||
// commit` exits with E2BIG. The full notes already live in:
|
||||
// 1. CHANGELOG.md (asset committed in the same step)
|
||||
// 2. The GitHub release body (via @semantic-release/github)
|
||||
// So duplicating them inside the commit message is pure cost
|
||||
// for zero reader benefit.
|
||||
message: "chore(release): ${nextRelease.version} [skip ci]",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@semantic-release/github",
|
||||
{
|
||||
// Attach the deployable zip + its sha256 to the GitHub release
|
||||
// page so manual installers can grab a single artifact instead
|
||||
// of cloning the whole repo at a specific tag.
|
||||
assets: [
|
||||
{
|
||||
path: "dist/ComfyUI-Wildcard-Pipeline-v*.zip",
|
||||
label: "ComfyUI-Wildcard-Pipeline-v${nextRelease.version}.zip",
|
||||
},
|
||||
],
|
||||
message:
|
||||
"chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}",
|
||||
},
|
||||
],
|
||||
"@semantic-release/github",
|
||||
],
|
||||
};
|
||||
|
||||
+123
-488
@@ -1,517 +1,152 @@
|
||||
## 🎉 Wildcard Pipeline 2.2.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #1 from DumiFlex/feat/community-embed ([a53d526](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a53d526)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
|
||||
* test: lock cascade-restore end-to-end constraint repoint (#2) ([67de90c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/67de90c)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)
|
||||
* test(cascade): edge-case qa for subcat rename/delete (collapse, null, multi-surface) ([54054fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/54054fb))
|
||||
* test(constraint): double-cast findComponent to VueWrapper in cached-names test ([c102361](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c102361))
|
||||
* test(engine): lock constraint source-pick fallback semantics ([690169b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/690169b))
|
||||
* test(sp2a): lock multi-pick debug render + var.K cascade/validation ([d1ee97b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d1ee97b))
|
||||
* test(spa): lock accessor-aware var autocomplete match ([8d7bda2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8d7bda2))
|
||||
* test(syntax): move subcat_filter tests into tests/syntax for the coverage gate ([3065e99](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3065e99))
|
||||
* test(validators): engine-parity harness to stop validator drift ([51f6793](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/51f6793))
|
||||
* test(wp_api): lock comma rejection on variable names server-side ([94ccd7f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/94ccd7f))
|
||||
* docs: document schema_version machine + column-add procedure ([ede4f4e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ede4f4e))
|
||||
* docs: flag pick-bucket collision + deprecate dead remapBundleUuids ([96fc26a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/96fc26a))
|
||||
* docs: update constraint conventions to the SP3 reach model ([8314963](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8314963))
|
||||
* docs(constraint): document target reach selectors and axis reattach ([844d16e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/844d16e))
|
||||
* docs(derivation): document nested refs in action values ([402fdcc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/402fdcc))
|
||||
* docs(syntax): document multi-pick, nested-in-inline, $var.K, and the surface gate ([7297b2f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7297b2f))
|
||||
* docs(wildcard): document multi-pick, sub-categories, and nested refs ([8b99795](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8b99795))
|
||||
* feat: pick_min/pick_max/pick_separator wildcard instance fields ([17a2149](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/17a2149))
|
||||
* feat(api): modules/hashes returns type + fingerprint per id ([885f7fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/885f7fb))
|
||||
* feat(bundles): add extractBundleChildren fresh-id materialization helper ([223770e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/223770e))
|
||||
* feat(bundles): extract bundle children to library with ref remap ([59af070](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/59af070))
|
||||
* feat(bundles): manual per-child reattach with community download ([f58d849](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f58d849))
|
||||
* feat(bundles): move extract action to children header, used-by beside title ([f1ab33c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f1ab33c))
|
||||
* feat(bundles): relink bundle to extracted modules + gate extract on dangling children ([b1fa0ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b1fa0ce))
|
||||
* feat(canvas): @{} chips + autocomplete in derivation instance modal ([ab813bb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ab813bb))
|
||||
* feat(cascade): expression-aware subcat rename/delete (engine + ts) ([11ad8ca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/11ad8ca)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name)
|
||||
* feat(cascade): per-entity wildcard-delete cleanup via cleanup_ids ([9a97613](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a97613))
|
||||
* feat(cascade): reconcileSubcatExpr token reconciler for remap preview ([fd25a63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd25a63))
|
||||
* feat(cascade): rewriteBrokenRef pure rewriter for broken-ref remap ([1d59df9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1d59df9))
|
||||
* feat(collision): add type-conflict state + classifyOne core ([9e34d23](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9e34d23))
|
||||
* feat(community): add public post detail + download fetch helpers ([c858bef](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c858bef))
|
||||
* feat(community): add transitive dependency closure walker ([1ee8fe2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ee8fe2))
|
||||
* feat(community): orchestrate transitive dep download for dangling refs ([7dc9817](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7dc9817))
|
||||
* feat(community): route Publish through embed instead of new tab ([59c5d70](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/59c5d70))
|
||||
* feat(community): surface underlying import error in EmbedLoadError ([6f51e4a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f51e4a))
|
||||
* feat(community): track install origin + update-available indicator ([9957702](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9957702))
|
||||
* feat(community): update-action dialog with in-place + as-new + dismiss ([af314d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af314d3))
|
||||
* feat(conflicts): surface broken nested @{} refs at module row ([fd254a6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd254a6))
|
||||
* feat(constraint): backfill cached wildcard name from live catalog on save ([d188ead](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d188ead))
|
||||
* feat(constraint): cache source/target wildcard names for broken-ref banner ([70da7e0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/70da7e0))
|
||||
* feat(constraint): canvas modal detects dangling source/target and reattaches via walkremap ([77f00ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/77f00ae))
|
||||
* feat(constraint): canvas parity for stranded read-only matrix + colored exception modes ([5ae0ff0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5ae0ff0))
|
||||
* feat(constraint): download missing deps from community + reattach ([02ad0eb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02ad0eb))
|
||||
* feat(constraint): list derivation-nested occurrences in reach pick-list + orphan scan ([098c069](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/098c069))
|
||||
* feat(constraint): lock stranded wildcard fields + color-code exception modes ([4598f68](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4598f68))
|
||||
* feat(constraint): reattach section for dangling source/target with blast-radius warning ([f7369ff](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f7369ff))
|
||||
* feat(constraint): recognise derivations as @{} carriers (branch-key option ids) ([8247d73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8247d73))
|
||||
* feat(constraint): show stranded constraint matrix + exceptions read-only in spa editor ([fa68a36](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa68a36))
|
||||
* feat(constraint): spa editor reattach control mirrors the canvas modal ([1bf1e42](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1bf1e42))
|
||||
* feat(context): type-aware drift/missing + id-clash state via classifyOne ([c13351d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c13351d))
|
||||
* feat(engine): bulk lazy runner — eager iteration of lazy_migrate_row ([73ca8a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/73ca8a3))
|
||||
* feat(engine): capture constraint's own bundle_origin on registered meta ([febb9f3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/febb9f3))
|
||||
* feat(engine): constraint reach selector (first/next/all) + multi-constraint combine; retire one-shot ([be1f765](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/be1f765))
|
||||
* feat(engine): content_rating column on library rows (migration 015) ([395af63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/395af63))
|
||||
* feat(engine): independent (allow-repeats) multi-pick for the wildcard pool ([33b3600](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33b3600))
|
||||
* feat(engine): lazy_migrate_row — the ONE migration runtime ([07fe3ab](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/07fe3ab))
|
||||
* feat(engine): list-backed variables + var.K accessor ([8eeb0a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8eeb0a8))
|
||||
* feat(engine): migrate library v1->v2 (multi-tag) + bump schema version ([39529a6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39529a6))
|
||||
* feat(engine): migration 014 - payload schema_version + verbatim-local mirror ([c46cbbf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c46cbbf))
|
||||
* feat(engine): multi-pick prefix gains N-M range + ~ independent flag ([0b36ce8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0b36ce8))
|
||||
* feat(engine): multi-select wildcard binds a list variable ([443db49](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/443db49))
|
||||
* feat(engine): multi-tag options + boolean sub-category filters (v2) ([13de622](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/13de622))
|
||||
* feat(engine): per-pick recording + apply constraints via combine_constraint_factor ([f68d2ec](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f68d2ec))
|
||||
* feat(engine): pure combine_constraint_factor + shared constraint corpus ([dd785c5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dd785c5))
|
||||
* feat(engine): range count + independent mode + lone-ref pool multi-pick ([63b824e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/63b824e))
|
||||
* feat(engine): record per-origin source pick view in __wp_picks__ ([d6094ed](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d6094ed))
|
||||
* feat(engine): resolve @{} refs on the derivation surface ([059517f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/059517f))
|
||||
* feat(engine): stamp derivation carrier (uid, branch_key) for nested reach ([599c3b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/599c3b1))
|
||||
* feat(engine): thread bundle_origin into resolve ctx per module ([7bcfcac](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7bcfcac))
|
||||
* feat(engine): thread carrier uid + option id for nested-pick constraint reach ([4165b2e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4165b2e))
|
||||
* feat(engine): tokenize var.K list accessor (py+ts parity) ([3ac0afa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3ac0afa))
|
||||
* feat(engine): validate + register constraint target_select; instance override field ([8c3c9ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8c3c9ae))
|
||||
* feat(import-export): install decision tree per spec §4 Flow 3 ([19836dd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/19836dd))
|
||||
* feat(import-export): parseTolerantAsCurrentShape — strip newer fields ([4758c84](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4758c84))
|
||||
* feat(import-export): publish stamps schema_version + producer_engine_version ([ad0f8c2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ad0f8c2))
|
||||
* feat(import): apply walkremap follow-through over committed entities on rename ([236c39a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/236c39a))
|
||||
* feat(import): surface cross-kind id clashes as CLASH; never replace ([4fabb76](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4fabb76))
|
||||
* feat(import): walkremap follow-through helper for renamed friend ids ([4dd6d6c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4dd6d6c))
|
||||
* feat(install): add buildReattachRemap slug-bridge helper ([7845d91](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7845d91))
|
||||
* feat(install): add reattach type surface (deps, slug, count) ([e138868](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e138868))
|
||||
* feat(install): auto-reattach inner-bundle references on bundle install ([b4f2d6b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b4f2d6b))
|
||||
* feat(install): auto-reattach references via slug-bridge on install ([0494e3c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0494e3c))
|
||||
* feat(install): flag + backstop cross-kind id clashes (never replace) ([ca54a3a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ca54a3a))
|
||||
* feat(install): host bridge forwards post deps + library slug ([a9bde14](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a9bde14))
|
||||
* feat(install): pre-commit collision detection + resolver callback ([519abd3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/519abd3))
|
||||
* feat(manager): add Publish to community button on Export tab ([c6c42c4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c6c42c4))
|
||||
* feat(manager): editor footers gain Upload + Copy buttons ([9c0b11f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9c0b11f))
|
||||
* feat(manager): embed Wildcard Pipeline Community via runtime bundle ([7d43164](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d43164))
|
||||
* feat(manager): enforce identifier grammar on variable names ([c661550](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c661550))
|
||||
* feat(manager): expose window.__wpcRuntime host bridge for community installs ([a10a321](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a10a321))
|
||||
* feat(manager): mirror v1->v2 migration + bump client schema version ([221aa90](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/221aa90))
|
||||
* feat(manager): move editor Publish + Copy from footer to header ([ea3c628](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ea3c628))
|
||||
* feat(manager): nsfw content filter on every list view + all items ([4b491b8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4b491b8))
|
||||
* feat(manager): nsfw toggle in editors + pill on library rows ([99374cf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/99374cf))
|
||||
* feat(manager): per-row community upload + copy + update diag ([7a5c382](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7a5c382))
|
||||
* feat(manager): real 404 page + global error boundary ([c0cfd13](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c0cfd13))
|
||||
* feat(manager): remaprefpopup for healing broken nested chips ([e897bd3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e897bd3))
|
||||
* feat(manager): thread module-id so remap clears broken-ref warnings in all #3 hosts ([770909d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/770909d)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* feat(manager): wire @remap in richtextinput to heal the open field ([a75c737](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a75c737))
|
||||
* feat(pool): multi-select pick-count control + null disable in multi ([d167a1f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d167a1f))
|
||||
* feat(publish): add listReferencedUuids dependency detector ([e709d53](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e709d53))
|
||||
* feat(publish): auto-detect + inject module dependencies into publish hash ([107d5f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/107d5f4))
|
||||
* feat(publish): guided unmet-dependency checklist before publish ([292a0a5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/292a0a5))
|
||||
* feat(publish): surface bundle children external refs as dependencies ([33316aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33316aa))
|
||||
* feat(publish): surface inner-bundle dependencies in guided publish ([e7e844b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e7e844b))
|
||||
* feat(publish): verify published deps + reclassify stale ones to publish-first ([feb760e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/feb760e))
|
||||
* feat(publish): wire inner-bundle dependency publish button to navigate ([f8edcfe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f8edcfe))
|
||||
* feat(publish): writeback community origin to local row after publish ([bbd1a37](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bbd1a37))
|
||||
* feat(refchip): distinct remap emit for unresolved broken chips ([43de217](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/43de217))
|
||||
* feat(spa): "Allow repeats" toggle for the wildcard pool multi-pick ([e785928](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e785928))
|
||||
* feat(spa): author @{} nested refs in derivation action values ([3564938](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3564938))
|
||||
* feat(spa): canvas wildcard editor decomposes blocks into inner chips (sp2b) ([0962aad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0962aad))
|
||||
* feat(spa): decompose brace blocks into scaffolding + inner chips, render v2 ([ef6fadc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ef6fadc))
|
||||
* feat(spa): render var.K as a single chip + accessor-aware autocomplete ([64d1cb1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/64d1cb1))
|
||||
* feat(spa): resolve inline blocks in the combine preview (deterministic) ([62f2e4a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/62f2e4a))
|
||||
* feat(spa): stamp sp2b schema_version on publish when range/independent used ([36942a2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/36942a2))
|
||||
* feat(syntax): boolean sub-category filter parser, matcher + ts parity ([0e1803e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e1803e))
|
||||
* feat(ts): badge reframe — reach suffix, contributor cluster + solid collapse chip, before name ([02ed686](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02ed686))
|
||||
* feat(ts): combineConstraintFactor mirror + shared corpus parity ([2eb0e36](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2eb0e36))
|
||||
* feat(ts): constraint reach selector — segmented modes + cross-node pick checklist ([c80605a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c80605a))
|
||||
* feat(ts): debug rows for constraint never_applied + partial_reach ([764c82e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/764c82e))
|
||||
* feat(ts): library-default target_select in the manager constraint editor ([a0f9651](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a0f9651))
|
||||
* feat(ts): pairings mark-all reach + contributors (drop exclusive claim) ([3902210](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3902210))
|
||||
* feat(ts): per-selector constraint orphan scan (drop one-shot claim-order) ([3f14a6f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3f14a6f))
|
||||
* feat(ts): per-WP_Context node_label, tagged through the cross-node walker + editable header chip ([a94cd54](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a94cd54))
|
||||
* feat(ts): preview resolver applies combine + reach (engine parity) ([b20faa0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b20faa0))
|
||||
* feat(ts): stamp schema_version 4 when constraint target_select is non-default ([826d86f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/826d86f))
|
||||
* feat(ts): transitive nested-target carrier walk (cap + cycle) with route chain ([1ddbe45](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ddbe45))
|
||||
* feat(ui): nested-ref filter popover + chip + in-graph pool editor ([33e6bd0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33e6bd0))
|
||||
* feat(ui): wildcard editor sub-category groups + per-option multi-tag ([c374863](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c374863))
|
||||
* feat(validators): v1 strict + tolerant validators per subtype + bundle ([810e33e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/810e33e)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
|
||||
* fix: refresh token field + show update pill outside #name slot ([9c7a110](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9c7a110)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name) [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name) [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name)
|
||||
* fix(assembler): render list-valued vars + .K accessor in node preview ([dc05a5d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dc05a5d))
|
||||
* fix(bundle): remap constraint source/target to restored uuids on cascade push ([bb9ec6d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb9ec6d))
|
||||
* fix(bundles): reconcile restored inner-bundle fingerprint to clear false drift ([e458e8d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e458e8d))
|
||||
* fix(bundles): repoint cascade-restored standalone library refs (phase 1 pass 2) ([fa0f2ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa0f2ce))
|
||||
* fix(bundles): repoint workflow module refs on cascade restore, not just ids ([6fe52c0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6fe52c0))
|
||||
* fix(bundles): stamp payload_hash on cascade-restored bundle children so re-insert shows no drift ([eff7a6a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eff7a6a))
|
||||
* fix(bundles): sync restored module payload_hash on cascade so canvas shows no drift ([37ca8bd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/37ca8bd))
|
||||
* fix(canvas): picker z-index + teleport theme + inline ↪#N badge in derivation summary ([ec3508a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ec3508a)), closes [#N](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/N) [#N](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/N)
|
||||
* fix(cascade): allow whole-entity delete for fixed_values/combine/derivation/constraint ([738ede3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/738ede3))
|
||||
* fix(cascade): discover v2 boolean sub-category filter refs ([4cda98f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4cda98f))
|
||||
* fix(cascade): keep bundles frozen on wildcard rename (no auto-mutate) ([e748064](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e748064)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name)
|
||||
* fix(cascade): stamp deleted wildcard name onto dependent constraints for broken-ref banner ([97d3036](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/97d3036))
|
||||
* fix(cascade): wildcard-delete leaves constraints, opt-in nested-ref strip ([e288d1b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e288d1b))
|
||||
* fix(combine): filter multi-pick representative to engine pool + honor .K in template preview ([b9df102](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b9df102))
|
||||
* fix(community): preserve bundles/library/deleted views in host nav ([f8efcc9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f8efcc9))
|
||||
* fix(community): sync embed theme with the manager toggle live ([04841aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/04841aa))
|
||||
* fix(community): tokenStore refresh hits /auth/token/refresh not /auth/refresh ([a2cc1bb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a2cc1bb))
|
||||
* fix(constraint): derive real dropped-cell count for reattach preview ([47af97e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/47af97e))
|
||||
* fix(constraint): float cached-name match in reattach dropdown (auto-suggest) ([07dcf31](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/07dcf31)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* fix(constraint): keep @{} carrier regex strict-hex (engine/dep-graph parity) ([b27b9f8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b27b9f8))
|
||||
* fix(constraint): origin-stamp downloaded deps so they get the community badge ([0984a7f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0984a7f))
|
||||
* fix(context): constraint-reach QA, node codenames, bundle-aware toolbar ([73a00a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/73a00a3))
|
||||
* fix(engine): bind constraint to its own bundle copy's source pick ([9071dfb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9071dfb))
|
||||
* fix(engine): fold list-valued vars in derivation + converter reads ([d41be86](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d41be86))
|
||||
* fix(engine): guard junk pick counts + warn on multi-pick constraint source ([ace0486](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ace0486))
|
||||
* fix(engine): slugify nested-ref tags in v1->v2 migrate + sp1 e2e smoke ([a628512](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a628512))
|
||||
* fix(graph): honor .K accessor + multi-pick representative in static preview ([f922dca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f922dca))
|
||||
* fix(import-export): refresh also reloads the export tab listing ([7b2d057](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7b2d057))
|
||||
* fix(install): drop normalizeEntity now that community speaks engine-row shape ([b9132af](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b9132af))
|
||||
* fix(install): lift meta.name to top-level name in install pipeline ([160b729](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/160b729))
|
||||
* fix(library): validate all-items bundles against loaded catalog not empty store ([c66c1f5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c66c1f5))
|
||||
* fix(manager): add Delete button to WildcardEditor ([3d92673](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3d92673))
|
||||
* fix(manager): anchor remap popup to the chip using its real height ([498a4ba](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/498a4ba))
|
||||
* fix(manager): bundle-editor constraint names + list keyboard nav reachability ([0da118e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0da118e)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* fix(manager): community pill lifecycle, list keyboard nav, dashboard intro ([24e83ea](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/24e83ea)), closes [4/#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6) [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8) [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9) [#10](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10)
|
||||
* fix(manager): community routes share layoutKey to stop embed re-mount ([6f2b804](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f2b804))
|
||||
* fix(manager): confirm before destructive actions ([bb82b66](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb82b66))
|
||||
* fix(manager): dashboard tabs left + settings restart confirm ([b85f21a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b85f21a)), closes [#actions](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/actions)
|
||||
* fix(manager): dismiss subcat menus on outside click + flip near edge + drag-to-regroup ([723adde](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/723adde))
|
||||
* fix(manager): keep editor header Publish + Copy grouped ([97c92b9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/97c92b9))
|
||||
* fix(manager): never chipify on backspace/delete in rich-text editor ([af90203](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af90203))
|
||||
* fix(manager): parseRoute handles :rest(.*)* array params ([84cd82f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/84cd82f))
|
||||
* fix(manager): re-anchor remap popup on resize + cap height to viewport ([3884ded](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3884ded))
|
||||
* fix(manager): render unsettled $var/@ref as plain text in editor ([bf189b3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bf189b3))
|
||||
* fix(manager): row actions/focus follow hover+keyboard, not a sticky state ([915e0cc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/915e0cc))
|
||||
* fix(pool): custom pick steppers + min<=max clamp + null-aware enabled/probability ([2a24f9f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2a24f9f))
|
||||
* fix(pool): strike + uncheck null row in multi-pick; align pick stepper styling ([7d267d0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d267d0))
|
||||
* fix(pool): surface multi-pick count in pool summary + instance subtitle ([31fe070](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/31fe070))
|
||||
* fix(preview): serialize list-valued vars in resolve endpoint ([6703583](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6703583))
|
||||
* fix(publish): bypass vue-router hash encoder ([63a7cd9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/63a7cd9))
|
||||
* fix(publish): correct fixed_values shape + strip history sidecar ([191f880](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/191f880))
|
||||
* fix(publish): forward content_rating + tags + origin slug to embed ([39c0637](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39c0637))
|
||||
* fix(publish): forward schema_version stamp into the community publish hash ([9a3b5fa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a3b5fa))
|
||||
* fix(publish): normalize bundle children to module-row shape ([47b1535](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/47b1535))
|
||||
* fix(publish): stamp constraint source/target wildcard names so shares self-describe ([50612dc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/50612dc))
|
||||
* fix(resolver): carry structured list-vars through static resolve so $mood.0 indexes ([435cdb1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/435cdb1))
|
||||
* fix(schema): accept natively-supported future versions on install (max-known, not chain head) ([9f7ee12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9f7ee12))
|
||||
* fix(schema): advertise max-known schema version to publish gate, not chain head ([0cb5706](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0cb5706))
|
||||
* fix(snapshot): include SP2a pick fields in instance baseline + snapshot type ([d3881c8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d3881c8))
|
||||
* fix(spa): $var autocomplete fires after the $$ multi-pick delimiter ([3a9a09a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3a9a09a))
|
||||
* fix(spa): block native delete eating the ZWSP pad at field boundaries ([2a1123f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2a1123f))
|
||||
* fix(spa): chip whitespace-padded block arms + recolour blocks on edit ([51a526e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/51a526e)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* fix(spa): colour rich-text syntax on the ComfyUI canvas ([ba455fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ba455fb))
|
||||
* fix(spa): detect @{uuid} refs nested inside brace/multi blocks ([6097590](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6097590))
|
||||
* fix(spa): drop leftover background box behind multi-pick blocks ([b9ae37a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b9ae37a))
|
||||
* fix(spa): keep autocomplete open across a $var.K accessor (no Enter newline split) ([d9235c3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d9235c3))
|
||||
* fix(spa): keep brace-block scaffolding colour through atom-direct edits ([619cd5a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/619cd5a)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* fix(spa): resolve \.K to base name in derivation validation + conflict scan ([bc7ea33](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bc7ea33))
|
||||
* fix(spa): show rich-text placeholder ghost (zwsp pad defeated :empty) ([e69ceae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e69ceae))
|
||||
* fix(spa): show the !null ban mark on canvas inner-block ref chips ([55d9ef1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/55d9ef1))
|
||||
* fix(spa): single-char backspace/delete on raw-typed $var (atomic-delete only committed chips) ([743f8b0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/743f8b0))
|
||||
* fix(spa): stable $var.K chip round-trip in rich-text editor ([1208222](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1208222))
|
||||
* fix(ts): pick reach stores bare _uid; partial_reach severity info + stale-comment cleanup ([d826c6b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d826c6b))
|
||||
* fix(ts): thread cross-node chain to the constraint modal (chain-modules prop) ([71e05b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/71e05b1))
|
||||
* fix(ui): anchor ref-filter popover snug to its chip + move delete button far-left ([ff0f9dc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff0f9dc))
|
||||
* fix(ui): clamp ref-filter popover on-screen, show match count, link null toggle to exclude_null ([b23e29e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b23e29e))
|
||||
* fix(ui): peel !null in nested-ref filter chips on canvas + preview ([c5720e7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c5720e7))
|
||||
* fix(ui): render embed/canvas checkboxes via a scoped WpCheck component ([8000e1b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8000e1b))
|
||||
* fix(ui): shrink ref-filter action buttons by overriding the fixed .wp-btn height ([c777810](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c777810))
|
||||
* fix(ui): stack the pool allow-repeats + exclude-null toggles ([8ad2d0c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8ad2d0c))
|
||||
* fix(ui): style wildcard editor sub-category groups + option-tag picker ([3bcafc9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3bcafc9))
|
||||
* fix(ui): subcategory polish across spa + graph (colours, exclude-null, palette, popover) ([c2d0ef5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c2d0ef5)), closes [1/#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2) [#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6) [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8) [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4) [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [9/#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6)
|
||||
* fix(validation): parse v2 boolean sub-category ref filters ([5a0e05e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a0e05e))
|
||||
* fix(validators): match real engine shapes + widget-native bundle children ([8a9ee8c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8a9ee8c))
|
||||
* refactor(bundles): delete dead remapBundleUuids, keep walkRemap ([a4d1913](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a4d1913))
|
||||
* refactor(constraint): centralise derivation branch-key format + py/ts corpus parity ([0e9b5cf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e9b5cf))
|
||||
* refactor(engine): extract pick_k_unique from multi-pick resolver ([e5f54c6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e5f54c6))
|
||||
* refactor(import-export): per-row migratePayload contract per spec §2 ([320315c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/320315c))
|
||||
* refactor(install): applyCollisionDecisions returns rename map ([a5f95cf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a5f95cf))
|
||||
* refactor(manager): harmonize module-list row pills ([87b8c6d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/87b8c6d))
|
||||
* refactor(ui): convert remaining native checkboxes to the shared wp-check look ([c6baeb8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c6baeb8))
|
||||
* refactor(ui): make .wp-check the one checkbox style + restyle to wildcard-option look ([62d3768](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/62d3768))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.1.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* feat(api): add GET /wp/api/database/info ([dc839bf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dc839bf))
|
||||
* feat(api): add POST /wp/api/database/maintenance ([0909817](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0909817))
|
||||
* feat(api): expose db config get/put endpoints with pending-move ([04d3795](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/04d3795))
|
||||
* feat(api): run pending db move before migration on startup ([00b0c73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/00b0c73))
|
||||
* feat(engine): add analyze maintenance op ([d39add1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d39add1))
|
||||
* feat(engine): add gather_info DB introspection ([2b5f2b7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2b5f2b7))
|
||||
* feat(engine): add integrity_check maintenance op ([7f12575](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7f12575))
|
||||
* feat(engine): add run_migrations maintenance op ([5a62929](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a62929))
|
||||
* feat(engine): add sidecar db-config.json reader/writer ([069527a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/069527a))
|
||||
* feat(engine): add user/global/root location path helpers ([747a84a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/747a84a))
|
||||
* feat(engine): add vacuum maintenance op ([4abe679](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4abe679))
|
||||
* feat(engine): execute pending db moves at plugin load ([52966e6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/52966e6))
|
||||
* feat(engine): expose db path resolution source ([7e12626](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7e12626))
|
||||
* feat(engine): honor sidecar preference and add root db location ([c03fc07](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c03fc07))
|
||||
* feat(manager): add always-visible restart comfyui row to maintenance section ([c6a1030](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c6a1030))
|
||||
* feat(manager): add database API client + types ([f20a9e3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f20a9e3))
|
||||
* feat(manager): add database card with info and maintenance ([df7eb75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df7eb75))
|
||||
* feat(manager): add databaseStore Pinia store ([2003f38](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2003f38))
|
||||
* feat(manager): add db config types and api client methods ([8c760f2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8c760f2))
|
||||
* feat(manager): add location selector with copy or move flow ([eb48d7f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eb48d7f))
|
||||
* feat(manager): add maintenance op three-phase modal ([7cfe29b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7cfe29b))
|
||||
* feat(manager): add restart comfyui button via manager reboot endpoint ([02c8300](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02c8300))
|
||||
* feat(manager): browser preferences card with full prefix coverage ([b2b75d4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b2b75d4))
|
||||
* feat(manager): detect server restart and prompt stale page reload ([7102057](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7102057))
|
||||
* feat(manager): extend database store with config and pending move actions ([8eb5967](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8eb5967))
|
||||
* feat(manager): fade routes on tab switch with reduced-motion fallback ([9a053e8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a053e8))
|
||||
* feat(manager): heartbeat poll every 30s so stale banner appears without tab switch ([efb38ff](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/efb38ff))
|
||||
* feat(manager): replace storage card with browser prefs and database cards ([885a2d0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/885a2d0))
|
||||
* fix(manager): flex-based row layout for database info, wrap long paths ([a89bf7d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a89bf7d))
|
||||
* fix(manager): probe manager via get /manager/version instead of head reboot ([843a679](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/843a679))
|
||||
* chore: ignore db-config.json sidecar and root db dir ([d23a728](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d23a728))
|
||||
* chore(tests): fix fresh_db fixture return type annotation ([ecdbf6c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ecdbf6c))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.0.1
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* fix(ci): bypass wrapper action so publish.yml actually ships js + web ([3e755cc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3e755cc))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.0.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* ci: re-fire release after v1.8.0 tag re-anchor ([7ababca](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7ababca))
|
||||
* ci: re-trigger release workflow after E2BIG fix ([1287556](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1287556))
|
||||
* fix(release): drop nextRelease.notes from git commit message to avoid E2BIG ([ea90f97](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/ea90f97))
|
||||
* fix(spa): inject version + license at build time so About card stays in sync ([6f94bc5](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6f94bc5))
|
||||
* feat!: cut v2.0.0 — modular procedural prompt pipeline general availability ([64aa7d5](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/64aa7d5))
|
||||
* chore: pre-push audit cleanup — webmanifest, web_dist drift, dead TypePill ([b8c0387](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/b8c0387))
|
||||
* chore: tidy ComfyUI-Wildcard-Pipeline name in LICENSE + links comment ([4adb5b9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4adb5b9))
|
||||
* feat(github): publish workflow + issue/PR templates + release-zip + cleaner notes ([9b91012](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9b91012))
|
||||
* feat(registry): comfyignore + 21:9 banner + includes + collapsed changelog header ([730721f](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/730721f))
|
||||
* docs(readme): rewrite with svg flow banners + comfyui manager install path ([892479a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/892479a))
|
||||
|
||||
</details>
|
||||
|
||||
# [1.7.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.6.0...v1.7.0) (2026-04-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **context:** module picker + lock/internal + card indicators ([#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8)) ([b1fbd13](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b1fbd13af32415c087288f7f849b8b505f53da1d))
|
||||
|
||||
# [1.6.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.5.0...v1.6.0) (2026-04-28)
|
||||
## [1.4.2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.4.1...v1.4.2) (2026-04-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** list /modules `total` reflects unpaginated row count ([d536484](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d53648430d5ad7f5afcdd78e3ad7f3538d06ac3f))
|
||||
* **spa:** chip pills, derivation $var dropdown, constraint subcat axes ([bab0fe9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bab0fe99660f6f0032d3e115ce08e5182cd5b7a3))
|
||||
* **spa:** pipeline row — drop legacy refselect rules clipping shared select ([3e20ba5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3e20ba543c5c828cb9fb4aca544e5595a1ed535b))
|
||||
* **spa:** rich-text caret + wildcards-list syntax pills ([1298645](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/12986454eb74b947828c6ffc90fa0e09e724698b))
|
||||
* **spa:** wildcard ref-graph keys by 8-hex UUID, not full slug id ([5f12372](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5f123722f058ebc44b377f3b735ae5ad289c5f54))
|
||||
* **ui:** select dropdown — menu scroll works, viewport-aware max-height ([3b9262a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3b9262ab2d24e80884b4153ef950555b46b17206))
|
||||
* **spa:** @ autocomplete inserts UUID, editor cross-refs, derivation $ vars ([89c1662](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/89c16624db8c8859a9ba2c683afb5e549b6ecd5b))
|
||||
* **spa:** @ autocomplete UUIDs, editor cross-refs, derivation $ vars ([0a3e922](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0a3e92234ff1a4dd24905942ceb407a1b8ee3ec7))
|
||||
* **spa:** extract 8hex UUID suffix from module DB id for `@{uuid}` refs ([3d18829](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/3d18829e54b52eaac64d7a854aabe40906a39da6))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **spa:** uuid pills show var-names everywhere; clickable in test runner ([df046f5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df046f57935c42f1dceaa4196b1b99fbf49ac5b3))
|
||||
|
||||
# [1.5.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.4.2...v1.5.0) (2026-04-28)
|
||||
## [1.4.1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.4.0...v1.4.1) (2026-04-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** flatten embed-bundle response to match other endpoints ([d1d8d02](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d1d8d025a99e928a7d5af196de4a0c48bc5a5ed2))
|
||||
* **test-runner:** revert response envelope to flat shape ([1479d57](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1479d57817b3b4599a263a9edca6517d8a820acb))
|
||||
* **engine:** bump now_iso() to microsecond precision ([fa6c4a1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/fa6c4a1612ec94d2397a6db92a716d1288c09d89))
|
||||
* post-merge CI + docs/ ignore ([90f5492](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/90f549259ded6726948738fdea2d4eefdf3c313e))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** add /modules/hashes endpoint for drift detection ([27f3cc9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/27f3cc989073f8b32535e1e31b714e103e6dca53))
|
||||
* **api:** add embed-bundle endpoint for lazy walk + transitive deps ([be0b817](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/be0b81768ebdf38a439afdf27d5a344a48190544))
|
||||
* **db:** add get_by_uuid + get_by_uuids indexed lookups ([a25c700](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a25c700a9b7e4d6ecb8468d5008010e2d5a52817))
|
||||
* **db:** add indexed uuid column to modules with backfill ([c2f575d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c2f575d6f17fa2ed4303fbefb1cea578a65c621e))
|
||||
* **db:** repository writes uuid + returns payload_hash on every row ([b14b1c2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b14b1c297c88edf14c3beb4e22e0218a28506633))
|
||||
* **phase-5.5:** backend bridge — lazy catalog + walker + endpoints ([6ddb0e6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6ddb0e68925cc8fcf84edd2d8acd3ac775c52ba5))
|
||||
* **snapshot:** add canonical SnapshotEntry TypedDict (spec §2.4) ([4d9faa6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4d9faa607a70ba77ee91187cf5e0954d840b3ee3))
|
||||
* **snapshot:** walk_transitive_refs lazy walker (spec §2.10) ([0b43b78](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0b43b781159bacced67b0c3d6792a7a4907bef6a))
|
||||
* **spa-types:** add SnapshotEntry + EmbedBundle + embedBundle/hashes client ([bfe98f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bfe98f43b8fd1035edc2f8a297d1db369874349f))
|
||||
* **test-runner:** lazy catalog — resolve nested @{uuid} refs ([0f6fe72](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f6fe721829130bb8bfe12904e06fe510bccfde8))
|
||||
* **wp-api:** extract_referenced_uuids helper for lazy catalog roots ([339a3f3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/339a3f38f283f6fddf84db3c25bd223a52cb03e7))
|
||||
* **wp-nodes:** deserialize_node_input — modules + snapshots + pickOrder ([c319eef](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c319eef2763fdd793967226eb738256292ef0d18))
|
||||
* **wp-nodes:** inject __wp_catalog__ from embedded snapshots in WP_Context ([323dd55](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/323dd55e63b3116bb805c3809712dd0b10f09862))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **test-runner:** hoist catalog walk above per-sample loop ([8817e42](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8817e423b52f99de669ab5d9a6559fe2c97b97f8))
|
||||
|
||||
## [1.4.2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.4.1...v1.4.2) (2026-04-28)
|
||||
# [1.4.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.3.0...v1.4.0) (2026-04-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **spa:** @ autocomplete inserts UUID, editor cross-refs, derivation $ vars ([89c1662](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/89c16624db8c8859a9ba2c683afb5e549b6ecd5b))
|
||||
* **spa:** @ autocomplete UUIDs, editor cross-refs, derivation $ vars ([0a3e922](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0a3e92234ff1a4dd24905942ceb407a1b8ee3ec7))
|
||||
* **spa:** extract 8hex UUID suffix from module DB id for `@{uuid}` refs ([3d18829](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3d18829e54b52eaac64d7a854aabe40906a39da6))
|
||||
* **api:** atomic import + recursion guard + negative pagination + dry now_iso ([82b6c6e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/82b6c6eb37469b1f515639d0883b69f06be8f85b))
|
||||
* **api:** run migrations on register_routes so production db has tables ([1fc705d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1fc705d8da7b1ea7a2ae77f3449816bc6d9e4847))
|
||||
* **api:** use streamresponse return type for spa fallback handlers ([a12ee20](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a12ee2087c079ac3cab13020abaecd023f107bfd))
|
||||
* **ci:** python 3.10 datetime compat + rebaseline perf budgets ([eecb38e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/eecb38e85f170b807f109f7149d25eb6d60406e3))
|
||||
* **db:** drop utcnow + atomic migrations + row factory test ([3810a37](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/3810a373489fbcbaed204017b6ad9626e1706938))
|
||||
* **db:** id entropy + like escaping + ms timestamps + sentinel typing ([759626b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/759626b9148345af319e877699e85856e13d084b))
|
||||
* **db:** unset sentinel for required fields + slug validation + delete consistency ([c4ca447](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/c4ca4472ba44bab4259fd5590f25bd8e5570d91c))
|
||||
* **engine:** explicit type guard in resolve_module for malformed snapshots ([da0daf4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/da0daf47e0fc8f781179d6bb35d185ac9f503f29))
|
||||
* **engine:** isinstance guard avoids dataclass typeguard issue in handlers ([f4bf3d4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f4bf3d4635a53c57214340a6c00a5a38923f942b))
|
||||
* **engine:** read module id from raw dict to satisfy union narrowing ([9a98626](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9a98626dd767ae50fc6c6e03ced9a34a68b19f9a))
|
||||
* **manager:** favorites filter as wp-input box matching prototype ([c246360](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/c246360b97aba838e782e5c4dc19852c7baf7a62))
|
||||
* **spa:** align theme tokens with extension source of truth ([2917c04](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2917c04c3897b40d23287ec152883ac77488d84f))
|
||||
* **spa:** compact buttons + click-to-copy id + logo backdrop + dollar icon + hex color picker ([19727ad](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/19727ade47e057a10cb441342d5b0bab377435cf))
|
||||
* **spa:** enable primevue dark mode via .wp-dark class on documentelement ([fab8d0d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/fab8d0d20fcd4eb1ca5d6998c984b8ba424e9435))
|
||||
* **spa:** error handling on store actions + exact-active-class + matchmedia setup ([4cfe1f6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4cfe1f6d1487c4bb12b5daa0bbd4b4db57ac6106))
|
||||
* **spa:** light-mode + dropdown polish for chips, pills, selects ([cd8d23a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/cd8d23a750cc5a8dd7c8f0aa195f95fe61e14a72)), closes [#a78bfa](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/a78bfa) [#c026d3](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/c026d3)
|
||||
* **spa:** wrap tailwind in named layers so primevue specificity orders correctly ([f5652f4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f5652f4286e6b0cbcd6290bf0704f9c335ab8ba2))
|
||||
* **syntax:** drop vacuous "comment" check in richTokenize.test ([dee9fae](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/dee9fae32808697aeaeb28a52caa24baeaa12801))
|
||||
|
||||
## [1.4.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.4.0...v1.4.1) (2026-04-28)
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** /wp/api/modules crud endpoints ([89b7827](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/89b7827534e9814f93cac73d4c4c9261f3b4c9c6))
|
||||
* **api:** snapshot + match + duplicate + favorite + categories endpoints ([48ee214](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/48ee214f9667eadae372a17b6f25dc58eb8cf42f))
|
||||
* **api:** spa fallback route + comfyui registration + coverage gates ([51c1756](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/51c17565b8ea28864d076e5829d9689ef4f15967))
|
||||
* **api:** test runner + library import/export endpoints ([625cab7](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/625cab76820eaa6aa6b31cff91d3284f5a40c5b9))
|
||||
* **db:** category repository with crud ([746f3ad](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/746f3ad2db6e2e5430c2dde79671bbecbeb0322c))
|
||||
* **db:** module repository with crud and filtered list ([6c29f73](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6c29f738c50575329ce240c1cc7cc4c6990370dc))
|
||||
* **db:** sqlite library foundation + migration runner ([e4aabe6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e4aabe6313265a263bca1d3790d43b645f4366c0))
|
||||
* **engine:** fixedvalueshandler + route legacy dispatch through it ([2eeb900](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2eeb90052e34952d648e3b8e4456c2b2a0439164))
|
||||
* **engine:** module dispatcher + modulehandler abc ([f33ac4c](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f33ac4c9803b011f4efb26694f0d674c72970677))
|
||||
* **engine:** scaffold combine/derivation/constraint/pipeline module types ([a16c532](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a16c5324e281ffba6a08d3eda46f3deecf2faefa))
|
||||
* **engine:** snapshot freezing + payload hash + legacy coercion ([9d183d6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d183d6779a9a6ce1a9ebbdd558478420883000d))
|
||||
* **engine:** wildcardhandler with weighted rng and @{ref} resolution ([6626aca](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6626aca3d7cde2661d6276e867c362e6a9fecb34))
|
||||
* **manager:** favorites filter as chip toggle box in filter panel ([f51b752](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f51b752fd7a5ad84d3e9e785c4f2c9e98b4b1c84))
|
||||
* **manager:** surface-conditional @ autocomplete + warning highlights ([7f6f5b7](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7f6f5b719276019e3409e213cd4e44997977c7a6))
|
||||
* **manager:** wildcard.max_ref_depth setting (1-32, default 8) ([bc1d21e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/bc1d21e8aa040c6d6bef0c932b16c9b9bfa88663))
|
||||
* **spa:** add constraints to soon-disabled module types ([9d8a5ab](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d8a5ab00e565c163c93bba7ce2e5acdce04e96f))
|
||||
* **spa:** categories color picker (hex + swatch + presets) + sticky-header scroll ([f18be1e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f18be1eaff1cc197ef03251a1a23f4bfdddb2115))
|
||||
* **spa:** categories manager + import/export + test runner ([a20a7b0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a20a7b058b64f4b5347de39491e4828c15af0066))
|
||||
* **spa:** community tab — discover/detail/upload/profile + offline/404 (mock) ([f1add58](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f1add58631286260cde31332e2a1bb40f8a256e7))
|
||||
* **spa:** dashboard hero + branded categories/import/test pages ([e49df41](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e49df411d7f56dc89a309f5f5fff057fa32c324a))
|
||||
* **spa:** datatable expansion + category color chip + tags column ([133c8df](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/133c8df49166fda2f75faba5d79fac44a295781c))
|
||||
* **spa:** entity list view redesign with active-filter chips + pagination ([370fdd8](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/370fdd838828d96667d48d21c047d128d69325dd))
|
||||
* **spa:** entitylistview shared list skeleton ([5e8d042](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/5e8d0428d4da0fac6c5dcbe2da6c495ab96a79c7))
|
||||
* **spa:** fixedvalues.vue per-type list page ([32f0513](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/32f05134202cc035e992f5f4b064971e1a9d318a))
|
||||
* **spa:** full-page wildcard + fixedvalue forms with sticky footer ([82b568e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/82b568efbb1c75f89980048c5cc09858ea5a42ba))
|
||||
* **spa:** import/export with per-item selection + conflict resolution ([a5dfe52](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a5dfe52511e81251d333d6b7cfbfd37e48f8ff00))
|
||||
* **spa:** kind-aware Test Runner with per-type result panels ([8e28b40](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8e28b40655930db4f48042f21f7c589149b1440d))
|
||||
* **spa:** library datatable view with filters + row actions ([af66ca2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/af66ca263e49198fe46efb7e187d6fce2133408a))
|
||||
* **spa:** per-module version history with last-3 snapshots and restore panel ([678a0eb](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/678a0eb40317cfad442005908378ccb98e6439dc))
|
||||
* **spa:** per-type routes + drop polymorphic modules.vue ([33542e1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/33542e123994f462060b341de2c1c25f2a222bb5))
|
||||
* **spa:** pinia stores for modules + categories + ui ([86c36e0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/86c36e0f52eba4abfb37a4f1621c928ee94e777d))
|
||||
* **spa:** polish fixed-value form with \$name InputGroup + per-row identifier validation ([928e33c](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/928e33cc5ff445ca11e219fee2fb1a9acadd9a64))
|
||||
* **spa:** port community sub-screens + drop PrimeVue dependency (Wave 7+8) ([e4ab218](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e4ab2186e33f04908ff43b0699635bc3f3a982c8))
|
||||
* **spa:** port prototype dashboard + utilities views (Wave 5) ([1d13048](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1d1304898267ca0f9521adf16b19712cdef8e1a9))
|
||||
* **spa:** port prototype editors with EditorFrame scaffold (Wave 4) ([84fc961](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/84fc961fc2f4c31be250ef011e1c6f88430439f4))
|
||||
* **spa:** port prototype ModuleListView + drop PrimeVue from list screens (Wave 3) ([df5d38d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/df5d38d6250c7856e34b2ac2a7b80ff649da8e0d))
|
||||
* **spa:** port prototype shell — topbar + sidebar 1:1, drop PrimeVue (Wave 2) ([30fed89](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/30fed89ce3aad313faad8b138eed808766763f56))
|
||||
* **spa:** port prototype ui primitives + full styles.css (Wave 1) ([03d2b7b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/03d2b7bde50cd984bf312e73c6f0d200fc731a82))
|
||||
* **spa:** primevue brand preset derived from --wp-* tokens ([76bcd36](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/76bcd3629d609ce79652109e63025b7027104c6e))
|
||||
* **spa:** real Combine editor with template + RichTextInput + detected inputs ([d50dd8d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d50dd8d6015fa01466c34470833ac7ed6b9bf2e8))
|
||||
* **spa:** real Constraint editor with matrix grid + factor tune popovers ([02a526a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/02a526a53c7972382ca06742f7d3f8ad73bdfdeb))
|
||||
* **spa:** real Derivation editor with multi-rule IF/ELIF/ELSE ([4c603a2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4c603a20db432d9bb4a5c785411af2aa543817ba))
|
||||
* **spa:** real Pipeline editor with steps stack + module picker modal ([054a777](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/054a777ec1522d07fe017f81bacbfdc302df6100))
|
||||
* **spa:** redesign Dashboard with brand hero + 6 kind stats + recent/favorites ([0dcb562](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0dcb562801dd1d0fe395bbe1aedbda037ef87e85))
|
||||
* **spa:** rich text input + preview with token chips and autocomplete ([6a5bbb4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6a5bbb40ef56f66fa1ea1063aa8ddc5f4cedc700))
|
||||
* **spa:** rich-text input — restore $/@ autocomplete dropdown with keyboard nav ([60f911a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/60f911a0f8e2e32805e3153b698939e99e90123f))
|
||||
* **spa:** sakai-style topbar + sidebar layout ([79163fc](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/79163fce79e558c61f201cc44de6ab7405635a0b))
|
||||
* **spa:** scaffold combine/derivation/constraint/pipeline list + edit views ([8c03e72](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8c03e72d69785a7ae2b7101250cd35987e307231))
|
||||
* **spa:** shared typepill + relativedate components ([f3a1066](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f3a10664ab64bc1da83914629dbfe07d1adcda71))
|
||||
* **spa:** tags editor on forms + sort dropdown + category/tags filters ([0722da2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0722da2a98ae4f544a94df241faf17dad40ac0d2))
|
||||
* **spa:** theme toggle (dark/light/auto) with flash-suppression ([1b7576a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1b7576a55fb33af05b2f4ca6355354d1a032ab2e))
|
||||
* **spa:** topbar logo + 4-section sakai sidebar with primeicons ([42f30ef](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/42f30efbe7c699d9f7ab2565cee37ce48ec123a2))
|
||||
* **spa:** tweaks panel — accent palette + density + sidebar runtime switcher (Wave 6) ([975581e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/975581e111f1acfb13ec421a925af68a6fda8592))
|
||||
* **spa:** typed api client wrapping /wp/api/* ([7a7b632](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7a7b632f0693976b20b16ed5ef4c2aacb8dd7890))
|
||||
* **spa:** vite + tailwind + primevue scaffold ([7a47035](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7a4703546edc2467a75b4c132915bdf666e4dd70))
|
||||
* **spa:** vue router + 7 stub views ([d80fd24](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d80fd242545ef794aa0b013f1ff1178f65242919))
|
||||
* **spa:** wildcard + fixed-values editors ([8abab1e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8abab1ee3d0e83604030c2e97e777e23275dc86a))
|
||||
* **spa:** wildcard form gains varBinding field + RichTextInput on option values ([d84bd2d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d84bd2d60b048fe6790d629d3b46067304050e8c))
|
||||
* **spa:** wildcard sub-categories define + assign per option ([8a1b820](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8a1b820ab3f3998d1abcb7160d19c549daa74c52))
|
||||
* **spa:** wildcards list — syntax indicator column + nested-ref filters ([80cc323](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/80cc323f31b394c95a4de6f9e429bbf6fd6cd1db))
|
||||
* **spa:** wildcards.vue with full datatable + filter + bulk select ([22c29aa](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/22c29aa090511b352c80035c60702435f0da431a))
|
||||
* **syntax:** _resolve_inline_pick — uniform random branch + recurse ([2f48900](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2f489002e17de9ea092a05dde1b069f3edf0fbdc))
|
||||
* **syntax:** _resolve_multi_pick — weighted without-replacement + sep join ([966ab4b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/966ab4b1aac965f7b8601517a8ddfeb534aeb7a5))
|
||||
* **syntax:** _resolve_ref with surface/depth/cycle checks + weighted pick ([51a859b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/51a859b0956091fee9e296097b9b6779dd99b52d))
|
||||
* **syntax:** foundation types, error classes, ResolveContext Protocol ([45c2fa9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/45c2fa9bc48cecb25410aa04fd3ee993b442afb8))
|
||||
* **syntax:** resolve_text dispatch loop with text/escape/var resolution ([9e05e1f](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9e05e1fb668edc9bcabbcfeda154e90c1682e4ea))
|
||||
* **syntax:** resolveTokens TS twin of Python resolve_text ([9d3c6a9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d3c6a9851be55d60ea24df8abc9953f662eda38))
|
||||
* **syntax:** tokenize $var and @{uuid} kinds + corpus cases ([4fb2c99](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4fb2c997714d9bfc51f806b65a02b3bba8c0f4be))
|
||||
* **syntax:** tokenize dp_brace + dp_multi with nesting support ([9364eb8](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9364eb84f5899ab7d2e2dc2111f4c769d3c2c384))
|
||||
* **syntax:** tokenize_text — text + escape kinds with lossless invariant ([11ac437](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/11ac437fa391edcacdf210b8cb3e9d73e7c48cf7))
|
||||
|
||||
# [1.3.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.2.0...v1.3.0) (2026-04-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* i18n locales (en) + per-node help pages ([#2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/2)) ([5216547](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/52165471c0da315db59d2b2fb72537d7b06d62ef))
|
||||
|
||||
# [1.2.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.1.0...v1.2.0) (2026-04-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* polish suite — MVP frontend + subgraph support + DX + a11y ([79bdb45](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/79bdb457cbc98e191766e3fa7c84df2e1106ecc2))
|
||||
|
||||
# [1.1.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.0.0...v1.1.0) (2026-04-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **engine:** bump now_iso() to microsecond precision ([fa6c4a1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa6c4a1612ec94d2397a6db92a716d1288c09d89))
|
||||
* post-merge CI + docs/ ignore ([90f5492](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/90f549259ded6726948738fdea2d4eefdf3c313e))
|
||||
|
||||
# [1.4.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.3.0...v1.4.0) (2026-04-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** atomic import + recursion guard + negative pagination + dry now_iso ([82b6c6e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/82b6c6eb37469b1f515639d0883b69f06be8f85b))
|
||||
* **api:** run migrations on register_routes so production db has tables ([1fc705d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1fc705d8da7b1ea7a2ae77f3449816bc6d9e4847))
|
||||
* **api:** use streamresponse return type for spa fallback handlers ([a12ee20](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a12ee2087c079ac3cab13020abaecd023f107bfd))
|
||||
* **ci:** python 3.10 datetime compat + rebaseline perf budgets ([eecb38e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eecb38e85f170b807f109f7149d25eb6d60406e3))
|
||||
* **db:** drop utcnow + atomic migrations + row factory test ([3810a37](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3810a373489fbcbaed204017b6ad9626e1706938))
|
||||
* **db:** id entropy + like escaping + ms timestamps + sentinel typing ([759626b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/759626b9148345af319e877699e85856e13d084b))
|
||||
* **db:** unset sentinel for required fields + slug validation + delete consistency ([c4ca447](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c4ca4472ba44bab4259fd5590f25bd8e5570d91c))
|
||||
* **engine:** explicit type guard in resolve_module for malformed snapshots ([da0daf4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/da0daf47e0fc8f781179d6bb35d185ac9f503f29))
|
||||
* **engine:** isinstance guard avoids dataclass typeguard issue in handlers ([f4bf3d4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f4bf3d4635a53c57214340a6c00a5a38923f942b))
|
||||
* **engine:** read module id from raw dict to satisfy union narrowing ([9a98626](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a98626dd767ae50fc6c6e03ced9a34a68b19f9a))
|
||||
* **manager:** favorites filter as wp-input box matching prototype ([c246360](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c246360b97aba838e782e5c4dc19852c7baf7a62))
|
||||
* **spa:** align theme tokens with extension source of truth ([2917c04](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2917c04c3897b40d23287ec152883ac77488d84f))
|
||||
* **spa:** compact buttons + click-to-copy id + logo backdrop + dollar icon + hex color picker ([19727ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/19727ade47e057a10cb441342d5b0bab377435cf))
|
||||
* **spa:** enable primevue dark mode via .wp-dark class on documentelement ([fab8d0d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fab8d0d20fcd4eb1ca5d6998c984b8ba424e9435))
|
||||
* **spa:** error handling on store actions + exact-active-class + matchmedia setup ([4cfe1f6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4cfe1f6d1487c4bb12b5daa0bbd4b4db57ac6106))
|
||||
* **spa:** light-mode + dropdown polish for chips, pills, selects ([cd8d23a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/cd8d23a750cc5a8dd7c8f0aa195f95fe61e14a72)), closes [#a78bfa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/a78bfa) [#c026d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/c026d3)
|
||||
* **spa:** wrap tailwind in named layers so primevue specificity orders correctly ([f5652f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f5652f4286e6b0cbcd6290bf0704f9c335ab8ba2))
|
||||
* **syntax:** drop vacuous "comment" check in richTokenize.test ([dee9fae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dee9fae32808697aeaeb28a52caa24baeaa12801))
|
||||
* **engine:** annotate Module as TypeAlias so Pyright treats it as a type ([e1ab2f1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e1ab2f1f18f99c2e9971baeaeed9f769070b4099))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** /wp/api/modules crud endpoints ([89b7827](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/89b7827534e9814f93cac73d4c4c9261f3b4c9c6))
|
||||
* **api:** snapshot + match + duplicate + favorite + categories endpoints ([48ee214](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/48ee214f9667eadae372a17b6f25dc58eb8cf42f))
|
||||
* **api:** spa fallback route + comfyui registration + coverage gates ([51c1756](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/51c17565b8ea28864d076e5829d9689ef4f15967))
|
||||
* **api:** test runner + library import/export endpoints ([625cab7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/625cab76820eaa6aa6b31cff91d3284f5a40c5b9))
|
||||
* **db:** category repository with crud ([746f3ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/746f3ad2db6e2e5430c2dde79671bbecbeb0322c))
|
||||
* **db:** module repository with crud and filtered list ([6c29f73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6c29f738c50575329ce240c1cc7cc4c6990370dc))
|
||||
* **db:** sqlite library foundation + migration runner ([e4aabe6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e4aabe6313265a263bca1d3790d43b645f4366c0))
|
||||
* **engine:** fixedvalueshandler + route legacy dispatch through it ([2eeb900](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2eeb90052e34952d648e3b8e4456c2b2a0439164))
|
||||
* **engine:** module dispatcher + modulehandler abc ([f33ac4c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f33ac4c9803b011f4efb26694f0d674c72970677))
|
||||
* **engine:** scaffold combine/derivation/constraint/pipeline module types ([a16c532](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a16c5324e281ffba6a08d3eda46f3deecf2faefa))
|
||||
* **engine:** snapshot freezing + payload hash + legacy coercion ([9d183d6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9d183d6779a9a6ce1a9ebbdd558478420883000d))
|
||||
* **engine:** wildcardhandler with weighted rng and @{ref} resolution ([6626aca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6626aca3d7cde2661d6276e867c362e6a9fecb34))
|
||||
* **manager:** favorites filter as chip toggle box in filter panel ([f51b752](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f51b752fd7a5ad84d3e9e785c4f2c9e98b4b1c84))
|
||||
* **manager:** surface-conditional @ autocomplete + warning highlights ([7f6f5b7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7f6f5b719276019e3409e213cd4e44997977c7a6))
|
||||
* **manager:** wildcard.max_ref_depth setting (1-32, default 8) ([bc1d21e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bc1d21e8aa040c6d6bef0c932b16c9b9bfa88663))
|
||||
* **spa:** add constraints to soon-disabled module types ([9d8a5ab](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9d8a5ab00e565c163c93bba7ce2e5acdce04e96f))
|
||||
* **spa:** categories color picker (hex + swatch + presets) + sticky-header scroll ([f18be1e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f18be1eaff1cc197ef03251a1a23f4bfdddb2115))
|
||||
* **spa:** categories manager + import/export + test runner ([a20a7b0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a20a7b058b64f4b5347de39491e4828c15af0066))
|
||||
* **spa:** community tab — discover/detail/upload/profile + offline/404 (mock) ([f1add58](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f1add58631286260cde31332e2a1bb40f8a256e7))
|
||||
* **spa:** dashboard hero + branded categories/import/test pages ([e49df41](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e49df411d7f56dc89a309f5f5fff057fa32c324a))
|
||||
* **spa:** datatable expansion + category color chip + tags column ([133c8df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/133c8df49166fda2f75faba5d79fac44a295781c))
|
||||
* **spa:** entity list view redesign with active-filter chips + pagination ([370fdd8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/370fdd838828d96667d48d21c047d128d69325dd))
|
||||
* **spa:** entitylistview shared list skeleton ([5e8d042](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5e8d0428d4da0fac6c5dcbe2da6c495ab96a79c7))
|
||||
* **spa:** fixedvalues.vue per-type list page ([32f0513](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/32f05134202cc035e992f5f4b064971e1a9d318a))
|
||||
* **spa:** full-page wildcard + fixedvalue forms with sticky footer ([82b568e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/82b568efbb1c75f89980048c5cc09858ea5a42ba))
|
||||
* **spa:** import/export with per-item selection + conflict resolution ([a5dfe52](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a5dfe52511e81251d333d6b7cfbfd37e48f8ff00))
|
||||
* **spa:** kind-aware Test Runner with per-type result panels ([8e28b40](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8e28b40655930db4f48042f21f7c589149b1440d))
|
||||
* **spa:** library datatable view with filters + row actions ([af66ca2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af66ca263e49198fe46efb7e187d6fce2133408a))
|
||||
* **spa:** per-module version history with last-3 snapshots and restore panel ([678a0eb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/678a0eb40317cfad442005908378ccb98e6439dc))
|
||||
* **spa:** per-type routes + drop polymorphic modules.vue ([33542e1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33542e123994f462060b341de2c1c25f2a222bb5))
|
||||
* **spa:** pinia stores for modules + categories + ui ([86c36e0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/86c36e0f52eba4abfb37a4f1621c928ee94e777d))
|
||||
* **spa:** polish fixed-value form with \$name InputGroup + per-row identifier validation ([928e33c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/928e33cc5ff445ca11e219fee2fb1a9acadd9a64))
|
||||
* **spa:** port community sub-screens + drop PrimeVue dependency (Wave 7+8) ([e4ab218](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e4ab2186e33f04908ff43b0699635bc3f3a982c8))
|
||||
* **spa:** port prototype dashboard + utilities views (Wave 5) ([1d13048](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1d1304898267ca0f9521adf16b19712cdef8e1a9))
|
||||
* **spa:** port prototype editors with EditorFrame scaffold (Wave 4) ([84fc961](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/84fc961fc2f4c31be250ef011e1c6f88430439f4))
|
||||
* **spa:** port prototype ModuleListView + drop PrimeVue from list screens (Wave 3) ([df5d38d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df5d38d6250c7856e34b2ac2a7b80ff649da8e0d))
|
||||
* **spa:** port prototype shell — topbar + sidebar 1:1, drop PrimeVue (Wave 2) ([30fed89](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/30fed89ce3aad313faad8b138eed808766763f56))
|
||||
* **spa:** port prototype ui primitives + full styles.css (Wave 1) ([03d2b7b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/03d2b7bde50cd984bf312e73c6f0d200fc731a82))
|
||||
* **spa:** primevue brand preset derived from --wp-* tokens ([76bcd36](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/76bcd3629d609ce79652109e63025b7027104c6e))
|
||||
* **spa:** real Combine editor with template + RichTextInput + detected inputs ([d50dd8d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d50dd8d6015fa01466c34470833ac7ed6b9bf2e8))
|
||||
* **spa:** real Constraint editor with matrix grid + factor tune popovers ([02a526a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02a526a53c7972382ca06742f7d3f8ad73bdfdeb))
|
||||
* **spa:** real Derivation editor with multi-rule IF/ELIF/ELSE ([4c603a2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4c603a20db432d9bb4a5c785411af2aa543817ba))
|
||||
* **spa:** real Pipeline editor with steps stack + module picker modal ([054a777](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/054a777ec1522d07fe017f81bacbfdc302df6100))
|
||||
* **spa:** redesign Dashboard with brand hero + 6 kind stats + recent/favorites ([0dcb562](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0dcb562801dd1d0fe395bbe1aedbda037ef87e85))
|
||||
* **spa:** rich text input + preview with token chips and autocomplete ([6a5bbb4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6a5bbb40ef56f66fa1ea1063aa8ddc5f4cedc700))
|
||||
* **spa:** rich-text input — restore $/@ autocomplete dropdown with keyboard nav ([60f911a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/60f911a0f8e2e32805e3153b698939e99e90123f))
|
||||
* **spa:** sakai-style topbar + sidebar layout ([79163fc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/79163fce79e558c61f201cc44de6ab7405635a0b))
|
||||
* **spa:** scaffold combine/derivation/constraint/pipeline list + edit views ([8c03e72](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8c03e72d69785a7ae2b7101250cd35987e307231))
|
||||
* **spa:** shared typepill + relativedate components ([f3a1066](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f3a10664ab64bc1da83914629dbfe07d1adcda71))
|
||||
* **spa:** tags editor on forms + sort dropdown + category/tags filters ([0722da2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0722da2a98ae4f544a94df241faf17dad40ac0d2))
|
||||
* **spa:** theme toggle (dark/light/auto) with flash-suppression ([1b7576a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1b7576a55fb33af05b2f4ca6355354d1a032ab2e))
|
||||
* **spa:** topbar logo + 4-section sakai sidebar with primeicons ([42f30ef](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/42f30efbe7c699d9f7ab2565cee37ce48ec123a2))
|
||||
* **spa:** tweaks panel — accent palette + density + sidebar runtime switcher (Wave 6) ([975581e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/975581e111f1acfb13ec421a925af68a6fda8592))
|
||||
* **spa:** typed api client wrapping /wp/api/* ([7a7b632](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7a7b632f0693976b20b16ed5ef4c2aacb8dd7890))
|
||||
* **spa:** vite + tailwind + primevue scaffold ([7a47035](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7a4703546edc2467a75b4c132915bdf666e4dd70))
|
||||
* **spa:** vue router + 7 stub views ([d80fd24](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d80fd242545ef794aa0b013f1ff1178f65242919))
|
||||
* **spa:** wildcard + fixed-values editors ([8abab1e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8abab1ee3d0e83604030c2e97e777e23275dc86a))
|
||||
* **spa:** wildcard form gains varBinding field + RichTextInput on option values ([d84bd2d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d84bd2d60b048fe6790d629d3b46067304050e8c))
|
||||
* **spa:** wildcard sub-categories define + assign per option ([8a1b820](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8a1b820ab3f3998d1abcb7160d19c549daa74c52))
|
||||
* **spa:** wildcards list — syntax indicator column + nested-ref filters ([80cc323](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/80cc323f31b394c95a4de6f9e429bbf6fd6cd1db))
|
||||
* **spa:** wildcards.vue with full datatable + filter + bulk select ([22c29aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/22c29aa090511b352c80035c60702435f0da431a))
|
||||
* **syntax:** _resolve_inline_pick — uniform random branch + recurse ([2f48900](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2f489002e17de9ea092a05dde1b069f3edf0fbdc))
|
||||
* **syntax:** _resolve_multi_pick — weighted without-replacement + sep join ([966ab4b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/966ab4b1aac965f7b8601517a8ddfeb534aeb7a5))
|
||||
* **syntax:** _resolve_ref with surface/depth/cycle checks + weighted pick ([51a859b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/51a859b0956091fee9e296097b9b6779dd99b52d))
|
||||
* **syntax:** foundation types, error classes, ResolveContext Protocol ([45c2fa9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/45c2fa9bc48cecb25410aa04fd3ee993b442afb8))
|
||||
* **syntax:** resolve_text dispatch loop with text/escape/var resolution ([9e05e1f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9e05e1fb668edc9bcabbcfeda154e90c1682e4ea))
|
||||
* **syntax:** resolveTokens TS twin of Python resolve_text ([9d3c6a9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9d3c6a9851be55d60ea24df8abc9953f662eda38))
|
||||
* **syntax:** tokenize $var and @{uuid} kinds + corpus cases ([4fb2c99](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4fb2c997714d9bfc51f806b65a02b3bba8c0f4be))
|
||||
* **syntax:** tokenize dp_brace + dp_multi with nesting support ([9364eb8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9364eb84f5899ab7d2e2dc2111f4c769d3c2c384))
|
||||
* **syntax:** tokenize_text — text + escape kinds with lossless invariant ([11ac437](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/11ac437fa391edcacdf210b8cb3e9d73e7c48cf7))
|
||||
|
||||
# [1.3.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.2.0...v1.3.0) (2026-04-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* i18n locales (en) + per-node help pages ([#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)) ([5216547](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/52165471c0da315db59d2b2fb72537d7b06d62ef))
|
||||
|
||||
# [1.2.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.1.0...v1.2.0) (2026-04-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* polish suite — MVP frontend + subgraph support + DX + a11y ([79bdb45](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/79bdb457cbc98e191766e3fa7c84df2e1106ecc2))
|
||||
|
||||
# [1.1.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.0.0...v1.1.0) (2026-04-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **engine:** annotate Module as TypeAlias so Pyright treats it as a type ([e1ab2f1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e1ab2f1f18f99c2e9971baeaeed9f769070b4099))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **engine:** add Context type and strip_internals helper ([61a2e0e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/61a2e0e9acb44a04a0f0d07d7656811160d03ee6))
|
||||
* **engine:** add handle_fixed_values handler ([95ef0f9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/95ef0f95d2f6aadee80343e3487a7abeeda71f12))
|
||||
* **engine:** add module dataclasses and JSON round-trip helpers ([9f8fbf1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9f8fbf13e4db8c4b155e997420fdd58d4988cf97))
|
||||
* **engine:** add PipelineEngine with dispatcher and per-module tracing ([3f7236f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3f7236f61b36519a2ab7f265ff5c66985b8225b5))
|
||||
* **engine:** add template.resolve_variables with $var and $$ escape ([3399184](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/339918464e4a9500fc5bf574a0b499d5fc4e70b8))
|
||||
* **engine:** add Context type and strip_internals helper ([61a2e0e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/61a2e0e9acb44a04a0f0d07d7656811160d03ee6))
|
||||
* **engine:** add handle_fixed_values handler ([95ef0f9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/95ef0f95d2f6aadee80343e3487a7abeeda71f12))
|
||||
* **engine:** add module dataclasses and JSON round-trip helpers ([9f8fbf1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9f8fbf13e4db8c4b155e997420fdd58d4988cf97))
|
||||
* **engine:** add PipelineEngine with dispatcher and per-module tracing ([3f7236f](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/3f7236f61b36519a2ab7f265ff5c66985b8225b5))
|
||||
* **engine:** add template.resolve_variables with $var and $$ escape ([3399184](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/339918464e4a9500fc5bf574a0b499d5fc4e70b8))
|
||||
|
||||
# 1.0.0 (2026-04-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add __init__.py stub for ComfyUI loader; drop deprecated tsconfig baseUrl ([426f417](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/426f4175d69d773e4e72a1ec97da8239c5b6e560))
|
||||
* **commitlint:** disable body/footer max-line-length for release commits ([20f0779](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/20f077981e2986799f61583bb9841f61258aba73))
|
||||
* add __init__.py stub for ComfyUI loader; drop deprecated tsconfig baseUrl ([426f417](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/426f4175d69d773e4e72a1ec97da8239c5b6e560))
|
||||
* **commitlint:** disable body/footer max-line-length for release commits ([20f0779](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/20f077981e2986799f61583bb9841f61258aba73))
|
||||
|
||||
@@ -29,7 +29,6 @@ pnpm test:coverage # Vitest + v8 coverage (with thresholds)
|
||||
pnpm typecheck # vue-tsc --noEmit
|
||||
pnpm lint # ESLint flat config
|
||||
pnpm size # bundle-size gate (--json <path> for manifest)
|
||||
pnpm check:css-isolation # flag non-wp-prefixed top-level CSS selectors
|
||||
pytest # Python unit tests
|
||||
ruff check . # Python lint
|
||||
```
|
||||
@@ -44,17 +43,11 @@ ruff check . # Python lint
|
||||
- **Toast singleton** — one Vue app mounted to body-level div renders all toasts pushed via `src/components/shared/toast-store.ts`. Every Context node import same store so toasts surface from anywhere. Position mirror ComfyUI native PrimeVue toast anchor (rect-relative to `.graph-canvas-container`).
|
||||
- **Subgraph traversal** — `collectUpstreamVariables` / `collectUpstreamValues` / `findDownstreamAssemblers` cross subgraph boundaries via `-10` SubgraphInputNode + `-20` SubgraphOutputNode sentinel ids. Walkers expect `app.graph` (root); per-call `buildSubgraphParents` map handle step-out. Use `walkAllNodes(rootGraph)` for graph-wide scans (e.g. pre-run validation).
|
||||
- **Subgraph badges** — `extension/subgraph-badge.ts` attach `LGraphBadge` (via `window.LGraphBadge`) to any SubgraphNode containing WP nodes. Badge surface worst inner conflict severity with human label (e.g. `missing $foo`, `2 conflicts`). Wire via `nodeCreated` + `loadedGraphNode` hooks — `beforeRegisterNodeDef` does NOT fire for SubgraphNodes.
|
||||
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Rule types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning), `constraint_source_missing` / `constraint_target_missing` (warning — uuid not in catalog), `constraint_orphan_source` (warning — no source instance UPSTREAM), `constraint_orphan_target` (warning — the constraint's reach selector covers ZERO reachable downstream instances; per-selector, NOT count-aware — overlapping constraints may freely share an instance). Engine emits runtime warnings `constraint_never_applied` (the reach matched nothing at runtime) and `constraint_partial_reach` (a `next N` / `pick` reach matched fewer instances than requested).
|
||||
- **Constraint reach (SP3)** — each constraint carries a `target_select` reach selector: `{mode: "first"|"next"|"all"|"pick", count?, picks?}`, default `all`. Every constraint whose reach covers a firing downstream target instance applies, COMBINED — per-pick × per-tag × per-constraint multiply, with `exclude` (factor 0) absorbing. No more one-shot/consumed-set: the engine counts per-constraint downstream hits in `ctx["__wp_constraint_hits__"]`. Reach is downstream-relative (counts from the constraint's own chain position) and cross-node + transitive (resolves `@{uuid}` at any depth). `pick` selects specific instances by `_uid` (direct top-level) or carrier `_uid` + option id (nested). The re-weight math is the pure `engine/modules/_constraint_math.py::combine_constraint_factor`, mirrored in TS at `src/manager/utils/constraint-math.ts` via the shared `tests/fixtures/constraint-corpus.json`. Published payloads using a non-default `target_select` stamp `schema_version = 4` (additive; community catalog v4).
|
||||
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Three types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning).
|
||||
- **Bundle-size gate** — entry `main.js` must stay ≤ 30 KB gzipped; total `js/` ≤ 250 KB. Pre-commit run `pnpm build:extension && pnpm size`. Raising budget require explicit PR discussion.
|
||||
- **Coverage gate** — vitest v8 coverage with thresholds (lines/statements 50%, functions 40%, branches 65%). Anchored to current baseline; ratchet up alongside new tests.
|
||||
- **Caching** — `WP_Context` + `WP_Debug` set `not_idempotent=True` so seed cycling always re-executes.
|
||||
- **Conventional Commits** — enforced by commitlint. semantic-release read log to cut versions.
|
||||
- **Extension isolation — four layers**:
|
||||
1. **CSS class namespace**: every class selector starts with `.wp-*` (or `.var-1..8`). Never write a top-level rule with an unprefixed class — it could collide with ComfyUI core or another custom node. New top-level non-`wp-*` selectors are blocked by `pnpm check:css-isolation`.
|
||||
2. **`@layer wp-extension` wrap**: standalone extension CSS (`a11y.css`, `display-prefs.css`, `ContextWidget.vue` unscoped block, etc.) sits in `@layer wp-extension { ... }` so host CSS (unlayered) beats us on any selector collision. Layered author rules are subservient to unlayered ones in the same origin. NOT applicable to CSS that gets `@imported` into Vue `<style scoped>` blocks — the scoper can't parse `@layer` atrules; rely on namespace prefix there.
|
||||
3. **WeakMap stashes for litegraph object metadata**: never attach state to nodes/slots via string keys (`node._wpFoo = ...`). Use the WeakMaps in `src/extension/_stashes.ts` — module-private, no collision risk with other extensions, invisible to workflow serialization. Method overrides (`getInputPos`, `onConnectionsChange`, etc.) must keep their standard names because litegraph reads them; isolation there is via wrap-and-call-original chaining.
|
||||
4. **Persistent state on `node.properties` only**: anything that must survive workflow save lives on `node.properties[key]` with a `wp_*` / `collapse_connections`-style string key (workflow JSON is the serialization root). Transient session state belongs in WeakMaps.
|
||||
|
||||
## Frontend overview
|
||||
|
||||
@@ -65,87 +58,6 @@ ruff check . # Python lint
|
||||
- **Reactivity** `extension/reactive.ts:reactiveFromGraph` — wires `onConnectionsChange` chain + 400ms polling fallback + `afterConfigureGraph` re-sync so widgets recompute on graph edits + workflow loads without flashing stale state.
|
||||
- **Adding new widget**: (1) write SFC under `src/components/<name>/`, (2) add `src/widgets/<name>.ts` exposing `create(node, inputName)` that return `createDomWidgetHost(...)`, (3) wire in `src/main.ts` — either as `getCustomWidgets` entry (for inputs declared with matching widget-type) or via `beforeRegisterNodeDef → onNodeCreated` (for free-floating helpers). Use `import("./widgets/<name>")` so stay out of entry chunk + size gate keep holding.
|
||||
|
||||
## Schema versioning + lazy migration machine
|
||||
|
||||
Spec: `D:\Desktop\Wildcard-Pipeline-Community\docs\superpowers\specs\2026-06-05-schema-versioning-migration-design.md` (cross-repo lock; read before bumping).
|
||||
|
||||
**Three axes** every payload + library row carries:
|
||||
|
||||
1. **`schema_version`** (per row, engine column added in migration 014): which payload shape this row's `payload` is in. Single version per payload root — bundles' children inherit the bundle's `schema_version`.
|
||||
2. **`producer_engine_version`** (per published community version, server-side only): diagnostic. Never load-bearing.
|
||||
3. **`version_number`** (per community post, server-side): the user-facing 1/2/3 publish revision. Unrelated to schema_version.
|
||||
|
||||
**Engine row columns** (migration 014):
|
||||
- `schema_version INTEGER NOT NULL DEFAULT 1` — current shape this row targets.
|
||||
- `original_payload_json TEXT` — verbatim local mirror of the payload as it arrived from community (or import). Lazy migrator reads from here, never from the live `payload` field. Lets us re-project from the original if a migrator chain gets fixed retroactively.
|
||||
- `tolerant_drift_status TEXT` — `none` | `tolerant` | `strict`. Set by parseTolerantAsCurrentShape during install.
|
||||
- `schema_migrated_at TEXT` — wall-clock stamp of last lazy migration step.
|
||||
|
||||
**The machine**:
|
||||
|
||||
- **Validator registry** (`src/manager/import-export/validators/`): one Zod validator per known `schema_version`. Strict at `≤ CURRENT_SCHEMA_VERSION`; future versions go through `parseTolerantAsCurrentShape` (additive-strip).
|
||||
- **`parseTolerantAsCurrentShape(raw, currentVersion)`** (`src/manager/import-export/tolerant-parse.ts`): strips fields the current shape doesn't know, returns the projection that matches `currentVersion`. Used at install when source `schema_version > CURRENT`.
|
||||
- **`migratePayload(payload, from, to)`** (`src/manager/import-export/migration-machine.ts`): forward-only chain. Each step is a registered function in the migrators map. `from > to` is illegal — no downgrades.
|
||||
- **`lazy_migrate_row(row, currentVersion, migrators, validators)`** (`engine/db/lazy_migrate.py`): reads `original_payload_json` (or `payload` if absent), walks the migrator chain to `currentVersion`, validates, writes back. Bulk-at-boot runner is eager iteration of this routine.
|
||||
- **Install decision tree** (`src/manager/import-export/install.ts`): branches on `source_schema_version` vs `CURRENT_SCHEMA_VERSION`:
|
||||
- `=`: strict validate + insert.
|
||||
- `<`: migratePayload then strict validate + insert.
|
||||
- `>` and tolerant-only diff: parseTolerantAsCurrentShape, mark `tolerant_drift_status='tolerant'`, insert.
|
||||
- `>` and breaking-future diff (per catalog `is_breaking_from_previous` AND-fold across the interval): refuse install with a clear error.
|
||||
|
||||
**Server probe**: `engine-export-wrap.ts:ENGINE_SCHEMA_VERSION` is the sister's pinned CURRENT — it ships in the sister bundle, not fetched from server. The host bridge install path reads it from `window.__wpcRuntime.schemaVersion`. Server-first deploy ordering (see community CLAUDE.md) means the community catalog always reflects shapes ≥ sister's CURRENT.
|
||||
|
||||
### Bumping `schema_version` (the proper way)
|
||||
|
||||
When a payload shape change lands (not a row-column change — those don't bump schema):
|
||||
|
||||
1. **Add the new shape's validator.** New file under `src/manager/import-export/validators/v<N>.ts` exporting a Zod schema. Register in `validators/index.ts`.
|
||||
2. **Add the migrator step.** In `src/manager/import-export/migration-machine.ts`, register `migrators[N-1 → N]: (payload) => ...`. Forward-only. The function is pure — no side effects.
|
||||
3. **Add fixtures.** `src/manager/__tests__/import-export/fixtures/v<N>/` — at minimum: a `valid-strict.json`, a `from-v<N-1>-migrated.json` round-trip case, and one `breaking-shape.json` if the bump is breaking.
|
||||
4. **Bump the constant.** `CURRENT_SCHEMA_VERSION` lives in the validator registry. Bump it to `N`. Also bump `ENGINE_SCHEMA_VERSION` in `src/manager/components/engine-export-wrap.ts`.
|
||||
5. **Write the migration test.** `tests/engine/db/test_lazy_migrate.py` should cover v<N-1>→v<N> round-trip + the breaking-future path if applicable.
|
||||
6. **Run `pytest tests/engine/ -q && pnpm test` until green.** Sister pre-commit hook (~2 min) also runs lint + typecheck + build + size; expect that wait.
|
||||
7. **Coordinate the community-side catalog row.** Community web's `schema_catalog` table gets the matching `(version, is_breaking_from_previous, notes)` row + a deploy that lands BEFORE sister's PR merges. See community CLAUDE.md "Bumping the schema" for the server side.
|
||||
8. **Sister deploys after server.** Once catalog HEAD ≥ sister's CURRENT, sister can safely refuse breaking-future shapes.
|
||||
|
||||
### Validator ↔ engine parity (DON'T skip)
|
||||
|
||||
The community TS validators in `src/validators/` are a hand-authored
|
||||
re-implementation of the engine's payload shapes, and re-implementations
|
||||
drift — fixed_values shipped for weeks as `entries/{variable_name}` when
|
||||
the engine has always used `values/{id,name,value}`. The guard against
|
||||
this:
|
||||
|
||||
- `scripts/dump_engine_shapes.py` builds one sample payload per subtype,
|
||||
validates each against that subtype's **own engine `Handler.validate_payload`**
|
||||
(the authority — raises if not engine-valid), and writes the engine-row
|
||||
shape to `src/validators/fixtures/engine-parity/*.json`.
|
||||
- `src/validators/__tests__/engine-parity.test.ts` asserts the **strict**
|
||||
community validator accepts every one of those fixtures.
|
||||
|
||||
**Any time you change an engine module payload shape** (a `*_handler.py`
|
||||
`validate_payload`, or what the editor stores), run
|
||||
`python scripts/dump_engine_shapes.py` and commit the regenerated
|
||||
fixtures. If the sample no longer validates against the handler, the
|
||||
script fails — update the sample. If the TS validator then rejects the
|
||||
new shape, the parity test fails — update the validator. Either failure
|
||||
means the two have diverged; fix it before publish breaks for a user.
|
||||
|
||||
### Row-level column changes (no schema_version bump needed)
|
||||
|
||||
The most recent example is **migration 015 (`content_rating` column)**. Adding a column to `modules` or `bundles` is just an Alembic-style SQLite migration — no payload-shape change, no validator update, no migrator chain. Procedure:
|
||||
|
||||
1. **New file** `engine/db/migrations_sql/<NNN>_<name>.py` mirroring 013/015 shape: `_has_column` gate + `with conn: conn.execute("ALTER TABLE … ADD COLUMN …")` for both `modules` and `bundles` if the field applies to both.
|
||||
2. **Update `_row_to_module` / `_row_to_bundle`** in `engine/db/repositories.py` — wrap the new column read in `try/except (IndexError, KeyError)` so pre-migration fixtures don't blow up.
|
||||
3. **Update `ModuleRepository.create/update` + `BundleRepository.create/update`** to accept the new field as a kwarg.
|
||||
4. **Update `_insert_module` / `_insert_bundle` (+ `_update_module` / `_update_bundle` if needed)** in `engine/importer.py` to pass the field through from the entity dict. Use `COALESCE(?, col)` on the UPDATE side when you want missing-in-content to preserve existing value (NOT overwrite to NULL).
|
||||
5. **Update `wp_api/modules.py` + `wp_api/bundles.py`**: add the field to `_UPDATABLE_FIELDS` + plumb into the create handlers.
|
||||
6. **TS types**: extend `ModuleRow` / `BundleRow` + `*CreateInput` / `*UpdateInput` in `src/manager/api/types.ts`.
|
||||
7. **Bump the head-migration test**: `tests/engine/db/test_migrations.py:test_migrate_records_version` asserts the head version — update to `<NNN>`. Same for the expected-columns set tests.
|
||||
8. **Write a migration test** at `tests/engine/db/test_migration_<NNN>.py` — column exists, default backfill correct, idempotent rerun, accepts the new values.
|
||||
|
||||
If the column also flows through the host-bridge install seam (community origin metadata), extend `InstallOrigin` in `src/manager/import-export/install.ts` AND the embed's `HostInstallOpts.origin` in `web/src/embed/EmbedDetail.vue` (community web repo). Map community-side naming → engine-side naming at the seam if they differ (e.g. `'sfw'` → `'safe'`).
|
||||
|
||||
## Anti-patterns (inherited from reference project `ComfyUI-Wildcard-Pipeline`)
|
||||
|
||||
- Never use V1 `NODE_CLASS_MAPPINGS` / `EXTENSION_WEB_DIRS`.
|
||||
|
||||
@@ -631,7 +631,7 @@ to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
ComfyUI-Wildcard-Pipeline — modular procedural prompt generation for ComfyUI.
|
||||
ComfyUI-WildcardPipeline — modular procedural prompt generation for ComfyUI.
|
||||
Copyright (C) 2026 DumiFlex
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
@@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
ComfyUI-Wildcard-Pipeline Copyright (C) 2026 DumiFlex
|
||||
ComfyUI-WildcardPipeline Copyright (C) 2026 DumiFlex
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
@@ -1,156 +1,36 @@
|
||||
<h1 align="center">
|
||||
<img src="public/images/favicon.svg" width="40" align="center" alt=""/>
|
||||
Wildcard Pipeline
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<em>Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, loops, and a persistent module library.</em>
|
||||
<img src="public/images/favicon.svg" width="120" alt="wildcard-pipeline logo" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/actions"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/DumiFlex/ComfyUI-Wildcard-Pipeline/ci.yml?branch=main&label=ci"></a>
|
||||
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/releases"><img alt="Release" src="https://img.shields.io/github/v/release/DumiFlex/ComfyUI-Wildcard-Pipeline?include_prereleases"></a>
|
||||
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/DumiFlex/ComfyUI-Wildcard-Pipeline"></a>
|
||||
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki"><img alt="Wiki" src="https://img.shields.io/badge/docs-wiki-blue"></a>
|
||||
<a href="https://discord.gg/BFYR9WQdVR"><img alt="Discord" src="https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
</p>
|
||||
<h1 align="center">wildcard-pipeline</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/docs/flow-intro.svg" alt="Modules resolve inside WP Context → named $variables → WP Prompt Assembler → CLIP" />
|
||||
</p>
|
||||
<p align="center"><em>Composable prompt context for ComfyUI.</em></p>
|
||||
|
||||
## What it does
|
||||
Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines.
|
||||
|
||||
Stock ComfyUI prompt nodes either take a single literal string or pick from a flat wildcard file. **Wildcard Pipeline** lets each module read what the previous modules picked — so `$mood` reacts to `$weather` via a constraint, a `combine` interpolates `$style $subject` into `$scene`, a derivation flips `$accent` based on `$mood`, and your final prompt is a coherent multi-element composition instead of a Frankenstein concatenation.
|
||||
**Status:** early development (walking skeleton).
|
||||
|
||||
**One Generate. One coherent prompt. Re-roll until you stop laughing.**
|
||||
## Install
|
||||
|
||||
## Highlights
|
||||
Clone into `ComfyUI/custom_nodes/`:
|
||||
|
||||
- **Module stack inside a Context node** — drop in `wildcard`, `fixed_values`, `combine`, `derivation`, `constraint`, `bundle` modules and they each publish a `$variable` to the chain.
|
||||
- **Constraints with carriers** — re-weight downstream wildcards based on what upstream picked. Matrix + per-option exceptions. Carrier-via-nested-ref so a constraint can reach a wildcard nested inside another wildcard's option.
|
||||
- **Loops + per-iteration seeds** — `WP Context Loop` runs your whole chain N times from a single Generate; `WP Seed List` pairs N unique sampler seeds with the N prompts so every output is distinct.
|
||||
- **Persistent library** — a manager SPA backed by SQLite stores reusable modules, frozen bundles, prompt templates, and a category tree. Browse / edit / fork / push from the canvas.
|
||||
- **Inspector** — `WP Debug` shows the post-run snapshot, per-module trace, per-wildcard picks, and the conflict-scanner warnings.
|
||||
- **Type-coercion helpers** — `WP Var → Int / Float / Bool` parse typed values out of any `$variable` so wildcards can drive image width, step count, sampler cfg, conditional switches.
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/DumiFlex/ComfyUI-WildcardPipeline.git
|
||||
```
|
||||
|
||||
## Installation
|
||||
No Python dependencies required beyond ComfyUI itself.
|
||||
|
||||
### ComfyUI Manager (recommended)
|
||||
## Nodes (MVP)
|
||||
|
||||
1. Open the **ComfyUI Manager** in your browser.
|
||||
2. Search for **Wildcard Pipeline** by **dumiflex**.
|
||||
3. Click **Install** and restart ComfyUI.
|
||||
- `WP_Context` — runs an ordered list of modules, emits a typed context.
|
||||
- `WP_PromptAssembler` — fills `$var` placeholders in a template.
|
||||
- `WP_Debug` — inspects the context at any point in the chain.
|
||||
|
||||
### Manual installation
|
||||
## Development
|
||||
|
||||
1. Navigate to your ComfyUI `custom_nodes` directory.
|
||||
2. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline
|
||||
```
|
||||
|
||||
3. Install the Python dependencies:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
4. Build the frontend assets (requires Node.js and pnpm):
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
5. Restart ComfyUI.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Open the Wildcard Pipeline manager (sidebar → **Wildcards** → **+ New**). Name `subject`, variable binding `$subject`, three options: `a cat`, `a dog`, `a fox` at weight 1 each. Save.
|
||||
2. On the canvas, drop `WP Context` → `WP Prompt Assembler` → `CLIP Text Encode` → `KSampler`. Add your wildcard to the context. Type `a $subject` into the assembler template. Queue.
|
||||
|
||||
Each Generate rolls a fresh subject. Full walkthrough: [Quick Start wiki page](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start).
|
||||
|
||||
## The nodes
|
||||
|
||||
| Node | What it does |
|
||||
|---|---|
|
||||
| `WP Context` | Holds a module stack, emits a resolved `$variable` context. |
|
||||
| `WP Context Loop` | Runs the downstream chain N times in one click; emits per-iteration `$iteration` + `$iteration_total`. |
|
||||
| `WP Context Injector` | Lifts any ComfyUI node output (multiline String, INT, etc.) into a named `$variable`. |
|
||||
| `WP Seed List` | Emits a list of N derived seeds — one per loop iteration — so every (prompt, seed) pair is unique. |
|
||||
| `WP Prompt Assembler` | Fills `$var` placeholders in a template string. Supports `{a\|b\|c}` inline picks + missing-var detection. |
|
||||
| `WP Prompt Cleaner` | Rule-based prompt cleanup: whitespace, punctuation, exact + fuzzy dedupe, blocklist. |
|
||||
| `WP Debug` | Read-only inspector — Snapshot / Trace / Picks / Warnings tabs. |
|
||||
| `WP Var → Int / Float / Bool` | Parse typed values out of any `$variable` to drive ComfyUI INT / FLOAT / BOOLEAN inputs. |
|
||||
|
||||
Full node reference: [Nodes wiki page](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Nodes).
|
||||
|
||||
## The modules
|
||||
|
||||
| Module | Writes | Role |
|
||||
|---|---|---|
|
||||
| **Wildcard** | `$<name>` | Weighted random pick from a pool. The core building block. |
|
||||
| **Fixed Values** | `$<name>` (× N) | Static `name → value` bindings — style, quality boosters, negative-prompt fragments. |
|
||||
| **Combine** | `$<name>` | Templated string that interpolates earlier `$vars` into a single output `$var`. |
|
||||
| **Derivation** | `$<name>` | IF / ELIF / ELSE rules that read picked `$vars` and rewrite others. |
|
||||
| **Constraint** | *(none)* | One-shot re-weight of a downstream wildcard's pool based on an upstream pick. |
|
||||
| **Bundle** | *(group)* | Frozen group of modules — drop into any Context as a reusable unit. |
|
||||
| **Template** | *(asm side)* | Saved Prompt Assembler template string in the library. |
|
||||
|
||||
Full module reference: [Modules wiki page](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Modules).
|
||||
|
||||
## Looping a chain — one click, N coherent prompts
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/docs/flow-loop.svg" alt="WP Context Loop + WP Seed List → KSampler — both lists fan out in lockstep" />
|
||||
</p>
|
||||
|
||||
Drop `WP Context Loop` before your `WP Context` and pair it with `WP Seed List` for distinct sampler seeds. Both lists fan out in lockstep so iteration N's prompt always pairs with seed N. Each iteration carries its own `$iteration` / `$iteration_total` for in-template labelling (`frame 1 of 4 — a fox`). See [Concepts → Seeds and loops](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Concepts#seeds-and-loops).
|
||||
|
||||
## The manager (SPA)
|
||||
|
||||
A dedicated single-page app for browsing + editing the library. Open it from the ComfyUI sidebar.
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/docs/spa_manager.png" alt="Wildcard Pipeline manager — Dashboard view with module counts, Quick Create row, and a list of recently opened library entries" />
|
||||
</p>
|
||||
|
||||
- **Library** — wildcards / fixed values / combines / derivations / constraints / bundles / templates / categories. Filter, search, bulk-tag, favorite.
|
||||
- **Test Runner** — resolve any module against the engine N times and inspect per-rule fire rates / per-option pick rates.
|
||||
- **Import / Export** — versioned JSON exports with conflict resolution (rename / overwrite / skip per row).
|
||||
- **Cascade impact dialog** — deleting a module shows you every bundle + constraint + combine that references it before you confirm.
|
||||
- **Documentation tab** — full in-app docs covering every node + module + concept, with worked examples.
|
||||
|
||||
## Inspecting a run
|
||||
|
||||
Wire a `WP Debug` node off any Context output. After Generate it fills with a tabbed snapshot of what flowed through that point:
|
||||
|
||||
- **Snapshot** — final `$variable → value` map.
|
||||
- **Trace** — per-module execution log: what each ran, what it wrote, where the seed came from.
|
||||
- **Picks** — which wildcard option was picked, its weight, its sub-category.
|
||||
- **Warnings** — runtime warnings (constraint never fired, missing variable, etc.).
|
||||
|
||||
See [Nodes → WP Debug](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Nodes#wp-debug).
|
||||
|
||||
## Documentation
|
||||
|
||||
- 📖 **[GitHub Wiki](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki)** — Home / Quick Start / Nodes / Modules / Concepts
|
||||
- 🖥️ **In-app docs** — sidebar → Documentation tab inside the manager SPA (every concept, every module, every node, with worked examples)
|
||||
- 💬 **[Discord](https://discord.gg/BFYR9WQdVR)** — chat, show-and-tell, fastest path to a response
|
||||
- 💭 **[GitHub Discussions](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/discussions)** — long-form questions, ideas, design threads
|
||||
- 🐛 **[Issue tracker](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)** — bug reports + feature requests
|
||||
|
||||
## Status
|
||||
|
||||
Active development. Versioned releases with [semantic-release](https://github.com/semantic-release/semantic-release) — every merged commit on `main` either ships a release or stays as `next` until the next breaking / feature change rolls one. Bundle-size + test gates run on every PR.
|
||||
|
||||
## Contributing
|
||||
|
||||
PRs welcome. See [CONTRIBUTING.md](./.github/CONTRIBUTING.md) for the dev loop (`pnpm install` → `pnpm dev` for the extension watcher, `pnpm test` for vitest, `pytest` for the engine, `ruff check .` for lint).
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
[GPL-3.0-or-later](./LICENSE). Free to use, modify, fork. If you ship modifications, ship the source too.
|
||||
GPL-3.0-or-later. See [LICENSE](./LICENSE).
|
||||
|
||||
+2
-20
@@ -1,4 +1,4 @@
|
||||
"""ComfyUI-Wildcard-Pipeline — modular procedural prompt generation."""
|
||||
"""ComfyUI-WildcardPipeline — modular procedural prompt generation."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
@@ -23,33 +23,15 @@ if _PKG_DIR not in sys.path:
|
||||
from comfy_api.latest import ComfyExtension
|
||||
|
||||
from wp_nodes.assembler_node import WPPromptAssembler
|
||||
from wp_nodes.context_loop import WPContextLoop
|
||||
from wp_nodes.context_node import WPContext
|
||||
from wp_nodes.debug_node import WPDebug
|
||||
from wp_nodes.injector_node import WPContextInjector
|
||||
from wp_nodes.prompt_cleaner import WPPromptCleaner
|
||||
from wp_nodes.seed_list import WPSeedList
|
||||
from wp_nodes.var_to_bool import WPVarToBool
|
||||
from wp_nodes.var_to_float import WPVarToFloat
|
||||
from wp_nodes.var_to_int import WPVarToInt
|
||||
|
||||
|
||||
class WildcardPipelineExtension(ComfyExtension):
|
||||
"""Registers all WP nodes with ComfyUI."""
|
||||
|
||||
async def get_node_list(self):
|
||||
return [
|
||||
WPContext,
|
||||
WPContextLoop,
|
||||
WPPromptAssembler,
|
||||
WPDebug,
|
||||
WPContextInjector,
|
||||
WPPromptCleaner,
|
||||
WPSeedList,
|
||||
WPVarToInt,
|
||||
WPVarToFloat,
|
||||
WPVarToBool,
|
||||
]
|
||||
return [WPContext, WPPromptAssembler, WPDebug]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> WildcardPipelineExtension:
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
// Path is relative to the directory PostCSS runs from. Vite always
|
||||
// invokes PostCSS with `cwd` = repo root (where `vite.config.mts`
|
||||
// lives), so this string resolves against the root regardless of
|
||||
// where this config file itself lives.
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: { config: "config/tailwind.config.js" },
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
+34
-39
@@ -1,46 +1,41 @@
|
||||
# WP Context
|
||||
|
||||
The pipeline's module runner. Holds an ordered list of **modules** (wildcards, fixed values, combines, derivations, constraints) plus optional **bundles** that group related modules. Each queue, the engine executes every enabled module in order, building up a context of `$variable` bindings that downstream nodes consume.
|
||||
Hold a list of named values you can use later in your prompt. Each value gets a name (like `style` or `subject`) and the value you want it to be (like `watercolor` or `a cat`). Then a **WP Prompt Assembler** can plug those values into your prompt wherever you write `$style` or `$subject`.
|
||||
|
||||
## Inputs
|
||||
## Adding values
|
||||
|
||||
- **upstream** *(optional)* — chain another WP Context to inherit its bindings. Same-name bindings in this node override the upstream.
|
||||
- **seed** — drives every random pick this run (wildcards, weighted lookups). `control_after_generate` rotates per queue. Per-row seed locks override per-instance.
|
||||
- **modules** *(widget)* — the ordered module list. Picked from the library or created inline.
|
||||
A **module** is one small group of values. Click the big purple **Add your first module** button, or **+ add module** if you already have some.
|
||||
|
||||
Each module appears as a small card. Right-click the card and pick **Edit** to:
|
||||
|
||||
- Give it a name (just a label so you remember what's inside, like "Character details")
|
||||
- Add as many `name = value` lines as you need
|
||||
- Paste a bunch of lines at once if you have them ready
|
||||
|
||||
Press **Enter** on the value field to add another row quickly. **Ctrl+Enter** saves and closes.
|
||||
|
||||
## What the icons on a card mean
|
||||
|
||||
- **☰** Drag handle — drag a card to reorder, or drag onto another Context node to move it there
|
||||
- **▾ / ▸** Show or hide the values preview
|
||||
- **Toggle (checkbox)** — turn a module off without deleting it. Useful for trying variations.
|
||||
- **✕** Remove the module. Don't worry about misclicks — an **Undo** button appears for a few seconds.
|
||||
|
||||
## Stacking multiple Contexts
|
||||
|
||||
Connect another **WP Context** node into the **upstream** input. The upstream Context's values flow through. If both define the same name, this Context wins (it's the closer one to your prompt).
|
||||
|
||||
This lets you build a library: "character" Context → "scene" Context → "style" Context → Prompt Assembler.
|
||||
|
||||
## Conflict dots
|
||||
|
||||
A card may show a small colored dot:
|
||||
|
||||
- 🔵 **Blue (info)** — You're overriding something an upstream Context already provides. Usually that's exactly what you want.
|
||||
- 🟡 **Amber (warning)** — Two of your modules in this Context use the same name. One quietly overwrites the other; check that's intentional.
|
||||
|
||||
Hover the dot to see exactly which variable.
|
||||
|
||||
## Output
|
||||
|
||||
- **context** — `PIPELINE_CONTEXT` payload carrying all bindings + run trace + warnings. Feed into WP Prompt Assembler, WP Prompt Cleaner, WP Debug, or chain another Context.
|
||||
|
||||
## Module kinds
|
||||
|
||||
| Kind | What it does |
|
||||
|---|---|
|
||||
| **wildcard** | Picks one option from a weighted list. Roll changes each queue. Supports sub-categories, nested `@{uuid}` refs, null slots. |
|
||||
| **fixed_values** | Assigns explicit `name = value` bindings — no randomness. Quick way to define static vars. |
|
||||
| **combine** | Template-fills `$vars` into a string and writes the result to a new binding. E.g. `$mood $subject` → `subject_phrase = "sleepy cat"`. |
|
||||
| **derivation** | IF/ELIF/ELSE conditional rules over the runtime context. E.g. *if `$mood == sleepy` then `$lighting = soft`*. |
|
||||
| **constraint** | Re-weights a target wildcard's options based on a source wildcard's pick. E.g. *if `$hair == bald` then exclude `$style == braided`*. First-instance: fires on the first matching target downstream, then consumed. |
|
||||
|
||||
Bundles group modules that belong together (e.g. "final framing": all the wildcards + the combine that builds the final prompt). They're reusable across workflows.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Click **+ Add module** to insert a row. Pick from the library, or create a Fixed Values entry inline.
|
||||
2. Order matters — modules run top-to-bottom. A combine that reads `$mood` must come after the wildcard that picks `$mood`.
|
||||
3. Wire the **context** output into a WP Prompt Assembler / WP Prompt Cleaner / WP Debug.
|
||||
|
||||
Drag a row by its handle to reorder. Drag onto another Context node to move it. Toggle the checkbox to disable a row without deleting it.
|
||||
|
||||
## Per-row indicators
|
||||
|
||||
- **MODIFIED** badge — the row diverges from its library entry (instance overrides applied).
|
||||
- **MISSING VAR** badge — a combine/derivation references a `$var` no upstream module provides.
|
||||
- **Conflict dots** — blue = overriding an upstream binding (info), amber = sibling collision in this Context (warning).
|
||||
- **Bundle frame** — colored border grouping related modules. Click the bundle header to collapse / toggle the whole group.
|
||||
|
||||
## Tips
|
||||
|
||||
- Variables prefixed `__` are engine-internal and hidden from the assembler.
|
||||
- Right-click a row for Edit / Duplicate / Save-to-library / Open-in-SPA.
|
||||
- Run once with WP Debug downstream to see exactly what each module produced.
|
||||
Wire **context** out into a **WP Prompt Assembler** (or another **WP Context** to chain).
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# WP Context Injector
|
||||
|
||||
Lifts arbitrary ComfyUI outputs into named `$variable` bindings on the PipelineContext. Use when you want a value from a non-WP node (LoRA loader, prompt picker, CLIP text) available as `$var` downstream.
|
||||
|
||||
## Inputs
|
||||
|
||||
- **upstream** *(optional)* — chain another WP Context / Injector.
|
||||
- **slot_0 … slot_9** — wire any ComfyUI output here. Each connected slot becomes a row.
|
||||
|
||||
## Output
|
||||
|
||||
- **context** — `PIPELINE_CONTEXT` carrying the injected bindings on top of any upstream values.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Drag wires from external nodes into the slot sockets.
|
||||
2. Each connected slot adds a row. Type the variable name (e.g. `style`).
|
||||
3. Optionally tick **internal** to hide it from the assembled prompt while keeping it readable by Combine / Derivation rules.
|
||||
4. Optionally write a template (`"I love $input_0"`) — the slot's raw value substitutes `$<slot_name>` before being written to ctx.
|
||||
|
||||
## Tips
|
||||
|
||||
- Rows reorder freely — engine reads by `slot_name`, not position.
|
||||
- Names must match `^[a-zA-Z][a-zA-Z0-9_]*$`.
|
||||
- Capped at 10 sockets. Chain a second injector if you need more.
|
||||
- Non-primitive values (LATENT/IMAGE/etc.) stringify via Python `str()` — typically used for STRING/INT/FLOAT only.
|
||||
@@ -1,33 +0,0 @@
|
||||
# WP Context Loop
|
||||
|
||||
Optional power-user node that loops a WP_Context chain N times in a single workflow run, producing N variations.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Type | Notes |
|
||||
|---|---|---|
|
||||
| `seed` | `INT` | Base seed widget. Has `control_after_generate` (randomize / fixed / increment buttons). Right-click → "Convert widget to input" to wire rgthree's Seed (or any INT source) instead. |
|
||||
| `count` | `INT` | How many iterations. 1 = no loop. |
|
||||
| `strategy` | chips | How to derive N seeds from base. `hash_index` (default) is independent per-iteration; `sequential` is `base, base+1, …`; `stride` is `base + i × 1,000,003`. |
|
||||
| `override_seed` | switch | When OFF (default): `seed` is ignored. Downstream WP_Context widget seeds drive each node; loop iteration only adds XOR variation. When ON: `seed` becomes the base; derived N times via `strategy`; replaces downstream widget seeds. |
|
||||
| `iteration var` | text | Stamps `$<name>` (0..N-1) and `$<name>_total` (N) into each iteration's context. Default `iteration`. |
|
||||
| `bypass` | switch | Skip the loop. Behaves as `count=1`. |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Type | Notes |
|
||||
|---|---|---|
|
||||
| `context` | `PIPELINE_CONTEXT` (list) | One payload per iteration. ComfyUI auto-iterates downstream nodes per item. |
|
||||
|
||||
## How it works
|
||||
|
||||
Each iteration runs the downstream chain with a different seed. Locked modules (any wildcard with a locked seed) ignore the loop — they always roll their fixed seed. Everything else varies per iteration.
|
||||
|
||||
Concrete example: `count=3`, `override_seed=ON`, `seed=42`, `strategy=sequential` → downstream WP_Context chain runs three times with chain seeds 42, 43, 44. PromptAssembler emits three prompts; KSampler renders three batches; SaveImage writes three files.
|
||||
|
||||
## Tips
|
||||
|
||||
- The `seed` field is a stock ComfyUI INT widget with `control_after_generate` — same randomize / fixed / increment buttons KSampler exposes. Right-click → "Convert widget to input" to swap it for an rgthree Seed wire when you want its UI affordances.
|
||||
- Set `override_seed=OFF` (default) to let each WP_Context's own widget seed drive that node's rolls; loop iteration still varies results via XOR with `loop_index`. Set `override_seed=ON` to take central control.
|
||||
- Combine with `$iteration` in your prompt template for variation labels: `"variation $iteration of $iteration_total: ..."`.
|
||||
- The `bypass` switch collapses the loop to a single run (`count=1`) while keeping the node wired — handy for A/B without rebuilding the graph. Native ComfyUI bypass (Ctrl-B) and mute (Ctrl-M) also work as usual.
|
||||
+13
-15
@@ -1,24 +1,22 @@
|
||||
# WP Debug
|
||||
|
||||
Inspect every variable flowing through the WP chain at this point. Terminal node — no output.
|
||||
|
||||
## Input
|
||||
|
||||
- **context** — connect to any WP node's PIPELINE_CONTEXT output.
|
||||
Drop this anywhere in your pipeline to see exactly what variables are flowing through that point. Like a window into the chain.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Drop it anywhere in the chain.
|
||||
2. Run the workflow.
|
||||
3. The body shows tabs:
|
||||
- **Snapshot** — every bound variable + value.
|
||||
- **Trace** — per-module resolution history.
|
||||
- **Picks** — what each wildcard rolled.
|
||||
- **Warnings** — unknown vars, constraints that never fired, etc.
|
||||
1. Connect the **context** input to any WP node (Context or Prompt Assembler).
|
||||
2. Run the workflow once.
|
||||
3. The viewer below shows every variable name and its value at that point in the chain.
|
||||
|
||||
Drag the bottom-right corner to resize.
|
||||
That's it. There's nothing to wire on the output side — Debug is read-only.
|
||||
|
||||
## When it's useful
|
||||
|
||||
- **"Why is `$style` showing as empty?"** Drop a Debug after each Context to see where the value gets lost or overridden.
|
||||
- **"Did my new module override the old one correctly?"** The trace shows which module set each variable.
|
||||
- **"Are upstream variables coming through the chain?"** Drop a Debug right before your Prompt Assembler to confirm.
|
||||
|
||||
## Tips
|
||||
|
||||
- Always re-runs on queue (not cached) so seed-driven values refresh.
|
||||
- Use the filter box at the top of each tab to narrow on a variable name.
|
||||
- The viewer fills the node body. Drag the bottom-right corner of the node to make it bigger if you have lots of variables.
|
||||
- Debug nodes always re-run, even when nothing visually changed. Helpful when you're cycling seeds and want to see the values shift.
|
||||
|
||||
@@ -1,44 +1,47 @@
|
||||
# WP Prompt Assembler
|
||||
|
||||
Fill-in-the-blank prompt template. Anywhere you write `$variable`, the assembler substitutes the matching value from the upstream Context.
|
||||
Write your prompt as a fill-in-the-blank template. Anywhere you put `$something`, the Assembler swaps in the matching value from the upstream Context.
|
||||
|
||||
## Inputs
|
||||
## Writing a template
|
||||
|
||||
- **context** — `PIPELINE_CONTEXT` from any WP node.
|
||||
- **template** — your prompt text. Use `$varname` to reference bound values.
|
||||
Type your prompt in the **template** box. Use `$name` to reference any variable from the connected Context.
|
||||
|
||||
**Example template:**
|
||||
```
|
||||
a portrait of $subject, $style, $mood
|
||||
```
|
||||
|
||||
If the upstream Context provides:
|
||||
- `subject = a cat`
|
||||
- `style = watercolor`
|
||||
- `mood = sleepy`
|
||||
|
||||
…the final prompt becomes:
|
||||
```
|
||||
a portrait of a cat, watercolor, sleepy
|
||||
```
|
||||
|
||||
## The helper below the template
|
||||
|
||||
**Variables** lists every value available from the upstream Context. Click any chip to insert `$name` into your template at the cursor — saves typing and avoids typos.
|
||||
|
||||
**Preview** shows what the final prompt will look like, color-coded:
|
||||
|
||||
- **Filled in (purple highlight)** — the variable is resolved, you see its actual value.
|
||||
- **Outlined chip** — the name will resolve, just briefly during loading.
|
||||
- **Amber wavy underline** — missing! Nothing upstream provides this name, so it'll render empty.
|
||||
|
||||
If your template is empty, the preview shows a placeholder instead.
|
||||
|
||||
## Special characters
|
||||
|
||||
- Use `$$` if you want a literal `$` in your prompt (e.g. `$$10` to mean "ten dollars" rather than a variable named `10`).
|
||||
- Variable names can contain letters, numbers, and underscores only. `$my_var` works; `$my-var` doesn't.
|
||||
|
||||
## Output
|
||||
|
||||
- **prompt** — the resolved STRING. Feed into CLIP encoders, WP Prompt Cleaner, etc.
|
||||
**prompt** is the final assembled string. Connect it to a **CLIP Text Encode** node (or any text input).
|
||||
|
||||
## How to use
|
||||
## Pre-flight warnings
|
||||
|
||||
1. Wire a WP Context (or chain) into the context input.
|
||||
2. Write your template:
|
||||
```
|
||||
A $style portrait of $subject, $lighting
|
||||
```
|
||||
3. Run the workflow. Each `$var` gets replaced with its current value.
|
||||
|
||||
## Helper widget
|
||||
|
||||
Below the template, the **Variables** strip lists every value available from upstream — click any chip to insert `$name` at the cursor.
|
||||
|
||||
**Preview** color-codes the resolved prompt:
|
||||
- purple highlight = resolved value
|
||||
- amber wavy underline = missing variable (will render empty)
|
||||
|
||||
## Save / Load template
|
||||
|
||||
Two toolbar buttons next to **Clear template**:
|
||||
|
||||
- **Load** — opens a library picker with search plus category / tags / favorites filters. Click a template to load it; this replaces the current template (confirms first if you've already typed something). The assembler remembers which template you loaded.
|
||||
- **Save** — opens a save dialog (name, category, tags, description, live preview). When the name matches an existing template — or you loaded one — you get two actions: **Update existing** (overwrite that library entry) and **Save as new entry** (create a fresh copy, auto-suffixed). Otherwise it's a plain **Save**. After saving, the assembler tracks that entry so the next Save defaults to updating it.
|
||||
|
||||
Any `$vars` the loaded template needs that aren't in the upstream chain show as the usual amber "missing" markers. Manage saved templates in the SPA **Templates** tab.
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `$$` for a literal `$` in your prompt.
|
||||
- Variable names: letters, numbers, underscores only.
|
||||
- Pre-flight warning toasts surface missing `$vars` at queue time.
|
||||
When you press the **Run** button, the extension scans your template for `$variables` that aren't provided anywhere upstream. If it finds any, you'll see one toast at the top of the screen listing them. The run still happens — missing variables just become empty strings — but it's a heads-up in case you have a typo.
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# WP Prompt Cleaner
|
||||
|
||||
Drops duplicate tags, strips orphan punctuation, filters blocklisted words. Operates on the prompt string alone — no context input needed.
|
||||
|
||||
## Inputs
|
||||
|
||||
- **prompt** — STRING from any source (typically a WP Prompt Assembler).
|
||||
|
||||
## Output
|
||||
|
||||
- **prompt** — the cleaned STRING.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Wire any STRING output into the prompt input.
|
||||
2. Pick a mode + intensity in the widget.
|
||||
3. Optionally toggle individual rules. Click **Blocklist…** to filter specific words.
|
||||
4. Wire the output to your CLIP encoder / preview.
|
||||
|
||||
## Mode
|
||||
|
||||
- **tags** — split input on commas. Each segment is a tag.
|
||||
- **text** — treat input as prose. Tag-only rules (dedupe, fuzzy) are disabled.
|
||||
|
||||
## Intensity presets
|
||||
|
||||
| Preset | Rules |
|
||||
|---|---|
|
||||
| gentle | whitespace |
|
||||
| balanced | whitespace, punctuation, tag dedupe |
|
||||
| aggressive | whitespace, punctuation, tag dedupe, fuzzy dedupe |
|
||||
|
||||
Blocklist auto-enables when its entries are non-empty.
|
||||
|
||||
## Blocklist
|
||||
|
||||
Click **Blocklist…** to open the editor. Two modes:
|
||||
|
||||
- **list** — comma- or newline-separated plain words. Each entry matches as a case-insensitive word-boundary substring. `cat` drops `black cat` but not `catcher`.
|
||||
- **regex** — one regex per line, compiled with `IGNORECASE`. Bad patterns are skipped (reported in run stats), so one typo won't kill the rule.
|
||||
|
||||
What gets dropped:
|
||||
|
||||
- **tags mode** — the whole comma-separated tag containing the match. Blocklist `steps` on `cfg, steps . avoid:` drops the entire `steps . avoid:` tag → `cfg`.
|
||||
- **text mode** — only the matched word is removed; adjacent orphan punctuation/space is scrubbed. Blocklist `steps` on `cfg, steps. avoid` → `cfg, avoid`.
|
||||
|
||||
Toggling the rule off via its row in RULES overrides the auto-enable, even with entries present. Toggling it on with no entries does nothing (no patterns to match).
|
||||
|
||||
## Tips
|
||||
|
||||
- Hover any control for an inline tooltip.
|
||||
- The **CUSTOM** badge lights up when your toggles diverge from the intensity preset. Click the intensity again to reset.
|
||||
- Stats next to each active rule show what it dropped on the last run.
|
||||
@@ -1,25 +0,0 @@
|
||||
# WP Var → Bool
|
||||
|
||||
Reads a `$var` from the upstream **PipelineContext** and emits a `BOOLEAN`.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Type | Notes |
|
||||
|---|---|---|
|
||||
| `context` | `PIPELINE_CONTEXT` | Required. |
|
||||
| `var_name` | dropdown | Pick from upstream `$vars`. |
|
||||
| `index` | `INT` | Which bool token in the var's value. |
|
||||
| `default` | `BOOLEAN` | Returned when no Nth match. |
|
||||
|
||||
## Parsing rule
|
||||
|
||||
Splits the var's value on `[\s,;|/]+` and checks each token against the truthy/falsy sets:
|
||||
|
||||
- **Truthy:** `true`, `yes`, `on`, `1` (case-insensitive)
|
||||
- **Falsy:** `false`, `no`, `off`, `0` (case-insensitive)
|
||||
|
||||
Tokens that match neither (e.g. `"1.5"`, `"enabled"`) are skipped — they do NOT consume an index slot.
|
||||
|
||||
- `"true false true"` index `2` → `True`
|
||||
- `"yes,no,yes"` index `1` → `False`
|
||||
- `"1.5"` index `0` → `default` (1.5 is not a bool token)
|
||||
@@ -1,19 +0,0 @@
|
||||
# WP Var → Float
|
||||
|
||||
Reads a `$var` from the upstream **PipelineContext** and emits a `FLOAT`.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Type | Notes |
|
||||
|---|---|---|
|
||||
| `context` | `PIPELINE_CONTEXT` | Required. |
|
||||
| `var_name` | dropdown | Pick from upstream `$vars`. |
|
||||
| `index` | `INT` | Which float match in the var's value. |
|
||||
| `default` | `FLOAT` | Returned when no Nth match. |
|
||||
|
||||
## Parsing rule
|
||||
|
||||
Scans for substrings matching `-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?`. Integers count — `"1920"` index `0` → `1920.0`. Scientific notation supported.
|
||||
|
||||
- `"strength: 0.85, scale: 1.5"` index `1` → `1.5`
|
||||
- `"3.2e-4"` index `0` → `0.00032`
|
||||
@@ -1,25 +0,0 @@
|
||||
# WP Var → Int
|
||||
|
||||
Reads a `$var` from the upstream **PipelineContext** and emits an `INT`.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Type | Notes |
|
||||
|---|---|---|
|
||||
| `context` | `PIPELINE_CONTEXT` | Required. Comes from a `WP Context` chain. |
|
||||
| `var_name` | dropdown | Pick from upstream `$vars`. Stored as `"$seed"`. |
|
||||
| `index` | `INT` | Which integer match in the var's value (0-based). |
|
||||
| `default` | `INT` | Returned when the var is missing or no Nth match. |
|
||||
|
||||
## Parsing rule
|
||||
|
||||
Scans the var's value for substrings matching `-?\d+`, returns the Nth match.
|
||||
|
||||
- `"1920x1080"` index `0` → `1920`
|
||||
- `"1920x1080"` index `1` → `1080`
|
||||
- `"hello"` any index → `default`
|
||||
- `""` / missing var → `default`
|
||||
|
||||
## Tip
|
||||
|
||||
For width + height in one shot, place two `WP Var → Int` nodes reading the same var with `index=0` and `index=1`.
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Module content fingerprint. Mirror of TypeScript
|
||||
`src/manager/import-export/fingerprint.ts:moduleFingerprint`.
|
||||
|
||||
Single unified helper for all 5 module types. Hashes
|
||||
[type, name, description, sorted_tags_csv, payload_hash] joined by '\\n'.
|
||||
|
||||
Pure function — no DB access, no ComfyUI imports.
|
||||
|
||||
Cross-language contract: identical djb2 output for identical input
|
||||
strings. djb2 iterates UTF-16 code units (matching JS `charCodeAt`)
|
||||
so any string — including non-BMP characters like emoji — hashes
|
||||
identically on both sides.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _djb2(s: str) -> str:
|
||||
"""djb2 hash over UTF-16 code units, returning 8 lowercase hex chars.
|
||||
|
||||
Iterates UTF-16-LE bytes in pairs to match JavaScript's
|
||||
String.prototype.charCodeAt() semantics, which iterates code units
|
||||
(not codepoints). This makes Python's hash byte-identical to the
|
||||
TypeScript implementation for any Unicode string.
|
||||
"""
|
||||
h = 5381
|
||||
utf16 = s.encode("utf-16-le")
|
||||
for i in range(0, len(utf16), 2):
|
||||
code_unit = utf16[i] | (utf16[i + 1] << 8)
|
||||
h = ((h * 33) ^ code_unit) & 0xFFFFFFFF
|
||||
return f"{h:08x}"
|
||||
|
||||
|
||||
def module_fingerprint(m: dict[str, Any]) -> str:
|
||||
"""Content fingerprint of a module row.
|
||||
|
||||
Reads keys: type, name, description, tags (list[str]), payload_hash.
|
||||
Other keys (id, category_id, is_favorite, version, etc.) are ignored
|
||||
by design — they are not content.
|
||||
"""
|
||||
parts = [
|
||||
m.get("type", ""),
|
||||
m.get("name", ""),
|
||||
m.get("description", ""),
|
||||
",".join(sorted(m.get("tags") or [])),
|
||||
m.get("payload_hash", ""),
|
||||
]
|
||||
return _djb2("\n".join(parts))
|
||||
@@ -1,801 +0,0 @@
|
||||
"""Per-mutation cleanup fixers for cascade-edit operations.
|
||||
|
||||
Each fixer handles one (kind, action) combination. All take a live
|
||||
``sqlite3.Connection`` plus target identifiers, mutate affected entities
|
||||
via repositories, and return:
|
||||
|
||||
(touched, diff)
|
||||
|
||||
* ``touched`` — list of entity row dicts captured *before* mutation
|
||||
(BEFORE-state snapshots). Passed back to the orchestrator so it can
|
||||
persist them in the undo log.
|
||||
* ``diff`` — list of lightweight index-patch dicts that describe what
|
||||
changed, keyed by ``entity_id``.
|
||||
|
||||
Contracts:
|
||||
- Fixers do NOT delete the target entity. The orchestrator handles the
|
||||
primary mutation.
|
||||
- Fixers do NOT write to ``cascade_undo``. The orchestrator persists the
|
||||
undo entry.
|
||||
- All mutations go through repository methods — never raw SQL.
|
||||
- Zero ComfyUI imports. Stdlib ``re`` + repositories only.
|
||||
|
||||
Caller contract (atomicity)
|
||||
---------------------------
|
||||
The caller (orchestrator at engine/cascade/orchestrator.py, Task 5)
|
||||
MUST wrap each fixer call in an explicit BEGIN…COMMIT/ROLLBACK
|
||||
transaction. Individual repository methods auto-commit via their
|
||||
own `with conn:` blocks; the outer transaction the orchestrator
|
||||
opens is the only rollback boundary if a fixer raises mid-loop.
|
||||
Without an outer transaction, partial mutations are persisted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from engine.db.repositories import BundleRepository, ModuleRepository
|
||||
from engine.syntax.subcat_filter import ParseError as _ParseError
|
||||
from engine.syntax.subcat_filter import parse as _parse_subcat
|
||||
from engine.syntax.subcat_filter import reads_as as _reads_as
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _deepcopy_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a deep-copied snapshot of a row dict."""
|
||||
return copy.deepcopy(row)
|
||||
|
||||
|
||||
def _rewrite_var_in_string(s: str, old_name: str, new_name: str) -> str:
|
||||
"""Replace ``$old_name`` → ``$new_name`` word-boundary-safe.
|
||||
|
||||
Uses a negative-lookbehind/lookahead so ``$mood`` matches but
|
||||
``$moodier`` does not.
|
||||
"""
|
||||
pattern = re.compile(
|
||||
r"\$" + re.escape(old_name) + r"(?![A-Za-z0-9_])"
|
||||
)
|
||||
return pattern.sub(f"${new_name}", s)
|
||||
|
||||
|
||||
def _map_tags(ast: dict[str, Any] | None, fn) -> dict[str, Any] | None:
|
||||
"""Apply *fn* to each tag leaf of a parsed sub-category expression;
|
||||
drop tags *fn* maps to ``None`` and collapse the now-childless
|
||||
operator (a one-child and/or becomes that child). Returns ``None``
|
||||
when the whole expression empties. Mirror of TS `mapTags`
|
||||
(src/manager/cascade/subcatExprCascade.ts)."""
|
||||
if ast is None:
|
||||
return None
|
||||
if "tag" in ast:
|
||||
v = fn(ast["tag"])
|
||||
return None if v is None else {"tag": v}
|
||||
if ast["op"] == "not":
|
||||
x = _map_tags(ast["x"], fn)
|
||||
return {"op": "not", "x": x} if x is not None else None
|
||||
kids = [k for k in (_map_tags(c, fn) for c in ast["kids"]) if k is not None]
|
||||
if not kids:
|
||||
return None
|
||||
return kids[0] if len(kids) == 1 else {"op": ast["op"], "kids": kids}
|
||||
|
||||
|
||||
def _expr_tags(ast: dict[str, Any] | None) -> set[str]:
|
||||
out: set[str] = set()
|
||||
|
||||
def walk(n: dict[str, Any] | None) -> None:
|
||||
if n is None:
|
||||
return
|
||||
if "tag" in n:
|
||||
out.add(n["tag"])
|
||||
return
|
||||
if n["op"] == "not":
|
||||
walk(n["x"])
|
||||
return
|
||||
for k in n["kids"]:
|
||||
walk(k)
|
||||
|
||||
walk(ast)
|
||||
return out
|
||||
|
||||
|
||||
def collect_tags(expr: str | None) -> set[str]:
|
||||
"""Distinct sub-category tags referenced by a filter *expr* string.
|
||||
|
||||
Mirror of TS ``collectTags`` (src/manager/cascade/subcatExprCascade.ts).
|
||||
Empty or malformed expressions yield the empty set — the caller simply
|
||||
won't link the ref; ``validate_expression`` surfaces real syntax errors
|
||||
elsewhere. Used by the discovery scan so a rename/delete of any one tag
|
||||
finds every ref whose boolean filter mentions it (negated tags count).
|
||||
"""
|
||||
if not expr or not expr.strip():
|
||||
return set()
|
||||
try:
|
||||
ast = _parse_subcat(expr)
|
||||
except _ParseError:
|
||||
return set()
|
||||
return _expr_tags(ast)
|
||||
|
||||
|
||||
def _ref_expr_pattern(wildcard_id: str) -> re.Pattern[str]:
|
||||
# 4-segment ref to a specific wildcard: groups = (#name, :expr, !null).
|
||||
return re.compile(
|
||||
r"@\{" + re.escape(wildcard_id)
|
||||
+ r"(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
|
||||
)
|
||||
|
||||
|
||||
def _rewrite_ref_expr(s: str, wildcard_id: str, target: str, fn) -> str:
|
||||
"""Rewrite the boolean ``:expr`` of every ``@{wildcard_id...}`` ref
|
||||
whose expression contains *target*, mapping its tags via *fn*
|
||||
(rename, or remove + collapse). Refs that don't mention *target* are
|
||||
left verbatim — no cosmetic re-serialization. ``#name`` + ``!null``
|
||||
are preserved; an expression that empties drops the ``:expr``
|
||||
segment, collapsing ``@{uuid:cold}`` to a bare ``@{uuid}``."""
|
||||
pattern = _ref_expr_pattern(wildcard_id)
|
||||
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
name, expr, null = m.group(1), m.group(2), m.group(3)
|
||||
if expr is None or not expr.strip():
|
||||
return m.group(0)
|
||||
ast = _parse_subcat(expr)
|
||||
if target not in _expr_tags(ast):
|
||||
return m.group(0)
|
||||
new_expr = _reads_as(_map_tags(ast, fn))
|
||||
out = "@{" + wildcard_id + (f"#{name}" if name else "")
|
||||
if new_expr:
|
||||
out += ":" + new_expr
|
||||
if null == "null":
|
||||
out += "!null"
|
||||
return out + "}"
|
||||
|
||||
return pattern.sub(repl, s)
|
||||
|
||||
|
||||
def _rewrite_subcat_ref_in_string(
|
||||
s: str,
|
||||
wildcard_id: str,
|
||||
old_subcat: str,
|
||||
new_subcat: str,
|
||||
) -> str:
|
||||
"""Rename ``old_subcat`` -> ``new_subcat`` inside every
|
||||
``@{wildcard_id...}`` ref's boolean expression (e.g.
|
||||
``@{u:warm or cold}`` -> ``@{u:warm or chilly}``). Structure,
|
||||
``#name``, and ``!null`` are preserved."""
|
||||
return _rewrite_ref_expr(
|
||||
s, wildcard_id, old_subcat,
|
||||
lambda t: new_subcat if t == old_subcat else t,
|
||||
)
|
||||
|
||||
|
||||
def _strip_subcat_ref_in_string(
|
||||
s: str,
|
||||
wildcard_id: str,
|
||||
subcat_name: str,
|
||||
) -> str:
|
||||
"""Remove ``subcat_name`` from every ``@{wildcard_id...}`` ref's
|
||||
expression, collapsing operators (``@{u:warm or cold}`` ->
|
||||
``@{u:warm}``); a ref whose expression empties collapses to the bare
|
||||
``@{wildcard_id}`` (the nested insertion survives, only its filter
|
||||
is dropped)."""
|
||||
return _rewrite_ref_expr(
|
||||
s, wildcard_id, subcat_name,
|
||||
lambda t: None if t == subcat_name else t,
|
||||
)
|
||||
|
||||
|
||||
def _strip_whole_ref_in_string(s: str, wildcard_id: str) -> str:
|
||||
"""Remove every whole ``@{wildcard_id...}`` token from *s*.
|
||||
|
||||
Unlike ``_strip_subcat_ref_in_string`` (which only drops the
|
||||
``:expr`` filter, collapsing to a bare ``@{uuid}``), this deletes the
|
||||
entire nested-insertion token — used by ``fix_wildcard_delete`` when
|
||||
the referenced wildcard itself is gone, so the insertion can't
|
||||
resolve at all. Surrounding text is preserved; whitespace left at the
|
||||
seam is collapsed (doubled spaces -> single) and the result trimmed,
|
||||
so ``"warm @{u} glow"`` -> ``"warm glow"`` rather than
|
||||
``"warm glow"``.
|
||||
|
||||
Reuses the shared 4-segment ref pattern (``_ref_expr_pattern``) so the
|
||||
``#name`` / ``:expr`` / ``!null`` variants of the token all match.
|
||||
"""
|
||||
pattern = _ref_expr_pattern(wildcard_id)
|
||||
stripped = pattern.sub("", s)
|
||||
if stripped == s:
|
||||
return s
|
||||
# Collapse whitespace left at the seam, then trim edges.
|
||||
return re.sub(r"[ \t]{2,}", " ", stripped).strip()
|
||||
|
||||
|
||||
def _rewrite_ref_name_in_string(
|
||||
s: str,
|
||||
wildcard_id: str,
|
||||
new_name: str,
|
||||
) -> str:
|
||||
"""Rewrite every ``@{wildcard_id...}`` ref in *s* so its ``#name``
|
||||
segment matches *new_name*. Bare-uuid refs gain the segment;
|
||||
refs that already carry an old name get it replaced. Subcat
|
||||
suffix is preserved.
|
||||
|
||||
Empty *new_name* drops the segment entirely (canonical bare-uuid
|
||||
form), which lets the same fixer handle "name was cleared" later
|
||||
without a separate code path."""
|
||||
pattern = re.compile(
|
||||
r"@\{" + re.escape(wildcard_id)
|
||||
+ r"(?:#[^#:}@{]*)?(:[^}]*)?\}"
|
||||
)
|
||||
name_seg = f"#{new_name}" if new_name else ""
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
sub_seg = m.group(1) or ""
|
||||
return f"@{{{wildcard_id}{name_seg}{sub_seg}}}"
|
||||
return pattern.sub(repl, s)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public fixers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fix_wildcard_delete(
|
||||
conn: sqlite3.Connection,
|
||||
wildcard_id: str,
|
||||
cleanup_ids: list[str],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Opt-in strip of nested ``@{wildcard_id...}`` refs to a deleted wildcard.
|
||||
|
||||
Constraints are never DELETED here. They reference the wildcard by id
|
||||
only and are healable via the reattach UI — the cascade leaves them
|
||||
broken so the user can re-point them later, rather than deleting them
|
||||
(the old behavior, removed). The ONE constraint mutation that does run
|
||||
is a display-only name stamp: the deleted wildcard's name is cached onto
|
||||
every dependent constraint's ``source_wildcard_name`` /
|
||||
``target_wildcard_name`` (the last moment the live name is known), so the
|
||||
broken-reference banner shows "(was …)" instead of a bare uuid. The
|
||||
id/matrix/exceptions stay untouched. The stamp is in `touched` (undo) but
|
||||
not in `diff` (it's not a ref removal). It runs regardless of cleanup_ids.
|
||||
|
||||
Nested ``@{wildcard_id...}`` refs (the deleted wildcard inserted
|
||||
inside ANOTHER wildcard's option value, or a derivation action's
|
||||
string value) are stripped ONLY for the specific entity ids the
|
||||
caller lists in *cleanup_ids*. Every entity not in *cleanup_ids* is
|
||||
left untouched — its broken ref survives and is healed later via a
|
||||
remap. An empty/absent *cleanup_ids* therefore strips nothing: only
|
||||
the wildcard row itself is dropped (by the orchestrator), and all
|
||||
constraints + nested refs survive.
|
||||
|
||||
For each entity in *cleanup_ids*:
|
||||
* ``wildcard`` — every ``@{wildcard_id...}`` token is removed from
|
||||
each ``options[].value`` string (whole token, surrounding text
|
||||
preserved).
|
||||
* ``derivation`` — same strip applied to every string value inside
|
||||
``rules[].branches[].actions[]`` (mirrors ``_scan_wildcard_delete``).
|
||||
|
||||
Bundles are intentionally NOT touched (unchanged): bundle children
|
||||
are full frozen snapshots, so the bundle keeps resolving with its
|
||||
captured payload even when the source library row is deleted.
|
||||
|
||||
The fixer does NOT delete the wildcard row itself; the orchestrator
|
||||
owns that mutation.
|
||||
"""
|
||||
touched: list[dict[str, Any]] = []
|
||||
diff: list[dict[str, Any]] = []
|
||||
|
||||
mod_repo = ModuleRepository(conn)
|
||||
|
||||
# Stamp the about-to-be-deleted wildcard's name onto every dependent
|
||||
# constraint's source/target name cache, BEFORE the orchestrator drops the
|
||||
# row. This is the last moment the live name is known; without it a
|
||||
# constraint broken by the deletion shows only a bare uuid in the reattach
|
||||
# banner ("Source wildcard 1e9c0e2c is not in your library"). Display-only:
|
||||
# the id/matrix/exceptions are untouched and the constraint is never deleted
|
||||
# (still healable via reattach). Recorded in `touched` for undo but NOT
|
||||
# emitted as a `diff` entry — it's not a ref removal, so it must not inflate
|
||||
# affected_count. Runs regardless of cleanup_ids (the empty-cleanup
|
||||
# early-return is below).
|
||||
target = mod_repo.get(wildcard_id)
|
||||
wc_name = target.get("name") if target else None
|
||||
if wc_name:
|
||||
for c in mod_repo.list():
|
||||
if c["type"] != "constraint":
|
||||
continue
|
||||
cp = c.get("payload") or {}
|
||||
new_cp = copy.deepcopy(cp)
|
||||
stamped = False
|
||||
if cp.get("source_wildcard_id") == wildcard_id:
|
||||
if cp.get("source_wildcard_name") != wc_name:
|
||||
new_cp["source_wildcard_name"] = wc_name
|
||||
stamped = True
|
||||
if cp.get("target_wildcard_id") == wildcard_id:
|
||||
if cp.get("target_wildcard_name") != wc_name:
|
||||
new_cp["target_wildcard_name"] = wc_name
|
||||
stamped = True
|
||||
if stamped:
|
||||
touched.append(_deepcopy_row(c))
|
||||
mod_repo.update(c["id"], payload=new_cp)
|
||||
|
||||
cleanup = set(cleanup_ids)
|
||||
if not cleanup:
|
||||
return touched, diff
|
||||
|
||||
for m in mod_repo.list():
|
||||
if m["id"] not in cleanup:
|
||||
continue
|
||||
t = m["type"]
|
||||
p = m.get("payload") or {}
|
||||
new_payload = copy.deepcopy(p)
|
||||
changed = False
|
||||
|
||||
if t == "wildcard":
|
||||
for opt in new_payload.get("options") or []:
|
||||
v = opt.get("value")
|
||||
if isinstance(v, str):
|
||||
new_v = _strip_whole_ref_in_string(v, wildcard_id)
|
||||
if new_v != v:
|
||||
opt["value"] = new_v
|
||||
changed = True
|
||||
|
||||
elif t == "derivation":
|
||||
for rule in new_payload.get("rules") or []:
|
||||
for branch in rule.get("branches") or []:
|
||||
for action in branch.get("actions") or []:
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
for k, v in list(action.items()):
|
||||
if isinstance(v, str):
|
||||
new_v = _strip_whole_ref_in_string(v, wildcard_id)
|
||||
if new_v != v:
|
||||
action[k] = new_v
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
touched.append(_deepcopy_row(m))
|
||||
mod_repo.update(m["id"], payload=new_payload)
|
||||
diff.append({
|
||||
"entity_id": m["id"],
|
||||
"remove_ref": {"kind": "wildcard", "wildcard_id": wildcard_id},
|
||||
})
|
||||
|
||||
return touched, diff
|
||||
|
||||
|
||||
def fix_subcat_delete(
|
||||
conn: sqlite3.Connection,
|
||||
wildcard_id: str,
|
||||
subcat_name: str,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Strip all references to ``wildcard_id:subcat_name``.
|
||||
|
||||
* Source wildcard's ``options[].sub_categories`` — removes the name.
|
||||
* Constraint matrix keys (source-side) — removes the top-level key.
|
||||
* Constraint matrix values (target-side) — removes nested keys.
|
||||
* Other wildcards' option values — removes ``@{wildcard_id:subcat_name}``.
|
||||
"""
|
||||
touched: list[dict[str, Any]] = []
|
||||
diff: list[dict[str, Any]] = []
|
||||
|
||||
repo = ModuleRepository(conn)
|
||||
|
||||
for m in repo.list():
|
||||
t = m["type"]
|
||||
p = m.get("payload") or {}
|
||||
changed = False
|
||||
new_payload = copy.deepcopy(p)
|
||||
|
||||
if t == "wildcard" and m["id"] == wildcard_id:
|
||||
# Top-level declared list
|
||||
declared = new_payload.get("sub_categories") or []
|
||||
if subcat_name in declared:
|
||||
new_payload["sub_categories"] = [
|
||||
s for s in declared if s != subcat_name
|
||||
]
|
||||
changed = True
|
||||
# Per-option membership — drop the deleted subcat from each
|
||||
# option's sub_categories list (preserves the option).
|
||||
for opt in new_payload.get("options") or []:
|
||||
subs = opt.get("sub_categories")
|
||||
if isinstance(subs, list) and subcat_name in subs:
|
||||
opt["sub_categories"] = [s for s in subs if s != subcat_name]
|
||||
changed = True
|
||||
|
||||
elif t == "constraint":
|
||||
matrix = new_payload.get("matrix") or {}
|
||||
|
||||
# Source-side: top-level matrix keys are source subcat names
|
||||
if (new_payload.get("source_wildcard_id") == wildcard_id
|
||||
and subcat_name in matrix):
|
||||
del matrix[subcat_name]
|
||||
new_payload["matrix"] = matrix
|
||||
changed = True
|
||||
|
||||
# Target-side: nested keys within each row value
|
||||
if new_payload.get("target_wildcard_id") == wildcard_id:
|
||||
for _row_key, row_val in list(matrix.items()):
|
||||
if isinstance(row_val, dict) and subcat_name in row_val:
|
||||
del row_val[subcat_name]
|
||||
changed = True
|
||||
|
||||
elif t == "wildcard" and m["id"] != wildcard_id:
|
||||
# Strip @{wildcard_id:subcat_name} text refs from option values
|
||||
for opt in new_payload.get("options") or []:
|
||||
v = opt.get("value")
|
||||
if isinstance(v, str):
|
||||
new_v = _strip_subcat_ref_in_string(v, wildcard_id, subcat_name)
|
||||
if new_v != v:
|
||||
opt["value"] = new_v
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
touched.append(_deepcopy_row(m))
|
||||
repo.update(m["id"], payload=new_payload)
|
||||
diff.append({
|
||||
"entity_id": m["id"],
|
||||
"remove_ref": {"kind": "subcat", "wildcard_id": wildcard_id, "subcat": subcat_name},
|
||||
})
|
||||
|
||||
return touched, diff
|
||||
|
||||
|
||||
def fix_subcat_rename(
|
||||
conn: sqlite3.Connection,
|
||||
wildcard_id: str,
|
||||
old_name: str,
|
||||
new_name: str,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Rewrite all references to ``wildcard_id:old_name`` → ``wildcard_id:new_name``.
|
||||
|
||||
* Source wildcard's ``options[].sub_categories``.
|
||||
* Constraint matrix keys (source-side) — renamed top-level key.
|
||||
* Constraint matrix values (target-side) — renamed nested keys.
|
||||
* Other wildcards' option values — ``@{uuid:old}`` → ``@{uuid:new}``.
|
||||
"""
|
||||
touched: list[dict[str, Any]] = []
|
||||
diff: list[dict[str, Any]] = []
|
||||
|
||||
repo = ModuleRepository(conn)
|
||||
|
||||
for m in repo.list():
|
||||
t = m["type"]
|
||||
p = m.get("payload") or {}
|
||||
changed = False
|
||||
new_payload = copy.deepcopy(p)
|
||||
|
||||
if t == "wildcard" and m["id"] == wildcard_id:
|
||||
# Top-level declared sub_categories list
|
||||
declared = new_payload.get("sub_categories") or []
|
||||
if old_name in declared:
|
||||
new_payload["sub_categories"] = [
|
||||
new_name if s == old_name else s for s in declared
|
||||
]
|
||||
changed = True
|
||||
# Per-option membership list (`opt.sub_categories`)
|
||||
for opt in new_payload.get("options") or []:
|
||||
subs = opt.get("sub_categories")
|
||||
if isinstance(subs, list) and old_name in subs:
|
||||
opt["sub_categories"] = [
|
||||
new_name if s == old_name else s for s in subs
|
||||
]
|
||||
changed = True
|
||||
|
||||
elif t == "constraint":
|
||||
matrix = new_payload.get("matrix") or {}
|
||||
|
||||
# Source-side: rename top-level matrix key
|
||||
if (new_payload.get("source_wildcard_id") == wildcard_id
|
||||
and old_name in matrix):
|
||||
matrix[new_name] = matrix.pop(old_name)
|
||||
new_payload["matrix"] = matrix
|
||||
changed = True
|
||||
|
||||
# Target-side: rename nested keys in each row
|
||||
if new_payload.get("target_wildcard_id") == wildcard_id:
|
||||
for row_val in matrix.values():
|
||||
if isinstance(row_val, dict) and old_name in row_val:
|
||||
row_val[new_name] = row_val.pop(old_name)
|
||||
changed = True
|
||||
|
||||
elif t == "wildcard" and m["id"] != wildcard_id:
|
||||
# Rewrite @{wildcard_id:old_name} → @{wildcard_id:new_name}
|
||||
for opt in new_payload.get("options") or []:
|
||||
v = opt.get("value")
|
||||
if isinstance(v, str):
|
||||
new_v = _rewrite_subcat_ref_in_string(v, wildcard_id, old_name, new_name)
|
||||
if new_v != v:
|
||||
opt["value"] = new_v
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
touched.append(_deepcopy_row(m))
|
||||
repo.update(m["id"], payload=new_payload)
|
||||
diff.append({
|
||||
"entity_id": m["id"],
|
||||
"rename_ref": {
|
||||
"kind": "subcat",
|
||||
"wildcard_id": wildcard_id,
|
||||
"old": old_name,
|
||||
"new": new_name,
|
||||
},
|
||||
})
|
||||
|
||||
return touched, diff
|
||||
|
||||
|
||||
def fix_wildcard_rename_name(
|
||||
conn: sqlite3.Connection,
|
||||
wildcard_id: str,
|
||||
new_name: str,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Rewrite every ``@{wildcard_id...}`` ref so its ``#name`` segment
|
||||
matches *new_name*. Walks the same surfaces ``fix_wildcard_delete``
|
||||
+ ``fix_subcat_rename`` cover for ref strings:
|
||||
|
||||
* Other wildcards' option values.
|
||||
* Derivation rules' action string values (the only surface besides
|
||||
wildcards where ``@{}`` text refs are resolved).
|
||||
|
||||
Combine templates, fixed-values, and constraint payloads don't hold
|
||||
``@{}`` text refs (constraints reference by id only via
|
||||
``source_wildcard_id`` / ``target_wildcard_id``), so they're
|
||||
skipped. Bundle children are frozen point-in-time snapshots and are
|
||||
intentionally NOT rewritten (mirrors ``fix_wildcard_delete``): a rename
|
||||
leaves the bundle's cached ``#name`` / refs stale until the user
|
||||
explicitly refreshes the bundle. Otherwise editing ANY module would
|
||||
silently mutate every bundle holding a snapshot of it — a bundle must
|
||||
stay a fixed snapshot, not auto-track its source modules.
|
||||
|
||||
Skips the renamed wildcard itself — its own name lives on the row,
|
||||
not in its own option values.
|
||||
"""
|
||||
touched: list[dict[str, Any]] = []
|
||||
diff: list[dict[str, Any]] = []
|
||||
|
||||
mod_repo = ModuleRepository(conn)
|
||||
|
||||
for m in mod_repo.list():
|
||||
if m["id"] == wildcard_id:
|
||||
continue
|
||||
t = m["type"]
|
||||
p = m.get("payload") or {}
|
||||
new_payload = copy.deepcopy(p)
|
||||
changed = False
|
||||
|
||||
if t == "wildcard":
|
||||
for opt in new_payload.get("options") or []:
|
||||
v = opt.get("value")
|
||||
if isinstance(v, str):
|
||||
new_v = _rewrite_ref_name_in_string(v, wildcard_id, new_name)
|
||||
if new_v != v:
|
||||
opt["value"] = new_v
|
||||
changed = True
|
||||
|
||||
elif t == "derivation":
|
||||
for rule in new_payload.get("rules") or []:
|
||||
for branch in rule.get("branches") or []:
|
||||
for action in branch.get("actions") or []:
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
for k, v in list(action.items()):
|
||||
if isinstance(v, str):
|
||||
new_v = _rewrite_ref_name_in_string(v, wildcard_id, new_name)
|
||||
if new_v != v:
|
||||
action[k] = new_v
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
touched.append(_deepcopy_row(m))
|
||||
mod_repo.update(m["id"], payload=new_payload)
|
||||
diff.append({
|
||||
"entity_id": m["id"],
|
||||
"rename_ref": {
|
||||
"kind": "wildcard_name",
|
||||
"wildcard_id": wildcard_id,
|
||||
"new": new_name,
|
||||
},
|
||||
})
|
||||
|
||||
# Bundle children are frozen snapshots — intentionally NOT rewritten
|
||||
# here (see docstring). They drift; the explicit bundle-refresh flow
|
||||
# re-snapshots them on demand, so a module edit never auto-mutates a
|
||||
# bundle.
|
||||
return touched, diff
|
||||
|
||||
|
||||
def fix_bundle_delete(
|
||||
conn: sqlite3.Connection,
|
||||
bundle_id: str,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Strip every ``{type: "bundle", id: bundle_id}`` ref from other
|
||||
bundles' ``children[]`` lists. Mirrors `fix_wildcard_delete` for
|
||||
bundles — when a bundle is removed, parent bundles holding it as
|
||||
a tier-2 ref are cleaned so they don't ship `_missing_ref` on
|
||||
their next GET expansion.
|
||||
|
||||
The fixer does NOT delete the bundle row itself; the orchestrator
|
||||
owns that mutation. Skips self-reference (tier-2 cap forbids it,
|
||||
defensive).
|
||||
"""
|
||||
touched: list[dict[str, Any]] = []
|
||||
diff: list[dict[str, Any]] = []
|
||||
bundle_repo = BundleRepository(conn)
|
||||
|
||||
for b in bundle_repo.list():
|
||||
if b["id"] == bundle_id:
|
||||
continue
|
||||
children = b.get("children") or []
|
||||
new_children = [
|
||||
ch for ch in children
|
||||
if not (ch.get("type") == "bundle" and ch.get("id") == bundle_id)
|
||||
]
|
||||
if len(new_children) != len(children):
|
||||
touched.append(_deepcopy_row(b))
|
||||
bundle_repo.update(b["id"], children=new_children)
|
||||
diff.append({
|
||||
"entity_id": b["id"],
|
||||
"remove_ref": {"kind": "bundle", "id": bundle_id},
|
||||
})
|
||||
|
||||
return touched, diff
|
||||
|
||||
|
||||
def fix_combine_output_var_rename(
|
||||
conn: sqlite3.Connection,
|
||||
combine_id: str,
|
||||
old_name: str,
|
||||
new_name: str,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Rename a combine's output variable and all ``$old_name`` references.
|
||||
|
||||
* Combine's ``output_var`` field updated to *new_name*.
|
||||
* Other wildcards: ``$old_name`` → ``$new_name`` in option values.
|
||||
* Derivations: ``$old_name`` → ``$new_name`` in action string values;
|
||||
``set_var: "old_name"`` → ``set_var: "new_name"``.
|
||||
* Other combines: ``$old_name`` → ``$new_name`` in template.
|
||||
"""
|
||||
touched: list[dict[str, Any]] = []
|
||||
diff: list[dict[str, Any]] = []
|
||||
|
||||
repo = ModuleRepository(conn)
|
||||
|
||||
for m in repo.list():
|
||||
t = m["type"]
|
||||
p = m.get("payload") or {}
|
||||
changed = False
|
||||
new_payload = copy.deepcopy(p)
|
||||
|
||||
if t == "combine" and m["id"] == combine_id:
|
||||
# Update the combine's own output_var
|
||||
if new_payload.get("output_var") == old_name:
|
||||
new_payload["output_var"] = new_name
|
||||
changed = True
|
||||
# Also rewrite $old in its own template in case it self-references
|
||||
tpl = new_payload.get("template", "")
|
||||
if isinstance(tpl, str):
|
||||
new_tpl = _rewrite_var_in_string(tpl, old_name, new_name)
|
||||
if new_tpl != tpl:
|
||||
new_payload["template"] = new_tpl
|
||||
changed = True
|
||||
|
||||
elif t == "wildcard":
|
||||
for opt in new_payload.get("options") or []:
|
||||
v = opt.get("value")
|
||||
if isinstance(v, str):
|
||||
new_v = _rewrite_var_in_string(v, old_name, new_name)
|
||||
if new_v != v:
|
||||
opt["value"] = new_v
|
||||
changed = True
|
||||
|
||||
elif t == "derivation":
|
||||
for rule in new_payload.get("rules") or []:
|
||||
for branch in rule.get("branches") or []:
|
||||
for action in branch.get("actions") or []:
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
for k in list(action.keys()):
|
||||
v = action[k]
|
||||
if k == "set_var" and v == old_name:
|
||||
action[k] = new_name
|
||||
changed = True
|
||||
elif isinstance(v, str):
|
||||
new_v = _rewrite_var_in_string(v, old_name, new_name)
|
||||
if new_v != v:
|
||||
action[k] = new_v
|
||||
changed = True
|
||||
|
||||
elif t == "combine" and m["id"] != combine_id:
|
||||
tpl = new_payload.get("template", "")
|
||||
if isinstance(tpl, str):
|
||||
new_tpl = _rewrite_var_in_string(tpl, old_name, new_name)
|
||||
if new_tpl != tpl:
|
||||
new_payload["template"] = new_tpl
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
touched.append(_deepcopy_row(m))
|
||||
repo.update(m["id"], payload=new_payload)
|
||||
diff.append({
|
||||
"entity_id": m["id"],
|
||||
"rename_ref": {"kind": "var", "old": old_name, "new": new_name},
|
||||
})
|
||||
|
||||
return touched, diff
|
||||
|
||||
|
||||
def fix_option_delete(
|
||||
conn: sqlite3.Connection,
|
||||
wildcard_id: str,
|
||||
option_id: str,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Remove option from source wildcard + scrub it from constraint exceptions.
|
||||
|
||||
Standard fixer contract: returns (touched_snapshots, diff). The
|
||||
orchestrator's outer ``with conn:`` is the rollback boundary; this
|
||||
function neither opens nor commits a transaction.
|
||||
|
||||
Matching is by stable ``option_id`` so a value-rename (which keeps
|
||||
the id) does not silently delete the wrong option.
|
||||
"""
|
||||
touched: list[dict[str, Any]] = []
|
||||
diff: list[dict[str, Any]] = []
|
||||
repo = ModuleRepository(conn)
|
||||
|
||||
# --- Source wildcard: remove option entry --------------------------
|
||||
src = repo.get(wildcard_id)
|
||||
new_payload = copy.deepcopy(src.get("payload") or {})
|
||||
original_opts = list(new_payload.get("options") or [])
|
||||
new_options = [o for o in original_opts if o.get("id") != option_id]
|
||||
if len(new_options) == len(original_opts):
|
||||
# Option already missing; nothing to do.
|
||||
return touched, diff
|
||||
|
||||
touched.append(_deepcopy_row(src))
|
||||
new_payload["options"] = new_options
|
||||
repo.update(wildcard_id, payload=new_payload)
|
||||
diff.append({"entity_id": wildcard_id, "remove_option": option_id})
|
||||
|
||||
# --- Constraints: drop exceptions referencing option_id ------------
|
||||
for m in repo.list():
|
||||
if m["type"] != "constraint":
|
||||
continue
|
||||
p = m.get("payload") or {}
|
||||
exceptions = p.get("exceptions") or []
|
||||
kept = [
|
||||
e for e in exceptions
|
||||
if e.get("source_id") != option_id and e.get("target_id") != option_id
|
||||
]
|
||||
if len(kept) == len(exceptions):
|
||||
continue
|
||||
touched.append(_deepcopy_row(m))
|
||||
new_p = copy.deepcopy(p)
|
||||
new_p["exceptions"] = kept
|
||||
repo.update(m["id"], payload=new_p)
|
||||
diff.append({
|
||||
"entity_id": m["id"],
|
||||
"remove_ref": {"kind": "option", "option_id": option_id},
|
||||
})
|
||||
|
||||
return touched, diff
|
||||
|
||||
|
||||
def fix_category_delete(
|
||||
conn: sqlite3.Connection,
|
||||
category_id: str,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Null out ``category_id`` on every module referencing *category_id*.
|
||||
|
||||
Does NOT delete the category row itself; the orchestrator handles that.
|
||||
"""
|
||||
touched: list[dict[str, Any]] = []
|
||||
diff: list[dict[str, Any]] = []
|
||||
|
||||
repo = ModuleRepository(conn)
|
||||
|
||||
for m in repo.list():
|
||||
if m.get("category_id") == category_id:
|
||||
touched.append(_deepcopy_row(m))
|
||||
repo.update(m["id"], category_id=None)
|
||||
diff.append({
|
||||
"entity_id": m["id"],
|
||||
"remove_ref": {"kind": "category", "id": category_id},
|
||||
})
|
||||
|
||||
return touched, diff
|
||||
@@ -1,272 +0,0 @@
|
||||
"""End-to-end cascade-edit orchestrator.
|
||||
|
||||
apply_cascade dispatches scan + fixer + target-delete + undo-write in a
|
||||
single SQLite transaction. The `with conn:` block is the rollback boundary
|
||||
described in the fixers.py atomicity contract.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from engine.cascade.fixers import (
|
||||
fix_bundle_delete,
|
||||
fix_category_delete,
|
||||
fix_combine_output_var_rename,
|
||||
fix_option_delete,
|
||||
fix_subcat_delete,
|
||||
fix_subcat_rename,
|
||||
fix_wildcard_delete,
|
||||
)
|
||||
from engine.cascade.scan import scan_affected
|
||||
from engine.cascade.undo import write_undo_entry
|
||||
from engine.db.repositories import (
|
||||
BundleRepository,
|
||||
CategoryRepository,
|
||||
ModuleRepository,
|
||||
)
|
||||
|
||||
|
||||
def _delete_target(conn: sqlite3.Connection, kind: str, target_id: str) -> dict[str, Any]:
|
||||
"""Delete the primary target entity and return its BEFORE-snapshot.
|
||||
|
||||
Raises XNotFound (e.g. ModuleNotFound, CategoryNotFound) if the target
|
||||
does not exist — propagates to the caller's try/except error envelope.
|
||||
"""
|
||||
if kind == "bundle":
|
||||
repo: Any = BundleRepository(conn)
|
||||
elif kind == "category":
|
||||
repo = CategoryRepository(conn)
|
||||
else:
|
||||
repo = ModuleRepository(conn)
|
||||
snapshot = repo.get(target_id) # raises XNotFound if missing → caller catches
|
||||
repo.delete(target_id)
|
||||
return snapshot
|
||||
|
||||
|
||||
def _rename_subcat_target_only(
|
||||
conn: sqlite3.Connection,
|
||||
wildcard_id: str,
|
||||
old_name: str,
|
||||
new_name: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Rename a subcat inside the source wildcard ONLY (no cascade to refs).
|
||||
|
||||
Touches both the top-level `payload.sub_categories` declared list and
|
||||
each option's `sub_categories` membership list (SP1 multi-tag shape).
|
||||
"""
|
||||
mod = ModuleRepository(conn)
|
||||
wc = mod.get(wildcard_id)
|
||||
snapshot = dict(wc) # capture BEFORE
|
||||
payload = dict(wc.get("payload") or {})
|
||||
|
||||
declared = payload.get("sub_categories") or []
|
||||
if old_name in declared:
|
||||
payload["sub_categories"] = [
|
||||
new_name if s == old_name else s for s in declared
|
||||
]
|
||||
|
||||
new_options = []
|
||||
for opt in payload.get("options") or []:
|
||||
new_opt = dict(opt)
|
||||
subs = new_opt.get("sub_categories")
|
||||
if isinstance(subs, list) and old_name in subs:
|
||||
new_opt["sub_categories"] = [
|
||||
new_name if s == old_name else s for s in subs
|
||||
]
|
||||
new_options.append(new_opt)
|
||||
payload["options"] = new_options
|
||||
|
||||
mod.update(wildcard_id, payload=payload)
|
||||
return snapshot
|
||||
|
||||
|
||||
def apply_cascade(conn: sqlite3.Connection, req: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Dispatch scan + fixer + target-delete + undo-write atomically.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
conn:
|
||||
Open, migrated sqlite3.Connection.
|
||||
req:
|
||||
Operation descriptor dict — see module docstring for full shape.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Response envelope. Always has ``ok: bool``.
|
||||
On error: ``{ok: False, error: str}``.
|
||||
On dry_run: ``{ok: True, affected_count: int, affected_entities: list}``.
|
||||
On commit: ``{ok: True, undo_entry_id: str, affected_count: int,
|
||||
affected_entities: list, diff: list}``.
|
||||
On opt-out rename: ``{ok: True, undo_entry_id: str, affected_count: 0,
|
||||
broken_refs: list}``.
|
||||
"""
|
||||
kind = req.get("kind")
|
||||
target_id = req.get("id")
|
||||
action = req.get("action")
|
||||
cascade_refs = req.get("cascade_refs", True)
|
||||
dry_run = req.get("dry_run", False)
|
||||
new_name = req.get("new_name")
|
||||
extra = req.get("extra") or {}
|
||||
|
||||
# --- Validate required fields -------------------------------------------
|
||||
if not kind:
|
||||
return {"ok": False, "error": "kind required"}
|
||||
if not target_id:
|
||||
return {"ok": False, "error": "id required"}
|
||||
if not action:
|
||||
return {"ok": False, "error": "action required"}
|
||||
|
||||
# --- Scan (read-only, safe outside transaction) --------------------------
|
||||
affected = scan_affected(conn, kind=kind, id=target_id, action=action, extra=extra)
|
||||
|
||||
if dry_run:
|
||||
return {
|
||||
"ok": True,
|
||||
"affected_count": len(affected),
|
||||
"affected_entities": affected,
|
||||
}
|
||||
|
||||
# --- Opt-out rename: snapshot + rename target only, write undo ----------
|
||||
if action == "rename" and cascade_refs is False:
|
||||
if not new_name:
|
||||
return {"ok": False, "error": "new_name required for rename"}
|
||||
try:
|
||||
with conn:
|
||||
snapshot_before: list[dict[str, Any]] = []
|
||||
if kind == "subcategory":
|
||||
old_name = extra.get("subcat_name", "")
|
||||
snap = _rename_subcat_target_only(conn, target_id, old_name, new_name)
|
||||
if snap is not None:
|
||||
snapshot_before.append(snap)
|
||||
elif kind == "combine_output_var":
|
||||
# Update the combine's output_var field only (not its name).
|
||||
mod = ModuleRepository(conn)
|
||||
cb = mod.get(target_id)
|
||||
snapshot_before.append(cb)
|
||||
new_payload = {**(cb.get("payload") or {}), "output_var": new_name}
|
||||
mod.update(target_id, payload=new_payload)
|
||||
else:
|
||||
# Generic module rename: snapshot then update name.
|
||||
repo = ModuleRepository(conn)
|
||||
before = repo.get(target_id)
|
||||
snapshot_before.append(before)
|
||||
repo.update(target_id, name=new_name)
|
||||
undo_id = write_undo_entry(
|
||||
conn,
|
||||
target_kind=kind,
|
||||
target_id=target_id,
|
||||
action=action,
|
||||
snapshot_before=snapshot_before,
|
||||
snapshot_after=[],
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"undo_entry_id": undo_id,
|
||||
"affected_count": 0,
|
||||
"broken_refs": affected,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
# --- Cascade-on path: dispatch fixer + target delete + undo -------------
|
||||
# Validate rename operands before opening the transaction.
|
||||
if action == "rename" and kind in ("subcategory", "combine_output_var") and not new_name:
|
||||
return {"ok": False, "error": "new_name required for rename"}
|
||||
|
||||
try:
|
||||
with conn:
|
||||
touched_before: list[dict[str, Any]] = []
|
||||
diff: list[dict[str, Any]] = []
|
||||
key = (kind, action)
|
||||
|
||||
if key == ("wildcard", "delete"):
|
||||
# Opt-in nested-ref cleanup: only entities the caller
|
||||
# lists get their @{wildcard_id...} token stripped.
|
||||
# Constraints are never deleted (healed via reattach UI);
|
||||
# an empty list strips nothing.
|
||||
cleanup_ids = req.get("cleanup_ids") or []
|
||||
touched_before, diff = fix_wildcard_delete(conn, target_id, cleanup_ids)
|
||||
elif key == ("subcategory", "delete"):
|
||||
touched_before, diff = fix_subcat_delete(
|
||||
conn, target_id, extra.get("subcat_name", "")
|
||||
)
|
||||
elif key == ("subcategory", "rename"):
|
||||
touched_before, diff = fix_subcat_rename(
|
||||
conn, target_id, extra.get("subcat_name", ""), new_name, # type: ignore[arg-type]
|
||||
)
|
||||
elif key == ("combine_output_var", "rename"):
|
||||
touched_before, diff = fix_combine_output_var_rename(
|
||||
conn, target_id, extra.get("old_name", ""), new_name, # type: ignore[arg-type]
|
||||
)
|
||||
elif key == ("category", "delete"):
|
||||
touched_before, diff = fix_category_delete(conn, target_id)
|
||||
elif key == ("bundle", "delete"):
|
||||
touched_before, diff = fix_bundle_delete(conn, target_id)
|
||||
elif key == ("option", "delete"):
|
||||
touched_before, diff = fix_option_delete(
|
||||
conn, extra.get("wildcard_id", ""), target_id,
|
||||
)
|
||||
elif action == "delete" and kind in (
|
||||
"fixed_values",
|
||||
"combine",
|
||||
"derivation",
|
||||
"constraint",
|
||||
):
|
||||
# These module types are leaves from a reverse-ref
|
||||
# perspective: nothing in another row's payload points
|
||||
# at them by id the way `option.id` is referenced from
|
||||
# constraint matrices or `wildcard.id` is referenced
|
||||
# from constraint source/target. A whole-entity delete
|
||||
# is just a row drop + undo snapshot — no fix step,
|
||||
# no diff entries.
|
||||
#
|
||||
# Caveat: combine.output_var IS referenced by name
|
||||
# from downstream payloads, but the cascade pipeline
|
||||
# treats that as a "rename" concern (already wired as
|
||||
# combine_output_var/rename above). Deleting a combine
|
||||
# leaves any consumer referencing the output_var with
|
||||
# an unresolved variable, surfaced separately by the
|
||||
# integrity/broken-refs scan.
|
||||
touched_before, diff = [], []
|
||||
else:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"unsupported (kind, action) pair: ({kind}, {action})",
|
||||
}
|
||||
|
||||
# Delete target entity for whole-entity delete ops. Module
|
||||
# subtypes route through ModuleRepository via _delete_target
|
||||
# (the else-branch of its dispatch).
|
||||
if action == "delete" and kind in (
|
||||
"wildcard",
|
||||
"category",
|
||||
"bundle",
|
||||
"fixed_values",
|
||||
"combine",
|
||||
"derivation",
|
||||
"constraint",
|
||||
):
|
||||
target_snapshot = _delete_target(conn, kind, target_id)
|
||||
touched_before.append(target_snapshot)
|
||||
diff.append({"entity_id": target_id, "removed": True})
|
||||
|
||||
undo_id = write_undo_entry(
|
||||
conn,
|
||||
target_kind=kind,
|
||||
target_id=target_id,
|
||||
action=action,
|
||||
snapshot_before=touched_before,
|
||||
snapshot_after=[],
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"undo_entry_id": undo_id,
|
||||
"affected_count": len(diff),
|
||||
"affected_entities": affected,
|
||||
"diff": diff,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
@@ -1,353 +0,0 @@
|
||||
"""Pure reverse-ref scan for cascade-edit mutations.
|
||||
|
||||
Given a target (kind, id) + action, return the list of entities that
|
||||
reference the target. Used by:
|
||||
- confirm dialog (dry-run mode) to show the user what will be affected
|
||||
- orchestrator to plan the cleanup pass
|
||||
|
||||
The scan reads via repositories and walks payload JSON for ``@{uuid}`` /
|
||||
``@{uuid:subcat}`` / ``$varname`` text refs. Mirrors the regexes in
|
||||
``src/manager/import-export/dep-graph.ts``.
|
||||
|
||||
Pure function — no mutation, no I/O beyond DB reads.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from engine.cascade.fixers import collect_tags
|
||||
from engine.db.repositories import ModuleRepository
|
||||
|
||||
# 4-segment ref: ``@{8hex [#name] [:expr] [!null]}`` — uuid + optional
|
||||
# cached display name + optional boolean sub-category filter expression
|
||||
# + optional exclude-null marker. Mirrors `engine/syntax/tokenize.py:_REF_RE`
|
||||
# and the TS twin in `src/manager/cascade/dep-graph.ts`. findall groups:
|
||||
# (uuid, name, expr, null-marker). This scanner reads the uuid; the
|
||||
# subcat scan also reads the raw expr (see `_scan_subcat`).
|
||||
_REF_REGEX = re.compile(
|
||||
r"@\{([0-9a-f]{8})(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
|
||||
)
|
||||
# ``$varname`` — leading-letter identifier
|
||||
_VAR_REGEX = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
|
||||
def _list_all_modules(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
"""Return all module rows (no filter). ``ModuleRepository.list()``
|
||||
accepts all-optional kwargs, so bare call returns everything."""
|
||||
return ModuleRepository(conn).list()
|
||||
|
||||
|
||||
def _ref_entry(kind: str, row: dict[str, Any], ref_path: str) -> dict[str, Any]:
|
||||
return {"kind": kind, "id": row["id"], "name": row.get("name", ""), "ref_path": ref_path}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-(kind, action) scan helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _scan_wildcard_delete(conn: sqlite3.Connection, wildcard_id: str) -> list[dict[str, Any]]:
|
||||
"""Wildcard delete: find constraints, other wildcards, derivations,
|
||||
and bundles that reference this wildcard id."""
|
||||
out: list[dict[str, Any]] = []
|
||||
|
||||
for m in _list_all_modules(conn):
|
||||
if m["id"] == wildcard_id:
|
||||
continue
|
||||
t = m["type"]
|
||||
p = m.get("payload") or {}
|
||||
|
||||
if t == "constraint":
|
||||
if (p.get("source_wildcard_id") == wildcard_id
|
||||
or p.get("target_wildcard_id") == wildcard_id):
|
||||
out.append(_ref_entry("constraint", m, "source_wildcard_id|target_wildcard_id"))
|
||||
continue
|
||||
|
||||
if t == "wildcard":
|
||||
opts = p.get("options") or []
|
||||
for idx, opt in enumerate(opts):
|
||||
v = opt.get("value")
|
||||
if not isinstance(v, str):
|
||||
continue
|
||||
if any(m.group(1) == wildcard_id for m in _REF_REGEX.finditer(v)):
|
||||
out.append(_ref_entry("wildcard", m, f"options[{idx}].value"))
|
||||
break
|
||||
continue
|
||||
|
||||
if t == "derivation":
|
||||
found = False
|
||||
rules = p.get("rules") or []
|
||||
for ri, rule in enumerate(rules):
|
||||
if found:
|
||||
break
|
||||
for bi, branch in enumerate(rule.get("branches") or []):
|
||||
if found:
|
||||
break
|
||||
for ai, action in enumerate(branch.get("actions") or []):
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
for v in action.values():
|
||||
if isinstance(v, str) and any(
|
||||
m.group(1) == wildcard_id for m in _REF_REGEX.finditer(v)
|
||||
):
|
||||
out.append(_ref_entry(
|
||||
"derivation", m,
|
||||
f"rules[{ri}].branches[{bi}].actions[{ai}]",
|
||||
))
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
break
|
||||
continue
|
||||
|
||||
# Bundles intentionally excluded from the wildcard-delete impact
|
||||
# set. Bundle children are full frozen snapshots, not live refs —
|
||||
# the bundle keeps resolving identically even after the source
|
||||
# wildcard's library row is deleted. Surfacing the bundle in the
|
||||
# cascade confirm dialog would imply the snapshot is going to be
|
||||
# mutated (and the matching fixer in `fix_wildcard_delete` used to
|
||||
# strip the child entry, which destroyed the captured snapshot).
|
||||
# See `fix_wildcard_delete` for the matching no-op rationale.
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _scan_subcat(
|
||||
conn: sqlite3.Connection,
|
||||
wildcard_id: str,
|
||||
subcat_name: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Shared scan for subcategory delete and rename — affected set is identical."""
|
||||
out: list[dict[str, Any]] = []
|
||||
|
||||
for m in _list_all_modules(conn):
|
||||
if m["type"] == "constraint":
|
||||
p = m.get("payload") or {}
|
||||
matrix = p.get("matrix") or {}
|
||||
|
||||
# Source side: matrix keys are source subcat names
|
||||
source_match = (
|
||||
p.get("source_wildcard_id") == wildcard_id
|
||||
and subcat_name in matrix
|
||||
)
|
||||
|
||||
# Target side: matrix row values are dicts keyed by target subcat names
|
||||
target_match = False
|
||||
if p.get("target_wildcard_id") == wildcard_id:
|
||||
for row_val in matrix.values():
|
||||
if isinstance(row_val, dict) and subcat_name in row_val:
|
||||
target_match = True
|
||||
break
|
||||
|
||||
if source_match or target_match:
|
||||
out.append(_ref_entry(
|
||||
"constraint", m,
|
||||
"matrix.source" if source_match else "matrix.target",
|
||||
))
|
||||
continue
|
||||
|
||||
if m["type"] == "wildcard" and m["id"] != wildcard_id:
|
||||
opts = (m.get("payload") or {}).get("options") or []
|
||||
for idx, opt in enumerate(opts):
|
||||
v = opt.get("value")
|
||||
if not isinstance(v, str):
|
||||
continue
|
||||
# SP1: `sub` is the raw `:expr` segment (the 4-segment regex
|
||||
# already split `!null` into the null group). A boolean filter
|
||||
# like `warm or cold` references multiple tags, so parse it and
|
||||
# test tag membership — the same set the fixer rewrites
|
||||
# (engine/cascade/fixers.py). Exact-string equality would miss
|
||||
# every multi-tag / negated / parenthesized filter and make the
|
||||
# preview undercount what the apply actually changes.
|
||||
for uid, _name, sub, _null in _REF_REGEX.findall(v):
|
||||
if uid == wildcard_id and subcat_name in collect_tags(sub):
|
||||
out.append(_ref_entry("wildcard", m, f"options[{idx}].value"))
|
||||
break
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _scan_combine_output_var(
|
||||
conn: sqlite3.Connection,
|
||||
var_name: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Find modules that use ``$var_name`` as a variable reference or
|
||||
a ``set_var`` assignment target."""
|
||||
out: list[dict[str, Any]] = []
|
||||
|
||||
for m in _list_all_modules(conn):
|
||||
t = m["type"]
|
||||
p = m.get("payload") or {}
|
||||
|
||||
if t == "wildcard":
|
||||
for idx, opt in enumerate(p.get("options") or []):
|
||||
v = opt.get("value")
|
||||
if isinstance(v, str) and var_name in _VAR_REGEX.findall(v):
|
||||
out.append(_ref_entry("wildcard", m, f"options[{idx}].value"))
|
||||
break
|
||||
continue
|
||||
|
||||
if t == "derivation":
|
||||
found = False
|
||||
for ri, rule in enumerate(p.get("rules") or []):
|
||||
if found:
|
||||
break
|
||||
for bi, branch in enumerate(rule.get("branches") or []):
|
||||
if found:
|
||||
break
|
||||
for ai, action in enumerate(branch.get("actions") or []):
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
for k, v in action.items():
|
||||
# Match as variable ref in a string value
|
||||
if isinstance(v, str) and var_name in _VAR_REGEX.findall(v):
|
||||
out.append(_ref_entry(
|
||||
"derivation", m,
|
||||
f"rules[{ri}].branches[{bi}].actions[{ai}].{k}",
|
||||
))
|
||||
found = True
|
||||
break
|
||||
# Or match as the explicit set_var target
|
||||
if k == "set_var" and v == var_name:
|
||||
out.append(_ref_entry(
|
||||
"derivation", m,
|
||||
f"rules[{ri}].branches[{bi}].actions[{ai}].set_var",
|
||||
))
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
break
|
||||
continue
|
||||
|
||||
if t == "combine":
|
||||
tpl = p.get("template", "")
|
||||
if isinstance(tpl, str) and var_name in _VAR_REGEX.findall(tpl):
|
||||
out.append(_ref_entry("combine", m, "payload.template"))
|
||||
continue
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _scan_bundle_delete(
|
||||
conn: sqlite3.Connection,
|
||||
bundle_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Find every bundle whose ``children[]`` includes *bundle_id* as a
|
||||
tier-2 reference. Mirrors `_scan_wildcard_delete` for bundles —
|
||||
when a bundle is deleted, parent bundles holding it as a ref need
|
||||
to either drop that entry (cascade-on) or accept a broken ref
|
||||
(cascade-off; server stamps `_missing_ref` on next GET).
|
||||
|
||||
Self-reference (bundle in its own children) is filtered — bundles
|
||||
can't reference themselves per the tier-2 cap, but defensive.
|
||||
"""
|
||||
from engine.db.repositories import BundleRepository
|
||||
out: list[dict[str, Any]] = []
|
||||
bundle_repo = BundleRepository(conn)
|
||||
for b in bundle_repo.list():
|
||||
if b["id"] == bundle_id:
|
||||
continue
|
||||
for ci, child in enumerate(b.get("children") or []):
|
||||
if child.get("type") == "bundle" and child.get("id") == bundle_id:
|
||||
out.append(_ref_entry("bundle", b, f"children[{ci}]"))
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _scan_option_delete(
|
||||
conn: sqlite3.Connection,
|
||||
option_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Find constraints whose exceptions reference *option_id* on either axis.
|
||||
|
||||
Walks every constraint's `exceptions[]`, matching either `source_id`
|
||||
or `target_id` against the deleted option. Stops at the first hit per
|
||||
constraint so the affected list lists each constraint at most once.
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for m in _list_all_modules(conn):
|
||||
if m["type"] != "constraint":
|
||||
continue
|
||||
p = m.get("payload") or {}
|
||||
for idx, ex in enumerate(p.get("exceptions") or []):
|
||||
if ex.get("source_id") == option_id or ex.get("target_id") == option_id:
|
||||
axis = "source_id" if ex.get("source_id") == option_id else "target_id"
|
||||
out.append(_ref_entry("constraint", m, f"exceptions[{idx}].{axis}"))
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _scan_category_delete(
|
||||
conn: sqlite3.Connection,
|
||||
category_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return every module whose ``category_id`` field equals the target."""
|
||||
return [
|
||||
_ref_entry(m["type"], m, "category_id")
|
||||
for m in _list_all_modules(conn)
|
||||
if m.get("category_id") == category_id
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scan_affected(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
kind: str,
|
||||
id: str,
|
||||
action: str,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Dispatch reverse-ref scan by (kind, action). Pure function.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
conn:
|
||||
Open, migrated ``sqlite3.Connection``.
|
||||
kind:
|
||||
Entity kind being mutated (e.g. ``"wildcard"``, ``"subcategory"``).
|
||||
id:
|
||||
Primary-key id of the target entity.
|
||||
action:
|
||||
The mutation action (e.g. ``"delete"``, ``"rename"``).
|
||||
extra:
|
||||
Per-mutation context dict. Keys vary by (kind, action):
|
||||
|
||||
* subcategory delete/rename → ``{"subcat_name": "warm"}``
|
||||
* combine_output_var rename → ``{"old_name": "mood", "new_name": "tone"}``
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict]
|
||||
Each entry: ``{kind, id, name, ref_path}``.
|
||||
Returns ``[]`` for unsupported (kind, action) pairs (v1 stub —
|
||||
other pairs land when their fixer + scan ship together).
|
||||
"""
|
||||
extra = extra or {}
|
||||
|
||||
if kind == "wildcard" and action == "delete":
|
||||
return _scan_wildcard_delete(conn, id)
|
||||
|
||||
if kind == "subcategory" and action in ("delete", "rename"):
|
||||
return _scan_subcat(conn, id, extra.get("subcat_name", ""))
|
||||
|
||||
if kind == "combine_output_var":
|
||||
# Accept either old_name (rename flow) or name (generic)
|
||||
var_name = extra.get("old_name") or extra.get("name", "")
|
||||
return _scan_combine_output_var(conn, var_name)
|
||||
|
||||
if kind == "category" and action == "delete":
|
||||
return _scan_category_delete(conn, id)
|
||||
|
||||
if kind == "bundle" and action == "delete":
|
||||
return _scan_bundle_delete(conn, id)
|
||||
|
||||
if kind == "option" and action == "delete":
|
||||
return _scan_option_delete(conn, id)
|
||||
|
||||
# Unsupported (kind, action) pair — v1 stub, returns empty.
|
||||
return []
|
||||
@@ -1,226 +0,0 @@
|
||||
"""Cascade-apply undo persistence + replay.
|
||||
|
||||
write_undo_entry stores BEFORE-state snapshots in cascade_undo.
|
||||
undo_cascade reads + restores via repository methods in a single transaction.
|
||||
|
||||
Engine isolation: zero ComfyUI/torch/wp_nodes imports. Stdlib json,
|
||||
secrets, sqlite3 + repositories only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from engine._utils import now_iso as _now
|
||||
from engine.db.repositories import (
|
||||
BundleNotFound,
|
||||
BundleRepository,
|
||||
CategoryNotFound,
|
||||
CategoryRepository,
|
||||
ModuleNotFound,
|
||||
ModuleRepository,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def write_undo_entry(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
target_kind: str,
|
||||
target_id: str,
|
||||
action: str,
|
||||
snapshot_before: list[dict[str, Any]] | None,
|
||||
snapshot_after: list[dict[str, Any]] | None,
|
||||
) -> str:
|
||||
"""Persist a BEFORE/AFTER snapshot pair in cascade_undo.
|
||||
|
||||
Returns the generated undo_id (16 hex chars — secrets.token_hex(8)).
|
||||
Defensive defaults: None is treated as [] for both snapshot args so
|
||||
callers that pass None don't trip the NOT NULL constraint.
|
||||
"""
|
||||
undo_id = secrets.token_hex(8)
|
||||
conn.execute(
|
||||
"INSERT INTO cascade_undo "
|
||||
"(id, created_at, target_kind, target_id, action, snapshot_before, snapshot_after) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
undo_id,
|
||||
_now(),
|
||||
target_kind,
|
||||
target_id,
|
||||
action,
|
||||
json.dumps(snapshot_before or []),
|
||||
json.dumps(snapshot_after or []),
|
||||
),
|
||||
)
|
||||
return undo_id
|
||||
|
||||
|
||||
def undo_cascade(conn: sqlite3.Connection, undo_id: str) -> dict[str, Any]:
|
||||
"""Restore all entities from snapshot_before atomically.
|
||||
|
||||
Returns ``{ok: True}`` on success, ``{ok: False, error: str}`` if the
|
||||
undo_id is not found or any restore step raises. The "not found"
|
||||
substring in the error message is intentional — wp_api maps it to
|
||||
HTTP 404.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT snapshot_before, target_kind, target_id "
|
||||
"FROM cascade_undo WHERE id = ?",
|
||||
(undo_id,),
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
return {"ok": False, "error": f"undo entry not found: {undo_id}"}
|
||||
|
||||
snapshot_before: list[dict[str, Any]] = json.loads(row["snapshot_before"])
|
||||
|
||||
try:
|
||||
with conn:
|
||||
for entity in snapshot_before:
|
||||
_dispatch_restore(conn, entity)
|
||||
conn.execute("DELETE FROM cascade_undo WHERE id = ?", (undo_id,))
|
||||
return {"ok": True}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal restore helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _dispatch_restore(conn: sqlite3.Connection, entity: dict[str, Any]) -> None:
|
||||
"""Route an entity snapshot to the correct repository restore path.
|
||||
|
||||
Dispatch priority:
|
||||
1. ``type`` in module-type set → module.
|
||||
2. ``children`` key present → bundle.
|
||||
3. ``color`` or ``icon`` key present (and no ``type``) → category.
|
||||
4. Fallback → treat as module (most common case).
|
||||
"""
|
||||
entity_type = entity.get("type")
|
||||
if entity_type in ("wildcard", "fixed_values", "combine", "derivation", "constraint"):
|
||||
_restore_module(conn, entity)
|
||||
elif "children" in entity:
|
||||
_restore_bundle(conn, entity)
|
||||
elif "color" in entity or "icon" in entity:
|
||||
_restore_category(conn, entity)
|
||||
else:
|
||||
# Fallback: attempt module restore.
|
||||
_restore_module(conn, entity)
|
||||
|
||||
|
||||
def _restore_module(conn: sqlite3.Connection, entity: dict[str, Any]) -> None:
|
||||
repo = ModuleRepository(conn)
|
||||
eid: str = entity["id"]
|
||||
try:
|
||||
repo.get(eid)
|
||||
# Row exists — overwrite with snapshot values.
|
||||
# Build kwargs only for fields present + non-None in snapshot.
|
||||
kwargs: dict[str, Any] = {}
|
||||
if (name := entity.get("name")) is not None:
|
||||
kwargs["name"] = name
|
||||
if (description := entity.get("description")) is not None:
|
||||
kwargs["description"] = description
|
||||
if (category_id := entity.get("category_id")) is not None:
|
||||
kwargs["category_id"] = category_id
|
||||
if (tags := entity.get("tags")) is not None:
|
||||
kwargs["tags"] = tags if tags else []
|
||||
if (payload := entity.get("payload")) is not None:
|
||||
kwargs["payload"] = payload if payload else {}
|
||||
if (is_favorite := entity.get("is_favorite")) is not None:
|
||||
kwargs["is_favorite"] = is_favorite
|
||||
repo.update(eid, **kwargs)
|
||||
except ModuleNotFound:
|
||||
# Row was deleted — recreate at the original id.
|
||||
# ModuleRepository.create() accepts id= (8-hex validated).
|
||||
repo.create(
|
||||
type=entity["type"],
|
||||
name=entity.get("name", ""),
|
||||
description=entity.get("description", ""),
|
||||
category_id=entity.get("category_id"),
|
||||
tags=entity.get("tags") or [],
|
||||
payload=entity.get("payload") or {},
|
||||
is_favorite=entity.get("is_favorite", False),
|
||||
id=eid,
|
||||
)
|
||||
|
||||
|
||||
def _restore_bundle(conn: sqlite3.Connection, entity: dict[str, Any]) -> None:
|
||||
repo = BundleRepository(conn)
|
||||
eid: str = entity["id"]
|
||||
try:
|
||||
repo.get(eid)
|
||||
# Row exists — overwrite.
|
||||
# Build kwargs only for fields present + non-None in snapshot.
|
||||
kwargs: dict[str, Any] = {}
|
||||
if (name := entity.get("name")) is not None:
|
||||
kwargs["name"] = name
|
||||
if (description := entity.get("description")) is not None:
|
||||
kwargs["description"] = description
|
||||
if (color := entity.get("color")) is not None:
|
||||
kwargs["color"] = color
|
||||
if (category_id := entity.get("category_id")) is not None:
|
||||
kwargs["category_id"] = category_id
|
||||
if (tags := entity.get("tags")) is not None:
|
||||
kwargs["tags"] = tags if tags else []
|
||||
if (children := entity.get("children")) is not None:
|
||||
kwargs["children"] = children if children else []
|
||||
if (is_favorite := entity.get("is_favorite")) is not None:
|
||||
kwargs["is_favorite"] = is_favorite
|
||||
repo.update(eid, **kwargs)
|
||||
except BundleNotFound:
|
||||
# Row was deleted — recreate at the original id.
|
||||
# BundleRepository.create() accepts id= (8-hex validated).
|
||||
repo.create(
|
||||
name=entity.get("name", ""),
|
||||
description=entity.get("description", ""),
|
||||
color=entity.get("color"),
|
||||
category_id=entity.get("category_id"),
|
||||
tags=entity.get("tags") or [],
|
||||
children=entity.get("children") or [],
|
||||
is_favorite=entity.get("is_favorite", False),
|
||||
id=eid,
|
||||
)
|
||||
|
||||
|
||||
def _restore_category(conn: sqlite3.Connection, entity: dict[str, Any]) -> None:
|
||||
"""Restore a category row.
|
||||
|
||||
CategoryRepository.create() derives the id from the name slug and does
|
||||
not accept an explicit ``id=`` kwarg. For deleted categories we fall back
|
||||
to a raw INSERT so the original (slug-based) id is preserved exactly.
|
||||
"""
|
||||
repo = CategoryRepository(conn)
|
||||
eid: str = entity["id"]
|
||||
try:
|
||||
repo.get(eid)
|
||||
# Row exists — update in place.
|
||||
# Build kwargs only for fields present + non-None in snapshot.
|
||||
kwargs: dict[str, Any] = {}
|
||||
if (name := entity.get("name")) is not None:
|
||||
kwargs["name"] = name
|
||||
if (color := entity.get("color")) is not None:
|
||||
kwargs["color"] = color
|
||||
if (icon := entity.get("icon")) is not None:
|
||||
kwargs["icon"] = icon
|
||||
if (sort_order := entity.get("sort_order")) is not None:
|
||||
kwargs["sort_order"] = sort_order
|
||||
repo.update(eid, **kwargs)
|
||||
except CategoryNotFound:
|
||||
# Raw INSERT to preserve the original slug-based id.
|
||||
conn.execute(
|
||||
"INSERT INTO module_categories (id, name, color, icon, sort_order) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
eid,
|
||||
entity.get("name", ""),
|
||||
entity.get("color"),
|
||||
entity.get("icon"),
|
||||
entity.get("sort_order", 0),
|
||||
),
|
||||
)
|
||||
@@ -1,7 +0,0 @@
|
||||
"""Prompt cleaner engine — pure-Python rule pipeline.
|
||||
|
||||
Zero ComfyUI imports — runs as plain Python and is testable with
|
||||
vanilla pytest. The node layer (wp_nodes/prompt_cleaner.py) adapts
|
||||
PIPELINE_CONTEXT into the small CleanerCtx dataclass this module
|
||||
consumes.
|
||||
"""
|
||||
@@ -1,72 +0,0 @@
|
||||
"""PromptCleaner — orchestrate rule execution by intensity preset.
|
||||
|
||||
The pipeline owns three responsibilities:
|
||||
|
||||
1. Translate an intensity preset ("gentle"/"balanced"/"aggressive")
|
||||
into the set of rules that should run by default.
|
||||
2. Apply per-rule overrides supplied by the node config.
|
||||
3. Iterate the registry in canonical order, accumulating a
|
||||
`RunReport` of stats keyed by rule_id.
|
||||
|
||||
Rules always execute in registry order — the intensity preset only
|
||||
controls which rules are *enabled*, never reorders them. This keeps
|
||||
output deterministic across presets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from engine.cleaner.rules import RULE_REGISTRY
|
||||
from engine.cleaner.types import RuleId, RunReport
|
||||
|
||||
INTENSITY_TO_RULES: dict[str, list[RuleId]] = {
|
||||
"gentle": ["whitespace"],
|
||||
"balanced": ["whitespace", "punctuation", "dedupe_exact"],
|
||||
"aggressive": [
|
||||
"whitespace",
|
||||
"punctuation",
|
||||
"dedupe_exact",
|
||||
"fuzzy_dedupe",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _effective_rules(intensity: str, overrides: dict[str, bool]) -> set[RuleId]:
|
||||
base: set[RuleId] = set(INTENSITY_TO_RULES.get(intensity, INTENSITY_TO_RULES["balanced"]))
|
||||
for rule_id, enabled in overrides.items():
|
||||
if enabled:
|
||||
base.add(rule_id) # type: ignore[arg-type]
|
||||
else:
|
||||
base.discard(rule_id) # type: ignore[arg-type]
|
||||
return base
|
||||
|
||||
|
||||
class PromptCleaner:
|
||||
"""Stateless orchestrator. Safe to instantiate once and reuse."""
|
||||
|
||||
def run(
|
||||
self,
|
||||
text: str,
|
||||
config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cfg = config or {}
|
||||
mode = cfg.get("mode", "tags")
|
||||
intensity = cfg.get("intensity", "balanced")
|
||||
overrides: dict[str, bool] = dict(cfg.get("rules_override") or {})
|
||||
|
||||
# Blocklist auto-enables when entries are present unless the user
|
||||
# explicitly turned it off via override.
|
||||
blocklist_cfg = cfg.get("blocklist") or {}
|
||||
if blocklist_cfg.get("entries") and "blocklist" not in overrides:
|
||||
overrides["blocklist"] = True
|
||||
|
||||
active = _effective_rules(intensity, overrides)
|
||||
out = text
|
||||
report: RunReport = {}
|
||||
for rule_id, fn in RULE_REGISTRY:
|
||||
if rule_id not in active:
|
||||
continue
|
||||
result = fn(out, mode, cfg)
|
||||
out = result["text"]
|
||||
report[rule_id] = result["stats"]
|
||||
return {"text": out, "report": report}
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Cleaner rule registry.
|
||||
|
||||
Each rule module exports a single `apply(text, mode, config)` function
|
||||
returning a `RuleResult`. The registry below pins the canonical
|
||||
execution order — the pipeline iterates rules in this order regardless
|
||||
of how the widget toggles them, so output stays deterministic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from engine.cleaner.rules import (
|
||||
blocklist,
|
||||
dedupe,
|
||||
punctuation,
|
||||
whitespace,
|
||||
)
|
||||
from engine.cleaner.types import RuleId, RuleResult
|
||||
|
||||
ApplyFn = Callable[[str, str, dict], RuleResult]
|
||||
|
||||
RULE_REGISTRY: list[tuple[RuleId, ApplyFn]] = [
|
||||
("whitespace", whitespace.apply),
|
||||
("punctuation", punctuation.apply),
|
||||
("dedupe_exact", dedupe.apply_exact),
|
||||
("fuzzy_dedupe", dedupe.apply_fuzzy),
|
||||
("blocklist", blocklist.apply),
|
||||
]
|
||||
|
||||
|
||||
def get_rule(rule_id: RuleId) -> ApplyFn:
|
||||
for rid, fn in RULE_REGISTRY:
|
||||
if rid == rule_id:
|
||||
return fn
|
||||
raise KeyError(f"unknown rule id: {rule_id!r}")
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Blocklist filter — drop tags matching a user-supplied list or regex set.
|
||||
|
||||
Two modes (chosen via config.blocklist.kind):
|
||||
- "list": case-insensitive word-boundary substring match within each
|
||||
tag (tags mode) or whole-string (text mode). Drops the
|
||||
whole matching tag.
|
||||
- "regex": each entry compiled separately; bad patterns are caught
|
||||
and reported in stats.errors so a single bad regex doesn't
|
||||
kill the cleaner.
|
||||
|
||||
The widget's blocklist modal owns mode selection + entry editing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from engine.cleaner.types import RuleResult
|
||||
|
||||
# Post-strip cleanup patterns for text mode. After removing a blocklist
|
||||
# match the surrounding `, .` artifacts need to collapse so the user
|
||||
# doesn't see "cfg, . avoid" where "cfg, steps. avoid" used to be.
|
||||
_TEXT_ORPHAN_PUNCT = re.compile(r"\s+[.,:;!?]+(?=\s|$)")
|
||||
_TEXT_DOUBLE_SPACE = re.compile(r"\s{2,}")
|
||||
_TEXT_STRAY_EDGES = re.compile(r"^[\s.,:;!?]+|[\s.,:;!?]+$")
|
||||
|
||||
|
||||
def _cleanup_text(s: str) -> str:
|
||||
out = _TEXT_ORPHAN_PUNCT.sub("", s)
|
||||
out = _TEXT_DOUBLE_SPACE.sub(" ", out)
|
||||
out = _TEXT_STRAY_EDGES.sub("", out)
|
||||
return out
|
||||
|
||||
|
||||
def apply(text: str, mode: str, config: dict) -> RuleResult:
|
||||
blocklist = (config or {}).get("blocklist") or {}
|
||||
kind = blocklist.get("kind", "list")
|
||||
entries = blocklist.get("entries") or []
|
||||
if not entries:
|
||||
return {"text": text, "stats": {"dropped": [], "errors": []}}
|
||||
|
||||
if kind == "regex":
|
||||
return _apply_regex(text, mode, entries)
|
||||
return _apply_list(text, mode, entries)
|
||||
|
||||
|
||||
def _apply_list(text: str, mode: str, entries: list[str]) -> RuleResult:
|
||||
norm_entries = [e for e in entries if isinstance(e, str) and e]
|
||||
dropped: list[str] = []
|
||||
if mode == "tags":
|
||||
tags = [t.strip() for t in text.split(",") if t.strip()]
|
||||
kept: list[str] = []
|
||||
compiled: list[tuple[str, re.Pattern[str]]] = []
|
||||
for entry in norm_entries:
|
||||
try:
|
||||
compiled.append((entry, re.compile(rf"\b{re.escape(entry)}\b", re.IGNORECASE)))
|
||||
except re.error:
|
||||
continue
|
||||
for tag in tags:
|
||||
if any(p.search(tag) or tag.casefold() == src.casefold() for src, p in compiled):
|
||||
dropped.append(tag)
|
||||
else:
|
||||
kept.append(tag)
|
||||
out = ", ".join(kept)
|
||||
else:
|
||||
out = text
|
||||
any_changed = False
|
||||
for entry in norm_entries:
|
||||
pattern = re.compile(rf"\b{re.escape(entry)}\b", re.IGNORECASE)
|
||||
new = pattern.sub("", out)
|
||||
if new != out:
|
||||
dropped.append(entry)
|
||||
any_changed = True
|
||||
out = new
|
||||
if any_changed:
|
||||
out = _cleanup_text(out)
|
||||
return {"text": out, "stats": {"dropped": dropped, "errors": []}}
|
||||
|
||||
|
||||
def _apply_regex(text: str, mode: str, entries: list[str]) -> RuleResult:
|
||||
out = text
|
||||
dropped: list[str] = []
|
||||
errors: list[str] = []
|
||||
compiled: list[tuple[str, re.Pattern[str]]] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, str) or not entry:
|
||||
continue
|
||||
try:
|
||||
compiled.append((entry, re.compile(entry, re.IGNORECASE)))
|
||||
except re.error:
|
||||
errors.append(entry)
|
||||
if mode == "tags":
|
||||
tags = [t.strip() for t in out.split(",") if t.strip()]
|
||||
kept: list[str] = []
|
||||
for tag in tags:
|
||||
matched = any(p.search(tag) for _, p in compiled)
|
||||
if matched:
|
||||
dropped.append(tag)
|
||||
else:
|
||||
kept.append(tag)
|
||||
out = ", ".join(kept)
|
||||
else:
|
||||
any_changed = False
|
||||
for source, pattern in compiled:
|
||||
new = pattern.sub("", out)
|
||||
if new != out:
|
||||
dropped.append(source)
|
||||
any_changed = True
|
||||
out = new
|
||||
if any_changed:
|
||||
out = _cleanup_text(out)
|
||||
return {"text": out, "stats": {"dropped": dropped, "errors": errors}}
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Dedupe family — exact + fuzzy.
|
||||
|
||||
- `apply_exact` — drop later occurrences of an identical tag (tags
|
||||
mode only). Leftmost wins. Case-insensitive comparison;
|
||||
preserved case in the kept token.
|
||||
- `apply_fuzzy` — drop near-duplicates via Levenshtein ratio. Threshold
|
||||
hardcoded at 0.9. Tags mode only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from engine.cleaner.types import RuleResult
|
||||
|
||||
_FUZZY_THRESHOLD = 0.9
|
||||
|
||||
|
||||
def _split_tags(text: str) -> list[str]:
|
||||
return [t.strip() for t in text.split(",") if t.strip()]
|
||||
|
||||
|
||||
def _join_tags(tags: list[str]) -> str:
|
||||
return ", ".join(tags)
|
||||
|
||||
|
||||
def apply_exact(text: str, mode: str, config: dict) -> RuleResult:
|
||||
if mode != "tags":
|
||||
return {"text": text, "stats": {"dropped": []}}
|
||||
tags = _split_tags(text)
|
||||
seen: set[str] = set()
|
||||
kept: list[str] = []
|
||||
dropped: list[str] = []
|
||||
for tag in tags:
|
||||
key = tag.casefold()
|
||||
if key in seen:
|
||||
dropped.append(tag)
|
||||
else:
|
||||
seen.add(key)
|
||||
kept.append(tag)
|
||||
return {"text": _join_tags(kept), "stats": {"dropped": dropped}}
|
||||
|
||||
|
||||
def _levenshtein_ratio(a: str, b: str) -> float:
|
||||
if a == b:
|
||||
return 1.0
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
la, lb = len(a), len(b)
|
||||
prev = list(range(lb + 1))
|
||||
cur = [0] * (lb + 1)
|
||||
for i, ca in enumerate(a, 1):
|
||||
cur[0] = i
|
||||
for j, cb in enumerate(b, 1):
|
||||
cost = 0 if ca == cb else 1
|
||||
cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
|
||||
prev, cur = cur, prev
|
||||
distance = prev[lb]
|
||||
return 1.0 - distance / max(la, lb)
|
||||
|
||||
|
||||
try:
|
||||
from rapidfuzz.fuzz import ratio as _rapidfuzz_ratio # type: ignore[import-not-found]
|
||||
|
||||
def _ratio(a: str, b: str) -> float:
|
||||
return _rapidfuzz_ratio(a, b) / 100.0
|
||||
except ImportError:
|
||||
_ratio = _levenshtein_ratio
|
||||
|
||||
|
||||
def apply_fuzzy(text: str, mode: str, config: dict) -> RuleResult:
|
||||
if mode != "tags":
|
||||
return {"text": text, "stats": {"dropped": []}}
|
||||
tags = _split_tags(text)
|
||||
kept: list[str] = []
|
||||
dropped: list[str] = []
|
||||
for tag in tags:
|
||||
key = tag.casefold()
|
||||
is_dup = False
|
||||
for kept_tag in kept:
|
||||
if _ratio(key, kept_tag.casefold()) >= _FUZZY_THRESHOLD:
|
||||
is_dup = True
|
||||
break
|
||||
if is_dup:
|
||||
dropped.append(tag)
|
||||
else:
|
||||
kept.append(tag)
|
||||
return {"text": _join_tags(kept), "stats": {"dropped": dropped}}
|
||||
@@ -1,50 +0,0 @@
|
||||
"""Strip orphan / leading / trailing punctuation.
|
||||
|
||||
Two cleanups:
|
||||
|
||||
1. Tags mode — drop tags whose only content is punctuation
|
||||
(`.`, `,`, `:`, `;`, `!`, `?`, `-`, `_`, plus runs of those).
|
||||
2. Tags + text mode — strip leading + trailing punctuation runs
|
||||
from each tag (tags mode) or from the whole string (text mode).
|
||||
|
||||
Idempotent: re-running on the same input produces the same output.
|
||||
Reports `stats.stripped` = total tags + edges trimmed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from engine.cleaner.types import RuleResult
|
||||
|
||||
_PUNCT_CHARS = r".,:;!?\-_"
|
||||
_PURE_PUNCT_RE = re.compile(rf"^[{_PUNCT_CHARS}\s]+$")
|
||||
_LEADING_PUNCT_RE = re.compile(rf"^[{_PUNCT_CHARS}\s]+")
|
||||
_TRAILING_PUNCT_RE = re.compile(rf"[{_PUNCT_CHARS}\s]+$")
|
||||
|
||||
|
||||
def _strip_edges(s: str) -> tuple[str, bool]:
|
||||
new = _LEADING_PUNCT_RE.sub("", s)
|
||||
new = _TRAILING_PUNCT_RE.sub("", new)
|
||||
return new, new != s
|
||||
|
||||
|
||||
def apply(text: str, mode: str, config: dict) -> RuleResult:
|
||||
stripped = 0
|
||||
if mode == "tags":
|
||||
tags = [t.strip() for t in text.split(",") if t.strip()]
|
||||
kept: list[str] = []
|
||||
for tag in tags:
|
||||
if _PURE_PUNCT_RE.match(tag):
|
||||
stripped += 1
|
||||
continue
|
||||
edged, changed = _strip_edges(tag)
|
||||
if changed:
|
||||
stripped += 1
|
||||
if edged:
|
||||
kept.append(edged)
|
||||
out = ", ".join(kept)
|
||||
else:
|
||||
out, changed = _strip_edges(text)
|
||||
if changed:
|
||||
stripped += 1
|
||||
return {"text": out, "stats": {"stripped": stripped}}
|
||||
@@ -1,55 +0,0 @@
|
||||
"""Whitespace + punctuation normalization.
|
||||
|
||||
Rules applied in one pass over the input:
|
||||
1. Collapse runs of horizontal whitespace into a single space.
|
||||
2. Strip leading + trailing whitespace.
|
||||
3. Collapse runs of commas (`,,,` -> `,`).
|
||||
4. Drop leading + trailing standalone commas.
|
||||
5. Normalize `comma-space` separator in tags mode.
|
||||
|
||||
Counts every kind of fix into stats.fixed so the UI can show
|
||||
`ws:N` next to the rule row.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from engine.cleaner.types import RuleResult
|
||||
|
||||
_WS_RUN = re.compile(r"[ \t]+")
|
||||
_COMMA_RUN = re.compile(r",[ \t,]*,")
|
||||
|
||||
|
||||
def apply(text: str, mode: str, config: dict) -> RuleResult:
|
||||
fixed = 0
|
||||
out = text
|
||||
|
||||
collapsed_ws = _WS_RUN.sub(" ", out)
|
||||
if collapsed_ws != out:
|
||||
fixed += 1
|
||||
out = collapsed_ws
|
||||
|
||||
trimmed = out.strip()
|
||||
if trimmed != out:
|
||||
fixed += 1
|
||||
out = trimmed
|
||||
|
||||
collapsed_commas = _COMMA_RUN.sub(",", out)
|
||||
if collapsed_commas != out:
|
||||
fixed += 1
|
||||
out = collapsed_commas
|
||||
|
||||
while out.startswith(","):
|
||||
out = out[1:].lstrip()
|
||||
fixed += 1
|
||||
while out.endswith(","):
|
||||
out = out[:-1].rstrip()
|
||||
fixed += 1
|
||||
|
||||
if mode == "tags":
|
||||
normalized = re.sub(r",(?!\s)", ", ", out)
|
||||
if normalized != out:
|
||||
fixed += 1
|
||||
out = normalized
|
||||
|
||||
return {"text": out, "stats": {"fixed": fixed}}
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Lightweight token/word/char counters for the cleaner UI.
|
||||
|
||||
The cleaner widget shows `Nw · Nc` next to the prompt. These helpers
|
||||
keep that math out of the rule modules so they stay focused on
|
||||
transformation.
|
||||
|
||||
CLIP token count is deferred to the node layer where the actual CLIP
|
||||
tokenizer instance lives (it can't be imported in the engine because
|
||||
torch loading is heavy).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_WORD = re.compile(r"\w+", re.UNICODE)
|
||||
|
||||
|
||||
def count_words(text: str) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
return len(_WORD.findall(text))
|
||||
|
||||
|
||||
def count_chars(text: str) -> int:
|
||||
return len(text)
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Shared types for the cleaner pipeline.
|
||||
|
||||
All rules are string-only — no upstream PIPELINE_CONTEXT dependency.
|
||||
The cleaner operates purely on the prompt string + the user's widget
|
||||
config (mode, intensity, rule overrides, blocklist).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
RuleId = Literal[
|
||||
"whitespace",
|
||||
"punctuation",
|
||||
"dedupe_exact",
|
||||
"fuzzy_dedupe",
|
||||
"blocklist",
|
||||
]
|
||||
|
||||
Mode = Literal["tags", "text"]
|
||||
|
||||
|
||||
class RuleResult(TypedDict):
|
||||
"""Return shape for every rule.
|
||||
|
||||
`text` is the transformed prompt. `stats` is a per-rule key/value
|
||||
dict the pipeline collects into a RunReport — UI surfaces these
|
||||
inline next to each active rule in the widget.
|
||||
"""
|
||||
|
||||
text: str
|
||||
stats: dict[str, Any]
|
||||
|
||||
|
||||
# A RunReport is `{rule_id -> stats dict}`. Keep as a type alias rather
|
||||
# than TypedDict so callers can iterate rule_ids dynamically.
|
||||
RunReport = dict[RuleId, dict[str, Any]]
|
||||
+1
-41
@@ -22,45 +22,5 @@ Context = dict[str, Any]
|
||||
|
||||
|
||||
def strip_internals(ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a copy of ``ctx`` with engine-only + user-flagged-internal keys removed.
|
||||
|
||||
Use at the *prompt render* boundary (PromptAssembler) — both engine
|
||||
bookkeeping AND user-marked-internal vars are filtered:
|
||||
|
||||
1. ``__``-prefixed keys: engine bookkeeping (``__wp_node_seed__``,
|
||||
``__wp_trace__``, ``__wp_internal_flags__``, …).
|
||||
2. Keys whose name appears with ``True`` in the
|
||||
``__wp_internal_flags__`` map: user-marked-internal bindings
|
||||
from modules with ``instance.internal == True``. These vars
|
||||
drive composition (other modules can read them) but must not
|
||||
appear as standalone ``$var`` substitutions in the rendered
|
||||
prompt — that's the "internal" UX promise.
|
||||
|
||||
For the *socket* boundary (PIPELINE_CONTEXT carrying state between
|
||||
Context nodes), use ``strip_engine_internals`` instead — it drops
|
||||
only the ``__``-prefixed engine keys so user-flagged internal vars
|
||||
continue to propagate through downstream Context / Combine / etc.
|
||||
until they reach a PromptAssembler that filters them at render
|
||||
time.
|
||||
"""
|
||||
flags = ctx.get("__wp_internal_flags__")
|
||||
internal_names: set[str] = set()
|
||||
if isinstance(flags, dict):
|
||||
internal_names = {k for k, v in flags.items() if v}
|
||||
return {
|
||||
k: v for k, v in ctx.items()
|
||||
if not k.startswith("__") and k not in internal_names
|
||||
}
|
||||
|
||||
|
||||
def strip_engine_internals(ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a copy of ``ctx`` with only the ``__``-prefixed engine keys removed.
|
||||
|
||||
User-flagged internal vars stay in the result — they need to
|
||||
propagate across Context-node boundaries so downstream Combine /
|
||||
Derivation / Constraint modules can read them. Only the final
|
||||
PromptAssembler filters them out at render time (via
|
||||
``strip_internals``) so they don't appear as standalone ``$var``
|
||||
substitutions in the prompt text.
|
||||
"""
|
||||
"""Return a copy of ``ctx`` without ``__``-prefixed internal keys."""
|
||||
return {k: v for k, v in ctx.items() if not k.startswith("__")}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Pure-Python parsers for the WP_VarTo* converter nodes.
|
||||
|
||||
Each function:
|
||||
- Never raises. Unparseable input or out-of-range index returns `default`.
|
||||
- Has a TS mirror in `src/components/var-picker/parser.ts`. The two copies
|
||||
share a fixture (`tests/fixtures/converter_cases.json`) consumed by a
|
||||
parity test (`tests/test_parser_parity.py` + `parser.test.ts`).
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Protocol
|
||||
|
||||
from engine.syntax.types import deref_var_value, split_var_accessor
|
||||
|
||||
|
||||
class _CtxLike(Protocol):
|
||||
context: dict[str, object]
|
||||
|
||||
|
||||
_INT_RE = re.compile(r"-?\d+")
|
||||
_FLOAT_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?")
|
||||
_BOOL_SPLIT_RE = re.compile(r"[\s,;|/]+")
|
||||
_TRUTHY: frozenset[str] = frozenset({"true", "yes", "on", "1"})
|
||||
_FALSY: frozenset[str] = frozenset({"false", "no", "off", "0"})
|
||||
|
||||
|
||||
def parse_int(text: str, index: int, default: int) -> int:
|
||||
"""Return the Nth signed-integer match from `text`, or `default`."""
|
||||
matches = _INT_RE.findall(text or "")
|
||||
if index < 0 or index >= len(matches):
|
||||
return default
|
||||
try:
|
||||
return int(matches[index])
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def parse_float(text: str, index: int, default: float) -> float:
|
||||
"""Return the Nth signed-float match from `text`, or `default`.
|
||||
|
||||
Matches integers too (no required decimal) so a var of `"1920"` still
|
||||
yields `1920.0`. Scientific notation supported (`3.2e-4`).
|
||||
"""
|
||||
matches = _FLOAT_RE.findall(text or "")
|
||||
if index < 0 or index >= len(matches):
|
||||
return default
|
||||
try:
|
||||
return float(matches[index])
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def parse_bool(text: str, index: int, default: bool) -> bool:
|
||||
"""Return the Nth bool token from `text`, or `default`.
|
||||
|
||||
Tokens are produced by splitting on `[\\s,;|/]+`. Each token is
|
||||
matched case-insensitively against the truthy/falsy sets. Tokens
|
||||
that match neither (e.g. `"1.5"`, `"enabled"`) are skipped — they
|
||||
don't consume an index slot.
|
||||
"""
|
||||
if not text:
|
||||
return default
|
||||
tokens = _BOOL_SPLIT_RE.split(text)
|
||||
bools: list[bool] = []
|
||||
for tok in tokens:
|
||||
low = tok.lower()
|
||||
if low in _TRUTHY:
|
||||
bools.append(True)
|
||||
elif low in _FALSY:
|
||||
bools.append(False)
|
||||
if index < 0 or index >= len(bools):
|
||||
return default
|
||||
return bools[index]
|
||||
|
||||
|
||||
def lookup_var(context: _CtxLike, name: str) -> str:
|
||||
"""Read `name` from `context.context`. Strips leading `$`. Never raises.
|
||||
|
||||
Missing key, `None`, or empty name all collapse to `""` — every parser
|
||||
then falls back to its `default`.
|
||||
"""
|
||||
bare = (name or "").lstrip("$").strip()
|
||||
if not bare:
|
||||
return ""
|
||||
base, index = split_var_accessor(bare)
|
||||
raw = context.context.get(base) if context and getattr(context, "context", None) else None
|
||||
return deref_var_value(raw, index)
|
||||
@@ -1,112 +0,0 @@
|
||||
"""Sidecar config for DB location preference + pending move state.
|
||||
|
||||
Lives at ``<plugin>/db-config.json`` so the resolver can read it BEFORE
|
||||
opening any DB connection (chicken-and-egg: the preference about where
|
||||
the DB lives can't live inside the DB itself).
|
||||
|
||||
Atomic writes: write-to-temp-then-rename so a crash mid-write can't
|
||||
corrupt the sidecar.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
Preference = Literal["user", "global", "root"]
|
||||
MoveMode = Literal["copy", "move"]
|
||||
|
||||
# Plugin root: this file is at <plugin>/engine/db/config.py — go up three.
|
||||
_PLUGIN_ROOT = Path(__file__).resolve().parents[2]
|
||||
SIDECAR_PATH = _PLUGIN_ROOT / "db-config.json"
|
||||
|
||||
_VALID_PREFERENCES = {"user", "global", "root"}
|
||||
_VALID_MODES = {"copy", "move"}
|
||||
|
||||
|
||||
class PendingMove(TypedDict, total=False):
|
||||
from_: str # NB: "from" is a Python keyword; we map this on read/write
|
||||
to: str
|
||||
mode: MoveMode
|
||||
|
||||
|
||||
class DbConfig(TypedDict, total=False):
|
||||
preference: Preference
|
||||
pending_move: dict[str, str] # {"from": "...", "to": "...", "mode": "..."}
|
||||
|
||||
|
||||
def _read_raw(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_sidecar(path: Path | None) -> Path:
|
||||
"""Resolve the effective sidecar path.
|
||||
|
||||
Reads ``SIDECAR_PATH`` from this module dynamically when ``path`` is
|
||||
``None`` so tests can ``monkeypatch.setattr("engine.db.config.SIDECAR_PATH", ...)``
|
||||
and have all three of ``load``/``save``/``clear_pending_move`` honor
|
||||
the override. Using a default argument like ``path = SIDECAR_PATH``
|
||||
captures the value at function-definition time, which breaks
|
||||
monkeypatching the module attribute."""
|
||||
return path if path is not None else SIDECAR_PATH
|
||||
|
||||
|
||||
def load(path: Path | None = None) -> DbConfig:
|
||||
"""Return the sidecar config or {} if absent / unreadable / invalid."""
|
||||
raw = _read_raw(_resolve_sidecar(path))
|
||||
out: DbConfig = {}
|
||||
pref = raw.get("preference")
|
||||
if isinstance(pref, str) and pref in _VALID_PREFERENCES:
|
||||
out["preference"] = pref # type: ignore[typeddict-item]
|
||||
pm = raw.get("pending_move")
|
||||
if isinstance(pm, dict):
|
||||
mode = pm.get("mode")
|
||||
src = pm.get("from")
|
||||
dst = pm.get("to")
|
||||
if (isinstance(mode, str) and mode in _VALID_MODES
|
||||
and isinstance(src, str) and isinstance(dst, str)):
|
||||
out["pending_move"] = {"from": src, "to": dst, "mode": mode}
|
||||
return out
|
||||
|
||||
|
||||
def save(config: DbConfig, path: Path | None = None) -> None:
|
||||
"""Atomically write the sidecar (write-temp + rename)."""
|
||||
path = _resolve_sidecar(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Strip any unknown keys; only persist the validated shape.
|
||||
out: dict[str, Any] = {}
|
||||
if "preference" in config:
|
||||
out["preference"] = config["preference"]
|
||||
pm = config.get("pending_move")
|
||||
if isinstance(pm, dict) and {"from", "to", "mode"} <= pm.keys():
|
||||
out["pending_move"] = {"from": pm["from"], "to": pm["to"], "mode": pm["mode"]}
|
||||
fd, tmp_str = tempfile.mkstemp(prefix=".db-config-", dir=str(path.parent))
|
||||
tmp_path = Path(tmp_str)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, indent=2)
|
||||
os.replace(tmp_path, path)
|
||||
except OSError:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def clear_pending_move(path: Path | None = None) -> None:
|
||||
"""Remove just the pending_move field; preserve preference."""
|
||||
path = _resolve_sidecar(path)
|
||||
cfg = load(path)
|
||||
if "pending_move" in cfg:
|
||||
cfg.pop("pending_move", None)
|
||||
save(cfg, path)
|
||||
|
||||
|
||||
def plugin_root() -> Path:
|
||||
"""Expose the plugin root so the resolver can compute the `root` location."""
|
||||
return _PLUGIN_ROOT
|
||||
+11
-162
@@ -1,29 +1,9 @@
|
||||
"""SQLite connection factory.
|
||||
|
||||
DB path resolution order (highest priority first):
|
||||
|
||||
1. ``WP_DB_PATH`` env var — explicit override, used as-is. Source ``"WP_DB_PATH"``.
|
||||
2. ``COMFYUI_USER_DIR`` env var — places the DB under
|
||||
``$COMFYUI_USER_DIR/wildcard-pipeline.db``. Source ``"COMFYUI_USER_DIR"``.
|
||||
3. Sidecar ``preference`` field at ``<plugin>/db-config.json`` (loaded
|
||||
indirectly through ``_load_sidecar`` so tests can stub it):
|
||||
- ``"user"`` → ComfyUI user dir + ``wildcard-pipeline.db``,
|
||||
source ``"user"``. Falls through if the user dir can't be detected.
|
||||
- ``"global"`` → ``~/.comfyui/wildcard-pipeline.db``, source ``"global"``.
|
||||
- ``"root"`` → ``<plugin>/db/wildcard-pipeline.db``, source ``"root"``.
|
||||
4. Default — ComfyUI user dir + ``wildcard-pipeline.db``, source ``"user"``.
|
||||
Falls through if detection fails.
|
||||
5. Last-ditch fallback — ``~/.comfyui/wildcard-pipeline.db``, source
|
||||
``"global"`` (the design folds the old ``"legacy"`` label into
|
||||
``"global"`` since the path is identical and the UI exposes a Global
|
||||
radio for it).
|
||||
|
||||
The pre-feature back-compat clause that preferred legacy when the new
|
||||
path had no DB has been removed — the design intentionally surprises
|
||||
users with an empty new DB so they migrate explicitly via Settings.
|
||||
|
||||
Tests can short-circuit the entire chain by setting ``WP_DB_PATH`` or by
|
||||
stubbing ``_load_sidecar`` to return a chosen preference.
|
||||
DB path resolution order:
|
||||
1. ``WP_DB_PATH`` env var (explicit override)
|
||||
2. ``$COMFYUI_USER_DIR/wildcard-pipeline.db``
|
||||
3. Fallback: ``~/.comfyui/wildcard-pipeline.db``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -31,146 +11,15 @@ import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
DB_FILENAME = "wildcard-pipeline.db"
|
||||
|
||||
|
||||
def _comfyui_user_dir_from_api() -> Path | None:
|
||||
"""Use ComfyUI's `folder_paths.get_user_directory()` when the host
|
||||
process exposes it. Import is guarded because pytest runs outside
|
||||
the ComfyUI sys.path and the module won't be importable there."""
|
||||
try:
|
||||
import folder_paths # type: ignore[import-not-found]
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
return Path(folder_paths.get_user_directory())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _comfyui_user_dir_from_path() -> Path | None:
|
||||
"""Walk up from this file to locate the ComfyUI root.
|
||||
|
||||
Custom nodes live at ``<ComfyUI>/custom_nodes/<plugin>/`` by
|
||||
convention, so this file at
|
||||
``<ComfyUI>/custom_nodes/ComfyUI-Wildcard-Pipeline/engine/db/connection.py``
|
||||
is exactly 4 parents away from the ComfyUI root.
|
||||
|
||||
A presence check on ``<root>/main.py`` (ComfyUI's entrypoint) +
|
||||
the `custom_nodes/` directory guards against returning a wrong
|
||||
root for unusual install layouts (e.g. when the plugin is
|
||||
symlinked from somewhere else).
|
||||
"""
|
||||
here = Path(__file__).resolve()
|
||||
parents = here.parents
|
||||
if len(parents) < 5:
|
||||
return None
|
||||
candidate = parents[4]
|
||||
if not (candidate / "custom_nodes").is_dir():
|
||||
return None
|
||||
if not (candidate / "main.py").is_file():
|
||||
return None
|
||||
return candidate / "user"
|
||||
|
||||
|
||||
def _legacy_home_path() -> Path:
|
||||
"""Return ``~/.comfyui/wildcard-pipeline.db``.
|
||||
|
||||
Still named ``_legacy_*`` for historical reasons but now serves as
|
||||
both the explicit ``global`` preference target and the last-ditch
|
||||
fallback when no ComfyUI user dir can be detected. Both report
|
||||
source ``"global"``."""
|
||||
return Path.home() / ".comfyui" / DB_FILENAME
|
||||
|
||||
|
||||
def _plugin_root_db_path() -> Path:
|
||||
"""Return ``<plugin>/db/wildcard-pipeline.db`` — the ``root``
|
||||
preference target. Lives inside the plugin install dir so it
|
||||
survives ComfyUI reinstalls; vulnerable to plugin git pulls that
|
||||
wipe untracked files (called out in the UI as "not recommended")."""
|
||||
from engine.db.config import plugin_root
|
||||
|
||||
return plugin_root() / "db" / DB_FILENAME
|
||||
|
||||
|
||||
def _load_sidecar() -> dict:
|
||||
"""Indirection over ``engine.db.config.load`` so tests can stub the
|
||||
sidecar read without touching the real ``<plugin>/db-config.json``
|
||||
file."""
|
||||
from engine.db.config import load
|
||||
|
||||
return dict(load())
|
||||
|
||||
|
||||
def user_location_path() -> Path | None:
|
||||
"""Return the ``user`` preference target path, or ``None`` if the
|
||||
ComfyUI user dir can't be detected.
|
||||
|
||||
Same detector chain that ``resolve_db_path_with_source`` uses for the
|
||||
``user`` branch. Exposed for the SPA Settings API so the frontend can
|
||||
list all three potential locations regardless of which one is
|
||||
currently active (or whether one is detectable at all)."""
|
||||
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
|
||||
return (comfy_user / DB_FILENAME) if comfy_user else None
|
||||
|
||||
|
||||
def global_location_path() -> Path:
|
||||
"""Return the ``global`` preference target — ``~/.comfyui/wildcard-pipeline.db``.
|
||||
|
||||
Always available regardless of host detection (just ``Path.home()``)."""
|
||||
return _legacy_home_path()
|
||||
|
||||
|
||||
def root_location_path() -> Path:
|
||||
"""Return the ``root`` preference target — ``<plugin>/db/wildcard-pipeline.db``."""
|
||||
return _plugin_root_db_path()
|
||||
|
||||
|
||||
def resolve_db_path_with_source() -> tuple[Path, str]:
|
||||
"""Like ``resolve_db_path`` but also reports which resolver rule won.
|
||||
|
||||
Source values:
|
||||
- ``"WP_DB_PATH"`` — explicit env override
|
||||
- ``"COMFYUI_USER_DIR"`` — env user-dir
|
||||
- ``"user"`` — ComfyUI user dir (sidecar preference or default)
|
||||
- ``"global"`` — ~/.comfyui/wildcard-pipeline.db
|
||||
- ``"root"`` — <plugin>/db/wildcard-pipeline.db
|
||||
"""
|
||||
override = os.environ.get("WP_DB_PATH")
|
||||
if override:
|
||||
return Path(override), "WP_DB_PATH"
|
||||
|
||||
user_dir_env = os.environ.get("COMFYUI_USER_DIR")
|
||||
if user_dir_env:
|
||||
return Path(user_dir_env) / DB_FILENAME, "COMFYUI_USER_DIR"
|
||||
|
||||
sidecar = _load_sidecar()
|
||||
preference = sidecar.get("preference")
|
||||
if preference == "global":
|
||||
return _legacy_home_path(), "global"
|
||||
if preference == "root":
|
||||
return _plugin_root_db_path(), "root"
|
||||
if preference == "user":
|
||||
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
|
||||
if comfy_user is not None:
|
||||
return comfy_user / DB_FILENAME, "user"
|
||||
# Detection failed — fall through to the default chain rather
|
||||
# than crash. Last-ditch fallback below will catch it.
|
||||
|
||||
# Default — same as explicit "user" preference.
|
||||
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
|
||||
if comfy_user is not None:
|
||||
return comfy_user / DB_FILENAME, "user"
|
||||
|
||||
# Last-ditch fallback. The design folds the old "legacy" label into
|
||||
# "global" because the path is identical and the UI has no separate
|
||||
# "legacy" radio.
|
||||
return _legacy_home_path(), "global"
|
||||
|
||||
|
||||
def resolve_db_path() -> Path:
|
||||
"""Path-only convenience wrapper — preserved for all existing callers."""
|
||||
return resolve_db_path_with_source()[0]
|
||||
override = os.environ.get("WP_DB_PATH")
|
||||
if override:
|
||||
return Path(override)
|
||||
user_dir = os.environ.get("COMFYUI_USER_DIR")
|
||||
if user_dir:
|
||||
return Path(user_dir) / "wildcard-pipeline.db"
|
||||
return Path.home() / ".comfyui" / "wildcard-pipeline.db"
|
||||
|
||||
|
||||
def get_connection(path: Path | None = None) -> sqlite3.Connection:
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
"""Read-only DB introspection + safe maintenance operations.
|
||||
|
||||
Pure Python — no ComfyUI imports, no aiohttp. Each public function takes
|
||||
a ``sqlite3.Connection`` and returns a dict. Expected-failure modes
|
||||
(locked DB, integrity errors) are reported via ``ok: False`` payloads,
|
||||
not exceptions, so the API layer can pass results through verbatim.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from engine.db.migrations import _discover, current_version
|
||||
|
||||
# Map module-row "type" column values to the human/UI-facing names used
|
||||
# in the counts response. Kept in one place so future kinds get a single
|
||||
# insertion point.
|
||||
_KIND_TYPES: dict[str, str] = {
|
||||
"wildcards": "wildcard",
|
||||
"fixed_values": "fixed_values",
|
||||
"combines": "combine",
|
||||
"derivations": "derivation",
|
||||
"constraints": "constraint",
|
||||
}
|
||||
|
||||
# Maps the UI-facing count key to the real SQLite table name. Some
|
||||
# tables have a `module_` prefix in storage but show up unprefixed in
|
||||
# the response (e.g. `module_categories` -> "categories") to keep the
|
||||
# settings card readable.
|
||||
_TABLE_COUNTS: dict[str, str] = {
|
||||
"bundles": "bundles",
|
||||
"templates": "templates",
|
||||
"categories": "module_categories",
|
||||
}
|
||||
|
||||
|
||||
def _iso(ts: float) -> str:
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _count_modules_by_type(conn: sqlite3.Connection, type_value: str) -> int:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM modules WHERE type = ?", (type_value,)
|
||||
).fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
def _count_table(conn: sqlite3.Connection, table: str) -> int:
|
||||
try:
|
||||
row = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
# Table might not exist on very old DBs that haven't migrated yet.
|
||||
return 0
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
def _pragma_int(conn: sqlite3.Connection, name: str) -> int:
|
||||
row = conn.execute(f"PRAGMA {name}").fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
def _pragma_str(conn: sqlite3.Connection, name: str) -> str:
|
||||
row = conn.execute(f"PRAGMA {name}").fetchone()
|
||||
return str(row[0]) if row else ""
|
||||
|
||||
|
||||
def _migration_names() -> dict[int, str]:
|
||||
"""Discover known migration files and map version -> stem name.
|
||||
|
||||
The migrations table only stores ``version`` and ``applied_at``;
|
||||
the human-readable name lives in the filename
|
||||
(``012_templates.sql`` -> ``012_templates``). Walking the same
|
||||
discovery routine the migrator uses keeps this in sync.
|
||||
"""
|
||||
return {version: path.stem for version, path in _discover()}
|
||||
|
||||
|
||||
def _applied_migrations(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
"""Return all rows from the migrations table joined with the
|
||||
on-disk migration name. Empty list if the table doesn't exist
|
||||
(pre-migration DB)."""
|
||||
table = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='migrations';"
|
||||
).fetchone()
|
||||
if not table:
|
||||
return []
|
||||
rows = conn.execute(
|
||||
"SELECT version, applied_at FROM migrations ORDER BY version ASC"
|
||||
).fetchall()
|
||||
names = _migration_names()
|
||||
return [
|
||||
{
|
||||
"version": int(r[0]),
|
||||
"name": names.get(int(r[0]), f"migration_{int(r[0]):03d}"),
|
||||
"applied_at": str(r[1]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def gather_info(
|
||||
conn: sqlite3.Connection, db_path: Path, *, source: str = "unknown"
|
||||
) -> dict[str, Any]:
|
||||
"""Return a read-only snapshot of the DB suitable for the SPA card.
|
||||
|
||||
``source`` is passed through verbatim. The resolver in
|
||||
``engine.db.connection`` produces one of:
|
||||
``"WP_DB_PATH" | "COMFYUI_USER_DIR" | "user" | "global" | "root"``.
|
||||
``"unknown"`` is reserved for callers that don't have a real source
|
||||
(e.g. engine unit tests that build a connection directly)."""
|
||||
counts: dict[str, int] = {}
|
||||
for ui_name, type_value in _KIND_TYPES.items():
|
||||
counts[ui_name] = _count_modules_by_type(conn, type_value)
|
||||
for ui_name, table_name in _TABLE_COUNTS.items():
|
||||
counts[ui_name] = _count_table(conn, table_name)
|
||||
|
||||
try:
|
||||
stat = db_path.stat()
|
||||
size_bytes = stat.st_size
|
||||
mtime_iso = _iso(stat.st_mtime)
|
||||
except OSError:
|
||||
size_bytes = 0
|
||||
mtime_iso = ""
|
||||
|
||||
return {
|
||||
"path": str(db_path),
|
||||
"source": source,
|
||||
"size_bytes": size_bytes,
|
||||
"mtime_iso": mtime_iso,
|
||||
"counts": counts,
|
||||
"migration": {
|
||||
"current_version": current_version(conn),
|
||||
"applied": _applied_migrations(conn),
|
||||
},
|
||||
"pragma": {
|
||||
"journal_mode": _pragma_str(conn, "journal_mode"),
|
||||
"foreign_keys": _pragma_int(conn, "foreign_keys"),
|
||||
"page_size": _pragma_int(conn, "page_size"),
|
||||
"page_count": _pragma_int(conn, "page_count"),
|
||||
"freelist_count": _pragma_int(conn, "freelist_count"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _db_filename(conn: sqlite3.Connection) -> str | None:
|
||||
"""Return the on-disk filename for the main DB attached to this
|
||||
connection, or None if it's an in-memory / un-attached connection.
|
||||
|
||||
Uses ``PRAGMA database_list`` which returns one row per attached
|
||||
DB; the ``main`` one is what we care about. The third column is the
|
||||
absolute file path (empty string for ``:memory:``)."""
|
||||
try:
|
||||
for row in conn.execute("PRAGMA database_list"):
|
||||
# row order: seq, name, file
|
||||
if row[1] == "main":
|
||||
fname = row[2]
|
||||
return fname if fname else None
|
||||
except sqlite3.OperationalError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _file_size(conn: sqlite3.Connection) -> int:
|
||||
"""Return current DB file size from the filesystem. Falls back to
|
||||
page_size * page_count (logical size) when the connection is
|
||||
in-memory or the file can't be stat'd."""
|
||||
fname = _db_filename(conn)
|
||||
if fname:
|
||||
try:
|
||||
return Path(fname).stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
page_size = _pragma_int(conn, "page_size")
|
||||
page_count = _pragma_int(conn, "page_count")
|
||||
return page_size * page_count
|
||||
|
||||
|
||||
def vacuum(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Run VACUUM. Returns {ok, op, duration_ms, bytes_reclaimed}.
|
||||
|
||||
bytes_reclaimed is the on-disk size delta of the main DB file. In
|
||||
WAL mode pending writes live in the ``-wal`` sidecar until a
|
||||
checkpoint flushes them into the main DB, so we issue a TRUNCATE
|
||||
checkpoint BOTH before VACUUM (so ``size_before`` reflects the real
|
||||
occupied size including the freelist we're about to reclaim) and
|
||||
after VACUUM (so ``size_after`` reflects the post-vacuum shrinkage
|
||||
immediately rather than waiting for the next automatic checkpoint).
|
||||
"""
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
# Pre-checkpoint: flush any pending WAL writes into the main DB
|
||||
# file so size_before captures the true on-disk footprint.
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
size_before = _file_size(conn)
|
||||
conn.execute("VACUUM")
|
||||
conn.commit()
|
||||
# Post-checkpoint: VACUUM itself writes via the WAL; flush again
|
||||
# so the shrinkage is reflected on the main DB file right now.
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
except sqlite3.OperationalError as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"op": "vacuum",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"error": str(e),
|
||||
}
|
||||
size_after = _file_size(conn)
|
||||
return {
|
||||
"ok": True,
|
||||
"op": "vacuum",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"bytes_reclaimed": max(0, size_before - size_after),
|
||||
}
|
||||
|
||||
|
||||
def integrity_check(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Run PRAGMA integrity_check. ``ok=True`` iff output is exactly ['ok']."""
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
rows = conn.execute("PRAGMA integrity_check").fetchall()
|
||||
except sqlite3.OperationalError as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"op": "integrity",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"output": [],
|
||||
"error": str(e),
|
||||
}
|
||||
output = [str(r[0]) for r in rows]
|
||||
is_ok = output == ["ok"]
|
||||
return {
|
||||
"ok": is_ok,
|
||||
"op": "integrity",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"output": output,
|
||||
}
|
||||
|
||||
|
||||
def analyze(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Run ANALYZE to refresh the query planner's statistics tables."""
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
conn.execute("ANALYZE")
|
||||
conn.commit()
|
||||
except sqlite3.OperationalError as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"op": "analyze",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"error": str(e),
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"op": "analyze",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
}
|
||||
|
||||
|
||||
def run_migrations(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Force-run pending migrations. Returns the list of versions applied
|
||||
(empty when the DB was already up to date)."""
|
||||
from engine.db.migrations import migrate # local import to keep top-level clean
|
||||
|
||||
before = current_version(conn)
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
migrate(conn)
|
||||
except Exception as e: # noqa: BLE001 — surface to UI as a structured failure
|
||||
return {
|
||||
"ok": False,
|
||||
"op": "migrate",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"error": str(e),
|
||||
"applied": [],
|
||||
}
|
||||
after = current_version(conn)
|
||||
applied = list(range(before + 1, after + 1)) if after > before else []
|
||||
return {
|
||||
"ok": True,
|
||||
"op": "migrate",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"applied": applied,
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
"""Lazy-read routine — the ONE migration runtime.
|
||||
|
||||
See community-web spec
|
||||
D:\\Desktop\\Wildcard-Pipeline-Community\\docs\\superpowers\\specs\\
|
||||
2026-06-05-schema-versioning-migration-design.md §4 Flow 5. Every row
|
||||
read (loadModule / loadBundle) goes through this function; the engine
|
||||
startup bulk runner also calls it for each stale row.
|
||||
|
||||
Six branches:
|
||||
1. No-op (row at CURRENT, original NULL or original.sv ≤ CURRENT).
|
||||
2. NULL-drop optimization (row.sv == CURRENT, original.sv == CURRENT).
|
||||
3. Recovery exact catch-up (original.sv ≤ CURRENT, strict-validate
|
||||
succeeds): promote original into payload, NULL original column.
|
||||
4. Still-drifted re-project (original.sv > CURRENT): tolerantly strip.
|
||||
5. Deferred strict-validate failure: mark row 'broken'.
|
||||
6. Standard forward chain (row.sv < CURRENT, no recovery applicable).
|
||||
|
||||
CRITICAL bug-fix from spec §4: the write-back gate is
|
||||
'base_v < current OR base IS original', NOT just 'base_v < current'.
|
||||
Otherwise the recovery exact-catch-up case (base_v == current AND base
|
||||
IS original) silently no-ops and the row stays drifted.
|
||||
|
||||
The three dependency-injected dicts:
|
||||
migrators[(kind, v)] → (payload, ctx) -> next_payload
|
||||
validators[(kind, v, subtype)] → payload -> bool
|
||||
tolerant_strip[(kind, target_v, sub)] → payload -> stripped_payload
|
||||
|
||||
Subtype lookup: for modules, derived from payload["type"]; for bundles, None.
|
||||
original_payload_json is INERT — only the initial tolerant install + this
|
||||
NULL-drop touch it. schema_migrated_at is SEPARATE from updated_at
|
||||
(structural migration is not a user edit).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from engine._utils import now_iso
|
||||
|
||||
Migrators = dict[tuple[str, int], Callable[[dict[str, Any], dict], dict]]
|
||||
Validators = dict[tuple[str, int, str | None], Callable[[dict[str, Any]], bool]]
|
||||
TolerantStrip = dict[tuple[str, int, str | None], Callable[[dict[str, Any]], dict]]
|
||||
|
||||
|
||||
def lazy_migrate_row(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
kind: str,
|
||||
row_id: str,
|
||||
current_version: int,
|
||||
migrators: Migrators,
|
||||
validators: Validators,
|
||||
tolerant_strip: TolerantStrip | None = None,
|
||||
) -> None:
|
||||
"""Apply lazy migration to a single row.
|
||||
|
||||
Idempotent: if row is already at CURRENT and original is NULL or
|
||||
matches CURRENT, returns without writing (or fires NULL-drop only).
|
||||
"""
|
||||
table = "modules" if kind == "module" else "bundles"
|
||||
row = conn.execute(f"SELECT * FROM {table} WHERE id=?", (row_id,)).fetchone()
|
||||
if row is None:
|
||||
return
|
||||
|
||||
sv = row["schema_version"]
|
||||
original_raw = row["original_payload_json"]
|
||||
original = json.loads(original_raw) if original_raw else None
|
||||
# Narrow original_v to int so the rest of the function's `original is not None`
|
||||
# checks correctly imply an int. A malformed original (no schema_version, or
|
||||
# non-int) is demoted to the no-original branch — the row's payload column is
|
||||
# the only thing we trust.
|
||||
original_v: int | None = None
|
||||
if original is not None:
|
||||
raw_v = original.get("schema_version")
|
||||
if isinstance(raw_v, int):
|
||||
original_v = raw_v
|
||||
else:
|
||||
original = None
|
||||
payload = json.loads(row["payload"])
|
||||
subtype = payload.get("type") if kind == "module" else None
|
||||
|
||||
# Branch 1: no-op or NULL-drop
|
||||
if sv == current_version and (
|
||||
original is None or (original_v is not None and original_v <= current_version)
|
||||
):
|
||||
if original is not None and original_v == sv:
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET original_payload_json=NULL, "
|
||||
f"tolerant_drift_status='none' WHERE id=?",
|
||||
(row_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
|
||||
# Decide base
|
||||
base_is_original = False
|
||||
base_v: int
|
||||
if original is not None and original_v is not None and original_v > sv:
|
||||
if original_v <= current_version:
|
||||
# Recovery candidate — deferred strict-validate FIRST
|
||||
strict = validators.get((kind, original_v, subtype))
|
||||
if strict is not None and not strict(original):
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET tolerant_drift_status='broken', "
|
||||
f"schema_migrated_at=? WHERE id=?",
|
||||
(now_iso(), row_id),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
base = original
|
||||
base_v = original_v
|
||||
base_is_original = True
|
||||
else:
|
||||
# Still drifted; tolerantly strip original down to current
|
||||
strip = (tolerant_strip or {}).get((kind, current_version, subtype))
|
||||
if strip is None:
|
||||
return
|
||||
projected = strip(original)
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET payload=?, schema_version=?, "
|
||||
f"tolerant_drift_status='pending', schema_migrated_at=? WHERE id=?",
|
||||
(
|
||||
json.dumps(projected),
|
||||
current_version,
|
||||
now_iso(),
|
||||
row_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
else:
|
||||
base = payload
|
||||
base_v = sv
|
||||
|
||||
# Write-back gate (THE BUG FIX): write when base_v < current OR base IS original.
|
||||
# The recovery exact-catch-up case has zero chain steps (base_v == current_version)
|
||||
# but still needs the write to promote original into payload + NULL the original
|
||||
# column. Gating on 'base_v < current_version' alone silently no-ops the recovery.
|
||||
needs_write = (base_v < current_version) or base_is_original
|
||||
if not needs_write:
|
||||
return
|
||||
|
||||
new_payload = base
|
||||
for v in range(base_v, current_version):
|
||||
migrator = migrators.get((kind, v))
|
||||
if migrator is None:
|
||||
return
|
||||
new_payload = migrator(new_payload, {})
|
||||
|
||||
promote_from_original = base_is_original
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET payload=?, schema_version=?, schema_migrated_at=?, "
|
||||
f"original_payload_json=?, tolerant_drift_status=? WHERE id=?",
|
||||
(
|
||||
json.dumps(new_payload),
|
||||
current_version,
|
||||
now_iso(),
|
||||
None if promote_from_original else row["original_payload_json"],
|
||||
"none" if promote_from_original else row["tolerant_drift_status"],
|
||||
row_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
+15
-55
@@ -1,29 +1,19 @@
|
||||
"""Versioned, forward-only migration runner.
|
||||
|
||||
Migration files live in ``engine/db/migrations_sql/<NNN>_<name>.<ext>``
|
||||
where ``<ext>`` is ``.sql`` (executed verbatim) or ``.py`` (imported and
|
||||
its top-level ``up(conn)`` callable invoked). Filenames must start with
|
||||
a 3-digit zero-padded version number; the runner applies them in
|
||||
numeric order.
|
||||
|
||||
Python migrations are useful when a transformation needs to read row
|
||||
payloads, mutate JSON, or otherwise do work SQL alone can't express
|
||||
cleanly. The ``.py`` file is imported via ``importlib.util`` so it
|
||||
can sit next to the ``.sql`` files without polluting the package's
|
||||
import graph.
|
||||
SQL files live in ``engine/db/migrations_sql/<NNN>_<name>.sql``. Filenames
|
||||
must start with a 3-digit zero-padded version number; the runner applies
|
||||
them in numeric order.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from engine._utils import now_iso
|
||||
|
||||
_SQL_DIR = Path(__file__).parent / "migrations_sql"
|
||||
_VERSION_RE = re.compile(r"^(\d{3})_.+\.(sql|py)$")
|
||||
_VERSION_RE = re.compile(r"^(\d{3})_.+\.sql$")
|
||||
|
||||
|
||||
def _migrations_table_exists(conn: sqlite3.Connection) -> bool:
|
||||
@@ -54,49 +44,19 @@ def _discover() -> list[tuple[int, Path]]:
|
||||
return pairs
|
||||
|
||||
|
||||
def _load_py_migration(path: Path) -> Callable[[sqlite3.Connection], object]:
|
||||
"""Import a `.py` migration file and return its `up` callable.
|
||||
|
||||
The module is loaded via ``importlib.util.spec_from_file_location``
|
||||
so it doesn't have to be importable through a normal package path —
|
||||
callers can drop a script into ``migrations_sql/`` without adding
|
||||
it to ``__init__.py``. Return value of `up` is ignored.
|
||||
"""
|
||||
spec = importlib.util.spec_from_file_location(f"_mig_{path.stem}", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load migration module at {path}")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
up = getattr(mod, "up", None)
|
||||
if not callable(up):
|
||||
raise RuntimeError(
|
||||
f"Migration {path.name} must export a top-level `up(conn)` function."
|
||||
)
|
||||
return up # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def migrate(conn: sqlite3.Connection) -> None:
|
||||
"""Apply all pending migrations. Safe to call repeatedly."""
|
||||
applied = current_version(conn)
|
||||
for version, mig_file in _discover():
|
||||
for version, sql_file in _discover():
|
||||
if version <= applied:
|
||||
continue
|
||||
if mig_file.suffix == ".py":
|
||||
up = _load_py_migration(mig_file)
|
||||
with conn:
|
||||
up(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
else:
|
||||
sql = mig_file.read_text(encoding="utf-8")
|
||||
with conn:
|
||||
for stmt in sql.split(";"):
|
||||
stmt = stmt.strip()
|
||||
if stmt:
|
||||
conn.execute(stmt)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
sql = sql_file.read_text(encoding="utf-8")
|
||||
with conn:
|
||||
for stmt in sql.split(";"):
|
||||
stmt = stmt.strip()
|
||||
if stmt:
|
||||
conn.execute(stmt)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
-- 003_add_uuid_column.sql
|
||||
--
|
||||
-- Adds an explicit, indexed `uuid` column to the `modules` table.
|
||||
-- Backfills existing rows by extracting the trailing 8 hex chars of the
|
||||
-- generated id (`<prefix>_<slug>_<token_hex(4)>` per ModuleRepository._gen_id).
|
||||
--
|
||||
-- After this migration, server code that needs canonical UUID lookup
|
||||
-- (engine catalog, embed-bundle, drift hashes) does indexed equality
|
||||
-- queries against `uuid` instead of `LIKE '%_<8hex>'` string scans.
|
||||
--
|
||||
-- Partial-apply note: SQLite's Python binding implicitly commits DDL
|
||||
-- before the surrounding `with conn:` block can roll back. If the final
|
||||
-- CREATE UNIQUE INDEX fails because two pre-existing rows share the same
|
||||
-- trailing 8-hex chars (collision probability ~10^-6 for libraries with
|
||||
-- <1000 modules), the column is added but the migrations table is not
|
||||
-- bumped, and a retry would fail with `duplicate column name: uuid`.
|
||||
-- Recovery is manual: rename one of the colliding ids in the modules
|
||||
-- table, then re-run migrate(). This is acceptable for a forward-only
|
||||
-- one-shot migration on a per-user library DB. Collisions in practice
|
||||
-- are vanishingly rare and recoverable.
|
||||
|
||||
ALTER TABLE modules ADD COLUMN uuid TEXT;
|
||||
|
||||
UPDATE modules
|
||||
SET uuid = substr(id, -8)
|
||||
WHERE uuid IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_modules_uuid ON modules(uuid);
|
||||
@@ -1,152 +0,0 @@
|
||||
"""Migration 004 — unify `modules.id` with the 8-hex `uuid`.
|
||||
|
||||
Pre-state: ``modules.id`` is shaped ``<prefix>_<slug>_<8hex>``
|
||||
(e.g. ``wc_outfit_a1b2c3d4``). Migration 003 added a separate
|
||||
indexed ``uuid`` column carrying just the trailing 8 hex chars.
|
||||
|
||||
Two coexisting identifiers turned into a leak-prone abstraction —
|
||||
the SPA picker emitted the slug form, the embed-bundle endpoint
|
||||
looked rows up by uuid, and they never matched, surfacing as
|
||||
silent ``missing_target`` walker reports for every pick.
|
||||
|
||||
Post-state: ``modules.id`` IS the 8-hex uuid. There is no second
|
||||
identifier. The ``uuid`` column is dropped (SQLite ≥3.35 supports
|
||||
``ALTER TABLE … DROP COLUMN``; if the runtime sqlite is older we
|
||||
leave the column orphaned — code paths only read ``id``).
|
||||
|
||||
Cross-refs inside payload JSON also need rewriting so they point at
|
||||
the new short ids:
|
||||
|
||||
- ``pipeline.steps[].module_id``
|
||||
- ``constraint.source_wildcard_id`` / ``target_wildcard_id``
|
||||
- ``derivation`` rule fields if any reference modules by id
|
||||
(current schema doesn't, but we scan defensively)
|
||||
|
||||
Wildcard option values (``@{8hex}`` ref tokens) are already short by
|
||||
construction — the tokenizer regex never matched the slug form.
|
||||
Same for combine templates. No rewrite needed there.
|
||||
|
||||
Idempotent: rerunning is a no-op once ``id == uuid`` for every row
|
||||
(mapping table is empty → every UPDATE sets a column to its current
|
||||
value).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _rewrite_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
mod_type: str,
|
||||
id_to_uuid: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
"""Return a new payload with cross-refs rewritten to short uuids.
|
||||
|
||||
The mapping is keyed by the OLD slugged id; values not present in
|
||||
the mapping (already-short refs, dangling refs to deleted modules)
|
||||
pass through unchanged.
|
||||
"""
|
||||
if mod_type == "pipeline":
|
||||
steps = payload.get("steps")
|
||||
if isinstance(steps, list):
|
||||
new_steps: list[Any] = []
|
||||
for step in steps:
|
||||
if isinstance(step, dict) and isinstance(step.get("module_id"), str):
|
||||
old = step["module_id"]
|
||||
new_steps.append({**step, "module_id": id_to_uuid.get(old, old)})
|
||||
else:
|
||||
new_steps.append(step)
|
||||
payload = {**payload, "steps": new_steps}
|
||||
elif mod_type == "constraint":
|
||||
out = dict(payload)
|
||||
for key in ("source_wildcard_id", "target_wildcard_id"):
|
||||
v = out.get(key)
|
||||
if isinstance(v, str):
|
||||
out[key] = id_to_uuid.get(v, v)
|
||||
payload = out
|
||||
return payload
|
||||
|
||||
|
||||
def _has_uuid_column(conn: sqlite3.Connection) -> bool:
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(modules);")}
|
||||
return "uuid" in cols
|
||||
|
||||
|
||||
def _has_uuid_index(conn: sqlite3.Connection) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='index' AND name='idx_modules_uuid';"
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
# Need row-as-dict access for column-name lookups even if the
|
||||
# caller's connection wasn't configured with `Row` factory.
|
||||
prev_factory = conn.row_factory
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
# Build the (old_id → uuid) mapping. After 003 every row has
|
||||
# a uuid column populated. Skip rows where id already equals
|
||||
# uuid so reruns are no-ops.
|
||||
if not _has_uuid_column(conn):
|
||||
# 003 not yet applied (shouldn't happen — runner enforces
|
||||
# ordering — but be defensive).
|
||||
return
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, uuid, type, payload FROM modules;"
|
||||
).fetchall()
|
||||
|
||||
id_to_uuid: dict[str, str] = {}
|
||||
for row in rows:
|
||||
old_id = row["id"]
|
||||
uuid = row["uuid"]
|
||||
if old_id != uuid and isinstance(uuid, str) and uuid:
|
||||
id_to_uuid[old_id] = uuid
|
||||
|
||||
# Rewrite payload cross-refs first — references must point at
|
||||
# the new short ids by the time we update the id column itself.
|
||||
for row in rows:
|
||||
mod_type = row["type"]
|
||||
try:
|
||||
payload = json.loads(row["payload"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
new_payload = _rewrite_payload(
|
||||
payload, mod_type=mod_type, id_to_uuid=id_to_uuid,
|
||||
)
|
||||
if new_payload is not payload:
|
||||
conn.execute(
|
||||
"UPDATE modules SET payload = ? WHERE id = ?;",
|
||||
(json.dumps(new_payload), row["id"]),
|
||||
)
|
||||
|
||||
# Now rewrite the id column itself. Only touches rows where
|
||||
# id != uuid; rerunning is a no-op.
|
||||
if id_to_uuid:
|
||||
conn.executemany(
|
||||
"UPDATE modules SET id = ? WHERE id = ?;",
|
||||
[(uuid, old) for old, uuid in id_to_uuid.items()],
|
||||
)
|
||||
|
||||
# Tear down the now-redundant uuid index + column. SQLite has
|
||||
# supported ALTER TABLE … DROP COLUMN since 3.35 (2021-03).
|
||||
# If the running sqlite is older the DROP raises — catch and
|
||||
# leave the column orphaned. Code paths only read id from
|
||||
# this point forward.
|
||||
if _has_uuid_index(conn):
|
||||
conn.execute("DROP INDEX idx_modules_uuid;")
|
||||
if _has_uuid_column(conn):
|
||||
try:
|
||||
conn.execute("ALTER TABLE modules DROP COLUMN uuid;")
|
||||
except sqlite3.OperationalError:
|
||||
# Older sqlite — leave column. Fine: nothing reads it.
|
||||
pass
|
||||
finally:
|
||||
conn.row_factory = prev_factory
|
||||
@@ -1,43 +0,0 @@
|
||||
-- Bundles — library-tracked groupings of modules.
|
||||
--
|
||||
-- A bundle is a deep-cloned snapshot package: when the user "Save as
|
||||
-- bundle", the full payloads + instance overrides of every contained
|
||||
-- module are serialized into this table's `children` column as a JSON
|
||||
-- array. On insert into a Context, the frontend deserializes that
|
||||
-- JSON, regenerates uuids, and splices into modules[].
|
||||
--
|
||||
-- Bundles are intentionally frozen — library updates to the original
|
||||
-- wildcards / combines / etc do NOT propagate into existing bundles.
|
||||
-- Drift detection happens at the child instance level via existing
|
||||
-- per-kind logic (each spliced child carries its own library_id +
|
||||
-- payload_hash).
|
||||
--
|
||||
-- `color` is the user-picked hex for the frame around the bundle's
|
||||
-- range in ContextWidget. When NULL/empty, the UI falls back to the
|
||||
-- default `#46566B` token.
|
||||
--
|
||||
-- `payload_hash` is the content hash of `children` (and any other
|
||||
-- fields that affect runtime behavior). Each inserted instance
|
||||
-- captures this at insert time as `inserted_at_hash` so the UI can
|
||||
-- show an informational "library updated" hint when the live entry's
|
||||
-- hash diverges. This is NOT a conflict — bundles are frozen by
|
||||
-- design.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bundles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
color TEXT,
|
||||
category_id TEXT REFERENCES module_categories(id) ON DELETE SET NULL,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||
children TEXT NOT NULL DEFAULT '[]',
|
||||
payload_hash TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bundles_name ON bundles(name COLLATE NOCASE);
|
||||
CREATE INDEX IF NOT EXISTS idx_bundles_category ON bundles(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bundles_updated_at ON bundles(updated_at DESC);
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Add snapshot_fingerprint to the modules table.
|
||||
-- Computed on next write via ModuleRepository.create() / update().
|
||||
-- Existing rows from before this migration keep NULL until next write —
|
||||
-- Task 8 (collision detection) treats NULL as "no collision" so legacy
|
||||
-- rows never block imports.
|
||||
|
||||
ALTER TABLE modules ADD COLUMN snapshot_fingerprint TEXT;
|
||||
@@ -1,23 +0,0 @@
|
||||
-- Import undo metadata. Stores the snapshot bundle needed to reverse a
|
||||
-- single `commit_import` call: which ids the commit inserted (so undo
|
||||
-- deletes them) and the full pre-replace row for every id the commit
|
||||
-- overwrote (so undo restores them).
|
||||
--
|
||||
-- One row per import. The row id is the `undo_id` returned by
|
||||
-- `commit_import` and consumed by `undo_import`. There is no GC policy
|
||||
-- in this migration — the wp_api layer (Task 14) decides when to evict
|
||||
-- old undo entries (e.g. "keep last N" or "expire after T").
|
||||
--
|
||||
-- All three JSON columns are opaque to SQLite — the importer module
|
||||
-- defines + reads the shapes. See `engine/importer.py` module docstring
|
||||
-- for the schema.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_undo (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
imported_records TEXT NOT NULL, -- JSON list[{kind, id}]
|
||||
replaced_snapshots TEXT NOT NULL, -- JSON dict[id, {kind, row}]
|
||||
rename_map TEXT NOT NULL -- JSON dict[old_id, new_id]
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_import_undo_created_at ON import_undo(created_at DESC);
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Backfill ``modules.snapshot_fingerprint`` for any row left NULL by
|
||||
migration 006.
|
||||
|
||||
Migration 006 added the column but deferred populating it until the next
|
||||
write (``ModuleRepository.update``/``create``). Rows that haven't been
|
||||
touched since the upgrade therefore carry NULL — and the import-export
|
||||
collision detector flags those as ``exists-unknown`` (amber "EXISTING"
|
||||
badge), which is correct but misleading: a freshly-exported-and-imported
|
||||
NULL row looks "drifted" when the content is actually byte-identical.
|
||||
|
||||
Running the backfill eagerly here turns every NULL into the same djb2
|
||||
hash ``create`` would produce, so identical content reads as
|
||||
``silent-skip`` instead of ``exists-unknown``. Pure read+UPDATE; no
|
||||
schema change, no version bump on the row itself (``version`` stays
|
||||
where the user left it).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from engine._fingerprint import module_fingerprint
|
||||
from engine.modules.snapshot import payload_hash
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
rows = conn.execute(
|
||||
"SELECT id, type, name, description, tags, payload "
|
||||
"FROM modules WHERE snapshot_fingerprint IS NULL;"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
payload = json.loads(row["payload"])
|
||||
tags = json.loads(row["tags"])
|
||||
fp = module_fingerprint({
|
||||
"type": row["type"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"tags": tags,
|
||||
"payload_hash": payload_hash(payload),
|
||||
})
|
||||
conn.execute(
|
||||
"UPDATE modules SET snapshot_fingerprint = ? WHERE id = ?;",
|
||||
(fp, row["id"]),
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
-- Cascade-edit undo. One row per cascade-apply transaction.
|
||||
-- snapshot_before / snapshot_after are JSON arrays of entity rows
|
||||
-- captured pre- and post-mutation, used to atomically restore on undo.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cascade_undo (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
target_kind TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
snapshot_before TEXT NOT NULL,
|
||||
snapshot_after TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cascade_undo_created_at ON cascade_undo(created_at DESC);
|
||||
@@ -1,161 +0,0 @@
|
||||
"""Backfill stable per-option ids and migrate constraint exception strings.
|
||||
|
||||
After this migration:
|
||||
* Every wildcard's payload.options[i] has a unique 8-hex `id` field.
|
||||
* Every constraint's payload.exceptions[i] references options by
|
||||
`source_id` / `target_id` instead of value strings.
|
||||
* Exceptions whose source or target string can no longer be resolved
|
||||
against the current option list are moved to `broken_exceptions[]`
|
||||
with a `reason` field describing the failure mode (source_value not
|
||||
found, target_value not found, source_wildcard missing,
|
||||
target_wildcard missing, ambiguous_match).
|
||||
|
||||
The migration is forward-only. The runner ensures it only applies
|
||||
once, but the implementation is defensive: existing option ids are
|
||||
preserved, and exceptions already in the new shape are left untouched.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _load_modules(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, type, payload FROM modules WHERE type IN ('wildcard', 'constraint');"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
{"id": r["id"], "type": r["type"], "payload": json.loads(r["payload"])}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _backfill_option_ids(payload: dict[str, Any]) -> tuple[bool, dict[str, Any]]:
|
||||
changed = False
|
||||
new_opts = []
|
||||
for opt in payload.get("options") or []:
|
||||
new_opt = dict(opt)
|
||||
if not isinstance(new_opt.get("id"), str) or not new_opt["id"]:
|
||||
new_opt["id"] = secrets.token_hex(4)
|
||||
changed = True
|
||||
new_opts.append(new_opt)
|
||||
if changed:
|
||||
payload = dict(payload)
|
||||
payload["options"] = new_opts
|
||||
return changed, payload
|
||||
|
||||
|
||||
def _option_value_index(wc: dict[str, Any]) -> tuple[dict[str, str], set[str]]:
|
||||
"""Return (value_to_id_map, ambiguous_values_set) for one wildcard."""
|
||||
value_to_id: dict[str, str] = {}
|
||||
counts: dict[str, int] = {}
|
||||
for opt in wc["payload"].get("options") or []:
|
||||
v = opt.get("value")
|
||||
oid = opt.get("id")
|
||||
if not isinstance(v, str) or not isinstance(oid, str):
|
||||
continue
|
||||
counts[v] = counts.get(v, 0) + 1
|
||||
value_to_id[v] = oid
|
||||
ambiguous = {v for v, count in counts.items() if count > 1}
|
||||
return value_to_id, ambiguous
|
||||
|
||||
|
||||
def _resolve_axis(
|
||||
value: Any,
|
||||
wildcard_id: str | None,
|
||||
wildcards_by_id: dict[str, dict[str, Any]],
|
||||
axis_label: str,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Return (option_id, reason) for a single exception axis.
|
||||
|
||||
On success: (id, None). On failure: (None, reason_string).
|
||||
"""
|
||||
if wildcard_id is None:
|
||||
return None, f"{axis_label}_wildcard_id missing"
|
||||
if wildcard_id not in wildcards_by_id:
|
||||
return None, f"{axis_label}_wildcard missing"
|
||||
if not isinstance(value, str):
|
||||
return None, f"{axis_label}_value not a string"
|
||||
lookup, ambiguous = _option_value_index(wildcards_by_id[wildcard_id])
|
||||
if value in ambiguous:
|
||||
return None, f"ambiguous_match: {axis_label}"
|
||||
if value not in lookup:
|
||||
return None, f"{axis_label}_value not found: {value!r}"
|
||||
return lookup[value], None
|
||||
|
||||
|
||||
def _migrate_constraint_exceptions(
|
||||
payload: dict[str, Any],
|
||||
wildcards_by_id: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
src_id = payload.get("source_wildcard_id")
|
||||
tgt_id = payload.get("target_wildcard_id")
|
||||
|
||||
new_exceptions: list[dict[str, Any]] = []
|
||||
broken: list[dict[str, Any]] = list(payload.get("broken_exceptions") or [])
|
||||
|
||||
for ex in payload.get("exceptions") or []:
|
||||
if "source_id" in ex and "target_id" in ex:
|
||||
new_exceptions.append(ex)
|
||||
continue
|
||||
|
||||
sid, sreason = _resolve_axis(ex.get("source"), src_id, wildcards_by_id, "source")
|
||||
if sreason:
|
||||
broken.append({**ex, "reason": sreason})
|
||||
continue
|
||||
tid, treason = _resolve_axis(ex.get("target"), tgt_id, wildcards_by_id, "target")
|
||||
if treason:
|
||||
broken.append({**ex, "reason": treason})
|
||||
continue
|
||||
|
||||
# Keep `source` / `target` value strings alongside new ids.
|
||||
# The runtime constraint resolver still keys instance-override
|
||||
# lookups by encoded (source_value, target_value) pairs (see
|
||||
# `engine/modules/constraint_handler.py`'s exception loop), so
|
||||
# dropping the legacy fields would break Tier-2 overrides for
|
||||
# any constraint touched by this migration. Cascade indexing
|
||||
# uses `source_id`/`target_id`; runtime stays on value strings.
|
||||
migrated = dict(ex)
|
||||
migrated["source_id"] = sid
|
||||
migrated["target_id"] = tid
|
||||
new_exceptions.append(migrated)
|
||||
|
||||
payload = dict(payload)
|
||||
payload["exceptions"] = new_exceptions
|
||||
if broken:
|
||||
payload["broken_exceptions"] = broken
|
||||
return payload
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
mods = _load_modules(conn)
|
||||
wildcards_by_id: dict[str, dict[str, Any]] = {}
|
||||
|
||||
cur = conn.cursor()
|
||||
# Pass 1: backfill option ids on every wildcard.
|
||||
for m in mods:
|
||||
if m["type"] != "wildcard":
|
||||
continue
|
||||
changed, new_payload = _backfill_option_ids(m["payload"])
|
||||
m["payload"] = new_payload
|
||||
if changed:
|
||||
cur.execute(
|
||||
"UPDATE modules SET payload = ? WHERE id = ?;",
|
||||
(json.dumps(new_payload), m["id"]),
|
||||
)
|
||||
wildcards_by_id[m["id"]] = m
|
||||
|
||||
# Pass 2: migrate constraint exceptions.
|
||||
for m in mods:
|
||||
if m["type"] != "constraint":
|
||||
continue
|
||||
migrated = _migrate_constraint_exceptions(m["payload"], wildcards_by_id)
|
||||
if migrated != m["payload"]:
|
||||
cur.execute(
|
||||
"UPDATE modules SET payload = ? WHERE id = ?;",
|
||||
(json.dumps(migrated), m["id"]),
|
||||
)
|
||||
@@ -1,160 +0,0 @@
|
||||
"""Backfill the `#name` segment into every `@{uuid}` / `@{uuid:subcat}` ref.
|
||||
|
||||
The ref grammar gained an optional `#name` cache so a broken ref can
|
||||
still tell the user which wildcard it pointed at. Existing rows store
|
||||
bare-uuid refs; this migration walks every surface that holds ref
|
||||
strings and rewrites them with the current library name.
|
||||
|
||||
Surfaces walked:
|
||||
* `wildcard.payload.options[].value` — other wildcards' option text.
|
||||
* `derivation.payload.rules[].branches[].actions[].*` — derivation
|
||||
rules' string action fields (the only non-wildcard surface where
|
||||
`@{uuid}` refs resolve at runtime).
|
||||
* `bundle.children[].payload.options[].value` — frozen module
|
||||
snapshots inside bundle library entries. Same shape as live
|
||||
wildcards, walked separately so the snapshot survives in lockstep.
|
||||
|
||||
Refs whose target uuid doesn't resolve to a live wildcard are left
|
||||
unchanged (bare-uuid form). On the next save the cascade-rename fixer
|
||||
will either fill them in or the user re-attaches the missing entry.
|
||||
|
||||
The migration is forward-only and idempotent: refs that already
|
||||
carry a `#name` segment (e.g. authored after this code lands) pass
|
||||
through unchanged. payload_hash + fingerprint columns are recomputed
|
||||
in-place by the repository layer the next time each row is updated;
|
||||
this script only touches the JSON blobs so the workflow JSON the
|
||||
engine reads is consistent with the new tokenizer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
# Same shape as `engine/syntax/tokenize.py:_REF_RE` minus the anchors.
|
||||
# Groups: 1=uuid, 2=optional existing name, 3=optional subcat list.
|
||||
_REF_RE = re.compile(r"@\{([0-9a-f]{8})(?:#([^#:}@{]*))?(?::([^}]*))?\}")
|
||||
|
||||
|
||||
def _build_wildcard_name_map(conn: sqlite3.Connection) -> dict[str, str]:
|
||||
"""{uuid → current display name} for every wildcard in the library."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, name FROM modules WHERE type = 'wildcard';")
|
||||
return {row["id"]: row["name"] for row in cur.fetchall()}
|
||||
|
||||
|
||||
def _rewrite_ref_string(s: str, name_map: dict[str, str]) -> str:
|
||||
"""Apply the name cache to every ref in *s*. Returns the input
|
||||
unchanged when no rewrite was needed (preserves identity so the
|
||||
caller can cheaply detect changes)."""
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
uuid = m.group(1)
|
||||
old_name = m.group(2)
|
||||
subcat = m.group(3)
|
||||
live_name = name_map.get(uuid)
|
||||
# Skip refs whose target wildcard is gone — bare-uuid form is
|
||||
# honest signal that the ref is broken.
|
||||
if live_name is None:
|
||||
return m.group(0)
|
||||
# Idempotency: if the existing name already matches live, no-op.
|
||||
if old_name == live_name:
|
||||
return m.group(0)
|
||||
sub_seg = f":{subcat}" if subcat is not None else ""
|
||||
return f"@{{{uuid}#{live_name}{sub_seg}}}"
|
||||
return _REF_RE.sub(repl, s)
|
||||
|
||||
|
||||
def _walk_wildcard_payload(payload: dict[str, Any], name_map: dict[str, str]) -> bool:
|
||||
"""In-place mutate option values. Returns True when anything changed."""
|
||||
changed = False
|
||||
for opt in payload.get("options") or []:
|
||||
v = opt.get("value")
|
||||
if isinstance(v, str):
|
||||
new_v = _rewrite_ref_string(v, name_map)
|
||||
if new_v != v:
|
||||
opt["value"] = new_v
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _walk_derivation_payload(payload: dict[str, Any], name_map: dict[str, str]) -> bool:
|
||||
"""In-place mutate string fields on rule actions."""
|
||||
changed = False
|
||||
for rule in payload.get("rules") or []:
|
||||
for branch in rule.get("branches") or []:
|
||||
for action in branch.get("actions") or []:
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
for k, v in list(action.items()):
|
||||
if isinstance(v, str):
|
||||
new_v = _rewrite_ref_string(v, name_map)
|
||||
if new_v != v:
|
||||
action[k] = new_v
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _walk_bundle_children(
|
||||
children: list[Any], name_map: dict[str, str],
|
||||
) -> bool:
|
||||
"""In-place mutate children list. Mirrors the engine fixer's bundle
|
||||
walk — module snapshots get their option values rewritten."""
|
||||
changed = False
|
||||
for ch in children:
|
||||
if not isinstance(ch, dict):
|
||||
continue
|
||||
if ch.get("type") != "wildcard":
|
||||
continue
|
||||
payload = ch.get("payload")
|
||||
if isinstance(payload, dict):
|
||||
if _walk_wildcard_payload(payload, name_map):
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
name_map = _build_wildcard_name_map(conn)
|
||||
if not name_map:
|
||||
return # empty library — nothing to rewrite
|
||||
|
||||
# Modules — wildcards + derivations carry ref strings.
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, type, payload FROM modules "
|
||||
"WHERE type IN ('wildcard', 'derivation');"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
for row in rows:
|
||||
try:
|
||||
payload = json.loads(row["payload"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
if row["type"] == "wildcard":
|
||||
changed = _walk_wildcard_payload(payload, name_map)
|
||||
else:
|
||||
changed = _walk_derivation_payload(payload, name_map)
|
||||
if changed:
|
||||
conn.execute(
|
||||
"UPDATE modules SET payload = ? WHERE id = ?;",
|
||||
(json.dumps(payload), row["id"]),
|
||||
)
|
||||
|
||||
# Bundles — children snapshots may carry ref strings inside frozen
|
||||
# module copies.
|
||||
cur.execute("SELECT id, children FROM bundles;")
|
||||
bundle_rows = cur.fetchall()
|
||||
for row in bundle_rows:
|
||||
try:
|
||||
children = json.loads(row["children"]) if row["children"] else []
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
if not isinstance(children, list):
|
||||
continue
|
||||
if _walk_bundle_children(children, name_map):
|
||||
conn.execute(
|
||||
"UPDATE bundles SET children = ? WHERE id = ?;",
|
||||
(json.dumps(children), row["id"]),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
@@ -1,27 +0,0 @@
|
||||
-- Prompt templates — reusable PromptAssembler template strings.
|
||||
--
|
||||
-- A template is just the assembler's `template` STRING ($var tokens).
|
||||
-- Loading copies the string into a node's template widget with NO stored
|
||||
-- back-reference, so (unlike bundles) templates can't drift — hence no
|
||||
-- payload_hash / version columns. Referenced var names are derived from
|
||||
-- the string on demand, never stored.
|
||||
--
|
||||
-- `category_id` FK mirrors bundles (ON DELETE SET NULL) — the API catches
|
||||
-- sqlite3.IntegrityError on a bad category_id and returns 400. NOTE: the
|
||||
-- migration runner splits on the semicolon char, so comments must avoid it.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
category_id TEXT REFERENCES module_categories(id) ON DELETE SET NULL,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||
template_string TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_templates_name ON templates(name COLLATE NOCASE);
|
||||
CREATE INDEX IF NOT EXISTS idx_templates_category ON templates(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_templates_updated_at ON templates(updated_at DESC);
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Migration 013 — community install origin columns.
|
||||
|
||||
Adds ``community_post_slug`` and ``community_version_number`` to both
|
||||
``modules`` and ``bundles``. Stamped at install time by the host-bridge
|
||||
``install()`` path (via the embed's ``origin`` opt) so the SPA can:
|
||||
|
||||
- Render an "installed from community" pill on origin-stamped rows.
|
||||
- Run an update-availability check that compares
|
||||
``community_version_number`` against the post's
|
||||
``latest_version_number`` in the community API.
|
||||
|
||||
Both columns are nullable on purpose — locally-authored rows (Create
|
||||
flow, Import-from-file, starter set) leave them NULL forever.
|
||||
|
||||
Idempotent: each ALTER is gated on ``PRAGMA table_info`` so re-running
|
||||
the migration against a DB that already has the columns is a no-op.
|
||||
The migration runner's existing version table guards against re-runs
|
||||
in production; this guard exists for tests that explicitly rewind the
|
||||
``migrations`` table to exercise the migration on a populated DB
|
||||
(see ``tests/engine/db/test_migration_010.py``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
cur = conn.execute(f"PRAGMA table_info({table});")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
with conn:
|
||||
if not _has_column(conn, "modules", "community_post_slug"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN community_post_slug TEXT;")
|
||||
if not _has_column(conn, "modules", "community_version_number"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN community_version_number INTEGER;")
|
||||
if not _has_column(conn, "bundles", "community_post_slug"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN community_post_slug TEXT;")
|
||||
if not _has_column(conn, "bundles", "community_version_number"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN community_version_number INTEGER;")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_modules_community_slug "
|
||||
"ON modules(community_post_slug);"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_bundles_community_slug "
|
||||
"ON bundles(community_post_slug);"
|
||||
)
|
||||
@@ -1,86 +0,0 @@
|
||||
"""Migration 014: payload schema_version + verbatim-local mirror columns.
|
||||
|
||||
See the community-web design doc at
|
||||
docs/superpowers/specs/2026-06-05-schema-versioning-migration-design.md
|
||||
(in the community-web repo) sections 4 + 5 for the design. The columns:
|
||||
|
||||
- schema_version (INTEGER NOT NULL DEFAULT 1): the payload's shape
|
||||
version. ``1`` is the implicit baseline — every row that existed
|
||||
before this migration was authored against shape v1, so the DEFAULT
|
||||
doubles as the backfill.
|
||||
- original_payload_json (TEXT, nullable): verbatim future-shaped
|
||||
producer bytes preserved through tolerant installs. NULL means no
|
||||
preservation was needed (row was installed at-or-below CURRENT, i.e.
|
||||
no future-shape tolerance was applied).
|
||||
- tolerant_drift_status (TEXT NOT NULL DEFAULT 'none'): enum tracking
|
||||
the drift state of the row's payload vs the catalog. ``'none'`` = no
|
||||
drift; ``'pending'`` = drifted (tolerant-installed from a future
|
||||
shape and not yet reconciled); ``'broken'`` = deferred strict-validate
|
||||
failed.
|
||||
- schema_migrated_at (TEXT, nullable): timestamp of the last structural
|
||||
migration on the row. Intentionally SEPARATE from ``updated_at`` —
|
||||
structural migration is the engine moving the payload between shape
|
||||
versions, not a user edit, so it must not dirty user-edit state.
|
||||
|
||||
Backfill: every pre-existing row stamps ``schema_version=1`` and
|
||||
``tolerant_drift_status='none'`` implicitly via the DEFAULT on the
|
||||
NOT NULL columns — SQLite materializes the default into existing rows
|
||||
when ADD COLUMN runs.
|
||||
|
||||
Idempotent: each ALTER is gated on ``PRAGMA table_info`` so re-running
|
||||
the migration against a DB that already has the columns is a no-op.
|
||||
The migration runner's existing version table guards against re-runs
|
||||
in production; this guard exists for tests that explicitly rewind the
|
||||
``migrations`` table to exercise the migration on a populated DB
|
||||
(see ``tests/engine/db/test_migration_010.py``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from engine.db.migrations_sql._014_helpers import run_bulk_lazy
|
||||
|
||||
|
||||
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
cur = conn.execute(f"PRAGMA table_info({table});")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
with conn:
|
||||
# modules
|
||||
if not _has_column(conn, "modules", "schema_version"):
|
||||
conn.execute(
|
||||
"ALTER TABLE modules ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;"
|
||||
)
|
||||
if not _has_column(conn, "modules", "original_payload_json"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN original_payload_json TEXT;")
|
||||
if not _has_column(conn, "modules", "tolerant_drift_status"):
|
||||
conn.execute(
|
||||
"ALTER TABLE modules ADD COLUMN tolerant_drift_status "
|
||||
"TEXT NOT NULL DEFAULT 'none';"
|
||||
)
|
||||
if not _has_column(conn, "modules", "schema_migrated_at"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN schema_migrated_at TEXT;")
|
||||
|
||||
# bundles
|
||||
if not _has_column(conn, "bundles", "schema_version"):
|
||||
conn.execute(
|
||||
"ALTER TABLE bundles ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;"
|
||||
)
|
||||
if not _has_column(conn, "bundles", "original_payload_json"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN original_payload_json TEXT;")
|
||||
if not _has_column(conn, "bundles", "tolerant_drift_status"):
|
||||
conn.execute(
|
||||
"ALTER TABLE bundles ADD COLUMN tolerant_drift_status "
|
||||
"TEXT NOT NULL DEFAULT 'none';"
|
||||
)
|
||||
if not _has_column(conn, "bundles", "schema_migrated_at"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN schema_migrated_at TEXT;")
|
||||
|
||||
# Bulk run: at the time 014 ships, CURRENT_SCHEMA_VERSION is still 1, so
|
||||
# every existing row is already at CURRENT and the bulk is a no-op pass.
|
||||
# We invoke it anyway to exercise + validate the wiring on first run.
|
||||
# FUTURE: when a bump migration 015 ships, it ALSO calls run_bulk_lazy
|
||||
# with the new CURRENT and the bump's migrators.
|
||||
run_bulk_lazy(conn, current_version=1, migrators={}, validators={}, tolerant_strip={})
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Migration 015 -- content_rating column on library rows.
|
||||
|
||||
Adds ``content_rating`` (TEXT, default 'safe') to both ``modules`` and
|
||||
``bundles``. Stamped by:
|
||||
|
||||
- The IdentityCard's NSFW toggle in every editor (user-controlled
|
||||
flag the author sets on their own row).
|
||||
- The community install bridge when ``opts.origin.content_rating`` is
|
||||
supplied -- NSFW community posts auto-stamp the row at install
|
||||
time, so the SPA can render the ``18+`` pill without an extra
|
||||
fetch.
|
||||
|
||||
Default 'safe' means rows that pre-date this migration (every
|
||||
locally-authored row prior to v2.3) end up marked safe. Toggle to
|
||||
'nsfw' if needed. No CHECK constraint at the DB level (SQLite ALTER
|
||||
TABLE does not support adding CHECK); validation happens at the
|
||||
application layer (``set_content_rating``).
|
||||
|
||||
Idempotent: each ALTER is gated on ``PRAGMA table_info``. The
|
||||
migration runner's version table is the primary re-run guard; the
|
||||
column check exists for test fixtures that rewind ``migrations``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
cur = conn.execute(f"PRAGMA table_info({table});")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
with conn:
|
||||
if not _has_column(conn, "modules", "content_rating"):
|
||||
conn.execute(
|
||||
"ALTER TABLE modules ADD COLUMN content_rating TEXT "
|
||||
"NOT NULL DEFAULT 'safe';"
|
||||
)
|
||||
if not _has_column(conn, "bundles", "content_rating"):
|
||||
conn.execute(
|
||||
"ALTER TABLE bundles ADD COLUMN content_rating TEXT "
|
||||
"NOT NULL DEFAULT 'safe';"
|
||||
)
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Migration 016: wildcard multi-sub-category v2 (SP1).
|
||||
|
||||
Marches every stale row from schema_version 1 -> 2 by eager iteration of
|
||||
the lazy-read routine (see `_014_helpers.run_bulk_lazy`), using the SP1
|
||||
per-row migrators. The transform:
|
||||
|
||||
* option `sub_category: "x"` -> `sub_categories: ["x"]`;
|
||||
* sub-category names slugified (whitespace / grammar chars -> `_`),
|
||||
reserved words suffixed, de-duped, cascaded to options + groups;
|
||||
* nested `@{uuid:a,b,null}` refs -> `@{uuid:a or b!null}`.
|
||||
|
||||
See engine/migrations/v1_to_v2.py for the shared transform + the envelope
|
||||
mirror used by import/export, and CLAUDE.md "Schema versioning".
|
||||
|
||||
Idempotent: rows already at schema_version 2 are not stale, so a re-run
|
||||
(or a run on a fresh DB with no v1 rows) is a no-op. validators /
|
||||
tolerant_strip are empty — the forward 1->2 chain needs neither (there is
|
||||
no future-shaped `original_payload_json` to recover at this bump).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from engine.db.migrations_sql._014_helpers import run_bulk_lazy
|
||||
from engine.migrations.v1_to_v2 import (
|
||||
migrate_bundle_v1_to_v2,
|
||||
migrate_module_v1_to_v2,
|
||||
)
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
run_bulk_lazy(
|
||||
conn,
|
||||
current_version=2,
|
||||
migrators={
|
||||
("module", 1): migrate_module_v1_to_v2,
|
||||
("bundle", 1): migrate_bundle_v1_to_v2,
|
||||
},
|
||||
validators={},
|
||||
tolerant_strip={},
|
||||
)
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Helpers for migration 014 — the bulk runner used at startup + at
|
||||
future payload-bump migrations.
|
||||
|
||||
See community-web spec
|
||||
D:\\Desktop\\Wildcard-Pipeline-Community\\docs\\superpowers\\specs\\
|
||||
2026-06-05-schema-versioning-migration-design.md §4 Flow 4: bulk is
|
||||
EAGER ITERATION of the lazy-read routine, NOT a parallel forward-chain
|
||||
implementation. This eliminates the transient-wrong window where bulk
|
||||
could write fabricated defaults over recoverable data preserved in
|
||||
original_payload_json.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from engine.db.lazy_migrate import lazy_migrate_row
|
||||
|
||||
STALE_PREDICATE = (
|
||||
"schema_version < ? "
|
||||
"OR (original_payload_json IS NOT NULL AND "
|
||||
" json_extract(original_payload_json, '$.schema_version') > schema_version)"
|
||||
)
|
||||
|
||||
|
||||
def run_bulk_lazy(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
current_version: int,
|
||||
migrators,
|
||||
validators,
|
||||
tolerant_strip,
|
||||
) -> int:
|
||||
"""Eager iteration of lazy_migrate_row over every stale row.
|
||||
|
||||
The stale predicate catches BOTH 'sv < current' AND 'tolerantly-
|
||||
installed row whose original now outranks the projection'. The
|
||||
second clause handles rows where the extension has caught up after
|
||||
a previous tolerant install.
|
||||
|
||||
Returns count of rows that failed to migrate (caller surfaces a
|
||||
dashboard banner when > 0).
|
||||
"""
|
||||
failed = 0
|
||||
for table, kind in [("modules", "module"), ("bundles", "bundle")]:
|
||||
ids = [
|
||||
row["id"]
|
||||
for row in conn.execute(
|
||||
f"SELECT id FROM {table} WHERE {STALE_PREDICATE}",
|
||||
(current_version,),
|
||||
).fetchall()
|
||||
]
|
||||
for row_id in ids:
|
||||
try:
|
||||
lazy_migrate_row(
|
||||
conn,
|
||||
kind=kind,
|
||||
row_id=row_id,
|
||||
current_version=current_version,
|
||||
migrators=migrators,
|
||||
validators=validators,
|
||||
tolerant_strip=tolerant_strip,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — defensive sweep
|
||||
failed += 1
|
||||
continue
|
||||
# After lazy_migrate_row: if the row's schema_version is still
|
||||
# below current_version, count as failed.
|
||||
after = conn.execute(
|
||||
f"SELECT schema_version FROM {table} WHERE id=?", (row_id,)
|
||||
).fetchone()
|
||||
if after and after["schema_version"] < current_version:
|
||||
failed += 1
|
||||
return failed
|
||||
@@ -1,55 +0,0 @@
|
||||
"""Execute deferred DB file moves at plugin load.
|
||||
|
||||
The SPA Settings UI lets users change the DB location. The actual file
|
||||
operation runs here, on next ComfyUI start, BEFORE any DB connection
|
||||
opens. Errors are logged and never crash the host.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from engine.db.config import clear_pending_move, load
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def execute_pending_move() -> None:
|
||||
"""If the sidecar has a pending_move, run it and clear the entry.
|
||||
|
||||
Idempotent if the move already happened (source missing, dest present).
|
||||
Failures (source missing without dest, permission errors) are logged;
|
||||
sidecar is left intact so the UI can show the pending state on next
|
||||
page load.
|
||||
"""
|
||||
cfg = load()
|
||||
pm = cfg.get("pending_move")
|
||||
if not pm:
|
||||
return
|
||||
src = Path(pm["from"])
|
||||
dst = Path(pm["to"])
|
||||
mode = pm["mode"]
|
||||
|
||||
# Resume: previous run completed the move but failed to clear sidecar.
|
||||
if not src.exists() and dst.exists():
|
||||
logger.info("wp_db_pending_move: already-done (src missing, dst present), clearing")
|
||||
clear_pending_move()
|
||||
return
|
||||
|
||||
if not src.exists():
|
||||
logger.error("wp_db_pending_move: source %s missing, skipping", src)
|
||||
return
|
||||
|
||||
try:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if mode == "copy":
|
||||
shutil.copy2(src, dst)
|
||||
else: # move
|
||||
shutil.move(str(src), str(dst))
|
||||
except OSError as e:
|
||||
logger.exception("wp_db_pending_move: %s failed: %s", mode, e)
|
||||
return
|
||||
|
||||
clear_pending_move()
|
||||
logger.info("wp_db_pending_move: %s %s -> %s OK", mode, src, dst)
|
||||
+39
-726
@@ -12,43 +12,18 @@ import secrets
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from engine._fingerprint import module_fingerprint
|
||||
from engine._utils import now_iso as _now
|
||||
from engine.modules.snapshot import payload_hash
|
||||
|
||||
_VALID_TYPES: frozenset[str] = frozenset({
|
||||
"wildcard", "fixed_values", "combine", "derivation", "constraint",
|
||||
})
|
||||
|
||||
# Free-text adult-content flag on every library row (migration 015).
|
||||
# Stamped by the IdentityCard's NSFW toggle or auto-stamped at install
|
||||
# time when a community post carried `content_rating='nsfw'`. No DB-level
|
||||
# CHECK constraint (SQLite ALTER limit); validate at the repository.
|
||||
_VALID_CONTENT_RATINGS: frozenset[str] = frozenset({"safe", "nsfw"})
|
||||
|
||||
|
||||
def _validate_content_rating(rating: str) -> str:
|
||||
if rating not in _VALID_CONTENT_RATINGS:
|
||||
raise ValueError(
|
||||
f"content_rating must be one of {sorted(_VALID_CONTENT_RATINGS)}, "
|
||||
f"got {rating!r}"
|
||||
)
|
||||
return rating
|
||||
# Module ids are 8-hex short uuids — same shape the tokenizer's
|
||||
# `@{8hex}` ref token captures and the engine catalog keys by. Slug
|
||||
# prefixes (e.g. `wc_outfit_a1b2c3d4`) were removed in migration 004
|
||||
# to collapse the two-identifier model down to one canonical form.
|
||||
_ID_HEX_LEN = 8
|
||||
|
||||
# Categories keep slug-based ids (`subjects`, `style`, …) — human
|
||||
# readable and never need to be embedded in a tokenizer regex, so
|
||||
# the unification doesn't apply to them.
|
||||
_CATEGORY_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def _category_slug(name: str) -> str:
|
||||
s = _CATEGORY_SLUG_RE.sub("_", name.lower()).strip("_")
|
||||
return s[:24] or "module"
|
||||
_TYPE_PREFIX = {
|
||||
"wildcard": "wc",
|
||||
"fixed_values": "fv",
|
||||
"combine": "cb",
|
||||
"derivation": "dr",
|
||||
"constraint": "ct",
|
||||
"pipeline": "pl",
|
||||
}
|
||||
_VALID_TYPES: frozenset[str] = frozenset(_TYPE_PREFIX.keys())
|
||||
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
class _Unset:
|
||||
@@ -68,26 +43,12 @@ class ModuleNotFound(LookupError):
|
||||
"""Raised when a requested module id does not exist."""
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
s = _SLUG_RE.sub("_", name.lower()).strip("_")
|
||||
return s[:24] or "module"
|
||||
|
||||
|
||||
def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
|
||||
payload = json.loads(row["payload"])
|
||||
# Community origin columns (migration 013) — NULL for any row that
|
||||
# didn't come from a community install. Surfaced verbatim so the
|
||||
# SPA can render an "installed from community" badge + drive
|
||||
# update-available checks.
|
||||
try:
|
||||
community_post_slug = row["community_post_slug"]
|
||||
community_version_number = row["community_version_number"]
|
||||
except (IndexError, KeyError):
|
||||
# Defensive: a connection opened against a DB that hasn't run
|
||||
# migration 013 yet (e.g. mid-rollout, test fixtures) won't
|
||||
# have these columns. Treat absence as NULL.
|
||||
community_post_slug = None
|
||||
community_version_number = None
|
||||
try:
|
||||
content_rating = row["content_rating"]
|
||||
except (IndexError, KeyError):
|
||||
# Pre-015 fixture: column absent, treat as 'safe'.
|
||||
content_rating = "safe"
|
||||
return {
|
||||
"id": row["id"],
|
||||
"type": row["type"],
|
||||
@@ -96,15 +57,10 @@ def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
|
||||
"category_id": row["category_id"],
|
||||
"tags": json.loads(row["tags"]),
|
||||
"is_favorite": bool(row["is_favorite"]),
|
||||
"payload": payload,
|
||||
"payload_hash": payload_hash(payload),
|
||||
"snapshot_fingerprint": row["snapshot_fingerprint"], # NULL for pre-006 rows
|
||||
"payload": json.loads(row["payload"]),
|
||||
"version": row["version"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"community_post_slug": community_post_slug,
|
||||
"community_version_number": community_version_number,
|
||||
"content_rating": content_rating,
|
||||
}
|
||||
|
||||
|
||||
@@ -112,43 +68,9 @@ class ModuleRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def _gen_id(self) -> str:
|
||||
"""Generate a fresh 8-hex module id.
|
||||
|
||||
Post migration 004 the id IS the only identifier — we no
|
||||
longer carry a separate slugged form. Collisions are rejected
|
||||
by the PRIMARY KEY constraint on `modules.id`; `secrets.token_hex(4)`
|
||||
gives 4 bytes (~4.3B values), so the birthday-paradox 50% mark
|
||||
sits around 65k modules — well past the expected single-user
|
||||
library size.
|
||||
"""
|
||||
return secrets.token_hex(_ID_HEX_LEN // 2)
|
||||
|
||||
@staticmethod
|
||||
def _backfill_option_ids(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a copy of *payload* with each option assigned a stable
|
||||
8-hex `id` if missing. Wildcard payload only — caller checks type.
|
||||
|
||||
Option ids must be unique per-wildcard (validated in
|
||||
WildcardHandler.validate_payload). Existing ids are preserved so
|
||||
constraint exception refs stay intact across edits.
|
||||
|
||||
Payloads without an ``options`` key are returned unchanged. Adding
|
||||
an empty ``options: []`` would shift the payload hash for legacy
|
||||
rows that never declared an options field — see migration 008
|
||||
fingerprint parity test.
|
||||
"""
|
||||
if "options" not in payload:
|
||||
return payload
|
||||
new_payload = dict(payload)
|
||||
new_opts: list[dict[str, Any]] = []
|
||||
for opt in new_payload.get("options") or []:
|
||||
new_opt = dict(opt)
|
||||
if not isinstance(new_opt.get("id"), str) or not new_opt["id"]:
|
||||
new_opt["id"] = secrets.token_hex(_ID_HEX_LEN // 2)
|
||||
new_opts.append(new_opt)
|
||||
new_payload["options"] = new_opts
|
||||
return new_payload
|
||||
def _gen_id(self, type: str, name: str) -> str:
|
||||
prefix = _TYPE_PREFIX.get(type, "mod")
|
||||
return f"{prefix}_{_slug(name)}_{secrets.token_hex(4)}"
|
||||
|
||||
def create(
|
||||
self,
|
||||
@@ -160,54 +82,23 @@ class ModuleRepository:
|
||||
tags: list[str],
|
||||
payload: dict[str, Any],
|
||||
is_favorite: bool = False,
|
||||
id: str | None = None,
|
||||
content_rating: str = "safe",
|
||||
) -> dict[str, Any]:
|
||||
"""Insert a new module row.
|
||||
|
||||
``id`` is normally generated; pass an explicit id to import
|
||||
a workflow-resident snapshot back into the library at the
|
||||
SAME uuid (so a freshly-saved row immediately matches the
|
||||
workflow's existing references -- no broken `@{uuid}` refs).
|
||||
Validates the supplied id is 8-hex to prevent the workflow
|
||||
from injecting arbitrary primary-key shapes.
|
||||
"""
|
||||
if type not in _VALID_TYPES:
|
||||
raise ValueError(
|
||||
f"unknown module type {type!r}; expected one of {sorted(_VALID_TYPES)}"
|
||||
)
|
||||
_validate_content_rating(content_rating)
|
||||
if type == "wildcard":
|
||||
payload = self._backfill_option_ids(payload)
|
||||
if id is None:
|
||||
mid = self._gen_id()
|
||||
else:
|
||||
if not isinstance(id, str) or len(id) != _ID_HEX_LEN or not all(
|
||||
c in "0123456789abcdef" for c in id
|
||||
):
|
||||
raise ValueError(
|
||||
f"id must be a {_ID_HEX_LEN}-char lowercase-hex string"
|
||||
)
|
||||
mid = id
|
||||
fp = module_fingerprint({
|
||||
"type": type,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"tags": tags,
|
||||
"payload_hash": payload_hash(payload),
|
||||
})
|
||||
mid = self._gen_id(type, name)
|
||||
now = _now()
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"INSERT INTO modules("
|
||||
"id, type, name, description, category_id, tags, "
|
||||
"is_favorite, payload, snapshot_fingerprint, version, "
|
||||
"created_at, updated_at, content_rating"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?);",
|
||||
"is_favorite, payload, version, created_at, updated_at"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?);",
|
||||
(
|
||||
mid, type, name, description, category_id,
|
||||
json.dumps(tags), int(is_favorite),
|
||||
json.dumps(payload), fp, now, now, content_rating,
|
||||
json.dumps(payload), now, now,
|
||||
),
|
||||
)
|
||||
return self.get(mid)
|
||||
@@ -220,41 +111,6 @@ class ModuleRepository:
|
||||
raise ModuleNotFound(module_id)
|
||||
return _row_to_module(row)
|
||||
|
||||
# Back-compat aliases — `id == uuid` after migration 004, so the
|
||||
# uuid-named lookups are now thin wrappers around `get` / a bulk
|
||||
# `get` variant. Kept so external callers (test runner, embed-
|
||||
# bundle endpoint, future drift-hash endpoint) don't need code
|
||||
# changes; new code should prefer `get` / the new `get_many`.
|
||||
def get_by_uuid(self, uuid: str) -> dict[str, Any]:
|
||||
return self.get(uuid)
|
||||
|
||||
def get_many(self, ids: list[str]) -> list[dict[str, Any]]:
|
||||
"""Bulk lookup. Dedups input and returns rows in the same order
|
||||
as the (deduped) input. Silently skips ids that aren't present —
|
||||
callers needing explicit miss-detection should compare returned
|
||||
ids against the input set.
|
||||
|
||||
Note on input size: callers (test-runner walker, embed-bundle)
|
||||
pass at most a few dozen ids in practice, so we don't chunk.
|
||||
SQLite's `SQLITE_LIMIT_VARIABLE_NUMBER` defaults to 32766 on
|
||||
modern builds (3.32+) and 999 on older ones — well above any
|
||||
expected upper bound."""
|
||||
if not ids:
|
||||
return []
|
||||
unique = list(dict.fromkeys(ids)) # dedup, preserve input order
|
||||
placeholders = ",".join("?" for _ in unique)
|
||||
cur = self._conn.execute(
|
||||
f"SELECT * FROM modules WHERE id IN ({placeholders});",
|
||||
unique,
|
||||
)
|
||||
# SQLite makes no order guarantee for IN-predicate results, so
|
||||
# re-order against the input list to honor the docstring.
|
||||
by_id = {r["id"]: r for r in cur.fetchall()}
|
||||
return [_row_to_module(by_id[u]) for u in unique if u in by_id]
|
||||
|
||||
def get_by_uuids(self, uuids: list[str]) -> list[dict[str, Any]]:
|
||||
return self.get_many(uuids)
|
||||
|
||||
def update(
|
||||
self,
|
||||
module_id: str,
|
||||
@@ -265,18 +121,8 @@ class ModuleRepository:
|
||||
tags: list[str] | _Unset = _UNSET,
|
||||
payload: dict[str, Any] | _Unset = _UNSET,
|
||||
is_favorite: bool | _Unset = _UNSET,
|
||||
content_rating: str | _Unset = _UNSET,
|
||||
) -> dict[str, Any]:
|
||||
existing = self.get(module_id)
|
||||
# Backfill missing option ids when caller writes a fresh wildcard payload.
|
||||
if (
|
||||
existing["type"] == "wildcard"
|
||||
and not isinstance(payload, _Unset)
|
||||
and isinstance(payload, dict)
|
||||
):
|
||||
payload = self._backfill_option_ids(payload)
|
||||
if not isinstance(content_rating, _Unset):
|
||||
_validate_content_rating(content_rating)
|
||||
new = {
|
||||
"name": existing["name"] if isinstance(name, _Unset) else name,
|
||||
"description": (
|
||||
@@ -290,84 +136,39 @@ class ModuleRepository:
|
||||
"is_favorite": (
|
||||
existing["is_favorite"] if isinstance(is_favorite, _Unset) else is_favorite
|
||||
),
|
||||
"content_rating": (
|
||||
existing["content_rating"]
|
||||
if isinstance(content_rating, _Unset)
|
||||
else content_rating
|
||||
),
|
||||
}
|
||||
new_payload_hash = payload_hash(new["payload"])
|
||||
new_fp = module_fingerprint({
|
||||
"type": existing["type"],
|
||||
"name": new["name"],
|
||||
"description": new["description"],
|
||||
"tags": new["tags"],
|
||||
"payload_hash": new_payload_hash,
|
||||
})
|
||||
now = _now()
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
|
||||
"version = version + 1, updated_at = ?, content_rating = ? "
|
||||
"is_favorite = ?, payload = ?, version = version + 1, "
|
||||
"updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
new["name"], new["description"], new["category_id"],
|
||||
json.dumps(new["tags"]), int(new["is_favorite"]),
|
||||
json.dumps(new["payload"]), new_fp, now,
|
||||
new["content_rating"], module_id,
|
||||
json.dumps(new["payload"]), now, module_id,
|
||||
),
|
||||
)
|
||||
return self.get(module_id)
|
||||
|
||||
def set_community_origin(
|
||||
self, module_id: str, *, post_slug: str, version_number: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Stamp community origin on an existing row WITHOUT a version
|
||||
bump or fingerprint change.
|
||||
|
||||
Used by the publish-writeback path (host bridge
|
||||
``markPublished``): after a row is published to community, the
|
||||
local row gets linked to the resulting post so the SPA renders
|
||||
the community pill, the update-available checker tracks it, and
|
||||
a future re-publish detects "this is my own post" (version bump
|
||||
vs fork). Origin metadata is NOT runtime-affecting, so unlike
|
||||
``update`` this neither bumps ``version`` nor recomputes the
|
||||
snapshot fingerprint -- a published row stays byte-identical to
|
||||
what consumers already installed.
|
||||
"""
|
||||
with self._conn:
|
||||
cur = self._conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"community_post_slug = ?, community_version_number = ? "
|
||||
"WHERE id = ?;",
|
||||
(post_slug, version_number, module_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise ModuleNotFound(module_id)
|
||||
return self.get(module_id)
|
||||
|
||||
def delete(self, module_id: str) -> None:
|
||||
with self._conn:
|
||||
cur = self._conn.execute("DELETE FROM modules WHERE id = ?;", (module_id,))
|
||||
if cur.rowcount == 0:
|
||||
raise ModuleNotFound(module_id)
|
||||
|
||||
def _build_filter_clause(
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
type: str | None,
|
||||
category_id: str | None,
|
||||
query: str | None,
|
||||
favorites_only: bool,
|
||||
) -> tuple[str, list[Any]]:
|
||||
"""Build the WHERE-clause fragment + bound params shared by list/count.
|
||||
|
||||
Extracted so list() (paginated rows) and count() (total ignoring
|
||||
pagination) apply the same filters bit-for-bit. Mismatched filters
|
||||
between the two paths is the kind of bug that silently makes
|
||||
Dashboard counts wrong — the helper closes that loop."""
|
||||
type: str | None = None,
|
||||
category_id: str | None = None,
|
||||
query: str | None = None,
|
||||
favorites_only: bool = False,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if type is not None:
|
||||
@@ -386,24 +187,10 @@ class ModuleRepository:
|
||||
params.append(f"%{escaped}%")
|
||||
if favorites_only:
|
||||
clauses.append("is_favorite = 1")
|
||||
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
return where, params
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
type: str | None = None,
|
||||
category_id: str | None = None,
|
||||
query: str | None = None,
|
||||
favorites_only: bool = False,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
where, params = self._build_filter_clause(
|
||||
type=type, category_id=category_id, query=query,
|
||||
favorites_only=favorites_only,
|
||||
)
|
||||
sql = "SELECT * FROM modules" + where + " ORDER BY updated_at DESC, id DESC"
|
||||
sql = "SELECT * FROM modules"
|
||||
if clauses:
|
||||
sql += " WHERE " + " AND ".join(clauses)
|
||||
sql += " ORDER BY updated_at DESC, id DESC"
|
||||
if limit is not None:
|
||||
sql += " LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
@@ -414,26 +201,6 @@ class ModuleRepository:
|
||||
rows = self._conn.execute(sql, params).fetchall()
|
||||
return [_row_to_module(r) for r in rows]
|
||||
|
||||
def count(
|
||||
self,
|
||||
*,
|
||||
type: str | None = None,
|
||||
category_id: str | None = None,
|
||||
query: str | None = None,
|
||||
favorites_only: bool = False,
|
||||
) -> int:
|
||||
"""Total rows matching the same filters as list(), ignoring limit
|
||||
and offset. Used by the API to populate `ModuleListResponse.total`
|
||||
accurately even when the client passed `limit=1` for cheap polling
|
||||
(Dashboard count cards do exactly this)."""
|
||||
where, params = self._build_filter_clause(
|
||||
type=type, category_id=category_id, query=query,
|
||||
favorites_only=favorites_only,
|
||||
)
|
||||
sql = "SELECT COUNT(*) AS n FROM modules" + where
|
||||
row = self._conn.execute(sql, params).fetchone()
|
||||
return int(row["n"]) if row is not None else 0
|
||||
|
||||
|
||||
class CategoryNotFound(LookupError):
|
||||
"""Raised when a requested category id does not exist."""
|
||||
@@ -461,7 +228,7 @@ class CategoryRepository:
|
||||
icon: str | None,
|
||||
sort_order: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
cid = _category_slug(name)
|
||||
cid = _slug(name)
|
||||
if cid == "module":
|
||||
# _slug returned its fallback because `name` had no alphanumeric chars.
|
||||
raise ValueError(
|
||||
@@ -540,457 +307,3 @@ class CategoryRepository:
|
||||
"ORDER BY sort_order ASC, name COLLATE NOCASE ASC;"
|
||||
).fetchall()
|
||||
return [_row_to_category(r) for r in rows]
|
||||
|
||||
|
||||
class BundleNotFound(LookupError):
|
||||
"""Raised when a requested bundle id does not exist."""
|
||||
|
||||
|
||||
def _row_to_bundle(row: sqlite3.Row) -> dict[str, Any]:
|
||||
"""Materialize a bundles row into the SPA-facing dict shape.
|
||||
|
||||
`children` is stored as a JSON array of full module snapshots
|
||||
(deep-cloned at save time). The repository deserializes it on
|
||||
read so callers don't have to. `payload_hash` lives in the row
|
||||
rather than being recomputed on demand because bundles are
|
||||
intentionally frozen — the saved hash is the source of truth for
|
||||
insert-time `inserted_at_hash` capture + library-drift detection.
|
||||
"""
|
||||
try:
|
||||
community_post_slug = row["community_post_slug"]
|
||||
community_version_number = row["community_version_number"]
|
||||
except (IndexError, KeyError):
|
||||
community_post_slug = None
|
||||
community_version_number = None
|
||||
try:
|
||||
content_rating = row["content_rating"]
|
||||
except (IndexError, KeyError):
|
||||
content_rating = "safe"
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"color": row["color"],
|
||||
"category_id": row["category_id"],
|
||||
"tags": json.loads(row["tags"]),
|
||||
"is_favorite": bool(row["is_favorite"]),
|
||||
"children": json.loads(row["children"]),
|
||||
"payload_hash": row["payload_hash"],
|
||||
"version": row["version"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"community_post_slug": community_post_slug,
|
||||
"community_version_number": community_version_number,
|
||||
"content_rating": content_rating,
|
||||
}
|
||||
|
||||
|
||||
class BundleRepository:
|
||||
"""Library-side store for bundle entries. Mirrors `ModuleRepository`
|
||||
shape (list / get / create / update / delete / count + favorite
|
||||
toggle) so the SPA Library page can re-use its existing fetch
|
||||
patterns.
|
||||
|
||||
Bundles intentionally do NOT support the embed-bundle / snapshot
|
||||
endpoints modules expose — bundles are themselves the snapshot
|
||||
package, so re-snapshotting is a no-op."""
|
||||
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def _gen_id(self) -> str:
|
||||
"""Generate a fresh 8-hex bundle id. Same scheme as modules so
|
||||
`@{<id>}` ref syntax could theoretically reference a bundle in
|
||||
the future (not currently used — bundles aren't `$variable`
|
||||
producers themselves; their children are). Birthday-paradox
|
||||
50% mark at ~65k bundles, well past expected library size."""
|
||||
return secrets.token_hex(_ID_HEX_LEN // 2)
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
description: str = "",
|
||||
color: str | None = None,
|
||||
category_id: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
children: list[dict[str, Any]] | None = None,
|
||||
is_favorite: bool = False,
|
||||
id: str | None = None,
|
||||
content_rating: str = "safe",
|
||||
) -> dict[str, Any]:
|
||||
_validate_content_rating(content_rating)
|
||||
if id is None:
|
||||
bid = self._gen_id()
|
||||
else:
|
||||
if not isinstance(id, str) or len(id) != _ID_HEX_LEN or not all(
|
||||
c in "0123456789abcdef" for c in id
|
||||
):
|
||||
raise ValueError(
|
||||
f"id must be a {_ID_HEX_LEN}-char lowercase-hex string"
|
||||
)
|
||||
bid = id
|
||||
children_blob = list(children) if children else []
|
||||
# `payload_hash` is computed off the children blob -- the only
|
||||
# field whose change should signal "library has a newer
|
||||
# version" to inserted instances.
|
||||
ph = payload_hash({"children": children_blob})
|
||||
now = _now()
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"INSERT INTO bundles("
|
||||
"id, name, description, color, category_id, tags, "
|
||||
"is_favorite, children, payload_hash, version, "
|
||||
"created_at, updated_at, content_rating"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?);",
|
||||
(
|
||||
bid, name, description, color, category_id,
|
||||
json.dumps(tags or []), int(is_favorite),
|
||||
json.dumps(children_blob), ph, now, now, content_rating,
|
||||
),
|
||||
)
|
||||
return self.get(bid)
|
||||
|
||||
def get(self, bundle_id: str) -> dict[str, Any]:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM bundles WHERE id = ?;", (bundle_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise BundleNotFound(bundle_id)
|
||||
return _row_to_bundle(row)
|
||||
|
||||
def update(
|
||||
self,
|
||||
bundle_id: str,
|
||||
*,
|
||||
name: str | _Unset = _UNSET,
|
||||
description: str | _Unset = _UNSET,
|
||||
color: str | None | _Unset = _UNSET,
|
||||
category_id: str | None | _Unset = _UNSET,
|
||||
tags: list[str] | _Unset = _UNSET,
|
||||
children: list[dict[str, Any]] | _Unset = _UNSET,
|
||||
is_favorite: bool | _Unset = _UNSET,
|
||||
content_rating: str | _Unset = _UNSET,
|
||||
) -> dict[str, Any]:
|
||||
existing = self.get(bundle_id)
|
||||
if not isinstance(content_rating, _Unset):
|
||||
_validate_content_rating(content_rating)
|
||||
new = {
|
||||
"name": existing["name"] if isinstance(name, _Unset) else name,
|
||||
"description": (
|
||||
existing["description"] if isinstance(description, _Unset) else description
|
||||
),
|
||||
"color": existing["color"] if isinstance(color, _Unset) else color,
|
||||
"category_id": (
|
||||
existing["category_id"] if isinstance(category_id, _Unset) else category_id
|
||||
),
|
||||
"tags": existing["tags"] if isinstance(tags, _Unset) else tags,
|
||||
"children": (
|
||||
existing["children"] if isinstance(children, _Unset) else children
|
||||
),
|
||||
"is_favorite": (
|
||||
existing["is_favorite"] if isinstance(is_favorite, _Unset) else is_favorite
|
||||
),
|
||||
"content_rating": (
|
||||
existing["content_rating"]
|
||||
if isinstance(content_rating, _Unset)
|
||||
else content_rating
|
||||
),
|
||||
}
|
||||
# Recompute payload_hash whenever children change. Other field
|
||||
# edits (rename, recolor, retag, NSFW toggle) don't affect runtime
|
||||
# behavior so they don't bump the hash -- inserted instances stay
|
||||
# clean on a pure-cosmetic library update.
|
||||
ph = (
|
||||
existing["payload_hash"]
|
||||
if isinstance(children, _Unset)
|
||||
else payload_hash({"children": new["children"]})
|
||||
)
|
||||
now = _now()
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"name = ?, description = ?, color = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
|
||||
"version = version + 1, updated_at = ?, content_rating = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
new["name"], new["description"], new["color"],
|
||||
new["category_id"], json.dumps(new["tags"]),
|
||||
int(new["is_favorite"]),
|
||||
json.dumps(new["children"]), ph, now,
|
||||
new["content_rating"], bundle_id,
|
||||
),
|
||||
)
|
||||
return self.get(bundle_id)
|
||||
|
||||
def set_community_origin(
|
||||
self, bundle_id: str, *, post_slug: str, version_number: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Stamp community origin on a bundle row WITHOUT a version bump
|
||||
or payload-hash change. See ModuleRepository.set_community_origin
|
||||
for the rationale -- origin metadata is not runtime-affecting."""
|
||||
with self._conn:
|
||||
cur = self._conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"community_post_slug = ?, community_version_number = ? "
|
||||
"WHERE id = ?;",
|
||||
(post_slug, version_number, bundle_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise BundleNotFound(bundle_id)
|
||||
return self.get(bundle_id)
|
||||
|
||||
def delete(self, bundle_id: str) -> None:
|
||||
with self._conn:
|
||||
cur = self._conn.execute(
|
||||
"DELETE FROM bundles WHERE id = ?;", (bundle_id,),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise BundleNotFound(bundle_id)
|
||||
|
||||
def _build_filter_clause(
|
||||
self,
|
||||
*,
|
||||
category_id: str | None,
|
||||
query: str | None,
|
||||
favorites_only: bool,
|
||||
) -> tuple[str, list[Any]]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if category_id is not None:
|
||||
clauses.append("category_id = ?")
|
||||
params.append(category_id)
|
||||
if query:
|
||||
escaped = (
|
||||
query.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
|
||||
params.append(f"%{escaped}%")
|
||||
if favorites_only:
|
||||
clauses.append("is_favorite = 1")
|
||||
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
return where, params
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
category_id: str | None = None,
|
||||
query: str | None = None,
|
||||
favorites_only: bool = False,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
where, params = self._build_filter_clause(
|
||||
category_id=category_id, query=query,
|
||||
favorites_only=favorites_only,
|
||||
)
|
||||
sql = "SELECT * FROM bundles" + where + " ORDER BY updated_at DESC, id DESC"
|
||||
if limit is not None:
|
||||
sql += " LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
elif offset:
|
||||
sql += " LIMIT -1 OFFSET ?"
|
||||
params.append(offset)
|
||||
rows = self._conn.execute(sql, params).fetchall()
|
||||
return [_row_to_bundle(r) for r in rows]
|
||||
|
||||
def count(
|
||||
self,
|
||||
*,
|
||||
category_id: str | None = None,
|
||||
query: str | None = None,
|
||||
favorites_only: bool = False,
|
||||
) -> int:
|
||||
where, params = self._build_filter_clause(
|
||||
category_id=category_id, query=query,
|
||||
favorites_only=favorites_only,
|
||||
)
|
||||
sql = "SELECT COUNT(*) AS n FROM bundles" + where
|
||||
row = self._conn.execute(sql, params).fetchone()
|
||||
return int(row["n"]) if row is not None else 0
|
||||
|
||||
|
||||
class TemplateNotFound(LookupError):
|
||||
"""Raised when a requested template id does not exist."""
|
||||
|
||||
|
||||
def _row_to_template(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"category_id": row["category_id"],
|
||||
"tags": json.loads(row["tags"]),
|
||||
"is_favorite": bool(row["is_favorite"]),
|
||||
"template_string": row["template_string"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
class TemplateRepository:
|
||||
"""CRUD for reusable PromptAssembler template strings.
|
||||
|
||||
Mirrors BundleRepository but simpler: a template is a single
|
||||
`template_string` plus library metadata. No children / color /
|
||||
payload_hash / version (templates can't drift — loading copies the
|
||||
string with no stored back-reference). Pure DB — engine-isolated.
|
||||
"""
|
||||
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def _gen_id(self) -> str:
|
||||
return secrets.token_hex(_ID_HEX_LEN // 2)
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
template_string: str = "",
|
||||
description: str = "",
|
||||
category_id: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
is_favorite: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
tid = self._gen_id()
|
||||
now = _now()
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"INSERT INTO templates("
|
||||
"id, name, description, category_id, tags, "
|
||||
"is_favorite, template_string, created_at, updated_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
(
|
||||
tid, name, description, category_id,
|
||||
json.dumps(tags or []), int(is_favorite),
|
||||
template_string, now, now,
|
||||
),
|
||||
)
|
||||
return self.get(tid)
|
||||
|
||||
def get(self, template_id: str) -> dict[str, Any]:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM templates WHERE id = ?;", (template_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise TemplateNotFound(template_id)
|
||||
return _row_to_template(row)
|
||||
|
||||
def update(
|
||||
self,
|
||||
template_id: str,
|
||||
*,
|
||||
name: str | _Unset = _UNSET,
|
||||
description: str | _Unset = _UNSET,
|
||||
category_id: str | None | _Unset = _UNSET,
|
||||
tags: list[str] | _Unset = _UNSET,
|
||||
template_string: str | _Unset = _UNSET,
|
||||
is_favorite: bool | _Unset = _UNSET,
|
||||
) -> dict[str, Any]:
|
||||
existing = self.get(template_id)
|
||||
new = {
|
||||
"name": existing["name"] if isinstance(name, _Unset) else name,
|
||||
"description": (
|
||||
existing["description"] if isinstance(description, _Unset) else description
|
||||
),
|
||||
"category_id": (
|
||||
existing["category_id"] if isinstance(category_id, _Unset) else category_id
|
||||
),
|
||||
"tags": existing["tags"] if isinstance(tags, _Unset) else tags,
|
||||
"template_string": (
|
||||
existing["template_string"]
|
||||
if isinstance(template_string, _Unset)
|
||||
else template_string
|
||||
),
|
||||
"is_favorite": (
|
||||
existing["is_favorite"] if isinstance(is_favorite, _Unset) else is_favorite
|
||||
),
|
||||
}
|
||||
now = _now()
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE templates SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, template_string = ?, updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
new["name"], new["description"], new["category_id"],
|
||||
json.dumps(new["tags"]), int(new["is_favorite"]),
|
||||
new["template_string"], now, template_id,
|
||||
),
|
||||
)
|
||||
return self.get(template_id)
|
||||
|
||||
def delete(self, template_id: str) -> None:
|
||||
with self._conn:
|
||||
cur = self._conn.execute(
|
||||
"DELETE FROM templates WHERE id = ?;", (template_id,),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise TemplateNotFound(template_id)
|
||||
|
||||
def _build_filter_clause(
|
||||
self,
|
||||
*,
|
||||
category_id: str | None,
|
||||
query: str | None,
|
||||
favorites_only: bool,
|
||||
) -> tuple[str, list[Any]]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if category_id is not None:
|
||||
clauses.append("category_id = ?")
|
||||
params.append(category_id)
|
||||
if query:
|
||||
escaped = (
|
||||
query.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
|
||||
params.append(f"%{escaped}%")
|
||||
if favorites_only:
|
||||
clauses.append("is_favorite = 1")
|
||||
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
return where, params
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
category_id: str | None = None,
|
||||
query: str | None = None,
|
||||
favorites_only: bool = False,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
where, params = self._build_filter_clause(
|
||||
category_id=category_id, query=query, favorites_only=favorites_only,
|
||||
)
|
||||
sql = "SELECT * FROM templates" + where + " ORDER BY updated_at DESC, id DESC"
|
||||
if limit is not None:
|
||||
sql += " LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
elif offset:
|
||||
sql += " LIMIT -1 OFFSET ?"
|
||||
params.append(offset)
|
||||
rows = self._conn.execute(sql, params).fetchall()
|
||||
return [_row_to_template(r) for r in rows]
|
||||
|
||||
def count(
|
||||
self,
|
||||
*,
|
||||
category_id: str | None = None,
|
||||
query: str | None = None,
|
||||
favorites_only: bool = False,
|
||||
) -> int:
|
||||
where, params = self._build_filter_clause(
|
||||
category_id=category_id, query=query, favorites_only=favorites_only,
|
||||
)
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM templates" + where, params,
|
||||
).fetchone()
|
||||
return int(row["n"]) if row is not None else 0
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
"""Export payload builder. Transitive ref walk + inner-bundle
|
||||
auto-include + 8-bucket grouping.
|
||||
|
||||
Pure relative to DB — reads via repositories only, no writes.
|
||||
Stateless: caller passes the requested UUID sets, exporter returns
|
||||
the full payload including auto-resolved bundle dependencies.
|
||||
|
||||
Module rows are routed by type: a UUID passed in `wildcard_uuids` is
|
||||
looked up and emitted into `wildcards[]` ONLY if the module's type is
|
||||
'wildcard'. Mis-typed UUIDs are silently skipped (filter, not error)
|
||||
so the picker UI's loose type tracking can't accidentally cross-pollinate
|
||||
buckets.
|
||||
|
||||
Inner-bundle resolution: bundle ``children[]`` entries with
|
||||
``type == "bundle"`` are stored as reference objects with ``id``,
|
||||
``type``, ``name``, ``color`` fields (not full snapshots). The transitive
|
||||
walk follows these references until a fixed point, then includes every
|
||||
reachable bundle in the export.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from engine._utils import now_iso
|
||||
from engine.db.repositories import (
|
||||
BundleNotFound,
|
||||
BundleRepository,
|
||||
CategoryNotFound,
|
||||
CategoryRepository,
|
||||
ModuleNotFound,
|
||||
ModuleRepository,
|
||||
TemplateNotFound,
|
||||
TemplateRepository,
|
||||
)
|
||||
from engine.migrations import CURRENT_SCHEMA_VERSION
|
||||
|
||||
|
||||
def _walk_inner_bundles(repo: BundleRepository, seed: list[str]) -> list[str]:
|
||||
"""Transitive walk of inner-bundle refs starting from ``seed``.
|
||||
|
||||
For each bundle, its ``children[]`` entries with ``type=='bundle'``
|
||||
carry an ``id`` field pointing to the referenced bundle. Walk until
|
||||
a fixed point (visited set prevents cycles). Returns sorted list of
|
||||
all reachable bundle UUIDs (including seed members that exist).
|
||||
|
||||
Missing UUIDs in seed are silently dropped — the caller may pass
|
||||
stale ids; we log no error and return only what exists.
|
||||
"""
|
||||
visited: set[str] = set()
|
||||
queue = list(seed)
|
||||
while queue:
|
||||
uuid = queue.pop()
|
||||
if uuid in visited:
|
||||
continue
|
||||
try:
|
||||
b = repo.get(uuid)
|
||||
except BundleNotFound:
|
||||
continue
|
||||
visited.add(uuid)
|
||||
children = b.get("children") or []
|
||||
for child in children:
|
||||
if not isinstance(child, dict):
|
||||
continue
|
||||
if child.get("type") == "bundle":
|
||||
child_id = child.get("id")
|
||||
if isinstance(child_id, str) and child_id not in visited:
|
||||
queue.append(child_id)
|
||||
return sorted(visited)
|
||||
|
||||
|
||||
def _modules_by_type(
|
||||
repo: ModuleRepository,
|
||||
uuids: list[str],
|
||||
expected_type: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Look up modules by UUID, filter to expected type, drop misses.
|
||||
|
||||
Returns rows in input order (mod the type filter), each carrying
|
||||
``snapshot_fingerprint`` already stamped by repository writes.
|
||||
|
||||
A UUID that doesn't exist in the DB or belongs to a different type
|
||||
is silently skipped — the picker UI's UUID sets may be imprecise and
|
||||
the exporter must never raise on a bad cross-bucket reference.
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for uuid in uuids:
|
||||
try:
|
||||
row = repo.get(uuid)
|
||||
except ModuleNotFound:
|
||||
continue
|
||||
if row.get("type") != expected_type:
|
||||
continue
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def build_export_payload(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
bundle_uuids: list[str],
|
||||
wildcard_uuids: list[str],
|
||||
fixed_values_uuids: list[str],
|
||||
combine_uuids: list[str],
|
||||
derivation_uuids: list[str],
|
||||
constraint_uuids: list[str],
|
||||
category_uuids: list[str],
|
||||
template_uuids: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Build the 8-bucket export payload.
|
||||
|
||||
Performs:
|
||||
- Transitive inner-bundle walk from ``bundle_uuids`` seed
|
||||
- Type-gated module resolution (UUID in wrong bucket → silently skipped)
|
||||
- Category lookup by id
|
||||
- Template lookup by id (templates have their own repo/table; a
|
||||
missing id is silently dropped)
|
||||
|
||||
Returns a dict ready for JSON serialisation.
|
||||
"""
|
||||
bundle_repo = BundleRepository(conn)
|
||||
module_repo = ModuleRepository(conn)
|
||||
category_repo = CategoryRepository(conn)
|
||||
|
||||
# Bundles: transitive walk, then bulk fetch in walk order (sorted).
|
||||
resolved_bundle_uuids = _walk_inner_bundles(bundle_repo, bundle_uuids)
|
||||
bundles: list[dict[str, Any]] = []
|
||||
for uuid in resolved_bundle_uuids:
|
||||
try:
|
||||
bundles.append(bundle_repo.get(uuid))
|
||||
except BundleNotFound:
|
||||
pass # was reachable during walk but deleted in a race — skip
|
||||
|
||||
wildcards = _modules_by_type(module_repo, wildcard_uuids, "wildcard")
|
||||
fixed_values = _modules_by_type(module_repo, fixed_values_uuids, "fixed_values")
|
||||
combines = _modules_by_type(module_repo, combine_uuids, "combine")
|
||||
derivations = _modules_by_type(module_repo, derivation_uuids, "derivation")
|
||||
constraints = _modules_by_type(module_repo, constraint_uuids, "constraint")
|
||||
|
||||
categories: list[dict[str, Any]] = []
|
||||
for uuid in category_uuids:
|
||||
try:
|
||||
categories.append(category_repo.get(uuid))
|
||||
except CategoryNotFound:
|
||||
pass
|
||||
|
||||
template_repo = TemplateRepository(conn)
|
||||
templates: list[dict[str, Any]] = []
|
||||
for uuid in template_uuids:
|
||||
try:
|
||||
templates.append(template_repo.get(uuid))
|
||||
except TemplateNotFound:
|
||||
pass
|
||||
|
||||
return {
|
||||
"schema_version": CURRENT_SCHEMA_VERSION,
|
||||
"exported_at": now_iso(),
|
||||
"bundles": bundles,
|
||||
"wildcards": wildcards,
|
||||
"fixed_values": fixed_values,
|
||||
"combines": combines,
|
||||
"derivations": derivations,
|
||||
"constraints": constraints,
|
||||
"categories": categories,
|
||||
"templates": templates,
|
||||
}
|
||||
@@ -1,898 +0,0 @@
|
||||
"""Atomic import commit + snapshot-based undo.
|
||||
|
||||
Receives a classified commit payload from the SPA (Task 15 client-side
|
||||
classifier produces it from a diff vs. live DB), and applies the three
|
||||
operation buckets — adds, replaces, renames — inside a single SQLite
|
||||
transaction. If any row write raises (e.g. PK collision), the entire
|
||||
commit rolls back and no undo entry is created.
|
||||
|
||||
For every successful commit we persist a row in ``import_undo`` that
|
||||
carries enough information to reverse the commit: the kind+id of every
|
||||
inserted record, and the pre-replace row contents (with kind) of every
|
||||
replaced record. ``undo_import`` deletes the inserts and restores the
|
||||
snapshots, then evicts the undo row.
|
||||
|
||||
Commit payload shape (received)::
|
||||
|
||||
{
|
||||
"adds": [{"kind": ..., "entity": {...full row...}}, ...],
|
||||
"replaces": [{"kind": ..., "id": ..., "new_content": {...}}, ...],
|
||||
"renames": [{"kind": ..., "old_id": ..., "new_id": ..., "content": {...}}, ...]
|
||||
}
|
||||
|
||||
``kind`` is one of: ``wildcard``, ``fixed_values``, ``combine``,
|
||||
``derivation``, ``constraint``, ``bundle``, ``category``, ``template``.
|
||||
The first five are dispatched to the ``modules`` table (discriminated by
|
||||
the ``type`` column). Templates have their own ``templates`` table and
|
||||
support add/replace/rename like bundles. Categories appear only in
|
||||
``adds`` (name-based merge, no replace/rename concept).
|
||||
|
||||
Undo metadata shape (persisted in ``import_undo`` row)::
|
||||
|
||||
imported_records: list[{"kind": str, "id": str}]
|
||||
— records added or renamed; undo deletes each.
|
||||
replaced_snapshots: dict[id, {"kind": str, "row": {... full pre-replace ...}}]
|
||||
— rows to restore on undo.
|
||||
rename_map: dict[old_id, new_id]
|
||||
— informational mapping; the new_id also appears
|
||||
in imported_records for the actual delete.
|
||||
|
||||
Atomicity note: each repository method opens its own ``with self._conn:``
|
||||
block, which auto-commits per call. To get a single atomic commit across
|
||||
multi-row inserts we bypass the repositories and use raw SQL inside one
|
||||
outer transaction — mirroring the legacy ``wp_api/import_export.py``
|
||||
``import_bundle`` pattern.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from engine._fingerprint import module_fingerprint
|
||||
from engine._utils import now_iso
|
||||
from engine.modules.snapshot import payload_hash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _ImporterContractError(ValueError):
|
||||
"""Raised for importer-defined contract violations.
|
||||
|
||||
Carries a user-safe message. Caught separately from raw sqlite
|
||||
errors so the envelope can preserve our wording but generalize
|
||||
sqlite's (which can leak column/constraint internals).
|
||||
"""
|
||||
|
||||
|
||||
# Module kinds dispatched to the `modules` table. Mirrors the 5 entries
|
||||
# of `engine.db.repositories._VALID_TYPES` — kept duplicated here so a
|
||||
# typo in the discriminator field is caught locally, not silently
|
||||
# inserted as an unknown type.
|
||||
_MODULE_KINDS: frozenset[str] = frozenset({
|
||||
"wildcard", "fixed_values", "combine", "derivation", "constraint",
|
||||
})
|
||||
|
||||
|
||||
def _is_module_kind(kind: str) -> bool:
|
||||
return kind in _MODULE_KINDS
|
||||
|
||||
|
||||
def _require_entity_fields(
|
||||
kind: str, op: str, entity: dict[str, Any], required: tuple[str, ...],
|
||||
) -> None:
|
||||
"""Raise ``_ImporterContractError`` listing every missing field.
|
||||
|
||||
Treats ``None``, missing key, and falsy non-empty-string values as
|
||||
missing. Empty strings ``""`` are allowed — callers explicitly opt
|
||||
into "must be non-empty" by passing the field name; empty string
|
||||
fields like ``description`` are NOT included in ``required``.
|
||||
"""
|
||||
missing = [
|
||||
k for k in required
|
||||
if entity.get(k) is None
|
||||
or (not entity.get(k) and entity.get(k) != "")
|
||||
]
|
||||
if missing:
|
||||
raise _ImporterContractError(
|
||||
f"{op} for {kind} missing required field(s): {sorted(missing)}",
|
||||
)
|
||||
|
||||
|
||||
def _gen_undo_id() -> str:
|
||||
"""Generate a fresh 16-hex undo id.
|
||||
|
||||
Wider than the 8-hex module/bundle ids — undo entries can pile up
|
||||
over a session, and a wider id keeps collision probability
|
||||
negligible without a uniqueness check round-trip.
|
||||
"""
|
||||
return secrets.token_hex(8)
|
||||
|
||||
|
||||
def _module_fp_for_entity(entity: dict[str, Any]) -> str:
|
||||
"""Compute snapshot_fingerprint for a module row dict.
|
||||
|
||||
Mirrors the formula `ModuleRepository.create` uses at line 126:
|
||||
derive payload_hash on-the-fly from payload, then build the
|
||||
fingerprint. The exporter (Task 12) stamps fingerprints on rows
|
||||
before they leave the source DB, but we must re-compute on the
|
||||
destination because (a) the source-side hash may differ if the
|
||||
payload was migrated en route, and (b) the destination repository
|
||||
contract is that any write recomputes the fingerprint.
|
||||
"""
|
||||
return module_fingerprint({
|
||||
"type": entity.get("type", ""),
|
||||
"name": entity.get("name", ""),
|
||||
"description": entity.get("description", ""),
|
||||
"tags": entity.get("tags") or [],
|
||||
"payload_hash": payload_hash(entity.get("payload") or {}),
|
||||
})
|
||||
|
||||
|
||||
def _insert_module(
|
||||
conn: sqlite3.Connection, kind: str, entity: dict[str, Any], *,
|
||||
target_id: str | None = None,
|
||||
) -> str:
|
||||
"""INSERT a module row using raw SQL. Returns the inserted id.
|
||||
|
||||
``target_id`` overrides ``entity["id"]`` — used by the rename path
|
||||
where the inbound entity carries its original id but we want to
|
||||
insert under a freshly-allocated id. When None, ``entity["id"]``
|
||||
is used verbatim (the add path).
|
||||
"""
|
||||
# When target_id is supplied (rename path), the caller has already
|
||||
# validated new_id; only `name` is required from the entity.
|
||||
required = ("name",) if target_id else ("name", "id")
|
||||
op_label = "rename" if target_id else "add"
|
||||
_require_entity_fields(kind, op_label, entity, required)
|
||||
mid = target_id or entity["id"]
|
||||
now = now_iso()
|
||||
fp = _module_fp_for_entity({**entity, "type": kind})
|
||||
conn.execute(
|
||||
"INSERT INTO modules("
|
||||
"id, type, name, description, category_id, tags, "
|
||||
"is_favorite, payload, snapshot_fingerprint, version, "
|
||||
"created_at, updated_at, "
|
||||
"community_post_slug, community_version_number, "
|
||||
"content_rating"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
(
|
||||
mid, kind, entity["name"], entity.get("description", ""),
|
||||
entity.get("category_id"),
|
||||
json.dumps(entity.get("tags") or []),
|
||||
int(entity.get("is_favorite", False)),
|
||||
json.dumps(entity.get("payload") or {}),
|
||||
fp,
|
||||
entity.get("version", 1),
|
||||
entity.get("created_at", now),
|
||||
entity.get("updated_at", now),
|
||||
# Community origin -- only set when the install bridge stamps
|
||||
# them on the entity (see manager/main.ts snapshotLibrary +
|
||||
# the embed's install call). Local Create/Import-from-file
|
||||
# paths leave these NULL so the row stays "locally authored".
|
||||
entity.get("community_post_slug"),
|
||||
entity.get("community_version_number"),
|
||||
# content_rating defaults to 'safe' when the entity carries
|
||||
# no value -- migration 015 stamps the same default. Community
|
||||
# install passes 'nsfw' through via the entity when the post
|
||||
# carries content_rating='nsfw'.
|
||||
entity.get("content_rating", "safe"),
|
||||
),
|
||||
)
|
||||
return mid
|
||||
|
||||
|
||||
def _insert_bundle(
|
||||
conn: sqlite3.Connection, entity: dict[str, Any], *,
|
||||
target_id: str | None = None,
|
||||
) -> str:
|
||||
"""INSERT a bundle row using raw SQL. Returns the inserted id.
|
||||
|
||||
Mirrors `BundleRepository.create` for the field set, including
|
||||
`payload_hash` recomputation off `children` (the source-side hash
|
||||
may not match this DB's hashing rules — we always recompute on
|
||||
insert).
|
||||
"""
|
||||
required = ("name",) if target_id else ("name", "id")
|
||||
op_label = "rename" if target_id else "add"
|
||||
_require_entity_fields("bundle", op_label, entity, required)
|
||||
bid = target_id or entity["id"]
|
||||
children_blob = list(entity.get("children") or [])
|
||||
ph = payload_hash({"children": children_blob})
|
||||
now = now_iso()
|
||||
conn.execute(
|
||||
"INSERT INTO bundles("
|
||||
"id, name, description, color, category_id, tags, "
|
||||
"is_favorite, children, payload_hash, version, "
|
||||
"created_at, updated_at, "
|
||||
"community_post_slug, community_version_number, "
|
||||
"content_rating"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
(
|
||||
bid, entity["name"], entity.get("description", ""),
|
||||
entity.get("color"),
|
||||
entity.get("category_id"),
|
||||
json.dumps(entity.get("tags") or []),
|
||||
int(entity.get("is_favorite", False)),
|
||||
json.dumps(children_blob),
|
||||
ph,
|
||||
entity.get("version", 1),
|
||||
entity.get("created_at", now),
|
||||
entity.get("updated_at", now),
|
||||
# Community origin -- see _insert_module comment above.
|
||||
entity.get("community_post_slug"),
|
||||
entity.get("community_version_number"),
|
||||
# content_rating -- see _insert_module comment above.
|
||||
entity.get("content_rating", "safe"),
|
||||
),
|
||||
)
|
||||
return bid
|
||||
|
||||
|
||||
def _update_module(
|
||||
conn: sqlite3.Connection, mid: str, kind: str, content: dict[str, Any],
|
||||
) -> None:
|
||||
"""UPDATE the modules row identified by `mid` to mirror `content`.
|
||||
|
||||
Preserves `mid` and the row's `type` (the kind dispatch already
|
||||
routed us here so type is implied). Bumps `version` and stamps
|
||||
`updated_at` like `ModuleRepository.update`.
|
||||
|
||||
Community origin (migration 013) is rewritten ONLY when the new
|
||||
content explicitly carries it — a community Update-in-place
|
||||
install stamps both fields on the entity, while a vanilla Import
|
||||
tab replace leaves them absent and the row keeps its prior
|
||||
community_* values (or NULL for locally-authored rows).
|
||||
"""
|
||||
_require_entity_fields(kind, "replace", content, ("name",))
|
||||
now = now_iso()
|
||||
fp = _module_fp_for_entity({**content, "type": kind})
|
||||
if "community_post_slug" in content or "community_version_number" in content:
|
||||
# `content_rating` uses COALESCE so missing key preserves the
|
||||
# existing value -- community-update installs that only bump
|
||||
# the version don't accidentally wipe a user-set NSFW flag.
|
||||
conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
|
||||
"version = version + 1, updated_at = ?, "
|
||||
"community_post_slug = ?, community_version_number = ?, "
|
||||
"content_rating = COALESCE(?, content_rating) "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(content.get("payload") or {}),
|
||||
fp, now,
|
||||
content.get("community_post_slug"),
|
||||
content.get("community_version_number"),
|
||||
content.get("content_rating"),
|
||||
mid,
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
|
||||
"version = version + 1, updated_at = ?, "
|
||||
"content_rating = COALESCE(?, content_rating) "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(content.get("payload") or {}),
|
||||
fp, now,
|
||||
content.get("content_rating"),
|
||||
mid,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _update_bundle(
|
||||
conn: sqlite3.Connection, bid: str, content: dict[str, Any],
|
||||
) -> None:
|
||||
"""UPDATE the bundles row identified by `bid` to mirror `content`.
|
||||
|
||||
See _update_module for the community_* rewrite semantics.
|
||||
"""
|
||||
_require_entity_fields("bundle", "replace", content, ("name",))
|
||||
children_blob = list(content.get("children") or [])
|
||||
ph = payload_hash({"children": children_blob})
|
||||
now = now_iso()
|
||||
if "community_post_slug" in content or "community_version_number" in content:
|
||||
conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"name = ?, description = ?, color = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
|
||||
"version = version + 1, updated_at = ?, "
|
||||
"community_post_slug = ?, community_version_number = ?, "
|
||||
"content_rating = COALESCE(?, content_rating) "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("color"),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(children_blob), ph, now,
|
||||
content.get("community_post_slug"),
|
||||
content.get("community_version_number"),
|
||||
content.get("content_rating"),
|
||||
bid,
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"name = ?, description = ?, color = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
|
||||
"version = version + 1, updated_at = ?, "
|
||||
"content_rating = COALESCE(?, content_rating) "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("color"),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(children_blob), ph, now,
|
||||
content.get("content_rating"),
|
||||
bid,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _module_exists(conn: sqlite3.Connection, mid: str) -> bool:
|
||||
row = conn.execute("SELECT 1 FROM modules WHERE id = ?;", (mid,)).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _bundle_exists(conn: sqlite3.Connection, bid: str) -> bool:
|
||||
row = conn.execute("SELECT 1 FROM bundles WHERE id = ?;", (bid,)).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _category_id_exists(conn: sqlite3.Connection, cid: str) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM module_categories WHERE id = ?;", (cid,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _fetch_module_row(
|
||||
conn: sqlite3.Connection, mid: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Read a modules row as a plain dict (JSON columns deserialised)."""
|
||||
row = conn.execute("SELECT * FROM modules WHERE id = ?;", (mid,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"id": row["id"],
|
||||
"type": row["type"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"category_id": row["category_id"],
|
||||
"tags": json.loads(row["tags"]),
|
||||
"is_favorite": bool(row["is_favorite"]),
|
||||
"payload": json.loads(row["payload"]),
|
||||
"snapshot_fingerprint": row["snapshot_fingerprint"],
|
||||
"version": row["version"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def _fetch_bundle_row(
|
||||
conn: sqlite3.Connection, bid: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Read a bundles row as a plain dict (JSON columns deserialised)."""
|
||||
row = conn.execute("SELECT * FROM bundles WHERE id = ?;", (bid,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"color": row["color"],
|
||||
"category_id": row["category_id"],
|
||||
"tags": json.loads(row["tags"]),
|
||||
"is_favorite": bool(row["is_favorite"]),
|
||||
"children": json.loads(row["children"]),
|
||||
"payload_hash": row["payload_hash"],
|
||||
"version": row["version"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def _insert_template(
|
||||
conn: sqlite3.Connection, entity: dict[str, Any], *,
|
||||
target_id: str | None = None,
|
||||
) -> str:
|
||||
"""INSERT a template row using raw SQL. Returns the inserted id.
|
||||
|
||||
Mirrors `_insert_bundle` but for the templates table — no
|
||||
`payload_hash` / `version` columns (templates can't drift). The
|
||||
rename path passes ``target_id`` (only `name` required); the add
|
||||
path uses ``entity["id"]`` verbatim (`name` + `id` required).
|
||||
"""
|
||||
required = ("name",) if target_id else ("name", "id")
|
||||
op_label = "rename" if target_id else "add"
|
||||
_require_entity_fields("template", op_label, entity, required)
|
||||
tid = target_id or entity["id"]
|
||||
now = now_iso()
|
||||
conn.execute(
|
||||
"INSERT INTO templates("
|
||||
"id, name, description, category_id, tags, "
|
||||
"is_favorite, template_string, created_at, updated_at"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
(
|
||||
tid, entity["name"], entity.get("description", ""),
|
||||
entity.get("category_id"),
|
||||
json.dumps(entity.get("tags") or []),
|
||||
int(entity.get("is_favorite", False)),
|
||||
entity.get("template_string", ""),
|
||||
entity.get("created_at", now),
|
||||
entity.get("updated_at", now),
|
||||
),
|
||||
)
|
||||
return tid
|
||||
|
||||
|
||||
def _update_template(
|
||||
conn: sqlite3.Connection, tid: str, content: dict[str, Any],
|
||||
) -> None:
|
||||
"""UPDATE the templates row identified by `tid` to mirror `content`.
|
||||
|
||||
Stamps `updated_at`; no `version` bump (templates have no version
|
||||
column).
|
||||
"""
|
||||
_require_entity_fields("template", "replace", content, ("name",))
|
||||
now = now_iso()
|
||||
conn.execute(
|
||||
"UPDATE templates SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, template_string = ?, updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
content.get("template_string", ""),
|
||||
now, tid,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _template_exists(conn: sqlite3.Connection, tid: str) -> bool:
|
||||
row = conn.execute("SELECT 1 FROM templates WHERE id = ?;", (tid,)).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _fetch_template_row(
|
||||
conn: sqlite3.Connection, tid: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Read a templates row as a plain dict (JSON columns deserialised)."""
|
||||
row = conn.execute("SELECT * FROM templates WHERE id = ?;", (tid,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"category_id": row["category_id"],
|
||||
"tags": json.loads(row["tags"]),
|
||||
"is_favorite": bool(row["is_favorite"]),
|
||||
"template_string": row["template_string"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def commit_import(
|
||||
conn: sqlite3.Connection, payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a classified commit payload atomically.
|
||||
|
||||
Returns ``{"ok": True, "undo_id": str, "summary": {...counts...}}``
|
||||
on success, or ``{"ok": False, "error": str}`` on contract
|
||||
violation (collision on add, missing-id on replace, missing-id on
|
||||
rename target table, etc.).
|
||||
|
||||
On any error all in-progress writes are rolled back via the outer
|
||||
``with conn:`` block, and the import_undo row is NOT inserted.
|
||||
"""
|
||||
adds = list(payload.get("adds") or [])
|
||||
replaces = list(payload.get("replaces") or [])
|
||||
renames = list(payload.get("renames") or [])
|
||||
|
||||
imported_records: list[dict[str, str]] = []
|
||||
replaced_snapshots: dict[str, dict[str, Any]] = {}
|
||||
rename_map: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
with conn:
|
||||
# ---- ADDS ---------------------------------------------------
|
||||
for op in adds:
|
||||
kind = op.get("kind")
|
||||
entity = op.get("entity") or {}
|
||||
if kind == "category":
|
||||
# Mirror legacy wp_api/import_export.py:76 verbatim
|
||||
# (no .strip()) so behaviour stays bit-for-bit
|
||||
# compatible. Tightening here would silently change
|
||||
# name-merge semantics for whitespace-padded names.
|
||||
name = entity.get("name") or ""
|
||||
if not name:
|
||||
# Skip nameless categories the same way the
|
||||
# legacy import_bundle does — but loudly, since
|
||||
# the new pipeline pre-validates.
|
||||
raise _ImporterContractError(
|
||||
"category add missing 'name' field",
|
||||
)
|
||||
# Name-based merge (case-insensitive). Matches the
|
||||
# legacy `import_bundle` behaviour at lines 71-82
|
||||
# of wp_api/import_export.py.
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM module_categories "
|
||||
"WHERE name = ? COLLATE NOCASE;",
|
||||
(name,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
# Skip — nothing to undo.
|
||||
continue
|
||||
cid = entity.get("id") or name.lower()
|
||||
if _category_id_exists(conn, cid):
|
||||
# Same id, different name — treat as collision.
|
||||
raise _ImporterContractError(
|
||||
f"category id collision: {cid!r}",
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO module_categories("
|
||||
"id, name, color, icon, sort_order"
|
||||
") VALUES(?, ?, ?, ?, ?);",
|
||||
(
|
||||
cid, name,
|
||||
entity.get("color"), entity.get("icon"),
|
||||
entity.get("sort_order", 0),
|
||||
),
|
||||
)
|
||||
imported_records.append({"kind": "category", "id": cid})
|
||||
|
||||
elif kind == "bundle":
|
||||
_require_entity_fields(
|
||||
"bundle", "add", entity, ("id", "name"),
|
||||
)
|
||||
bid = entity["id"]
|
||||
if _bundle_exists(conn, bid):
|
||||
raise _ImporterContractError(
|
||||
f"bundle id collision on add: {bid!r}",
|
||||
)
|
||||
_insert_bundle(conn, entity)
|
||||
imported_records.append({"kind": "bundle", "id": bid})
|
||||
|
||||
elif kind == "template":
|
||||
_require_entity_fields(
|
||||
"template", "add", entity, ("id", "name"),
|
||||
)
|
||||
tid = entity["id"]
|
||||
if _template_exists(conn, tid):
|
||||
raise _ImporterContractError(
|
||||
f"template id collision on add: {tid!r}",
|
||||
)
|
||||
_insert_template(conn, entity)
|
||||
imported_records.append({"kind": "template", "id": tid})
|
||||
|
||||
elif _is_module_kind(kind or ""):
|
||||
_require_entity_fields(
|
||||
kind, "add", entity, ("id", "name"), # type: ignore[arg-type]
|
||||
)
|
||||
mid = entity["id"]
|
||||
if _module_exists(conn, mid):
|
||||
raise _ImporterContractError(
|
||||
f"module id collision on add: {mid!r}",
|
||||
)
|
||||
_insert_module(conn, kind, entity) # type: ignore[arg-type]
|
||||
imported_records.append({"kind": kind, "id": mid}) # type: ignore[dict-item]
|
||||
|
||||
else:
|
||||
raise _ImporterContractError(
|
||||
f"unknown kind in add: {kind!r}",
|
||||
)
|
||||
|
||||
# ---- REPLACES ----------------------------------------------
|
||||
# Categories never appear here (spec lock #2).
|
||||
for op in replaces:
|
||||
kind = op.get("kind")
|
||||
rid = op.get("id")
|
||||
new_content = op.get("new_content") or {}
|
||||
if not rid:
|
||||
raise _ImporterContractError(
|
||||
f"{kind} replace missing 'id' field",
|
||||
)
|
||||
if kind == "bundle":
|
||||
existing = _fetch_bundle_row(conn, rid)
|
||||
if existing is None:
|
||||
raise _ImporterContractError(
|
||||
f"bundle replace target {rid!r} not found",
|
||||
)
|
||||
replaced_snapshots[rid] = {"kind": "bundle", "row": existing}
|
||||
_update_bundle(conn, rid, new_content)
|
||||
elif kind == "template":
|
||||
existing = _fetch_template_row(conn, rid)
|
||||
if existing is None:
|
||||
raise _ImporterContractError(
|
||||
f"template replace target {rid!r} not found",
|
||||
)
|
||||
replaced_snapshots[rid] = {"kind": "template", "row": existing}
|
||||
_update_template(conn, rid, new_content)
|
||||
elif _is_module_kind(kind or ""):
|
||||
existing = _fetch_module_row(conn, rid)
|
||||
if existing is None:
|
||||
raise _ImporterContractError(
|
||||
f"{kind} replace target {rid!r} not found",
|
||||
)
|
||||
replaced_snapshots[rid] = {"kind": kind, "row": existing} # type: ignore[dict-item]
|
||||
_update_module(conn, rid, kind, new_content) # type: ignore[arg-type]
|
||||
else:
|
||||
raise _ImporterContractError(
|
||||
f"unknown kind in replace: {kind!r}",
|
||||
)
|
||||
|
||||
# ---- RENAMES -----------------------------------------------
|
||||
# Insert at new_id, treat as a fresh add for undo purposes.
|
||||
# Categories never appear here (spec lock #2).
|
||||
for op in renames:
|
||||
kind = op.get("kind")
|
||||
old_id = op.get("old_id")
|
||||
new_id = op.get("new_id")
|
||||
content = op.get("content") or {}
|
||||
if not new_id:
|
||||
raise _ImporterContractError(
|
||||
f"{kind} rename missing 'new_id' field",
|
||||
)
|
||||
if not old_id:
|
||||
raise _ImporterContractError(
|
||||
f"{kind} rename missing 'old_id' field",
|
||||
)
|
||||
# Content carries the renamed entity; `name` must be
|
||||
# present (id field is irrelevant — we use new_id).
|
||||
if not content.get("name"):
|
||||
raise _ImporterContractError(
|
||||
f"{kind} rename missing 'name' field in content",
|
||||
)
|
||||
if kind == "bundle":
|
||||
if _bundle_exists(conn, new_id):
|
||||
raise _ImporterContractError(
|
||||
f"bundle new_id collision on rename: {new_id!r}",
|
||||
)
|
||||
_insert_bundle(conn, content, target_id=new_id)
|
||||
imported_records.append({"kind": "bundle", "id": new_id})
|
||||
rename_map[old_id] = new_id
|
||||
elif kind == "template":
|
||||
if _template_exists(conn, new_id):
|
||||
raise _ImporterContractError(
|
||||
f"template new_id collision on rename: {new_id!r}",
|
||||
)
|
||||
_insert_template(conn, content, target_id=new_id)
|
||||
imported_records.append({"kind": "template", "id": new_id})
|
||||
rename_map[old_id] = new_id
|
||||
elif _is_module_kind(kind or ""):
|
||||
if _module_exists(conn, new_id):
|
||||
raise _ImporterContractError(
|
||||
f"module new_id collision on rename: {new_id!r}",
|
||||
)
|
||||
_insert_module(conn, kind, content, target_id=new_id) # type: ignore[arg-type]
|
||||
imported_records.append({"kind": kind, "id": new_id}) # type: ignore[dict-item]
|
||||
rename_map[old_id] = new_id
|
||||
else:
|
||||
raise _ImporterContractError(
|
||||
f"unknown kind in rename: {kind!r}",
|
||||
)
|
||||
|
||||
# ---- UNDO ROW ----------------------------------------------
|
||||
undo_id = _gen_undo_id()
|
||||
conn.execute(
|
||||
"INSERT INTO import_undo("
|
||||
"id, created_at, imported_records, "
|
||||
"replaced_snapshots, rename_map"
|
||||
") VALUES(?, ?, ?, ?, ?);",
|
||||
(
|
||||
undo_id, now_iso(),
|
||||
json.dumps(imported_records),
|
||||
json.dumps(replaced_snapshots),
|
||||
json.dumps(rename_map),
|
||||
),
|
||||
)
|
||||
except _ImporterContractError as exc:
|
||||
# `with conn:` already rolled back on exception. Return the
|
||||
# importer-side contract message verbatim — we wrote it, it's
|
||||
# safe.
|
||||
return {"ok": False, "error": str(exc)}
|
||||
except (sqlite3.IntegrityError, sqlite3.DatabaseError) as exc:
|
||||
# Raw sqlite error — don't leak column/constraint internals.
|
||||
# The pre-checks above (`_module_exists`, `_bundle_exists`,
|
||||
# `_require_entity_fields`) cover every contract-level
|
||||
# violation we expect; reaching here means the DB rejected
|
||||
# the write for a lower-level reason (e.g. FK, NOT NULL,
|
||||
# corruption). Log the real cause for ops; return a generic
|
||||
# envelope to the caller.
|
||||
logger.warning("import commit failed at DB layer: %s", exc)
|
||||
return {"ok": False, "error": "database integrity violation"}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"undo_id": undo_id,
|
||||
"summary": {
|
||||
"added": len(imported_records) - len(rename_map),
|
||||
"replaced": len(replaced_snapshots),
|
||||
"renamed": len(rename_map),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_undo_entry(
|
||||
conn: sqlite3.Connection, undo_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Read the persisted undo metadata. Returns ``None`` if missing.
|
||||
|
||||
The dict shape mirrors what `commit_import` wrote:
|
||||
``{id, created_at, imported_records, replaced_snapshots, rename_map}``.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT * FROM import_undo WHERE id = ?;", (undo_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"id": row["id"],
|
||||
"created_at": row["created_at"],
|
||||
"imported_records": json.loads(row["imported_records"]),
|
||||
"replaced_snapshots": json.loads(row["replaced_snapshots"]),
|
||||
"rename_map": json.loads(row["rename_map"]),
|
||||
}
|
||||
|
||||
|
||||
def undo_import(
|
||||
conn: sqlite3.Connection, undo_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Reverse a previous `commit_import` call.
|
||||
|
||||
Steps inside a single ``with conn:`` transaction:
|
||||
1. Restore each replaced row to its pre-replace state.
|
||||
2. Delete each inserted record (adds + renames' new_ids).
|
||||
3. Delete the import_undo row.
|
||||
|
||||
Returns ``{"ok": True}`` on success, ``{"ok": False, "error": str}``
|
||||
on missing entry or any restore/delete error (rolls back).
|
||||
"""
|
||||
entry = get_undo_entry(conn, undo_id)
|
||||
if entry is None:
|
||||
return {"ok": False, "error": f"undo entry {undo_id!r} not found"}
|
||||
|
||||
try:
|
||||
with conn:
|
||||
# ---- RESTORE REPLACES --------------------------------------
|
||||
for rid, snap in entry["replaced_snapshots"].items():
|
||||
kind = snap["kind"]
|
||||
row = snap["row"]
|
||||
if kind == "bundle":
|
||||
# Restore literal columns including the original
|
||||
# payload_hash, version, timestamps. We bypass
|
||||
# `_update_bundle` because that helper bumps
|
||||
# `version` and recomputes `payload_hash` — neither
|
||||
# is correct for an undo (we want the row to look
|
||||
# bit-identical to its pre-replace state).
|
||||
conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"name = ?, description = ?, color = ?, "
|
||||
"category_id = ?, tags = ?, is_favorite = ?, "
|
||||
"children = ?, payload_hash = ?, version = ?, "
|
||||
"created_at = ?, updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
row["name"], row["description"], row["color"],
|
||||
row["category_id"],
|
||||
json.dumps(row["tags"]),
|
||||
int(row["is_favorite"]),
|
||||
json.dumps(row["children"]),
|
||||
row["payload_hash"],
|
||||
row["version"],
|
||||
row["created_at"], row["updated_at"], rid,
|
||||
),
|
||||
)
|
||||
elif kind == "template":
|
||||
# Restore literal columns to the pre-replace state.
|
||||
# Templates have no version / payload_hash columns,
|
||||
# so the snapshot is just the metadata + string.
|
||||
conn.execute(
|
||||
"UPDATE templates SET "
|
||||
"name = ?, description = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, template_string = ?, "
|
||||
"created_at = ?, updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
row["name"], row["description"],
|
||||
row["category_id"],
|
||||
json.dumps(row["tags"]),
|
||||
int(row["is_favorite"]),
|
||||
row["template_string"],
|
||||
row["created_at"], row["updated_at"], rid,
|
||||
),
|
||||
)
|
||||
elif _is_module_kind(kind):
|
||||
conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, payload = ?, "
|
||||
"snapshot_fingerprint = ?, version = ?, "
|
||||
"created_at = ?, updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
row["name"], row["description"],
|
||||
row["category_id"],
|
||||
json.dumps(row["tags"]),
|
||||
int(row["is_favorite"]),
|
||||
json.dumps(row["payload"]),
|
||||
row["snapshot_fingerprint"],
|
||||
row["version"],
|
||||
row["created_at"], row["updated_at"], rid,
|
||||
),
|
||||
)
|
||||
else:
|
||||
raise _ImporterContractError(
|
||||
f"unknown kind {kind!r} in undo snapshot",
|
||||
)
|
||||
|
||||
# ---- DELETE INSERTS ----------------------------------------
|
||||
for rec in entry["imported_records"]:
|
||||
kind = rec["kind"]
|
||||
rec_id = rec["id"]
|
||||
if kind == "bundle":
|
||||
conn.execute(
|
||||
"DELETE FROM bundles WHERE id = ?;", (rec_id,),
|
||||
)
|
||||
elif kind == "template":
|
||||
conn.execute(
|
||||
"DELETE FROM templates WHERE id = ?;", (rec_id,),
|
||||
)
|
||||
elif kind == "category":
|
||||
conn.execute(
|
||||
"DELETE FROM module_categories WHERE id = ?;",
|
||||
(rec_id,),
|
||||
)
|
||||
elif _is_module_kind(kind):
|
||||
conn.execute(
|
||||
"DELETE FROM modules WHERE id = ?;", (rec_id,),
|
||||
)
|
||||
else:
|
||||
raise _ImporterContractError(
|
||||
f"unknown kind {kind!r} in imported_records",
|
||||
)
|
||||
|
||||
# ---- EVICT UNDO ROW ----------------------------------------
|
||||
conn.execute(
|
||||
"DELETE FROM import_undo WHERE id = ?;", (undo_id,),
|
||||
)
|
||||
except _ImporterContractError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
except KeyError as exc:
|
||||
# Corrupt undo metadata (missing 'kind'/'id'/'row' keys). Treat
|
||||
# as our contract violation — the undo blob is internal so the
|
||||
# safe message names the missing field without leaking SQL.
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"corrupt undo metadata: missing key {exc!s}",
|
||||
}
|
||||
except sqlite3.DatabaseError as exc:
|
||||
logger.warning("undo failed at DB layer: %s", exc)
|
||||
return {"ok": False, "error": "database integrity violation"}
|
||||
|
||||
return {"ok": True}
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Versioned payload migration chain. Mirror of TypeScript
|
||||
`src/manager/import-export/migrations.ts`.
|
||||
|
||||
Used by `engine/importer.py` on server-side parse paths (re-validate
|
||||
on commit). Client-side migration is the source of truth for what
|
||||
the user saw in the picker; server re-runs as defense in depth.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from engine.migrations.v0_to_v1 import migrate_v0_to_v1
|
||||
from engine.migrations.v1_to_v2 import migrate_v1_to_v2
|
||||
|
||||
CURRENT_SCHEMA_VERSION = 2
|
||||
|
||||
# Highest schema version this runtime can natively READ + WRITE — distinct
|
||||
# from CURRENT_SCHEMA_VERSION (the migration-chain head, which stays 2 because
|
||||
# v2->v3/v3->v4 are no-ops). v3 is the SP2b text-grammar bump (shape-identical
|
||||
# to v2) and v4 is the additive `target_select` constraint reach; both are
|
||||
# handled natively, so a payload at CURRENT < v <= MAX_KNOWN installs AS-IS
|
||||
# with no migration. Mirror of TS `MAX_KNOWN_SCHEMA_VERSION` in
|
||||
# `src/manager/import-export/migrations.ts`.
|
||||
#
|
||||
# MAINTENANCE CONTRACT: bump this whenever the TS `schemaVersionForPayload()`
|
||||
# learns to stamp a new (higher) version — otherwise this commit-side
|
||||
# re-validate will reject the very shapes the runtime just learned to produce.
|
||||
MAX_KNOWN_SCHEMA_VERSION = 4
|
||||
|
||||
_CHAIN = {
|
||||
0: migrate_v0_to_v1,
|
||||
1: migrate_v1_to_v2,
|
||||
}
|
||||
|
||||
|
||||
def migrate_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Walk the migration chain from `payload.schema_version` up to current.
|
||||
|
||||
Returns one of:
|
||||
- {"ok": True, "migrated": <dict>, "migrated_entity_count": int}
|
||||
where migrated_entity_count is the cumulative entity count across
|
||||
all migration steps (total bundles+wildcards+fixed_values+combines+
|
||||
derivations+constraints+categories that passed through any step).
|
||||
Matches TS `migratedEntityCount`.
|
||||
- {"ok": False, "reason": str}
|
||||
"""
|
||||
version = payload.get("schema_version")
|
||||
if not isinstance(version, int):
|
||||
return {"ok": False, "reason": "payload missing schema_version field"}
|
||||
# Reject threshold reads MAX_KNOWN (highest natively-supported version),
|
||||
# NOT CURRENT (the migration-chain head). A payload at
|
||||
# CURRENT < v <= MAX_KNOWN is shape-compatible and handled natively, so it
|
||||
# passes through AS-IS — the while-loop below is bound by CURRENT, so there
|
||||
# is nothing to migrate and schema_version is preserved. Only > MAX_KNOWN
|
||||
# rejects. Mirror of TS `migrateImportEnvelope`.
|
||||
if version > MAX_KNOWN_SCHEMA_VERSION:
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": f"future schema version {version} (max known: {MAX_KNOWN_SCHEMA_VERSION})",
|
||||
}
|
||||
current = {
|
||||
"schema_version": version,
|
||||
"bundles": payload.get("bundles", []),
|
||||
"wildcards": payload.get("wildcards", []),
|
||||
"fixed_values": payload.get("fixed_values", []),
|
||||
"combines": payload.get("combines", []),
|
||||
"derivations": payload.get("derivations", []),
|
||||
"constraints": payload.get("constraints", []),
|
||||
"categories": payload.get("categories", []),
|
||||
}
|
||||
migrated_entity_count = 0
|
||||
while current["schema_version"] < CURRENT_SCHEMA_VERSION:
|
||||
fn = _CHAIN.get(current["schema_version"])
|
||||
if fn is None:
|
||||
ver = current["schema_version"]
|
||||
return {"ok": False, "reason": f"no migration registered for v{ver}"}
|
||||
before = sum(
|
||||
len(current[k])
|
||||
for k in ("bundles", "wildcards", "fixed_values", "combines",
|
||||
"derivations", "constraints", "categories")
|
||||
)
|
||||
current = fn(current)
|
||||
migrated_entity_count += before
|
||||
return {"ok": True, "migrated": current, "migrated_entity_count": migrated_entity_count}
|
||||
@@ -1,27 +0,0 @@
|
||||
"""v0 -> v1: no-op stub. Tags every entity with migrated_from=0.
|
||||
|
||||
NOTE: explicitly enumerates all seven known entity arrays (bundles,
|
||||
wildcards, fixed_values, combines, derivations, constraints, categories).
|
||||
If a future schema version adds an eighth entity array to the payload
|
||||
shape, update BOTH this migration AND every subsequent migration in the
|
||||
chain to tag the new array. Otherwise migrations will silently pass the
|
||||
new array through untagged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def migrate_v0_to_v1(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
def tag(e: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**e, "migrated_from": 0}
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"bundles": [tag(b) for b in payload["bundles"]],
|
||||
"wildcards": [tag(w) for w in payload["wildcards"]],
|
||||
"fixed_values": [tag(v) for v in payload["fixed_values"]],
|
||||
"combines": [tag(v) for v in payload["combines"]],
|
||||
"derivations": [tag(v) for v in payload["derivations"]],
|
||||
"constraints": [tag(c) for c in payload["constraints"]],
|
||||
"categories": [tag(v) for v in payload["categories"]],
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
"""v1 -> v2 payload migration (SP1 multi-tag + boolean sub-category filter).
|
||||
|
||||
Transforms, all idempotent:
|
||||
* option `sub_category: "x"` -> `sub_categories: ["x"]` (None/absent -> []);
|
||||
* registry + option + tag_group sub-category names slugified to a single
|
||||
safe token (whitespace / boolean-grammar / ref chars -> `_`), reserved
|
||||
words suffixed (`or` -> `or_1`), de-duped — cascaded so an option/group
|
||||
membership tracks its renamed registry entry;
|
||||
* instance `category_filter: [...]` -> a boolean expression string
|
||||
(`" or ".join`) + a separate `exclude_null` flag (the old `"null"`
|
||||
list-entry);
|
||||
* nested `@{uuid#name:a,b,null}` refs -> `@{uuid#name:a or b!null}`
|
||||
(comma list -> `or`, `null` keyword -> trailing `!null` marker).
|
||||
|
||||
Two entry shapes share one core (`_deep_migrate`):
|
||||
* `migrate_v1_to_v2(envelope)` — the import/export multi-row envelope
|
||||
(`wildcards[]`/`bundles[]`/...); registered in the version `_CHAIN`.
|
||||
* `migrate_module_v1_to_v2` / `migrate_bundle_v1_to_v2` — per-row payload
|
||||
migrators for the lazy/bulk DB path (`engine/db/lazy_migrate.py`); the
|
||||
row's `payload` column is the inner module payload (`{options, ...}`,
|
||||
no `type` field — wildcard-ness is detected structurally).
|
||||
|
||||
Mirror: src/manager/import-export/migrations.ts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from engine.syntax.subcat_filter import RESERVED
|
||||
|
||||
# Chars a v2 sub-category name may not contain (mirrors subcat_filter
|
||||
# validate_subcat_name): whitespace + the boolean-grammar + ref delimiters.
|
||||
_BAD = re.compile(r"[\s()!,#:}@{$]+")
|
||||
# Broad ref matcher (v1 OR v2 form) so v1 comma/null lists get rewritten
|
||||
# while v2 refs pass through untouched. Groups: uuid, `#name` seg, `:tail`.
|
||||
_REF_RE = re.compile(r"@\{([0-9a-f]{8})(#[^:}]*)?(:[^}]*)?\}")
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
return _BAD.sub("_", name).strip("_") or "_"
|
||||
|
||||
|
||||
def _name_map(registry: list[str]) -> dict[str, str]:
|
||||
"""old sub-category name -> sanitized, reserved-safe, unique name."""
|
||||
out: dict[str, str] = {}
|
||||
used: set[str] = set()
|
||||
for raw in registry:
|
||||
s = _slug(raw)
|
||||
if s.lower() in RESERVED:
|
||||
s = f"{s}_1"
|
||||
base, n = s, 1
|
||||
while s in used:
|
||||
n += 1
|
||||
s = f"{base}_{n}"
|
||||
used.add(s)
|
||||
out[raw] = s
|
||||
return out
|
||||
|
||||
|
||||
def _rewrite_ref(m: re.Match[str]) -> str:
|
||||
uuid = m.group(1)
|
||||
name_seg = m.group(2) or ""
|
||||
tail = m.group(3)
|
||||
if tail is None:
|
||||
return m.group(0) # bare `@{uuid}` / `@{uuid#name}` — nothing to do
|
||||
lst = tail[1:] # strip the leading ':'
|
||||
has_comma = "," in lst
|
||||
parts = [x.strip() for x in lst.split(",")]
|
||||
has_null = "null" in parts
|
||||
if not has_comma and not has_null:
|
||||
# Single tag OR an already-v2 boolean expression — leave as-is so
|
||||
# the rewrite is idempotent and never corrupts a v2 ref.
|
||||
return m.group(0)
|
||||
# Slug each tag so a cross-wildcard ref tracks the target's renamed
|
||||
# registry entry (deterministic — same _slug the target applies).
|
||||
tags = [_slug(p) for p in parts if p and p != "null"]
|
||||
expr = " or ".join(tags)
|
||||
out = "@{" + uuid + name_seg
|
||||
if expr:
|
||||
out += ":" + expr
|
||||
if has_null:
|
||||
out += "!null"
|
||||
return out + "}"
|
||||
|
||||
|
||||
def _rewrite_refs(s: str) -> str:
|
||||
return _REF_RE.sub(_rewrite_ref, s)
|
||||
|
||||
|
||||
def _is_wildcard_payload(p: Any) -> bool:
|
||||
"""A wildcard payload is the only shape carrying a sub-category model:
|
||||
an `options` list plus either a `sub_categories` registry or an option
|
||||
bearing the legacy singular `sub_category` field."""
|
||||
if not isinstance(p, dict) or not isinstance(p.get("options"), list):
|
||||
return False
|
||||
if "sub_categories" in p:
|
||||
return True
|
||||
return any(isinstance(o, dict) and "sub_category" in o for o in p["options"])
|
||||
|
||||
|
||||
def _apply_wildcard_payload(
|
||||
p: dict[str, Any], nmap: dict[str, str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
reg = [s for s in p.get("sub_categories", []) if isinstance(s, str)]
|
||||
if nmap is None:
|
||||
nmap = _name_map(reg)
|
||||
out = {**p, "sub_categories": [nmap[s] for s in reg]}
|
||||
new_opts: list[Any] = []
|
||||
for o in p.get("options", []):
|
||||
if not isinstance(o, dict):
|
||||
new_opts.append(o)
|
||||
continue
|
||||
o = dict(o)
|
||||
if isinstance(o.get("sub_categories"), list): # already-v2 / idempotent
|
||||
o["sub_categories"] = [nmap.get(t, _slug(t)) for t in o["sub_categories"]]
|
||||
else:
|
||||
sc = o.pop("sub_category", None)
|
||||
o["sub_categories"] = (
|
||||
[nmap.get(sc, _slug(sc))] if isinstance(sc, str) and sc else []
|
||||
)
|
||||
if isinstance(o.get("value"), str):
|
||||
o["value"] = _rewrite_refs(o["value"])
|
||||
new_opts.append(o)
|
||||
out["options"] = new_opts
|
||||
if isinstance(p.get("tag_groups"), dict):
|
||||
out["tag_groups"] = {
|
||||
g: [nmap.get(m, _slug(m)) for m in mem if isinstance(m, str)]
|
||||
for g, mem in p["tag_groups"].items()
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _migrate_instance(inst: dict[str, Any], nmap: dict[str, str]) -> dict[str, Any]:
|
||||
cf = inst.get("category_filter")
|
||||
if not isinstance(cf, list):
|
||||
return inst
|
||||
out = dict(inst)
|
||||
has_null = "null" in cf
|
||||
tags = [nmap.get(c, _slug(c)) for c in cf if isinstance(c, str) and c and c != "null"]
|
||||
out["category_filter"] = " or ".join(tags)
|
||||
out["exclude_null"] = bool(out.get("exclude_null")) or has_null
|
||||
return out
|
||||
|
||||
|
||||
def _deep_migrate(obj: Any) -> Any:
|
||||
"""Recursive transform shared by the per-row module/bundle migrators:
|
||||
migrate any wildcard-shaped dict in place, rewrite `@{}` refs in every
|
||||
string, recurse into containers. Idempotent."""
|
||||
if isinstance(obj, dict):
|
||||
if _is_wildcard_payload(obj):
|
||||
return _apply_wildcard_payload(obj)
|
||||
return {k: _deep_migrate(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_deep_migrate(x) for x in obj]
|
||||
if isinstance(obj, str):
|
||||
return _rewrite_refs(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def migrate_module_v1_to_v2(payload: dict[str, Any], ctx: Any = None) -> dict[str, Any]:
|
||||
"""Per-row module-payload migrator for the lazy/bulk DB path."""
|
||||
return _deep_migrate(payload)
|
||||
|
||||
|
||||
def migrate_bundle_v1_to_v2(payload: dict[str, Any], ctx: Any = None) -> dict[str, Any]:
|
||||
"""Per-row bundle-payload migrator — recurses into frozen child snapshots."""
|
||||
return _deep_migrate(payload)
|
||||
|
||||
|
||||
def _migrate_wildcard_entry(w: dict[str, Any]) -> dict[str, Any]:
|
||||
p = w.get("payload")
|
||||
nmap: dict[str, str] = {}
|
||||
out = dict(w)
|
||||
if isinstance(p, dict):
|
||||
reg = [s for s in p.get("sub_categories", []) if isinstance(s, str)]
|
||||
nmap = _name_map(reg)
|
||||
out["payload"] = _apply_wildcard_payload(p, nmap)
|
||||
inst = w.get("instance")
|
||||
if isinstance(inst, dict):
|
||||
out["instance"] = _migrate_instance(inst, nmap)
|
||||
return out
|
||||
|
||||
|
||||
def migrate_v1_to_v2(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Envelope migrator (mirrors the TS chain step). Maps each wildcard
|
||||
entry structurally (+ its instance), deep-migrates the other entity
|
||||
arrays (ref rewrite + any nested wildcard snapshots), bumps to v2."""
|
||||
out = dict(payload)
|
||||
out["wildcards"] = [
|
||||
_migrate_wildcard_entry(w) if isinstance(w, dict) else w
|
||||
for w in payload.get("wildcards", [])
|
||||
]
|
||||
for key in ("bundles", "fixed_values", "combines", "derivations",
|
||||
"constraints", "categories", "templates"):
|
||||
if isinstance(payload.get(key), list):
|
||||
out[key] = [_deep_migrate(e) for e in payload[key]]
|
||||
out["schema_version"] = 2
|
||||
return out
|
||||
+5
-102
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from engine.syntax.types import ResolveContext, SurfaceKind
|
||||
@@ -23,101 +23,15 @@ class _RuntimeResolveContext:
|
||||
warnings: list[dict[str, Any]]
|
||||
_vars: dict[str, Any]
|
||||
_catalog: dict[str, dict[str, Any]]
|
||||
# Back-channels for the syntax resolver's nested-`@{}` path so it
|
||||
# can apply constraints to wildcards reached transitively (same
|
||||
# rule book as the chain-level wildcard handler). Both default to
|
||||
# empty so legacy callers that build a ResolveContext by hand keep
|
||||
# working — the resolver falls back to "no constraints" when the
|
||||
# bucket is empty/None.
|
||||
_constraints: list[dict[str, Any]] = field(default_factory=list)
|
||||
_picks: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
# SP3 reach-selector hit counter (per-constraint, keyed by
|
||||
# `__constraint_module_id__`). Threaded by reference so increments
|
||||
# inside apply_constraints_for_target on the nested-ref path share
|
||||
# one counter with the chain-level direct path + the pipeline's ctx
|
||||
# bucket — first/next coverage spans both surfaces.
|
||||
_hits: dict[str, int] = field(default_factory=dict)
|
||||
# SP3 nested-pick carrier identity for the CURRENT resolve frame:
|
||||
# the wildcard whose chosen option value textually contains the
|
||||
# `@{T}` ref being resolved, plus that option's id. MUTABLE +
|
||||
# save/restored by the resolver around each nested-option recursion
|
||||
# (mirroring the rng swap) so `_resolve_ref` can tell
|
||||
# apply_constraints_for_target which (carrier_uid, option_id) is
|
||||
# firing target T — the only thing a `pick` selector's `nested`
|
||||
# occurrence matches on. None outside any carrier option.
|
||||
_carrier_uid: str | None = None
|
||||
_carrier_option_id: str | None = None
|
||||
|
||||
def get_var(self, name: str) -> str | None:
|
||||
# SP2a: return the raw stored value (may be a ListVar from a
|
||||
# multi-select wildcard); the resolver formats it. Typed str | None
|
||||
# for the Protocol — a ListVar flows through at runtime via the
|
||||
# Any-valued vars dict, and the resolver narrows on isinstance.
|
||||
if name in self._vars:
|
||||
return self._vars[name]
|
||||
return str(self._vars[name])
|
||||
return None
|
||||
|
||||
def get_module(self, uuid: str) -> dict[str, Any] | None:
|
||||
"""Catalog lookup. Pure O(1) dict get. Spec §2.5.
|
||||
|
||||
No DB fallback. No network. No side effects. If the catalog does
|
||||
not contain `uuid`, returns None and lets the resolver decide
|
||||
(lenient → warning, strict → UnknownRefError).
|
||||
|
||||
Engine isolation invariant — `engine/modules/__init__.py` MUST NOT
|
||||
import from `engine.db`, `wp_api`, or `wp_nodes`. Pinned by
|
||||
`tests/engine/modules/test_resolve_context_isolation.py`.
|
||||
"""
|
||||
return self._catalog.get(uuid)
|
||||
|
||||
def get_constraints(self) -> list[dict[str, Any]]:
|
||||
"""Registered constraints from the current pipeline run.
|
||||
|
||||
Each entry is the meta dict that ``constraint_handler`` writes
|
||||
into ``ctx['__wp_constraints__']``. Used by ``_resolve_ref`` to
|
||||
apply constraints against the nested-target wildcard before
|
||||
rolling its options.
|
||||
"""
|
||||
return self._constraints
|
||||
|
||||
def get_picks(self) -> dict[str, dict[str, Any]]:
|
||||
"""``ctx['__wp_picks__']`` — every wildcard's last pick keyed
|
||||
by module id. Constraint application needs this to look up the
|
||||
source wildcard's pick when reweighting the target."""
|
||||
return self._picks
|
||||
|
||||
def get_constraint_hits(self) -> dict[str, int]:
|
||||
"""``ctx['__wp_constraint_hits__']`` — per-constraint firing
|
||||
count keyed by module id. Threaded by reference so the
|
||||
nested-ref resolver's increments are observed by the pipeline +
|
||||
by subsequent target-instance resolves later in the chain
|
||||
(first/next coverage spans direct + nested encounters)."""
|
||||
return self._hits
|
||||
|
||||
def set_carrier(self, carrier_uid: str | None, option_id: str | None) -> None:
|
||||
"""Set the CURRENT carrier identity (the wildcard whose chosen
|
||||
option value contains the `@{T}` ref about to resolve, + that
|
||||
option's id). The resolver save/restores this around each nested
|
||||
option recursion (mirroring the rng swap)."""
|
||||
self._carrier_uid = carrier_uid
|
||||
self._carrier_option_id = option_id
|
||||
|
||||
def get_carrier(self) -> tuple[str | None, str | None]:
|
||||
"""Return the current ``(carrier_uid, option_id)`` so the
|
||||
resolver can save it before a recursion and restore it after."""
|
||||
return self._carrier_uid, self._carrier_option_id
|
||||
|
||||
def get_carrier_ctx(self) -> dict[str, Any] | None:
|
||||
"""The current carrier as the dict shape
|
||||
``apply_constraints_for_target`` /
|
||||
``_constraints._occurrence_matches`` expect, or ``None`` when no
|
||||
carrier is set (outside any carrier option — e.g. a top-level
|
||||
ref). A `pick` selector's `nested` entry matches on these two
|
||||
keys; every other selector mode ignores them."""
|
||||
if self._carrier_uid is None and self._carrier_option_id is None:
|
||||
return None
|
||||
return {"carrier_uid": self._carrier_uid, "option_id": self._carrier_option_id}
|
||||
|
||||
|
||||
def build_resolve_ctx(
|
||||
ctx: dict[str, Any],
|
||||
@@ -130,9 +44,6 @@ def build_resolve_ctx(
|
||||
Handlers call this with their type-appropriate surface ("wildcard" /
|
||||
"combine" / "derivation" / "assembler") then pass the result to resolve_text.
|
||||
"""
|
||||
constraints = ctx.get("__wp_constraints__")
|
||||
picks = ctx.get("__wp_picks__")
|
||||
hits = ctx.setdefault("__wp_constraint_hits__", {})
|
||||
return _RuntimeResolveContext( # type: ignore[return-value]
|
||||
rng=ctx["__wp_rng__"],
|
||||
max_ref_depth=int(ctx.get("__wp_max_ref_depth__", 8)),
|
||||
@@ -142,17 +53,6 @@ def build_resolve_ctx(
|
||||
warnings=ctx["__wp_warnings__"],
|
||||
_vars={k: v for k, v in ctx.items() if not k.startswith("__")},
|
||||
_catalog=ctx.get("__wp_catalog__", {}),
|
||||
# Constraint bucket + picks table threaded through so the
|
||||
# syntax resolver's `_resolve_ref` can apply chain-level
|
||||
# constraints against nested-via-`@{}` target wildcards. Both
|
||||
# may be missing (legacy ctx, tests that pre-date constraint
|
||||
# support) — defaults handle that path.
|
||||
_constraints=constraints if isinstance(constraints, list) else [],
|
||||
_picks=picks if isinstance(picks, dict) else {},
|
||||
# SP3 hit counter — passed by reference so the nested-ref
|
||||
# resolver's increments stick in ctx + share one counter with
|
||||
# the direct path for first/next coverage.
|
||||
_hits=hits if isinstance(hits, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
@@ -219,15 +119,18 @@ __all__ += ["FixedValuesHandler"]
|
||||
from engine.modules.combine_handler import CombineHandler # noqa: E402
|
||||
from engine.modules.constraint_handler import ConstraintHandler # noqa: E402
|
||||
from engine.modules.derivation_handler import DerivationHandler # noqa: E402
|
||||
from engine.modules.pipeline_handler import PipelineHandler # noqa: E402
|
||||
|
||||
register_handler(CombineHandler)
|
||||
register_handler(DerivationHandler)
|
||||
register_handler(ConstraintHandler)
|
||||
register_handler(PipelineHandler)
|
||||
|
||||
__all__ += [
|
||||
"CombineHandler",
|
||||
"ConstraintHandler",
|
||||
"DerivationHandler",
|
||||
"PipelineHandler",
|
||||
]
|
||||
|
||||
__all__ += ["build_resolve_ctx"]
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Pure constraint re-weight math (SP3). No engine/ctx imports — testable bare.
|
||||
|
||||
One multiply operator at three levels: across an option's matching matrix
|
||||
cells (multi-tag), across source picks (multi-pick), and — by the caller —
|
||||
across constraints. `exclude` (factor 0) is the absorbing element.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
EXCLUDE = object() # sentinel: option drops out of the pool
|
||||
|
||||
def _apply_rule(rule: dict[str, Any]):
|
||||
mode = rule.get("mode")
|
||||
if mode == "exclude":
|
||||
return EXCLUDE
|
||||
if mode in ("boost", "reduce"):
|
||||
try:
|
||||
return max(0.0, float(rule.get("factor", 1.0)))
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
return 1.0 # allow / unknown -> no weight change
|
||||
|
||||
def combine_constraint_factor(source_picks, option, matrix, exceptions):
|
||||
matrix = matrix or {}
|
||||
exc_by_pair: dict[tuple[str, str], dict] = {}
|
||||
for e in (exceptions or []):
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
s = e.get("source_value")
|
||||
if s is None:
|
||||
s = e.get("source")
|
||||
t = e.get("target_value")
|
||||
if t is None:
|
||||
t = e.get("target")
|
||||
if isinstance(s, str) and isinstance(t, str):
|
||||
exc_by_pair[(s, t)] = e
|
||||
opt_value = str(option.get("value", ""))
|
||||
opt_tags = option.get("tags") or []
|
||||
factor = 1.0
|
||||
for pick in (source_picks or []):
|
||||
p_value = str(pick.get("value", ""))
|
||||
p_tags = pick.get("tags") or []
|
||||
exc = exc_by_pair.get((p_value, opt_value))
|
||||
if exc is not None:
|
||||
r = _apply_rule(exc)
|
||||
if r is EXCLUDE:
|
||||
return EXCLUDE
|
||||
factor *= r
|
||||
continue
|
||||
for s in p_tags:
|
||||
row = matrix.get(s)
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
for t in opt_tags:
|
||||
rule = row.get(t)
|
||||
if isinstance(rule, dict):
|
||||
r = _apply_rule(rule)
|
||||
if r is EXCLUDE:
|
||||
return EXCLUDE
|
||||
factor *= r
|
||||
return factor
|
||||
@@ -1,264 +0,0 @@
|
||||
"""Shared constraint application — used by wildcard_handler (chain-level
|
||||
target wildcards) and engine.syntax.resolve._resolve_ref (nested-via-@{}
|
||||
target wildcards).
|
||||
|
||||
Pre-2026-05 the constraint matrix only applied when the target wildcard
|
||||
ran as a top-level module in the chain. Wildcards reached through
|
||||
``@{uuid}`` refs inside another wildcard's option value silently
|
||||
bypassed the matrix, defeating the whole point of constraints in any
|
||||
composed pipeline (outfit-set wildcards, palette reuse, etc.). This
|
||||
module pulls the apply logic out of wildcard_handler so both surfaces
|
||||
share one implementation.
|
||||
|
||||
The helper takes raw primitives (options list, target uuid, constraints
|
||||
bucket, picks bucket, warnings list) so it can be called from any
|
||||
context that has access to those — full pipeline ctx dict (wildcard
|
||||
handler) or narrow ResolveContext-style accessors (ref resolver).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from engine.modules.wildcard_handler import _apply_constraint_to_options
|
||||
|
||||
|
||||
def _push_constraint_warning(
|
||||
warnings: list[dict[str, Any]],
|
||||
*,
|
||||
type_: str,
|
||||
target_uuid: str,
|
||||
detail: dict[str, Any],
|
||||
) -> None:
|
||||
"""Mirror the warning shape used elsewhere in the engine so the
|
||||
debug viewer can render it uniformly."""
|
||||
warnings.append({
|
||||
"type": type_,
|
||||
"severity": "warn",
|
||||
"module_id": target_uuid,
|
||||
"source_field": "",
|
||||
"position": 0,
|
||||
"token_index": None,
|
||||
"detail": detail,
|
||||
"message": (
|
||||
f"constraint source not yet picked: {detail.get('source_wildcard_id')!r}"
|
||||
if type_ == "unknown_constraint_source"
|
||||
else f"constraint warning: {detail!r}"
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
def _occurrence_matches(pick_entries, firing_uid, carrier_ctx) -> bool:
|
||||
"""True when an explicit ``pick`` reach selector covers THIS firing
|
||||
target occurrence.
|
||||
|
||||
Two occurrence kinds:
|
||||
- ``direct`` — a top-level wildcard instance. Matched by its
|
||||
per-instance uid (``firing_uid``). ``carrier_ctx`` is ``None``
|
||||
for these (the wildcard handler fires them directly).
|
||||
- ``nested`` — a ``@{target}`` ref resolved inside a carrier
|
||||
wildcard's chosen option. Matched by (carrier_uid, option_id).
|
||||
``carrier_ctx`` carries those, threaded by the nested-resolve
|
||||
path (``engine.syntax.resolve``). The direct-firing wildcard
|
||||
handler passes ``carrier_ctx=None`` (it fires top-level
|
||||
instances, not nested refs), so the two kinds never cross-match.
|
||||
"""
|
||||
for p in pick_entries:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
if carrier_ctx is None:
|
||||
if p.get("kind") == "direct" and p.get("uid") == firing_uid:
|
||||
return True
|
||||
else:
|
||||
if (
|
||||
p.get("kind") == "nested"
|
||||
and p.get("carrier_uid") == carrier_ctx.get("carrier_uid")
|
||||
and p.get("option_id") == carrier_ctx.get("option_id")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _select_source_pick(
|
||||
picks: dict[str, dict[str, Any]],
|
||||
src_id: Any,
|
||||
constraint: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Resolve the source pick a constraint should re-weight against.
|
||||
|
||||
Selection rule (task_5200c1fc — source-instance binding):
|
||||
1. Look up the top-level entry `picks[src_id]` (today's bucket, the
|
||||
last-writer-wins survivor when two instances share a library uuid).
|
||||
2. If the constraint carries a `bundle_origin` AND that entry has a
|
||||
`by_origin[origin]` view, return that view — the source instance in
|
||||
the SAME bundle copy as this constraint.
|
||||
3. Otherwise return the top-level entry as-is = TODAY'S behavior,
|
||||
exactly. Covers: a no-bundle constraint, a constraint inside a
|
||||
bundle whose source is OUTSIDE the bundle, and every legacy
|
||||
workflow. This exact-fallback is what makes the change safe.
|
||||
|
||||
DOCUMENTED LIMITATION (task_5200c1fc): the same library wildcard added
|
||||
MANUALLY twice OUTSIDE any bundle shares no `bundle_origin`, so both
|
||||
manual instances land in the same top-level bucket and the constraint
|
||||
falls back to last-writer-wins. The constraint payload genuinely does
|
||||
not encode WHICH manual instance the user meant, so the design declines
|
||||
to guess rather than guessing wrong. This is a principled residual, not
|
||||
a gap; an explicit stored `source_uid` binding (Option 2 in the design)
|
||||
can be layered on later with precedence explicit > bundle-scope >
|
||||
fallback, foreclosing nothing.
|
||||
"""
|
||||
if not isinstance(src_id, str):
|
||||
return None
|
||||
top = picks.get(src_id)
|
||||
if not isinstance(top, dict):
|
||||
return None
|
||||
origin = constraint.get("__constraint_bundle_origin__")
|
||||
if origin:
|
||||
by_origin = top.get("by_origin")
|
||||
if isinstance(by_origin, dict):
|
||||
scoped = by_origin.get(origin)
|
||||
if isinstance(scoped, dict):
|
||||
return scoped
|
||||
return top
|
||||
|
||||
|
||||
def apply_constraints_for_target(
|
||||
options: list[dict[str, Any]],
|
||||
target_uuid: str,
|
||||
constraints: list[dict[str, Any]] | None,
|
||||
picks: dict[str, dict[str, Any]] | None,
|
||||
warnings: list[dict[str, Any]],
|
||||
*,
|
||||
consumed=None,
|
||||
hits=None,
|
||||
firing_uid=None,
|
||||
carrier_ctx=None,
|
||||
) -> tuple[list[dict[str, Any]], bool]:
|
||||
"""Apply EVERY constraint whose reach selector covers this firing
|
||||
target instance, combined sequentially.
|
||||
|
||||
Returns ``(adjusted_options, any_applied)``. ``any_applied`` is True
|
||||
iff at least one constraint actually fired — callers use it to
|
||||
decide whether to run the excludes-all check.
|
||||
|
||||
**Reach selector (SP3).** Each constraint carries
|
||||
``target_select = {mode, count?, picks?}`` (default ``{mode: "all"}``).
|
||||
On every firing target instance the per-constraint hit counter
|
||||
``hits[cid]`` increments; the selector then decides coverage:
|
||||
- ``all`` — covers every instance (re-weights every downstream
|
||||
target occurrence).
|
||||
- ``first`` — covers only the 1st (``n == 1``).
|
||||
- ``next`` — covers the first ``count`` (``n <= count``).
|
||||
- ``pick`` — covers the explicitly-listed occurrences
|
||||
(``_occurrence_matches``).
|
||||
Multiple covering constraints stack: their factors multiply in
|
||||
registration order and ``exclude`` is absorbing. This replaces the
|
||||
pre-SP3 one-shot / consumed-set model.
|
||||
|
||||
Reach is downstream-relative by construction: a constraint only
|
||||
appears in ``ctx["__wp_constraints__"]`` after its own module ran,
|
||||
so target occurrences upstream of it never see it.
|
||||
|
||||
``hits`` is the ctx-resident ``__wp_constraint_hits__`` dict; the
|
||||
caller threads it from ctx so first/next counters persist across the
|
||||
run (direct + nested encounters share one counter). ``consumed`` is
|
||||
accepted but ignored — kept so legacy callers don't break.
|
||||
|
||||
Each constraint warns + skips when its source wildcard hasn't been
|
||||
picked yet — caller code is responsible for ordering source
|
||||
wildcards before the target's roll. The warning gives the user a
|
||||
signal in WP_Debug instead of a silent no-op.
|
||||
"""
|
||||
if not constraints or not isinstance(constraints, list):
|
||||
return options, False
|
||||
if not isinstance(picks, dict):
|
||||
picks = {}
|
||||
if hits is None:
|
||||
hits = {}
|
||||
|
||||
any_applied = False
|
||||
for c in constraints:
|
||||
if not isinstance(c, dict) or c.get("target_wildcard_id") != target_uuid:
|
||||
continue
|
||||
cid = c.get("__constraint_module_id__")
|
||||
src_id = c.get("source_wildcard_id")
|
||||
src_pick = _select_source_pick(picks, src_id, c)
|
||||
if not isinstance(src_pick, dict):
|
||||
_push_constraint_warning(
|
||||
warnings,
|
||||
type_="unknown_constraint_source",
|
||||
target_uuid=target_uuid,
|
||||
detail={
|
||||
"source_wildcard_id": src_id,
|
||||
"target_wildcard_id": target_uuid,
|
||||
"hint": (
|
||||
"source must be picked before target — check chain order"
|
||||
),
|
||||
},
|
||||
)
|
||||
continue
|
||||
if cid is not None:
|
||||
hits[cid] = hits.get(cid, 0) + 1
|
||||
n = hits.get(cid, 1)
|
||||
sel = c.get("target_select") or {"mode": "all"}
|
||||
mode = sel.get("mode", "all")
|
||||
if mode == "first":
|
||||
covered = n == 1
|
||||
elif mode == "next":
|
||||
try:
|
||||
cnt = int(sel.get("count", 1))
|
||||
except (TypeError, ValueError):
|
||||
cnt = 1
|
||||
covered = n <= cnt
|
||||
elif mode == "pick":
|
||||
covered = _occurrence_matches(
|
||||
sel.get("picks") or [], firing_uid, carrier_ctx
|
||||
)
|
||||
else:
|
||||
covered = True
|
||||
if not covered:
|
||||
continue
|
||||
adjust_warnings: list[dict[str, Any]] = []
|
||||
options = _apply_constraint_to_options(
|
||||
options, c, src_pick, adjust_warnings,
|
||||
)
|
||||
any_applied = True
|
||||
for w in adjust_warnings:
|
||||
_push_constraint_warning(
|
||||
warnings,
|
||||
type_=w.get("type", "unknown_constraint_mode"),
|
||||
target_uuid=target_uuid,
|
||||
detail={
|
||||
**{k: v for k, v in w.items() if k != "type"},
|
||||
"source_wildcard_id": src_id,
|
||||
"target_wildcard_id": target_uuid,
|
||||
},
|
||||
)
|
||||
return options, any_applied
|
||||
|
||||
|
||||
def warn_excludes_all(
|
||||
options: list[dict[str, Any]],
|
||||
target_uuid: str,
|
||||
warnings: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Emit the ``constraint_excludes_all_options`` warning when post-
|
||||
application weights sum to zero. ``_pick_weighted`` falls back to
|
||||
``options[0]`` silently — without this warning the user sees the
|
||||
same option forever with no signal that constraints over-narrowed
|
||||
the pool.
|
||||
|
||||
Call AFTER ``apply_constraints_for_target`` returned ``any_applied``.
|
||||
"""
|
||||
if not options:
|
||||
return
|
||||
total = sum(max(0.0, float(o.get("weight", 1))) for o in options)
|
||||
if total <= 0:
|
||||
_push_constraint_warning(
|
||||
warnings,
|
||||
type_="constraint_excludes_all_options",
|
||||
target_uuid=target_uuid,
|
||||
detail={
|
||||
"target_wildcard_id": target_uuid,
|
||||
"hint": "every option was excluded; falling back to options[0]",
|
||||
},
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Composite-key encoder. Mirrors JS `encodeKey` byte-for-byte.
|
||||
|
||||
Spec invariant: composite keys are JSON-encoded string arrays, no
|
||||
whitespace, unicode literal (not escaped), array order preserved.
|
||||
Cross-language fitness test in `tests/test_engine_keys.py` enforces
|
||||
byte-exact match.
|
||||
|
||||
See: docs/superpowers/specs/2026-05-07-instance-overrides-modal-design.md §4.5
|
||||
"""
|
||||
import json
|
||||
|
||||
|
||||
def encode_key(parts: list[str]) -> str:
|
||||
return json.dumps(parts, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def decode_key(key: str) -> list[str]:
|
||||
"""Inverse of encode_key. Round-trip safe by spec invariant.
|
||||
|
||||
Raises ValueError on malformed input — caller should treat unknown
|
||||
keys as "skip silently" rather than crash the resolve.
|
||||
"""
|
||||
parts = json.loads(key)
|
||||
if not isinstance(parts, list) or not all(isinstance(p, str) for p in parts):
|
||||
raise ValueError(f"composite key must decode to list[str], got {parts!r}")
|
||||
return parts
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Shared seed-derivation helper for module handlers.
|
||||
|
||||
Every kind that needs reproducible randomness (wildcard pick, combine
|
||||
{a|b|c} resolution, fixed_values value alternations) routes through
|
||||
this same function so the same `(seed, key)` pair always yields the
|
||||
same RNG state. Lifting it out of wildcard_handler.py prevents drift
|
||||
when more kinds gain seed-lock support.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
|
||||
|
||||
def derive_module_rng(seed: int, key: str) -> random.Random:
|
||||
"""Per-module RNG derived from (seed, key).
|
||||
|
||||
`key` is a stable per-module string — wildcard uses `binding`,
|
||||
combine uses `output_var`, fixed_values uses module id. Different
|
||||
keys under the same seed yield independent streams so two
|
||||
co-resident modules binding different vars roll independently.
|
||||
|
||||
Used for BOTH locked and unlocked picks — applying the same
|
||||
derivation either way is what lets lock capture the visible
|
||||
roll: locking with `locked_seed = chain_seed` reproduces the
|
||||
unlocked pick exactly because both paths derive from
|
||||
`sha256(chain_seed:key)`.
|
||||
"""
|
||||
digest = hashlib.sha256(f"{int(seed)}:{key}".encode()).hexdigest()
|
||||
return random.Random(int(digest[:16], 16))
|
||||
@@ -1,15 +1,7 @@
|
||||
"""Combine module resolver — syntax-aware template fill.
|
||||
|
||||
Reads payload.template (or instance.template_override when set), resolves
|
||||
all $var / {a|b|c} / {N$$sep$$...} constructs against the runtime ctx,
|
||||
binds the result to payload.output_var.
|
||||
|
||||
Surface-gated: combine resolves $var (it's the consumer) but not @{uuid}
|
||||
(refs only resolve from wildcard option values — RefOutOfSurfaceError).
|
||||
|
||||
Seed lock: instance.locked_seed pins {a|b|c} resolution per instance;
|
||||
without it, derives from chain seed via the shared
|
||||
engine.modules._seed.derive_module_rng helper.
|
||||
Reads payload.template, resolves all $var / @{uuid} / {a|b|c} / {N$$sep$$...}
|
||||
constructs against the runtime ctx, binds the result to payload.output_var.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,13 +9,10 @@ import re
|
||||
from typing import Any
|
||||
|
||||
from engine.modules import build_resolve_ctx
|
||||
from engine.modules._seed import derive_module_rng
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
from engine.syntax import resolve_text
|
||||
|
||||
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||||
_MAX_IDENT_LEN = 64
|
||||
_MAX_TEMPLATE_LEN = 8000
|
||||
|
||||
|
||||
class CombineHandler(ModuleHandler):
|
||||
@@ -38,30 +27,9 @@ class CombineHandler(ModuleHandler):
|
||||
template = payload.get("template")
|
||||
if not isinstance(template, str):
|
||||
raise ValueError("combine payload.template must be a string")
|
||||
# Empty template means the module emits an empty string for its
|
||||
# output_var — almost always a config mistake (the user removed
|
||||
# the template content but forgot the module is still in the
|
||||
# chain). Reject so the broken state surfaces at save time.
|
||||
if not template:
|
||||
raise ValueError("combine payload.template must not be empty")
|
||||
if len(template) > _MAX_TEMPLATE_LEN:
|
||||
raise ValueError(
|
||||
f"combine payload.template must be at most {_MAX_TEMPLATE_LEN} "
|
||||
f"chars (got {len(template)})"
|
||||
)
|
||||
output_var = payload.get("output_var")
|
||||
if not isinstance(output_var, str) or not output_var:
|
||||
raise ValueError("combine payload.output_var must be a non-empty string")
|
||||
if len(output_var) > _MAX_IDENT_LEN:
|
||||
raise ValueError(
|
||||
f"combine payload.output_var must be at most {_MAX_IDENT_LEN} "
|
||||
f"chars (got {len(output_var)})"
|
||||
)
|
||||
if output_var.startswith("__"):
|
||||
raise ValueError(
|
||||
"combine payload.output_var must not start with '__' "
|
||||
"(reserved for engine-internal keys)"
|
||||
)
|
||||
if not _IDENT_RE.match(output_var):
|
||||
raise ValueError(
|
||||
f"combine payload.output_var {output_var!r} is not a valid identifier"
|
||||
@@ -81,37 +49,9 @@ class CombineHandler(ModuleHandler):
|
||||
ctx: Any,
|
||||
) -> dict[str, str]:
|
||||
cls.validate_payload(payload)
|
||||
# instance.template_override wins over payload.template — same
|
||||
# precedence pattern wildcard's option_weights / fixed_values'
|
||||
# values_overrides use.
|
||||
override = instance.get("template_override")
|
||||
template: str = override if isinstance(override, str) else payload["template"]
|
||||
# instance.variable_binding rebinds the produced var (mirrors
|
||||
# wildcard precedence). Empty string / non-string falls back to
|
||||
# payload.output_var. Strip leading `$` so callers can write
|
||||
# either `$foo` or `foo` in the modal.
|
||||
binding_override = instance.get("variable_binding")
|
||||
if isinstance(binding_override, str) and binding_override.strip():
|
||||
output_var: str = binding_override.lstrip("$").strip()
|
||||
else:
|
||||
output_var = payload["output_var"]
|
||||
template: str = payload["template"]
|
||||
output_var: str = payload["output_var"]
|
||||
|
||||
# Effective seed selection (mirrors WildcardHandler):
|
||||
# - locked_seed when present → reproducible per-instance
|
||||
# - chain seed otherwise → re-rolls each queue
|
||||
# Both feed derive_module_rng(seed, output_var) so locking with
|
||||
# locked_seed = chain_seed reproduces the unlocked roll exactly.
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
if isinstance(locked_seed, (int, float)):
|
||||
effective_seed = int(locked_seed)
|
||||
else:
|
||||
effective_seed = chain_seed
|
||||
rng = derive_module_rng(effective_seed, output_var)
|
||||
|
||||
# Patch ctx for the scope of this resolve so build_resolve_ctx
|
||||
# picks up our derived RNG instead of the chain RNG.
|
||||
ctx_local = {**ctx, "__wp_rng__": rng}
|
||||
resolve_ctx = build_resolve_ctx(ctx_local, surface="combine")
|
||||
resolve_ctx = build_resolve_ctx(ctx, surface="combine")
|
||||
result = resolve_text(template, resolve_ctx)
|
||||
return {output_var: result}
|
||||
|
||||
@@ -2,15 +2,10 @@
|
||||
|
||||
A constraint module declares a re-weighting matrix from one wildcard module's
|
||||
options to another's. The actual mutation is intentionally **not** done here:
|
||||
WildcardHandler will eventually inspect ``ctx["__wp_constraints__"]`` and apply the
|
||||
WildcardHandler will eventually inspect ``ctx["_constraints"]`` and apply the
|
||||
matrix when picking options. This handler only validates the payload and
|
||||
records metadata in the context for the wildcard handler to consume.
|
||||
|
||||
The bucket key uses the canonical ``__wp_*__`` engine-internal prefix so
|
||||
``strip_internals`` drops it from the public socket payload — without the
|
||||
``__`` prefix the constraint metadata leaked to the assembler as a
|
||||
user-facing ``_constraints`` variable, polluting autocomplete + templates.
|
||||
|
||||
TODO: When WildcardHandler grows constraint awareness, switch from recording
|
||||
metadata to applying it here, or keep it as a pure metadata channel and let
|
||||
the consumer drive resolution. The current decision is metadata-only.
|
||||
@@ -19,70 +14,13 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from engine.modules._keys import decode_key, encode_key
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
|
||||
_VALID_MODES = {"allow", "exclude", "boost", "reduce"}
|
||||
|
||||
# SP3 reach selector — which downstream target instance(s) a constraint
|
||||
# reaches. Shape only here; reach *behaviour* is applied by a later task.
|
||||
_REACH_MODES = {"first", "next", "all", "pick"}
|
||||
|
||||
|
||||
def _validate_target_select(ts):
|
||||
"""Validate the optional ``target_select`` reach selector.
|
||||
|
||||
Shape: ``{mode: "first"|"next"|"all"|"pick", count?, picks?}``. ``None``
|
||||
(absent) is allowed — the resolver defaults it to ``{mode: "all"}``.
|
||||
"""
|
||||
if ts is None:
|
||||
return
|
||||
if not isinstance(ts, dict):
|
||||
raise ValueError("constraint payload.target_select must be an object")
|
||||
mode = ts.get("mode", "all")
|
||||
if mode not in _REACH_MODES:
|
||||
raise ValueError(
|
||||
f"constraint payload.target_select.mode must be one of {sorted(_REACH_MODES)}"
|
||||
)
|
||||
if mode == "next":
|
||||
count = ts.get("count")
|
||||
if not isinstance(count, int) or isinstance(count, bool) or count < 1:
|
||||
raise ValueError(
|
||||
"constraint payload.target_select.count must be an int >= 1 for mode 'next'"
|
||||
)
|
||||
if mode == "pick":
|
||||
picks = ts.get("picks")
|
||||
if not isinstance(picks, list):
|
||||
raise ValueError(
|
||||
"constraint payload.target_select.picks must be a list for mode 'pick'"
|
||||
)
|
||||
for i, p in enumerate(picks):
|
||||
if not isinstance(p, dict) or p.get("kind") not in ("direct", "nested"):
|
||||
raise ValueError(
|
||||
f"constraint payload.target_select.picks[{i}].kind must be 'direct' or 'nested'"
|
||||
)
|
||||
if p["kind"] == "direct" and not isinstance(p.get("uid"), str):
|
||||
raise ValueError(
|
||||
f"constraint payload.target_select.picks[{i}].uid must be a string"
|
||||
)
|
||||
if p["kind"] == "nested" and not (
|
||||
isinstance(p.get("carrier_uid"), str) and isinstance(p.get("option_id"), str)
|
||||
):
|
||||
raise ValueError(
|
||||
f"constraint payload.target_select.picks[{i}] nested "
|
||||
"needs carrier_uid + option_id"
|
||||
)
|
||||
|
||||
|
||||
def _ctx_set_constraint(ctx: Any, meta: dict[str, Any]) -> None:
|
||||
"""Append ``meta`` to ``ctx['__wp_constraints__']`` (best-effort).
|
||||
|
||||
On total failure (both setter API and dict-like access raise), emit a
|
||||
`constraint_register_failed` warning so the user gets a debug-viewer
|
||||
signal — pre-fix the constraint silently never registered and the
|
||||
wildcard handler ran unconstrained with zero indication that the
|
||||
user's constraint was even loaded.
|
||||
"""
|
||||
"""Append ``meta`` to ``ctx['_constraints']`` (best-effort)."""
|
||||
if ctx is None:
|
||||
return
|
||||
# Try setter-based access first (engine Context API).
|
||||
@@ -90,56 +28,27 @@ def _ctx_set_constraint(ctx: Any, meta: dict[str, Any]) -> None:
|
||||
getter = getattr(ctx, "get", None)
|
||||
if callable(setter) and callable(getter):
|
||||
try:
|
||||
existing = getter("__wp_constraints__", None)
|
||||
existing = getter("_constraints", None)
|
||||
except TypeError:
|
||||
existing = None
|
||||
bucket = list(existing) if isinstance(existing, list) else []
|
||||
bucket.append(meta)
|
||||
try:
|
||||
setter("__wp_constraints__", bucket)
|
||||
setter("_constraints", bucket)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
# Fall back to dict-like __getitem__/__setitem__.
|
||||
try:
|
||||
existing = ctx["__wp_constraints__"] if "__wp_constraints__" in ctx else [] # type: ignore[operator,index]
|
||||
existing = ctx["_constraints"] if "_constraints" in ctx else [] # type: ignore[operator,index]
|
||||
except Exception:
|
||||
existing = []
|
||||
bucket = list(existing) if isinstance(existing, list) else []
|
||||
bucket.append(meta)
|
||||
try:
|
||||
ctx["__wp_constraints__"] = bucket # type: ignore[index]
|
||||
ctx["_constraints"] = bucket # type: ignore[index]
|
||||
except Exception:
|
||||
return
|
||||
except Exception as e:
|
||||
# Both setter + dict paths failed. Emit a warning so the
|
||||
# constraint failure surfaces in WP_Debug instead of being a
|
||||
# silent no-op (pre-fix users had no signal that their
|
||||
# constraint never made it into ctx — the wildcard handler
|
||||
# then ran unconstrained with zero indication).
|
||||
warnings = None
|
||||
try:
|
||||
warnings = ctx.get("__wp_warnings__") if hasattr(ctx, "get") else None
|
||||
except Exception:
|
||||
warnings = None
|
||||
if isinstance(warnings, list):
|
||||
warnings.append({
|
||||
"type": "constraint_register_failed",
|
||||
"severity": "error",
|
||||
"module_id": "",
|
||||
"source_field": "",
|
||||
"position": 0,
|
||||
"token_index": None,
|
||||
"detail": {
|
||||
"exception_type": type(e).__name__,
|
||||
"exception_message": str(e),
|
||||
"source_wildcard_id": meta.get("source_wildcard_id"),
|
||||
"target_wildcard_id": meta.get("target_wildcard_id"),
|
||||
},
|
||||
"message": (
|
||||
"constraint failed to register in ctx — "
|
||||
f"{type(e).__name__}: {e}"
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
def _validate_cell(cell: Any, where: str) -> None:
|
||||
@@ -153,14 +62,8 @@ def _validate_cell(cell: Any, where: str) -> None:
|
||||
factor = cell.get("factor")
|
||||
if not isinstance(factor, (int, float)) or isinstance(factor, bool):
|
||||
raise ValueError(f"constraint {where}.factor must be a number")
|
||||
# factor >= 0 — factor=0 is meaningful (effectively excludes the
|
||||
# option, same as `mode: exclude`). Strict `> 0` rejected legitimate
|
||||
# SPA-saved payloads where the editor surfaced an explicit zero
|
||||
# weight, breaking the entire constraint at runtime. Negative
|
||||
# factors stay rejected since they have no defined semantics in the
|
||||
# weighted-pick model.
|
||||
if float(factor) < 0:
|
||||
raise ValueError(f"constraint {where}.factor must not be negative")
|
||||
if float(factor) <= 0:
|
||||
raise ValueError(f"constraint {where}.factor must be a positive number")
|
||||
|
||||
|
||||
class ConstraintHandler(ModuleHandler):
|
||||
@@ -200,20 +103,12 @@ class ConstraintHandler(ModuleHandler):
|
||||
raise ValueError(
|
||||
f"constraint payload.exceptions[{i}] must be an object"
|
||||
)
|
||||
# Support both legacy (source/target) and tier 2 (source_value/target_value) naming.
|
||||
# Empty STRING is allowed — it's the null-option marker (a
|
||||
# wildcard option with `is_null: True` has `value: ""`, and
|
||||
# exceptions targeting that option store `source: ""` /
|
||||
# `target: ""`). Reject only when neither key is present as
|
||||
# a string (genuine missing-data) — that means we don't know
|
||||
# WHICH option the exception was authored against.
|
||||
for key_legacy, key_tier2 in (("source", "source_value"), ("target", "target_value")):
|
||||
v_legacy = exc.get(key_legacy)
|
||||
v_tier2 = exc.get(key_tier2)
|
||||
if not isinstance(v_legacy, str) and not isinstance(v_tier2, str):
|
||||
for key in ("source", "target"):
|
||||
v = exc.get(key)
|
||||
if not isinstance(v, str) or not v:
|
||||
raise ValueError(
|
||||
f"constraint payload.exceptions[{i}].{key_legacy} must be a "
|
||||
f"string (empty allowed for null-option matches)"
|
||||
f"constraint payload.exceptions[{i}].{key} must be a "
|
||||
f"non-empty string"
|
||||
)
|
||||
mode = exc.get("mode")
|
||||
if mode not in _VALID_MODES:
|
||||
@@ -226,15 +121,10 @@ class ConstraintHandler(ModuleHandler):
|
||||
raise ValueError(
|
||||
f"constraint payload.exceptions[{i}].factor must be a number"
|
||||
)
|
||||
# factor >= 0 — factor=0 is the canonical exception form for
|
||||
# "always exclude this pair". Same logic as the matrix-cell
|
||||
# check above; SPA payloads surface zero factors and the
|
||||
# strict > 0 rejected them, killing the whole constraint.
|
||||
if float(factor) < 0:
|
||||
if float(factor) <= 0:
|
||||
raise ValueError(
|
||||
f"constraint payload.exceptions[{i}].factor must not be negative"
|
||||
f"constraint payload.exceptions[{i}].factor must be positive"
|
||||
)
|
||||
_validate_target_select(payload.get("target_select"))
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
@@ -244,246 +134,13 @@ class ConstraintHandler(ModuleHandler):
|
||||
ctx: Any,
|
||||
) -> dict[str, str]:
|
||||
cls.validate_payload(payload)
|
||||
|
||||
# Tier 2 instance filters — runtime projection layer, applied
|
||||
# before recording into ctx so downstream consumers see only the
|
||||
# active subset.
|
||||
disabled_cells = set(instance.get("disabled_matrix_cells") or [])
|
||||
disabled_excs = set(instance.get("disabled_exception_keys") or [])
|
||||
|
||||
# Tier-D override maps (sparse — only set keys override library).
|
||||
cell_mode_overrides = instance.get("cell_mode_overrides") or {}
|
||||
cell_factor_overrides = instance.get("cell_factor_overrides") or {}
|
||||
exception_mode_overrides = instance.get("exception_mode_overrides") or {}
|
||||
exception_factor_overrides = instance.get("exception_factor_overrides") or {}
|
||||
extra_exceptions = instance.get("extra_exceptions") or []
|
||||
|
||||
# SP3 reach selector — instance override wins over the payload's
|
||||
# value, falling back to the {mode:"all"} default. Shape was
|
||||
# already validated in validate_payload (payload side); the
|
||||
# instance side is validated when the instance is loaded.
|
||||
ts = (
|
||||
instance.get("target_select")
|
||||
or payload.get("target_select")
|
||||
or {"mode": "all"}
|
||||
)
|
||||
|
||||
# ── Matrix: filter then override ─────────────────────────────
|
||||
raw_matrix = payload.get("matrix", {})
|
||||
matrix: dict[str, Any] = {}
|
||||
# Track which (src, tgt) pairs the library iteration covered so
|
||||
# the second pass below only touches keys that have NO library
|
||||
# rule. Without this guard, instance-only overrides would
|
||||
# double-apply on top of the library merge already done here.
|
||||
covered_keys: set[str] = set()
|
||||
for src, row in raw_matrix.items():
|
||||
kept: dict[str, Any] = {}
|
||||
for tgt, cell in row.items():
|
||||
key = encode_key([src, tgt])
|
||||
covered_keys.add(key)
|
||||
if key in disabled_cells:
|
||||
continue
|
||||
# Apply override on a copy — payload cells are shared
|
||||
# references; mutating them would leak into the library
|
||||
# value across resolves.
|
||||
merged = dict(cell)
|
||||
if key in cell_mode_overrides:
|
||||
mode = cell_mode_overrides[key]
|
||||
if mode not in _VALID_MODES:
|
||||
raise ValueError(
|
||||
f"constraint cell_mode_overrides[{key!r}] must be one of "
|
||||
f"{sorted(_VALID_MODES)}"
|
||||
)
|
||||
merged["mode"] = mode
|
||||
if key in cell_factor_overrides:
|
||||
factor = cell_factor_overrides[key]
|
||||
if not isinstance(factor, (int, float)) or isinstance(factor, bool):
|
||||
raise ValueError(
|
||||
f"constraint cell_factor_overrides[{key!r}] must be a number"
|
||||
)
|
||||
if float(factor) < 0:
|
||||
raise ValueError(
|
||||
f"constraint cell_factor_overrides[{key!r}] must not be negative"
|
||||
)
|
||||
merged["factor"] = float(factor)
|
||||
kept[tgt] = merged
|
||||
if kept:
|
||||
matrix[src] = kept
|
||||
|
||||
# ── Empty-cell instance overrides — keys present in
|
||||
# cell_mode_overrides / cell_factor_overrides but absent from
|
||||
# the library matrix. Implicit baseline is mode "allow" + factor
|
||||
# 1.0; whichever side(s) the user set populate the rest.
|
||||
# Keys in disabled_cells skip — equivalent to "no rule" runtime.
|
||||
instance_only_keys = (
|
||||
set(cell_mode_overrides) | set(cell_factor_overrides)
|
||||
) - covered_keys
|
||||
for key in instance_only_keys:
|
||||
if key in disabled_cells:
|
||||
continue
|
||||
try:
|
||||
parts = decode_key(key)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
src, tgt = parts[0], parts[1]
|
||||
mode = cell_mode_overrides.get(key, "allow")
|
||||
if mode not in _VALID_MODES:
|
||||
raise ValueError(
|
||||
f"constraint cell_mode_overrides[{key!r}] must be one of "
|
||||
f"{sorted(_VALID_MODES)}"
|
||||
)
|
||||
factor_raw = cell_factor_overrides.get(key, 1.0)
|
||||
if not isinstance(factor_raw, (int, float)) or isinstance(factor_raw, bool):
|
||||
raise ValueError(
|
||||
f"constraint cell_factor_overrides[{key!r}] must be a number"
|
||||
)
|
||||
if float(factor_raw) < 0:
|
||||
raise ValueError(
|
||||
f"constraint cell_factor_overrides[{key!r}] must not be negative"
|
||||
)
|
||||
matrix.setdefault(src, {})[tgt] = {
|
||||
"mode": mode,
|
||||
"factor": float(factor_raw),
|
||||
}
|
||||
|
||||
# ── Exceptions: filter, override, then append extras ─────────
|
||||
raw_excs = payload.get("exceptions", [])
|
||||
exceptions: list[dict[str, Any]] = []
|
||||
for exc in raw_excs:
|
||||
key = encode_key([
|
||||
exc.get("source_value") or exc.get("source", ""),
|
||||
exc.get("target_value") or exc.get("target", ""),
|
||||
])
|
||||
if key in disabled_excs:
|
||||
continue
|
||||
merged = dict(exc)
|
||||
if key in exception_mode_overrides:
|
||||
mode = exception_mode_overrides[key]
|
||||
if mode not in _VALID_MODES:
|
||||
raise ValueError(
|
||||
f"constraint exception_mode_overrides[{key!r}] must be one of "
|
||||
f"{sorted(_VALID_MODES)}"
|
||||
)
|
||||
merged["mode"] = mode
|
||||
if key in exception_factor_overrides:
|
||||
factor = exception_factor_overrides[key]
|
||||
if not isinstance(factor, (int, float)) or isinstance(factor, bool):
|
||||
raise ValueError(
|
||||
f"constraint exception_factor_overrides[{key!r}] must be a number"
|
||||
)
|
||||
if float(factor) < 0:
|
||||
raise ValueError(
|
||||
f"constraint exception_factor_overrides[{key!r}] must not be negative"
|
||||
)
|
||||
merged["factor"] = float(factor)
|
||||
exceptions.append(merged)
|
||||
|
||||
# Validate + append instance-only extras. Same shape as library
|
||||
# exceptions; rejected with same error message style. NOT
|
||||
# written back to library — lives entirely in the instance.
|
||||
for i, exc in enumerate(extra_exceptions):
|
||||
if not isinstance(exc, dict):
|
||||
raise ValueError(
|
||||
f"constraint extra_exceptions[{i}] must be an object"
|
||||
)
|
||||
for key_legacy, _key_tier2 in (("source", "source_value"), ("target", "target_value")):
|
||||
v = exc.get(key_legacy) or exc.get(_key_tier2)
|
||||
if not isinstance(v, str) or not v:
|
||||
raise ValueError(
|
||||
f"constraint extra_exceptions[{i}].{key_legacy} must be a "
|
||||
f"non-empty string"
|
||||
)
|
||||
mode = exc.get("mode")
|
||||
if mode not in _VALID_MODES:
|
||||
raise ValueError(
|
||||
f"constraint extra_exceptions[{i}].mode must be one of "
|
||||
f"{sorted(_VALID_MODES)}"
|
||||
)
|
||||
factor = exc.get("factor")
|
||||
if not isinstance(factor, (int, float)) or isinstance(factor, bool):
|
||||
raise ValueError(
|
||||
f"constraint extra_exceptions[{i}].factor must be a number"
|
||||
)
|
||||
if float(factor) < 0:
|
||||
raise ValueError(
|
||||
f"constraint extra_exceptions[{i}].factor must not be negative"
|
||||
)
|
||||
exceptions.append(dict(exc))
|
||||
|
||||
# Carry the owning constraint module's id into the registered
|
||||
# bucket entry as `__constraint_module_id__` (the `cid`). Under the
|
||||
# SP3 reach model it identifies the constraint for two things:
|
||||
# (1) the per-constraint hit counter `__wp_constraint_hits__[cid]`
|
||||
# that drives first / next / pick coverage in
|
||||
# `apply_constraints_for_target`, and (2) the dedup key + ref of
|
||||
# the never-applied / partial-reach finalisation warnings.
|
||||
#
|
||||
# Key on the PER-INSTANCE uid (`__wp_current_module_uid__`), not
|
||||
# the library uuid (`__wp_current_module_id__`): two instances of
|
||||
# the same library constraint entry must reach INDEPENDENTLY
|
||||
# (CLAUDE.md — "author multiple constraint modules" to affect
|
||||
# multiple target instances). Pre-2026-05-26 both keyed on the
|
||||
# shared library uuid, so the two instances' counters collided —
|
||||
# one instance's encounters advanced the other's first/next
|
||||
# selector. The pipeline falls back to the library id when `_uid`
|
||||
# is absent (legacy / hand-built test modules).
|
||||
module_id = None
|
||||
try:
|
||||
if hasattr(ctx, "get"):
|
||||
module_id = (
|
||||
ctx.get("__wp_current_module_uid__")
|
||||
or ctx.get("__wp_current_module_id__")
|
||||
)
|
||||
except Exception:
|
||||
module_id = None
|
||||
# The per-instance `_uid` (12+ chars) keys the per-constraint hit
|
||||
# counter so two canvas instances of the same library constraint
|
||||
# reach independently. The LIBRARY id (8-hex) is also stashed
|
||||
# because the never-applied / partial-reach warning text needs it
|
||||
# to render the chip — the SPA / Debug viewer's chip resolver looks
|
||||
# up library uuids in the module catalog, and a per-instance `_uid`
|
||||
# would never resolve (silently rendering as raw `@{…}` text in the
|
||||
# warning).
|
||||
library_id = None
|
||||
library_name = None
|
||||
bundle_origin = None
|
||||
try:
|
||||
if hasattr(ctx, "get"):
|
||||
library_id = ctx.get("__wp_current_module_id__")
|
||||
library_name = ctx.get("__wp_current_module_name__")
|
||||
bundle_origin = ctx.get("__wp_current_module_bundle_origin__")
|
||||
except Exception:
|
||||
library_id = None
|
||||
library_name = None
|
||||
bundle_origin = None
|
||||
meta = {
|
||||
"source_wildcard_id": payload["source_wildcard_id"],
|
||||
"target_wildcard_id": payload["target_wildcard_id"],
|
||||
"matrix": matrix,
|
||||
"exceptions": exceptions,
|
||||
# SP3 reach selector — recorded for the (later) apply pass.
|
||||
"target_select": ts,
|
||||
"__constraint_module_id__": module_id,
|
||||
"__constraint_library_id__": library_id,
|
||||
# Cached display name (e.g. "Starter pairing"). The never-applied
|
||||
# warning text embeds this as the `#name` suffix on the constraint
|
||||
# ref so the chip renders a readable label even when the workflow
|
||||
# was imported on a machine whose library doesn't have this
|
||||
# constraint — both the SPA library catalog AND the embed-bundle
|
||||
# endpoint would 404 there, leaving the chip stuck on the raw
|
||||
# uuid otherwise.
|
||||
"__constraint_library_name__": library_name,
|
||||
# Per-insertion bundle discriminator (task_5200c1fc). When this
|
||||
# constraint instance lives inside a bundle copy, it shares one
|
||||
# `bundle_origin` with the source wildcard instance in the SAME
|
||||
# copy. `apply_constraints_for_target` uses it to read that
|
||||
# copy's source pick (`by_origin[origin]`) rather than the
|
||||
# last-writer survivor. Falsy (None) for a top-level / manual /
|
||||
# legacy constraint — then apply falls back to today's
|
||||
# last-writer-wins lookup, unchanged.
|
||||
"__constraint_bundle_origin__": bundle_origin,
|
||||
"matrix": payload.get("matrix", {}),
|
||||
"exceptions": payload.get("exceptions", []),
|
||||
}
|
||||
_ctx_set_constraint(ctx, meta)
|
||||
# Pass-through stub: return no bindings — WildcardHandler is expected
|
||||
# to consume ctx["_constraints"] when it grows constraint awareness.
|
||||
return {}
|
||||
|
||||
@@ -16,93 +16,37 @@ import re
|
||||
from typing import Any
|
||||
|
||||
from engine.modules import build_resolve_ctx
|
||||
from engine.modules._seed import derive_module_rng
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
from engine.syntax import resolve_text
|
||||
from engine.syntax.types import deref_var_value, split_var_accessor
|
||||
|
||||
_VALID_OPS = {
|
||||
"equals", "not_equals", "contains", "matches",
|
||||
# Presence-check ops added in 2026-05-09 cycle. `exists`/`not_exists`
|
||||
# check key presence in ctx regardless of value (empty string still
|
||||
# counts as present); `is_set`/`is_unset` additionally require the
|
||||
# value to be non-empty. Both pairs ship so users can pick the
|
||||
# semantics they need — wildcards can pick options with empty
|
||||
# values, leaving keys present-but-empty.
|
||||
"exists", "not_exists", "is_set", "is_unset",
|
||||
}
|
||||
_VALID_OPS = {"equals", "not_equals", "contains", "matches"}
|
||||
_VALID_MODES = {"replace", "append", "prepend"}
|
||||
|
||||
|
||||
def _ctx_get_raw(ctx: Any, name: str) -> Any:
|
||||
"""Read the RAW stored value for `name` (may be a ListVar) through the
|
||||
callable-getter -> dict-lookup indirection. Missing / error -> None."""
|
||||
if ctx is None:
|
||||
return None
|
||||
getter = getattr(ctx, "get", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
return getter(name, None)
|
||||
except TypeError:
|
||||
try:
|
||||
return getter(name)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
if name in ctx: # type: ignore[operator]
|
||||
return ctx[name] # type: ignore[index]
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _ctx_get(ctx: Any, name: str) -> str:
|
||||
"""Read `name` as a string, honoring a `.K` list accessor and folding a
|
||||
ListVar to its joined value (SP2a): `$mood.0` splits to base `mood` +
|
||||
index 0. The fold/accessor contract lives in deref_var_value."""
|
||||
base, index = split_var_accessor(name)
|
||||
return deref_var_value(_ctx_get_raw(ctx, base), index)
|
||||
|
||||
|
||||
def _ctx_has(ctx: Any, name: str) -> bool:
|
||||
"""Whether `name` is a known key in `ctx`, regardless of value.
|
||||
|
||||
Mirrors `_ctx_get`'s indirection chain (callable getter →
|
||||
`__contains__` / dict lookup) but returns bool. Required so the
|
||||
`exists` / `not_exists` ops can distinguish "key absent" from
|
||||
"key present but value is empty string" — `_ctx_get` collapses
|
||||
both into `""`. Used only by presence-check ops; non-presence
|
||||
ops continue to read through `_ctx_get`.
|
||||
"""
|
||||
if ctx is None:
|
||||
return False
|
||||
name = split_var_accessor(name)[0] # SP2a: presence checks the base var
|
||||
# Engine ctx is a dict in tests + at runtime; check `__contains__`
|
||||
# before falling back to a getter probe so we get the cheap path.
|
||||
try:
|
||||
return name in ctx # type: ignore[operator]
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
getter = getattr(ctx, "get", None)
|
||||
if callable(getter):
|
||||
# Use a sentinel default so a "missing" key can be told apart
|
||||
# from a key whose value is `None` or `""`.
|
||||
sentinel = object()
|
||||
try:
|
||||
value = getter(name, sentinel)
|
||||
value = getter(name, "")
|
||||
except TypeError:
|
||||
try:
|
||||
value = getter(name)
|
||||
except Exception:
|
||||
return False
|
||||
return value is not sentinel
|
||||
return False
|
||||
return ""
|
||||
return "" if value is None else str(value)
|
||||
try:
|
||||
if name in ctx: # type: ignore[operator]
|
||||
return str(ctx[name]) # type: ignore[index]
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def _ctx_set(ctx: Any, name: str, value: str) -> None:
|
||||
if ctx is None:
|
||||
return
|
||||
name = split_var_accessor(name)[0] # SP2a: writing $x.K targets the base var
|
||||
setter = getattr(ctx, "set", None)
|
||||
if callable(setter):
|
||||
setter(name, value)
|
||||
@@ -117,18 +61,6 @@ def _match_condition(condition: dict[str, Any], ctx: Any) -> bool:
|
||||
var = condition.get("var", "")
|
||||
op = condition.get("op", "")
|
||||
value = condition.get("value", "")
|
||||
# Presence-check ops short-circuit before reading the value — they
|
||||
# only care about key presence (and, for is_set/is_unset, whether
|
||||
# the stored value is non-empty). The `value` field is ignored by
|
||||
# these ops; the SPA disables the value input when one is selected.
|
||||
if op == "exists":
|
||||
return _ctx_has(ctx, var)
|
||||
if op == "not_exists":
|
||||
return not _ctx_has(ctx, var)
|
||||
if op == "is_set":
|
||||
return _ctx_has(ctx, var) and _ctx_get(ctx, var) != ""
|
||||
if op == "is_unset":
|
||||
return not _ctx_has(ctx, var) or _ctx_get(ctx, var) == ""
|
||||
actual = _ctx_get(ctx, var)
|
||||
if op == "equals":
|
||||
return actual == value
|
||||
@@ -144,48 +76,17 @@ def _match_condition(condition: dict[str, Any], ctx: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def branch_carrier_key(rule_id: str, branch: int | str) -> str:
|
||||
"""Carrier key for a derivation branch occurrence -- the `option_id` the
|
||||
SP3 nested-occurrence model matches a constraint `pick` against. `branch`
|
||||
is the 0-based branch index (0 = IF) or the literal ``"else"``.
|
||||
|
||||
MUST stay byte-identical to the TS twin ``branchKey`` in
|
||||
``src/extension/constraint-pairs.ts``; both are corpus-locked against
|
||||
``tests/fixtures/constraint-corpus.json`` ``branch_key_cases``.
|
||||
"""
|
||||
return f"{rule_id}:{branch}"
|
||||
|
||||
|
||||
def _apply_action(
|
||||
action: dict[str, Any],
|
||||
ctx: Any,
|
||||
resolve_ctx: Any,
|
||||
carrier_key: str | None = None,
|
||||
) -> tuple[str, str] | None:
|
||||
target = action.get("target_var", "")
|
||||
if not target:
|
||||
return None
|
||||
mode = action.get("mode", "replace")
|
||||
raw_value = str(action.get("value", ""))
|
||||
# Stamp this derivation instance as the CARRIER of any `@{}` in the action
|
||||
# value, keyed by the branch key (`${rule_id}:${bi}` / `${rule_id}:else`) —
|
||||
# the `option_id` the SP3 nested-occurrence model matches a `pick` against.
|
||||
# Save/restore around the resolve, mirroring the resolver's own carrier
|
||||
# save/restore for nested recursion.
|
||||
set_carrier = getattr(resolve_ctx, "set_carrier", None)
|
||||
get_carrier = getattr(resolve_ctx, "get_carrier", None)
|
||||
if carrier_key is not None and callable(set_carrier) and callable(get_carrier):
|
||||
module_uid = None
|
||||
if isinstance(ctx, dict):
|
||||
module_uid = ctx.get("__wp_current_module_uid__") or ctx.get("__wp_current_module_id__")
|
||||
prev = get_carrier()
|
||||
set_carrier(module_uid, carrier_key)
|
||||
try:
|
||||
new_value = resolve_text(raw_value, resolve_ctx)
|
||||
finally:
|
||||
set_carrier(*prev)
|
||||
else:
|
||||
new_value = resolve_text(raw_value, resolve_ctx)
|
||||
new_value = resolve_text(raw_value, resolve_ctx)
|
||||
if mode == "replace":
|
||||
result = new_value
|
||||
elif mode == "append":
|
||||
@@ -242,7 +143,6 @@ class DerivationHandler(ModuleHandler):
|
||||
rules = payload.get("rules")
|
||||
if not isinstance(rules, list):
|
||||
raise ValueError("derivation payload.rules must be a list")
|
||||
seen_rule_ids: set[str] = set()
|
||||
for ri, rule in enumerate(rules):
|
||||
if not isinstance(rule, dict):
|
||||
raise ValueError(f"derivation payload.rules[{ri}] must be an object")
|
||||
@@ -251,14 +151,6 @@ class DerivationHandler(ModuleHandler):
|
||||
raise ValueError(
|
||||
f"derivation payload.rules[{ri}].id must be a non-empty string"
|
||||
)
|
||||
# Rule ids index `instance.disabled_rule_ids` and
|
||||
# `rule_order_override` — dupes make both ambiguous.
|
||||
if rule_id in seen_rule_ids:
|
||||
raise ValueError(
|
||||
f"derivation payload.rules[{ri}].id {rule_id!r} duplicates "
|
||||
f"an earlier rule id"
|
||||
)
|
||||
seen_rule_ids.add(rule_id)
|
||||
branches = rule.get("branches")
|
||||
if not isinstance(branches, list) or not branches:
|
||||
raise ValueError(
|
||||
@@ -289,105 +181,22 @@ class DerivationHandler(ModuleHandler):
|
||||
ctx: Any,
|
||||
) -> dict[str, str]:
|
||||
cls.validate_payload(payload)
|
||||
|
||||
# Effective seed selection — same pattern wildcard / combine /
|
||||
# fixed_values use. Locked seed pins {a|b|c} resolution in
|
||||
# action.value across runs; without lock the chain seed
|
||||
# propagates per-queue. Falls back to the host ctx's RNG when
|
||||
# the ctx wasn't built with `__wp_rng__` (legacy callers).
|
||||
if isinstance(ctx, dict) and "__wp_rng__" in ctx:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
effective_seed = (
|
||||
int(locked_seed) if isinstance(locked_seed, (int, float))
|
||||
else chain_seed
|
||||
)
|
||||
module_id = str(ctx.get("__wp_module_id__") or "derivation")
|
||||
rng = derive_module_rng(effective_seed, module_id)
|
||||
ctx_local = {**ctx, "__wp_rng__": rng}
|
||||
resolve_ctx = build_resolve_ctx(ctx_local, surface="derivation")
|
||||
else:
|
||||
resolve_ctx = build_resolve_ctx(ctx, surface="derivation")
|
||||
|
||||
# Tier-D instance overrides (2026-05-10 cycle).
|
||||
disabled_rule_ids = set(instance.get("disabled_rule_ids") or [])
|
||||
disabled_branch_keys = set(instance.get("disabled_branch_keys") or [])
|
||||
action_overrides = instance.get("action_value_overrides") or {}
|
||||
cond_overrides = instance.get("condition_value_overrides") or {}
|
||||
|
||||
# Per-instance rule reorder — sort by rule_order_override list.
|
||||
# IDs not in the override fall through in their original library
|
||||
# order at the end (defensive against partial reorders).
|
||||
rules = list(payload.get("rules", []))
|
||||
order = instance.get("rule_order_override")
|
||||
if isinstance(order, list) and order:
|
||||
ordering = {rid: i for i, rid in enumerate(order)}
|
||||
tail = len(order)
|
||||
rules.sort(
|
||||
key=lambda r: ordering.get(r.get("id"), tail + 1),
|
||||
)
|
||||
|
||||
resolve_ctx = build_resolve_ctx(ctx, surface="derivation")
|
||||
out: dict[str, str] = {}
|
||||
|
||||
for rule in rules:
|
||||
rule_id = rule.get("id", "")
|
||||
if rule_id in disabled_rule_ids:
|
||||
continue
|
||||
|
||||
for rule in payload.get("rules", []):
|
||||
applied = False
|
||||
for bi, branch in enumerate(rule.get("branches", [])):
|
||||
# Branch-level disable — `r1:1` etc. IF (bi=0) ignored
|
||||
# even when listed because disabling IF == disabling rule
|
||||
# (the per-rule toggle handles that case cleanly).
|
||||
if bi != 0 and branch_carrier_key(rule_id, bi) in disabled_branch_keys:
|
||||
continue
|
||||
|
||||
# Condition-value override per branch index.
|
||||
cond = branch.get("condition", {})
|
||||
cond_override = (
|
||||
cond_overrides.get(rule_id, {}).get(str(bi))
|
||||
if isinstance(cond_overrides.get(rule_id), dict)
|
||||
else None
|
||||
)
|
||||
if isinstance(cond_override, str):
|
||||
cond = {**cond, "value": cond_override}
|
||||
|
||||
if _match_condition(cond, ctx):
|
||||
# Action-value override per branch index.
|
||||
action = branch.get("action", {})
|
||||
action_override = (
|
||||
action_overrides.get(rule_id, {}).get(str(bi))
|
||||
if isinstance(action_overrides.get(rule_id), dict)
|
||||
else None
|
||||
)
|
||||
if isinstance(action_override, str):
|
||||
action = {**action, "value": action_override}
|
||||
|
||||
pair = _apply_action(
|
||||
action, ctx, resolve_ctx, carrier_key=branch_carrier_key(rule_id, bi)
|
||||
)
|
||||
for branch in rule.get("branches", []):
|
||||
if _match_condition(branch.get("condition", {}), ctx):
|
||||
pair = _apply_action(branch.get("action", {}), ctx, resolve_ctx)
|
||||
if pair is not None:
|
||||
out[pair[0]] = pair[1]
|
||||
applied = True
|
||||
break
|
||||
|
||||
if not applied:
|
||||
# ELSE skip when listed in disabled_branch_keys.
|
||||
if branch_carrier_key(rule_id, "else") in disabled_branch_keys:
|
||||
continue
|
||||
else_clause = rule.get("else")
|
||||
if isinstance(else_clause, dict):
|
||||
action = else_clause.get("action", {})
|
||||
# ELSE action-value override under `else` key.
|
||||
action_override = (
|
||||
action_overrides.get(rule_id, {}).get("else")
|
||||
if isinstance(action_overrides.get(rule_id), dict)
|
||||
else None
|
||||
)
|
||||
if isinstance(action_override, str):
|
||||
action = {**action, "value": action_override}
|
||||
pair = _apply_action(
|
||||
action, ctx, resolve_ctx, carrier_key=branch_carrier_key(rule_id, "else")
|
||||
else_clause.get("action", {}), ctx, resolve_ctx
|
||||
)
|
||||
if pair is not None:
|
||||
out[pair[0]] = pair[1]
|
||||
|
||||
@@ -1,90 +1,14 @@
|
||||
"""Fixed-values module resolver.
|
||||
|
||||
Two-tier model mirroring wildcard:
|
||||
- `payload.values` = library-truth (immutable while picked).
|
||||
- `instance.values_overrides` = optional full-replacement list of
|
||||
`{id, name, value}` written by the modal when the user edits a
|
||||
library-tracked fixed_values.
|
||||
|
||||
Per-value syntax resolution (Phase: combine v2 + syntax parity cycle):
|
||||
each emitted value passes through resolve_text with surface="fixed_values"
|
||||
so {a|b|c} alternations + {N$$sep$$...} repeats + escapes resolve.
|
||||
$var reads + @{uuid} refs are gated off — the surface is a binding
|
||||
PRODUCER, not a consumer. Lenient mode renders unsupported tokens as
|
||||
literal text + emits warnings (see engine/syntax/resolve.py).
|
||||
|
||||
Seed lock: instance.locked_seed pins {a|b|c} resolution per instance
|
||||
via the shared engine.modules._seed.derive_module_rng helper, keyed by
|
||||
the module id when present (ctx["__wp_module_id__"]).
|
||||
"""
|
||||
"""Fixed-values module resolver. Emits one binding per named value."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from engine.modules import build_resolve_ctx
|
||||
from engine.modules._seed import derive_module_rng
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
from engine.syntax import resolve_text
|
||||
|
||||
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||||
_MAX_IDENT_LEN = 64
|
||||
|
||||
|
||||
class FixedValuesHandler(ModuleHandler):
|
||||
type_id = "fixed_values"
|
||||
|
||||
@classmethod
|
||||
def validate_payload(cls, payload: dict[str, Any]) -> None:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("fixed_values payload must be an object")
|
||||
values = payload.get("values")
|
||||
if not isinstance(values, list):
|
||||
raise ValueError("fixed_values payload.values must be a list")
|
||||
seen_names: set[str] = set()
|
||||
for i, v in enumerate(values):
|
||||
if not isinstance(v, dict):
|
||||
raise ValueError(
|
||||
f"fixed_values payload.values[{i}] must be an object"
|
||||
)
|
||||
name = v.get("name")
|
||||
if name is not None and not isinstance(name, str):
|
||||
raise ValueError(
|
||||
f"fixed_values payload.values[{i}].name must be a string"
|
||||
)
|
||||
# Empty name is allowed (resolver skips those entries) but a
|
||||
# non-empty name must be a valid identifier — it gets emitted
|
||||
# as a $var into the runtime ctx.
|
||||
if isinstance(name, str) and name:
|
||||
if len(name) > _MAX_IDENT_LEN:
|
||||
raise ValueError(
|
||||
f"fixed_values payload.values[{i}].name must be at most "
|
||||
f"{_MAX_IDENT_LEN} chars (got {len(name)})"
|
||||
)
|
||||
if name.startswith("__"):
|
||||
raise ValueError(
|
||||
f"fixed_values payload.values[{i}].name must not start "
|
||||
f"with '__' (reserved for engine-internal keys)"
|
||||
)
|
||||
if not _IDENT_RE.match(name):
|
||||
raise ValueError(
|
||||
f"fixed_values payload.values[{i}].name {name!r} is not a valid identifier"
|
||||
)
|
||||
# Names must be unique across rows — two rows with the
|
||||
# same name silently last-write-wins at runtime, hiding
|
||||
# the earlier row's value.
|
||||
if name in seen_names:
|
||||
raise ValueError(
|
||||
f"fixed_values payload.values[{i}].name {name!r} "
|
||||
f"duplicates an earlier row"
|
||||
)
|
||||
seen_names.add(name)
|
||||
value = v.get("value", "")
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"fixed_values payload.values[{i}].value must be a string"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
cls,
|
||||
@@ -92,100 +16,15 @@ class FixedValuesHandler(ModuleHandler):
|
||||
instance: dict[str, Any],
|
||||
ctx: Any,
|
||||
) -> dict[str, str]:
|
||||
# Values source-of-truth: instance.values_overrides wins when
|
||||
# set + non-empty, else payload.values. Existing two-tier model
|
||||
# preserved.
|
||||
overrides = instance.get("values_overrides")
|
||||
if isinstance(overrides, list) and overrides:
|
||||
values: list[dict[str, Any]] = [v for v in overrides if isinstance(v, dict)]
|
||||
else:
|
||||
# Non-None but non-list (string/dict/number) is malformed
|
||||
# state — almost certainly a workflow saved by an old SPA
|
||||
# or hand-edited JSON. Surface a warning so the user sees
|
||||
# their edits were dropped, instead of silently reverting
|
||||
# to library values without explanation.
|
||||
if overrides is not None and not isinstance(overrides, list):
|
||||
warnings = None
|
||||
if ctx is not None:
|
||||
try:
|
||||
warnings = ctx.get("__wp_warnings__") if hasattr(ctx, "get") else None
|
||||
except Exception:
|
||||
warnings = None
|
||||
if isinstance(warnings, list):
|
||||
warnings.append({
|
||||
"type": "fixed_values_overrides_malformed",
|
||||
"severity": "warn",
|
||||
"module_id": "",
|
||||
"source_field": "instance.values_overrides",
|
||||
"position": 0,
|
||||
"token_index": None,
|
||||
"detail": {
|
||||
"got_type": type(overrides).__name__,
|
||||
"fell_back_to": "payload.values",
|
||||
},
|
||||
"message": (
|
||||
"fixed_values.instance.values_overrides was "
|
||||
f"{type(overrides).__name__}, expected list — "
|
||||
"fell back to library payload.values"
|
||||
),
|
||||
})
|
||||
values = list(payload.get("values", []))
|
||||
|
||||
values: list[dict[str, Any]] = list(payload.get("values", []))
|
||||
enabled = instance.get("enabled_options")
|
||||
if enabled is not None:
|
||||
allowed = set(enabled)
|
||||
values = [v for v in values if v.get("id") in allowed]
|
||||
|
||||
# Syntax-aware emission path. When ctx is missing required keys
|
||||
# for build_resolve_ctx (legacy callers passing None / minimal
|
||||
# dicts), fall back to raw emission so existing tests + workflows
|
||||
# keep working.
|
||||
if not _ctx_supports_resolve(ctx):
|
||||
out: dict[str, str] = {}
|
||||
for v in values:
|
||||
name = (v.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
out[name] = str(v.get("value", ""))
|
||||
return out
|
||||
|
||||
# Effective seed selection (mirrors WildcardHandler + CombineHandler):
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
if isinstance(locked_seed, (int, float)):
|
||||
effective_seed = int(locked_seed)
|
||||
else:
|
||||
effective_seed = chain_seed
|
||||
# Use module id as the per-module RNG key when ctx carries one;
|
||||
# fall back to "fixed_values" literal so two co-resident
|
||||
# fixed_values modules in the same chain still derive distinct
|
||||
# RNG streams from the same seed (independence property).
|
||||
module_key = str(ctx.get("__wp_module_id__") or "fixed_values")
|
||||
rng = derive_module_rng(effective_seed, module_key)
|
||||
|
||||
ctx_local = {**ctx, "__wp_rng__": rng}
|
||||
resolve_ctx = build_resolve_ctx(ctx_local, surface="fixed_values")
|
||||
|
||||
out_resolved: dict[str, str] = {}
|
||||
out: dict[str, str] = {}
|
||||
for v in values:
|
||||
name = (v.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
raw = str(v.get("value", ""))
|
||||
out_resolved[name] = resolve_text(raw, resolve_ctx)
|
||||
return out_resolved
|
||||
|
||||
|
||||
def _ctx_supports_resolve(ctx: Any) -> bool:
|
||||
"""build_resolve_ctx requires __wp_rng__ + __wp_warnings__ in ctx.
|
||||
Legacy test/usage passes None or minimal dicts — detect those and
|
||||
skip the syntax-resolution path so behavior matches pre-rewrite.
|
||||
"""
|
||||
if ctx is None:
|
||||
return False
|
||||
if not hasattr(ctx, "__getitem__"):
|
||||
return False
|
||||
try:
|
||||
return "__wp_rng__" in ctx and "__wp_warnings__" in ctx
|
||||
except (TypeError, KeyError):
|
||||
return False
|
||||
out[name] = str(v.get("value", ""))
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Pipeline module resolver — runs an ordered list of sub-modules.
|
||||
|
||||
Each step references a module in the library by id. The repository is
|
||||
threaded through ``ctx["_repo"]``; without it the handler can still validate
|
||||
its payload but ``resolve`` will raise ``RuntimeError`` because it cannot load
|
||||
sub-modules. Steps marked ``enabled=False`` are skipped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from engine.modules.dispatcher import ModuleHandler, resolve_module
|
||||
|
||||
|
||||
def _ctx_get(ctx: Any, name: str, default: Any = None) -> Any:
|
||||
if ctx is None:
|
||||
return default
|
||||
getter = getattr(ctx, "get", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
return getter(name, default)
|
||||
except TypeError:
|
||||
try:
|
||||
return getter(name)
|
||||
except Exception:
|
||||
return default
|
||||
try:
|
||||
if name in ctx: # type: ignore[operator]
|
||||
return ctx[name] # type: ignore[index]
|
||||
except Exception:
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
class PipelineHandler(ModuleHandler):
|
||||
"""Composes other modules into an ordered execution list."""
|
||||
|
||||
type_id = "pipeline"
|
||||
|
||||
@classmethod
|
||||
def validate_payload(cls, payload: dict[str, Any]) -> None:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("pipeline payload must be an object")
|
||||
steps = payload.get("steps")
|
||||
if not isinstance(steps, list):
|
||||
raise ValueError("pipeline payload.steps must be a list")
|
||||
for i, step in enumerate(steps):
|
||||
if not isinstance(step, dict):
|
||||
raise ValueError(f"pipeline payload.steps[{i}] must be an object")
|
||||
module_id = step.get("module_id")
|
||||
if not isinstance(module_id, str) or not module_id:
|
||||
raise ValueError(
|
||||
f"pipeline payload.steps[{i}].module_id must be a non-empty string"
|
||||
)
|
||||
step_id = step.get("id")
|
||||
if not isinstance(step_id, str) or not step_id:
|
||||
raise ValueError(
|
||||
f"pipeline payload.steps[{i}].id must be a non-empty string"
|
||||
)
|
||||
enabled = step.get("enabled", True)
|
||||
if not isinstance(enabled, bool):
|
||||
raise ValueError(
|
||||
f"pipeline payload.steps[{i}].enabled must be a boolean"
|
||||
)
|
||||
instance = step.get("instance", {})
|
||||
if not isinstance(instance, dict):
|
||||
raise ValueError(
|
||||
f"pipeline payload.steps[{i}].instance must be an object"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
cls,
|
||||
payload: dict[str, Any],
|
||||
instance: dict[str, Any],
|
||||
ctx: Any,
|
||||
) -> dict[str, str]:
|
||||
cls.validate_payload(payload)
|
||||
repo = _ctx_get(ctx, "_repo", None)
|
||||
if repo is None:
|
||||
raise RuntimeError(
|
||||
"pipeline handler requires ctx['_repo'] to load sub-modules"
|
||||
)
|
||||
|
||||
out: dict[str, str] = {}
|
||||
for step in payload.get("steps", []):
|
||||
if not step.get("enabled", True):
|
||||
continue
|
||||
module_id = step["module_id"]
|
||||
row = repo.get(module_id)
|
||||
sub_snapshot = {
|
||||
"type": row["type"],
|
||||
"payload": row["payload"],
|
||||
"instance": step.get("instance", {}),
|
||||
}
|
||||
bindings = resolve_module(sub_snapshot, ctx)
|
||||
if bindings:
|
||||
out.update(bindings)
|
||||
return out
|
||||
+18
-294
@@ -1,5 +1,4 @@
|
||||
"""Snapshot freezing, content hashing, legacy-shape coercion, and transitive
|
||||
ref walking.
|
||||
"""Snapshot freezing, content hashing, and legacy-shape coercion.
|
||||
|
||||
A *snapshot* is the JSON shape embedded in a ComfyUI workflow's WP_Context
|
||||
node when a user picks a library row. The snapshot is decoupled from the
|
||||
@@ -13,94 +12,17 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, TypedDict
|
||||
from typing import Any
|
||||
|
||||
from engine._utils import now_iso as _now_iso
|
||||
from engine.syntax.tokenize import tokenize_text
|
||||
from engine.syntax.types import TokenKind
|
||||
|
||||
|
||||
class _SourceUser(TypedDict):
|
||||
kind: Literal["user"]
|
||||
|
||||
|
||||
class _SourceDep(TypedDict):
|
||||
kind: Literal["dep"]
|
||||
parent_uuids: list[str]
|
||||
|
||||
|
||||
SnapshotSource = _SourceUser | _SourceDep
|
||||
|
||||
|
||||
class SnapshotEntry(TypedDict):
|
||||
"""Canonical shape stored in `__wp_catalog__` and embedded in
|
||||
WP_Context workflow JSON. Spec §2.4 — pinned, do not rename fields.
|
||||
|
||||
`payload_hash` covers `payload` only (not name/description/tags) so
|
||||
a rename does not flip drift state. `source` records why the entry
|
||||
is in the embed: explicit user pick vs. transitive dep of one or
|
||||
more picks (multi-parent supported)."""
|
||||
snapshot_version: Literal[1]
|
||||
uuid: str # 8 hex chars
|
||||
type: str # ModuleType — wildcards-only in catalog (spec §2.7)
|
||||
name: str # display label
|
||||
payload: dict[str, Any]
|
||||
payload_hash: str # SHA-256 of payload
|
||||
source: SnapshotSource
|
||||
|
||||
|
||||
def _fresh_instance() -> dict[str, Any]:
|
||||
"""Default per-snapshot instance overrides. Fresh dict per call.
|
||||
|
||||
Invariant: every field defaults to ``None``. The frontend
|
||||
"modified-state" predicate is ``value != null``, so any non-None
|
||||
sentinel (e.g. ``""`` for a string field, ``False`` for a bool)
|
||||
would falsely light up the orange dot, force the modal to default
|
||||
to the Instance tab, and tag the section as modified — even on a
|
||||
pristine snapshot. Engine readers already tolerate ``None`` (see
|
||||
``wildcard_handler.py:216`` for the binding fallback chain and
|
||||
``pipeline.py:160`` for the internal-flag coercion), so we keep a
|
||||
single contract: ``None`` everywhere = "no override, use library
|
||||
default".
|
||||
"""
|
||||
"""Default per-snapshot instance overrides. Fresh dict per call."""
|
||||
return {
|
||||
"variable_binding": None,
|
||||
"variable_binding": "",
|
||||
"enabled_options": None,
|
||||
"category_filter": None,
|
||||
"option_weights": None,
|
||||
# SP2a multi-select + the null toggle — part of the pool-shaping
|
||||
# override set, so a frozen snapshot carries them too (all None =
|
||||
# "no override"). Keeps this dict honest with the "every field" claim
|
||||
# above + the instance schema (engine/schemas/instance_schemas.py).
|
||||
"exclude_null": None,
|
||||
"pick_min": None,
|
||||
"pick_max": None,
|
||||
"pick_separator": None,
|
||||
"pick_independent": None, # SP2c: multi-pick with replacement when true
|
||||
# Pick mode — `None`/missing → `random` (legacy behavior:
|
||||
# weighted RNG over enabled options). `pinned` short-circuits
|
||||
# the RNG and always picks `pinned_option_id`. `subcategory`
|
||||
# is currently identical to `random` engine-side; the modal
|
||||
# uses it as a UX framing for "manual subset via enabled_options".
|
||||
"mode": None,
|
||||
"pinned_option_id": None,
|
||||
# Lock — when set, derives a stable per-instance RNG seed
|
||||
# independent of the chain's `__wp_node_seed__`. Means the
|
||||
# wildcard's pick stays the same value across runs even when
|
||||
# the user cycles the Context node's seed. `None` = roll with
|
||||
# the chain seed (default).
|
||||
"locked_seed": None,
|
||||
# Internal — when True, every binding this module produces is
|
||||
# marked engine-only by `PipelineEngine.run` (via
|
||||
# `__wp_internal_flags__`). Downstream modules still see the
|
||||
# value; the public socket payload doesn't. ``None`` is coerced
|
||||
# to ``False`` at the read site, so the default stays "off".
|
||||
"internal": None,
|
||||
"disabled_rule_ids": None,
|
||||
"disabled_exception_keys": None,
|
||||
"disabled_matrix_cells": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -130,54 +52,28 @@ def coerce_legacy_module(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Upgrade a legacy module dict to canonical snapshot shape.
|
||||
|
||||
Idempotent: returns ``raw`` unchanged if it already has both
|
||||
``library_id`` and ``instance`` keys (the legacy "frozen" shape).
|
||||
|
||||
SPA-picked modules don't carry ``library_id`` (the picked uuid IS
|
||||
the library id, stored as ``id``), but they may carry an
|
||||
``instance`` dict with user overrides (option enable/disable,
|
||||
weight overrides). Merge those onto the fresh-instance defaults
|
||||
instead of stomping them with ``_fresh_instance()`` below.
|
||||
``library_id`` and ``instance`` keys.
|
||||
"""
|
||||
if "instance" in raw and "library_id" in raw:
|
||||
return raw
|
||||
|
||||
# Capture caller-supplied instance early so we can merge it with
|
||||
# defaults once the rest of the snapshot is built. Missing → empty.
|
||||
raw_instance = raw.get("instance")
|
||||
user_instance = raw_instance if isinstance(raw_instance, dict) else {}
|
||||
|
||||
payload: dict[str, Any]
|
||||
if raw.get("type") == "fixed_values":
|
||||
# Two shapes can land here:
|
||||
#
|
||||
# 1. Inline-created (legacy): `entries: [{variable_name, value}]`
|
||||
# with no `payload.values`. Translate entries → values.
|
||||
# 2. Library-picked (post-5.5.4): `payload: {values: [...]}` with
|
||||
# an empty `entries: []` (the field is always present so the
|
||||
# ContextWidget conflict-scanner doesn't have to null-check).
|
||||
# Use `payload` directly — translating its empty entries
|
||||
# would zero out a perfectly good values list.
|
||||
raw_payload = raw.get("payload") or {}
|
||||
legacy_entries = raw.get("entries") or []
|
||||
if legacy_entries and not raw_payload.get("values"):
|
||||
payload = {
|
||||
"values": [
|
||||
{
|
||||
"id": f"val_{i:04x}",
|
||||
"name": e.get("variable_name", ""),
|
||||
"value": e.get("value", ""),
|
||||
}
|
||||
for i, e in enumerate(legacy_entries)
|
||||
],
|
||||
}
|
||||
else:
|
||||
payload = raw_payload
|
||||
if raw.get("type") == "fixed_values" and "entries" in raw:
|
||||
legacy_entries = raw.get("entries", [])
|
||||
payload = {
|
||||
"values": [
|
||||
{
|
||||
"id": f"val_{i:04x}",
|
||||
"name": e.get("variable_name", ""),
|
||||
"value": e.get("value", ""),
|
||||
}
|
||||
for i, e in enumerate(legacy_entries)
|
||||
],
|
||||
}
|
||||
else:
|
||||
payload = raw.get("payload", {})
|
||||
|
||||
meta = raw.get("meta") or {}
|
||||
instance = _fresh_instance()
|
||||
instance.update(user_instance)
|
||||
return {
|
||||
"library_id": raw.get("library_id"),
|
||||
"library_snapshot_at": raw.get("library_snapshot_at"),
|
||||
@@ -186,177 +82,5 @@ def coerce_legacy_module(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"name": raw.get("name") or meta.get("name", ""),
|
||||
"category_id": raw.get("category_id"),
|
||||
"payload": payload,
|
||||
"instance": instance,
|
||||
"instance": _fresh_instance(),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transitive-ref walker (spec §2.7, §2.8, §2.10, §6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WalkOverflow(TypedDict):
|
||||
"""Walker-recorded anomaly. Returned alongside the partial snapshot
|
||||
bundle; never aborts the walk. Spec §2.8."""
|
||||
uuid: str
|
||||
reason: Literal["max_depth", "cycle_detected", "missing_target"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalkResult:
|
||||
"""Return value of `walk_transitive_refs`. Two fields, two concerns:
|
||||
`snapshots` is the materialised catalog dict; `walk_overflow` lists
|
||||
every uuid the walker could not include (cycle, depth, miss)."""
|
||||
snapshots: dict[str, SnapshotEntry] = field(default_factory=dict)
|
||||
walk_overflow: list[WalkOverflow] = field(default_factory=list)
|
||||
|
||||
|
||||
def _extract_at_uuid_refs(payload: dict[str, Any]) -> set[str]:
|
||||
"""Collect all ``@{8hex}`` refs referenced inside a payload's
|
||||
string-typed leaves. Uses ``tokenize_text`` exclusively — only
|
||||
canonical 8-hex-char refs (spec §2.4) are followed.
|
||||
|
||||
Surface-agnostic: collects from any string in the dict/list tree;
|
||||
the caller decides which module types have their payloads walked
|
||||
(spec §2.7)."""
|
||||
refs: set[str] = set()
|
||||
|
||||
def _scan(value: Any) -> None:
|
||||
if isinstance(value, str):
|
||||
for tok in tokenize_text(value):
|
||||
if tok.kind == TokenKind.REF:
|
||||
uid = (tok.meta or {}).get("uuid")
|
||||
if isinstance(uid, str):
|
||||
refs.add(uid)
|
||||
elif isinstance(value, dict):
|
||||
for v in value.values():
|
||||
_scan(v)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
_scan(item)
|
||||
|
||||
_scan(payload)
|
||||
return refs
|
||||
|
||||
|
||||
def _row_to_snapshot_entry(
|
||||
row: dict[str, Any], source: SnapshotSource,
|
||||
) -> SnapshotEntry:
|
||||
"""Convert a repository row dict to a canonical SnapshotEntry.
|
||||
|
||||
Post migration 004 the row's `id` IS the canonical 8-hex uuid;
|
||||
there is no separate `uuid` field. The snapshot schema still
|
||||
names the field `uuid` because that's the published wire format
|
||||
embedded in workflow JSON — we just source it from `row["id"]`.
|
||||
"""
|
||||
return {
|
||||
"snapshot_version": 1,
|
||||
"uuid": row["id"],
|
||||
"type": row["type"],
|
||||
"name": row["name"],
|
||||
"payload": row["payload"],
|
||||
"payload_hash": row.get("payload_hash") or payload_hash(row["payload"]),
|
||||
"source": source,
|
||||
}
|
||||
|
||||
|
||||
def walk_transitive_refs(
|
||||
root_uuids: Iterable[str],
|
||||
*,
|
||||
fetch_module: Callable[[str], dict[str, Any] | None],
|
||||
max_depth: int = 8,
|
||||
) -> WalkResult:
|
||||
"""Lazy transitive walker. Spec §2.10.
|
||||
|
||||
Starts from ``root_uuids``, follows ``@{8hex}`` refs in wildcard payloads
|
||||
only (spec §2.7), and returns a ``WalkResult`` containing every reachable
|
||||
SnapshotEntry plus a list of overflow records (cycle, depth, missing).
|
||||
|
||||
Cycle detection uses a per-path ancestor frozenset carried with each BFS
|
||||
queue entry. When we pop ``(uuid, depth, parent, ancestors)`` and find
|
||||
``uuid`` already in ``ancestors``, we record ``cycle_detected`` and skip
|
||||
without emitting the node. This means the node that *caused* the cycle
|
||||
reference is already snapshotted (it was emitted when we first reached it
|
||||
on a shorter path), so both sides of a two-node cycle appear in snapshots.
|
||||
|
||||
Engine isolation invariant: this function does NOT import from ``engine.db``
|
||||
or ``wp_api``; the caller injects a ``fetch_module(uuid) -> row | None``
|
||||
callable. WP_Context graph nodes never call this — they receive a
|
||||
pre-walked dict embedded in workflow JSON.
|
||||
"""
|
||||
result = WalkResult()
|
||||
# parent_map: uuid -> list of direct parent uuids (accumulated across BFS)
|
||||
parent_map: dict[str, list[str]] = {}
|
||||
seen_overflow: set[tuple[str, str]] = set()
|
||||
|
||||
def _record_overflow(uuid: str, reason: str) -> None:
|
||||
key = (uuid, reason)
|
||||
if key in seen_overflow:
|
||||
return
|
||||
seen_overflow.add(key)
|
||||
result.walk_overflow.append(
|
||||
{"uuid": uuid, "reason": reason}, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
# Queue items: (uuid, depth, parent_uuid_or_None, ancestor_frozenset)
|
||||
# ancestor_frozenset contains all uuids on the path from a root to *before*
|
||||
# this node — used to detect back-edges (cycles).
|
||||
queue: list[tuple[str, int, str | None, frozenset[str]]] = []
|
||||
|
||||
for u in root_uuids:
|
||||
queue.append((u, 0, None, frozenset()))
|
||||
|
||||
while queue:
|
||||
uuid, depth, parent, ancestors = queue.pop(0)
|
||||
|
||||
# Track parentage before any short-circuit so multi-parent deps
|
||||
# accumulate even if we have already snapshotted the child.
|
||||
if parent is not None:
|
||||
parent_map.setdefault(uuid, [])
|
||||
if parent not in parent_map[uuid]:
|
||||
parent_map[uuid].append(parent)
|
||||
|
||||
# Cycle: this uuid appears on the path from a root to here.
|
||||
if uuid in ancestors:
|
||||
_record_overflow(uuid, "cycle_detected")
|
||||
continue
|
||||
|
||||
# Already snapshotted on a shorter BFS path — skip emission but
|
||||
# parent tracking above already ran, so multi-parent deps accumulate.
|
||||
if uuid in result.snapshots:
|
||||
continue
|
||||
|
||||
if depth > max_depth:
|
||||
_record_overflow(uuid, "max_depth")
|
||||
continue
|
||||
|
||||
row = fetch_module(uuid)
|
||||
if row is None:
|
||||
_record_overflow(uuid, "missing_target")
|
||||
continue
|
||||
|
||||
# Determine source based on whether this uuid has a parent.
|
||||
if parent is None:
|
||||
source: SnapshotSource = {"kind": "user"}
|
||||
else:
|
||||
source = {"kind": "dep", "parent_uuids": list(parent_map[uuid])}
|
||||
result.snapshots[uuid] = _row_to_snapshot_entry(row, source)
|
||||
|
||||
# Only walk wildcard payloads — spec §2.7.
|
||||
if row.get("type") != "wildcard":
|
||||
continue
|
||||
|
||||
new_ancestors = ancestors | {uuid}
|
||||
for child_uuid in _extract_at_uuid_refs(row["payload"]):
|
||||
queue.append((child_uuid, depth + 1, uuid, new_ancestors))
|
||||
|
||||
# After walk: re-stamp every dep's parent list (BFS may have accumulated
|
||||
# additional parents after first emission).
|
||||
for uuid, parents in parent_map.items():
|
||||
if uuid not in result.snapshots:
|
||||
continue
|
||||
existing = result.snapshots[uuid]
|
||||
if existing["source"]["kind"] == "dep":
|
||||
existing["source"]["parent_uuids"] = list(parents)
|
||||
|
||||
return result
|
||||
|
||||
@@ -3,59 +3,14 @@
|
||||
Each option's `value` may contain $var, @{uuid}, {a|b|c}, {N$$sep$$...}, and
|
||||
escapes. After picking the option, resolve_text expands the syntax against
|
||||
the runtime context.
|
||||
|
||||
Constraint integration: when the chain has registered constraints in
|
||||
``ctx["__wp_constraints__"]`` whose ``target_wildcard_id`` matches the
|
||||
current module's id, this handler applies the constraint matrix +
|
||||
exception list to the option pool's weights before picking. The source
|
||||
wildcard MUST have already been resolved earlier in the chain — picks
|
||||
are recorded in ``ctx["__wp_picks__"][module_id]`` so a downstream
|
||||
constraint-aware target can look them up. If a constraint fires
|
||||
against a source that hasn't been picked yet (graph ordering bug), the
|
||||
constraint is skipped and an ``unknown_constraint_source`` warning is
|
||||
emitted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from engine.modules import build_resolve_ctx
|
||||
from engine.modules._seed import derive_module_rng as _derive_module_rng
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
from engine.syntax import resolve_text
|
||||
from engine.syntax.resolve import pick_k_unique
|
||||
from engine.syntax.subcat_filter import (
|
||||
matches as _subcat_matches,
|
||||
)
|
||||
from engine.syntax.subcat_filter import (
|
||||
parse as _parse_subcat,
|
||||
)
|
||||
from engine.syntax.subcat_filter import (
|
||||
validate_subcat_name,
|
||||
)
|
||||
from engine.syntax.types import ListVar
|
||||
|
||||
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||||
_MAX_IDENT_LEN = 64
|
||||
|
||||
# `_derive_module_rng` lifted to engine/modules/_seed.py so combine +
|
||||
# fixed_values handlers share the same helper (Phase: combine v2 +
|
||||
# syntax parity cycle). The private alias preserves the existing call
|
||||
# site at line ~376 unchanged.
|
||||
|
||||
|
||||
def _coerce_pick_int(value: Any, default: int) -> int:
|
||||
"""SP2a: coerce a pick-count field (`pick_min`/`pick_max`) to int, falling
|
||||
back to `default` on junk. The engine is reachable by hand-built and legacy
|
||||
instances, so a non-numeric value must degrade to single-pick rather than
|
||||
raise out of resolve() — mirrors the locked_seed coercion guard."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _pick_weighted(options: list[dict[str, Any]], rng) -> dict[str, Any] | None:
|
||||
@@ -74,454 +29,9 @@ def _pick_weighted(options: list[dict[str, Any]], rng) -> dict[str, Any] | None:
|
||||
return options[-1]
|
||||
|
||||
|
||||
def _apply_constraint_to_options(
|
||||
options: list[dict[str, Any]],
|
||||
constraint: dict[str, Any],
|
||||
source_pick: dict[str, Any],
|
||||
adjustment_warnings: list[dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Adjust option weights according to a single constraint, given the
|
||||
already-picked source option(s).
|
||||
|
||||
SP3: weight adjustment is delegated to
|
||||
:func:`engine.modules._constraint_math.combine_constraint_factor`, which
|
||||
multiplies one factor per matching matrix cell (an option on N source-tag
|
||||
rows multiplies N times — true multi-tag) across every source pick (true
|
||||
multi-pick). The exception layer (literal `(source_value, target_value)`
|
||||
pair) still overrides the matrix for that pick, and `exclude` is absorbing
|
||||
(factor 0 → weight 0, option drops out).
|
||||
|
||||
`source_pick["picks"]` is the per-pick list (`[{value, tags}]`) recorded by
|
||||
`_record_pick` / `_record_pick_multi`. Legacy callers that hand-build a
|
||||
source dict without it fall back to a single synthetic pick from the
|
||||
top-level `value` + `sub_categories`.
|
||||
|
||||
Returns a shallow-copied list — the input options array is never mutated,
|
||||
so the underlying snapshot stays clean across runs.
|
||||
|
||||
`adjustment_warnings` (optional) collects editor-footgun warnings the
|
||||
caller forwards to the chain warning list:
|
||||
- ``constraint_factor_ignored_on_allow`` — a `mode: allow` cell carries a
|
||||
non-1 factor (user expects weight×factor, engine ignores it).
|
||||
- ``unknown_constraint_mode`` — a typo'd mode (`exlcude`/`alllow`) the
|
||||
combine fn treats as a no-op.
|
||||
These are diagnostic only; they never change the computed weight.
|
||||
"""
|
||||
from engine.modules._constraint_math import EXCLUDE, combine_constraint_factor
|
||||
|
||||
matrix = constraint.get("matrix") or {}
|
||||
exceptions = constraint.get("exceptions") or []
|
||||
|
||||
# Per-pick source list (SP3). Fall back to a single synthetic pick for
|
||||
# legacy callers that pass only the top-level value + sub_categories.
|
||||
picks = source_pick.get("picks")
|
||||
if not isinstance(picks, list):
|
||||
picks = [{
|
||||
"value": source_pick.get("value", ""),
|
||||
"tags": list(source_pick.get("sub_categories") or []),
|
||||
}]
|
||||
|
||||
# Pre-scan for diagnostic warnings only. The combine fn intentionally
|
||||
# treats `allow` and unknown modes as factor 1.0 (no weight change), so it
|
||||
# can't surface them — mirror its (pick × option) rule selection here,
|
||||
# exception-pair winning over matrix cell, and warn where the selected rule
|
||||
# is an ignored-factor `allow` or an unrecognised mode.
|
||||
if adjustment_warnings is not None:
|
||||
_scan_constraint_warnings(
|
||||
picks, options, matrix, exceptions, adjustment_warnings,
|
||||
)
|
||||
|
||||
adjusted: list[dict[str, Any]] = []
|
||||
for opt in options:
|
||||
option = {"value": opt.get("value", ""), "tags": opt.get("sub_categories") or []}
|
||||
f = combine_constraint_factor(picks, option, matrix, exceptions)
|
||||
weight = float(opt.get("weight", 1))
|
||||
weight = 0.0 if f is EXCLUDE else max(0.0, weight * float(f))
|
||||
adjusted.append({**opt, "weight": weight})
|
||||
return adjusted
|
||||
|
||||
|
||||
_KNOWN_CONSTRAINT_MODES = frozenset({"allow", "exclude", "boost", "reduce"})
|
||||
|
||||
|
||||
def _exc_pair_index(exceptions: list[Any]) -> dict[tuple[str, str], dict[str, Any]]:
|
||||
"""Index exceptions by (source_value, target_value), mirroring the
|
||||
combine fn's key fallback (tier-2 `source_value`/`target_value` names,
|
||||
then legacy `source`/`target`). Empty string is a valid key — it's the
|
||||
null-option marker (`is_null` options carry `value: ""`)."""
|
||||
out: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for exc in exceptions or []:
|
||||
if not isinstance(exc, dict):
|
||||
continue
|
||||
s = exc.get("source_value")
|
||||
if s is None:
|
||||
s = exc.get("source")
|
||||
t = exc.get("target_value")
|
||||
if t is None:
|
||||
t = exc.get("target")
|
||||
if isinstance(s, str) and isinstance(t, str):
|
||||
out[(s, t)] = exc
|
||||
return out
|
||||
|
||||
|
||||
def _scan_constraint_warnings(
|
||||
picks: list[dict[str, Any]],
|
||||
options: list[dict[str, Any]],
|
||||
matrix: dict[str, Any],
|
||||
exceptions: list[Any],
|
||||
adjustment_warnings: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Append `constraint_factor_ignored_on_allow` / `unknown_constraint_mode`
|
||||
diagnostics for the rules that fire (per pick × option). Selection order
|
||||
matches `combine_constraint_factor`: an exception on the literal value pair
|
||||
wins over a matrix cell, and only the matched rule is inspected."""
|
||||
exc_by_pair = _exc_pair_index(exceptions)
|
||||
for pick in picks:
|
||||
p_value = str(pick.get("value", ""))
|
||||
p_tags = pick.get("tags") or []
|
||||
for opt in options:
|
||||
opt_value = str(opt.get("value", ""))
|
||||
opt_tags = opt.get("sub_categories") or []
|
||||
|
||||
exc = exc_by_pair.get((p_value, opt_value))
|
||||
if exc is not None:
|
||||
_warn_for_rule(exc, p_value, opt_value, adjustment_warnings)
|
||||
continue
|
||||
|
||||
for s in p_tags:
|
||||
row = matrix.get(s)
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
for t in opt_tags:
|
||||
rule = row.get(t)
|
||||
if isinstance(rule, dict):
|
||||
_warn_for_rule(rule, p_value, opt_value, adjustment_warnings)
|
||||
|
||||
|
||||
def _warn_for_rule(
|
||||
rule: dict[str, Any],
|
||||
src_value: str,
|
||||
opt_value: str,
|
||||
adjustment_warnings: list[dict[str, Any]],
|
||||
) -> None:
|
||||
mode = rule.get("mode")
|
||||
if mode == "allow":
|
||||
# `allow` is a weight no-op; a non-1 factor is almost certainly a
|
||||
# user expecting weight×factor and silently getting full weight.
|
||||
try:
|
||||
factor = float(rule.get("factor", 1.0))
|
||||
except (TypeError, ValueError):
|
||||
factor = 1.0
|
||||
if factor != 1.0:
|
||||
adjustment_warnings.append({
|
||||
"type": "constraint_factor_ignored_on_allow",
|
||||
"factor": factor,
|
||||
"src_value": src_value,
|
||||
"opt_value": opt_value,
|
||||
})
|
||||
elif mode not in _KNOWN_CONSTRAINT_MODES:
|
||||
# Typo like "exlcude" / "alllow". The combine fn no-ops it; bubble up
|
||||
# so the caller can surface `unknown_constraint_mode`.
|
||||
adjustment_warnings.append({
|
||||
"type": "unknown_constraint_mode",
|
||||
"mode": mode,
|
||||
"src_value": src_value,
|
||||
"opt_value": opt_value,
|
||||
})
|
||||
|
||||
|
||||
def _file_pick_by_origin(
|
||||
ctx: Any,
|
||||
entry: dict[str, Any],
|
||||
bucket: dict[str, Any],
|
||||
module_id: Any,
|
||||
) -> None:
|
||||
"""Attach `entry` under `bucket[module_id]["by_origin"][origin]` when the
|
||||
active module carries a `bundle_origin` (stamped by pipeline.py). This is
|
||||
the per-instance source-pick view a constraint in the same bundle copy
|
||||
reads (task_5200c1fc); the top-level `bucket[module_id]` stays the
|
||||
last-writer-wins fallback. No-op when no origin is stamped, so a
|
||||
top-level / manual / legacy module's pick entry is byte-for-byte today's
|
||||
shape. The `by_origin` map is carried FORWARD across re-records of the
|
||||
same module_id so two origins writing the same library uuid each keep
|
||||
their own view (the 2nd record must not drop the 1st origin's bucket).
|
||||
"""
|
||||
origin = ctx.get("__wp_current_module_bundle_origin__") if ctx is not None else None
|
||||
if not origin:
|
||||
return
|
||||
prev = bucket.get(module_id)
|
||||
by_origin = {}
|
||||
if isinstance(prev, dict) and isinstance(prev.get("by_origin"), dict):
|
||||
by_origin = dict(prev["by_origin"])
|
||||
by_origin[origin] = {k: v for k, v in entry.items() if k != "by_origin"}
|
||||
entry["by_origin"] = by_origin
|
||||
|
||||
|
||||
def _record_pick(ctx: Any, chosen: dict[str, Any]) -> None:
|
||||
"""Stash the picked option dict in `ctx["__wp_picks__"][module_id]` so a
|
||||
downstream constraint-aware wildcard can look up its source's value +
|
||||
sub_categories. Keyed by the active module id (set by pipeline.py).
|
||||
"""
|
||||
# KNOWN COLLISION (task_5200c1fc): module_id is the LIBRARY uuid
|
||||
# (pipeline.py stamps __wp_current_module_id__ = library id). Two
|
||||
# instances of one library wildcard (bundle inserted twice, or the same
|
||||
# wildcard added twice) share this bucket key — the 2nd pick clobbers the
|
||||
# 1st, and every constraint sourcing that uuid reads the survivor. Fix =
|
||||
# key on the per-instance _uid + give constraints a source-INSTANCE bind.
|
||||
# Do NOT "fix" by regenerating ids on insert: that severs drift/refresh-
|
||||
# by-id lineage (see the remapBundleUuids dead-code note + task_5200c1fc).
|
||||
module_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
|
||||
if not module_id:
|
||||
return
|
||||
bucket = ctx.setdefault("__wp_picks__", {})
|
||||
if isinstance(bucket, dict):
|
||||
entry = {
|
||||
"value": chosen.get("value"),
|
||||
"sub_categories": chosen.get("sub_categories", []),
|
||||
"id": chosen.get("id"),
|
||||
# SP3: per-pick list the combine fn reads. A single pick is a
|
||||
# one-element list so the multi-tag/multi-pick applier has a
|
||||
# uniform shape regardless of single- vs multi-select source.
|
||||
"picks": [{
|
||||
"value": chosen.get("value", ""),
|
||||
"tags": list(chosen.get("sub_categories") or []),
|
||||
}],
|
||||
}
|
||||
# Additive per-instance view (task_5200c1fc). When this wildcard
|
||||
# carries a `bundle_origin`, ALSO file the pick under
|
||||
# `by_origin[origin]` so a constraint in the SAME bundle copy can
|
||||
# read its own source instance instead of the last-writer survivor
|
||||
# at the top-level key. The top-level entry above is left as the
|
||||
# fallback bucket (unchanged shape) — anything reading it is
|
||||
# unaffected. Omitted entirely when no origin is stamped.
|
||||
_file_pick_by_origin(ctx, entry, bucket, module_id)
|
||||
bucket[module_id] = entry
|
||||
|
||||
|
||||
def _record_pick_multi(ctx: Any, chosen_list: list[dict[str, Any]], sep: str) -> None:
|
||||
"""Multi-select counterpart to `_record_pick` (SP2a). Stash the joined
|
||||
value + the individual picked values + the union of their sub-categories,
|
||||
so the debug Picks view can render the list. Single-pick records (via
|
||||
`_record_pick`) keep their original shape."""
|
||||
# Same library-uuid bucket collision as `_record_pick` (see task_5200c1fc).
|
||||
module_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
|
||||
if not module_id:
|
||||
return
|
||||
bucket = ctx.setdefault("__wp_picks__", {})
|
||||
if not isinstance(bucket, dict):
|
||||
return
|
||||
values = [str(c.get("value", "")) for c in chosen_list]
|
||||
union: list[str] = []
|
||||
for c in chosen_list:
|
||||
for t in (c.get("sub_categories") or []):
|
||||
if t not in union:
|
||||
union.append(t)
|
||||
# SP3: per-pick list (value + that pick's own tags, NOT the union) so the
|
||||
# combine fn can apply the matrix per pick. The union above stays for the
|
||||
# debug Picks view; `picks` is what constraint application reads.
|
||||
per_pick = [
|
||||
{"value": str(c.get("value", "")), "tags": list(c.get("sub_categories") or [])}
|
||||
for c in chosen_list
|
||||
]
|
||||
entry = {
|
||||
"value": sep.join(values),
|
||||
"values": values,
|
||||
"sub_categories": union,
|
||||
"id": chosen_list[0].get("id") if chosen_list else None,
|
||||
"picks": per_pick,
|
||||
}
|
||||
# Same per-instance view as `_record_pick` (task_5200c1fc).
|
||||
_file_pick_by_origin(ctx, entry, bucket, module_id)
|
||||
bucket[module_id] = entry
|
||||
|
||||
|
||||
class WildcardHandler(ModuleHandler):
|
||||
type_id = "wildcard"
|
||||
|
||||
@staticmethod
|
||||
def _apply_pool_filter(
|
||||
options: list[dict[str, Any]],
|
||||
expr: str,
|
||||
*,
|
||||
exclude_null: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Narrow an option pool by a boolean sub-category expression.
|
||||
|
||||
`expr` is parsed via the shared subcat_filter matcher and tested
|
||||
against each non-null option's tag set (`sub_categories`). Empty
|
||||
/ whitespace `expr` ⇒ no tag filtering. The null option
|
||||
(`is_null`) is governed solely by `exclude_null`; the expression
|
||||
never applies to it (spec §3.4).
|
||||
"""
|
||||
ast = _parse_subcat(expr) if isinstance(expr, str) else None
|
||||
out: list[dict[str, Any]] = []
|
||||
for o in options:
|
||||
if o.get("is_null"):
|
||||
if not exclude_null:
|
||||
out.append(o)
|
||||
continue
|
||||
if _subcat_matches(ast, set(o.get("sub_categories") or [])):
|
||||
out.append(o)
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def validate_payload(cls, payload: dict[str, Any]) -> None:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("wildcard payload must be an object")
|
||||
options = payload.get("options")
|
||||
if not isinstance(options, list):
|
||||
raise ValueError("wildcard payload.options must be a list")
|
||||
|
||||
# Sub-category registry — the allowed tag names. Validated first
|
||||
# so each option's membership + tag_groups can be checked against
|
||||
# it. `validate_subcat_name` enforces the §3.6 name rules (single
|
||||
# token; no whitespace / boolean-grammar / ref chars; not a
|
||||
# reserved word incl. `null`).
|
||||
registry_list = payload.get("sub_categories", [])
|
||||
if not isinstance(registry_list, list):
|
||||
raise ValueError("wildcard payload.sub_categories must be a list")
|
||||
registry: set[str] = set()
|
||||
for i, sc in enumerate(registry_list):
|
||||
if not isinstance(sc, str):
|
||||
raise ValueError(
|
||||
f"wildcard payload.sub_categories[{i}] must be a string"
|
||||
)
|
||||
name_err = validate_subcat_name(sc)
|
||||
if name_err:
|
||||
raise ValueError(
|
||||
f"wildcard payload.sub_categories[{i}]: {name_err}"
|
||||
)
|
||||
registry.add(sc)
|
||||
|
||||
# Optional UI-only grouping of registry tags into named axes. The
|
||||
# engine ignores it at resolve time; validate the shape, that
|
||||
# every member is a registry tag, and that a tag lives in at most
|
||||
# one group.
|
||||
tag_groups = payload.get("tag_groups")
|
||||
if tag_groups is not None:
|
||||
if not isinstance(tag_groups, dict):
|
||||
raise ValueError("wildcard payload.tag_groups must be an object")
|
||||
seen_grouped: set[str] = set()
|
||||
for gname, members in tag_groups.items():
|
||||
if not isinstance(gname, str) or not isinstance(members, list):
|
||||
raise ValueError(
|
||||
"wildcard payload.tag_groups entries must be "
|
||||
"name -> string[]"
|
||||
)
|
||||
for mem in members:
|
||||
if mem not in registry:
|
||||
raise ValueError(
|
||||
f"wildcard payload.tag_groups[{gname!r}]: {mem!r} "
|
||||
f"not in sub_categories registry"
|
||||
)
|
||||
if mem in seen_grouped:
|
||||
raise ValueError(
|
||||
f"wildcard payload.tag_groups: {mem!r} appears in "
|
||||
f"more than one group"
|
||||
)
|
||||
seen_grouped.add(mem)
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
null_count = 0
|
||||
for i, opt in enumerate(options):
|
||||
if not isinstance(opt, dict):
|
||||
raise ValueError(f"wildcard payload.options[{i}] must be an object")
|
||||
# Option ids must be unique across the pool — `pinned_option_id`
|
||||
# lookup, `enabled_options` toggles, and `option_weights`
|
||||
# overrides all key by id, so dupes make those features
|
||||
# nondeterministic.
|
||||
opt_id = opt.get("id")
|
||||
if not isinstance(opt_id, str) or not opt_id:
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].id must be a string"
|
||||
)
|
||||
if opt_id in seen_ids:
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].id {opt_id!r} duplicates "
|
||||
f"an earlier option id"
|
||||
)
|
||||
seen_ids.add(opt_id)
|
||||
value = opt.get("value", "")
|
||||
is_null = bool(opt.get("is_null"))
|
||||
if is_null:
|
||||
null_count += 1
|
||||
# Null option contract: value must be empty string, no
|
||||
# sub_category. The flag is the source of truth — see
|
||||
# docs/superpowers/specs/2026-05-24-null-wildcard-option-design.md.
|
||||
if value != "":
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}] null option "
|
||||
f"{opt_id!r} must have empty value (got {value!r})"
|
||||
)
|
||||
null_subs = opt.get("sub_categories", [])
|
||||
if null_subs:
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}] null option "
|
||||
f"{opt_id!r} must have no sub_categories (got "
|
||||
f"{null_subs!r})"
|
||||
)
|
||||
else:
|
||||
if not isinstance(value, str) or value == "":
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].value must be a "
|
||||
f"non-empty string (use is_null=True for the null option)"
|
||||
)
|
||||
weight = opt.get("weight", 1)
|
||||
if not isinstance(weight, (int, float)) or isinstance(weight, bool):
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].weight must be a number"
|
||||
)
|
||||
if float(weight) < 0:
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].weight must not be negative"
|
||||
)
|
||||
subs = opt.get("sub_categories", [])
|
||||
if not isinstance(subs, list):
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].sub_categories must be a list"
|
||||
)
|
||||
for s in subs:
|
||||
if not isinstance(s, str):
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].sub_categories must be "
|
||||
f"strings"
|
||||
)
|
||||
if s not in registry:
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].sub_categories: {s!r} "
|
||||
f"not in sub_categories registry"
|
||||
)
|
||||
if null_count > 1:
|
||||
raise ValueError(
|
||||
f"wildcard payload may have at most one null option "
|
||||
f"(found {null_count})"
|
||||
)
|
||||
binding = payload.get("var_binding")
|
||||
if binding is not None:
|
||||
if not isinstance(binding, str):
|
||||
raise ValueError("wildcard payload.var_binding must be a string")
|
||||
# Empty string used to slip through (the old guard was
|
||||
# `if binding and not match`, which short-circuited on
|
||||
# falsy). Reject explicitly + bound length + reject the
|
||||
# `__dunder` prefix (collides with engine-internal key
|
||||
# convention — see engine/modules/types.py:strip_internals).
|
||||
if not binding:
|
||||
raise ValueError("wildcard payload.var_binding must not be empty")
|
||||
if len(binding) > _MAX_IDENT_LEN:
|
||||
raise ValueError(
|
||||
f"wildcard payload.var_binding must be at most "
|
||||
f"{_MAX_IDENT_LEN} chars (got {len(binding)})"
|
||||
)
|
||||
if binding.startswith("__"):
|
||||
raise ValueError(
|
||||
"wildcard payload.var_binding must not start with '__' "
|
||||
"(reserved for engine-internal keys)"
|
||||
)
|
||||
if not _IDENT_RE.match(binding):
|
||||
raise ValueError(
|
||||
f"wildcard payload.var_binding {binding!r} is not a valid identifier"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
cls,
|
||||
@@ -534,47 +44,6 @@ class WildcardHandler(ModuleHandler):
|
||||
return {}
|
||||
|
||||
options: list[dict[str, Any]] = list(payload.get("options", []))
|
||||
|
||||
# `pinned` mode short-circuits the RNG: always pick the option
|
||||
# whose id matches `pinned_option_id`. Falls through to random
|
||||
# (with a warning) if the pinned id can't be found — handles
|
||||
# the case where the user pinned an option that the library
|
||||
# has since removed. Honors enabled_options + weights normally
|
||||
# in every other mode.
|
||||
mode = instance.get("mode")
|
||||
if mode == "pinned":
|
||||
pinned_id = instance.get("pinned_option_id")
|
||||
pinned = next((o for o in options if o.get("id") == pinned_id), None)
|
||||
if pinned is not None:
|
||||
# Track the pinned pick the same way as a random pick —
|
||||
# downstream constraint-aware wildcards need source
|
||||
# info regardless of how the source resolved its option.
|
||||
_record_pick(ctx, pinned)
|
||||
value = str(pinned.get("value", ""))
|
||||
if not value:
|
||||
return {binding: ""}
|
||||
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard")
|
||||
return {binding: resolve_text(value, resolve_ctx)}
|
||||
# else: pinned target is missing — fall through to random.
|
||||
|
||||
# `category_filter` narrows the option pool to entries whose tag
|
||||
# set (`sub_categories`) satisfies a boolean expression
|
||||
# (`and`/`or`/`not`/parens; comma=or). Empty / missing = no tag
|
||||
# filter. `exclude_null` is a separate flag: when true the null
|
||||
# option is dropped, otherwise it is an orthogonal "no-output"
|
||||
# slot that survives every filter (the expression never applies
|
||||
# to it). Untagged non-null options bypass the expression unless
|
||||
# a bare tag term excludes them (spec §3.4). See the shared
|
||||
# matcher in engine/syntax/subcat_filter.py.
|
||||
category_filter = instance.get("category_filter")
|
||||
exclude_null = bool(instance.get("exclude_null", False))
|
||||
if (isinstance(category_filter, str) and category_filter.strip()) or exclude_null:
|
||||
options = cls._apply_pool_filter(
|
||||
options,
|
||||
category_filter if isinstance(category_filter, str) else "",
|
||||
exclude_null=exclude_null,
|
||||
)
|
||||
|
||||
enabled = instance.get("enabled_options")
|
||||
if enabled is not None:
|
||||
allowed = set(enabled)
|
||||
@@ -583,169 +52,15 @@ class WildcardHandler(ModuleHandler):
|
||||
if not options:
|
||||
return {binding: ""}
|
||||
|
||||
# Per-instance weight overrides — replaces (not multiplies) the
|
||||
# library weight when the option's id appears in the override
|
||||
# map. Missing/non-numeric overrides leave the library weight
|
||||
# untouched. Shallow-copy each option so we don't mutate the
|
||||
# underlying snapshot payload (which is shared across runs).
|
||||
weight_overrides = instance.get("option_weights")
|
||||
if isinstance(weight_overrides, dict) and weight_overrides:
|
||||
adjusted: list[dict[str, Any]] = []
|
||||
for o in options:
|
||||
oid = o.get("id")
|
||||
if oid in weight_overrides:
|
||||
try:
|
||||
new_weight = float(weight_overrides[oid])
|
||||
except (TypeError, ValueError):
|
||||
adjusted.append(o)
|
||||
continue
|
||||
o = {**o, "weight": new_weight}
|
||||
adjusted.append(o)
|
||||
options = adjusted
|
||||
|
||||
# Constraint application — each constraint targeting THIS module
|
||||
# adjusts the option pool's weights based on what its source
|
||||
# wildcard already picked. Apply logic extracted to
|
||||
# `engine.modules._constraints` so the nested-`@{}` path in
|
||||
# `engine.syntax.resolve` shares the same implementation.
|
||||
from engine.modules._constraints import (
|
||||
apply_constraints_for_target,
|
||||
warn_excludes_all,
|
||||
)
|
||||
my_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
|
||||
any_constraint_applied = False
|
||||
if my_id:
|
||||
constraints = ctx.get("__wp_constraints__") if ctx is not None else None
|
||||
picks = ctx.get("__wp_picks__") if ctx is not None else None
|
||||
# SP3 reach selector: thread the ctx-resident per-constraint
|
||||
# hit counter so each covering constraint re-weights this
|
||||
# firing target instance (no more one-shot / consumed-set).
|
||||
# `firing_uid` identifies THIS direct top-level instance for
|
||||
# the `pick` selector's occurrence match.
|
||||
hits = (
|
||||
ctx.setdefault("__wp_constraint_hits__", {})
|
||||
if isinstance(ctx, dict) else {}
|
||||
)
|
||||
firing_uid = (
|
||||
ctx.get("__wp_current_module_uid__")
|
||||
if isinstance(ctx, dict) else None
|
||||
)
|
||||
options, any_constraint_applied = apply_constraints_for_target(
|
||||
options, my_id, constraints, picks, ctx["__wp_warnings__"],
|
||||
hits=hits, firing_uid=firing_uid,
|
||||
)
|
||||
if any_constraint_applied:
|
||||
warn_excludes_all(options, my_id or "", ctx["__wp_warnings__"])
|
||||
|
||||
# Effective seed selection:
|
||||
# - locked_seed when present → reproducible per-instance
|
||||
# - chain seed (`__wp_node_seed__`) otherwise
|
||||
# Both paths feed the SAME `_derive_module_rng(seed, binding)`,
|
||||
# so locking with `locked_seed = chain_seed` reproduces the
|
||||
# unlocked pick exactly. This is the contract the lock UX
|
||||
# relies on: the user sees a roll, locks it, captures the
|
||||
# current chain seed, future runs reproduce the same roll.
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
if locked_seed is not None:
|
||||
try:
|
||||
effective_seed = int(locked_seed)
|
||||
except (TypeError, ValueError):
|
||||
effective_seed = chain_seed
|
||||
locked_seed = None
|
||||
else:
|
||||
effective_seed = chain_seed
|
||||
rng = _derive_module_rng(effective_seed, binding)
|
||||
|
||||
# SP2a multi-select: a count range other than (1, 1) picks N options
|
||||
# without replacement and binds a ListVar. The null option is never in
|
||||
# the multi-pick pool (exclude_null is single-pick-only); "maybe
|
||||
# nothing" is min=0. (1, 1) falls through to the single-pick path
|
||||
# below unchanged — same RNG draws, so existing locked seeds reproduce.
|
||||
raw_min = _coerce_pick_int(instance.get("pick_min"), 1)
|
||||
raw_max = _coerce_pick_int(instance.get("pick_max"), raw_min)
|
||||
lo = max(0, min(raw_min, raw_max))
|
||||
hi = max(lo, raw_max)
|
||||
if not (lo == 1 and hi == 1):
|
||||
pool = [o for o in options if not o.get("is_null")]
|
||||
weights = [max(0.0, float(o.get("weight", 1))) for o in pool]
|
||||
pool_n = len(pool)
|
||||
independent = bool(instance.get("pick_independent", False))
|
||||
if independent:
|
||||
# SP2c: independent multi-pick draws WITH replacement (repeats
|
||||
# allowed), mirroring the inline `~` flag — so the count is the
|
||||
# requested range and is NOT clamped to the pool size.
|
||||
count = lo if lo == hi else lo + int(rng.random() * (hi - lo + 1))
|
||||
picks = [
|
||||
p for p in (_pick_weighted(pool, rng) for _ in range(count))
|
||||
if p is not None
|
||||
]
|
||||
else:
|
||||
lo_c, hi_c = min(lo, pool_n), min(hi, pool_n)
|
||||
count = lo_c if lo_c == hi_c else lo_c + int(rng.random() * (hi_c - lo_c + 1))
|
||||
picks = pick_k_unique(pool, weights, count, rng)
|
||||
sep = instance.get("pick_separator")
|
||||
if not isinstance(sep, str):
|
||||
sep = ", "
|
||||
items: list[str] = []
|
||||
saved_rng_multi = ctx.get("__wp_rng__")
|
||||
ctx["__wp_rng__"] = rng
|
||||
try:
|
||||
multi_ctx = build_resolve_ctx(ctx, surface="wildcard")
|
||||
for opt in picks:
|
||||
items.append(resolve_text(str(opt.get("value", "")), multi_ctx))
|
||||
finally:
|
||||
ctx["__wp_rng__"] = saved_rng_multi
|
||||
_record_pick_multi(ctx, picks, sep)
|
||||
return {binding: ListVar(items, sep)}
|
||||
|
||||
rng = ctx["__wp_rng__"]
|
||||
chosen = _pick_weighted(options, rng)
|
||||
if chosen is None:
|
||||
return {binding: ""}
|
||||
|
||||
# Record this wildcard's pick so a downstream constraint-aware
|
||||
# wildcard can read it. Done BEFORE resolve_text so even an
|
||||
# empty-string-value pick is registered (matters if a
|
||||
# constraint exception keys on the literal empty pick value).
|
||||
_record_pick(ctx, chosen)
|
||||
|
||||
value = str(chosen.get("value", ""))
|
||||
if not value:
|
||||
# Empty / null pick — no ref to resolve.
|
||||
return {binding: ""}
|
||||
|
||||
# Swap `ctx['__wp_rng__']` to the per-module rng for the
|
||||
# duration of `resolve_text`. The swap is UNCONDITIONAL —
|
||||
# both locked and unlocked paths run nested resolution
|
||||
# against the per-module rng — because:
|
||||
# - it's the only way locking can reproduce the unlocked
|
||||
# pick (same effective_seed → same per-module rng →
|
||||
# same pick AND same nested rolls).
|
||||
# - it isolates wildcards from each other within one chain
|
||||
# run: inline `{a|b|c}` picks no longer depend on
|
||||
# module ordering / sibling picks.
|
||||
# `build_resolve_ctx` snapshots the rng reference at call
|
||||
# time, so it must be built INSIDE the swap.
|
||||
saved_rng = ctx.get("__wp_rng__")
|
||||
ctx["__wp_rng__"] = rng
|
||||
try:
|
||||
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard")
|
||||
# SP3 nested-pick reach: THIS wildcard is the carrier of any
|
||||
# `@{T}` ref inside `value`. Stamp (its per-instance uid, the
|
||||
# chosen option's id) on the resolve frame so a constraint on
|
||||
# T whose `pick` selector lists this (carrier_uid, option_id)
|
||||
# nested occurrence matches. A `pick` keyed on a DEEPER
|
||||
# carrier won't match this top-level uid — that's by design
|
||||
# (only one-hop carriers are individually pickable). Set on
|
||||
# the freshly-built ctx (carrier defaults None); no restore
|
||||
# needed — resolve_ctx is scoped to this resolve_text call.
|
||||
set_carrier = getattr(resolve_ctx, "set_carrier", None)
|
||||
if callable(set_carrier):
|
||||
set_carrier(
|
||||
ctx.get("__wp_current_module_uid__"), chosen.get("id"),
|
||||
)
|
||||
resolved = resolve_text(value, resolve_ctx)
|
||||
finally:
|
||||
ctx["__wp_rng__"] = saved_rng
|
||||
|
||||
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard")
|
||||
resolved = resolve_text(value, resolve_ctx)
|
||||
return {binding: resolved}
|
||||
|
||||
+9
-538
@@ -10,8 +10,6 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from engine.context import Context
|
||||
from engine.modules import Module
|
||||
@@ -20,221 +18,6 @@ from engine.modules.snapshot import coerce_legacy_module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# `@{uuid}` ref matcher — mirror of engine.syntax.tokenize._REF_RE
|
||||
# (4-segment: uuid + optional #name / :expr / !null; only the uuid is
|
||||
# captured here). Used by the never_applied warning classifier to tell
|
||||
# "target has a carrier/instance somewhere in the chain" apart from
|
||||
# "target uuid is nowhere to be found".
|
||||
_PIPELINE_REF_RE = re.compile(
|
||||
r"@\{([0-9a-f]{8})(?:#[^#:}@{!]*)?(?::[^}!]*)?(?:![^}]*)?\}"
|
||||
)
|
||||
|
||||
|
||||
def _module_field(module: Any, key: str) -> Any:
|
||||
"""Read a field from a module that may be a dict or an object."""
|
||||
if isinstance(module, dict):
|
||||
return module.get(key)
|
||||
return getattr(module, key, None)
|
||||
|
||||
|
||||
def _target_present_in_chain(
|
||||
target_uuid: str,
|
||||
modules: list[Any],
|
||||
catalog: dict[str, Any] | None,
|
||||
) -> bool:
|
||||
"""True when `target_uuid` has SOME representation in the chain —
|
||||
either a direct wildcard instance (a module whose id == target) or a
|
||||
carrier (any wildcard, in the module list OR the catalog, with an
|
||||
`@{target}` ref in one of its option values).
|
||||
|
||||
Used purely to pick the right never_applied warning wording: if the
|
||||
target is present-but-unclaimed the cause is ordering / a skipped
|
||||
roll; if it's absent the cause is a wrong uuid. Best-effort + cheap
|
||||
(library volumes are dozens of modules, single-digit refs each)."""
|
||||
def _options_of(payload: Any) -> list:
|
||||
if isinstance(payload, dict):
|
||||
opts = payload.get("options")
|
||||
return opts if isinstance(opts, list) else []
|
||||
return []
|
||||
|
||||
def _carries(payload: Any) -> bool:
|
||||
for opt in _options_of(payload):
|
||||
val = opt.get("value") if isinstance(opt, dict) else None
|
||||
if isinstance(val, str) and any(
|
||||
m.group(1) == target_uuid for m in _PIPELINE_REF_RE.finditer(val)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
for m in modules:
|
||||
if _module_field(m, "type") != "wildcard":
|
||||
continue
|
||||
if _module_field(m, "id") == target_uuid:
|
||||
return True
|
||||
if _carries(_module_field(m, "payload")):
|
||||
return True
|
||||
if isinstance(catalog, dict):
|
||||
for entry in catalog.values():
|
||||
payload = entry.get("payload") if isinstance(entry, dict) else None
|
||||
if _carries(payload):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Characters that break the engine ref tokenizer's `#name` segment
|
||||
# (`[^#:}@{]*`). Anything in this set gets replaced with a space when
|
||||
# embedding a cached name into a `@{uuid#name}` ref so a stray apostrophe
|
||||
# or curly brace can't shatter the warning's parse on the frontend.
|
||||
_REF_NAME_FORBIDDEN = re.compile(r"[#:}@{]")
|
||||
|
||||
|
||||
def _build_ref_name_suffix(name: Any) -> str:
|
||||
"""Return the `#name` suffix for a cached ref token, or `""` when no
|
||||
usable name was provided. Used by the `constraint_never_applied`
|
||||
warning emitter so chips render a readable label even when the
|
||||
workflow is imported on a machine whose library lacks the referenced
|
||||
modules — the cached `#name` is the only fallback RefChip has in
|
||||
that scenario."""
|
||||
if not isinstance(name, str):
|
||||
return ""
|
||||
cleaned = _REF_NAME_FORBIDDEN.sub(" ", name).strip()
|
||||
return f"#{cleaned}" if cleaned else ""
|
||||
|
||||
|
||||
def _extract_static_meta(
|
||||
module_raw: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Pull metadata fields out of a module dict/dataclass that the
|
||||
debug viewer surfaces independently of execution success.
|
||||
|
||||
Returns a dict that may contain:
|
||||
- ``binding`` — the variable a single-binding module declares
|
||||
(``wildcard``/``combine``/``derivation``). Empty for
|
||||
``constraint`` (no binding) and ``fixed_values`` (multi-binding
|
||||
— entries listed via ``writes`` after run instead).
|
||||
- ``bindings`` — list of variable names a multi-binding module
|
||||
declares (``fixed_values``). Used when the module is disabled
|
||||
/ errors before writes get populated.
|
||||
- ``internal`` — bool, true when ``instance.internal`` is set;
|
||||
every binding the module would produce is engine-only.
|
||||
- ``seed_locked`` — bool, true when ``instance.locked_seed`` is
|
||||
a number; the module rolls with that seed not the chain seed.
|
||||
- ``constraint_source`` / ``constraint_target`` — the source +
|
||||
target wildcard uuids on a ``constraint`` payload, so the
|
||||
debug viewer can label the row as `$src → $tgt`.
|
||||
|
||||
Robust to malformed shapes — every read is type-checked, every
|
||||
failure path returns an empty dict so the trace path doesn't
|
||||
crash on a bad workflow JSON.
|
||||
"""
|
||||
if isinstance(module_raw, dict):
|
||||
m_type = module_raw.get("type", "") or ""
|
||||
inst = module_raw.get("instance", {})
|
||||
payload = module_raw.get("payload", {})
|
||||
entries = module_raw.get("entries", [])
|
||||
else:
|
||||
m_type = getattr(module_raw, "type", "") or ""
|
||||
inst = getattr(module_raw, "instance", {})
|
||||
payload = getattr(module_raw, "payload", {})
|
||||
entries = getattr(module_raw, "entries", [])
|
||||
|
||||
inst = inst if isinstance(inst, dict) else {}
|
||||
payload = payload if isinstance(payload, dict) else {}
|
||||
|
||||
locked_seed = inst.get("locked_seed")
|
||||
is_locked = isinstance(locked_seed, (int, float))
|
||||
meta: dict[str, Any] = {
|
||||
"internal": bool(inst.get("internal", False)),
|
||||
"seed_locked": is_locked,
|
||||
}
|
||||
# When the module declares a locked_seed, surface the value in the
|
||||
# trace row regardless of execution status. Pre-fix only ok-status
|
||||
# rows got `seed: effective_seed` set; disabled / errored rows had
|
||||
# no seed, so the user couldn't see what seed a locked-but-disabled
|
||||
# module would have used.
|
||||
if is_locked:
|
||||
meta["seed"] = int(locked_seed)
|
||||
|
||||
if m_type == "fixed_values":
|
||||
# fixed_values declares its variables in `entries` (or
|
||||
# `payload.values` for the unified-list shape used by the SPA).
|
||||
# Either way, surface every variable name so the disabled
|
||||
# trace row can show *what* would have been written.
|
||||
seen: list[str] = []
|
||||
for source in (entries, payload.get("entries"), payload.get("values")):
|
||||
if not isinstance(source, list):
|
||||
continue
|
||||
for e in source:
|
||||
if isinstance(e, dict):
|
||||
name = e.get("variable_name") or e.get("name")
|
||||
if isinstance(name, str) and name and name not in seen:
|
||||
seen.append(name)
|
||||
if seen:
|
||||
meta["bindings"] = seen
|
||||
elif m_type == "constraint":
|
||||
src = payload.get("source_wildcard_id")
|
||||
tgt = payload.get("target_wildcard_id")
|
||||
if isinstance(src, str) and src:
|
||||
meta["constraint_source"] = src
|
||||
if isinstance(tgt, str) and tgt:
|
||||
meta["constraint_target"] = tgt
|
||||
elif m_type == "combine":
|
||||
# Combine writes to `payload.output_var` (instance may override
|
||||
# via `variable_binding` for the picker-driven flow). Surface
|
||||
# the resolved name so disabled-combine rows in the debug
|
||||
# viewer show `$output_var (disabled)` instead of a uuid.
|
||||
b = (
|
||||
inst.get("variable_binding")
|
||||
or payload.get("output_var")
|
||||
or payload.get("var_binding")
|
||||
or ""
|
||||
)
|
||||
if isinstance(b, str):
|
||||
b = b.lstrip("$").strip()
|
||||
if b:
|
||||
meta["binding"] = b
|
||||
elif m_type == "derivation":
|
||||
# Derivation can write to MULTIPLE target_vars (one per branch
|
||||
# `action.target_var`, plus the `else.action.target_var`).
|
||||
# Collect every declared target so the disabled-derivation row
|
||||
# shows all of them in the debug viewer instead of a uuid.
|
||||
seen_targets: list[str] = []
|
||||
rules = payload.get("rules")
|
||||
if isinstance(rules, list):
|
||||
for rule in rules:
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
for branch in rule.get("branches", []) or []:
|
||||
if not isinstance(branch, dict):
|
||||
continue
|
||||
action = branch.get("action")
|
||||
if isinstance(action, dict):
|
||||
target = action.get("target_var")
|
||||
if isinstance(target, str):
|
||||
target = target.lstrip("$").strip()
|
||||
if target and target not in seen_targets:
|
||||
seen_targets.append(target)
|
||||
else_block = rule.get("else")
|
||||
if isinstance(else_block, dict):
|
||||
action = else_block.get("action")
|
||||
if isinstance(action, dict):
|
||||
target = action.get("target_var")
|
||||
if isinstance(target, str):
|
||||
target = target.lstrip("$").strip()
|
||||
if target and target not in seen_targets:
|
||||
seen_targets.append(target)
|
||||
if seen_targets:
|
||||
meta["bindings"] = seen_targets
|
||||
else:
|
||||
# Wildcard / pipeline / other single-binding modules. Picker
|
||||
# writes `instance.variable_binding`; legacy snapshots may
|
||||
# carry `payload.var_binding` instead.
|
||||
b = inst.get("variable_binding") or payload.get("var_binding") or ""
|
||||
if isinstance(b, str) and b:
|
||||
meta["binding"] = b.lstrip("$").strip()
|
||||
|
||||
return meta
|
||||
|
||||
|
||||
class PipelineEngine:
|
||||
"""Runs an ordered list of modules against a context dict."""
|
||||
@@ -252,69 +35,20 @@ class PipelineEngine:
|
||||
ctx.setdefault("__wp_warnings__", [])
|
||||
ctx.setdefault("__wp_trace__", [])
|
||||
ctx.setdefault("__wp_internal_flags__", {})
|
||||
# SP3 reach selector: per-constraint firing count keyed by
|
||||
# `__constraint_module_id__`. Drives first/next/all/pick
|
||||
# coverage in apply_constraints_for_target + the never_applied /
|
||||
# partial_reach finalisation below. Replaces the pre-SP3
|
||||
# one-shot consumed-set.
|
||||
ctx.setdefault("__wp_constraint_hits__", {})
|
||||
|
||||
for index, module in enumerate(modules):
|
||||
# Normalise id/type/enabled reads to work for both dicts and objects.
|
||||
if isinstance(module, dict):
|
||||
_module_id = module.get("id", "")
|
||||
# Per-instance uid distinct from the library uuid `id`.
|
||||
# Siblings (same library entry instantiated twice) share
|
||||
# `id` but each has its own `_uid`; trace consumers that
|
||||
# need per-row state (lock-fallback, per-instance seed
|
||||
# display) key by `_uid`. Defaults to empty string when
|
||||
# the entry pre-dates the `_uid` migration.
|
||||
_module_uid = module.get("_uid", "") or ""
|
||||
_module_type_raw = module.get("type", None) or ""
|
||||
_module_enabled = module.get("enabled", True)
|
||||
# Display name. Stashed alongside `id` / `_uid` so handlers
|
||||
# that emit user-facing warnings (notably the constraint
|
||||
# handler — see `__constraint_library_name__`) can embed
|
||||
# the name as a cached `#name` ref suffix. That suffix
|
||||
# survives library + embed-bundle misses (workflows
|
||||
# imported from someone else's machine), letting WP_Debug
|
||||
# render a readable chip instead of a raw uuid.
|
||||
_module_name = module.get("name", "") or ""
|
||||
# Per-insertion bundle discriminator. Two instances of one
|
||||
# library wildcard (a bundle inserted twice) carry DISTINCT
|
||||
# `bundle_origin` values (insert.ts stamps each child with
|
||||
# its immediate BundleInstance._uid). Threaded into ctx so a
|
||||
# constraint can bind to the source pick from its OWN bundle
|
||||
# copy (task_5200c1fc). Empty string when the module has no
|
||||
# bundle origin (top-level / manually-added / legacy).
|
||||
_module_bundle_origin = module.get("bundle_origin", "") or ""
|
||||
else:
|
||||
_module_id = getattr(module, "id", "")
|
||||
_module_uid = getattr(module, "_uid", "") or ""
|
||||
_module_type_raw = getattr(module, "type", None) or ""
|
||||
_module_enabled = getattr(module, "enabled", True)
|
||||
_module_name = getattr(module, "name", "") or ""
|
||||
_module_bundle_origin = getattr(module, "bundle_origin", "") or ""
|
||||
|
||||
if not _module_enabled:
|
||||
# Disabled modules don't run — but the trace row still
|
||||
# surfaces what the module would have written, so the
|
||||
# debug viewer can label it `$varname (disabled)`
|
||||
# instead of falling back to an opaque short-uuid.
|
||||
meta = _extract_static_meta(module)
|
||||
if not getattr(module, "enabled", True):
|
||||
ctx["__wp_trace__"].append({
|
||||
"id": _module_id,
|
||||
"_uid": _module_uid,
|
||||
"type": _module_type_raw,
|
||||
"id": getattr(module, "id", ""),
|
||||
"type": getattr(module, "type", ""),
|
||||
"enabled": False,
|
||||
"status": "skipped_disabled",
|
||||
"writes": [],
|
||||
"error": None,
|
||||
**meta,
|
||||
})
|
||||
continue
|
||||
|
||||
module_type = _module_type_raw
|
||||
module_type = getattr(module, "type", None) or ""
|
||||
try:
|
||||
# coerce_legacy_module expects a dict; convert dataclasses or
|
||||
# duck-typed objects by reading known attrs via getattr.
|
||||
@@ -340,50 +74,16 @@ class PipelineEngine:
|
||||
logger.warning(
|
||||
"Failed to coerce module %r at index %s: %s", module, index, e
|
||||
)
|
||||
meta = _extract_static_meta(module)
|
||||
ctx["__wp_trace__"].append({
|
||||
"id": _module_id,
|
||||
"_uid": _module_uid,
|
||||
"id": getattr(module, "id", ""),
|
||||
"type": module_type,
|
||||
"enabled": True,
|
||||
"status": "failed",
|
||||
"writes": [],
|
||||
"error": {"type": type(e).__name__, "message": str(e)},
|
||||
**meta,
|
||||
})
|
||||
continue
|
||||
|
||||
# Stash the active module's id in ctx so handlers that
|
||||
# need to identify themselves (constraint-aware wildcards
|
||||
# looking up `__wp_constraints__[*].target_wildcard_id`)
|
||||
# can read it without reaching into snapshot internals.
|
||||
# Cleared in the finally below so a stale id doesn't leak
|
||||
# into the next iteration's resolve call.
|
||||
#
|
||||
# Two distinct identifiers:
|
||||
# - `__wp_current_module_id__` = library uuid (`id`). The
|
||||
# wildcard handler matches THIS against a constraint's
|
||||
# `target_wildcard_id` (which is a library uuid).
|
||||
# - `__wp_current_module_uid__` = per-instance uid (`_uid`).
|
||||
# This is the BARE per-instance uid (no node prefix). The
|
||||
# constraint handler keys its `__constraint_module_id__`
|
||||
# (the SP3 per-constraint hit counter) on THIS so two
|
||||
# instances of the same library constraint entry reach
|
||||
# INDEPENDENTLY (per CLAUDE.md: "author multiple constraint
|
||||
# modules" to affect multiple targets). It's ALSO the
|
||||
# `firing_uid` a direct `pick` selector matches against —
|
||||
# so a persisted `pick` must carry the bare `_uid`, not the
|
||||
# UI's badge rowKey (`${nodeId}#${_uid}`). Falls back to the
|
||||
# library id when `_uid` is absent (legacy / test modules).
|
||||
ctx["__wp_current_module_id__"] = _module_id
|
||||
ctx["__wp_current_module_uid__"] = _module_uid or _module_id
|
||||
ctx["__wp_current_module_name__"] = _module_name
|
||||
# See the `_module_bundle_origin` derivation above — exposed to
|
||||
# handlers (wildcard_handler records it on the pick; constraint_
|
||||
# handler captures it on the registered meta) so source-instance
|
||||
# binding can match a constraint to its own bundle copy's pick.
|
||||
ctx["__wp_current_module_bundle_origin__"] = _module_bundle_origin
|
||||
meta = _extract_static_meta(module)
|
||||
try:
|
||||
bindings = resolve_module(snapshot, ctx)
|
||||
except UnknownModuleType:
|
||||
@@ -393,14 +93,12 @@ class PipelineEngine:
|
||||
index,
|
||||
)
|
||||
ctx["__wp_trace__"].append({
|
||||
"id": _module_id,
|
||||
"_uid": _module_uid,
|
||||
"id": getattr(module, "id", ""),
|
||||
"type": module_type,
|
||||
"enabled": True,
|
||||
"status": "skipped_unknown_type",
|
||||
"writes": [],
|
||||
"error": None,
|
||||
**meta,
|
||||
})
|
||||
continue
|
||||
except Exception as e:
|
||||
@@ -410,7 +108,7 @@ class PipelineEngine:
|
||||
ctx["__wp_warnings__"].append({
|
||||
"type": "handler_error",
|
||||
"severity": "error",
|
||||
"module_id": _module_id,
|
||||
"module_id": getattr(module, "id", ""),
|
||||
"source_field": "payload",
|
||||
"position": 0,
|
||||
"token_index": None,
|
||||
@@ -424,34 +122,20 @@ class PipelineEngine:
|
||||
),
|
||||
})
|
||||
ctx["__wp_trace__"].append({
|
||||
"id": _module_id,
|
||||
"_uid": _module_uid,
|
||||
"id": getattr(module, "id", ""),
|
||||
"type": module_type,
|
||||
"enabled": True,
|
||||
"status": "failed",
|
||||
"writes": [],
|
||||
"error": {"type": type(e).__name__, "message": str(e)},
|
||||
**meta,
|
||||
})
|
||||
continue
|
||||
|
||||
writes = []
|
||||
# `instance.internal == True` marks every binding the
|
||||
# module produces as engine-only — they stay in ctx so
|
||||
# downstream modules can read them, but `strip_internals`
|
||||
# drops them on the way out so they don't surface on the
|
||||
# public PIPELINE_CONTEXT socket. Useful for "scratch"
|
||||
# vars that drive a derivation/combine but shouldn't
|
||||
# become prompt-text noise.
|
||||
inst = snapshot.get("instance", {}) if isinstance(snapshot, dict) else {}
|
||||
mark_internal = bool(inst.get("internal", False)) if isinstance(inst, dict) else False
|
||||
for var, value in (bindings or {}).items():
|
||||
key = var.lstrip("$")
|
||||
before = ctx.get(key)
|
||||
ctx[key] = value
|
||||
if mark_internal:
|
||||
flags = ctx.setdefault("__wp_internal_flags__", {})
|
||||
flags[key] = True
|
||||
writes.append({
|
||||
"variable": key,
|
||||
"value": value,
|
||||
@@ -459,226 +143,13 @@ class PipelineEngine:
|
||||
"overwrite": before is not None and before != value,
|
||||
})
|
||||
|
||||
# `seed` on the trace entry: the effective seed THIS
|
||||
# module rolled with — `instance.locked_seed` if locked,
|
||||
# else the chain seed. Surfaces to the frontend via the
|
||||
# WP_Context node's UI payload so the lock-toggle defaults
|
||||
# can grab the AUTHORITATIVE value (not the local widget,
|
||||
# which lies when the seed input is link-driven).
|
||||
locked = inst.get("locked_seed") if isinstance(inst, dict) else None
|
||||
if isinstance(locked, (int, float)):
|
||||
effective_seed = int(locked)
|
||||
else:
|
||||
effective_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
ctx["__wp_trace__"].append({
|
||||
"id": _module_id,
|
||||
"_uid": _module_uid,
|
||||
"id": getattr(module, "id", ""),
|
||||
"type": module_type,
|
||||
"enabled": True,
|
||||
"status": "ok",
|
||||
"writes": writes,
|
||||
"error": None,
|
||||
"seed": effective_seed,
|
||||
**meta,
|
||||
})
|
||||
|
||||
# Drop the active-module markers so they don't leak into the
|
||||
# public socket payload (both are `__`-prefixed so
|
||||
# `strip_internals` would filter them, but clearing is cheap and
|
||||
# keeps post-run ctx introspection tidy).
|
||||
ctx.pop("__wp_current_module_id__", None)
|
||||
ctx.pop("__wp_current_module_uid__", None)
|
||||
ctx.pop("__wp_current_module_name__", None)
|
||||
ctx.pop("__wp_current_module_bundle_origin__", None)
|
||||
|
||||
# SP3 reach selector finalisation: emit `constraint_never_applied`
|
||||
# for every registered constraint whose selector covered ZERO
|
||||
# firing target instances (hit count 0). Surfaces in WP_Debug so
|
||||
# the user sees "you put a constraint here but nothing downstream
|
||||
# took it" — the chain still succeeds. The `partial_reach` pass
|
||||
# below covers constraints that DID fire but reached fewer
|
||||
# instances than a `next N` / `pick` selector asked for.
|
||||
#
|
||||
# Dedup by cid — sibling instances of the same library entry
|
||||
# share `__constraint_module_id__`, so they show up as two
|
||||
# entries in the bucket. Without the dedup the user sees the
|
||||
# same warning twice for the same logical constraint (2026-05-26).
|
||||
constraints_bucket = ctx.get("__wp_constraints__") or []
|
||||
hits = ctx.get("__wp_constraint_hits__") or {}
|
||||
catalog = ctx.get("__wp_catalog__")
|
||||
warned_cids: set[str] = set()
|
||||
if isinstance(constraints_bucket, list):
|
||||
for c in constraints_bucket:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
cid = c.get("__constraint_module_id__")
|
||||
if not cid or hits.get(cid, 0) != 0:
|
||||
continue
|
||||
if cid in warned_cids:
|
||||
continue
|
||||
warned_cids.add(cid)
|
||||
# `cid` keys the dedup bookkeeping (per-instance `_uid` so
|
||||
# two canvas instances of the same library constraint are
|
||||
# independent). The message text, on the other hand, must
|
||||
# reference the LIBRARY id — that's the 8-hex uuid the
|
||||
# SPA/Debug chip resolver looks up in the module catalog
|
||||
# to render the constraint's name. Falling back to `cid`
|
||||
# only when the library id is missing (legacy / hand-built
|
||||
# constraints) keeps the warning informative either way.
|
||||
library_cid = c.get("__constraint_library_id__") or cid
|
||||
target_uuid = c.get("target_wildcard_id", "")
|
||||
# Build `#name` cache suffixes so the chip survives a
|
||||
# library-missing import. The SPA's chip resolver looks
|
||||
# the uuid up against the importer's local library — if
|
||||
# the workflow ships with modules the importer doesn't
|
||||
# have, the lookup misses and the chip falls back to
|
||||
# whatever cached name the ref token carries (per
|
||||
# RefChip.vue's unresolved-with-cached-name path). The
|
||||
# constraint's name was stashed by its handler; the
|
||||
# target's name is read from the per-run catalog. Sanitise
|
||||
# both against the engine's ref tokenizer (`[^#:}@{]*` is
|
||||
# the allowed name charset; anything else is replaced with
|
||||
# a space) so a name with stray punctuation can't break
|
||||
# the warning's parse.
|
||||
target_name = ""
|
||||
if isinstance(catalog, dict):
|
||||
target_row = catalog.get(target_uuid)
|
||||
if isinstance(target_row, dict):
|
||||
target_name = target_row.get("name", "") or ""
|
||||
cid_suffix = _build_ref_name_suffix(c.get("__constraint_library_name__"))
|
||||
tgt_suffix = _build_ref_name_suffix(target_name)
|
||||
# Classify the cause so the message is actionable.
|
||||
# Reach is downstream-relative: a constraint only applies
|
||||
# to target occurrences that resolve AFTER it registers.
|
||||
# So a never-applied constraint whose target IS present in
|
||||
# the chain means every occurrence ran BEFORE this
|
||||
# constraint registered (ordering), or sits in a
|
||||
# branch/Context this run didn't reach. Absent target =
|
||||
# a wrong/stale uuid.
|
||||
present = _target_present_in_chain(
|
||||
target_uuid, modules, catalog,
|
||||
)
|
||||
if present:
|
||||
# Target IS in the chain but the selector covered no
|
||||
# firing occurrence — every appearance resolved
|
||||
# BEFORE this constraint registered (e.g. a source
|
||||
# wildcard that nests its own @{target}), or lives in
|
||||
# a branch / Context this run didn't execute, or is
|
||||
# disabled. Deliberately NOT prescribing "move the
|
||||
# constraint up" — that's wrong when the target's
|
||||
# carrier is also the constraint's source (the source
|
||||
# must stay upstream of the constraint).
|
||||
message = (
|
||||
f"constraint @{{{library_cid}{cid_suffix}}} did not apply — its target "
|
||||
f"@{{{target_uuid}{tgt_suffix}}} is in the chain but every appearance "
|
||||
f"resolved before this constraint registered, or is "
|
||||
f"disabled / in a branch this run skipped."
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"constraint @{{{library_cid}{cid_suffix}}} did not apply — "
|
||||
f"no @{{{target_uuid}{tgt_suffix}}} wildcard instance or "
|
||||
f"nested-ref carrier found in this chain "
|
||||
f"(check the constraint's target uuid)."
|
||||
)
|
||||
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
|
||||
if isinstance(warnings_bucket, list):
|
||||
warnings_bucket.append({
|
||||
"type": "constraint_never_applied",
|
||||
# `warn` (not `info`): an authored constraint that
|
||||
# silently never fires is almost always either a
|
||||
# misconfiguration (wrong target uuid, missing
|
||||
# source/target wildcard in the chain) or a
|
||||
# disabled / dead branch the user forgot about.
|
||||
# Either way it deserves the yellow advisory chip,
|
||||
# not the blue informational one — the constraint
|
||||
# is doing nothing, and the user should see it.
|
||||
"severity": "warn",
|
||||
"module_id": cid,
|
||||
"source_field": "",
|
||||
"position": 0,
|
||||
"token_index": None,
|
||||
"detail": {
|
||||
"constraint_id": cid,
|
||||
"target_wildcard_id": target_uuid,
|
||||
"source_wildcard_id": c.get("source_wildcard_id", ""),
|
||||
# `target_present` lets the DebugViewer pick a
|
||||
# distinct icon/severity later without re-parsing
|
||||
# the message string.
|
||||
"target_present": present,
|
||||
},
|
||||
# Ids wrapped as `@{uuid}` so the DebugViewer's
|
||||
# RichTextPreview resolves them to display names
|
||||
# via the library cache (raw short-uuids would
|
||||
# otherwise render as plain text).
|
||||
"message": message,
|
||||
})
|
||||
|
||||
# SP3 partial-reach finalisation: a constraint that DID fire but
|
||||
# whose `next N` / `pick` selector matched FEWER occurrences than
|
||||
# requested. Distinct from never_applied (which fired zero times)
|
||||
# — this is "the selector asked for more than the chain offered
|
||||
# downstream". `all` / `first` can't be partial. Deduped by cid,
|
||||
# same as never_applied.
|
||||
partial_warned: set[str] = set()
|
||||
if isinstance(constraints_bucket, list):
|
||||
for c in constraints_bucket:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
cid = c.get("__constraint_module_id__")
|
||||
seen = hits.get(cid, 0) if cid else 0
|
||||
if not cid or seen == 0 or cid in partial_warned:
|
||||
continue
|
||||
sel = c.get("target_select") or {"mode": "all"}
|
||||
mode = sel.get("mode", "all")
|
||||
if mode == "next":
|
||||
try:
|
||||
want = int(sel.get("count", 1))
|
||||
except (TypeError, ValueError):
|
||||
want = 1
|
||||
elif mode == "pick":
|
||||
want = len(sel.get("picks") or [])
|
||||
else:
|
||||
continue
|
||||
# LIMITATION (holistic-review L1): `seen` is the per-cid
|
||||
# TOTAL target-encounter count (`hits[cid]`, bumped on
|
||||
# every firing in apply_constraints_for_target), NOT the
|
||||
# count of picks that actually MATCHED. So a `pick`
|
||||
# partial signal is imprecise: it compares encounters to
|
||||
# `len(picks)` rather than matched-vs-listed. A precise
|
||||
# count would need a separate ctx-threaded matched-pick
|
||||
# counter (new bookkeeping across the _constraints apply
|
||||
# boundary) — deferred to avoid over-plumbing. `next` is
|
||||
# exact (it covers the first N encounters by construction).
|
||||
if seen >= want:
|
||||
continue
|
||||
partial_warned.add(cid)
|
||||
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
|
||||
if isinstance(warnings_bucket, list):
|
||||
warnings_bucket.append({
|
||||
# Spec: partial reach is INFO, not warn — the
|
||||
# selector simply found fewer downstream targets
|
||||
# than requested this run (not an error condition).
|
||||
# `constraint_never_applied` stays `warn` (a
|
||||
# constraint that fired zero times is worth a louder
|
||||
# signal). DebugViewer maps `info` → blue/accent.
|
||||
"type": "constraint_partial_reach",
|
||||
"severity": "info",
|
||||
"module_id": cid,
|
||||
"source_field": "",
|
||||
"position": 0,
|
||||
"token_index": None,
|
||||
"detail": {
|
||||
"constraint_id": cid,
|
||||
"target_wildcard_id": c.get("target_wildcard_id"),
|
||||
"requested": want,
|
||||
"reached": seen,
|
||||
},
|
||||
"message": (
|
||||
f"constraint reach selector ({mode}) asked for "
|
||||
f"{want} target instance(s) but only {seen} "
|
||||
f"resolved downstream this run."
|
||||
),
|
||||
})
|
||||
|
||||
return ctx
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
"""Type-only validation schema for instance overrides per kind.
|
||||
|
||||
Mirrors `src/components/context/editors/_shell.ts:INSTANCE_FIELDS_PER_KIND`
|
||||
on the engine side. Extending field set: add to BOTH this schema AND the
|
||||
TS registry. Cross-language parity test (`tests/test_instance_schema_parity.py`)
|
||||
asserts both sides have identical kind→field mappings.
|
||||
|
||||
See: docs/superpowers/specs/2026-05-07-instance-overrides-modal-design.md §7.6
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
InstanceFieldType = Literal[
|
||||
"string", "list[string]", "dict[string,number]", "dict[string,dict]",
|
||||
"dict[string,string]",
|
||||
# Generic object — used by SP3 `target_select`, whose interior shape is
|
||||
# validated by the constraint handler rather than this type-only schema.
|
||||
"dict",
|
||||
"boolean", "number", "list[dict]",
|
||||
]
|
||||
|
||||
INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
"wildcard": {
|
||||
"variable_binding": "string",
|
||||
"enabled_options": "list[string]",
|
||||
"option_weights": "dict[string,number]",
|
||||
# `category_filter` is a boolean sub-category expression string
|
||||
# (SP1). The separate `exclude_null` instance flag is declared
|
||||
# here + in the TS `_shell.ts` twin together in the SP1 UI chunk
|
||||
# (keeps the parity guard green); the engine resolver already
|
||||
# reads it directly.
|
||||
"category_filter": "string",
|
||||
"exclude_null": "boolean",
|
||||
"locked_seed": "number",
|
||||
"internal": "boolean",
|
||||
# SP2a multi-select: per-instance count range (pick_min..pick_max; a
|
||||
# range other than 1..1 resolves N options without replacement into a
|
||||
# list-valued variable) + the separator used to join it for a bare
|
||||
# `$var`. Instance-local: NOT part of a standalone module's published
|
||||
# payload — but bundle children freeze the whole instance as a
|
||||
# snapshot, so a published bundle DOES carry them (intentional
|
||||
# frozen-snapshot fidelity).
|
||||
"pick_min": "number",
|
||||
"pick_max": "number",
|
||||
"pick_separator": "string",
|
||||
# SP2c: multi-pick draws WITH replacement (repeats allowed) when true,
|
||||
# without (unique) when false/absent. Mirrors the inline `~` flag.
|
||||
"pick_independent": "boolean",
|
||||
# `mode` and `pinned_option_id` removed in v2 — resolve mode is
|
||||
# implicit in pool state. Engine handler still reads them when
|
||||
# present in legacy snapshots; the schema validator now flags
|
||||
# them as unknown fields with an advisory warning.
|
||||
},
|
||||
"fixed_values": {
|
||||
"values_overrides": "list[dict]",
|
||||
"enabled_options": "list[string]",
|
||||
# Added in 2026-05-08 syntax-parity cycle: per-value `{a|b|c}`
|
||||
# resolution gives fixed_values seed-lock parity with wildcard
|
||||
# + combine. Locked seed pins the alternation outcome.
|
||||
"locked_seed": "number",
|
||||
},
|
||||
"combine": {
|
||||
# v2 single-pane modal exposes a richer override surface:
|
||||
# - template_override: per-instance template string
|
||||
# - variable_binding: rebind the produced var (mirrors wildcard)
|
||||
# - locked_seed: pin {a|b|c} resolution
|
||||
# - internal: hide-from-prompt toggle
|
||||
"template_override": "string",
|
||||
"variable_binding": "string",
|
||||
"locked_seed": "number",
|
||||
"internal": "boolean",
|
||||
},
|
||||
"derivation": {
|
||||
"disabled_rule_ids": "list[string]",
|
||||
# Tier-D modal expansion (2026-05-10 cycle):
|
||||
# - disabled_branch_keys: list of "{rule_id}:{branch_idx}" or
|
||||
# "{rule_id}:else" entries the engine skips during evaluation
|
||||
# - action_value_overrides / condition_value_overrides:
|
||||
# per-rule per-branch value swaps (engine reads override
|
||||
# before payload value)
|
||||
# - rule_order_override: list of rule_ids in evaluation order
|
||||
# - locked_seed + internal: standard runtime fields the
|
||||
# pipeline already honored on other kinds; surfaced here so
|
||||
# RESET_FIELDS_PER_KIND + INSTANCE_FIELDS_PER_KIND keep
|
||||
# parity with what the modal writes.
|
||||
"disabled_branch_keys": "list[string]",
|
||||
"action_value_overrides": "dict[string,dict]",
|
||||
"condition_value_overrides": "dict[string,dict]",
|
||||
"rule_order_override": "list[string]",
|
||||
"locked_seed": "number",
|
||||
"internal": "boolean",
|
||||
},
|
||||
"constraint": {
|
||||
"disabled_exception_keys": "list[string]",
|
||||
"disabled_matrix_cells": "list[string]",
|
||||
# Tier-D modal expansion (2026-05-10 cycle):
|
||||
# - cell_mode_overrides / cell_factor_overrides: per-cell
|
||||
# value overrides keyed by encode_key([src_subcat, tgt_subcat]).
|
||||
# Sparse — only override keys present in the dict.
|
||||
# - exception_mode_overrides / exception_factor_overrides:
|
||||
# same shape, keyed by encode_key([source_value, target_value]).
|
||||
# - extra_exceptions: instance-only exception list, appended
|
||||
# to the library exceptions in resolve. Never written back
|
||||
# to library.
|
||||
"cell_mode_overrides": "dict[string,string]",
|
||||
"cell_factor_overrides": "dict[string,number]",
|
||||
"exception_mode_overrides": "dict[string,string]",
|
||||
"exception_factor_overrides": "dict[string,number]",
|
||||
"extra_exceptions": "list[dict]",
|
||||
# SP3 reach selector override. Interior shape (mode/count/picks) is
|
||||
# validated by ConstraintHandler; here it is type-only ("is a dict").
|
||||
"target_select": "dict",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _matches_type(value: Any, spec: InstanceFieldType) -> bool:
|
||||
if spec == "string":
|
||||
return isinstance(value, str)
|
||||
if spec == "boolean":
|
||||
return isinstance(value, bool)
|
||||
if spec == "number":
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
if spec == "list[string]":
|
||||
return isinstance(value, list) and all(isinstance(x, str) for x in value)
|
||||
if spec == "list[dict]":
|
||||
return isinstance(value, list) and all(isinstance(x, dict) for x in value)
|
||||
if spec == "dict":
|
||||
return isinstance(value, dict)
|
||||
if spec == "dict[string,number]":
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and all(isinstance(k, str) for k in value.keys())
|
||||
and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in value.values())
|
||||
)
|
||||
if spec == "dict[string,dict]":
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and all(isinstance(k, str) for k in value.keys())
|
||||
and all(isinstance(v, dict) for v in value.values())
|
||||
)
|
||||
if spec == "dict[string,string]":
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and all(isinstance(k, str) for k in value.keys())
|
||||
and all(isinstance(v, str) for v in value.values())
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def validate_instance(kind: str, instance: dict[str, Any]) -> list[str]:
|
||||
"""Returns list of warnings (empty = clean). Does NOT raise — disabled-malformed
|
||||
items surface as warnings per spec §7.3/§7.4 design. Caller decides escalation."""
|
||||
schema = INSTANCE_SCHEMAS.get(kind, {})
|
||||
warnings: list[str] = []
|
||||
for field, value in instance.items():
|
||||
if field == "_ui" or field.startswith("__"):
|
||||
continue # UI scratch / engine-internal namespace ignored
|
||||
spec = schema.get(field)
|
||||
if spec is None:
|
||||
warnings.append(f"unknown instance field: {kind}.{field}")
|
||||
continue
|
||||
if value is None:
|
||||
continue # null = no override, always valid
|
||||
if not _matches_type(value, spec):
|
||||
warnings.append(
|
||||
f"{kind}.{field} type mismatch: expected {spec}, got {type(value).__name__}"
|
||||
)
|
||||
# SP2a advisory soft-warns for the pick range (not type errors).
|
||||
if kind == "wildcard":
|
||||
pmin = instance.get("pick_min")
|
||||
pmax = instance.get("pick_max")
|
||||
nums = (int, float)
|
||||
if (isinstance(pmin, nums) and not isinstance(pmin, bool)
|
||||
and isinstance(pmax, nums) and not isinstance(pmax, bool)):
|
||||
if pmin == 0 and pmax == 0:
|
||||
warnings.append("wildcard pick range 0..0 — always empty (resolves to nothing)")
|
||||
elif pmax < pmin:
|
||||
warnings.append(
|
||||
f"wildcard.pick_max ({pmax}) is below pick_min ({pmin}) — clamped to pick_max"
|
||||
)
|
||||
return warnings
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Per-iteration seed derivation for WP_ContextLoop.
|
||||
|
||||
`derive_loop_seeds` produces the N base seeds emitted by ContextLoop.
|
||||
`effective_chain_seed` is what the WP_Context wrapper stamps as
|
||||
`__wp_node_seed__` for each iteration — locked-seed precedence happens
|
||||
LATER inside per-module handlers, which is why locked modules ignore
|
||||
the loop automatically (they never read __wp_node_seed__).
|
||||
|
||||
Pure Python — zero ComfyUI imports.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
MAX_SAFE_SEED = (1 << 50) - 1
|
||||
"""1_125_899_906_842_623 — matches ComfyUI's frontend randomize cap
|
||||
(Math.random() * 1125899906842624) so emitted seeds stay copy-pasteable +
|
||||
visually consistent with stock seed nodes. KSampler accepts up to 64-bit,
|
||||
but anything past 2^50 falls outside the canvas's typical seed shape and
|
||||
is unsightly."""
|
||||
|
||||
|
||||
def derive_loop_seeds(base: int, count: int, strategy: str) -> list[int]:
|
||||
"""Produce `count` base seeds from `base` using the given strategy.
|
||||
|
||||
Every emitted seed is masked to ``MAX_SAFE_SEED`` (50 bits) so the
|
||||
values stay within ComfyUI's frontend randomize range — see the
|
||||
module docstring for the rationale.
|
||||
"""
|
||||
if count <= 1:
|
||||
return [base & MAX_SAFE_SEED]
|
||||
if strategy == "sequential":
|
||||
return [(base + i) & MAX_SAFE_SEED for i in range(count)]
|
||||
if strategy == "prime_stride":
|
||||
return [(base + i * 1000003) & MAX_SAFE_SEED for i in range(count)]
|
||||
if strategy == "hash_index":
|
||||
out: list[int] = []
|
||||
for i in range(count):
|
||||
digest = hashlib.sha256(f"{base}:{i}".encode()).digest()
|
||||
out.append(int.from_bytes(digest[:8], "big") & MAX_SAFE_SEED)
|
||||
return out
|
||||
raise ValueError(f"unknown loop seed strategy: {strategy!r}")
|
||||
|
||||
|
||||
def effective_chain_seed(
|
||||
*,
|
||||
widget_seed: int,
|
||||
seed_override: int | None,
|
||||
loop_index: int,
|
||||
) -> int:
|
||||
"""Compute the chain seed for THIS iteration before module handlers run.
|
||||
|
||||
Precedence:
|
||||
1. ``seed_override`` (from ContextLoop) replaces ``widget_seed`` as
|
||||
base when present.
|
||||
2. Otherwise ``widget_seed`` is the base.
|
||||
3. ``loop_index`` is mixed via XOR with a stable hash. ``loop_index=0``
|
||||
→ no-op (backwards-compat for chains without ContextLoop).
|
||||
|
||||
Locked-module precedence lives in the per-module handlers, NOT here.
|
||||
Locked modules read ``instance.locked_seed`` directly and ignore the
|
||||
chain seed — so loop iteration cannot affect them.
|
||||
|
||||
Returns a non-negative seed masked to ``MAX_SAFE_SEED`` (50 bits) —
|
||||
matches the visual shape of ComfyUI's frontend-randomised seeds.
|
||||
"""
|
||||
base = seed_override if seed_override is not None else widget_seed
|
||||
if loop_index == 0:
|
||||
return base & MAX_SAFE_SEED
|
||||
digest = hashlib.sha256(f"loop:{loop_index}".encode()).digest()
|
||||
shift = int.from_bytes(digest[:8], "big")
|
||||
return (base ^ shift) & MAX_SAFE_SEED
|
||||
+53
-293
@@ -8,8 +8,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from engine.syntax.subcat_filter import matches as _subcat_matches
|
||||
from engine.syntax.subcat_filter import parse as _parse_subcat
|
||||
from engine.syntax.tokenize import tokenize_text
|
||||
from engine.syntax.types import (
|
||||
CycleDetectedError,
|
||||
@@ -19,7 +17,6 @@ from engine.syntax.types import (
|
||||
Token,
|
||||
TokenKind,
|
||||
UnknownRefError,
|
||||
deref_var_value,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -65,64 +62,19 @@ def _resolve_tokens(
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
_VAR_SURFACES_ALLOWED = frozenset(["combine", "derivation", "assembler"])
|
||||
|
||||
|
||||
def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
|
||||
"""Look up a variable in ctx. Returns empty string for missing or
|
||||
internal-key vars. Missing vars also emit an `unknown_var` warning so
|
||||
the user gets a signal when a Combine / Derivation / Constraint
|
||||
template references a variable not bound by any upstream module.
|
||||
|
||||
Internal-`__`-prefixed keys are never substituted and never warn —
|
||||
they're engine bookkeeping (`__wp_rng__`, `__wp_catalog__`, etc.).
|
||||
|
||||
Surface-gated: only combine/derivation/assembler surfaces support
|
||||
$var reads. Wildcard + fixed_values surfaces are binding PRODUCERS
|
||||
(not consumers); $var reads there warn + render literal in lenient
|
||||
mode, raise VarOutOfSurfaceError in strict mode."""
|
||||
"""Look up a variable in ctx. Returns empty string for missing or internal-key vars."""
|
||||
name = tok.meta.get("name", "")
|
||||
if name.startswith("__"):
|
||||
# Engine-internal keys are never substituted.
|
||||
return ""
|
||||
if ctx.surface not in _VAR_SURFACES_ALLOWED:
|
||||
from engine.syntax.types import VarOutOfSurfaceError
|
||||
if ctx.strict:
|
||||
raise VarOutOfSurfaceError(name, ctx.surface)
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="var_out_of_surface",
|
||||
severity="warn",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={"name": name, "surface": ctx.surface},
|
||||
message=(
|
||||
f"${name} ignored in {ctx.surface!r} surface — only "
|
||||
f"combine / derivation / assembler surfaces support $var reads"
|
||||
),
|
||||
)
|
||||
return f"${name}"
|
||||
value = ctx.get_var(name)
|
||||
if value is None:
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="unknown_var",
|
||||
severity="warn",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={"name": name, "surface": ctx.surface},
|
||||
message=f"Unknown variable ${name}",
|
||||
)
|
||||
# TODO(task-9-onward): push warning when unknown var? Spec doesn't
|
||||
# explicitly call out unknown vars as warnings; they emit "" silently.
|
||||
# Revisit if user feedback shows confusion.
|
||||
return ""
|
||||
# SP2a list accessor: `$name` joins a ListVar with its separator;
|
||||
# `$name.K` indexes (0-based, out-of-range -> ""). A plain string behaves
|
||||
# as a 1-element list so `$str.0` == `$str` and `$str.1` == "". The
|
||||
# accessor + ListVar-fold contract lives in deref_var_value
|
||||
# (engine/syntax/types.py); the derivation + converter reads share it.
|
||||
return deref_var_value(value, tok.meta.get("index"))
|
||||
return str(value)
|
||||
|
||||
|
||||
def _push_warning(ctx: ResolveContext, **fields) -> None:
|
||||
@@ -157,15 +109,6 @@ def _resolve_inline_pick(
|
||||
visited: tuple[str, ...],
|
||||
) -> str:
|
||||
"""Pick one branch uniformly at random; recursively resolve its content."""
|
||||
# Assembler surface is seedless — its RNG always rolls from seed 0, so
|
||||
# an inline pick there would freeze on the same branch every run. That's
|
||||
# misleading, so render the source verbatim instead and let the user
|
||||
# produce the random value in a seeded module (Wildcard / Combine /
|
||||
# Derivation inside a WP Context) and reference its $var here. Other
|
||||
# surfaces (wildcard / combine / derivation) run inside a seeded Context
|
||||
# and keep resolving normally.
|
||||
if ctx.surface == "assembler":
|
||||
return tok.raw
|
||||
branches: list[str] = tok.meta.get("branches", [])
|
||||
if not branches:
|
||||
return ""
|
||||
@@ -217,120 +160,49 @@ def _weighted_pick_index(weights: list[float], rng) -> int:
|
||||
return len(weights) - 1
|
||||
|
||||
|
||||
def pick_k_unique(items, weights, k, rng):
|
||||
"""Pick ``k`` items from ``items`` without replacement, weighted by the
|
||||
parallel ``weights`` list, returned in pick order. ``k`` is clamped to
|
||||
``len(items)``; ``k <= 0`` -> ``[]``.
|
||||
|
||||
Shared by the inline ``{N$$sep$$}`` block (``_resolve_multi_pick``) and by
|
||||
multi-select wildcards (``wildcard_handler``). Draws from ``rng`` in the
|
||||
same order the inline block always has, so seed behavior is unchanged.
|
||||
"""
|
||||
n = min(max(0, k), len(items))
|
||||
available_idx = list(range(len(items)))
|
||||
available_w = list(weights)
|
||||
chosen = []
|
||||
for _ in range(n):
|
||||
if not available_idx:
|
||||
break
|
||||
j = _weighted_pick_index(available_w, rng)
|
||||
chosen.append(items[available_idx.pop(j)])
|
||||
available_w.pop(j)
|
||||
return chosen
|
||||
|
||||
|
||||
def _resolve_multi_pick(
|
||||
tok: Token,
|
||||
ctx: ResolveContext,
|
||||
depth: int,
|
||||
visited: tuple[str, ...],
|
||||
) -> str:
|
||||
"""Resolve a multi-pick block, joined by `sep`.
|
||||
"""Pick `count` branches without replacement, weighted, joined by `sep`.
|
||||
|
||||
SP2b: `count` is a range `[min, max]` (a fixed `{N$$}` has min==max), and
|
||||
`independent` (the `~` flag) toggles unique-without-replacement (default)
|
||||
vs N draws WITH replacement. A lone nested-ref body in unique mode draws N
|
||||
DISTINCT options from the ref's filtered pool (a per-site SP2a). Each chosen
|
||||
value is recursively resolved.
|
||||
Each chosen branch is recursively resolved.
|
||||
"""
|
||||
# Seedless assembler surface — see _resolve_inline_pick. Render verbatim
|
||||
# rather than freezing a deterministic seed-0 multi-pick.
|
||||
if ctx.surface == "assembler":
|
||||
return tok.raw
|
||||
cmin: int = tok.meta.get("min", tok.meta.get("count", 0))
|
||||
cmax: int = tok.meta.get("max", tok.meta.get("count", 0))
|
||||
independent: bool = bool(tok.meta.get("independent", False))
|
||||
count: int = tok.meta.get("count", 0)
|
||||
sep: str = tok.meta.get("sep", "")
|
||||
branches: list[str] = list(tok.meta.get("branches", []))
|
||||
|
||||
# Range count: a fixed N (min==max) does NOT draw from rng, so a `{N$$}`
|
||||
# reproduces the pre-SP2b seed sequence byte-for-byte. A true range draws.
|
||||
count = cmin if cmin == cmax else ctx.rng.randint(cmin, cmax)
|
||||
if count <= 0:
|
||||
return ""
|
||||
|
||||
# Lone nested-ref body, unique mode, count>=2, wildcard surface → pick N
|
||||
# DISTINCT options from the ref's filtered pool (per-site SP2a multi-select).
|
||||
# count==1 stays on the legacy branch path below so its seed is unchanged.
|
||||
if not independent and count >= 2 and len(branches) == 1 and ctx.surface == "wildcard":
|
||||
bt = tokenize_text(branches[0])
|
||||
if len(bt) == 1 and bt[0].kind == TokenKind.REF:
|
||||
ref_tok = bt[0]
|
||||
ref_uuid = ref_tok.meta.get("uuid", "")
|
||||
pool = ref_option_pool(ref_tok, ctx)
|
||||
if not pool:
|
||||
return ""
|
||||
values = [str(o.get("value", "")) for o in pool]
|
||||
weights = [float(o.get("weight", 1) or 1) for o in pool]
|
||||
chosen = pick_k_unique(values, weights, count, ctx.rng)
|
||||
# The multi-picked ref is the carrier of any `@{X}` inside the
|
||||
# values it resolves. We pick on VALUES (not option dicts) so a
|
||||
# specific option id isn't tracked here — set carrier to
|
||||
# (ref_uuid, None) for the recursion. option_id None can't match
|
||||
# a real `pick` entry, so a deeper `@{X}` falls back to
|
||||
# positional first/next/all (design: multi-pick children aren't
|
||||
# individually pickable). Restore the enclosing carrier after.
|
||||
get_carrier = getattr(ctx, "get_carrier", None)
|
||||
set_carrier = getattr(ctx, "set_carrier", None)
|
||||
saved_carrier = (
|
||||
get_carrier() if callable(get_carrier) else None
|
||||
)
|
||||
if callable(set_carrier):
|
||||
set_carrier(ref_uuid, None)
|
||||
try:
|
||||
return sep.join(
|
||||
_resolve_tokens(
|
||||
tokenize_text(v), ctx, depth=depth + 1,
|
||||
visited=visited + (ref_uuid,),
|
||||
) if v else ""
|
||||
for v in chosen
|
||||
)
|
||||
finally:
|
||||
if callable(set_carrier) and saved_carrier is not None:
|
||||
set_carrier(*saved_carrier)
|
||||
|
||||
weights = [_parse_branch_weight(b) for b in branches]
|
||||
branch_values = [_strip_branch_weight(b) for b in branches]
|
||||
resolved: list[str] = []
|
||||
|
||||
if independent:
|
||||
# N draws WITH replacement over the branches; each resolved
|
||||
# independently (a lone ref re-rolls every time → variety from nesting).
|
||||
for _ in range(count):
|
||||
j = _weighted_pick_index(weights, ctx.rng)
|
||||
v = branch_values[j]
|
||||
resolved.append(
|
||||
_resolve_tokens(tokenize_text(v), ctx, depth=depth, visited=visited)
|
||||
if v else ""
|
||||
)
|
||||
return sep.join(resolved)
|
||||
|
||||
# Unique over branches (legacy path, now range-aware): N distinct branches.
|
||||
# Clamp count to available branches (without-replacement constraint)
|
||||
n_picks = min(count, len(branches))
|
||||
if n_picks == 0:
|
||||
return ""
|
||||
chosen_values = pick_k_unique(branch_values, weights, n_picks, ctx.rng)
|
||||
for branch_text in chosen_values:
|
||||
|
||||
# Weighted without-replacement: branch weights default to 1; if a branch
|
||||
# uses the `N::value` form (a feature of the inline-pick weight micro-syntax),
|
||||
# the weight is parsed from the prefix. For simplicity, this implementation
|
||||
# treats all branches as weight=1 unless they begin with `<int>::`.
|
||||
weights = [_parse_branch_weight(b) for b in branches]
|
||||
branch_values = [_strip_branch_weight(b) for b in branches]
|
||||
|
||||
chosen_indices: list[int] = []
|
||||
available_indices = list(range(len(branches)))
|
||||
available_weights = list(weights)
|
||||
|
||||
for _ in range(n_picks):
|
||||
if not available_indices:
|
||||
break
|
||||
idx = _weighted_pick_index(available_weights, ctx.rng)
|
||||
chosen_indices.append(available_indices.pop(idx))
|
||||
available_weights.pop(idx)
|
||||
|
||||
resolved: list[str] = []
|
||||
for idx in chosen_indices:
|
||||
branch_text = branch_values[idx]
|
||||
if not branch_text:
|
||||
resolved.append("")
|
||||
continue
|
||||
@@ -340,111 +212,6 @@ def _resolve_multi_pick(
|
||||
return sep.join(resolved)
|
||||
|
||||
|
||||
def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
|
||||
"""Filtered + constrained option pool a ``@{uuid[#name][:expr][!null]}`` ref
|
||||
resolves against — catalog lookup, the ``:expr``/``!null`` sub-category
|
||||
filter, and chain constraints. Shared by ``_resolve_ref`` (single weighted
|
||||
pick) and the SP2b lone-ref multi-pick path.
|
||||
|
||||
Returns ``[]`` (pushing the same ``unknown_ref`` /
|
||||
``ref_subcategory_empty_pool`` warnings, and raising ``UnknownRefError`` in
|
||||
strict mode for an unknown ref) when there is nothing to pick. Does NOT
|
||||
apply the depth / cycle / surface guards — those stay in ``_resolve_ref``
|
||||
and the multi-pick caller.
|
||||
"""
|
||||
uuid = tok.meta.get("uuid", "")
|
||||
module = ctx.get_module(uuid)
|
||||
if module is None:
|
||||
if ctx.strict:
|
||||
raise UnknownRefError(uuid)
|
||||
cached_name = tok.meta.get("name") if hasattr(tok, "meta") else None
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="unknown_ref",
|
||||
severity="warn",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={"uuid": uuid, "name": cached_name},
|
||||
message=f"Unknown wildcard ref @{{{uuid}{'#' + cached_name if cached_name else ''}}}",
|
||||
)
|
||||
return []
|
||||
|
||||
payload_dict = module.get("payload")
|
||||
if isinstance(payload_dict, dict):
|
||||
options = list(payload_dict.get("options", []))
|
||||
else:
|
||||
options = list(module.get("options", []))
|
||||
|
||||
filter_expr = tok.meta.get("filter_expr")
|
||||
exclude_null = bool(tok.meta.get("exclude_null"))
|
||||
if (isinstance(filter_expr, str) and filter_expr.strip()) or exclude_null:
|
||||
ast = _parse_subcat(filter_expr) if isinstance(filter_expr, str) else None
|
||||
options = [
|
||||
o for o in options
|
||||
if isinstance(o, dict)
|
||||
and (
|
||||
(o.get("is_null") and not exclude_null)
|
||||
or (
|
||||
not o.get("is_null")
|
||||
and _subcat_matches(ast, set(o.get("sub_categories") or []))
|
||||
)
|
||||
)
|
||||
]
|
||||
if not options:
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="ref_subcategory_empty_pool",
|
||||
severity="warn",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={
|
||||
"uuid": uuid,
|
||||
"filter_expr": filter_expr,
|
||||
"exclude_null": exclude_null,
|
||||
},
|
||||
message=(
|
||||
f"@{{{uuid}:{filter_expr or ''}"
|
||||
f"{'!null' if exclude_null else ''}}} matched no options"
|
||||
),
|
||||
)
|
||||
return []
|
||||
|
||||
get_constraints = getattr(ctx, "get_constraints", None)
|
||||
get_picks = getattr(ctx, "get_picks", None)
|
||||
if callable(get_constraints) and callable(get_picks):
|
||||
constraints = get_constraints()
|
||||
if constraints:
|
||||
from engine.modules._constraints import (
|
||||
apply_constraints_for_target,
|
||||
warn_excludes_all,
|
||||
)
|
||||
# SP3: share the ctx-resident hit counter so a nested-ref
|
||||
# target occurrence counts toward the same first/next
|
||||
# coverage as direct top-level instances. `carrier_ctx` is
|
||||
# the identity of the carrier wildcard whose chosen option
|
||||
# value textually contains THIS `@{uuid}` ref — set on the
|
||||
# resolve frame by the enclosing wildcard handler (top-level
|
||||
# carrier) or by `_resolve_ref` (deeper carrier). A `pick`
|
||||
# selector's `nested` occurrence matches on it; first / next
|
||||
# / all ignore it. None when no carrier is set (a ref at the
|
||||
# surface root) — then `pick` simply won't cover.
|
||||
get_hits = getattr(ctx, "get_constraint_hits", None)
|
||||
hits = get_hits() if callable(get_hits) else {}
|
||||
get_carrier_ctx = getattr(ctx, "get_carrier_ctx", None)
|
||||
carrier_ctx = get_carrier_ctx() if callable(get_carrier_ctx) else None
|
||||
options, any_applied = apply_constraints_for_target(
|
||||
options, uuid, constraints, get_picks(), ctx.warnings,
|
||||
hits=hits, firing_uid=None, carrier_ctx=carrier_ctx,
|
||||
)
|
||||
if any_applied:
|
||||
warn_excludes_all(options, uuid, ctx.warnings)
|
||||
return options
|
||||
|
||||
|
||||
def _resolve_ref(
|
||||
tok: Token,
|
||||
ctx: ResolveContext,
|
||||
@@ -492,10 +259,8 @@ def _resolve_ref(
|
||||
)
|
||||
return ""
|
||||
|
||||
# Surface check — wildcard options AND derivation action values may host
|
||||
# `@{}` refs (both run resolve_text inside a seeded Context with the chain
|
||||
# constraint bucket threaded). combine + assembler stay gated.
|
||||
if ctx.surface not in ("wildcard", "derivation"):
|
||||
# Surface check
|
||||
if ctx.surface != "wildcard":
|
||||
if ctx.strict:
|
||||
raise RefOutOfSurfaceError(uuid, ctx.surface)
|
||||
# Try to resolve module name for the warning detail (best-effort)
|
||||
@@ -514,13 +279,26 @@ def _resolve_ref(
|
||||
)
|
||||
return ""
|
||||
|
||||
# Catalog lookup + `:expr`/`!null` filter + chain constraints, factored
|
||||
# into ref_option_pool (shared with the SP2b lone-ref multi-pick path).
|
||||
# The strict-unknown raise + the unknown / empty-pool warnings live there.
|
||||
options = ref_option_pool(tok, ctx)
|
||||
if not options:
|
||||
# Catalog lookup
|
||||
module = ctx.get_module(uuid)
|
||||
if module is None:
|
||||
if ctx.strict:
|
||||
raise UnknownRefError(uuid)
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="unknown_ref",
|
||||
severity="warn",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={"uuid": uuid, "name": None},
|
||||
message=f"Unknown wildcard ref @{{{uuid}}}",
|
||||
)
|
||||
return ""
|
||||
|
||||
# Pick weighted option
|
||||
options = module.get("options", [])
|
||||
chosen = _pick_weighted(options, ctx.rng)
|
||||
if chosen is None:
|
||||
return ""
|
||||
@@ -529,24 +307,6 @@ def _resolve_ref(
|
||||
if not chosen_value:
|
||||
return ""
|
||||
|
||||
# Recursive resolve with depth+1, visited extended. This ref'd
|
||||
# wildcard is the carrier of any `@{X}` inside its OWN chosen option
|
||||
# value, so stamp (its uuid, the chosen option's id) on the frame for
|
||||
# the duration of the recursion, then restore the prior carrier. The
|
||||
# carrier_uid here is the library uuid (a ref'd wildcard has no row
|
||||
# `_uid`); a `pick` keyed on a top-level row uid won't match it —
|
||||
# that's the expected deeper-than-one-hop behaviour (positional
|
||||
# first/next/all cover deeper nesting). Mirrors the rng save/restore.
|
||||
# Recursive resolve with depth+1, visited extended
|
||||
nested_tokens = tokenize_text(chosen_value)
|
||||
get_carrier = getattr(ctx, "get_carrier", None)
|
||||
set_carrier = getattr(ctx, "set_carrier", None)
|
||||
if callable(get_carrier) and callable(set_carrier):
|
||||
saved_carrier = get_carrier()
|
||||
set_carrier(uuid, chosen.get("id"))
|
||||
try:
|
||||
return _resolve_tokens(
|
||||
nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,)
|
||||
)
|
||||
finally:
|
||||
set_carrier(*saved_carrier)
|
||||
return _resolve_tokens(nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,))
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""Boolean sub-category filter — parser, matcher, normalizer, validators.
|
||||
|
||||
Grammar (precedence not > and > or; or/and are n-ary, flattened):
|
||||
expr := or
|
||||
or := and ( ("or" | ",") and )*
|
||||
and := unary ( "and" unary )*
|
||||
unary := "not" unary | atom
|
||||
atom := SUBCAT | "(" or ")"
|
||||
|
||||
Mirror: src/manager/parsing/subcatFilter.ts — keep behavior identical
|
||||
(shared fixture in tests/fixtures/subcat_filter_cases.json).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
RESERVED = {"and", "or", "not", "null"}
|
||||
# A sub-category NAME is one safe token (see validate_subcat_name).
|
||||
_NAME_RE = re.compile(r"^[^\s()!,#:}@{$]+$")
|
||||
_TOKEN_RE = re.compile(r"\s*(\(|\)|,|[^\s()!,#:}@{$]+)")
|
||||
|
||||
|
||||
class ParseError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _tokenize(s: str) -> list[str]:
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(s):
|
||||
m = _TOKEN_RE.match(s, i)
|
||||
if not m:
|
||||
if s[i:].strip() == "": # only trailing whitespace left
|
||||
break
|
||||
raise ParseError(f"unexpected character {s[i]!r}")
|
||||
out.append(m.group(1))
|
||||
i = m.end()
|
||||
return out
|
||||
|
||||
|
||||
def parse(s: str) -> dict[str, Any] | None:
|
||||
"""Parse an expression string to an AST, or None when empty."""
|
||||
toks = _tokenize(s)
|
||||
if not toks:
|
||||
return None
|
||||
pos = 0
|
||||
|
||||
def peek() -> str | None:
|
||||
return toks[pos] if pos < len(toks) else None
|
||||
|
||||
def eat() -> str:
|
||||
nonlocal pos
|
||||
t = toks[pos]
|
||||
pos += 1
|
||||
return t
|
||||
|
||||
def p_or() -> dict[str, Any]:
|
||||
kids = [p_and()]
|
||||
while peek() in ("or", ","):
|
||||
eat()
|
||||
kids.append(p_and())
|
||||
return kids[0] if len(kids) == 1 else {"op": "or", "kids": kids}
|
||||
|
||||
def p_and() -> dict[str, Any]:
|
||||
kids = [p_unary()]
|
||||
while peek() == "and":
|
||||
eat()
|
||||
kids.append(p_unary())
|
||||
return kids[0] if len(kids) == 1 else {"op": "and", "kids": kids}
|
||||
|
||||
def p_unary() -> dict[str, Any]:
|
||||
if peek() == "not":
|
||||
eat()
|
||||
return {"op": "not", "x": p_unary()}
|
||||
return p_atom()
|
||||
|
||||
def p_atom() -> dict[str, Any]:
|
||||
t = peek()
|
||||
if t == "(":
|
||||
eat()
|
||||
inner = p_or()
|
||||
if peek() != ")":
|
||||
raise ParseError("missing closing paren )")
|
||||
eat()
|
||||
return inner
|
||||
if t is None:
|
||||
raise ParseError("expression incomplete (missing a term)")
|
||||
if t in ("and", "or", "not", ")", ","):
|
||||
raise ParseError(f"unexpected token {t!r} (missing an operator/term?)")
|
||||
eat()
|
||||
return {"tag": t}
|
||||
|
||||
ast = p_or()
|
||||
if pos < len(toks):
|
||||
raise ParseError(f"unexpected token {toks[pos]!r} (missing an operator?)")
|
||||
return ast
|
||||
|
||||
|
||||
def matches(ast: dict[str, Any] | None, tags: set[str]) -> bool:
|
||||
"""True iff the option's `tags` satisfy the expression. Empty expr => True."""
|
||||
if ast is None:
|
||||
return True
|
||||
if "tag" in ast:
|
||||
return ast["tag"] in tags
|
||||
op = ast["op"]
|
||||
if op == "not":
|
||||
return not matches(ast["x"], tags)
|
||||
if op == "and":
|
||||
return all(matches(k, tags) for k in ast["kids"])
|
||||
return any(matches(k, tags) for k in ast["kids"]) # or
|
||||
|
||||
|
||||
def reads_as(ast: dict[str, Any] | None) -> str:
|
||||
"""Canonical pretty-print: flatten same-op chains; parenthesize only a
|
||||
child whose operator differs from the parent's."""
|
||||
if ast is None:
|
||||
return ""
|
||||
if "tag" in ast:
|
||||
return ast["tag"]
|
||||
op = ast["op"]
|
||||
if op == "not":
|
||||
c = ast["x"]
|
||||
inner = reads_as(c)
|
||||
return f"not ({inner})" if ("op" in c and c["op"] in ("and", "or")) else f"not {inner}"
|
||||
parts = []
|
||||
for k in ast["kids"]:
|
||||
inner = reads_as(k)
|
||||
if "op" in k and k["op"] in ("and", "or") and k["op"] != op:
|
||||
parts.append(f"({inner})")
|
||||
else:
|
||||
parts.append(inner)
|
||||
return f" {op} ".join(parts)
|
||||
|
||||
|
||||
def validate_subcat_name(name: str) -> str | None:
|
||||
"""Return an error string, or None when the name is a valid single token."""
|
||||
if not name:
|
||||
return "name is empty"
|
||||
if name.lower() in RESERVED:
|
||||
return f"reserved word: {name!r} (and/or/not/null are not allowed)"
|
||||
if any(ch.isspace() for ch in name):
|
||||
return "name must not contain whitespace"
|
||||
if not _NAME_RE.match(name):
|
||||
bad = next(ch for ch in name if ch in "()!,#:}@{$")
|
||||
return f"disallowed character: {bad!r}"
|
||||
return None
|
||||
|
||||
|
||||
def validate_expression(s: str, known: set[str]) -> str | None:
|
||||
"""Return an error string, or None when the expression is valid against `known`."""
|
||||
try:
|
||||
ast = parse(s)
|
||||
except ParseError as e:
|
||||
return str(e)
|
||||
if ast is None:
|
||||
return None
|
||||
seen: set[str] = set()
|
||||
|
||||
def walk(n: dict[str, Any]) -> None:
|
||||
if "tag" in n:
|
||||
seen.add(n["tag"])
|
||||
return
|
||||
if n["op"] == "not":
|
||||
walk(n["x"])
|
||||
return
|
||||
for k in n["kids"]:
|
||||
walk(k)
|
||||
|
||||
walk(ast)
|
||||
for t in seen:
|
||||
if t.lower() in RESERVED:
|
||||
return f"reserved word used as a term: {t!r}"
|
||||
if t not in known:
|
||||
return f"Unknown sub-category: {t!r}"
|
||||
return None
|
||||
+13
-67
@@ -13,34 +13,12 @@ import re
|
||||
|
||||
from engine.syntax.types import Token, TokenKind
|
||||
|
||||
# `$name` or `$name.K` — identifier starts with letter or underscore, then
|
||||
# word chars, with an optional `.K` list-index accessor (SP2a). Group 2 = the
|
||||
# index digits when present.
|
||||
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?")
|
||||
# 4-segment ref grammar (fixed order): `@{8hex [#name] [:expr] [!null]}`.
|
||||
# group 1 — uuid: exactly 8 lowercase hex chars.
|
||||
# group 2 — optional `#name`: display label cached at write-time so a
|
||||
# broken ref still tells the user what the wildcard was
|
||||
# called. Purely informational — the resolver matches uuid.
|
||||
# group 3 — optional `:expr`: a BOOLEAN sub-category filter expression
|
||||
# (`and`/`or`/`not`/parens/comma=or). Parsed + matched by the
|
||||
# shared `engine.syntax.subcat_filter` matcher; narrows the
|
||||
# nested pick to options whose tag set satisfies the
|
||||
# expression — same matcher as instance.category_filter.
|
||||
# group 4 — optional `!null`: the exclude-null marker (drops the null
|
||||
# option from the nested pick). Separate from the expression.
|
||||
#
|
||||
# Name/expr char-classes exclude `!` (the null-marker delimiter) so the
|
||||
# segments stay unambiguous; sub-category names additionally forbid the
|
||||
# boolean grammar chars (see `subcat_filter.validate_subcat_name`),
|
||||
# enforced at the editor + API boundary, not at parse time.
|
||||
_REF_RE = re.compile(
|
||||
r"@\{([0-9a-f]{8})(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
|
||||
)
|
||||
# Multi-pick prefix: {N$$sep$$ or {N-M~$$sep$$ — group 1 is the count (a fixed
|
||||
# `N` or a `N-M` range), group 2 is the optional `~` independent flag (repeats
|
||||
# allowed; absent = unique), group 3 is the separator (may be empty). SP2b.
|
||||
_MULTI_PREFIX_RE = re.compile(r"^\{(\d+(?:-\d+)?)(~?)\$\$(.*?)\$\$", flags=re.DOTALL)
|
||||
# `$name` — identifier starts with letter or underscore, then word chars
|
||||
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
|
||||
# `@{8hex}` — exactly 8 lowercase hex chars in braces
|
||||
_REF_RE = re.compile(r"@\{([0-9a-f]{8})\}")
|
||||
# Multi-pick prefix: {N$$sep$$ where N is one or more digits and sep can be empty.
|
||||
_MULTI_PREFIX_RE = re.compile(r"^\{(\d+)\$\$(.*?)\$\$", flags=re.DOTALL)
|
||||
|
||||
|
||||
def _split_top_level_pipes(s: str) -> list[str]:
|
||||
@@ -62,7 +40,7 @@ def _split_top_level_pipes(s: str) -> list[str]:
|
||||
|
||||
def _scan_brace_block(
|
||||
text: str, start: int
|
||||
) -> tuple[int, list[str], tuple[int, int, bool] | None, str | None] | None:
|
||||
) -> tuple[int, list[str], int | None, str | None] | None:
|
||||
"""Try to scan a `{...}` block starting at `text[start] == '{'`.
|
||||
|
||||
Returns:
|
||||
@@ -105,21 +83,13 @@ def _scan_brace_block(
|
||||
body_with_braces = "{" + body # match against `{N$$sep$$...`
|
||||
multi_match = _MULTI_PREFIX_RE.match(body_with_braces)
|
||||
if multi_match:
|
||||
count_raw = multi_match.group(1) # "N" or "N-M"
|
||||
independent = multi_match.group(2) == "~" # SP2b: repeats allowed
|
||||
sep = multi_match.group(3)
|
||||
count = int(multi_match.group(1))
|
||||
sep = multi_match.group(2)
|
||||
# Body remaining after the prefix is the branch list
|
||||
prefix_len = multi_match.end() - 1 # subtract the leading `{` we added
|
||||
rest = body[prefix_len:]
|
||||
branches = _split_top_level_pipes(rest)
|
||||
if "-" in count_raw:
|
||||
lo, hi = count_raw.split("-", 1)
|
||||
cmin, cmax = int(lo), int(hi)
|
||||
else:
|
||||
cmin = cmax = int(count_raw)
|
||||
if cmin > cmax: # normalize a swapped range
|
||||
cmin, cmax = cmax, cmin
|
||||
return (end_index, branches, (cmin, cmax, independent), sep)
|
||||
return (end_index, branches, count, sep)
|
||||
|
||||
# Single-pick: must contain at least one top-level `|`
|
||||
branches = _split_top_level_pipes(body)
|
||||
@@ -185,15 +155,12 @@ def tokenize_text(text: str) -> list[Token]:
|
||||
m = _VAR_RE.match(text, i)
|
||||
if m:
|
||||
_flush_text(i)
|
||||
var_meta: dict[str, object] = {"name": m.group(1)}
|
||||
if m.group(2) is not None:
|
||||
var_meta["index"] = int(m.group(2))
|
||||
out.append(Token(
|
||||
kind=TokenKind.VAR,
|
||||
raw=m.group(0),
|
||||
start=i,
|
||||
end=m.end(),
|
||||
meta=var_meta,
|
||||
meta={"name": m.group(1)},
|
||||
))
|
||||
i = m.end()
|
||||
continue
|
||||
@@ -204,27 +171,12 @@ def tokenize_text(text: str) -> list[Token]:
|
||||
m = _REF_RE.match(text, i)
|
||||
if m:
|
||||
_flush_text(i)
|
||||
# Group 1: uuid. Group 2: optional cached display name.
|
||||
# Group 3: optional boolean sub-category filter
|
||||
# expression (stored raw; the resolver parses it via the
|
||||
# shared subcat_filter matcher). A whitespace-only
|
||||
# `:expr` is "no filter" — keeps `@{xyz:}` from banning
|
||||
# every option. Group 4: the `!null` exclude-null marker.
|
||||
meta: dict = {"uuid": m.group(1)}
|
||||
name_raw = m.group(2)
|
||||
if name_raw:
|
||||
meta["name"] = name_raw
|
||||
expr_raw = m.group(3)
|
||||
if expr_raw is not None and expr_raw.strip():
|
||||
meta["filter_expr"] = expr_raw
|
||||
if m.group(4) == "null":
|
||||
meta["exclude_null"] = True
|
||||
out.append(Token(
|
||||
kind=TokenKind.REF,
|
||||
raw=m.group(0),
|
||||
start=i,
|
||||
end=m.end(),
|
||||
meta=meta,
|
||||
meta={"uuid": m.group(1)},
|
||||
))
|
||||
i = m.end()
|
||||
continue
|
||||
@@ -244,18 +196,12 @@ def tokenize_text(text: str) -> list[Token]:
|
||||
meta={"branches": branches},
|
||||
))
|
||||
else:
|
||||
cmin, cmax, independent = count
|
||||
out.append(Token(
|
||||
kind=TokenKind.DP_MULTI,
|
||||
raw=text[i:end_index],
|
||||
start=i,
|
||||
end=end_index,
|
||||
# `count` kept == max for back-compat readers; min/max
|
||||
# carry the range, independent carries the `~` flag (SP2b).
|
||||
meta={
|
||||
"min": cmin, "max": cmax, "independent": independent,
|
||||
"count": cmax, "sep": sep, "branches": branches,
|
||||
},
|
||||
meta={"count": count, "sep": sep, "branches": branches},
|
||||
))
|
||||
i = end_index
|
||||
continue
|
||||
|
||||
+3
-84
@@ -11,7 +11,6 @@ from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import random
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import (
|
||||
Any,
|
||||
@@ -20,7 +19,7 @@ from typing import (
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
SurfaceKind = Literal["wildcard", "combine", "derivation", "assembler", "fixed_values"]
|
||||
SurfaceKind = Literal["wildcard", "combine", "derivation", "assembler"]
|
||||
|
||||
|
||||
class TokenKind(enum.Enum):
|
||||
@@ -54,66 +53,6 @@ class Token:
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListVar:
|
||||
"""A list-valued variable plus the separator used to join it for a bare
|
||||
``$name`` reference (SP2a multi-select). Produced only by a multi-select
|
||||
wildcard; exists only inside one resolution pass — never persisted or
|
||||
published. ``$name`` renders ``sep.join(items)``; ``$name.K`` indexes
|
||||
(0-based; out-of-range -> "").
|
||||
"""
|
||||
|
||||
items: list[str]
|
||||
sep: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""A bare ``$name`` reference renders the list joined by ``sep``.
|
||||
Defining ``__str__`` (the @dataclass ``__repr__`` stays, for debug)
|
||||
keeps a list value that leaks into a plain ``str()`` context rendering
|
||||
as the user-facing joined string instead of ``ListVar(items=[...])``."""
|
||||
return self.sep.join(self.items)
|
||||
|
||||
|
||||
_VAR_ACCESSOR_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\.(\d+)$")
|
||||
|
||||
|
||||
def split_var_accessor(name: str) -> tuple[str, int | None]:
|
||||
"""Split a bare var reference into ``(base_name, index)`` (SP2a `.K`).
|
||||
|
||||
``"mood.0"`` -> ``("mood", 0)``; ``"mood"`` -> ``("mood", None)``. A name
|
||||
that is not an ``ident.digits`` shape is returned whole with
|
||||
``index=None`` so unusual keys pass through unchanged. Callers strip any
|
||||
leading ``$`` before calling.
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
return ("", None)
|
||||
m = _VAR_ACCESSOR_RE.match(name)
|
||||
if m:
|
||||
return (m.group(1), int(m.group(2)))
|
||||
return (name, None)
|
||||
|
||||
|
||||
def deref_var_value(value: str | ListVar | None, index: int | None) -> str:
|
||||
"""Render a resolved var value to a string, honoring an optional ``.K``
|
||||
list accessor (SP2a). ListVar: bare -> ``sep.join(items)``; ``.K`` ->
|
||||
``items[K]`` or ``""`` when out of range. A plain string behaves as a
|
||||
1-element list (``.0`` == itself, ``.K`` > 0 == ``""``). ``None`` -> ``""``.
|
||||
|
||||
Single source of the accessor contract: resolve.py, derivation_handler.py
|
||||
and converters.py all defer here so the read sites never drift.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, ListVar):
|
||||
if index is not None:
|
||||
return value.items[index] if 0 <= index < len(value.items) else ""
|
||||
return value.sep.join(value.items)
|
||||
s = value if isinstance(value, str) else str(value)
|
||||
if index is not None:
|
||||
return s if index == 0 else ""
|
||||
return s
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ResolveContext(Protocol):
|
||||
"""Interface the resolver depends on. Construct via build_resolve_ctx in
|
||||
@@ -128,10 +67,8 @@ class ResolveContext(Protocol):
|
||||
developer_mode: bool
|
||||
warnings: list[dict[str, Any]]
|
||||
|
||||
def get_var(self, name: str) -> str | ListVar | None:
|
||||
"""Return the bound value of `name`, or None if unbound. A
|
||||
multi-select wildcard binds a ``ListVar`` (SP2a); everything else
|
||||
binds a ``str``."""
|
||||
def get_var(self, name: str) -> str | None:
|
||||
"""Return the bound value of `name`, or None if unbound."""
|
||||
...
|
||||
|
||||
def get_module(self, uuid: str) -> dict[str, Any] | None:
|
||||
@@ -172,24 +109,6 @@ class RefOutOfSurfaceError(SyntaxError):
|
||||
)
|
||||
|
||||
|
||||
class VarOutOfSurfaceError(SyntaxError):
|
||||
"""$var read attempted in a surface that doesn't allow it.
|
||||
|
||||
Combine, derivation, and assembler surfaces support $var reads.
|
||||
Wildcard and fixed_values surfaces are binding PRODUCERS, not
|
||||
consumers — $var reads in those surfaces warn (lenient) or raise
|
||||
(strict) per ResolveContext.strict.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, surface: str) -> None:
|
||||
self.name = name
|
||||
self.surface = surface
|
||||
super().__init__(
|
||||
f"$var ${name} not allowed in {surface!r} surface; "
|
||||
f"only combine / derivation / assembler surfaces support $var reads"
|
||||
)
|
||||
|
||||
|
||||
class RecursionLimitExceeded(SyntaxError):
|
||||
"""@{uuid} chain reached configured max_ref_depth."""
|
||||
|
||||
|
||||
+6
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.2.0",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
@@ -16,10 +16,6 @@
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint \"src/**/*.{ts,vue}\"",
|
||||
"size": "node scripts/check-bundle-size.mjs",
|
||||
"check:css-isolation": "node scripts/check-css-isolation.mjs",
|
||||
"check:spacing": "node scripts/check-spacing-tokens.mjs",
|
||||
"check:typography": "node scripts/check-typography-tokens.mjs",
|
||||
"check:radius": "node scripts/check-radius-tokens.mjs",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
@@ -27,13 +23,15 @@
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.2.6",
|
||||
"primeicons": "^7.0.0",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.4.5",
|
||||
"zod": "^3.25.76"
|
||||
"pinia": "^2.2.6",
|
||||
"primeicons": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.14",
|
||||
"postcss": "^8.4.47",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"@comfyorg/comfyui-frontend-types": "^1.42.15",
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
@@ -46,15 +44,12 @@
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"@vue/test-utils": "^2.4.8",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.31.0",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.5.0",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^29.0.2",
|
||||
"postcss": "^8.4.47",
|
||||
"semantic-release": "^25.0.3",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||
|
||||
Generated
-3
@@ -20,9 +20,6 @@ importers:
|
||||
vue-router:
|
||||
specifier: ^4.4.5
|
||||
version: 4.6.4(vue@3.5.33(typescript@5.9.3))
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@comfyorg/comfyui-frontend-types':
|
||||
specifier: ^1.42.15
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,140 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 600" role="img" aria-label="Wildcard Pipeline — modular procedural prompt generation for ComfyUI">
|
||||
<style><![CDATA[
|
||||
.bg { fill: #0e1015; }
|
||||
.panel { fill: #1a1d2a; }
|
||||
.border-cyan { stroke: #3a8fb7; }
|
||||
.border-violet { stroke: #7c6ad6; }
|
||||
.border-neutral { stroke: #6a6a6a; }
|
||||
.ico-cyan { fill: #7ec8e3; }
|
||||
.ico-violet { fill: #c4b5fd; }
|
||||
.ico-neutral { fill: #aab; }
|
||||
.name { fill: #d6d8e0; font: 600 18px 'Inter', system-ui, sans-serif; }
|
||||
.sub { fill: #6f7384; font: 400 12px ui-monospace, monospace; }
|
||||
.title { fill: #e8eaf2; font: 700 56px 'Inter', system-ui, sans-serif; letter-spacing: -0.5px; }
|
||||
.tagline { fill: #aab2c0; font: 500 22px 'Inter', system-ui, sans-serif; }
|
||||
.badge { font: 600 13px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.badge-wc { fill: #b29cf2; }
|
||||
.badge-fv { fill: #2dd4bf; }
|
||||
.badge-cb { fill: #6ad28b; }
|
||||
.badge-dv { fill: #f4a261; }
|
||||
.badge-cs { fill: #ec96b8; }
|
||||
.arrow-lbl { fill: #6f7384; font: 600 12px 'Inter', system-ui, sans-serif; letter-spacing: 1.2px; text-transform: uppercase; }
|
||||
.arrow-line { stroke: #4a4d55; stroke-width: 2; fill: none; }
|
||||
.arrow-head { fill: #4a4d55; }
|
||||
.var-pill { fill: #c4b5fd; fill-opacity: 0.12; stroke: #c4b5fd; stroke-width: 1; }
|
||||
.var-text { fill: #c4b5fd; font: 500 14px 'Inter', system-ui, sans-serif; }
|
||||
.pill { fill-opacity: 0.12; stroke-width: 1; }
|
||||
.pill-stroke-wc { stroke: #b29cf2; }
|
||||
.pill-stroke-fv { stroke: #2dd4bf; }
|
||||
.pill-stroke-cb { stroke: #6ad28b; }
|
||||
.pill-stroke-dv { stroke: #f4a261; }
|
||||
.pill-stroke-cs { stroke: #ec96b8; }
|
||||
.glow { filter: drop-shadow(0 0 14px rgba(124, 106, 214, 0.18)); }
|
||||
]]></style>
|
||||
|
||||
<!-- Background -->
|
||||
<rect class="bg" width="1400" height="600" rx="0" />
|
||||
|
||||
<!-- Subtle decorative grid (very dim, evokes ComfyUI canvas) -->
|
||||
<g opacity="0.06" stroke="#3a8fb7" stroke-width="1">
|
||||
<line x1="0" y1="100" x2="1400" y2="100" />
|
||||
<line x1="0" y1="200" x2="1400" y2="200" />
|
||||
<line x1="0" y1="300" x2="1400" y2="300" />
|
||||
<line x1="0" y1="400" x2="1400" y2="400" />
|
||||
<line x1="0" y1="500" x2="1400" y2="500" />
|
||||
<line x1="200" y1="0" x2="200" y2="600" />
|
||||
<line x1="400" y1="0" x2="400" y2="600" />
|
||||
<line x1="600" y1="0" x2="600" y2="600" />
|
||||
<line x1="800" y1="0" x2="800" y2="600" />
|
||||
<line x1="1000" y1="0" x2="1000" y2="600" />
|
||||
<line x1="1200" y1="0" x2="1200" y2="600" />
|
||||
</g>
|
||||
|
||||
<!-- ===== Left half: branding ===== -->
|
||||
|
||||
<!-- Title -->
|
||||
<text class="title" x="70" y="180">Wildcard</text>
|
||||
<text class="title" x="70" y="248">Pipeline</text>
|
||||
|
||||
<!-- Underline accent -->
|
||||
<rect x="70" y="270" width="120" height="4" rx="2" fill="#7c6ad6" />
|
||||
|
||||
<!-- Tagline -->
|
||||
<text class="tagline" x="70" y="320">Modular procedural prompt</text>
|
||||
<text class="tagline" x="70" y="350">generation for ComfyUI</text>
|
||||
|
||||
<!-- Module-kind badges as a feature row -->
|
||||
<g transform="translate(70, 410)">
|
||||
<rect class="pill pill-stroke-wc" x="0" y="0" width="88" height="28" rx="14" fill="#b29cf2" />
|
||||
<text class="badge badge-wc" x="44" y="19" text-anchor="middle">Wildcard</text>
|
||||
<rect class="pill pill-stroke-fv" x="98" y="0" width="80" height="28" rx="14" fill="#2dd4bf" />
|
||||
<text class="badge badge-fv" x="138" y="19" text-anchor="middle">Fixed</text>
|
||||
<rect class="pill pill-stroke-cb" x="188" y="0" width="98" height="28" rx="14" fill="#6ad28b" />
|
||||
<text class="badge badge-cb" x="237" y="19" text-anchor="middle">Combine</text>
|
||||
</g>
|
||||
<g transform="translate(70, 446)">
|
||||
<rect class="pill pill-stroke-dv" x="0" y="0" width="108" height="28" rx="14" fill="#f4a261" />
|
||||
<text class="badge badge-dv" x="54" y="19" text-anchor="middle">Derivation</text>
|
||||
<rect class="pill pill-stroke-cs" x="118" y="0" width="110" height="28" rx="14" fill="#ec96b8" />
|
||||
<text class="badge badge-cs" x="173" y="19" text-anchor="middle">Constraint</text>
|
||||
</g>
|
||||
|
||||
<!-- Footnote -->
|
||||
<text class="sub" x="70" y="525">wildcards · constraints · loops · per-iteration seeds · library</text>
|
||||
|
||||
<!-- ===== Right half: flow diagram ===== -->
|
||||
|
||||
<g class="glow" transform="translate(620, 0)">
|
||||
<!-- Layout budget on the right half:
|
||||
Context (180) + EMITS (90) + Vars (130) + FILLS (90) + Assembler (210)
|
||||
= 700 px → fits within (1400 - 620) = 780 px viewBox with 80 px margin -->
|
||||
|
||||
<!-- WP Context box -->
|
||||
<g transform="translate(0, 220)">
|
||||
<rect class="panel border-cyan" x="0" y="0" width="180" height="140" rx="12" stroke-width="2" />
|
||||
<text class="ico-cyan" x="90" y="42" text-anchor="middle" font-size="28">⌬</text>
|
||||
<text class="name" x="90" y="80" text-anchor="middle">WP Context</text>
|
||||
<text class="sub" x="90" y="105" text-anchor="middle">rolls + resolves</text>
|
||||
</g>
|
||||
|
||||
<!-- EMITS arrow -->
|
||||
<g transform="translate(190, 290)">
|
||||
<text class="arrow-lbl" x="40" y="-16" text-anchor="middle">Emits</text>
|
||||
<line class="arrow-line" x1="0" y1="0" x2="76" y2="0" />
|
||||
<polygon class="arrow-head" points="76,-6 86,0 76,6" />
|
||||
</g>
|
||||
|
||||
<!-- Resolved vars cluster -->
|
||||
<g transform="translate(290, 260)">
|
||||
<rect class="var-pill" x="0" y="0" width="84" height="28" rx="14" />
|
||||
<text class="var-text" x="42" y="19" text-anchor="middle">$subject</text>
|
||||
<rect class="var-pill" x="92" y="0" width="62" height="28" rx="14" />
|
||||
<text class="var-text" x="123" y="19" text-anchor="middle">$style</text>
|
||||
<rect class="var-pill" x="35" y="38" width="86" height="28" rx="14" />
|
||||
<text class="var-text" x="78" y="57" text-anchor="middle">$lighting</text>
|
||||
</g>
|
||||
|
||||
<!-- FILLS arrow -->
|
||||
<g transform="translate(460, 290)">
|
||||
<text class="arrow-lbl" x="35" y="-16" text-anchor="middle">Fills</text>
|
||||
<line class="arrow-line" x1="0" y1="0" x2="66" y2="0" />
|
||||
<polygon class="arrow-head" points="66,-6 76,0 66,6" />
|
||||
</g>
|
||||
|
||||
<!-- Prompt Assembler -->
|
||||
<g transform="translate(550, 220)">
|
||||
<rect class="panel border-violet" x="0" y="0" width="210" height="140" rx="12" stroke-width="2" />
|
||||
<text class="ico-violet" x="105" y="42" text-anchor="middle" font-size="28">≡</text>
|
||||
<text class="name" x="105" y="80" text-anchor="middle">Prompt Assembler</text>
|
||||
<text class="sub" x="105" y="105" text-anchor="middle">"a $style portrait…"</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- favicon-ish sparkle marks in the corners (decorative) -->
|
||||
<g opacity="0.5" fill="#7c6ad6">
|
||||
<circle cx="40" cy="40" r="2" />
|
||||
<circle cx="1360" cy="40" r="2" />
|
||||
<circle cx="40" cy="560" r="2" />
|
||||
<circle cx="1360" cy="560" r="2" />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 255 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 160 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user