Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions .github/workflows/wizard-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
files=$(gh api "repos/$GH_REPO/pulls/$PR_NUMBER/files" --paginate --jq '.[] | .filename, (.previous_filename // empty)')
if grep -q '^plugins/spec-kit-copilot-wizard/' <<< "$files"; then
if grep -qE '^(plugins/spec-kit-copilot-wizard/|spec-kit-extensions/canvas-design/)' <<< "$files"; then
echo "wizard=true" >> "$GITHUB_OUTPUT"
else
echo "wizard=false" >> "$GITHUB_OUTPUT"
Expand All @@ -45,6 +45,13 @@ jobs:
cache: npm
cache-dependency-path: plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package-lock.json
- run: npm ci
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python -m pip install 'specify-cli>=1.0.7'
- run: node --test test/designer-launch.test.mjs test/designer-pages.test.mjs test/canvas-designer-dialog.test.mjs ../speckit-canvas-designer/test/provider.test.mjs
env:
DESIGNER_CLI_TESTS: '1'
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
Expand All @@ -54,9 +61,29 @@ jobs:
path: plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test-results/
retention-days: 7

windows-pages:
name: Designer pages with Windows uv
needs: changes
if: needs.changes.outputs.wizard == 'true'
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: astral-sh/setup-uv@v6
- run: uv tool install "specify-cli>=1.0.7"
- name: Test pages using isolated Specify environment
shell: pwsh
run: |
$env:PATH = "$(uv tool dir --bin);$env:PATH"
node --test plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/designer-pages.test.mjs plugins/spec-kit-copilot-wizard/extensions/speckit-canvas-designer/test/provider.test.mjs
env:
DESIGNER_CLI_TESTS: '1'

gate:
name: Wizard E2E gate
needs: [changes, browser]
needs: [changes, browser, windows-pages]
if: always()
runs-on: ubuntu-latest
steps:
Expand All @@ -65,13 +92,14 @@ jobs:
DETECTION: ${{ needs.changes.result }}
WIZARD: ${{ needs.changes.outputs.wizard }}
BROWSER: ${{ needs.browser.result }}
WINDOWS: ${{ needs.windows-pages.result }}
run: |
if [[ "$DETECTION" != "success" ]]; then
echo "::error::Could not determine whether Wizard files changed"
exit 1
fi
if [[ "$WIZARD" == "true" && "$BROWSER" != "success" ]]; then
echo "::error::Wizard browser tests did not pass ($BROWSER)"
if [[ "$WIZARD" == "true" && ( "$BROWSER" != "success" || "$WINDOWS" != "success" ) ]]; then
echo "::error::Wizard tests did not pass (browser=$BROWSER, windows=$WINDOWS)"
exit 1
fi
echo "Wizard E2E check complete (changed: $WIZARD)"
Original file line number Diff line number Diff line change
@@ -1,28 +1,79 @@
# Spec Kit Canvas Designer shell
# Spec Kit Canvas Designer

This extension is **under development and not ready for use**. It ships inside
the `spec-kit-copilot-wizard` plugin. Installing that plugin registers both the
Wizard and Designer canvases.

The intended primary flow launches Designer **from the Wizard**. The Wizard
The primary flow launches Designer **from the Wizard**. The Wizard
provides a handoff containing its configured pipeline and selected presets,
extensions, and bundles; these will guide the generation of a workflow-specific
canvas in a later update. For now, Designer displays only a shell (with a
handoff summary when provided): it does not generate a canvas or install
selected customizations.
extensions, and bundles. The child agent invokes the Spec Kit skills to initialize
the child project when needed and install the required Canvas Design extension
and approved customizations, inspect CLI results, and report setup failures.
The agent resolves JSON pages through the composed load-page skill and submits
them to the provider's custom tool before opening Designer. The provider does not
independently resolve templates or verify package installation. Essentials provides the identity controls;
Artifacts, Appearance, and Result Badges are empty placeholders. Save and
Generate remain disabled.

The Wizard launch flow supplies `{ "handoffId": "<id>" }` and asks the child
session to write the handoff JSON to
`speckit-canvas-designer/handoffs/<id>/handoff.json` **under that child's
`session.workspacePath`** before opening the canvas. The provider never writes
that file. Opening `speckit-canvas-designer` without input (or with `{}`)
`session.workspacePath`**. The handoff stays unchanged. In its own checkout,
the agent invokes `speckit-init` only if uninitialized,
`speckit-extension` for the required local DEV package, `speckit-bundle` for approved
bundles, and `speckit-extension` / `speckit-preset` for remaining selections.
The agent handles CLI results and avoids duplicate installs of components just
installed by a selected bundle. ID/version alone is not proof of an existing
package's source. Setup errors are reported in the child conversation and stop
the launch. Downloads, installs and package verification belong to the agent
following the skills, just as in the Wizard.

After init and again after all installations, the agent calls the provider's
`speckit_designer_reload_skills` tool. This uses `session.rpc.skills.reload()`, the
same mechanism as the Wizard's reload action; printing `/skills reload` is not
sufficient. The tool is available without opening a canvas. Missing reload support,
RPC failures or skill-loading errors stop setup. Warnings are logged.

There is no setup-verification script, package-provenance gate or readiness
receipt. Once setup and skill reload succeed, the agent invokes the generated
`speckit-canvas-design-load-page` skill, including preset-appended page declarations.
The skill directs the agent to run `specify preset resolve` for each name and pass
the complete set of resolved JSON paths to `speckit_designer_load_pages`. That
custom tool is registered by this provider, works before the panel opens, and
only validates and stores the pages. It does not locate a Python interpreter,
import Specify internals, or run subprocesses.

Only after the tool succeeds does the agent open Designer. The provider validates
the handoff and reads the persisted `pages.json` model beside that handoff in the
session artifacts. This is canvas state, not a setup receipt. Reopening or provider
restart uses that model without rerunning resolution. A successful open means valid pages and
a successful skill reload, not an independent attestation of package installation.
Opening also performs a real skill reload and fails if it reports errors.
The provider comes from the installed plugin, not a copy in the
child checkout. Opening `speckit-canvas-designer` without input (or with `{}`)
shows an empty shell, not a generated canvas.

Loading, reloading and recovering pages use the child checkout reported by
`session.rpc.metadata.snapshot()`, through the Wizard's shared workspace resolver.
The provider caches a successfully resolved checkout, never falls back to its
process cwd or the session-artifact directory, and reports unavailable metadata
as an error. The saved model's checkout binding remains enforced.

A supplied ID must match the bounded handoff ID pattern; the provider checks the
handoff structure, fingerprint, size, and session-artifact boundary. A supplied ID
with a missing or invalid file is an error, not an empty shell. The HTTP shell
with a missing or invalid file, changed bundled source, or invalid page templates is
an error, not an empty shell. The HTTP shell
binds to loopback and requires an unguessable URL token.

**Reload pages** reloads the session's skills and queues the composed load command
with a request token. A successful full-batch load atomically replaces the stored
model and updates matching open panels over SSE. The UI confirms discarding drafts,
retains them on failure, and offers explicit retry if an agent turn never reports
a result. Older request tokens are rejected. Tab switches and SSE reconnects do
not invoke the agent. Package changes are picked up on the next explicit reload,
not by a watcher. See the [package documentation](../../../../spec-kit-extensions/canvas-design/README.md)
for template registration, overrides and additional-page examples.

Run the provider tests with:

```bash
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,158 @@
import { createCanvas, CanvasError, joinSession } from "@github/copilot-sdk/extension";
import { readHandoff } from "./handoff.mjs";
import { randomUUID } from "node:crypto";
import { isAbsolute } from "node:path";
import { readHandoff, validateHandoffId } from "./handoff.mjs";
import { startShell } from "./server.mjs";
import { assertPageCommand, loadDesignerPages, PAGE_NAME, storeDesignerPages } from "./pages.mjs";
import { fetchSessionRepoPath } from "../speckit-wizard-canvas/env/workspace.mjs";

const servers = new Map();
const loads = new Map();
const writing = new Set();
const handoffIdSchema = { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$" };
let checkout;

async function getCheckout() {
if (!checkout) {
const path = await fetchSessionRepoPath(session);
if (!path || !isAbsolute(path)) {
throw new Error("Designer session checkout is unavailable in session metadata");
}
checkout = path;
}
return checkout;
}

function publish(handoffId, model) {
for (const entry of servers.values()) {
if (entry.handoffId === handoffId) entry.update(model, loads.get(handoffId));
}
}

function failLoad(handoffId, error, requestId) {
if (requestId && loads.get(handoffId)?.requestId !== requestId) return;
loads.set(handoffId, { pending: false, error });
publish(handoffId);
}

async function requestReload(handoffId, retry = false) {
if (writing.has(handoffId)) throw new Error("Designer is applying pages; wait for this load to finish");
if (loads.get(handoffId)?.pending && !retry) throw new Error("Designer page reload is already pending");
const requestId = randomUUID();
loads.set(handoffId, { pending: true, requestId, error: "" });
publish(handoffId);
try {
await readHandoff(session.workspacePath, handoffId);
await assertPageCommand(await getCheckout());
await reloadSessionSkills();
const prompt = `/speckit-canvas-design-load-page
Invoke the skill tool with name "speckit-canvas-design-load-page" before any other tool call.
Use the generated, preset-composed skill. Context: ${JSON.stringify({ handoffId, requestId })}.
Resolve and submit the complete page set once. Report resolution failures with the same custom load tool's error input. Do not open another panel or install packages.`;
setImmediate(() => {
Promise.resolve().then(() => {
if (loads.get(handoffId)?.requestId === requestId) return session.send({ prompt });
}).catch((error) => {
failLoad(handoffId, error.message, requestId);
const message = `Designer reload dispatch failed: ${error.message}`;
void Promise.resolve().then(() => session.log(message, { level: "error" }))
.catch((logError) => console.error(message, `Logging failed: ${logError}`));
});
});
return { queued: true };
} catch (error) {
failLoad(handoffId, error.message, requestId);
throw error;
}
}

async function acceptPages(input) {
let id, acquired = false, current = false;
try {
id = validateHandoffId(input.handoffId);
if (writing.has(id)) throw new Error("Designer is already applying a page load");
const expected = loads.get(id)?.requestId;
if ((expected || input.requestId) && expected !== input.requestId) {
throw new Error("Designer page load was superseded; use the current reload request");
}
current = true;
const handoff = await readHandoff(session.workspacePath, id);
if (Object.hasOwn(input, "error")) throw new Error(input.error);
// Recheck after asynchronous handoff reads, before taking the write lock.
if (writing.has(id)) {
current = false;
throw new Error("Designer is already applying a page load");
}
writing.add(id);
acquired = true;
const project = await getCheckout();
await assertPageCommand(project);
const model = await storeDesignerPages(handoff, session.workspacePath, project, input.pages,
() => loads.get(id)?.requestId === expected);
loads.set(id, { pending: false, error: "" });
publish(id, model);
return JSON.stringify({ loaded: true, handoffId: id,
pages: model.pages.map((page) => ({ name: page.id, title: page.title })),
revision: model.revision });
} catch (error) {
if (id && current && loads.get(id)?.requestId === input.requestId) {
failLoad(id, error.message, input.requestId);
}
return { resultType: "failure", textResultForLlm: error.message };
} finally {
if (acquired) writing.delete(id);
}
}

async function reloadSessionSkills() {
if (!session.rpc?.skills?.reload) throw new Error("Session skill reload is unavailable");
const diagnostics = await session.rpc.skills.reload();
if (!Array.isArray(diagnostics?.errors) || !Array.isArray(diagnostics?.warnings)
|| [...diagnostics.errors, ...diagnostics.warnings].some((message) => typeof message !== "string")) {
throw new Error("Invalid session skill reload diagnostics");
}
for (const warning of diagnostics.warnings) {
await session.log(warning, { level: "warning" });
}
if (diagnostics.errors.length) {
throw new Error(`Session skill reload failed: ${diagnostics.errors.join("; ")}`);
}
return diagnostics;
}

const session = await joinSession({
tools: [{
name: "speckit_designer_reload_skills",
description: "Reload this session's skills after Spec Kit init or package installation, before opening Designer. Reports reload failures; does not install anything.",
parameters: { type: "object", properties: {}, additionalProperties: false },
handler: async () => JSON.stringify(await reloadSessionSkills()),
}, {
name: "speckit_designer_load_pages",
description: "Validate agent-resolved Designer JSON paths and store the complete page model before opening, or update open panels. Does not resolve templates or install packages. Report resolution failures with error instead of pages.",
parameters: {
type: "object", additionalProperties: false, required: ["handoffId"],
properties: {
handoffId: handoffIdSchema,
requestId: { type: "string", format: "uuid" },
pages: { type: "array", minItems: 1, maxItems: 100, items: {
type: "object", additionalProperties: false, required: ["name", "path"],
properties: { name: { type: "string", pattern: PAGE_NAME },
path: { type: "string", minLength: 1, maxLength: 4096 } },
} },
error: { type: "string", minLength: 1, maxLength: 32768 },
},
oneOf: [{ required: ["pages"], not: { required: ["error"] } },
{ required: ["error"], not: { required: ["pages"] } }],
},
handler: acceptPages,
}],
canvases: [createCanvas({
id: "speckit-canvas-designer",
displayName: "Spec Kit Canvas Designer",
description: "Open the Designer shell, optionally with a validated Wizard handoff.",
description: "Open Designer using pages already validated by the composed load-page skill and custom tool.",
inputSchema: {
type: "object", additionalProperties: false,
properties: { handoffId: {
type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$",
} },
properties: { handoffId: handoffIdSchema },
},
open: async (ctx) => {
const handoffId = ctx.input?.handoffId;
Expand All @@ -29,10 +168,18 @@ const session = await joinSession({
if (previous && previous.handoffId === handoffId) {
return { title: "Spec Kit Canvas Designer", url: previous.url };
}
const next = await startShell(handoff);
servers.set(ctx.instanceId, { ...next, handoffId });
if (previous) await previous.close();
return { title: "Spec Kit Canvas Designer", url: next.url };
try {
const model = handoff
? await loadDesignerPages(handoff, session.workspacePath, await getCheckout()) : null;
if (handoff) await reloadSessionSkills();
const next = await startShell(handoff, model,
{ reload: (retry) => requestReload(handoffId, retry), load: loads.get(handoffId) });
servers.set(ctx.instanceId, { ...next, handoffId });
if (previous) await previous.close();
return { title: "Spec Kit Canvas Designer", url: next.url };
} catch (error) {
throw new CanvasError("designer_open_failed", error.message);
}
},
onClose: async ({ instanceId }) => {
const entry = servers.get(instanceId);
Expand Down
Loading
Loading