Skip to content
Merged
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
10 changes: 8 additions & 2 deletions .zdp/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@
"apiVersion": 1,
"id": "scheduler",
"name": "Scheduler",
"version": "0.1.4",
"version": "0.1.5",
"description": "Create normal ZCode tasks from timezone-aware cron schedules while ZCode is open.",
"entrypoints": {
"main": "dist/main.cjs",
"renderer": "dist/renderer.js"
},
"engines": {
"host": ">=0.2.0 <1",
"host": ">=0.3.4 <1",
"zcode": ">=3.3.6"
},
"capabilities": [
"zcode.workspaces.read",
"zcode.tasks.run",
"ui.pages",
"ui.overlays"
],
"pages": [
{"id": "jobs", "title": "Scheduler"}
]
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Scheduled runs are ordinary persistent ZCode tasks. They appear immediately in t

Open **Extensions → Available** in ZCode and install Scheduler. The host verifies the release archive checksum before staging it. New installs and updates take effect on the next ZCode launch.

Manual installation is also supported: download `zcode-scheduler-v0.1.4.zip` from the [latest release](https://github.com/notmike101/zcode-scheduler/releases/latest), extract the `scheduler` folder, and select it from **Extensions → Installed → Install folder**.
Manual installation is also supported: download `zcode-scheduler-v0.1.5.zip` from the [latest release](https://github.com/notmike101/zcode-scheduler/releases/latest), extract the `scheduler` folder, and select it from **Extensions → Installed → Install folder**.

## Scheduling behavior

Expand All @@ -30,7 +30,7 @@ Manual installation is also supported: download `zcode-scheduler-v0.1.4.zip` fro
```powershell
bun install
bun run check
bun run release:package -- --tag v0.1.4
bun run release:package -- --tag v0.1.5
```

The release command writes a ZIP, its SHA-256 checksum, and `extension-update.json` to `dist/release`.
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zcode-scheduler",
"version": "0.1.4",
"version": "0.1.5",
"description": "Schedule normal, visible ZCode tasks with timezone-aware cron expressions.",
"private": true,
"license": "MIT",
Expand All @@ -24,7 +24,7 @@
"zod": "^4.1.12"
},
"devDependencies": {
"@notmike101/zcode-extension-sdk": "0.3.0",
"@notmike101/zcode-extension-sdk": "0.3.4",
"@types/bun": "^1.3.4",
"@types/node": "^24.10.1",
"typescript": "^5.9.3"
Expand Down
52 changes: 34 additions & 18 deletions src/renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {render} from "preact";
import {useEffect, useMemo, useState} from "preact/hooks";
import type {ExtensionBridge as ZdpBridge} from "@notmike101/zcode-extension-sdk/renderer";
import {defineRendererExtension, type RendererExtensionContext} from "@notmike101/zcode-extension-sdk/renderer";
import type {RunRecord, SchedulerJob} from "./schemas.ts";
import styles from "./scheduler.css";

Expand Down Expand Up @@ -46,21 +46,27 @@ const emptyDraft = (timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
maxParallel: 4,
});

window.ZDP_REGISTER_PLUGIN_RENDERER?.({
export const schedulerRenderer = defineRendererExtension({
id: "scheduler",
mount(container, bridge) {
mountPage(pageId, container, context) {
if (pageId !== "jobs") return;
const style = document.createElement("style");
style.textContent = styles;
container.replaceChildren(style);
const mount = document.createElement("div");
mount.className = "scheduler-root";
container.append(mount);
render(<SchedulerPage bridge={bridge}/>, mount);
return () => render(null, mount);
render(<SchedulerPage context={context}/>, mount);
return () => {
render(null, mount);
container.replaceChildren();
};
},
});

function SchedulerPage({bridge}: {bridge: ZdpBridge}) {
if (typeof window !== "undefined") window.ZDP_REGISTER_PLUGIN_RENDERER?.(schedulerRenderer);

function SchedulerPage({context}: {context: RendererExtensionContext}) {
const [state, setState] = useState<SchedulerState>();
const [draft, setDraft] = useState<JobDraft>(emptyDraft());
const [editing, setEditing] = useState(false);
Expand All @@ -69,14 +75,13 @@ function SchedulerPage({bridge}: {bridge: ZdpBridge}) {
const [error, setError] = useState<string>();
const [busy, setBusy] = useState(false);

const invoke = <T,>(method: string, payload?: unknown) => bridge.invoke<T>("plugin:invoke", {pluginId: "scheduler", method, payload});
const invoke = <T,>(method: string, payload?: unknown) => context.ipc.invoke<T>(method, payload);
const refresh = () => invoke<SchedulerState>("get-state").then(setState).catch((cause) => setError(errorText(cause)));

useEffect(() => {
void refresh();
const listener = () => void refresh();
window.addEventListener("plugin:scheduler:state-changed", listener);
return () => window.removeEventListener("plugin:scheduler:state-changed", listener);
const subscription = context.ipc.on("state-changed", () => void refresh());
return () => subscription.dispose();
}, []);

useEffect(() => {
Expand All @@ -95,7 +100,12 @@ function SchedulerPage({bridge}: {bridge: ZdpBridge}) {
};

const save = async () => {
if (draft.mode === "yolo" && !window.confirm("Yolo mode permits unattended workspace changes. Save this job with Yolo permissions?")) return;
if (draft.mode === "yolo" && !await context.ui.showDialog({
title: "Confirm Yolo permissions",
message: "Yolo mode permits unattended workspace changes. Save this job with Yolo permissions?",
confirmLabel: "Save job",
cancelLabel: "Cancel",
})) return;
const model = draft.providerId.trim() && draft.modelId.trim()
? {providerId: draft.providerId.trim(), modelId: draft.modelId.trim(), ...(draft.variant.trim() ? {variant: draft.variant.trim()} : {})}
: undefined;
Expand Down Expand Up @@ -145,7 +155,7 @@ function SchedulerPage({bridge}: {bridge: ZdpBridge}) {
<div class="scheduler-heading-actions"><button class={tab === "jobs" ? "active" : ""} onClick={() => setTab("jobs")}>Jobs</button><button class={tab === "history" ? "active" : ""} onClick={() => setTab("history")}>History</button>
{tab === "jobs" && <button class="primary" onClick={() => {setDraft(emptyDraft(state?.timezone)); setEditing(true);}}>New job</button>}</div></div>
{error && <div class="scheduler-error">{error}</div>}
{editing && tab === "jobs" && <JobEditor draft={draft} setDraft={setDraft} preview={preview} busy={busy} bridge={bridge} save={save} cancel={() => {setEditing(false); setDraft(emptyDraft(state?.timezone));}}/>}
{editing && tab === "jobs" && <JobEditor draft={draft} setDraft={setDraft} preview={preview} busy={busy} context={context} save={save} cancel={() => {setEditing(false); setDraft(emptyDraft(state?.timezone));}}/>}
{!editing && tab === "jobs" && <div class="scheduler-jobs">
{state?.jobs.map((job) => <article class="scheduler-job">
<div class="scheduler-job-top"><div><h3>{job.name}</h3><p><code>{job.cron}</code> · {job.timezone}</p></div><span class={job.enabled ? "enabled" : "disabled"}>{job.enabled ? "Enabled" : "Paused"}</span></div>
Expand All @@ -156,9 +166,15 @@ function SchedulerPage({bridge}: {bridge: ZdpBridge}) {
<button disabled={busy} onClick={() => edit(job)}>Edit</button><button disabled={busy} onClick={() => edit(job, true)}>Duplicate</button>
<button disabled={busy} onClick={() => void run(() => invoke("set-enabled", {id: job.id, enabled: !job.enabled}))}>{job.enabled ? "Pause" : "Enable"}</button>
{activeByJob.get(job.id) && <button class="warn" disabled={busy} onClick={() => void run(() => invoke("cancel-run", {id: activeByJob.get(job.id)!.id}))}>Cancel active</button>}
<button class="danger" disabled={busy || Boolean(activeByJob.get(job.id))} onClick={() => {
if (window.confirm(`Delete scheduled job “${job.name}”?`)) void run(() => invoke("delete-job", {id: job.id}));
}}>Delete</button>
<button class="danger" disabled={busy || Boolean(activeByJob.get(job.id))} onClick={() => void (async () => {
const confirmed = await context.ui.showDialog({
title: "Delete scheduled job",
message: `Delete scheduled job “${job.name}”?`,
confirmLabel: "Delete",
cancelLabel: "Cancel",
});
if (confirmed) await run(() => invoke("delete-job", {id: job.id}));
})()}>Delete</button>
</div>
</article>)}
{state && state.jobs.length === 0 && <div class="scheduler-empty">No scheduled jobs yet.</div>}
Expand All @@ -167,17 +183,17 @@ function SchedulerPage({bridge}: {bridge: ZdpBridge}) {
</div>;
}

function JobEditor({draft, setDraft, preview, busy, bridge, save, cancel}: {
function JobEditor({draft, setDraft, preview, busy, context, save, cancel}: {
draft: JobDraft; setDraft: (value: JobDraft) => void; preview?: {nextRuns: string[]; description: string}; busy: boolean;
bridge: ZdpBridge; save: () => Promise<void>; cancel: () => void;
context: RendererExtensionContext; save: () => Promise<void>; cancel: () => void;
}) {
const set = <K extends keyof JobDraft>(key: K, value: JobDraft[K]) => setDraft({...draft, [key]: value});
return <section class="scheduler-editor"><div class="scheduler-grid">
<label>Name<input value={draft.name} onInput={(e) => set("name", e.currentTarget.value)} placeholder="Morning repository review"/></label>
<label>Cron expression<input value={draft.cron} onInput={(e) => set("cron", e.currentTarget.value)} placeholder="0 9 * * 1-5"/></label>
<label>Timezone<input value={draft.timezone} onInput={(e) => set("timezone", e.currentTarget.value)} placeholder="America/Chicago"/></label>
<label>Permission mode<select value={draft.mode} onChange={(e) => set("mode", e.currentTarget.value as JobDraft["mode"])}><option value="plan">Plan</option><option value="build">Build</option><option value="edit">Edit</option><option value="yolo">Yolo</option></select></label>
<label class="wide">Workspace<div class="scheduler-input-button"><input value={draft.workspacePath} onInput={(e) => set("workspacePath", e.currentTarget.value)} placeholder="D:\\project"/><button type="button" onClick={async () => {const folder = await bridge.invoke<string | null>("host:chooseDirectory"); if (folder) set("workspacePath", folder);}}>Browse</button></div></label>
<label class="wide">Workspace<div class="scheduler-input-button"><input value={draft.workspacePath} onInput={(e) => set("workspacePath", e.currentTarget.value)} placeholder="D:\\project"/><button type="button" onClick={async () => {const folder = await context.ui.chooseDirectory(); if (folder) set("workspacePath", folder);}}>Browse</button></div></label>
<label class="wide">Prompt<textarea value={draft.prompt} onInput={(e) => set("prompt", e.currentTarget.value)} rows={6} placeholder="Describe the task ZCode should perform…"/></label>
<label>Overlap<select value={draft.overlapPolicy} onChange={(e) => set("overlapPolicy", e.currentTarget.value as JobDraft["overlapPolicy"])}><option value="skip">Skip</option><option value="queue-one">Queue one</option><option value="parallel">Parallel</option></select></label>
{draft.overlapPolicy === "parallel" && <label>Max parallel<input type="number" min="1" max="4" value={draft.maxParallel} onInput={(e) => set("maxParallel", Number(e.currentTarget.value))}/></label>}
Expand Down
34 changes: 25 additions & 9 deletions tests/release.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,38 @@
import {describe, expect, test} from "bun:test";
import {readFileSync} from "node:fs";
import path from "node:path";
import {assertReleaseVersion, releaseBaseName, resolveReleaseTag} from "../scripts/release-helpers.ts";
import {schedulerRenderer} from "../src/renderer.tsx";

describe("release metadata", () => {
test("accepts a matching semantic version tag", () => {
expect(assertReleaseVersion("v0.1.4", "0.1.4", "0.1.4")).toBe("0.1.4");
expect(releaseBaseName("0.1.4")).toBe("zcode-scheduler-v0.1.4");
expect(assertReleaseVersion("v0.1.5", "0.1.5", "0.1.5")).toBe("0.1.5");
expect(releaseBaseName("0.1.5")).toBe("zcode-scheduler-v0.1.5");
});

test("ignores pull-request merge refs when inferring a release tag", () => {
expect(resolveReleaseTag(undefined, undefined, "1/merge", "0.1.4")).toBe("v0.1.4");
expect(resolveReleaseTag(undefined, "branch", "main", "0.1.4")).toBe("v0.1.4");
expect(resolveReleaseTag(undefined, "tag", "v0.1.4", "0.1.4")).toBe("v0.1.4");
expect(resolveReleaseTag("v0.1.5", "tag", "v0.1.4", "0.1.4")).toBe("v0.1.5");
expect(resolveReleaseTag(undefined, undefined, "1/merge", "0.1.5")).toBe("v0.1.5");
expect(resolveReleaseTag(undefined, "branch", "main", "0.1.5")).toBe("v0.1.5");
expect(resolveReleaseTag(undefined, "tag", "v0.1.5", "0.1.5")).toBe("v0.1.5");
expect(resolveReleaseTag("v0.1.6", "tag", "v0.1.5", "0.1.5")).toBe("v0.1.6");
});

test("rejects malformed or inconsistent versions", () => {
expect(() => assertReleaseVersion("release-0.1.4", "0.1.4", "0.1.4")).toThrow("strict vX.Y.Z");
expect(() => assertReleaseVersion("v0.1.5", "0.1.4", "0.1.4")).toThrow("package version");
expect(() => assertReleaseVersion("v0.1.4", "0.1.4", "0.1.3")).toThrow("manifest version");
expect(() => assertReleaseVersion("release-0.1.5", "0.1.5", "0.1.5")).toThrow("strict vX.Y.Z");
expect(() => assertReleaseVersion("v0.1.6", "0.1.5", "0.1.5")).toThrow("package version");
expect(() => assertReleaseVersion("v0.1.5", "0.1.5", "0.1.4")).toThrow("manifest version");
});

test("uses explicit capabilities and only the vNext renderer lifecycle", () => {
const manifest = JSON.parse(readFileSync(path.join(import.meta.dir, "..", ".zdp", "plugin.json"), "utf8"));
expect(manifest.engines.host).toBe(">=0.3.4 <1");
expect(manifest.capabilities).toEqual([
"zcode.workspaces.read",
"zcode.tasks.run",
"ui.pages",
"ui.overlays",
]);
expect(schedulerRenderer.mountPage).toBeFunction();
expect(schedulerRenderer.mount).toBeUndefined();
});
});
2 changes: 1 addition & 1 deletion tests/scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ function createContext(dataDir: string) {
apiVersion: 1,
id: "scheduler",
name: "Scheduler",
version: "0.1.4",
version: "0.1.5",
entrypoints: {},
engines: {host: ">=0.2.0 <1", zcode: ">=3.3.6"},
pages: [{id: "jobs", title: "Scheduler"}],
Expand Down