Files
malkuthroandClaude Opus 4.8 ae2480eee9 feat(setups): publish success feedback + published-setup discoverability (#227) (#239)
* feat(setups): publish success feedback + published-setup discoverability (#227)

Post-publish feedback (issue #227 Part 1):
- POST /koolook/api/setups now returns the on-disk storagePath
- new POST /koolook/api/setups/reveal opens the published-setups folder
  (shared _open_dir_in_file_manager helper; the snapshot reveal route was
  refactored onto it, behaviour-identical)
- the publish modal renders a success card reusing the snapshot Save dialog's
  "Saved to ... Open folder" language (Open folder / Copy path / Close)
  instead of closing silently; a draft publish still gets a confirmation

Published discoverability (reuses the tag system, no new schema):
- publishing auto-tags the source workflow `published` -> a "published" badge
  in the Workflows tree + the Tags-section "published" pool
- a new "P" toggle in the sidebar Tools row prunes the Workflows tree to
  published-only, folders force-expanded, so setups show in their origin
  structure; toggling off restores the full tree
- workflowHasTag() shared helper (unit-tested)

Deferred, still open on #227: separate destination/library setting (Part 2),
relocating the storage folder beside the snapshot library, and clearing the
filter when entering Compare mode.

Tests: +server (storagePath, reveal route happy + 404), +client (reveal
client, buildPublishSuccessView view-model), +sidebar (workflowHasTag).
Full suite: 610 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(setups): store published setups beside the snapshot library (#227)

- the published-setup registry path now derives from the configured snapshot
  library (`_configured_dir().parent / koolook-published-setups/setups.json`)
  instead of a fixed user-dir folder, so it follows libraryPath /
  KFORGELABS_PRESETS. The default config resolves to the same path as before
  (target == legacy), so existing default installs are unchanged.
- non-destructive one-time migration: copy a legacy user-dir registry to the
  new location when the new one is absent, leaving the original in place.
- sidebar: clear the "P" published filter when entering Compare mode so it
  doesn't render both compare columns filtered with no toggle to undo it.

Tests: +tests/server/test_published_setups_relocation.py (path beside library;
migration copy / no-op-when-present / no-op-when-no-legacy). Full suite: 614
passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(tests): remove unused pathlib.Path import (ruff F401)

The relocation test only uses the `tmp_path` fixture, not `Path` directly, so
`from pathlib import Path` was unused and failed the Lint & Smoke (ruff) gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(setups): address PR #239 review — migration masking + tag persist

- HIGH: a failed legacy->new migration no longer masks real setups with the
  bundled sample. The default registry keeps the legacy file as its primary
  storage when migration could not produce the new file, so stranded data
  stays visible. (+2 tests: keeps-legacy-on-copy-failure / uses-new-on-success)
- MEDIUM/LOW: the publish flow now awaits the `published`-tag persist so the
  success card reflects a committed tag (persist failures already surface via
  persistMutation's critical toast), and warns when the source workflow could
  not be tagged (vanished mid-publish) rather than on a harmless republish.

Full suite: 616 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(setups): harden migration against partial/corrupt relocation target

Addresses the remaining HIGH from the PR #239 consolidated re-review: a
zero-byte/partial/corrupt setups.json at the relocated path could shadow the
intact legacy registry (existence was treated as proof of migration).

- migration now copies via temp file + atomic os.replace (the same pattern
  FileSetupStorage.save_setups uses), so an interrupted copy can never leave
  a partial setups.json at the target; failed-copy temp files are cleaned up.
- primary selection is corruption-aware: when the relocated file exists but
  does not parse as a setups document while the legacy file is intact, the
  registry keeps serving the legacy file and logs a loud diagnostic. The
  unreadable file is left untouched (never overwritten) as evidence. A valid
  relocated file stays authoritative; if both are corrupt, the relocated path
  stays primary so storage diagnostics point at the current location.

Tests (+4): corrupt-target-prefers-intact-legacy (file left untouched),
valid-target-stays-authoritative, both-corrupt-keeps-target, and
interrupted-copy-leaves-no-partial-target-and-no-tmp-litter.

Full suite: 620 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:54:12 +02:00

448 lines
14 KiB
Python

from __future__ import annotations
import subprocess
import textwrap
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
def run_node_scenario(source: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["node", "--input-type=module"],
input=source,
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
def test_publish_saved_workflow_sends_registry_payload() -> None:
script = textwrap.dedent(
"""
import assert from "node:assert/strict";
import { publishSavedWorkflowSetup } from "./web/sidebar/published_setups.js";
const graph = {
nodes: [{ id: 12, inputs: [{ name: "text" }] }],
links: [],
};
const apiPrompt = {
"12": { class_type: "Text Multiline", inputs: { text: "published" } },
};
const calls = [];
const result = await publishSavedWorkflowSetup({
dirPath: ["Demos"],
wfName: "Director",
metadata: {
id: "director-demo",
title: "Director Demo",
description: "A curated director workflow.",
category: "Video",
tags: "director, video",
previewImage: "preview.png",
},
inputContract: {
inputs: [{
key: "prompt",
label: "Prompt",
type: "text",
required: true,
target: { node: "12", input: "text" },
}],
},
outputContract: { outputs: [{ key: "preview", type: "image" }] },
apiPrompt,
getWorkflowGraph: (path, name) => {
assert.deepEqual(path, ["Demos"]);
assert.equal(name, "Director");
return graph;
},
fetchImpl: async (url, options) => {
calls.push({ url, options });
return {
ok: true,
status: 200,
async json() {
return { ok: true, setup: { id: "director-demo" } };
},
};
},
});
assert.equal(result.setup.id, "director-demo");
assert.equal(calls.length, 1);
assert.equal(calls[0].url, "/koolook/api/setups");
assert.equal(calls[0].options.method, "POST");
assert.equal(calls[0].options.headers["Content-Type"], "application/json");
const payload = JSON.parse(calls[0].options.body);
assert.deepEqual(payload.visualGraph, graph);
assert.deepEqual(payload.apiPrompt, apiPrompt);
assert.deepEqual(payload.source, {
kind: "sidebar-workflow",
path: "Demos/Director",
inventoryPath: ["Demos"],
name: "Director",
});
assert.deepEqual(payload.metadata.tags, ["director", "video"]);
assert.equal(payload.metadata.previewImage, "preview.png");
"""
)
result = run_node_scenario(script)
assert result.returncode == 0, result.stderr
def test_publish_saved_workflow_rejects_missing_source_before_fetch() -> None:
script = textwrap.dedent(
"""
import assert from "node:assert/strict";
import { publishSavedWorkflowSetup } from "./web/sidebar/published_setups.js";
let fetchCalled = false;
await assert.rejects(
() => publishSavedWorkflowSetup({
dirPath: ["Missing"],
wfName: "Gone",
metadata: { id: "gone", title: "Gone", description: "Gone" },
inputContract: { inputs: [] },
outputContract: { outputs: [] },
getWorkflowGraph: () => null,
fetchImpl: async () => {
fetchCalled = true;
return { ok: true, async json() { return {}; } };
},
}),
/Saved workflow not found/
);
assert.equal(fetchCalled, false);
"""
)
result = run_node_scenario(script)
assert result.returncode == 0, result.stderr
def test_publish_saved_workflow_can_submit_reviewed_visual_graph() -> None:
script = textwrap.dedent(
"""
import assert from "node:assert/strict";
import { publishSavedWorkflowSetup } from "./web/sidebar/published_setups.js";
const reviewedGraph = {
nodes: [{ id: 12, type: "Load Image" }],
links: [],
groups: [{ title: "Koolook Input", bounding: [0, 0, 100, 100] }],
};
const changedGraph = {
nodes: [{ id: 99, type: "Different" }],
links: [],
groups: [],
};
const apiPrompt = {
"12": { class_type: "Load Image", inputs: {} },
};
let payload;
await publishSavedWorkflowSetup({
dirPath: ["Demos"],
wfName: "Director",
visualGraph: reviewedGraph,
apiPrompt,
metadata: { id: "director-demo", title: "Director Demo", description: "Reviewed graph" },
inputContract: { inputs: [] },
outputContract: { outputs: [] },
getWorkflowGraph: () => changedGraph,
fetchImpl: async (_url, options) => {
payload = JSON.parse(options.body);
return {
ok: true,
status: 200,
async json() {
return { ok: true, setup: { id: "director-demo" } };
},
};
},
});
assert.deepEqual(payload.visualGraph, reviewedGraph);
assert.deepEqual(payload.apiPrompt, apiPrompt);
assert.notDeepEqual(payload.visualGraph, changedGraph);
"""
)
result = run_node_scenario(script)
assert result.returncode == 0, result.stderr
def test_publish_saved_workflow_can_submit_comfy_api_prompt() -> None:
script = textwrap.dedent(
"""
import assert from "node:assert/strict";
import { publishSavedWorkflowSetup } from "./web/sidebar/published_setups.js";
const visualGraph = {
nodes: [{ id: 12, type: "Text Multiline" }],
links: [],
};
const apiPrompt = {
"12": { class_type: "Text Multiline", inputs: { text: "comfy export" } },
};
let payload;
await publishSavedWorkflowSetup({
dirPath: ["Demos"],
wfName: "Director",
visualGraph,
apiPrompt,
metadata: { id: "director-demo", title: "Director Demo", description: "API prompt" },
inputContract: { inputs: [] },
outputContract: { outputs: [{ key: "preview", type: "image" }] },
fetchImpl: async (_url, options) => {
payload = JSON.parse(options.body);
return {
ok: true,
status: 200,
async json() {
return { ok: true, setup: { id: "director-demo" } };
},
};
},
});
assert.deepEqual(payload.visualGraph, visualGraph);
assert.deepEqual(payload.apiPrompt, apiPrompt);
"""
)
result = run_node_scenario(script)
assert result.returncode == 0, result.stderr
def test_publish_saved_workflow_captures_api_prompt_when_missing() -> None:
script = textwrap.dedent(
"""
import assert from "node:assert/strict";
import { publishSavedWorkflowSetup } from "./web/sidebar/published_setups.js";
const visualGraph = {
nodes: [{ id: 12, type: "Text Multiline" }],
links: [],
};
const capturedPrompt = {
"12": { class_type: "Text Multiline", inputs: { text: "captured" } },
};
const calls = [];
await publishSavedWorkflowSetup({
dirPath: ["Models", "RMGB"],
wfName: "Publish v04",
visualGraph,
metadata: { id: "rmgb-v04", title: "RMGB v04", description: "Captured prompt" },
inputContract: { inputs: [] },
outputContract: { outputs: [] },
captureApiPrompt: async (graph) => {
calls.push({ kind: "capture", graph });
return capturedPrompt;
},
fetchImpl: async (_url, options) => {
calls.push({ kind: "fetch", payload: JSON.parse(options.body) });
return {
ok: true,
status: 200,
async json() {
return { ok: true, setup: { id: "rmgb-v04" } };
},
};
},
});
assert.equal(calls.length, 2);
assert.equal(calls[0].kind, "capture");
assert.deepEqual(calls[0].graph, visualGraph);
assert.equal(calls[1].kind, "fetch");
assert.deepEqual(calls[1].payload.apiPrompt, capturedPrompt);
assert.deepEqual(calls[1].payload.visualGraph, visualGraph);
"""
)
result = run_node_scenario(script)
assert result.returncode == 0, result.stderr
def test_publish_saved_workflow_requires_api_prompt_or_capture_before_fetch() -> None:
script = textwrap.dedent(
"""
import assert from "node:assert/strict";
import { publishSavedWorkflowSetup } from "./web/sidebar/published_setups.js";
let fetchCalled = false;
await assert.rejects(
() => publishSavedWorkflowSetup({
dirPath: ["Models"],
wfName: "Needs Capture",
visualGraph: { nodes: [], links: [] },
metadata: { id: "needs-capture", title: "Needs Capture", description: "No prompt" },
inputContract: { inputs: [] },
outputContract: { outputs: [] },
fetchImpl: async () => {
fetchCalled = true;
return { ok: true, async json() { return {}; } };
},
}),
/API prompt capture is required/
);
assert.equal(fetchCalled, false);
"""
)
result = run_node_scenario(script)
assert result.returncode == 0, result.stderr
def test_reveal_published_setup_folder_posts_reveal_route() -> None:
script = textwrap.dedent(
"""
import assert from "node:assert/strict";
import { revealPublishedSetupFolder } from "./web/sidebar/published_setups.js";
const calls = [];
const result = await revealPublishedSetupFolder({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return {
ok: true,
status: 200,
async json() {
return { ok: true, path: "/u/default/koolook-published-setups" };
},
};
},
});
assert.deepEqual(result, { ok: true, path: "/u/default/koolook-published-setups" });
assert.equal(calls.length, 1);
assert.equal(calls[0].url, "/koolook/api/setups/reveal");
assert.equal(calls[0].options.method, "POST");
"""
)
result = run_node_scenario(script)
assert result.returncode == 0, result.stderr
def test_reveal_published_setup_folder_throws_server_reason_on_error() -> None:
script = textwrap.dedent(
"""
import assert from "node:assert/strict";
import { revealPublishedSetupFolder } from "./web/sidebar/published_setups.js";
await assert.rejects(
() => revealPublishedSetupFolder({
fetchImpl: async () => ({
ok: false,
status: 404,
statusText: "Not Found",
async text() { return "Path does not exist on disk: /u/koolook-published-setups"; },
}),
}),
/Path does not exist on disk/
);
"""
)
result = run_node_scenario(script)
assert result.returncode == 0, result.stderr
def test_build_publish_success_view_maps_response_fields() -> None:
script = textwrap.dedent(
"""
import assert from "node:assert/strict";
import { buildPublishSuccessView } from "./web/sidebar/published_setups.js";
const view = buildPublishSuccessView({
ok: true,
storagePath: "/u/default/koolook-published-setups/setups.json",
setup: {
id: "director-demo",
metadata: { title: "Director Demo" },
source: { path: "Demos/Director" },
validation: { status: "valid" },
},
});
assert.equal(view.setupId, "director-demo");
assert.equal(view.title, "Director Demo");
assert.equal(view.sourcePath, "Demos/Director");
assert.equal(view.validationStatus, "valid");
assert.equal(view.storagePath, "/u/default/koolook-published-setups/setups.json");
assert.equal(view.canCopyPath, true);
assert.equal(view.canOpenFolder, true);
"""
)
result = run_node_scenario(script)
assert result.returncode == 0, result.stderr
def test_build_publish_success_view_disables_actions_without_storage_path() -> None:
script = textwrap.dedent(
"""
import assert from "node:assert/strict";
import { buildPublishSuccessView } from "./web/sidebar/published_setups.js";
const view = buildPublishSuccessView({
ok: true,
setup: {
id: "draft-demo",
metadata: { title: "Draft Demo" },
source: { path: "Demos/Draft" },
validation: { status: "draft" },
},
});
assert.equal(view.storagePath, "");
assert.equal(view.canCopyPath, false);
assert.equal(view.canOpenFolder, false);
assert.equal(view.validationStatus, "draft");
assert.equal(view.title, "Draft Demo");
"""
)
result = run_node_scenario(script)
assert result.returncode == 0, result.stderr
def test_publish_saved_workflow_rejects_failed_api_prompt_capture_before_fetch() -> None:
script = textwrap.dedent(
"""
import assert from "node:assert/strict";
import { publishSavedWorkflowSetup } from "./web/sidebar/published_setups.js";
let fetchCalled = false;
await assert.rejects(
() => publishSavedWorkflowSetup({
dirPath: ["Models"],
wfName: "Broken",
visualGraph: { nodes: [], links: [] },
metadata: { id: "broken", title: "Broken", description: "No prompt" },
inputContract: { inputs: [] },
outputContract: { outputs: [] },
captureApiPrompt: async () => null,
fetchImpl: async () => {
fetchCalled = true;
return { ok: true, async json() { return {}; } };
},
}),
/API prompt capture failed/
);
assert.equal(fetchCalled, false);
"""
)
result = run_node_scenario(script)
assert result.returncode == 0, result.stderr