@cloud-cli/on 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,7 @@ Designed with a strict **security-first boundary**, native **JavaScript AST eval
25
25
 
26
26
  ```text
27
27
  my-project/
28
- ├── runner.config.mjs # (Optional) Engine configuration
28
+ ├── runner.config.mjs # Optional plugin/advanced configuration
29
29
  └── package.json
30
30
 
31
31
  ```
@@ -263,6 +263,7 @@ Within `${...}`, `if:`, and `eval:` contexts, the following object scopes are ex
263
263
  - **`env`**: Merged environment variables from global config and workflow definitions.
264
264
  - **`secrets`**: Unmasked job-scoped values from encrypted central secret storage.
265
265
  - **`steps`**: Execution statuses and outputs from previous steps in the workflow (`steps.<id>.status`, `steps.<id>.outputs`).
266
+ - **`files`**: Workspace-bound file helpers for `if:` and `eval:` expressions: `files.exists('Dockerfile')`, `files.readFile('package.json')`, and `files.join('dist', 'artifact')`. Paths outside the job `workingDir` are rejected.
266
267
  - **`BUILTIN_HELPERS`**: JS utilities including `String`, `Number`, `Boolean`, and `JSON.parse` / `JSON.stringify`.
267
268
 
268
269
  ---
@@ -276,15 +277,22 @@ The Ingress Gateway listens for incoming HTTP requests and serves the live web U
276
277
  | Method | Endpoint | Description |
277
278
  | ---------- | ------------------ | -------------------------------------------------------------------------------------- |
278
279
  | **`POST`** | `/webhooks/github` | Webhook endpoint for GitHub events. Applies GitHub filters and evaluates `on.github.if`. |
279
- | **`GET`** | `/runs` | **Dashboard:** Live dark-mode monitoring page listing recent jobs and worker health. |
280
+ | **`GET`** | `/runs` | **Dashboard:** Public live dark-mode monitoring page listing recent jobs. |
280
281
  | **`GET`** | `/api/jobs?afterId=<id>&beforeId=<id>&limit=<n>` | Dashboard jobs with exclusive lower and upper ID cursors and a limit from 1 to 500 (default 50). |
281
- | **`GET`** | `/runs/:jobId` | **Job Report:** Interactive HTML trace view with step timings and terminal log output. |
282
+ | **`GET`** | `/runs/:jobId` | **Job Report:** Authenticated interactive trace with step timings and terminal log output. |
283
+ | **`GET`** | `/workflows` | Authenticated workflow list and encrypted secret management UI. |
284
+ | **`GET`** | `/workflows/new`, `/workflows/:id` | Authenticated YAML workflow editor. |
285
+ | **`POST`** | `/api/workflows/validate` | Authenticated YAML validation without persistence. |
286
+ | **`GET`, `PUT`, `DELETE`** | `/api/workflows/:id` | Authenticated revisioned workflow read, draft save, and deletion. |
287
+ | **`POST`** | `/api/workflows/:id/publish` | Authenticated publication of the latest draft revision. |
288
+ | **`GET`, `PUT`, `DELETE`** | `/api/secrets`, `/api/secrets/:name` | Authenticated encrypted secret-name and write-only value management. |
282
289
 
283
290
  ### Dashboard Features
284
291
 
285
292
  - **Real-time Auto-Refresh:** `/runs` automatically refreshes job queue statuses (`PENDING`, `RUNNING`, `SUCCESS`, `FAILED`, `CANCELLED`).
286
293
  - **ANSI Terminal Rendering:** Uses `ansi_up` to render bash colors, bold highlights, and console outputs accurately in step log boxes.
287
294
  - **Payload Inspection:** View JSON inputs received from webhooks for easy debugging.
295
+ - **Workflow Management:** Use the authenticated `/workflows` pages to create, validate, publish, edit, and delete DB-authored workflow revisions.
288
296
 
289
297
  ---
290
298
 
@@ -297,7 +305,7 @@ The Ingress Gateway listens for incoming HTTP requests and serves the live web U
297
305
  3. **Payload Size Guard:**
298
306
  The Ingress server enforces a strict 5MB payload limit to prevent Out-Of-Memory (OOM) denial-of-service attacks.
299
307
  4. **Single-operator API authentication:**
300
- Workflow validation, publishing, secret management, and job details require `RUNNER_ADMIN_SECRET` over HTTPS.
308
+ Workflow validation, publishing, secret management, and job details require `RUNNER_ADMIN_SECRET` over HTTPS. Workers use the separate `RUNNER_WORKER_SECRET` only for lifecycle events and secrets of jobs they have claimed.
301
309
 
302
310
  ## systemd Deployment
303
311
 
@@ -307,12 +315,31 @@ Place the server master key in `/etc/on/credentials/on-master-key` with root-onl
307
315
 
308
316
  For package-managed remote workers, enable `runner-worker-update.timer`. It runs daily at 04:00 with up to 45 minutes of per-machine jitter, installs `@cloud-cli/on@latest` into `/opt/on`, then restarts the worker only when installation succeeds. `runner-worker.service` allows up to one hour for running jobs to drain during that restart. Do not enable this timer on a source-managed development worker; deploy that worker from its checked-out build instead.
309
317
 
318
+ To drain a worker manually without leaving it stopped, send `SIGUSR1` only to its main process: `systemctl kill --kill-who=main --signal=SIGUSR1 runner-worker.service`. The worker stops claiming new jobs, waits for running jobs to complete, exits with status 0, and `Restart=always` starts a replacement. Use `systemctl restart runner-worker.service` for an intentional version change; systemd waits for the same drain behavior up to `TimeoutStopSec=1h`.
319
+
310
320
  Workers now resolve the immutable revision when they claim a job. A workflow can safely use a checkout step followed by `if: files.exists('Dockerfile')`; `files` is constrained to the job's `workingDir`, so it cannot inspect files outside that workspace.
311
321
 
312
322
  ## Future Security Work
313
323
 
314
324
  This MVP intentionally supports one trusted operator. Before sharing the UI or exposing it beyond a private deployment, add individual accounts and roles, secure browser sessions and CSRF protection, audit logs, worker enrollment credentials, secret key rotation, encrypted systemd credentials, rate limiting, and database high availability.
315
325
 
326
+ ## Planned Runner Rollouts
327
+
328
+ Package-managed workers currently update on a staggered daily timer. Before enabling automatic promotion of newly released runner versions, implement a controlled canary rollout:
329
+
330
+ 1. Publish a new immutable npm version and resolve the exact version behind the candidate tag.
331
+ 2. Install that exact version on one designated canary worker, never a moving tag during the rollout.
332
+ 3. Stage releases in versioned directories and atomically switch a `current` symlink only after installation and a local smoke check succeed.
333
+ 4. Drain the canary with `SIGUSR1`, restart it, and retain prior release directories for rollback.
334
+ 5. Require the canary to report a heartbeat containing its worker ID, running version, health state, and last-seen time.
335
+ 6. Run a dedicated `runner-health` workflow with `tags: [canary]` to verify queue claiming, revision lookup, job-scoped secret retrieval, and the execution driver.
336
+ 7. Record rollout state durably: candidate version, canary worker, status, promotion time, and failure reason.
337
+ 8. Promote only a healthy candidate with `npm dist-tag add @cloud-cli/on@<version> stable`.
338
+ 9. Configure normal daily worker updates to install `@cloud-cli/on@stable`, with a rollout concurrency limit of one worker.
339
+ 10. On failed installation, restart, heartbeat, or health check, mark the candidate rejected, leave `stable` unchanged, and roll the canary back to its prior version.
340
+
341
+ SSE should notify workers that an update is available, but the control-plane database must remain the authoritative rollout state so disconnected workers converge safely after reconnecting.
342
+
316
343
  ## Development
317
344
 
318
345
  ```bash
package/dist/on.js CHANGED
@@ -3655,7 +3655,7 @@ var En = class {
3655
3655
  inputs: e
3656
3656
  };
3657
3657
  }
3658
- }, Dn = "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Workflow Engine Dashboard</title>\n <script type=\"importmap\">\n { \"imports\": { \"@li3/\": \"https://cdn.li3.dev/@li3/\" } }\n <\/script>\n</head>\n<body class=\"bg-gray-950 text-gray-100 min-h-screen p-4 md:p-6 font-sans\">\n <template app>\n <main class=\"max-w-6xl mx-auto space-y-6\">\n <header class=\"flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-gray-800 pb-4\">\n <div>\n <h1 class=\"text-2xl font-bold text-white\">Runner Engine Status</h1>\n <p class=\"text-xs text-gray-400\">Live Job Queue &amp; Execution Traces</p>\n </div>\n <div class=\"flex items-center gap-3 text-xs font-mono\">\n <a href=\"/workflows\" class=\"text-indigo-400 hover:text-indigo-300 hover:underline\">Manage workflows</a>\n <span class=\"text-gray-500\">Updated {{ lastUpdated }}</span>\n <span\n class=\"px-3 py-1 rounded-full border\"\n bind-class=\"{\n 'bg-emerald-500/10.text-emerald-400.border-emerald-500/20': !refreshError,\n 'bg-rose-500/10.text-rose-400.border-rose-500/20': refreshError\n }\"\n >\n {{ refreshError ? 'Refresh failed' : 'System operational' }}\n </span>\n </div>\n </header>\n\n <section class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden hidden md:block\">\n <table class=\"w-full text-left text-sm\">\n <thead class=\"bg-gray-800/50 text-gray-400 text-xs uppercase font-mono border-b border-gray-800\">\n <tr>\n <th class=\"py-3 px-4\">Job ID</th>\n <th class=\"py-3 px-4\">Workflow</th>\n <th class=\"py-3 px-4\">Status</th>\n <th class=\"py-3 px-4 hidden xl:table-cell\">Worker</th>\n <th class=\"py-3 px-4 hidden lg:table-cell\">Created At</th>\n <th class=\"py-3 px-4 text-right\">Action</th>\n </tr>\n </thead>\n <tbody>\n <template for=\"job of jobs\">\n <tr class=\"border-b border-gray-800 hover:bg-gray-800/40 transition\">\n <td class=\"py-3 px-4 font-mono text-indigo-400\">\n <a attr-href=\"'/runs/' + job.id\" class=\"hover:underline\">#{{ job.id }}</a>\n </td>\n <td class=\"py-3 px-4 font-medium text-white\">{{ job.workflowId }}</td>\n <td class=\"py-3 px-4\">\n <span\n class=\"px-2.5 py-0.5 rounded-full text-xs font-semibold\"\n bind-class=\"{\n 'bg-emerald-500/10.text-emerald-400': job.status === 'success',\n 'bg-rose-500/10.text-rose-400': job.status === 'failed',\n 'bg-indigo-500/10.text-indigo-400.animate-pulse': job.status === 'running',\n 'bg-gray-500/10.text-gray-400': !['success', 'failed', 'running'].includes(job.status)\n }\"\n >\n {{ upper(job.status) }}\n </span>\n </td>\n <td class=\"py-3 px-4 hidden xl:table-cell text-xs font-mono text-gray-400\">{{ job.workerId || '-' }}</td>\n <td class=\"py-3 px-4 hidden lg:table-cell text-xs text-gray-400\">{{ job.createdAt }}</td>\n <td class=\"py-3 px-4 text-right whitespace-nowrap\">\n <a attr-href=\"'/runs/' + job.id\" class=\"text-xs bg-gray-800 hover:bg-gray-700 text-gray-200 px-3 py-1 rounded border border-gray-700\">View Trace</a>\n </td>\n </tr>\n </template>\n </tbody>\n </table>\n <template if=\"!jobs.length\">\n <div class=\"p-8 text-center text-gray-500\">No jobs recorded yet.</div>\n </template>\n </section>\n\n <section class=\"md:hidden bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800 overflow-hidden\">\n <template for=\"job of jobs\">\n <article class=\"p-4 bg-gray-900/60\">\n <div class=\"flex items-start justify-between gap-3\">\n <div class=\"min-w-0\">\n <div class=\"flex items-center gap-2\">\n <span\n class=\"w-2.5 h-2.5 rounded-full shrink-0\"\n bind-class=\"{\n 'bg-emerald-400': job.status === 'success',\n 'bg-rose-400': job.status === 'failed',\n 'bg-indigo-400.animate-pulse': job.status === 'running',\n 'bg-gray-500': !['success', 'failed', 'running'].includes(job.status)\n }\"\n ></span>\n <h2 class=\"font-medium text-white truncate\">{{ job.workflowId }}</h2>\n </div>\n <p class=\"mt-1 pl-[18px] text-xs font-mono text-gray-400\">#{{ job.id }} · {{ upper(job.status) }}</p>\n </div>\n <a attr-href=\"'/runs/' + job.id\" class=\"shrink-0 text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2.5 py-1 rounded border border-gray-700\">Trace</a>\n </div>\n </article>\n </template>\n <template if=\"!jobs.length\">\n <div class=\"p-8 text-center text-gray-500\">No jobs recorded yet.</div>\n </template>\n </section>\n\n <template if=\"hasMore\">\n <div class=\"flex justify-center\">\n <button\n class=\"px-4 py-2 text-sm text-gray-200 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg\"\n on-click=\"loadMore()\"\n >\n {{ loadingMore ? 'Loading...' : 'Load more' }}\n </button>\n </div>\n </template>\n </main>\n\n <script setup>\n import { onDestroy, onInit, ref } from '@li3/web';\n\n export default function setup() {\n const jobs = ref([]);\n const lastUpdated = ref('now');\n const refreshError = ref(false);\n const hasMore = ref(false);\n const loadingMore = ref(false);\n let refreshTimer;\n let eventSource;\n let refreshing = false;\n let refreshPending = false;\n\n const upper = (value) => String(value || '').toUpperCase();\n const terminalStatuses = new Set(['success', 'failed', 'cancelled']);\n const refreshJobs = async () => {\n if (refreshing) {\n refreshPending = true;\n return;\n }\n refreshing = true;\n\n try {\n const activeIds = jobs.value\n .filter((job) => !terminalStatuses.has(job.status))\n .map((job) => Number(job.id));\n const knownIds = jobs.value.map((job) => Number(job.id));\n const afterId = activeIds.length\n ? Math.max(0, Math.min(...activeIds) - 1)\n : knownIds.length\n ? Math.max(...knownIds)\n : null;\n const url = afterId === null ? '/api/jobs?limit=500' : `/api/jobs?afterId=${afterId}&limit=500`;\n const response = await fetch(url, { headers: { accept: 'application/json' } });\n if (!response.ok) throw new Error(`Dashboard refresh failed: ${response.status}`);\n\n const data = await response.json();\n const merged = new Map(jobs.value.map((job) => [job.id, job]));\n for (const job of data.jobs) merged.set(job.id, job);\n jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id));\n lastUpdated.value = new Date().toLocaleTimeString();\n refreshError.value = false;\n } catch (error) {\n console.error(error);\n refreshError.value = true;\n } finally {\n refreshing = false;\n if (refreshPending) {\n refreshPending = false;\n void refreshJobs();\n }\n }\n };\n\n const loadMore = async () => {\n if (loadingMore.value || !hasMore.value || !jobs.value.length) return;\n loadingMore.value = true;\n\n try {\n const beforeId = Math.min(...jobs.value.map((job) => Number(job.id)));\n const response = await fetch(`/api/jobs?beforeId=${beforeId}`, {\n headers: { accept: 'application/json' },\n });\n if (!response.ok) throw new Error(`Loading older jobs failed: ${response.status}`);\n\n const data = await response.json();\n const merged = new Map(jobs.value.map((job) => [job.id, job]));\n for (const job of data.jobs) merged.set(job.id, job);\n jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id));\n hasMore.value = data.hasMore;\n } catch (error) {\n console.error(error);\n refreshError.value = true;\n } finally {\n loadingMore.value = false;\n }\n };\n\n onInit(() => {\n eventSource = new EventSource('/api/events');\n eventSource.addEventListener('jobs.available', refreshJobs);\n eventSource.addEventListener('jobs.changed', refreshJobs);\n eventSource.onerror = () => {\n refreshError.value = true;\n };\n refreshTimer = setInterval(refreshJobs, 60000);\n });\n onDestroy(() => {\n eventSource?.close();\n clearInterval(refreshTimer);\n });\n\n return { jobs, lastUpdated, refreshError, hasMore, loadingMore, loadMore, upper };\n }\n <\/script>\n <script state>__DASHBOARD_STATE__<\/script>\n </template>\n\n <script type=\"module\">import '@li3/web';<\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n</body>\n</html>\n";
3658
+ }, Dn = "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Workflow Engine Dashboard</title>\n <script type=\"importmap\">\n { \"imports\": { \"@li3/\": \"https://cdn.li3.dev/@li3/\" } }\n <\/script>\n</head>\n<body class=\"bg-gray-950 text-gray-100 min-h-screen p-4 md:p-6 font-sans\">\n <template app>\n <main class=\"max-w-6xl mx-auto space-y-6\">\n <header class=\"flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-gray-800 pb-4\">\n <div>\n <h1 class=\"text-2xl font-bold text-white\">Runner Engine Status</h1>\n <p class=\"text-xs text-gray-400\">Live Job Queue &amp; Execution Traces</p>\n </div>\n <div class=\"flex items-center gap-3 text-xs font-mono\">\n <a href=\"/workflows\" class=\"text-indigo-400 hover:text-indigo-300 hover:underline\">Manage workflows</a>\n <span class=\"text-gray-500\">Updated {{ lastUpdated }}</span>\n <template if=\"refreshError\">\n <span class=\"rounded border border-rose-500/20 bg-rose-500/10 px-3 py-1 text-rose-400\">Refresh failed</span>\n </template>\n </div>\n </header>\n\n <section class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden hidden md:block\">\n <table class=\"w-full text-left text-sm\">\n <thead class=\"bg-gray-800/50 text-gray-400 text-xs uppercase font-mono border-b border-gray-800\">\n <tr>\n <th class=\"py-3 px-4\">Job ID</th>\n <th class=\"py-3 px-4\">Workflow</th>\n <th class=\"py-3 px-4\">Status</th>\n <th class=\"py-3 px-4 hidden xl:table-cell\">Worker</th>\n <th class=\"py-3 px-4 hidden lg:table-cell\">Created At</th>\n <th class=\"py-3 px-4 text-right\">Action</th>\n </tr>\n </thead>\n <tbody>\n <template for=\"job of jobs\">\n <tr class=\"border-b border-gray-800 hover:bg-gray-800/40 transition\">\n <td class=\"py-3 px-4 font-mono text-indigo-400\">\n <a attr-href=\"'/runs/' + job.id\" class=\"hover:underline\">#{{ job.id }}</a>\n </td>\n <td class=\"py-3 px-4 font-medium text-white\">{{ job.workflowId }}</td>\n <td class=\"py-3 px-4\">\n <span\n class=\"px-2.5 py-0.5 rounded-full text-xs font-semibold\"\n bind-class=\"{\n 'bg-emerald-500/10.text-emerald-400': job.status === 'success',\n 'bg-rose-500/10.text-rose-400': job.status === 'failed',\n 'bg-indigo-500/10.text-indigo-400.animate-pulse': job.status === 'running',\n 'bg-gray-500/10.text-gray-400': !['success', 'failed', 'running'].includes(job.status)\n }\"\n >\n {{ upper(job.status) }}\n </span>\n </td>\n <td class=\"py-3 px-4 hidden xl:table-cell text-xs font-mono text-gray-400\">{{ job.workerId || '-' }}</td>\n <td class=\"py-3 px-4 hidden lg:table-cell text-xs text-gray-400\">{{ job.createdAt }}</td>\n <td class=\"py-3 px-4 text-right whitespace-nowrap\">\n <a attr-href=\"'/runs/' + job.id\" class=\"text-xs bg-gray-800 hover:bg-gray-700 text-gray-200 px-3 py-1 rounded border border-gray-700\">View Trace</a>\n </td>\n </tr>\n </template>\n </tbody>\n </table>\n <template if=\"!jobs.length\">\n <div class=\"p-8 text-center text-gray-500\">No jobs recorded yet.</div>\n </template>\n </section>\n\n <section class=\"md:hidden bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800 overflow-hidden\">\n <template for=\"job of jobs\">\n <article class=\"p-4 bg-gray-900/60\">\n <div class=\"flex items-start justify-between gap-3\">\n <div class=\"min-w-0\">\n <div class=\"flex items-center gap-2\">\n <span\n class=\"w-2.5 h-2.5 rounded-full shrink-0\"\n bind-class=\"{\n 'bg-emerald-400': job.status === 'success',\n 'bg-rose-400': job.status === 'failed',\n 'bg-indigo-400.animate-pulse': job.status === 'running',\n 'bg-gray-500': !['success', 'failed', 'running'].includes(job.status)\n }\"\n ></span>\n <h2 class=\"font-medium text-white truncate\">{{ job.workflowId }}</h2>\n </div>\n <p class=\"mt-1 pl-[18px] text-xs font-mono text-gray-400\">#{{ job.id }} · {{ upper(job.status) }}</p>\n </div>\n <a attr-href=\"'/runs/' + job.id\" class=\"shrink-0 text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2.5 py-1 rounded border border-gray-700\">Trace</a>\n </div>\n </article>\n </template>\n <template if=\"!jobs.length\">\n <div class=\"p-8 text-center text-gray-500\">No jobs recorded yet.</div>\n </template>\n </section>\n\n <template if=\"hasMore\">\n <div class=\"flex justify-center\">\n <button\n class=\"px-4 py-2 text-sm text-gray-200 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg\"\n on-click=\"loadMore()\"\n >\n {{ loadingMore ? 'Loading...' : 'Load more' }}\n </button>\n </div>\n </template>\n </main>\n\n <script setup>\n import { onDestroy, onInit, ref } from '@li3/web';\n\n export default function setup() {\n const jobs = ref([]);\n const lastUpdated = ref('now');\n const refreshError = ref(false);\n const hasMore = ref(false);\n const loadingMore = ref(false);\n let refreshTimer;\n let eventSource;\n let refreshing = false;\n let refreshPending = false;\n\n const upper = (value) => String(value || '').toUpperCase();\n const terminalStatuses = new Set(['success', 'failed', 'cancelled']);\n const refreshJobs = async () => {\n if (refreshing) {\n refreshPending = true;\n return;\n }\n refreshing = true;\n\n try {\n const activeIds = jobs.value\n .filter((job) => !terminalStatuses.has(job.status))\n .map((job) => Number(job.id));\n const knownIds = jobs.value.map((job) => Number(job.id));\n const afterId = activeIds.length\n ? Math.max(0, Math.min(...activeIds) - 1)\n : knownIds.length\n ? Math.max(...knownIds)\n : null;\n const url = afterId === null ? '/api/jobs?limit=500' : `/api/jobs?afterId=${afterId}&limit=500`;\n const response = await fetch(url, { headers: { accept: 'application/json' } });\n if (!response.ok) throw new Error(`Dashboard refresh failed: ${response.status}`);\n\n const data = await response.json();\n const merged = new Map(jobs.value.map((job) => [job.id, job]));\n for (const job of data.jobs) merged.set(job.id, job);\n jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id));\n lastUpdated.value = new Date().toLocaleTimeString();\n refreshError.value = false;\n } catch (error) {\n console.error(error);\n refreshError.value = true;\n } finally {\n refreshing = false;\n if (refreshPending) {\n refreshPending = false;\n void refreshJobs();\n }\n }\n };\n\n const loadMore = async () => {\n if (loadingMore.value || !hasMore.value || !jobs.value.length) return;\n loadingMore.value = true;\n\n try {\n const beforeId = Math.min(...jobs.value.map((job) => Number(job.id)));\n const response = await fetch(`/api/jobs?beforeId=${beforeId}`, {\n headers: { accept: 'application/json' },\n });\n if (!response.ok) throw new Error(`Loading older jobs failed: ${response.status}`);\n\n const data = await response.json();\n const merged = new Map(jobs.value.map((job) => [job.id, job]));\n for (const job of data.jobs) merged.set(job.id, job);\n jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id));\n hasMore.value = data.hasMore;\n } catch (error) {\n console.error(error);\n refreshError.value = true;\n } finally {\n loadingMore.value = false;\n }\n };\n\n onInit(() => {\n eventSource = new EventSource('/api/events');\n eventSource.addEventListener('jobs.available', refreshJobs);\n eventSource.addEventListener('jobs.changed', refreshJobs);\n eventSource.onerror = () => {\n refreshError.value = true;\n };\n refreshTimer = setInterval(refreshJobs, 60000);\n });\n onDestroy(() => {\n eventSource?.close();\n clearInterval(refreshTimer);\n });\n\n return { jobs, lastUpdated, refreshError, hasMore, loadingMore, loadMore, upper };\n }\n <\/script>\n <script state>__DASHBOARD_STATE__<\/script>\n </template>\n\n <script type=\"module\">import '@li3/web';<\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n</body>\n</html>\n";
3659
3659
  //#endregion
3660
3660
  //#region src/html-state.ts
3661
3661
  function On(e) {
@@ -8256,7 +8256,7 @@ var Ho = class {
8256
8256
  let t = Buffer.from(e, "base64"), n = l.createDecipheriv("aes-256-gcm", this.encryptionKey(), t.subarray(0, 12));
8257
8257
  return n.setAuthTag(t.subarray(12, 28)), Buffer.concat([n.update(t.subarray(28)), n.final()]).toString("utf8");
8258
8258
  }
8259
- }, Uo = "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Runner Workflow Management</title>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n <style>\n body[data-page=\"list\"] .editor-panel { display: none; }\n body[data-page=\"editor\"] .workflow-list, body[data-page=\"editor\"] .secrets-panel { display: none; }\n body[data-page=\"list\"] #workflow-layout { grid-template-columns: minmax(0, 1fr); }\n </style>\n</head>\n<body data-page=\"__WORKFLOW_PAGE__\" class=\"min-h-screen bg-gray-950 p-4 font-sans text-gray-100 md:p-6\">\n <main class=\"mx-auto max-w-7xl space-y-5\">\n <header class=\"flex flex-col justify-between gap-3 border-b border-gray-800 pb-4 sm:flex-row sm:items-end\">\n <div>\n <a href=\"/runs\" class=\"text-xs text-indigo-400 hover:underline\">Back to dashboard</a>\n <h1 class=\"mt-1 text-2xl font-bold text-white\">Workflow control room</h1>\n <p class=\"text-sm text-gray-400\">Draft, validate, and publish revisioned YAML definitions.</p>\n </div>\n <a href=\"/workflows/new\" class=\"rounded-lg border border-indigo-500/50 bg-indigo-500/10 px-4 py-2 text-sm font-medium text-indigo-200 hover:bg-indigo-500/20\">New workflow</a>\n </header>\n\n <p id=\"notice\" role=\"status\" class=\"hidden rounded-lg border px-3 py-2 text-sm\"></p>\n\n <section id=\"workflow-layout\" class=\"grid gap-5 lg:grid-cols-[17rem_1fr]\">\n <aside class=\"workflow-list rounded-xl border border-gray-800 bg-gray-900 p-3\">\n <h2 class=\"mb-2 px-1 text-xs font-semibold uppercase tracking-wider text-gray-400\">Workflows</h2>\n <div id=\"workflow-list\" class=\"space-y-1\"></div>\n </aside>\n\n <section class=\"editor-panel rounded-xl border border-gray-800 bg-gray-900 p-4\">\n <div class=\"mb-3 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between\">\n <label class=\"block flex-1 text-xs font-semibold uppercase tracking-wider text-gray-400\">Workflow ID\n <input id=\"workflow-id\" class=\"mt-1 w-full rounded border border-gray-700 bg-gray-950 px-3 py-2 font-mono text-sm text-white outline-none focus:border-indigo-500\" placeholder=\"derived-from-workflow-name\">\n </label>\n <div class=\"flex flex-wrap gap-2\">\n <button id=\"validate\" class=\"rounded border border-gray-700 px-3 py-2 text-sm hover:border-gray-500\">Validate</button>\n <button id=\"save\" class=\"rounded border border-indigo-500 bg-indigo-500 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-400\">Save draft</button>\n <button id=\"publish\" class=\"rounded border border-emerald-500/60 px-3 py-2 text-sm text-emerald-300 hover:bg-emerald-500/10\">Publish</button>\n <button id=\"delete\" class=\"rounded border border-rose-500/60 px-3 py-2 text-sm text-rose-300 hover:bg-rose-500/10\">Delete</button>\n </div>\n </div>\n <textarea id=\"source-yaml\" spellcheck=\"false\" class=\"h-[34rem] w-full rounded-lg border border-gray-700 bg-gray-950 p-4 font-mono text-sm leading-6 text-gray-100 outline-none focus:border-indigo-500\" placeholder=\"name: Example&#10;on:&#10; github:&#10; events: [push]&#10;steps:&#10; - run: echo hello\"></textarea>\n </section>\n </section>\n\n <section class=\"secrets-panel rounded-xl border border-gray-800 bg-gray-900 p-4\">\n <div class=\"mb-3\">\n <h2 class=\"text-base font-semibold text-white\">Secrets</h2>\n <p class=\"text-sm text-gray-400\">Values are write-only and encrypted. Re-saving a name replaces its value.</p>\n </div>\n <div class=\"grid gap-3 md:grid-cols-[14rem_1fr_auto_auto]\">\n <input id=\"secret-name\" class=\"rounded border border-gray-700 bg-gray-950 px-3 py-2 font-mono text-sm uppercase outline-none focus:border-indigo-500\" placeholder=\"NPM_TOKEN\">\n <input id=\"secret-value\" type=\"password\" class=\"rounded border border-gray-700 bg-gray-950 px-3 py-2 font-mono text-sm outline-none focus:border-indigo-500\" placeholder=\"New secret value\">\n <button id=\"save-secret\" class=\"rounded border border-indigo-500 px-3 py-2 text-sm text-indigo-200 hover:bg-indigo-500/10\">Save value</button>\n <button id=\"delete-secret\" class=\"rounded border border-rose-500/60 px-3 py-2 text-sm text-rose-300 hover:bg-rose-500/10\">Delete</button>\n </div>\n <div id=\"secret-list\" class=\"mt-4 flex flex-wrap gap-2\"></div>\n </section>\n </main>\n\n <script>\n const notice = document.querySelector('#notice');\n const workflowList = document.querySelector('#workflow-list');\n const source = document.querySelector('#source-yaml');\n const workflowId = document.querySelector('#workflow-id');\n const secretList = document.querySelector('#secret-list');\n const secretName = document.querySelector('#secret-name');\n const secretValue = document.querySelector('#secret-value');\n const page = document.body.dataset.page;\n const initialId = __WORKFLOW_ID__;\n let workflows = [];\n let selectedId = '';\n\n const showNotice = (message, error = false) => {\n notice.textContent = message;\n notice.className = `rounded-lg border px-3 py-2 text-sm ${error ? 'border-rose-500/40 bg-rose-500/10 text-rose-200' : 'border-emerald-500/40 bg-emerald-500/10 text-emerald-200'}`;\n };\n const api = async (url, options = {}) => {\n const response = await fetch(url, { headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) }, ...options });\n const body = response.status === 204 ? null : await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`);\n return body;\n };\n const derivedId = () => source.value.match(/^\\s*(?:id:\\s*([^\\n]+)|name:\\s*([^\\n]+))/m)?.slice(1).find(Boolean)?.trim().toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/(^-|-$)/g, '') || '';\n const renderWorkflows = () => {\n workflowList.replaceChildren();\n for (const workflow of workflows) {\n const button = document.createElement('button');\n button.className = `block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-800 ${workflow.id === selectedId ? 'bg-indigo-500/15 text-indigo-200' : 'text-gray-300'}`;\n button.textContent = `${workflow.name} · ${workflow.status}`;\n button.onclick = () => { window.location.href = `/workflows/${workflow.id}`; };\n workflowList.append(button);\n }\n };\n const loadWorkflows = async () => {\n workflows = (await api('/api/workflows')).workflows;\n renderWorkflows();\n };\n const openWorkflow = async (id) => {\n const workflow = await api(`/api/workflows/${id}`);\n selectedId = workflow.id;\n workflowId.value = workflow.id;\n source.value = workflow.sourceYaml;\n renderWorkflows();\n };\n const loadSecrets = async () => {\n const secrets = (await api('/api/secrets')).secrets;\n secretList.replaceChildren();\n for (const name of secrets) {\n const button = document.createElement('button');\n button.className = 'rounded border border-gray-700 px-2 py-1 font-mono text-xs text-gray-300 hover:border-indigo-500';\n button.textContent = name;\n button.onclick = () => { secretName.value = name; secretValue.focus(); };\n secretList.append(button);\n }\n };\n document.querySelector('#validate').onclick = async () => {\n try { const result = await api('/api/workflows/validate', { method: 'POST', body: JSON.stringify({ sourceYaml: source.value }) }); showNotice(`Valid: ${result.workflows.length} workflow definition(s).`); } catch (error) { showNotice(error.message, true); }\n };\n document.querySelector('#save').onclick = async () => {\n const id = workflowId.value.trim() || derivedId();\n if (!id) return showNotice('Set an ID or add a workflow name first.', true);\n try { const workflow = await api(`/api/workflows/${id}`, { method: 'PUT', body: JSON.stringify({ sourceYaml: source.value }) }); selectedId = workflow.id; workflowId.value = workflow.id; showNotice(`Saved ${workflow.id} as draft revision ${workflow.revision}.`); if (!initialId) history.replaceState(null, '', `/workflows/${workflow.id}`); } catch (error) { showNotice(error.message, true); }\n };\n document.querySelector('#publish').onclick = async () => {\n const id = workflowId.value.trim() || selectedId;\n if (!id) return showNotice('Save a workflow before publishing it.', true);\n try { const workflow = await api(`/api/workflows/${id}/publish`, { method: 'POST' }); selectedId = workflow.id; showNotice(`Published ${workflow.id} revision ${workflow.revision}.`); } catch (error) { showNotice(error.message, true); }\n };\n document.querySelector('#delete').onclick = async () => {\n const id = workflowId.value.trim() || selectedId;\n if (!id || !confirm(`Delete ${id} and all of its revisions?`)) return;\n try { await api(`/api/workflows/${id}`, { method: 'DELETE' }); window.location.href = '/workflows'; } catch (error) { showNotice(error.message, true); }\n };\n document.querySelector('#save-secret').onclick = async () => {\n try { await api(`/api/secrets/${secretName.value.trim()}`, { method: 'PUT', body: JSON.stringify({ value: secretValue.value }) }); secretValue.value = ''; await loadSecrets(); showNotice(`Saved ${secretName.value.trim()}.`); } catch (error) { showNotice(error.message, true); }\n };\n document.querySelector('#delete-secret').onclick = async () => {\n const name = secretName.value.trim();\n if (!name || !confirm(`Delete ${name}?`)) return;\n try { await api(`/api/secrets/${name}`, { method: 'DELETE' }); secretName.value = ''; secretValue.value = ''; await loadSecrets(); showNotice(`Deleted ${name}.`); } catch (error) { showNotice(error.message, true); }\n };\n const loadPage = page === 'editor'\n ? (initialId ? openWorkflow(initialId) : Promise.resolve())\n : Promise.all([loadWorkflows(), loadSecrets()]);\n loadPage.catch((error) => showNotice(error.message, true));\n <\/script>\n</body>\n</html>\n";
8259
+ }, Uo = "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Runner Workflow Management</title>\n <script type=\"importmap\">\n { \"imports\": { \"@li3/\": \"https://cdn.li3.dev/@li3/\" } }\n <\/script>\n <style>\n body[data-page=\"list\"] .editor-panel { display: none; }\n body[data-page=\"editor\"] .workflow-list, body[data-page=\"editor\"] .secrets-panel { display: none; }\n </style>\n</head>\n<body data-page=\"__WORKFLOW_PAGE__\" class=\"min-h-screen bg-gray-950 p-4 font-sans text-gray-100 md:p-6\">\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-editor.html\">\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\">\n\n <template app>\n <main class=\"mx-auto max-w-6xl space-y-5\">\n <header class=\"flex flex-col gap-4 border-b border-gray-800 pb-5 sm:flex-row sm:items-end sm:justify-between\">\n <div class=\"flex items-start gap-3\">\n <a href=\"/runs\" aria-label=\"Back to dashboard\" class=\"mt-1 rounded-lg border border-gray-800 bg-gray-900 p-2 text-gray-400 transition hover:border-indigo-500/60 hover:text-indigo-300\">\n <lucide-icon icon=\"arrow-left\" size=\"17\"></lucide-icon>\n </a>\n <div>\n <p class=\"text-xs font-semibold uppercase tracking-[0.18em] text-indigo-400\">Runner engine</p>\n <h1 class=\"mt-1 text-2xl font-bold tracking-tight text-white\">Workflow control room</h1>\n <p class=\"mt-1 text-sm text-gray-400\">Draft, validate, and publish revisioned YAML definitions.</p>\n </div>\n </div>\n <a href=\"/workflows/new\" class=\"inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-950/40 transition hover:bg-indigo-400\">\n <lucide-icon icon=\"plus\" size=\"16\"></lucide-icon>\n New workflow\n </a>\n </header>\n\n <template if=\"notice\">\n <p role=\"status\" class=\"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm\" bind-class=\"noticeError ? 'border-rose-500/40.bg-rose-500/10.text-rose-100' : 'border-emerald-500/40.bg-emerald-500/10.text-emerald-100'\">\n <lucide-icon bind-icon=\"noticeError ? 'circle-alert' : 'circle-check'\" size=\"17\"></lucide-icon>\n {{ notice }}\n </p>\n </template>\n\n <section id=\"workflow-layout\" class=\"space-y-5\">\n <aside class=\"workflow-list rounded-xl border border-gray-800 bg-gray-900/80 p-3 shadow-xl shadow-black/10\">\n <div class=\"mb-3 flex items-center justify-between px-2\">\n <h2 class=\"text-xs font-semibold uppercase tracking-wider text-gray-400\">Workflows</h2>\n <span class=\"rounded-full bg-gray-800 px-2 py-0.5 text-xs font-mono text-gray-400\">{{ workflows.length }}</span>\n </div>\n <div class=\"space-y-1\">\n <template for=\"workflow of workflows\">\n <button on-click=\"visitWorkflow(workflow.id)\" class=\"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left text-sm transition hover:bg-gray-800\" bind-class=\"workflow.id === selectedId ? 'bg-indigo-500/15.text-indigo-100' : 'text-gray-300'\">\n <span class=\"truncate font-medium\">{{ workflow.name }}</span>\n <span class=\"ml-3 rounded-full border border-gray-700 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-gray-400\">{{ workflow.status }}</span>\n </button>\n </template>\n <p class=\"px-3 py-5 text-center text-sm text-gray-500\" class-hidden=\"workflows.length > 0\">No workflows yet.</p>\n </div>\n </aside>\n\n <section class=\"editor-panel overflow-hidden rounded-xl border border-gray-800 bg-gray-900/80 shadow-xl shadow-black/10\">\n <div class=\"border-b border-gray-800 bg-gray-900 px-4 py-4 sm:px-5\">\n <div class=\"flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between\">\n <label class=\"block max-w-xl flex-1 text-xs font-semibold uppercase tracking-wider text-gray-400\">Workflow ID\n <input bind-value=\"workflowId\" on-input=\"setWorkflowId($event)\" class=\"mt-2 w-full rounded-lg border border-gray-700 bg-gray-950 px-3 py-2.5 font-mono text-sm normal-case tracking-normal text-white outline-none transition focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20\" placeholder=\"derived-from-workflow-name\">\n </label>\n <div class=\"flex flex-wrap gap-2\">\n <button on-click=\"validate()\" bind-disabled=\"busy\" class=\"inline-flex items-center gap-2 rounded-lg border border-gray-700 px-3 py-2.5 text-sm font-medium text-gray-200 transition hover:border-gray-500 hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50\">\n <lucide-icon icon=\"badge-check\" size=\"16\"></lucide-icon>\n Validate\n </button>\n <button on-click=\"save()\" bind-disabled=\"busy\" class=\"inline-flex items-center gap-2 rounded-lg bg-indigo-500 px-3 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-400 disabled:cursor-not-allowed disabled:opacity-50\">\n <lucide-icon icon=\"save\" size=\"16\"></lucide-icon>\n Save draft\n </button>\n <button on-click=\"publish()\" bind-disabled=\"busy\" class=\"inline-flex items-center gap-2 rounded-lg border border-emerald-500/50 px-3 py-2.5 text-sm font-medium text-emerald-300 transition hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-50\">\n <lucide-icon icon=\"rocket\" size=\"16\"></lucide-icon>\n Publish\n </button>\n <button on-click=\"remove()\" bind-disabled=\"busy\" class=\"inline-flex items-center gap-2 rounded-lg border border-rose-500/50 px-3 py-2.5 text-sm font-medium text-rose-300 transition hover:bg-rose-500/10 disabled:cursor-not-allowed disabled:opacity-50\">\n <lucide-icon icon=\"trash-2\" size=\"16\"></lucide-icon>\n Delete\n </button>\n </div>\n </div>\n </div>\n <div class=\"p-3 sm:p-5\">\n <div class=\"mb-2 flex items-center justify-between px-1 text-xs text-gray-500\">\n <span class=\"font-medium\">YAML definition</span>\n <span class=\"font-mono\">{{ source.length }} characters</span>\n </div>\n <code-editor id=\"source-yaml\" language=\"yaml\" theme=\"atom-one-dark\" nostatus=\"true\" class=\"block h-[calc(100vh-23rem)] min-h-[28rem] overflow-hidden rounded-lg border border-gray-700 bg-gray-950\" bind-value=\"source\" on-change=\"setSource($event.detail)\"></code-editor>\n </div>\n </section>\n </section>\n\n <section class=\"secrets-panel rounded-xl border border-gray-800 bg-gray-900/80 p-4 shadow-xl shadow-black/10 sm:p-5\">\n <div class=\"mb-4 flex items-start gap-3\">\n <span class=\"rounded-lg bg-indigo-500/10 p-2 text-indigo-300\"><lucide-icon icon=\"key-round\" size=\"18\"></lucide-icon></span>\n <div><h2 class=\"text-base font-semibold text-white\">Secrets</h2><p class=\"mt-0.5 text-sm text-gray-400\">Values are write-only and encrypted. Re-saving a name replaces its value.</p></div>\n </div>\n <div class=\"grid gap-3 md:grid-cols-[14rem_1fr_auto_auto]\">\n <input bind-value=\"secretName\" on-input=\"setSecretName($event)\" class=\"rounded-lg border border-gray-700 bg-gray-950 px-3 py-2.5 font-mono text-sm uppercase outline-none transition focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20\" placeholder=\"NPM_TOKEN\">\n <input ref=\"secretValueInput\" bind-value=\"secretValue\" on-input=\"setSecretValue($event)\" type=\"password\" class=\"rounded-lg border border-gray-700 bg-gray-950 px-3 py-2.5 font-mono text-sm outline-none transition focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20\" placeholder=\"New secret value\">\n <button on-click=\"saveSecret()\" bind-disabled=\"busy\" class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-indigo-500/60 px-3 py-2.5 text-sm font-medium text-indigo-200 transition hover:bg-indigo-500/10 disabled:opacity-50\"><lucide-icon icon=\"lock-keyhole\" size=\"16\"></lucide-icon>Save value</button>\n <button on-click=\"removeSecret()\" bind-disabled=\"busy\" class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-rose-500/50 px-3 py-2.5 text-sm font-medium text-rose-300 transition hover:bg-rose-500/10 disabled:opacity-50\"><lucide-icon icon=\"trash-2\" size=\"16\"></lucide-icon>Delete</button>\n </div>\n <div class=\"mt-4 flex flex-wrap gap-2\">\n <template for=\"name of secrets\"><button on-click=\"selectSecret(name)\" class=\"rounded-md border border-gray-700 px-2.5 py-1.5 font-mono text-xs text-gray-300 transition hover:border-indigo-500 hover:text-indigo-200\">{{ name }}</button></template>\n </div>\n </section>\n </main>\n\n <script setup>\n import { onInit, ref, templateRef } from '@li3/web';\n\n export default function () {\n const page = document.body.dataset.page;\n const initialId = __WORKFLOW_ID__;\n const workflows = ref([]);\n const secrets = ref([]);\n const source = ref('');\n const workflowId = ref('');\n const selectedId = ref('');\n const secretName = ref('');\n const secretValue = ref('');\n const notice = ref('');\n const noticeError = ref(false);\n const busy = ref(false);\n const secretValueInput = templateRef('secretValueInput');\n\n const showNotice = (message, error = false) => { notice.value = message; noticeError.value = error; };\n const api = async (url, options = {}) => {\n const response = await fetch(url, { headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) }, ...options });\n const body = response.status === 204 ? null : await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`);\n return body;\n };\n const derivedId = () => source.value.match(/^\\s*(?:id:\\s*([^\\n]+)|name:\\s*([^\\n]+))/m)?.slice(1).find(Boolean)?.trim().toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/(^-|-$)/g, '') || '';\n const run = async (action) => {\n if (busy.value) return;\n busy.value = true;\n try { await action(); } catch (error) { showNotice(error.message, true); } finally { busy.value = false; }\n };\n const loadWorkflows = async () => { workflows.value = (await api('/api/workflows')).workflows; };\n const loadSecrets = async () => { secrets.value = (await api('/api/secrets')).secrets; };\n const openWorkflow = async (id) => {\n const workflow = await api(`/api/workflows/${id}`);\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n source.value = workflow.sourceYaml;\n };\n const validate = () => run(async () => {\n const result = await api('/api/workflows/validate', { method: 'POST', body: JSON.stringify({ sourceYaml: source.value }) });\n showNotice(`Valid: ${result.workflows.length} workflow definition(s).`);\n });\n const save = () => run(async () => {\n const id = workflowId.value.trim() || derivedId();\n if (!id) throw new Error('Set an ID or add a workflow name first.');\n const workflow = await api(`/api/workflows/${id}`, { method: 'PUT', body: JSON.stringify({ sourceYaml: source.value }) });\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n showNotice(`Saved ${workflow.id} as draft revision ${workflow.revision}.`);\n if (!initialId) history.replaceState(null, '', `/workflows/${workflow.id}`);\n });\n const publish = () => run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id) throw new Error('Save a workflow before publishing it.');\n const workflow = await api(`/api/workflows/${id}/publish`, { method: 'POST' });\n selectedId.value = workflow.id;\n showNotice(`Published ${workflow.id} revision ${workflow.revision}.`);\n });\n const remove = () => run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id || !confirm(`Delete ${id} and all of its revisions?`)) return;\n await api(`/api/workflows/${id}`, { method: 'DELETE' });\n window.location.href = '/workflows';\n });\n const saveSecret = () => run(async () => {\n const name = secretName.value.trim();\n if (!name || !secretValue.value) throw new Error('Set a secret name and value first.');\n await api(`/api/secrets/${name}`, { method: 'PUT', body: JSON.stringify({ value: secretValue.value }) });\n secretValue.value = '';\n await loadSecrets();\n showNotice(`Saved ${name}.`);\n });\n const removeSecret = () => run(async () => {\n const name = secretName.value.trim();\n if (!name || !confirm(`Delete ${name}?`)) return;\n await api(`/api/secrets/${name}`, { method: 'DELETE' });\n secretName.value = '';\n secretValue.value = '';\n await loadSecrets();\n showNotice(`Deleted ${name}.`);\n });\n const setSource = (value) => { source.value = value; };\n const setWorkflowId = (event) => { workflowId.value = event.target.value; };\n const setSecretName = (event) => { secretName.value = event.target.value; };\n const setSecretValue = (event) => { secretValue.value = event.target.value; };\n const selectSecret = (name) => { secretName.value = name; secretValueInput.value?.focus(); };\n const visitWorkflow = (id) => { window.location.href = `/workflows/${id}`; };\n\n onInit(() => {\n const loadPage = page === 'editor' ? (initialId ? openWorkflow(initialId) : Promise.resolve()) : Promise.all([loadWorkflows(), loadSecrets()]);\n loadPage.catch((error) => showNotice(error.message, true));\n });\n return { workflows, secrets, source, workflowId, selectedId, secretName, secretValue, notice, noticeError, busy, validate, save, publish, remove, saveSecret, removeSecret, setSource, setWorkflowId, setSecretName, setSecretValue, selectSecret, visitWorkflow };\n }\n <\/script>\n </template>\n\n <script type=\"module\">import '@li3/web';<\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n</body>\n</html>\n";
8260
8260
  //#endregion
8261
8261
  //#region src/workflows-ui.ts
8262
8262
  function Wo(e, t = "") {
@@ -8895,9 +8895,7 @@ async function Qo(e) {
8895
8895
  let t = !1;
8896
8896
  async function n(n) {
8897
8897
  if (!t) {
8898
- t = !0, console.log(`\n🛑 Received ${n}. Initiating graceful shutdown...`), setTimeout(() => {
8899
- console.error("⚠️ Graceful shutdown timed out after 10s. Forcing exit!"), process.exit(1);
8900
- }, 1e4).unref();
8898
+ t = !0, console.log(`\n🛑 Received ${n}. Initiating graceful shutdown...`);
8901
8899
  try {
8902
8900
  as(), e.length > 0 && (console.log("⚙️ Waiting for active worker jobs to drain..."), await Promise.allSettled(e)), console.log("✨ Engine stopped cleanly. Goodbye!"), process.exit(0);
8903
8901
  } catch (e) {
@@ -8905,7 +8903,7 @@ async function Qo(e) {
8905
8903
  }
8906
8904
  }
8907
8905
  }
8908
- process.on("SIGINT", () => n("SIGINT")), process.on("SIGTERM", () => n("SIGTERM"));
8906
+ process.on("SIGINT", () => n("SIGINT")), process.on("SIGTERM", () => n("SIGTERM")), process.on("SIGUSR1", () => n("SIGUSR1"));
8909
8907
  }
8910
8908
  //#endregion
8911
8909
  //#region src/plugins/manager.ts
@@ -1 +1 @@
1
- {"version":3,"file":"signals.d.ts","sourceRoot":"","sources":["../src/signals.ts"],"names":[],"mappings":"AAEA,wBAAsB,mBAAmB,CAAC,oBAAoB,KAAA,iBAkC7D"}
1
+ {"version":3,"file":"signals.d.ts","sourceRoot":"","sources":["../src/signals.ts"],"names":[],"mappings":"AAEA,wBAAsB,mBAAmB,CAAC,oBAAoB,KAAA,iBA6B7D"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud-cli/on",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "CLI entry point for the on plugin ecosystem.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.30.3",