From 8c1f02c32c4cb344d233b1aee05fa535cd1d572b Mon Sep 17 00:00:00 2001
From: Daniel Petro <45949032+WellDunDun@users.noreply.github.com>
Date: Thu, 3 Sep 2026 08:50:36 +0000
Subject: [PATCH] chore: export from selftune-cloud-app
---
.changeset/imported-set-license-draft.md | 5 +
.changeset/publication-preflight.md | 5 +
.github/release-notes.json | 11 +++
.github/scripts/generate-sbom.sh | 70 +++++++++++++
.github/workflows/ci.yml | 22 +++++
.github/workflows/publish.yml | 70 ++-----------
apps/desktop/package.json | 2 +-
apps/local-dashboard/src/api.ts | 10 +-
apps/local/src/dashboard-operations.ts | 68 ++++++++-----
apps/local/src/routes/library-transfer.ts | 9 +-
apps/local/tests/dashboard-operations.test.ts | 60 +++++++++++
bun.lock | 2 +-
package.json | 2 +-
packages/dashboard-core/src/host/adapter.ts | 4 +-
.../projects/ShareSkillSetDialog.test.tsx | 42 +++++++-
.../screens/projects/ShareSkillSetDialog.tsx | 50 +++++-----
packages/runtime/license-draft.ts | 28 ++++--
.../runtime/skill-set-license-draft.test.ts | 89 +++++++++++++++++
packages/runtime/skill-set-license-draft.ts | 99 +++++++++++++++++++
skill/SKILL.md | 2 +-
tests/scripts/release-workflows.test.ts | 4 +-
tests/trust-floor/publish-deps.test.ts | 88 +++++++----------
22 files changed, 556 insertions(+), 186 deletions(-)
create mode 100644 .changeset/imported-set-license-draft.md
create mode 100644 .changeset/publication-preflight.md
create mode 100644 .github/scripts/generate-sbom.sh
create mode 100644 packages/runtime/skill-set-license-draft.test.ts
create mode 100644 packages/runtime/skill-set-license-draft.ts
diff --git a/.changeset/imported-set-license-draft.md b/.changeset/imported-set-license-draft.md
new file mode 100644
index 00000000..65b544ff
--- /dev/null
+++ b/.changeset/imported-set-license-draft.md
@@ -0,0 +1,5 @@
+---
+"@selftune/desktop": patch
+---
+
+Fix license draft review for imported Skill Sets. Show draft errors in the dialog and save approved license terms as a new Set revision without changing the original package.
diff --git a/.changeset/publication-preflight.md b/.changeset/publication-preflight.md
new file mode 100644
index 00000000..1c73bc87
--- /dev/null
+++ b/.changeset/publication-preflight.md
@@ -0,0 +1,5 @@
+---
+"@selftune/desktop": patch
+---
+
+Check the packed npm candidate and its validated dependency inventory before merge using the same SBOM generator as publication.
diff --git a/.github/release-notes.json b/.github/release-notes.json
index ab6bd15e..f0bcbab2 100644
--- a/.github/release-notes.json
+++ b/.github/release-notes.json
@@ -3,6 +3,17 @@
"generatedFrom": "sites/docs/changelog.mdx",
"docsUrl": "https://docs.selftune.dev/changelog",
"entries": [
+ {
+ "label": "2026-09-03",
+ "description": "Skill Set license draft recovery",
+ "tags": ["Dashboard", "OSS"],
+ "rss": {
+ "title": null,
+ "description": null
+ },
+ "bullets": [],
+ "versionRange": null
+ },
{
"label": "2026-09-02",
"description": "Signed macOS runtime startup validation",
diff --git a/.github/scripts/generate-sbom.sh b/.github/scripts/generate-sbom.sh
new file mode 100644
index 00000000..9fdcbb9e
--- /dev/null
+++ b/.github/scripts/generate-sbom.sh
@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+set -euo pipefail
+: "${TARBALL_PATH:?Set TARBALL_PATH to the packed candidate}"
+: "${SBOM_TOOLCHAIN_DIR:?Set SBOM_TOOLCHAIN_DIR to the installed locked toolchain}"
+: "${SBOM_OUTPUT:?Set SBOM_OUTPUT to an absolute output path}"
+TARBALL_PATH="$(cd "$(dirname "$TARBALL_PATH")" && pwd)/$(basename "$TARBALL_PATH")"
+SBOM_TOOLCHAIN_DIR="$(cd "$SBOM_TOOLCHAIN_DIR" && pwd)"
+SBOM_OUTPUT="$(cd "$(dirname "$SBOM_OUTPUT")" && pwd)/$(basename "$SBOM_OUTPUT")"
+SBOM_TMP="$(mktemp -d)"
+trap 'rm -rf "$SBOM_TMP"' EXIT
+tar -xzf "$TARBALL_PATH" -C "$SBOM_TMP"
+cd "$SBOM_TMP/package"
+node -e "const fs=require('node:fs'); const pkg=JSON.parse(fs.readFileSync('package.json','utf8')); delete pkg.workspaces; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');"
+
+# The SBOM is generated from the packed candidate before publishing to npm so
+# an unresolvable bundled dependency tree fails the release while npm,
+# the GitHub release, and the self-host image are still in sync.
+npm install --package-lock-only --ignore-scripts --omit=dev --no-audit --no-fund >/dev/null
+
+SBOM_LOG="$SBOM_TMP/cyclonedx.log"
+if ! node "$SBOM_TOOLCHAIN_DIR/node_modules/@cyclonedx/cyclonedx-npm/bin/cyclonedx-npm-cli.js" \
+ --package-lock-only \
+ --omit dev \
+ --no-workspaces \
+ --validate \
+ -v -v \
+ --output-file "$SBOM_OUTPUT" \
+ >"$SBOM_LOG" 2>&1; then
+ cat "$SBOM_LOG" >&2
+ exit 1
+fi
+cat "$SBOM_LOG"
+if grep --fixed-strings --quiet "skipped validating BOM" "$SBOM_LOG"; then
+ echo "CycloneDX skipped SBOM validation; refusing to publish." >&2
+ exit 1
+fi
+if ! grep --fixed-strings --line-regexp --quiet "INFO | BOM result appears valid" "$SBOM_LOG"; then
+ echo "CycloneDX did not confirm successful SBOM validation; refusing to publish." >&2
+ exit 1
+fi
+
+node - "$SBOM_TMP/package/package.json" "$SBOM_OUTPUT" <<'NODE'
+const fs = require("node:fs");
+const [, , packagePath, bomPath] = process.argv;
+const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8"));
+const bom = JSON.parse(fs.readFileSync(bomPath, "utf8"));
+const rootRef = bom.metadata?.component?.["bom-ref"];
+const root = bom.dependencies?.find((entry) => entry.ref === rootRef);
+if (!root) throw new Error(`Missing SBOM root dependency node ${rootRef}`);
+const namesByRef = new Map(
+ (bom.components ?? []).map((component) => [
+ component["bom-ref"],
+ `${component.group ? `${component.group}/` : ""}${component.name}`,
+ ]),
+);
+const actual = (root.dependsOn ?? [])
+ .map((ref) => {
+ const name = namesByRef.get(ref);
+ if (!name) throw new Error(`Unresolved SBOM root dependency ${ref}`);
+ return name;
+ })
+ .sort();
+const expected = Object.keys(pkg.dependencies ?? {}).sort();
+if (JSON.stringify(actual) !== JSON.stringify(expected)) {
+ throw new Error(
+ `Incomplete SBOM root dependencies: expected ${expected.length} [${expected.join(", ")}], got ${actual.length} [${actual.join(", ")}]`,
+ );
+}
+console.log(`SBOM root dependency coverage: ${actual.length}/${expected.length}`);
+NODE
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bd14204e..f14e869b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,6 +30,7 @@ jobs:
build-dashboard:
needs: [lint]
runs-on: ubuntu-latest
+ timeout-minutes: 20
permissions:
contents: read
steps:
@@ -48,6 +49,27 @@ jobs:
run: bunx tsc --noEmit
- run: bun run build:dashboard
+ - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
+ with:
+ node-version: "24"
+ - name: Pin npm to the release toolchain
+ run: npm install --global npm@11
+ - name: Pack and smoke the publication candidate
+ shell: bash
+ run: |
+ set -euo pipefail
+ bun run pack:release >/dev/null
+ tarball="$(node -p "const pkg=require('./package.json'); pkg.name.replace(/^@/,'').replace(/\//g,'-') + '-' + pkg.version + '.tgz'")"
+ bun run scripts/smoke-packed-package.ts "$tarball"
+ echo "TARBALL_PATH=$tarball" >> "$GITHUB_ENV"
+ - name: Install locked SBOM toolchain
+ run: npm ci --prefix .github/sbom-toolchain --ignore-scripts --omit=dev --no-audit --no-fund
+ - name: Generate SBOM
+ env:
+ SBOM_TOOLCHAIN_DIR: ${{ github.workspace }}/.github/sbom-toolchain
+ SBOM_OUTPUT: ${{ github.workspace }}/sbom.cdx.json
+ run: bash .github/scripts/generate-sbom.sh
+
test:
needs: [lint]
runs-on: ubuntu-latest
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index ddfe2b4f..8a1ea0bd 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -365,7 +365,9 @@ jobs:
with:
persist-credentials: false
ref: ${{ github.sha }}
- sparse-checkout: .github/sbom-toolchain
+ sparse-checkout: |
+ .github/sbom-toolchain
+ .github/scripts
path: .release-workflow
- name: Install locked SBOM toolchain
@@ -381,70 +383,10 @@ jobs:
shell: bash
env:
TARBALL_PATH: ${{ steps.pack.outputs.tarball }}
+ SBOM_TOOLCHAIN_DIR: ${{ github.workspace }}/.release-workflow/.github/sbom-toolchain
+ SBOM_OUTPUT: ${{ github.workspace }}/sbom.cdx.json
run: |
- set -euo pipefail
- TMPDIR="$(mktemp -d)"
- trap 'rm -rf "$TMPDIR"' EXIT
- tar -xzf "$TARBALL_PATH" -C "$TMPDIR"
- cd "$TMPDIR/package"
- node -e "const fs=require('node:fs'); const pkg=JSON.parse(fs.readFileSync('package.json','utf8')); delete pkg.workspaces; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');"
-
- # The SBOM is generated from the packed candidate before publishing to npm so
- # an unresolvable bundled dependency tree fails the release while npm,
- # the GitHub release, and the self-host image are still in sync.
- npm install --package-lock-only --ignore-scripts --omit=dev --no-audit --no-fund >/dev/null
-
- SBOM_LOG="$TMPDIR/cyclonedx.log"
- if ! node "$GITHUB_WORKSPACE/.release-workflow/.github/sbom-toolchain/node_modules/@cyclonedx/cyclonedx-npm/bin/cyclonedx-npm-cli.js" \
- --package-lock-only \
- --omit dev \
- --no-workspaces \
- --validate \
- -v -v \
- --output-file "$GITHUB_WORKSPACE/sbom.cdx.json" \
- >"$SBOM_LOG" 2>&1; then
- cat "$SBOM_LOG" >&2
- exit 1
- fi
- cat "$SBOM_LOG"
- if grep --fixed-strings --quiet "skipped validating BOM" "$SBOM_LOG"; then
- echo "CycloneDX skipped SBOM validation; refusing to publish." >&2
- exit 1
- fi
- if ! grep --fixed-strings --line-regexp --quiet "INFO | BOM result appears valid" "$SBOM_LOG"; then
- echo "CycloneDX did not confirm successful SBOM validation; refusing to publish." >&2
- exit 1
- fi
-
- node - "$TMPDIR/package/package.json" "$GITHUB_WORKSPACE/sbom.cdx.json" <<'NODE'
- const fs = require("node:fs");
- const [, , packagePath, bomPath] = process.argv;
- const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8"));
- const bom = JSON.parse(fs.readFileSync(bomPath, "utf8"));
- const rootRef = bom.metadata?.component?.["bom-ref"];
- const root = bom.dependencies?.find((entry) => entry.ref === rootRef);
- if (!root) throw new Error(`Missing SBOM root dependency node ${rootRef}`);
- const namesByRef = new Map(
- (bom.components ?? []).map((component) => [
- component["bom-ref"],
- `${component.group ? `${component.group}/` : ""}${component.name}`,
- ]),
- );
- const actual = (root.dependsOn ?? [])
- .map((ref) => {
- const name = namesByRef.get(ref);
- if (!name) throw new Error(`Unresolved SBOM root dependency ${ref}`);
- return name;
- })
- .sort();
- const expected = Object.keys(pkg.dependencies ?? {}).sort();
- if (JSON.stringify(actual) !== JSON.stringify(expected)) {
- throw new Error(
- `Incomplete SBOM root dependencies: expected ${expected.length} [${expected.join(", ")}], got ${actual.length} [${actual.join(", ")}]`,
- );
- }
- console.log(`SBOM root dependency coverage: ${actual.length}/${expected.length}`);
- NODE
+ bash "$GITHUB_WORKSPACE/.release-workflow/.github/scripts/generate-sbom.sh"
- name: Publish npm candidate after artifact gates
shell: bash
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 0e066864..0af95115 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@selftune/desktop",
- "version": "0.4.8",
+ "version": "0.4.9",
"private": true,
"description": "Native desktop control plane for SelfTune skill observability and improvement",
"homepage": "https://selftune.dev",
diff --git a/apps/local-dashboard/src/api.ts b/apps/local-dashboard/src/api.ts
index 40f2c8c1..b629792a 100644
--- a/apps/local-dashboard/src/api.ts
+++ b/apps/local-dashboard/src/api.ts
@@ -247,10 +247,16 @@ export interface LicenseDraftPreviewResponse {
function licenseDraftRequest(
path: "/api/v2/library/license/preview" | "/api/v2/library/license/apply",
- input: { skillId: string; terms: LicenseDraftTermsInput; previewId?: string },
+ input: {
+ skillId: string;
+ skillSetId?: string;
+ terms: LicenseDraftTermsInput;
+ previewId?: string;
+ },
): Promise {
return portfolioRequest(path, {
skill_id: input.skillId,
+ ...(input.skillSetId ? { set_id: input.skillSetId } : {}),
...(input.previewId ? { preview_id: input.previewId } : {}),
terms: {
copyright_holder: input.terms.copyrightHolder,
@@ -262,6 +268,7 @@ function licenseDraftRequest(
export function previewLibrarySkillLicense(input: {
skillId: string;
+ skillSetId?: string;
terms: LicenseDraftTermsInput;
}): Promise {
return licenseDraftRequest("/api/v2/library/license/preview", input);
@@ -269,6 +276,7 @@ export function previewLibrarySkillLicense(input: {
export function applyLibrarySkillLicense(input: {
skillId: string;
+ skillSetId?: string;
previewId: string;
terms: LicenseDraftTermsInput;
}): Promise {
diff --git a/apps/local/src/dashboard-operations.ts b/apps/local/src/dashboard-operations.ts
index 40d5a7a0..ad266901 100644
--- a/apps/local/src/dashboard-operations.ts
+++ b/apps/local/src/dashboard-operations.ts
@@ -1,3 +1,7 @@
+import {
+ previewSkillSetLicenseDraft,
+ applySkillSetLicenseDraft,
+} from "@selftune/runtime/skill-set-license-draft";
import { join, resolve } from "node:path";
import type { Database } from "bun:sqlite";
@@ -667,11 +671,13 @@ export class DashboardOperations extends Context.Service<
readonly previewLicenseDraft: (
skillId: string,
terms: LicenseDraftTerms,
+ skillSetId?: string,
) => Effect.Effect;
readonly applyLicenseDraft: (
skillId: string,
previewId: string,
terms: LicenseDraftTerms,
+ skillSetId?: string,
) => Effect.Effect;
readonly remoteLibraryShare: (
action: RemoteLibraryShareAction,
@@ -1493,30 +1499,44 @@ export function makeDashboardOperationsLayer(options: DashboardOperationOverride
},
),
),
- previewLicenseDraft: (skillId, terms) =>
- Effect.flatMap(libraryReport.read, (snapshot) =>
- attempt("library.license.preview", () => {
- const skill = snapshot.skills.find((candidate) => candidate.skillId === skillId);
- const location =
- skill?.locations.find((candidate) => candidate.active) ?? skill?.locations[0];
- if (!location) throw new Error("Refresh the Library and select this skill again.");
- return previewLocalLicenseDraft(location.skillPath, terms);
- }),
- ),
- applyLicenseDraft: (skillId, previewId, terms) =>
- Effect.flatMap(libraryReport.read, (snapshot) =>
- invalidatingAttempt("library.license.apply", () => {
- const skill = snapshot.skills.find((candidate) => candidate.skillId === skillId);
- const location =
- skill?.locations.find((candidate) => candidate.active) ?? skill?.locations[0];
- if (!location) throw new Error("Refresh the Library and select this skill again.");
- return applyLocalLicenseDraft({
- skillPath: location.skillPath,
- previewId,
- terms,
- });
- }),
- ),
+ previewLicenseDraft: (skillId, terms, skillSetId) =>
+ skillSetId !== undefined
+ ? attempt("library.license.preview", () =>
+ previewSkillSetLicenseDraft(skillSetId, skillId, terms, {
+ configRoot: options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR,
+ }),
+ )
+ : Effect.flatMap(libraryReport.read, (snapshot) =>
+ attempt("library.license.preview", () => {
+ const skill = snapshot.skills.find((candidate) => candidate.skillId === skillId);
+ const location =
+ skill?.locations.find((candidate) => candidate.active) ?? skill?.locations[0];
+ if (!location)
+ throw new Error("Refresh the Library and select this skill again.");
+ return previewLocalLicenseDraft(location.packagePath, terms);
+ }),
+ ),
+ applyLicenseDraft: (skillId, previewId, terms, skillSetId) =>
+ skillSetId !== undefined
+ ? invalidatingAttempt("library.license.apply", () =>
+ applySkillSetLicenseDraft(skillSetId, skillId, previewId, terms, {
+ configRoot: options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR,
+ }),
+ )
+ : Effect.flatMap(libraryReport.read, (snapshot) =>
+ invalidatingAttempt("library.license.apply", () => {
+ const skill = snapshot.skills.find((candidate) => candidate.skillId === skillId);
+ const location =
+ skill?.locations.find((candidate) => candidate.active) ?? skill?.locations[0];
+ if (!location)
+ throw new Error("Refresh the Library and select this skill again.");
+ return applyLocalLicenseDraft({
+ skillPath: location.packagePath,
+ previewId,
+ terms,
+ });
+ }),
+ ),
remoteLibraryShare: (action, input) =>
attempt(`remote_library.share.${action}`, () => runRemoteLibraryShare(action, input)),
workspace: (action, input) =>
diff --git a/apps/local/src/routes/library-transfer.ts b/apps/local/src/routes/library-transfer.ts
index 0131bf8f..6268e324 100644
--- a/apps/local/src/routes/library-transfer.ts
+++ b/apps/local/src/routes/library-transfer.ts
@@ -41,10 +41,12 @@ const LicenseDraftTermsBody = Schema.Struct({
year: Schema.Number,
});
const PreviewLicenseBody = Schema.Struct({
+ set_id: Schema.optional(Schema.String),
skill_id: Schema.String,
terms: LicenseDraftTermsBody,
});
const ApplyLicenseBody = Schema.Struct({
+ set_id: Schema.optional(Schema.String),
skill_id: Schema.String,
preview_id: Schema.String,
terms: LicenseDraftTermsBody,
@@ -141,7 +143,11 @@ export const routeLibraryTransfer = Effect.fn("DashboardApplication.routeLibrary
);
return {
response: Response.json(
- yield* operations.previewLicenseDraft(body.skill_id, licenseTerms(body.terms)),
+ yield* operations.previewLicenseDraft(
+ body.skill_id,
+ licenseTerms(body.terms),
+ body.set_id,
+ ),
{ headers: dashboardCorsHeaders() },
),
installed: false,
@@ -160,6 +166,7 @@ export const routeLibraryTransfer = Effect.fn("DashboardApplication.routeLibrary
body.skill_id,
body.preview_id,
licenseTerms(body.terms),
+ body.set_id,
),
{ headers: dashboardCorsHeaders() },
),
diff --git a/apps/local/tests/dashboard-operations.test.ts b/apps/local/tests/dashboard-operations.test.ts
index 79d85b6b..c090aed4 100644
--- a/apps/local/tests/dashboard-operations.test.ts
+++ b/apps/local/tests/dashboard-operations.test.ts
@@ -1,3 +1,5 @@
+import { createSkillSet, getSkillSet } from "@selftune/library";
+import { handleDashboardApplicationRoute } from "../src/routes/application.js";
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -342,3 +344,61 @@ describe("DashboardOperations", () => {
}
});
});
+
+test("license routes preview and revise a Set with no installed Library location", async () => {
+ const root = mkdtempSync(join(tmpdir(), "selftune-license-route-"));
+ const source = join(root, "source");
+ mkdirSync(source);
+ writeFileSync(
+ join(source, "SKILL.md"),
+ "---\nname: marketing-social\ndescription: Marketing\n---\n",
+ );
+ const options = { configRoot: join(root, "config") };
+ const set = createSkillSet(
+ {
+ name: "Ithraa",
+ harnesses: ["codex"],
+ skills: [{ name: "marketing-social", package_path: source }],
+ },
+ options,
+ );
+ const runtime = ManagedRuntime.make(
+ makeDashboardOperationsLayer({
+ skillSetConfigRoot: options.configRoot,
+ libraryLoader: () => {
+ throw new Error("Installed Library must not be consulted for pinned Set drafts");
+ },
+ }),
+ );
+ const origin = "http://127.0.0.1:3141";
+ const body = {
+ set_id: set.set_id,
+ skill_id: "marketing-social",
+ terms: { copyright_holder: "Daniel Petro", licensed_organization: "Ithraa Center", year: 2026 },
+ };
+ async function request(action: string, input: unknown, status = 200) {
+ const req = new Request(`${origin}/api/v2/library/license/${action}`, {
+ method: "POST",
+ headers: { Origin: origin, "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ });
+ const result = await runtime.runPromise(
+ handleDashboardApplicationRoute(req, new URL(req.url), { allowedOrigins: new Set([origin]) }),
+ );
+ expect(result?.status).toBe(status);
+ if (!result) throw new Error("License route did not handle the request");
+ return result.json();
+ }
+ try {
+ const preview = await request("preview", body);
+ expect(preview.files).toHaveLength(2);
+ expect(getSkillSet(set.set_id, options).revision).toBe(1);
+ await request("apply", { ...body, preview_id: preview.previewId });
+ expect(getSkillSet(set.set_id, options).revision).toBe(2);
+ const blocked = await request("preview", body, 409);
+ expect(JSON.stringify(blocked)).toContain("already bundles a LICENSE file");
+ } finally {
+ await runtime.dispose();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/bun.lock b/bun.lock
index 361255b3..5822f4b0 100644
--- a/bun.lock
+++ b/bun.lock
@@ -82,7 +82,7 @@
},
"apps/desktop": {
"name": "@selftune/desktop",
- "version": "0.4.8",
+ "version": "0.4.9",
"dependencies": {
"@selftune/config": "workspace:*",
"@sentry/electron": "^7.8.0",
diff --git a/package.json b/package.json
index 7131d843..8d35df7c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "selftune",
- "version": "0.4.8",
+ "version": "0.4.9",
"description": "Skill-level observability and self-improvement for AI agents — monitors skill routing, detects missed triggers, and evolves descriptions automatically",
"keywords": [
"agent",
diff --git a/packages/dashboard-core/src/host/adapter.ts b/packages/dashboard-core/src/host/adapter.ts
index 5c89e5f1..f97f34e6 100644
--- a/packages/dashboard-core/src/host/adapter.ts
+++ b/packages/dashboard-core/src/host/adapter.ts
@@ -173,11 +173,11 @@ export interface DashboardLibraryActions {
backup?: DashboardLibraryAction;
share?: DashboardShareAction;
previewLicenseDraft?: DashboardLibraryAction<
- { skillId: string; terms: LibraryLicenseDraftTerms },
+ { skillId: string; skillSetId?: string; terms: LibraryLicenseDraftTerms },
LibraryLicenseDraftPreviewModel
>;
applyLicenseDraft?: DashboardLibraryAction<
- { skillId: string; previewId: string; terms: LibraryLicenseDraftTerms },
+ { skillId: string; skillSetId?: string; previewId: string; terms: LibraryLicenseDraftTerms },
LibraryLicenseDraftPreviewModel
>;
installTargets?: ReadonlyArray<{ id: LibraryInstallAgent; label: string }>;
diff --git a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx
index eef1754f..7bbbd1e7 100644
--- a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx
+++ b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx
@@ -211,9 +211,49 @@ describe("ShareSkillSetDialog", () => {
fireEvent.click(screen.getByRole("button", { name: "Apply reviewed license" }));
await waitFor(() =>
expect(apply).toHaveBeenCalledWith(
- expect.objectContaining({ skillId: "reviewer", previewId: "preview-1" }),
+ expect.objectContaining({
+ skillId: "reviewer",
+ skillSetId: "review-set",
+ previewId: "preview-1",
+ }),
),
);
expect(screen.getByRole("button", { name: "Create link" })).toBeTruthy();
});
+ it("shows draft failures and lets the user retry without leaving the draft", async () => {
+ const preview = vi.fn(async () => {
+ throw new Error("This skill already bundles a LICENSE file.");
+ });
+ render(
+ {
+ throw new Error("Missing license");
+ }),
+ )}
+ previewLicenseAction={{ access: "available", execute: preview }}
+ applyLicenseAction={{ access: "available", execute: vi.fn() }}
+ open
+ onOpenChange={() => {}}
+ />,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Create link" }));
+ fireEvent.click(await screen.findByRole("button", { name: "Draft missing license" }));
+ fireEvent.change(screen.getByLabelText("Copyright holder"), {
+ target: { value: "Daniel Petro" },
+ });
+ fireEvent.change(screen.getByLabelText("Licensed organization"), {
+ target: { value: "Ithraa Center" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Review draft" }));
+ expect((await screen.findByRole("alert")).textContent).toBe(
+ "This skill already bundles a LICENSE file.",
+ );
+ expect(preview).toHaveBeenCalledWith(
+ expect.objectContaining({ skillId: "reviewer", skillSetId: "review-set" }),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Review draft" }));
+ await waitFor(() => expect(preview).toHaveBeenCalledTimes(2));
+ });
});
diff --git a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx
index 74693d40..510e4dac 100644
--- a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx
+++ b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx
@@ -108,7 +108,11 @@ export function ShareSkillSetDialog({
setError(null);
try {
setLicensePreview(
- await previewLicenseAction.execute({ skillId: draftSkillId, terms: terms() }),
+ await previewLicenseAction.execute({
+ skillId: draftSkillId,
+ skillSetId: skillSet.id,
+ terms: terms(),
+ }),
);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
@@ -124,6 +128,7 @@ export function ShareSkillSetDialog({
try {
await applyLicenseAction.execute({
skillId: draftSkillId,
+ skillSetId: skillSet.id,
previewId: licensePreview.previewId,
terms: terms(),
});
@@ -391,29 +396,30 @@ export function ShareSkillSetDialog({
Every included skill must have distributable license evidence. Usage telemetry is off
unless the recipient separately opts in.
- {error ? (
-
-
- {error}
-
- {previewLicenseAction?.access === "available" &&
- applyLicenseAction?.access === "available" &&
- /license/i.test(error) ? (
-
- ) : null}
-
- ) : null}
)}
+ {error ? (
+
+
+ {error}
+
+ {!draftingLicense &&
+ previewLicenseAction?.access === "available" &&
+ applyLicenseAction?.access === "available" &&
+ /license/i.test(error) ? (
+
+ ) : null}
+
+ ) : null}
{draftingLicense ? (