@cloud-cli/on 1.60.4 → 1.61.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 +2 -1
- package/dist/on.js +3 -3
- package/dist/{server-Bfa31cV3.js → server-BHeR25AN.js} +51 -421
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/{worker-riimPLUr.js → worker-BwNyfSpp.js} +1 -1
- package/dist/{workflows-BF2K4GpM.js → workflows-DQQIVH7c.js} +1 -1
- package/dist/{workflows-Dm_0dWb_.js → workflows-nhyVGFWV.js} +7 -0
- package/dist/workflows.d.ts +4 -0
- package/dist/workflows.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,7 +98,7 @@ steps:
|
|
|
98
98
|
npx --yes semantic-release@24 -b main --no-ci
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
-
Store the YAML as a draft and publish it through the authenticated API. Only published revisions can receive webhooks or scheduled runs:
|
|
101
|
+
Store the YAML as a draft and publish it through the authenticated API. Only published revisions can receive webhooks or scheduled runs. For an immediate test from the editor, save the draft and use **Run now**; this queues the latest saved draft revision without changing the schedule or publishing it:
|
|
102
102
|
|
|
103
103
|
```bash
|
|
104
104
|
curl -u admin:"$RUNNER_ADMIN_SECRET" -X POST http://localhost:11235/api/workflows/validate \
|
|
@@ -296,6 +296,7 @@ The Ingress Gateway listens for incoming HTTP requests and serves the live web U
|
|
|
296
296
|
| **`POST`** | `/api/workflows/validate` | Authenticated YAML validation without persistence. |
|
|
297
297
|
| **`GET`, `PUT`, `DELETE`** | `/api/workflows/:id` | Authenticated revisioned workflow read, draft save, and deletion. |
|
|
298
298
|
| **`POST`** | `/api/workflows/:id/publish` | Authenticated publication of the latest draft revision. |
|
|
299
|
+
| **`POST`** | `/api/workflows/:id/run` | Authenticated immediate run of the latest saved draft revision. |
|
|
299
300
|
| **`GET`, `PUT`, `DELETE`** | `/api/secrets`, `/api/secrets/:name` | Authenticated encrypted secret-name and write-only value management. |
|
|
300
301
|
|
|
301
302
|
### Dashboard Features
|
package/dist/on.js
CHANGED
|
@@ -102,9 +102,9 @@ async function m() {
|
|
|
102
102
|
let [{ QueueManager: n }, { SecretStore: r }, { WebhookServer: i }, { startWorkers: a }, { WorkflowRepository: o }, { WorkflowScheduler: s }] = await Promise.all([
|
|
103
103
|
import("./queue-DhXXzxm3.js"),
|
|
104
104
|
import("./secrets-DhkqmawU.js"),
|
|
105
|
-
import("./server-
|
|
106
|
-
import("./worker-
|
|
107
|
-
import("./workflows-
|
|
105
|
+
import("./server-BHeR25AN.js"),
|
|
106
|
+
import("./worker-BwNyfSpp.js"),
|
|
107
|
+
import("./workflows-DQQIVH7c.js"),
|
|
108
108
|
import("./scheduler-2d1UPcxM.js")
|
|
109
109
|
]), c = new r(), l = new n(process.env.WORKER_NAME || "cli");
|
|
110
110
|
switch (t) {
|
|
@@ -3,7 +3,7 @@ import { t as r } from "./db-client-BvMkK1lx.js";
|
|
|
3
3
|
import { a as i, n as a, r as o, t as s } from "./matrix-expander-BItKqWu2.js";
|
|
4
4
|
import { t as c } from "./secrets-BR6K16WY.js";
|
|
5
5
|
import { t as l } from "./events-BoODn7MY.js";
|
|
6
|
-
import { n as u } from "./workflows-
|
|
6
|
+
import { n as u } from "./workflows-nhyVGFWV.js";
|
|
7
7
|
import { readFileSync as d } from "node:fs";
|
|
8
8
|
import f from "node:crypto";
|
|
9
9
|
import p from "node:http";
|
|
@@ -1775,7 +1775,7 @@ w.use = Jt, w.walkTokens = function(e, t) {
|
|
|
1775
1775
|
}, w.parseInline = qt.parseInline, w.Parser = Gt, w.parser = Gt.parse, w.Renderer = Ut, w.TextRenderer = Wt, w.Lexer = Ht, w.lexer = Ht.lex, w.Tokenizer = Vt, w.Hooks = Kt, w.parse = w, w.options, w.setOptions, w.walkTokens, w.parseInline, Gt.parse, Ht.lex;
|
|
1776
1776
|
//#endregion
|
|
1777
1777
|
//#region docs/workflow-syntax.md?raw
|
|
1778
|
-
var Yt = "# Workflow Documentation\n\nThis is the living documentation for workflow authors. It is organized using the four Diataxis modes:\n\n- **Tutorials** teach the shortest path to a working workflow.\n- **How-to guides** solve focused tasks.\n- **Reference** documents the accepted syntax.\n- **Explanation** describes execution and security decisions.\n\n## Tutorials\n\n### Your First Workflow\n\nCreate a draft in the authenticated workflow editor, or send the YAML to the API:\n\n```yaml\nname: Hello\n\non:\n generic: {}\n\nsteps:\n - id: greet\n name: Greet\n run: echo \"Hello ${inputs.name}\"\n```\n\nSave it, validate it, and publish it. Only published and enabled workflows receive webhook or scheduled events.\n\nThe `generic` provider accepts a JSON request body as `inputs`. For example:\n\n```bash\ncurl -X POST http://localhost:11235/webhooks/generic \\\n -H 'content-type: application/json' \\\n --data '{\"name\":\"Ada\"}'\n```\n\n### Build a GitHub Workflow\n\n```yaml\nname: Build\n\non:\n github:\n events: [push]\n owner: cloud-cli\n repo: on\n branches: [main]\n\nsteps:\n - id: checkout\n name: Checkout\n env:\n clone_url: ${inputs.clone_url}\n commit_sha: ${inputs.commit_sha}\n run: |\n git clone --depth 1 \"$clone_url\" .\n git checkout \"$commit_sha\"\n\n - id: test\n name: Test\n run: pnpm install && pnpm test\n```\n\nGitHub webhooks must be signed with the configured `GITHUB_WEBHOOK_SECRET`. The preprocessor exposes normalized inputs including `event`, `owner`, `repo`, `branch`, `tag`, `ref`, `full_name`, `clone_url`, `commit_sha`, `author`, `action`, `changes`, and the original `raw` body.\n\nGitHub workflow `if:` conditions also receive an async `github.fileExists(path)` helper. It checks the repository contents API using the webhook's owner, repository, and ref:\n\n```yaml\non:\n github:\n if: github.fileExists('Dockerfile')\n```\n\nThe helper is only available to GitHub workflows. A missing file returns `false`; unsafe paths and failed lookups do not trigger the workflow.\n\n## How-To Guides\n\n### Reference Another Environment Value\n\nEnvironment entries are evaluated from top to bottom. Earlier values are available through `env` to later values:\n\n```yaml\nenv:\n image: ${inputs.repo}\n image_tag: ${env.image}:latest\n artifact_name: ${env.image_tag}-${inputs.commit_sha}\n```\n\nWorkflow-level values are available to every step. Step-level `env` values are evaluated after workflow-level values and can override them for that step.\n\nUse `${inputs.name}`, `${env.NAME}`, `${secrets.NAME}`, and `${steps.step_id.outputs.value}` where the value is available. A value without `${...}` remains literal text.\n\n### Run a Container with a Host Volume\n\nUse `image` with `volumes`. A volume is passed directly to `docker run` as a `-v` mapping:\n\n```yaml\nsteps:\n - id: sign\n name: Sign release\n image: alpine:latest\n volumes:\n - /var/lib/runner/secrets-store:/secrets:ro\n run: |\n ./sign.sh /secrets/signing.key dist/release.tar.gz\n```\n\nThe source path is on the worker host. The destination path is inside the container. Use absolute host paths when a specific host directory is required. The workflow must be trusted because volume mappings can expose host data to a container.\n\nA single string is also accepted:\n\n```yaml\nvolumes: /var/lib/runner/secrets-store:/secrets:ro\n```\n\n### Pass Additional Docker Arguments\n\nUse `dockerArgs` for additional arguments to `docker run`, before the image name:\n\n```yaml\nsteps:\n - id: build\n image: docker:cli\n dockerArgs:\n - --network=host\n - --user=1000:1000\n run: docker build -t example:latest .\n```\n\nArguments are not interpreted or shell-expanded by the runner. They are passed as individual Docker CLI arguments.\n\n### Use a Matrix\n\nKeep the matrix in the workflow definition:\n\n```yaml\nname: Package\n\nmatrix:\n node: [18, 20]\n os: [ubuntu, alpine]\n\ntags:\n - '${matrix.os}'\n\non:\n generic: {}\n\nenv:\n node_version: ${env.MATRIX_NODE}\n\nsteps:\n - id: package\n image: node:${env.MATRIX_NODE}\n run: npm pack\n```\n\nThe runner creates one job for every Cartesian combination. The example creates four jobs. Each variant receives `MATRIX_NODE` and `MATRIX_OS` environment variables. Matrix expressions in `tags` are resolved before enqueueing, so each job can target a different runner. Tags remain an AND requirement within each job.\n\nIf a matching runner is offline before claiming its matrix job, the job remains pending and is claimed when that runner reconnects. A job already claimed by a worker is recovered as stale after one hour if the worker process does not complete it.\n\n### Add a Scheduled Trigger\n\n```yaml\non:\n schedule:\n - id: nightly\n cron: '0 2 * * *'\n timezone: Europe/Berlin\n```\n\nCron expressions have five fields: minute, hour, day of month, month, and day of week. The scheduler uses the declared IANA timezone.\n\nSolar triggers use `sunrise` or `sunset` and geographic coordinates:\n\n```yaml\non:\n solar:\n - id: morning\n event: sunrise\n latitude: 52.52\n longitude: 13.405\n offset: +15m\n```\n\n### Use Secrets\n\nCreate a secret in the management UI or with the API, then reference it in environment values:\n\n```yaml\nsteps:\n - id: publish\n env:\n npm_token: ${secrets.NPM_TOKEN}\n run: npm publish\n```\n\nSecret values are delivered to a worker after it claims the job. Do not write them into reports or artifacts.\n\nManaged secrets can be text or binary. Use the management UI's file picker for binary values, or send base64 through the API:\n\n```json\n{\n \"value\": \"<base64 file bytes>\",\n \"encoding\": \"base64\",\n \"originalName\": \"release.keystore\"\n}\n```\n\nBinary values are encrypted at rest and are decoded only when written through a file secret mapping. They should not be injected into environment variables.\n\n### Persist Artifacts and Caches\n\nArtifacts are captured from the workspace after a successful run:\n\n```yaml\nartifacts:\n paths:\n - dist\n - reports/**/*.json\n```\n\nCaches are restored before steps start and saved after a successful run:\n\n```yaml\ncache:\n key: npm-${inputs.repo}-${inputs.branch}\n paths:\n - node_modules\n```\n\nConfigure a shared FileBin backend on the server and workers with `RUNNER_FILE_API_URL`, `RUNNER_FILE_BIN`, and optionally `RUNNER_FILE_PASSWORD`. Create a bin with `POST /bin`, then configure its ID. Without FileBin configuration, the runner uses the central database fallback. Cache keys are scoped by workflow; artifacts are scoped by job. Stored collections are limited to 50 MiB per capture.\n\nAdministrators can download a captured artifact with `GET /api/runs/:jobId/artifacts/:path`.\n\nPopulate workspace files from managed secrets before steps run:\n\n```yaml\nsecretFiles:\n .config/signing.key: SIGNING_KEY\n```\n\nSecret files are created with owner-only permissions in the worker workspace. They must be handled according to the worker storage lifecycle and should not be included in artifacts or caches.\n\n### Configure Workflow Plugins\n\nEnable status updates for an individual GitHub workflow by declaring a plugin and mapping its secret inputs through the existing server-managed secrets:\n\n```yaml\nplugins:\n - name: github-status\n secrets:\n GITHUB_TOKEN: ${secrets.GITHUB_TOKEN}\n context: ci/build\n```\n\nThe worker uses that secret for `pending`, `success`, `failure`, or `error` commit statuses. The token is never stored in workflow YAML or job reports. The workflow must have GitHub `owner`, `repo`, and `commit_sha` inputs; otherwise no status request is made.\n\nPlugin names are resolved through the server's workflow plugin registry. `github-status` is built in. External integrations can register a factory during runner configuration with `registerWorkflowPlugin(name, factory)` before workers start.\n\n## Reference\n\n### Top-Level Workflow Fields\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `name` | string | Required human-readable workflow name shown in the UI and reports. |\n| `id` | string | Optional identifier used when deriving an ID outside the management API. The management API ID is authoritative after creation. |\n| `on` | object | Required trigger configuration. |\n| `env` | object | Workflow environment values, evaluated in declaration order. |\n| `matrix` | object | Matrix dimensions. Each value is an array of strings, numbers, or booleans. |\n| `concurrency` | object | Optional concurrency group and cancellation policy. |\n| `tags` | string[] | Worker capability tags required by the job. |\n| `artifacts` | object | Workspace paths captured after successful runs. |\n| `cache` | object | Keyed workspace paths restored before and saved after successful runs. |\n| `secretFiles` | object | Workspace-relative file paths mapped to secret names. |\n| `plugins` | object[] | Optional per-workflow plugin declarations and secret mappings. |\n| `steps` | object[] | Required non-empty ordered list of steps. |\n| `retries` | integer | Additional attempts for a failed step. Must be zero or greater. |\n\n`includes` and workflow-to-workflow imports are not supported. Workflow definitions are stored as complete database revisions. Reusable behavior should be implemented through the planned step reuse mechanism rather than filesystem imports.\n\n### Triggers\n\nThe first non-`schedule`/`solar` key in `on` is the provider trigger. Supported providers include `generic` and `github`.\n\n#### GitHub Trigger Filters\n\n| Field | Type | Behavior |\n| --- | --- | --- |\n| `events` | string[] | Exact event names such as `push` or `pull_request`. |\n| `owner` | string or string[] | Exact repository owner. |\n| `repo` | string or string[] | Exact repository name. |\n| `name` | string or string[] | Exact `owner/repo` name. |\n| `branches` | string or string[] | Glob patterns matching normalized branches or tags. |\n| `refs` | string or string[] | Alias for branch/ref matching. |\n| `tag` | boolean | Requires a tag push when true, a non-tag event when false. |\n| `tags` | string[] | Regular expressions matched against the tag value. |\n| `paths` | string[] | Glob patterns matched against changed files. |\n| `if` | string or string[] | Safe JavaScript condition evaluated after provider filters. |\n\nConfigured fields are combined with AND. Multiple values within a field are combined with OR. Negated `!value` entries are supported by exact-value filters.\n\n#### Schedule Trigger\n\n```yaml\nschedule:\n - id: hourly\n cron: '0 * * * *'\n timezone: UTC\n```\n\n#### Solar Trigger\n\n```yaml\nsolar:\n - id: sunrise-build\n event: sunrise\n latitude: 52.52\n longitude: 13.405\n timezone: Europe/Berlin\n offset: -15m\n```\n\n### Environment and Expressions\n\nInterpolated strings use standard JavaScript template expressions inside `${...}`. The safe evaluator supports literals, property access, arithmetic, comparisons, boolean operators, ternaries, arrays, objects, and these helpers:\n\n```text\nString(value)\nNumber(value)\nBoolean(value)\nJSON.parse(value)\nJSON.stringify(value)\n```\n\nAvailable context objects are:\n\n| Object | Available data |\n| --- | --- |\n| `inputs` | Trigger payload and normalized provider inputs. |\n| `env` | Runner environment plus earlier workflow or step environment entries. |\n| `secrets` | Decrypted job-scoped secrets. |\n| `steps` | Earlier step statuses, exit codes, and outputs. |\n| `files` | Workspace-scoped `exists`, `readFile`, and `join` helpers for `eval:` steps. |\n\nExpressions cannot access JavaScript constructors or prototypes. Shell commands are not expression-evaluated; only declared interpolation fields are evaluated.\n\n### Steps\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `id` | string | Stable identifier used by `steps.<id>`. |\n| `name` | string | Display name. Defaults to `id`. |\n| `run` | string | Shell command or script. Mutually exclusive with `eval`. |\n| `eval` | string | In-process safe expression. Mutually exclusive with `run`. |\n| `env` | object | Step environment, evaluated after workflow environment. |\n| `image` | string | Docker image. Selects container execution for the step. |\n| `volumes` | string or string[] | Docker `-v` mappings. |\n| `dockerArgs` | string[] | Additional raw arguments for `docker run`. |\n| `timeoutMs` | positive integer | Maximum step runtime. Defaults to 30 seconds. |\n| `if` | string | Safe condition. False conditions produce a skipped step. |\n\n`run` steps execute through the selected worker driver. With `image`, the command runs as `sh -c` inside Docker. Without `image`, it runs through the worker shell. `eval` steps run inside the worker process and can produce structured outputs for later steps.\n\n### Management and API\n\nWorkflow definitions are drafted, validated, published, enabled, and deleted through the authenticated management UI or these endpoints:\n\n```text\nPOST /api/workflows/validate\nGET /api/workflows\nGET /api/workflows/:id\nPUT /api/workflows/:id\nPOST /api/workflows/:id/publish\nDELETE /api/workflows/:id\n```\n\nOnly published workflows with `enabled: true` are matched for incoming triggers. Saving a workflow creates a new immutable revision. Existing jobs continue to use the revision recorded when they were created.\n\n## Explanation\n\n### Why Workflow YAML Lives in the Database\n\nThe server stores source YAML and normalized revisions in the database. Workers are stateless: they fetch the referenced revision when claiming a job and do not read workflow files from their local filesystem. This keeps every worker on the same definition and makes published revisions reproducible.\n\n### Why Matrix Expansion Happens at Trigger Time\n\nThe stored revision remains one canonical workflow. When a trigger matches, the server creates one queue job per matrix combination. The payload records the matrix context, and the worker resolves that same variant before execution. This avoids storing duplicate workflow revisions while keeping each job independently observable.\n\n### Docker Volumes and Trust\n\n`volumes` and `dockerArgs` are intentionally powerful options. A host volume can expose credentials or signing material, and Docker arguments can change networking, users, capabilities, or isolation. Only trusted administrators should publish workflows using these fields. The runner does not copy, inspect, or redact files mounted into containers.\n\n### Reuse and Imports\n\nFilesystem imports were removed because workers no longer load workflow files. Future step reuse should operate on database-owned definitions or explicit reusable step mechanisms, keeping revisioning and authorization visible to the control plane.\n", Xt = "\n :root { color-scheme: dark; }\n * { box-sizing: border-box; }\n body { margin: 0; background: #030712; color: #e5e7eb; font-family: ui-sans-serif, system-ui, sans-serif; }\n main { max-width: 78rem; margin: 0 auto; padding: 2rem 1rem 5rem; }\n article { max-width: 54rem; margin: 0 auto; line-height: 1.7; }\n h1, h2, h3 { color: #f9fafb; line-height: 1.2; letter-spacing: -0.02em; }\n h1 { font-size: 2.25rem; margin: 0 0 1rem; }\n h2 { border-top: 1px solid #1f2937; margin-top: 3rem; padding-top: 1.5rem; font-size: 1.6rem; }\n h3 { margin-top: 2rem; font-size: 1.15rem; }\n p, li { color: #cbd5e1; }\n a { color: #93c5fd; }\n code { border-radius: .35rem; background: #111827; color: #bfdbfe; padding: .12rem .35rem; font-size: .9em; }\n pre { overflow-x: auto; border: 1px solid #1f2937; border-radius: .75rem; background: #0b1120; padding: 1rem; }\n pre code { background: transparent; padding: 0; color: #d1d5db; }\n table { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; margin: 1rem 0; }\n th, td { border-bottom: 1px solid #1f2937; padding: .65rem .75rem; text-align: left; vertical-align: top; }\n th { color: #f9fafb; background: #111827; }\n blockquote { border-left: 3px solid #6366f1; margin-left: 0; padding-left: 1rem; color: #94a3b8; }\n .help-layout { display: grid; grid-template-columns: minmax(0, 54rem); gap: 3rem; justify-content: center; }\n .toc { display: none; }\n .toc nav { position: sticky; top: 1.25rem; max-height: calc(100vh - 2.5rem); overflow-y: auto; border-left: 1px solid #1f2937; padding-left: 1rem; }\n .toc-title { margin: 0 0 .65rem; color: #f9fafb; font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }\n .toc ul { display: grid; gap: .15rem; list-style: none; margin: 0; padding: 0; }\n .toc a { display: block; border-radius: .35rem; color: #94a3b8; font-size: .8rem; line-height: 1.35; padding: .3rem .45rem; text-decoration: none; }\n .toc a:hover, .toc a:focus-visible { background: #111827; color: #dbeafe; outline: none; }\n .toc-depth-2 { padding-left: .9rem !important; }\n .toc-depth-3 { padding-left: 1.8rem !important; font-size: .75rem !important; }\n @media (min-width: 1024px) { .help-layout { grid-template-columns: minmax(0, 54rem) 16rem; justify-content: center; } .toc { display: block; } }\n @media (min-width: 640px) { main { padding: 3rem 1.5rem 6rem; } }\n";
|
|
1778
|
+
var Yt = "# Workflow Documentation\n\nThis is the living documentation for workflow authors. It is organized using the four Diataxis modes:\n\n- **Tutorials** teach the shortest path to a working workflow.\n- **How-to guides** solve focused tasks.\n- **Reference** documents the accepted syntax.\n- **Explanation** describes execution and security decisions.\n\n## Tutorials\n\n### Your First Workflow\n\nCreate a draft in the authenticated workflow editor, or send the YAML to the API:\n\n```yaml\nname: Hello\n\non:\n generic: {}\n\nsteps:\n - id: greet\n name: Greet\n run: echo \"Hello ${inputs.name}\"\n```\n\nSave it, validate it, and publish it. Only published and enabled workflows receive webhook or scheduled events.\n\nTo test without changing the schedule or publishing first, save the draft and select **Run now** in the workflow editor. The editor queues the latest saved draft revision immediately; a worker must still be running.\n\nThe `generic` provider accepts a JSON request body as `inputs`. For example:\n\n```bash\ncurl -X POST http://localhost:11235/webhooks/generic \\\n -H 'content-type: application/json' \\\n --data '{\"name\":\"Ada\"}'\n```\n\n### Build a GitHub Workflow\n\n```yaml\nname: Build\n\non:\n github:\n events: [push]\n owner: cloud-cli\n repo: on\n branches: [main]\n\nsteps:\n - id: checkout\n name: Checkout\n env:\n clone_url: ${inputs.clone_url}\n commit_sha: ${inputs.commit_sha}\n run: |\n git clone --depth 1 \"$clone_url\" .\n git checkout \"$commit_sha\"\n\n - id: test\n name: Test\n run: pnpm install && pnpm test\n```\n\nGitHub webhooks must be signed with the configured `GITHUB_WEBHOOK_SECRET`. The preprocessor exposes normalized inputs including `event`, `owner`, `repo`, `branch`, `tag`, `ref`, `full_name`, `clone_url`, `commit_sha`, `author`, `action`, `changes`, and the original `raw` body.\n\nGitHub workflow `if:` conditions also receive an async `github.fileExists(path)` helper. It checks the repository contents API using the webhook's owner, repository, and ref:\n\n```yaml\non:\n github:\n if: github.fileExists('Dockerfile')\n```\n\nThe helper is only available to GitHub workflows. A missing file returns `false`; unsafe paths and failed lookups do not trigger the workflow.\n\n## How-To Guides\n\n### Reference Another Environment Value\n\nEnvironment entries are evaluated from top to bottom. Earlier values are available through `env` to later values:\n\n```yaml\nenv:\n image: ${inputs.repo}\n image_tag: ${env.image}:latest\n artifact_name: ${env.image_tag}-${inputs.commit_sha}\n```\n\nWorkflow-level values are available to every step. Step-level `env` values are evaluated after workflow-level values and can override them for that step.\n\nUse `${inputs.name}`, `${env.NAME}`, `${secrets.NAME}`, and `${steps.step_id.outputs.value}` where the value is available. A value without `${...}` remains literal text.\n\n### Run a Container with a Host Volume\n\nUse `image` with `volumes`. A volume is passed directly to `docker run` as a `-v` mapping:\n\n```yaml\nsteps:\n - id: sign\n name: Sign release\n image: alpine:latest\n volumes:\n - /var/lib/runner/secrets-store:/secrets:ro\n run: |\n ./sign.sh /secrets/signing.key dist/release.tar.gz\n```\n\nThe source path is on the worker host. The destination path is inside the container. Use absolute host paths when a specific host directory is required. The workflow must be trusted because volume mappings can expose host data to a container.\n\nA single string is also accepted:\n\n```yaml\nvolumes: /var/lib/runner/secrets-store:/secrets:ro\n```\n\n### Pass Additional Docker Arguments\n\nUse `dockerArgs` for additional arguments to `docker run`, before the image name:\n\n```yaml\nsteps:\n - id: build\n image: docker:cli\n dockerArgs:\n - --network=host\n - --user=1000:1000\n run: docker build -t example:latest .\n```\n\nArguments are not interpreted or shell-expanded by the runner. They are passed as individual Docker CLI arguments.\n\n### Use a Matrix\n\nKeep the matrix in the workflow definition:\n\n```yaml\nname: Package\n\nmatrix:\n node: [18, 20]\n os: [ubuntu, alpine]\n\ntags:\n - '${matrix.os}'\n\non:\n generic: {}\n\nenv:\n node_version: ${env.MATRIX_NODE}\n\nsteps:\n - id: package\n image: node:${env.MATRIX_NODE}\n run: npm pack\n```\n\nThe runner creates one job for every Cartesian combination. The example creates four jobs. Each variant receives `MATRIX_NODE` and `MATRIX_OS` environment variables. Matrix expressions in `tags` are resolved before enqueueing, so each job can target a different runner. Tags remain an AND requirement within each job.\n\nIf a matching runner is offline before claiming its matrix job, the job remains pending and is claimed when that runner reconnects. A job already claimed by a worker is recovered as stale after one hour if the worker process does not complete it.\n\n### Add a Scheduled Trigger\n\n```yaml\non:\n schedule:\n - id: nightly\n cron: '0 2 * * *'\n timezone: Europe/Berlin\n```\n\nCron expressions have five fields: minute, hour, day of month, month, and day of week. The scheduler uses the declared IANA timezone.\n\nSolar triggers use `sunrise` or `sunset` and geographic coordinates:\n\n```yaml\non:\n solar:\n - id: morning\n event: sunrise\n latitude: 52.52\n longitude: 13.405\n offset: +15m\n```\n\n### Use Secrets\n\nCreate a secret in the management UI or with the API, then reference it in environment values:\n\n```yaml\nsteps:\n - id: publish\n env:\n npm_token: ${secrets.NPM_TOKEN}\n run: npm publish\n```\n\nSecret values are delivered to a worker after it claims the job. Do not write them into reports or artifacts.\n\nManaged secrets can be text or binary. Use the management UI's file picker for binary values, or send base64 through the API:\n\n```json\n{\n \"value\": \"<base64 file bytes>\",\n \"encoding\": \"base64\",\n \"originalName\": \"release.keystore\"\n}\n```\n\nBinary values are encrypted at rest and are decoded only when written through a file secret mapping. They should not be injected into environment variables.\n\n### Persist Artifacts and Caches\n\nArtifacts are captured from the workspace after a successful run:\n\n```yaml\nartifacts:\n paths:\n - dist\n - reports/**/*.json\n```\n\nCaches are restored before steps start and saved after a successful run:\n\n```yaml\ncache:\n key: npm-${inputs.repo}-${inputs.branch}\n paths:\n - node_modules\n```\n\nConfigure a shared FileBin backend on the server and workers with `RUNNER_FILE_API_URL`, `RUNNER_FILE_BIN`, and optionally `RUNNER_FILE_PASSWORD`. Create a bin with `POST /bin`, then configure its ID. Without FileBin configuration, the runner uses the central database fallback. Cache keys are scoped by workflow; artifacts are scoped by job. Stored collections are limited to 50 MiB per capture.\n\nAdministrators can download a captured artifact with `GET /api/runs/:jobId/artifacts/:path`.\n\nPopulate workspace files from managed secrets before steps run:\n\n```yaml\nsecretFiles:\n .config/signing.key: SIGNING_KEY\n```\n\nSecret files are created with owner-only permissions in the worker workspace. They must be handled according to the worker storage lifecycle and should not be included in artifacts or caches.\n\n### Configure Workflow Plugins\n\nEnable status updates for an individual GitHub workflow by declaring a plugin and mapping its secret inputs through the existing server-managed secrets:\n\n```yaml\nplugins:\n - name: github-status\n secrets:\n GITHUB_TOKEN: ${secrets.GITHUB_TOKEN}\n context: ci/build\n```\n\nThe worker uses that secret for `pending`, `success`, `failure`, or `error` commit statuses. The token is never stored in workflow YAML or job reports. The workflow must have GitHub `owner`, `repo`, and `commit_sha` inputs; otherwise no status request is made.\n\nPlugin names are resolved through the server's workflow plugin registry. `github-status` is built in. External integrations can register a factory during runner configuration with `registerWorkflowPlugin(name, factory)` before workers start.\n\n## Reference\n\n### Top-Level Workflow Fields\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `name` | string | Required human-readable workflow name shown in the UI and reports. |\n| `id` | string | Optional identifier used when deriving an ID outside the management API. The management API ID is authoritative after creation. |\n| `on` | object | Required trigger configuration. |\n| `env` | object | Workflow environment values, evaluated in declaration order. |\n| `matrix` | object | Matrix dimensions. Each value is an array of strings, numbers, or booleans. |\n| `concurrency` | object | Optional concurrency group and cancellation policy. |\n| `tags` | string[] | Worker capability tags required by the job. |\n| `artifacts` | object | Workspace paths captured after successful runs. |\n| `cache` | object | Keyed workspace paths restored before and saved after successful runs. |\n| `secretFiles` | object | Workspace-relative file paths mapped to secret names. |\n| `plugins` | object[] | Optional per-workflow plugin declarations and secret mappings. |\n| `steps` | object[] | Required non-empty ordered list of steps. |\n| `retries` | integer | Additional attempts for a failed step. Must be zero or greater. |\n\n`includes` and workflow-to-workflow imports are not supported. Workflow definitions are stored as complete database revisions. Reusable behavior should be implemented through the planned step reuse mechanism rather than filesystem imports.\n\n### Triggers\n\nThe first non-`schedule`/`solar` key in `on` is the provider trigger. Supported providers include `generic` and `github`.\n\n#### GitHub Trigger Filters\n\n| Field | Type | Behavior |\n| --- | --- | --- |\n| `events` | string[] | Exact event names such as `push` or `pull_request`. |\n| `owner` | string or string[] | Exact repository owner. |\n| `repo` | string or string[] | Exact repository name. |\n| `name` | string or string[] | Exact `owner/repo` name. |\n| `branches` | string or string[] | Glob patterns matching normalized branches or tags. |\n| `refs` | string or string[] | Alias for branch/ref matching. |\n| `tag` | boolean | Requires a tag push when true, a non-tag event when false. |\n| `tags` | string[] | Regular expressions matched against the tag value. |\n| `paths` | string[] | Glob patterns matched against changed files. |\n| `if` | string or string[] | Safe JavaScript condition evaluated after provider filters. |\n\nConfigured fields are combined with AND. Multiple values within a field are combined with OR. Negated `!value` entries are supported by exact-value filters.\n\n#### Schedule Trigger\n\n```yaml\nschedule:\n - id: hourly\n cron: '0 * * * *'\n timezone: UTC\n```\n\n#### Solar Trigger\n\n```yaml\nsolar:\n - id: sunrise-build\n event: sunrise\n latitude: 52.52\n longitude: 13.405\n timezone: Europe/Berlin\n offset: -15m\n```\n\n### Environment and Expressions\n\nInterpolated strings use standard JavaScript template expressions inside `${...}`. The safe evaluator supports literals, property access, arithmetic, comparisons, boolean operators, ternaries, arrays, objects, and these helpers:\n\n```text\nString(value)\nNumber(value)\nBoolean(value)\nJSON.parse(value)\nJSON.stringify(value)\n```\n\nAvailable context objects are:\n\n| Object | Available data |\n| --- | --- |\n| `inputs` | Trigger payload and normalized provider inputs. |\n| `env` | Runner environment plus earlier workflow or step environment entries. |\n| `secrets` | Decrypted job-scoped secrets. |\n| `steps` | Earlier step statuses, exit codes, and outputs. |\n| `files` | Workspace-scoped `exists`, `readFile`, and `join` helpers for `eval:` steps. |\n\nExpressions cannot access JavaScript constructors or prototypes. Shell commands are not expression-evaluated; only declared interpolation fields are evaluated.\n\n### Steps\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `id` | string | Stable identifier used by `steps.<id>`. |\n| `name` | string | Display name. Defaults to `id`. |\n| `run` | string | Shell command or script. Mutually exclusive with `eval`. |\n| `eval` | string | In-process safe expression. Mutually exclusive with `run`. |\n| `env` | object | Step environment, evaluated after workflow environment. |\n| `image` | string | Docker image. Selects container execution for the step. |\n| `volumes` | string or string[] | Docker `-v` mappings. |\n| `dockerArgs` | string[] | Additional raw arguments for `docker run`. |\n| `timeoutMs` | positive integer | Maximum step runtime. Defaults to 30 seconds. |\n| `if` | string | Safe condition. False conditions produce a skipped step. |\n\n`run` steps execute through the selected worker driver. With `image`, the command runs as `sh -c` inside Docker. Without `image`, it runs through the worker shell. `eval` steps run inside the worker process and can produce structured outputs for later steps.\n\n### Management and API\n\nWorkflow definitions are drafted, validated, published, enabled, and deleted through the authenticated management UI or these endpoints:\n\n```text\nPOST /api/workflows/validate\nGET /api/workflows\nGET /api/workflows/:id\nPUT /api/workflows/:id\nPOST /api/workflows/:id/publish\nPOST /api/workflows/:id/run\nDELETE /api/workflows/:id\n```\n\nOnly published workflows with `enabled: true` are matched for incoming triggers. Saving a workflow creates a new immutable revision. Existing jobs continue to use the revision recorded when they were created.\n\n`POST /api/workflows/:id/run` requires `workflows:write` and queues the latest saved revision directly. Send `{}` as the JSON body, or provide manual inputs as `{ \"inputs\": { \"name\": \"value\" } }`. Direct runs may execute draft revisions and do not require the scheduler.\n\n## Explanation\n\n### Why Workflow YAML Lives in the Database\n\nThe server stores source YAML and normalized revisions in the database. Workers are stateless: they fetch the referenced revision when claiming a job and do not read workflow files from their local filesystem. This keeps every worker on the same definition and makes published revisions reproducible.\n\n### Why Matrix Expansion Happens at Trigger Time\n\nThe stored revision remains one canonical workflow. When a trigger matches, the server creates one queue job per matrix combination. The payload records the matrix context, and the worker resolves that same variant before execution. This avoids storing duplicate workflow revisions while keeping each job independently observable.\n\n### Docker Volumes and Trust\n\n`volumes` and `dockerArgs` are intentionally powerful options. A host volume can expose credentials or signing material, and Docker arguments can change networking, users, capabilities, or isolation. Only trusted administrators should publish workflows using these fields. The runner does not copy, inspect, or redact files mounted into containers.\n\n### Reuse and Imports\n\nFilesystem imports were removed because workers no longer load workflow files. Future step reuse should operate on database-owned definitions or explicit reusable step mechanisms, keeping revisioning and authorization visible to the control plane.\n", Xt = "\n :root { color-scheme: dark; }\n * { box-sizing: border-box; }\n body { margin: 0; background: #030712; color: #e5e7eb; font-family: ui-sans-serif, system-ui, sans-serif; }\n main { max-width: 78rem; margin: 0 auto; padding: 2rem 1rem 5rem; }\n article { max-width: 54rem; margin: 0 auto; line-height: 1.7; }\n h1, h2, h3 { color: #f9fafb; line-height: 1.2; letter-spacing: -0.02em; }\n h1 { font-size: 2.25rem; margin: 0 0 1rem; }\n h2 { border-top: 1px solid #1f2937; margin-top: 3rem; padding-top: 1.5rem; font-size: 1.6rem; }\n h3 { margin-top: 2rem; font-size: 1.15rem; }\n p, li { color: #cbd5e1; }\n a { color: #93c5fd; }\n code { border-radius: .35rem; background: #111827; color: #bfdbfe; padding: .12rem .35rem; font-size: .9em; }\n pre { overflow-x: auto; border: 1px solid #1f2937; border-radius: .75rem; background: #0b1120; padding: 1rem; }\n pre code { background: transparent; padding: 0; color: #d1d5db; }\n table { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; margin: 1rem 0; }\n th, td { border-bottom: 1px solid #1f2937; padding: .65rem .75rem; text-align: left; vertical-align: top; }\n th { color: #f9fafb; background: #111827; }\n blockquote { border-left: 3px solid #6366f1; margin-left: 0; padding-left: 1rem; color: #94a3b8; }\n .help-layout { display: grid; grid-template-columns: minmax(0, 54rem); gap: 3rem; justify-content: center; }\n .toc { display: none; }\n .toc nav { position: sticky; top: 1.25rem; max-height: calc(100vh - 2.5rem); overflow-y: auto; border-left: 1px solid #1f2937; padding-left: 1rem; }\n .toc-title { margin: 0 0 .65rem; color: #f9fafb; font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }\n .toc ul { display: grid; gap: .15rem; list-style: none; margin: 0; padding: 0; }\n .toc a { display: block; border-radius: .35rem; color: #94a3b8; font-size: .8rem; line-height: 1.35; padding: .3rem .45rem; text-decoration: none; }\n .toc a:hover, .toc a:focus-visible { background: #111827; color: #dbeafe; outline: none; }\n .toc-depth-2 { padding-left: .9rem !important; }\n .toc-depth-3 { padding-left: 1.8rem !important; font-size: .75rem !important; }\n @media (min-width: 1024px) { .help-layout { grid-template-columns: minmax(0, 54rem) 16rem; justify-content: center; } .toc { display: block; } }\n @media (min-width: 640px) { main { padding: 3rem 1.5rem 6rem; } }\n";
|
|
1779
1779
|
function Zt(e = !1) {
|
|
1780
1780
|
let t = /* @__PURE__ */ new Map(), n = (e) => {
|
|
1781
1781
|
let n = e.replace(/[`*_~]/g, "").replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section", r = t.get(n) || 0;
|
|
@@ -1836,409 +1836,7 @@ var Qt = {
|
|
|
1836
1836
|
{ name: "webhooks" },
|
|
1837
1837
|
{ name: "notifications" }
|
|
1838
1838
|
],
|
|
1839
|
-
paths: {
|
|
1840
|
-
"/api": { get: {
|
|
1841
|
-
tags: ["discovery"],
|
|
1842
|
-
summary: "Get this OpenAPI specification",
|
|
1843
|
-
security: [],
|
|
1844
|
-
responses: { 200: {
|
|
1845
|
-
description: "OpenAPI JSON document",
|
|
1846
|
-
content: { "application/json": { schema: { type: "object" } } }
|
|
1847
|
-
} }
|
|
1848
|
-
} },
|
|
1849
|
-
"/api/api-keys": {
|
|
1850
|
-
get: {
|
|
1851
|
-
tags: ["discovery"],
|
|
1852
|
-
summary: "List active API keys",
|
|
1853
|
-
responses: { 200: {
|
|
1854
|
-
description: "API key metadata",
|
|
1855
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/ApiKeyList" } } }
|
|
1856
|
-
} }
|
|
1857
|
-
},
|
|
1858
|
-
post: {
|
|
1859
|
-
tags: ["discovery"],
|
|
1860
|
-
summary: "Issue a scoped API key",
|
|
1861
|
-
requestBody: {
|
|
1862
|
-
required: !0,
|
|
1863
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/ApiKeyIssue" } } }
|
|
1864
|
-
},
|
|
1865
|
-
responses: { 201: {
|
|
1866
|
-
description: "Key metadata and one-time token",
|
|
1867
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/ApiKeyCreated" } } }
|
|
1868
|
-
} }
|
|
1869
|
-
}
|
|
1870
|
-
},
|
|
1871
|
-
"/api/api-keys/{keyId}": { delete: {
|
|
1872
|
-
tags: ["discovery"],
|
|
1873
|
-
summary: "Revoke an API key",
|
|
1874
|
-
parameters: [{
|
|
1875
|
-
name: "keyId",
|
|
1876
|
-
in: "path",
|
|
1877
|
-
required: !0,
|
|
1878
|
-
schema: {
|
|
1879
|
-
type: "string",
|
|
1880
|
-
format: "uuid"
|
|
1881
|
-
}
|
|
1882
|
-
}],
|
|
1883
|
-
responses: {
|
|
1884
|
-
204: { description: "Key revoked" },
|
|
1885
|
-
404: { $ref: "#/components/responses/NotFound" }
|
|
1886
|
-
}
|
|
1887
|
-
} },
|
|
1888
|
-
"/api/jobs": { get: {
|
|
1889
|
-
tags: ["jobs"],
|
|
1890
|
-
summary: "List jobs",
|
|
1891
|
-
security: [],
|
|
1892
|
-
parameters: [
|
|
1893
|
-
{ $ref: "#/components/parameters/AfterId" },
|
|
1894
|
-
{ $ref: "#/components/parameters/BeforeId" },
|
|
1895
|
-
{ $ref: "#/components/parameters/Limit" },
|
|
1896
|
-
{ $ref: "#/components/parameters/Filter" },
|
|
1897
|
-
{ $ref: "#/components/parameters/WorkflowFilter" }
|
|
1898
|
-
],
|
|
1899
|
-
responses: { 200: {
|
|
1900
|
-
description: "Dashboard job list",
|
|
1901
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/JobList" } } }
|
|
1902
|
-
} }
|
|
1903
|
-
} },
|
|
1904
|
-
"/api/dispatch/{trigger}": { post: {
|
|
1905
|
-
tags: ["jobs"],
|
|
1906
|
-
summary: "Dispatch a workflow trigger with manual inputs",
|
|
1907
|
-
security: [{ bearerAuth: [] }, { basicAuth: [] }],
|
|
1908
|
-
parameters: [{
|
|
1909
|
-
name: "trigger",
|
|
1910
|
-
in: "path",
|
|
1911
|
-
required: !0,
|
|
1912
|
-
schema: { type: "string" }
|
|
1913
|
-
}],
|
|
1914
|
-
requestBody: {
|
|
1915
|
-
required: !0,
|
|
1916
|
-
content: { "application/json": { schema: { type: "object" } } }
|
|
1917
|
-
},
|
|
1918
|
-
responses: {
|
|
1919
|
-
202: {
|
|
1920
|
-
description: "Jobs queued",
|
|
1921
|
-
content: { "application/json": { schema: { type: "object" } } }
|
|
1922
|
-
},
|
|
1923
|
-
401: { $ref: "#/components/responses/Unauthorized" }
|
|
1924
|
-
}
|
|
1925
|
-
} },
|
|
1926
|
-
"/api/jobs/{jobId}/wait": { get: {
|
|
1927
|
-
tags: ["jobs"],
|
|
1928
|
-
summary: "Wait for a job to reach a terminal state",
|
|
1929
|
-
parameters: [{ $ref: "#/components/parameters/JobId" }, {
|
|
1930
|
-
name: "timeout",
|
|
1931
|
-
in: "query",
|
|
1932
|
-
schema: {
|
|
1933
|
-
type: "integer",
|
|
1934
|
-
minimum: 0,
|
|
1935
|
-
maximum: 12e4,
|
|
1936
|
-
default: 3e4
|
|
1937
|
-
}
|
|
1938
|
-
}],
|
|
1939
|
-
responses: {
|
|
1940
|
-
200: {
|
|
1941
|
-
description: "Latest job state",
|
|
1942
|
-
content: { "application/json": { schema: { type: "object" } } }
|
|
1943
|
-
},
|
|
1944
|
-
404: { $ref: "#/components/responses/NotFound" }
|
|
1945
|
-
}
|
|
1946
|
-
} },
|
|
1947
|
-
"/api/jobs/{jobId}/cancel": { post: {
|
|
1948
|
-
tags: ["jobs"],
|
|
1949
|
-
summary: "Cancel an active job",
|
|
1950
|
-
parameters: [{ $ref: "#/components/parameters/JobId" }],
|
|
1951
|
-
responses: {
|
|
1952
|
-
200: {
|
|
1953
|
-
description: "Job cancelled",
|
|
1954
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/JobStatus" } } }
|
|
1955
|
-
},
|
|
1956
|
-
404: { $ref: "#/components/responses/NotFound" },
|
|
1957
|
-
409: { $ref: "#/components/responses/Conflict" }
|
|
1958
|
-
}
|
|
1959
|
-
} },
|
|
1960
|
-
"/api/jobs/{jobId}/secrets": { get: {
|
|
1961
|
-
tags: ["jobs"],
|
|
1962
|
-
summary: "Get secrets for an assigned worker job",
|
|
1963
|
-
security: [{ workerBearerAuth: [] }],
|
|
1964
|
-
parameters: [{ $ref: "#/components/parameters/JobId" }, { $ref: "#/components/parameters/WorkerId" }],
|
|
1965
|
-
responses: {
|
|
1966
|
-
200: {
|
|
1967
|
-
description: "Decrypted job secrets",
|
|
1968
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/Secrets" } } }
|
|
1969
|
-
},
|
|
1970
|
-
403: { $ref: "#/components/responses/Forbidden" }
|
|
1971
|
-
}
|
|
1972
|
-
} },
|
|
1973
|
-
"/api/runs/{jobId}": { get: {
|
|
1974
|
-
tags: ["jobs"],
|
|
1975
|
-
summary: "Get a run view",
|
|
1976
|
-
parameters: [{ $ref: "#/components/parameters/JobId" }],
|
|
1977
|
-
responses: {
|
|
1978
|
-
200: {
|
|
1979
|
-
description: "Run details",
|
|
1980
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/RunView" } } }
|
|
1981
|
-
},
|
|
1982
|
-
404: { $ref: "#/components/responses/NotFound" }
|
|
1983
|
-
}
|
|
1984
|
-
} },
|
|
1985
|
-
"/api/runs/{jobId}/artifacts/{path}": { get: {
|
|
1986
|
-
tags: ["jobs"],
|
|
1987
|
-
summary: "Download an artifact",
|
|
1988
|
-
parameters: [{ $ref: "#/components/parameters/JobId" }, {
|
|
1989
|
-
name: "path",
|
|
1990
|
-
in: "path",
|
|
1991
|
-
required: !0,
|
|
1992
|
-
schema: { type: "string" }
|
|
1993
|
-
}],
|
|
1994
|
-
responses: {
|
|
1995
|
-
200: {
|
|
1996
|
-
description: "Artifact bytes",
|
|
1997
|
-
content: { "application/octet-stream": { schema: {
|
|
1998
|
-
type: "string",
|
|
1999
|
-
format: "binary"
|
|
2000
|
-
} } }
|
|
2001
|
-
},
|
|
2002
|
-
404: { $ref: "#/components/responses/NotFound" }
|
|
2003
|
-
}
|
|
2004
|
-
} },
|
|
2005
|
-
"/api/runs/{jobId}/ai-help": { post: {
|
|
2006
|
-
tags: ["jobs"],
|
|
2007
|
-
summary: "Ask AI to diagnose a failed step",
|
|
2008
|
-
parameters: [{ $ref: "#/components/parameters/JobId" }],
|
|
2009
|
-
requestBody: {
|
|
2010
|
-
required: !0,
|
|
2011
|
-
content: { "application/json": { schema: {
|
|
2012
|
-
type: "object",
|
|
2013
|
-
required: ["stepId"],
|
|
2014
|
-
properties: { stepId: { type: "string" } }
|
|
2015
|
-
} } }
|
|
2016
|
-
},
|
|
2017
|
-
responses: {
|
|
2018
|
-
200: {
|
|
2019
|
-
description: "AI diagnosis",
|
|
2020
|
-
content: { "application/json": { schema: {
|
|
2021
|
-
type: "object",
|
|
2022
|
-
properties: { answer: { type: "string" } }
|
|
2023
|
-
} } }
|
|
2024
|
-
},
|
|
2025
|
-
401: { $ref: "#/components/responses/Unauthorized" },
|
|
2026
|
-
502: { description: "AI provider failure" },
|
|
2027
|
-
503: { description: "AI help is not configured" }
|
|
2028
|
-
}
|
|
2029
|
-
} },
|
|
2030
|
-
"/api/workflows/validate": { post: {
|
|
2031
|
-
tags: ["workflows"],
|
|
2032
|
-
summary: "Validate workflow YAML",
|
|
2033
|
-
requestBody: {
|
|
2034
|
-
required: !0,
|
|
2035
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/WorkflowSource" } } }
|
|
2036
|
-
},
|
|
2037
|
-
responses: {
|
|
2038
|
-
200: {
|
|
2039
|
-
description: "Valid workflow definitions",
|
|
2040
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationResult" } } }
|
|
2041
|
-
},
|
|
2042
|
-
422: { $ref: "#/components/responses/ValidationError" }
|
|
2043
|
-
}
|
|
2044
|
-
} },
|
|
2045
|
-
"/api/workflows": { get: {
|
|
2046
|
-
tags: ["workflows"],
|
|
2047
|
-
summary: "List workflows",
|
|
2048
|
-
responses: { 200: {
|
|
2049
|
-
description: "Workflow list",
|
|
2050
|
-
content: { "application/json": { schema: {
|
|
2051
|
-
type: "object",
|
|
2052
|
-
properties: { workflows: {
|
|
2053
|
-
type: "array",
|
|
2054
|
-
items: { $ref: "#/components/schemas/Workflow" }
|
|
2055
|
-
} }
|
|
2056
|
-
} } }
|
|
2057
|
-
} }
|
|
2058
|
-
} },
|
|
2059
|
-
"/api/workflows/{workflowId}": {
|
|
2060
|
-
get: {
|
|
2061
|
-
tags: ["workflows"],
|
|
2062
|
-
summary: "Get a workflow or historical revision",
|
|
2063
|
-
parameters: [{ $ref: "#/components/parameters/WorkflowId" }, { $ref: "#/components/parameters/Revision" }],
|
|
2064
|
-
responses: {
|
|
2065
|
-
200: {
|
|
2066
|
-
description: "Workflow definition",
|
|
2067
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/Workflow" } } }
|
|
2068
|
-
},
|
|
2069
|
-
404: { $ref: "#/components/responses/NotFound" }
|
|
2070
|
-
}
|
|
2071
|
-
},
|
|
2072
|
-
put: {
|
|
2073
|
-
tags: ["workflows"],
|
|
2074
|
-
summary: "Save a workflow draft",
|
|
2075
|
-
parameters: [{ $ref: "#/components/parameters/WorkflowId" }],
|
|
2076
|
-
requestBody: {
|
|
2077
|
-
required: !0,
|
|
2078
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/WorkflowSave" } } }
|
|
2079
|
-
},
|
|
2080
|
-
responses: {
|
|
2081
|
-
200: {
|
|
2082
|
-
description: "Draft saved",
|
|
2083
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/Workflow" } } }
|
|
2084
|
-
},
|
|
2085
|
-
422: { $ref: "#/components/responses/ValidationError" }
|
|
2086
|
-
}
|
|
2087
|
-
},
|
|
2088
|
-
delete: {
|
|
2089
|
-
tags: ["workflows"],
|
|
2090
|
-
summary: "Delete a workflow and its revisions",
|
|
2091
|
-
parameters: [{ $ref: "#/components/parameters/WorkflowId" }],
|
|
2092
|
-
responses: {
|
|
2093
|
-
204: { description: "Workflow deleted" },
|
|
2094
|
-
404: { $ref: "#/components/responses/NotFound" }
|
|
2095
|
-
}
|
|
2096
|
-
}
|
|
2097
|
-
},
|
|
2098
|
-
"/api/workflows/{workflowId}/publish": { post: {
|
|
2099
|
-
tags: ["workflows"],
|
|
2100
|
-
summary: "Publish the latest workflow revision",
|
|
2101
|
-
parameters: [{ $ref: "#/components/parameters/WorkflowId" }],
|
|
2102
|
-
responses: {
|
|
2103
|
-
200: {
|
|
2104
|
-
description: "Workflow published",
|
|
2105
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/Workflow" } } }
|
|
2106
|
-
},
|
|
2107
|
-
404: { $ref: "#/components/responses/NotFound" }
|
|
2108
|
-
}
|
|
2109
|
-
} },
|
|
2110
|
-
"/api/secrets": { get: {
|
|
2111
|
-
tags: ["secrets"],
|
|
2112
|
-
summary: "List secret names",
|
|
2113
|
-
responses: { 200: {
|
|
2114
|
-
description: "Secret names",
|
|
2115
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/SecretNames" } } }
|
|
2116
|
-
} }
|
|
2117
|
-
} },
|
|
2118
|
-
"/api/secrets/{name}": {
|
|
2119
|
-
put: {
|
|
2120
|
-
tags: ["secrets"],
|
|
2121
|
-
summary: "Create or replace an encrypted secret",
|
|
2122
|
-
parameters: [{ $ref: "#/components/parameters/SecretName" }],
|
|
2123
|
-
requestBody: {
|
|
2124
|
-
required: !0,
|
|
2125
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/SecretValue" } } }
|
|
2126
|
-
},
|
|
2127
|
-
responses: { 204: { description: "Secret saved" } }
|
|
2128
|
-
},
|
|
2129
|
-
delete: {
|
|
2130
|
-
tags: ["secrets"],
|
|
2131
|
-
summary: "Delete a secret",
|
|
2132
|
-
parameters: [{ $ref: "#/components/parameters/SecretName" }],
|
|
2133
|
-
responses: {
|
|
2134
|
-
204: { description: "Secret deleted" },
|
|
2135
|
-
404: { $ref: "#/components/responses/NotFound" }
|
|
2136
|
-
}
|
|
2137
|
-
}
|
|
2138
|
-
},
|
|
2139
|
-
"/api/events": {
|
|
2140
|
-
get: {
|
|
2141
|
-
tags: ["events"],
|
|
2142
|
-
summary: "Subscribe to runner events",
|
|
2143
|
-
security: [],
|
|
2144
|
-
responses: { 200: {
|
|
2145
|
-
description: "Server-sent event stream",
|
|
2146
|
-
content: { "text/event-stream": { schema: { type: "string" } } }
|
|
2147
|
-
} }
|
|
2148
|
-
},
|
|
2149
|
-
post: {
|
|
2150
|
-
tags: ["events"],
|
|
2151
|
-
summary: "Publish a worker job-change event",
|
|
2152
|
-
security: [{ workerBearerAuth: [] }],
|
|
2153
|
-
requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/JobEvent" } } } },
|
|
2154
|
-
responses: { 202: { description: "Event accepted" } }
|
|
2155
|
-
}
|
|
2156
|
-
},
|
|
2157
|
-
"/api/push/public-key": { get: {
|
|
2158
|
-
tags: ["notifications"],
|
|
2159
|
-
summary: "Get the Web Push public key",
|
|
2160
|
-
security: [],
|
|
2161
|
-
responses: { 200: {
|
|
2162
|
-
description: "VAPID public key",
|
|
2163
|
-
content: { "application/json": { schema: {
|
|
2164
|
-
type: "object",
|
|
2165
|
-
properties: { publicKey: { type: "string" } }
|
|
2166
|
-
} } }
|
|
2167
|
-
} }
|
|
2168
|
-
} },
|
|
2169
|
-
"/api/push/subscriptions": {
|
|
2170
|
-
post: {
|
|
2171
|
-
tags: ["notifications"],
|
|
2172
|
-
summary: "Register a Web Push subscription",
|
|
2173
|
-
security: [],
|
|
2174
|
-
requestBody: {
|
|
2175
|
-
required: !0,
|
|
2176
|
-
content: { "application/json": { schema: { type: "object" } } }
|
|
2177
|
-
},
|
|
2178
|
-
responses: { 201: { description: "Subscription saved" } }
|
|
2179
|
-
},
|
|
2180
|
-
delete: {
|
|
2181
|
-
tags: ["notifications"],
|
|
2182
|
-
summary: "Remove a Web Push subscription",
|
|
2183
|
-
security: [],
|
|
2184
|
-
requestBody: {
|
|
2185
|
-
required: !0,
|
|
2186
|
-
content: { "application/json": { schema: {
|
|
2187
|
-
type: "object",
|
|
2188
|
-
required: ["endpoint"],
|
|
2189
|
-
properties: { endpoint: {
|
|
2190
|
-
type: "string",
|
|
2191
|
-
format: "uri"
|
|
2192
|
-
} }
|
|
2193
|
-
} } }
|
|
2194
|
-
},
|
|
2195
|
-
responses: { 204: { description: "Subscription removed" } }
|
|
2196
|
-
}
|
|
2197
|
-
},
|
|
2198
|
-
"/webhooks/{provider}": { post: {
|
|
2199
|
-
tags: ["webhooks"],
|
|
2200
|
-
summary: "Receive a provider webhook",
|
|
2201
|
-
security: [],
|
|
2202
|
-
parameters: [{
|
|
2203
|
-
name: "provider",
|
|
2204
|
-
in: "path",
|
|
2205
|
-
required: !0,
|
|
2206
|
-
schema: { type: "string" }
|
|
2207
|
-
}],
|
|
2208
|
-
requestBody: {
|
|
2209
|
-
required: !0,
|
|
2210
|
-
content: { "application/json": { schema: { type: "object" } } }
|
|
2211
|
-
},
|
|
2212
|
-
responses: {
|
|
2213
|
-
202: { description: "Webhook accepted" },
|
|
2214
|
-
401: { $ref: "#/components/responses/Unauthorized" },
|
|
2215
|
-
413: { $ref: "#/components/responses/PayloadTooLarge" }
|
|
2216
|
-
}
|
|
2217
|
-
} },
|
|
2218
|
-
"/restart/{jobId}": { post: {
|
|
2219
|
-
tags: ["jobs"],
|
|
2220
|
-
summary: "Create a new run from the active workflow revision",
|
|
2221
|
-
parameters: [{ $ref: "#/components/parameters/JobId" }],
|
|
2222
|
-
responses: {
|
|
2223
|
-
201: {
|
|
2224
|
-
description: "New job created",
|
|
2225
|
-
content: { "application/json": { schema: {
|
|
2226
|
-
type: "object",
|
|
2227
|
-
properties: { id: { type: "integer" } }
|
|
2228
|
-
} } }
|
|
2229
|
-
},
|
|
2230
|
-
404: { $ref: "#/components/responses/NotFound" }
|
|
2231
|
-
}
|
|
2232
|
-
} },
|
|
2233
|
-
"/admin/reload-secrets": { post: {
|
|
2234
|
-
tags: ["secrets"],
|
|
2235
|
-
summary: "Reload legacy in-memory secrets",
|
|
2236
|
-
responses: {
|
|
2237
|
-
200: { description: "Secrets reloaded" },
|
|
2238
|
-
403: { $ref: "#/components/responses/Forbidden" }
|
|
2239
|
-
}
|
|
2240
|
-
} }
|
|
2241
|
-
},
|
|
1839
|
+
paths: /* @__PURE__ */ JSON.parse("{\"/api\":{\"get\":{\"tags\":[\"discovery\"],\"summary\":\"Get this OpenAPI specification\",\"security\":[],\"responses\":{\"200\":{\"description\":\"OpenAPI JSON document\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}}}}},\"/api/api-keys\":{\"get\":{\"tags\":[\"discovery\"],\"summary\":\"List active API keys\",\"responses\":{\"200\":{\"description\":\"API key metadata\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ApiKeyList\"}}}}}},\"post\":{\"tags\":[\"discovery\"],\"summary\":\"Issue a scoped API key\",\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ApiKeyIssue\"}}}},\"responses\":{\"201\":{\"description\":\"Key metadata and one-time token\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ApiKeyCreated\"}}}}}}},\"/api/api-keys/{keyId}\":{\"delete\":{\"tags\":[\"discovery\"],\"summary\":\"Revoke an API key\",\"parameters\":[{\"name\":\"keyId\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\",\"format\":\"uuid\"}}],\"responses\":{\"204\":{\"description\":\"Key revoked\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/jobs\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"List jobs\",\"security\":[],\"parameters\":[{\"$ref\":\"#/components/parameters/AfterId\"},{\"$ref\":\"#/components/parameters/BeforeId\"},{\"$ref\":\"#/components/parameters/Limit\"},{\"$ref\":\"#/components/parameters/Filter\"},{\"$ref\":\"#/components/parameters/WorkflowFilter\"}],\"responses\":{\"200\":{\"description\":\"Dashboard job list\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/JobList\"}}}}}}},\"/api/dispatch/{trigger}\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Dispatch a workflow trigger with manual inputs\",\"security\":[{\"bearerAuth\":[]},{\"basicAuth\":[]}],\"parameters\":[{\"name\":\"trigger\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"202\":{\"description\":\"Jobs queued\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"}}}},\"/api/workflows/{workflowId}/run\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Run the latest saved workflow revision immediately\",\"security\":[{\"bearerAuth\":[]},{\"basicAuth\":[]}],\"parameters\":[{\"name\":\"workflowId\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"202\":{\"description\":\"Jobs queued\"},\"400\":{\"$ref\":\"#/components/responses/ValidationError\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"}}}},\"/api/jobs/{jobId}/wait\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Wait for a job to reach a terminal state\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"},{\"name\":\"timeout\",\"in\":\"query\",\"schema\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":120000,\"default\":30000}}],\"responses\":{\"200\":{\"description\":\"Latest job state\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/jobs/{jobId}/cancel\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Cancel an active job\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"responses\":{\"200\":{\"description\":\"Job cancelled\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/JobStatus\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"},\"409\":{\"$ref\":\"#/components/responses/Conflict\"}}}},\"/api/jobs/{jobId}/secrets\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Get secrets for an assigned worker job\",\"security\":[{\"workerBearerAuth\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"},{\"$ref\":\"#/components/parameters/WorkerId\"}],\"responses\":{\"200\":{\"description\":\"Decrypted job secrets\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Secrets\"}}}},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"}}}},\"/api/runs/{jobId}\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Get a run view\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"responses\":{\"200\":{\"description\":\"Run details\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/RunView\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/runs/{jobId}/artifacts/{path}\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Download an artifact\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"},{\"name\":\"path\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"responses\":{\"200\":{\"description\":\"Artifact bytes\",\"content\":{\"application/octet-stream\":{\"schema\":{\"type\":\"string\",\"format\":\"binary\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/runs/{jobId}/ai-help\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Ask AI to diagnose a failed step\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"stepId\"],\"properties\":{\"stepId\":{\"type\":\"string\"}}}}}},\"responses\":{\"200\":{\"description\":\"AI diagnosis\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"}}}}}},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"502\":{\"description\":\"AI provider failure\"},\"503\":{\"description\":\"AI help is not configured\"}}}},\"/api/workflows/validate\":{\"post\":{\"tags\":[\"workflows\"],\"summary\":\"Validate workflow YAML\",\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/WorkflowSource\"}}}},\"responses\":{\"200\":{\"description\":\"Valid workflow definitions\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ValidationResult\"}}}},\"422\":{\"$ref\":\"#/components/responses/ValidationError\"}}}},\"/api/workflows\":{\"get\":{\"tags\":[\"workflows\"],\"summary\":\"List workflows\",\"responses\":{\"200\":{\"description\":\"Workflow list\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"workflows\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/Workflow\"}}}}}}}}}},\"/api/workflows/{workflowId}\":{\"get\":{\"tags\":[\"workflows\"],\"summary\":\"Get a workflow or historical revision\",\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"},{\"$ref\":\"#/components/parameters/Revision\"}],\"responses\":{\"200\":{\"description\":\"Workflow definition\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Workflow\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}},\"put\":{\"tags\":[\"workflows\"],\"summary\":\"Save a workflow draft\",\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/WorkflowSave\"}}}},\"responses\":{\"200\":{\"description\":\"Draft saved\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Workflow\"}}}},\"422\":{\"$ref\":\"#/components/responses/ValidationError\"}}},\"delete\":{\"tags\":[\"workflows\"],\"summary\":\"Delete a workflow and its revisions\",\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"}],\"responses\":{\"204\":{\"description\":\"Workflow deleted\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/workflows/{workflowId}/publish\":{\"post\":{\"tags\":[\"workflows\"],\"summary\":\"Publish the latest workflow revision\",\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"}],\"responses\":{\"200\":{\"description\":\"Workflow published\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Workflow\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/secrets\":{\"get\":{\"tags\":[\"secrets\"],\"summary\":\"List secret names\",\"responses\":{\"200\":{\"description\":\"Secret names\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/SecretNames\"}}}}}}},\"/api/secrets/{name}\":{\"put\":{\"tags\":[\"secrets\"],\"summary\":\"Create or replace an encrypted secret\",\"parameters\":[{\"$ref\":\"#/components/parameters/SecretName\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/SecretValue\"}}}},\"responses\":{\"204\":{\"description\":\"Secret saved\"}}},\"delete\":{\"tags\":[\"secrets\"],\"summary\":\"Delete a secret\",\"parameters\":[{\"$ref\":\"#/components/parameters/SecretName\"}],\"responses\":{\"204\":{\"description\":\"Secret deleted\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/events\":{\"get\":{\"tags\":[\"events\"],\"summary\":\"Subscribe to runner events\",\"security\":[],\"responses\":{\"200\":{\"description\":\"Server-sent event stream\",\"content\":{\"text/event-stream\":{\"schema\":{\"type\":\"string\"}}}}}},\"post\":{\"tags\":[\"events\"],\"summary\":\"Publish a worker job-change event\",\"security\":[{\"workerBearerAuth\":[]}],\"requestBody\":{\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/JobEvent\"}}}},\"responses\":{\"202\":{\"description\":\"Event accepted\"}}}},\"/api/push/public-key\":{\"get\":{\"tags\":[\"notifications\"],\"summary\":\"Get the Web Push public key\",\"security\":[],\"responses\":{\"200\":{\"description\":\"VAPID public key\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"publicKey\":{\"type\":\"string\"}}}}}}}}},\"/api/push/subscriptions\":{\"post\":{\"tags\":[\"notifications\"],\"summary\":\"Register a Web Push subscription\",\"security\":[],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"201\":{\"description\":\"Subscription saved\"}}},\"delete\":{\"tags\":[\"notifications\"],\"summary\":\"Remove a Web Push subscription\",\"security\":[],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"endpoint\"],\"properties\":{\"endpoint\":{\"type\":\"string\",\"format\":\"uri\"}}}}}},\"responses\":{\"204\":{\"description\":\"Subscription removed\"}}}},\"/webhooks/{provider}\":{\"post\":{\"tags\":[\"webhooks\"],\"summary\":\"Receive a provider webhook\",\"security\":[],\"parameters\":[{\"name\":\"provider\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"202\":{\"description\":\"Webhook accepted\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"413\":{\"$ref\":\"#/components/responses/PayloadTooLarge\"}}}},\"/restart/{jobId}\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Create a new run from the active workflow revision\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"responses\":{\"201\":{\"description\":\"New job created\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"integer\"}}}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/admin/reload-secrets\":{\"post\":{\"tags\":[\"secrets\"],\"summary\":\"Reload legacy in-memory secrets\",\"responses\":{\"200\":{\"description\":\"Secrets reloaded\"},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"}}}}}"),
|
|
2242
1840
|
components: {
|
|
2243
1841
|
securitySchemes: {
|
|
2244
1842
|
bearerAuth: {
|
|
@@ -12484,7 +12082,7 @@ async function Vh(e, t, n, r) {
|
|
|
12484
12082
|
}
|
|
12485
12083
|
//#endregion
|
|
12486
12084
|
//#region src/app-header.html?raw
|
|
12487
|
-
var Hh = "<template component=\"app-header\">\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <header class=\"mx-auto flex w-full max-w-[78rem] items-center justify-between gap-4 border-b border-gray-800 pb-5\">\n <div class=\"flex min-w-0 items-start gap-3\">\n <template if=\"back\">\n <a class=\"mt-0.5 inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-800 text-gray-400 no-underline outline-none hover:border-indigo-500 hover:text-indigo-200 focus-visible:ring-2 focus-visible:ring-indigo-400/60\" attr-href=\"back\" attr-aria-label=\"backLabel || 'Back'\" attr-title=\"backLabel || 'Back'\">\n <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" width=\"17\" height=\"17\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m15 18-6-6 6-6\"></path></svg>\n </a>\n </template>\n <div>\n <div class=\"text-[.7rem] font-bold uppercase tracking-[.16em] text-indigo-400\">Runner engine</div>\n <h1 class=\"mt-1 text-2xl font-bold tracking-tight text-white\">{{ title }}</h1>\n <template if=\"subtitle\"><p class=\"mt-1 text-xs text-gray-400\">{{ subtitle }}</p></template>\n </div>\n </div>\n <div class=\"flex items-center gap-2\">\n <template if=\"authConfigured && !authenticated\">\n <a class=\"text-xs text-indigo-300 hover:text-white\" href=\"/auth/login\">Sign in</a>\n </template>\n <template if=\"authenticated\">\n <span class=\"hidden text-xs text-gray-400 sm:inline\">{{ userName }}</span>\n <a class=\"text-xs text-indigo-300 hover:text-white\" href=\"/auth/logout\">Sign out</a>\n </template>\n <a class=\"inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-700 text-indigo-200 no-underline outline-none hover:border-indigo-500 hover:bg-gray-900 hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\" href=\"/help\" aria-label=\"Help\" title=\"Help\">\n <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"></circle><path d=\"M9.6 9a2.5 2.5 0 1 1 4.2 1.8c-1.2 1-1.8 1.4-1.8 2.7\"></path><path d=\"M12 17h.01\"></path></svg>\n </a>\n <a class=\"inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-700 text-indigo-200 no-underline outline-none hover:border-indigo-500 hover:bg-gray-900 hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\" href=\"/settings\" aria-label=\"Settings\" title=\"Settings\">\n <lucide-icon icon=\"settings\" size=\"16\"></lucide-icon>\n </a>\n </div>\n </header>\n <script setup src=\"./app-header.mjs\"><\/script>\n</template>\n", Uh = "<!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\" />\n <meta name=\"theme-color\" content=\"#030712\" />\n <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n <link rel=\"icon\" href=\"/app-icon.svg\" type=\"image/svg+xml\" />\n <script type=\"importmap\">\n { \"imports\": { \"@app/\": \"/\", \"@li3/\": \"https://cdn.li3.dev/@li3/\", \"ansi_up\": \"https://esm.sh/ansi_up@6.0.6\", \"diff\": \"https://unpkg.com/diff@9.0.0/libesm/index.js\", \"marked\": \"https://cdn.jsdelivr.net/npm/marked@18.0.12/lib/marked.esm.js\" } }\n <\/script>\n <link rel=\"component\" href=\"/app-router.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-editor.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-block.html\" />\n <title>On Workflow Runner</title>\n </head>\n <body class=\"min-h-screen bg-gray-950 text-gray-100 font-sans\">\n <template app>\n <app-router></app-router>\n </template>\n <script type=\"module\">import '@li3/web';<\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n", Wh = "<template component=\"app-router\">\n <div ref=\"outlet\" aria-live=\"polite\"></div>\n <script setup src=\"./app-router.mjs\"><\/script>\n</template>\n", Gh = "<!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 <meta name=\"theme-color\" content=\"#030712\" />\n <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n <link rel=\"icon\" href=\"/app-icon.svg\" type=\"image/svg+xml\" />\n <title>Runner Workflow Management</title>\n <script type=\"importmap\">\n { \"imports\": { \"@app/\": \"/\", \"@li3/\": \"https://cdn.li3.dev/@li3/\", \"diff\": \"https://unpkg.com/diff@9.0.0/libesm/index.js\" } }\n <\/script>\n <style>\n body[data-page='list'] .editor-panel {\n display: none;\n }\n body[data-page='workflows'] .editor-panel,\n body[data-page='workflows'] .secrets-panel,\n body[data-page='secrets'] .workflow-list,\n body[data-page='secrets'] .editor-panel {\n display: none;\n }\n body[data-page='editor'] .workflow-list,\n body[data-page='editor'] .secrets-panel {\n display: none;\n }\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/code-block.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n\n <template app>\n <main class=\"mx-auto max-w-6xl space-y-5 py-6\">\n <template if=\"page === 'secrets'\"><app-header title=\"Secrets\" subtitle=\"Write-only credentials available to workflows\" back=\"/runs\" back-label=\"Back to dashboard\"></app-header></template>\n <template if=\"page !== 'secrets'\"><app-header title=\"Workflow control room\" subtitle=\"Draft, validate, and publish revisioned YAML definitions\" back=\"/runs\" back-label=\"Back to dashboard\"></app-header></template>\n <nav class=\"flex flex-wrap gap-1 rounded-xl border border-gray-800 bg-gray-900/70 p-1\" aria-label=\"Settings navigation\">\n <a href=\"/settings/workflows\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\" bind-class=\"page === 'workflows' || page === 'editor' ? 'bg-gray-800.text-white' : ''\">Workflows</a>\n <a href=\"/settings/secrets\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\" bind-class=\"page === 'secrets' ? 'bg-gray-800.text-white' : ''\">Secrets</a>\n <a href=\"/settings/tokens\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">API tokens</a>\n <a href=\"/settings/notifications\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Notifications</a>\n </nav>\n\n <template if=\"notice\">\n <div\n role=\"status\"\n class=\"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm\"\n 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 >\n <lucide-icon bind-icon=\"noticeError ? 'circle-alert' : 'circle-check'\" size=\"17\"></lucide-icon>\n <span>{{ notice }}</span>\n </div>\n </template>\n\n <section id=\"workflow-layout\" class=\"space-y-5\">\n <nav\n class=\"workflow-list rounded-2xl border border-gray-800 bg-gray-900/80 p-4 shadow-xl shadow-black/10\"\n aria-labelledby=\"workflow-list-title\"\n >\n <div class=\"mb-4 flex items-end justify-between gap-3 border-b border-gray-800 pb-4\">\n <div>\n <p class=\"text-[11px] font-semibold uppercase tracking-[0.18em] text-indigo-400\">Definitions</p>\n <h2 id=\"workflow-list-title\" class=\"mt-1 text-lg font-semibold text-white\">Workflows</h2>\n </div>\n <a\n href=\"/workflows/new\"\n class=\"inline-flex items-center gap-2 rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white outline-none transition hover:bg-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >\n <lucide-icon icon=\"plus\" size=\"16\"></lucide-icon>\n New workflow\n </a>\n </div>\n <ul class=\"grid gap-2\" aria-label=\"Workflow definitions\">\n <template for=\"workflow of workflows\">\n <li>\n <a\n attr-href=\"'/workflows/' + workflow.id\"\n class=\"group flex min-h-16 items-center gap-3 rounded-xl border border-gray-800 bg-gray-950/50 px-3.5 py-3 text-left text-sm text-gray-300 outline-none transition hover:border-indigo-500/50 hover:bg-indigo-500/10 focus-visible:border-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/50\"\n >\n <span\n class=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition group-hover:bg-indigo-500/15 group-hover:text-indigo-300\"\n >\n <lucide-icon icon=\"workflow\" size=\"17\"></lucide-icon>\n </span>\n <span class=\"min-w-0 flex-1\">\n <span class=\"block truncate text-base font-semibold text-gray-100\">{{ workflow.name }}</span>\n <span class=\"mt-0.5 block truncate font-mono text-[11px] text-gray-500\">{{ workflow.id }}</span>\n </span>\n <span\n class=\"rounded-full border px-2 py-1 text-[10px] font-semibold uppercase tracking-wide\"\n bind-class=\"workflow.status === 'published' ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300' : workflow.status === 'archived' ? 'border-gray-700 bg-gray-800 text-gray-500' : 'border-amber-500/30 bg-amber-500/10 text-amber-300'\"\n >{{ workflow.status }}</span\n >\n <lucide-icon\n icon=\"arrow-up-right\"\n size=\"16\"\n class=\"shrink-0 text-gray-600 transition group-hover:text-indigo-300\"\n ></lucide-icon>\n </a>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-gray-700 px-4 py-8 text-center\"\n class-hidden=\"workflows.length > 0\"\n >\n <lucide-icon icon=\"workflow\" size=\"22\" class=\"mx-auto text-gray-600\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-gray-300\">No workflows yet</p>\n <p class=\"mt-1 text-xs text-gray-500\">Create a definition to start automating jobs.</p>\n </div>\n </nav>\n\n <section\n class=\"editor-panel overflow-hidden rounded-xl border border-gray-800 bg-gray-900/80 shadow-xl shadow-black/10\"\n >\n <div class=\"border-b border-gray-800 px-4 pt-3 sm:px-5\">\n <a\n href=\"/workflows\"\n class=\"inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-gray-400 outline-none transition hover:bg-gray-800 hover:text-gray-100 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >\n <lucide-icon icon=\"arrow-left\" size=\"14\"></lucide-icon>\n Back to workflows\n </a>\n </div>\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\"\n >Workflow ID\n <input\n bind-value=\"workflowId\"\n on-input=\"setWorkflowId($event)\"\n bind-disabled=\"selectedId\"\n aria-describedby=\"workflow-id-help\"\n 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\"\n placeholder=\"derived-from-workflow-name\"\n />\n <span\n id=\"workflow-id-help\"\n class=\"mt-1 block text-[11px] font-normal normal-case tracking-normal text-gray-500\"\n >The ID becomes permanent after the first save.</span\n >\n </label>\n <label class=\"inline-flex items-center gap-2 text-sm text-gray-300\">\n <input\n type=\"checkbox\"\n bind-checked=\"enabled\"\n on-change=\"setEnabled($event)\"\n class=\"h-4 w-4 rounded border-gray-700 bg-gray-950 text-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n />\n Enabled\n </label>\n <div class=\"flex flex-wrap gap-2\" class-hidden=\"readOnly\">\n <button type=\"button\" aria-label=\"Get AI workflow help\" title=\"Get AI workflow help\" on-click=\"openAiHelp()\" class=\"inline-flex items-center gap-2 rounded-lg border border-indigo-500/50 px-3 py-2.5 text-sm font-medium text-indigo-200 outline-none hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <lucide-icon icon=\"sparkles\" size=\"16\"></lucide-icon><span class=\"hidden sm:inline\">AI help</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Save workflow draft\"\n title=\"Save workflow draft\"\n on-click=\"save()\"\n bind-disabled=\"busy\"\n 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 >\n <lucide-icon icon=\"save\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm:inline\">Save draft</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Publish workflow\"\n title=\"Publish workflow\"\n on-click=\"publish()\"\n bind-disabled=\"busy || !selectedId || source !== savedSource || enabled !== savedEnabled\"\n 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 >\n <lucide-icon icon=\"rocket\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm:inline\">Publish</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Delete workflow\"\n title=\"Delete workflow\"\n on-click=\"remove()\"\n bind-disabled=\"busy\"\n 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 >\n <lucide-icon icon=\"trash\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm:inline\">Delete</span>\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 <div class=\"flex items-center gap-2\">\n <button\n type=\"button\"\n aria-label=\"Validate workflow\"\n title=\"Validate workflow\"\n on-click=\"validate(1)\"\n bind-disabled=\"validation.running\"\n bind-class=\"{ 'hidden': readOnly, 'text-emerald-400': validation.valid, 'text-rose-400': !validation.valid }\"\n class=\"disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon\n class=\"inline-block\"\n bind-icon=\"validation.running ? 'loader-circle' : (validation.valid ? 'circle-check' : 'circle-alert')\"\n bind-class=\"validation.running ? 'animate-spin' : ''\"\n size=\"16\"\n ></lucide-icon>\n </button>\n <span class=\"font-medium\" bind-class=\"validation.error ? 'bg-rose-500/10 text-rose-100' : ''\"\n >{{ validation.error || '' }}</span\n >\n </div>\n <template if=\"revision\">\n <button\n type=\"button\"\n on-click=\"openRevisionDialog()\"\n class=\"font-mono text-indigo-300 outline-none hover:underline focus-visible:rounded focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >Revision {{ revision }}</button>\n </template>\n <template if=\"!revision\"><span class=\"font-mono\">Unsaved draft</span></template>\n </div>\n <template if=\"readOnly\">\n <code-block\n language=\"yaml\"\n theme=\"atom-one-dark\"\n class=\"block h-[calc(100vh-23rem)] min-h-[28rem] overflow-auto rounded-lg border border-gray-700 bg-gray-950 p-4\"\n bind-source=\"source\"\n ></code-block>\n </template>\n <template if=\"!readOnly\">\n <code-editor\n id=\"source-yaml\"\n language=\"yaml\"\n theme=\"atom-one-dark\"\n nostatus=\"true\"\n class=\"block h-[calc(100vh-23rem)] min-h-[28rem] overflow-hidden rounded-lg border border-gray-700 bg-gray-950\"\n bind-value=\"source\"\n on-change=\"setSource($event.detail)\"\n ></code-editor>\n </template>\n <details\n class=\"mt-4 overflow-hidden rounded-xl border border-gray-800 bg-gray-950/50\"\n on-toggle=\"loadHelp($event)\"\n >\n <summary\n class=\"flex cursor-pointer list-none items-center gap-2 px-4 py-3 text-sm font-medium text-gray-300 outline-none transition hover:bg-gray-900 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >\n <span class=\"inline-flex items-center gap-2\"\n ><lucide-icon icon=\"circle-question-mark\" size=\"16\"></lucide-icon>Syntax</span\n >\n <span class=\"ml-2 text-xs font-normal text-gray-500\">Reference, tutorials, and examples</span>\n </summary>\n <div\n ref=\"helpContent\"\n class=\"max-h-[38rem] overflow-y-auto border-t border-gray-800 bg-gray-950 px-4 py-5\"\n role=\"region\"\n aria-label=\"Workflow syntax help\"\n aria-live=\"polite\"\n >\n <p class=\"text-sm text-gray-500\">Expand to load the workflow syntax reference.</p>\n </div>\n </details>\n </div>\n </section>\n </section>\n\n <template if=\"aiDialogOpen\">\n <div class=\"fixed inset-0 z-50 flex items-center justify-center bg-gray-950/80 p-4\" role=\"presentation\">\n <div class=\"flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-gray-700 bg-gray-900 shadow-2xl\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"workflow-ai-title\">\n <div class=\"flex items-start justify-between gap-3 border-b border-gray-800 px-5 py-4\"><div><h2 id=\"workflow-ai-title\" class=\"text-lg font-semibold text-white\">AI workflow help</h2><p class=\"mt-1 text-sm text-gray-400\">Describe the change you want. The suggestion will not modify the editor until you accept it.</p></div><button type=\"button\" on-click=\"closeAiHelp()\" aria-label=\"Close AI help\" class=\"rounded-lg p-2 text-gray-400 hover:bg-gray-800\"><lucide-icon icon=\"x\" size=\"17\"></lucide-icon></button></div>\n <div class=\"min-h-0 overflow-auto p-5\">\n <label class=\"sr-only\" for=\"workflow-ai-request\">Requested workflow change</label>\n <textarea id=\"workflow-ai-request\" bind-value=\"aiRequest\" placeholder=\"Example: Add a Docker cache volume to the build step.\" class=\"min-h-24 w-full rounded-xl border border-gray-700 bg-gray-950 p-3 text-sm text-gray-200 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"></textarea>\n <p class=\"mt-2 text-xs text-rose-300\" class-hidden=\"!aiError\">{{ aiError }}</p>\n <template if=\"aiDiffHtml\">\n <div class=\"mt-4\"><p class=\"mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500\">Suggested changes</p><pre class=\"max-h-[36rem] overflow-auto rounded-xl border border-gray-700 bg-gray-950 p-4 font-mono text-xs leading-6\" bind-innerhtml=\"aiDiffHtml\"></pre></div>\n </template>\n </div>\n <div class=\"flex items-center justify-end gap-2 border-t border-gray-800 px-5 py-3\"><button type=\"button\" on-click=\"closeAiHelp()\" class=\"rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Cancel</button><template if=\"aiDiffHtml\"><button type=\"button\" on-click=\"rejectAiSuggestion()\" class=\"rounded-lg border border-rose-500/50 px-3 py-2 text-sm text-rose-300 hover:bg-rose-500/10\">Reject</button><button type=\"button\" on-click=\"acceptAiSuggestion()\" class=\"rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-400\">Accept suggestion</button></template><button type=\"button\" on-click=\"requestAiHelp()\" bind-disabled=\"aiLoading || !aiRequest.trim()\" class=\"rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-400 disabled:opacity-50\">{{ aiLoading ? 'Thinking...' : 'Suggest' }}</button></div>\n </div>\n </div>\n </template>\n\n <template if=\"revisionDialogOpen\">\n <div class=\"fixed inset-0 z-50 flex items-center justify-center bg-gray-950/80 p-4\" role=\"presentation\">\n <div\n class=\"flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-gray-700 bg-gray-900 shadow-2xl\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby=\"revision-dialog-title\"\n >\n <div class=\"flex items-center justify-between border-b border-gray-800 px-4 py-3\">\n <div>\n <h2 id=\"revision-dialog-title\" class=\"font-semibold text-white\">Workflow revision {{ revisionNumber }}</h2>\n <p class=\"text-xs text-gray-500\">Read-only source snapshot</p>\n </div>\n <button type=\"button\" on-click=\"closeRevisionDialog()\" aria-label=\"Close revision dialog\" class=\"rounded-lg p-2 text-gray-400 outline-none hover:bg-gray-800 hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <lucide-icon icon=\"x\" size=\"17\"></lucide-icon>\n </button>\n </div>\n <div class=\"min-h-0 flex-1 overflow-auto p-4\">\n <p class=\"mb-3 text-sm text-rose-300\" class-hidden=\"!revisionError\">{{ revisionError }}</p>\n <pre class=\"min-h-[20rem] overflow-auto rounded-lg border border-gray-700 bg-gray-950 p-4 font-mono text-xs leading-6\" bind-innerhtml=\"revisionDiffHtml\"></pre>\n </div>\n <div class=\"flex items-center justify-between border-t border-gray-800 px-4 py-3\">\n <button type=\"button\" on-click=\"navigateRevision(-1)\" bind-disabled=\"revisionLoading || revisionNumber <= 1\" class=\"inline-flex items-center gap-2 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-200 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-40\">\n <lucide-icon icon=\"chevron-left\" size=\"16\"></lucide-icon>Previous\n </button>\n <button type=\"button\" on-click=\"navigateRevision(1)\" bind-disabled=\"revisionLoading || revisionNumber >= revision\" class=\"inline-flex items-center gap-2 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-200 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-40\">\n Next<lucide-icon icon=\"chevron-right\" size=\"16\"></lucide-icon>\n </button>\n </div>\n </div>\n </div>\n </template>\n\n <section\n class=\"secrets-panel rounded-2xl border border-gray-800 bg-gray-900/80 p-4 shadow-xl shadow-black/10 sm:p-5\"\n aria-labelledby=\"secrets-title\"\n >\n <div class=\"mb-5 flex items-start justify-between gap-3\">\n <div class=\"flex items-start gap-3\">\n <span class=\"rounded-lg bg-indigo-500/10 p-2 text-indigo-300\"\n ><lucide-icon icon=\"key-round\" size=\"18\"></lucide-icon\n ></span>\n <div>\n <h2 id=\"secrets-title\" class=\"text-lg font-semibold text-white\">Secrets</h2>\n <p class=\"mt-0.5 text-sm text-gray-400\">Write-only credentials available to workflows.</p>\n </div>\n </div>\n <span\n class=\"rounded-full border border-gray-700 bg-gray-950 px-2.5 py-1 text-xs font-mono text-gray-400\"\n aria-label=\"Secret count\"\n >{{ secrets.length }}</span\n >\n </div>\n <details ref=\"secretForm\" class=\"mb-5 overflow-hidden rounded-xl border border-gray-800 bg-gray-950/50\">\n <summary class=\"flex cursor-pointer list-none items-center justify-between px-4 py-3 text-sm font-medium text-gray-300 outline-none hover:bg-gray-900 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <span>Add a secret</span>\n <lucide-icon icon=\"plus\" size=\"17\"></lucide-icon>\n </summary>\n <div class=\"space-y-3 border-t border-gray-800 p-4\">\n <p class=\"text-xs text-gray-500\">Saving an existing name replaces its encrypted value.</p>\n <label class=\"sr-only\" for=\"secret-name\">Secret name</label>\n <input\n id=\"secret-name\"\n bind-value=\"secretName\"\n on-input=\"setSecretName($event)\"\n class=\"rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 font-mono text-sm uppercase outline-none transition placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n placeholder=\"NPM_TOKEN\"\n />\n <label class=\"inline-flex items-center gap-2 text-sm text-gray-300\">\n <input type=\"checkbox\" bind-checked=\"fileMode\" on-change=\"setFileMode($event)\" class=\"h-4 w-4 rounded border-gray-700 bg-gray-900 text-indigo-500 focus:ring-2 focus:ring-indigo-500/30\" />\n Binary file secret\n </label>\n <template if=\"!fileMode\">\n <label class=\"sr-only\" for=\"secret-value\">Text secret value</label>\n <input\n id=\"secret-value\"\n ref=\"secretValueInput\"\n bind-value=\"secretValue\"\n on-input=\"setSecretValue($event)\"\n type=\"password\"\n class=\"rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 font-mono text-sm outline-none transition placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n placeholder=\"New secret value\"\n />\n </template>\n <template if=\"fileMode\">\n <label class=\"sr-only\" for=\"secret-file\">Binary secret file</label>\n <input\n id=\"secret-file\"\n ref=\"secretFileInput\"\n on-change=\"setSecretFile($event)\"\n type=\"file\"\n class=\"rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-xs text-gray-400 file:mr-3 file:rounded file:border-0 file:bg-gray-700 file:px-2 file:py-1 file:text-xs file:text-gray-200 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30\"\n />\n </template>\n <div>\n <button\n type=\"button\"\n aria-label=\"Save secret value\"\n title=\"Save secret value\"\n on-click=\"saveSecret()\"\n bind-disabled=\"busy\"\n 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 outline-none transition hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-50\"\n >\n <lucide-icon icon=\"lock-keyhole\" size=\"16\"></lucide-icon>Save secret\n </button>\n </div>\n </div>\n </div>\n </details>\n <ul class=\"grid gap-2\" aria-label=\"Saved secrets\">\n <template for=\"name of secrets\">\n <li>\n <div class=\"flex min-h-14 items-center gap-2 rounded-xl border border-gray-800 bg-gray-950/50 px-3.5 py-3\">\n <button\n type=\"button\"\n on-click=\"selectSecret(name)\"\n class=\"group flex min-w-0 flex-1 items-center gap-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-indigo-400/50\"\n aria-label=\"Edit secret {{ name }}\"\n >\n <span\n class=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-800 text-gray-500 group-hover:text-indigo-300\"\n ><lucide-icon icon=\"key-round\" size=\"15\"></lucide-icon\n ></span>\n <span class=\"min-w-0 flex-1 truncate font-mono text-sm text-gray-200\">{{ name }}</span>\n <span class=\"text-xs text-gray-500\">Select</span>\n <lucide-icon icon=\"chevron-right\" size=\"16\" class=\"text-gray-600 transition group-hover:text-indigo-300\"></lucide-icon>\n </button>\n <button\n type=\"button\"\n on-click=\"removeSecret(name)\"\n aria-label=\"Delete secret {{ name }}\"\n title=\"Delete secret\"\n class=\"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-rose-300 outline-none transition hover:bg-rose-500/10 focus-visible:ring-2 focus-visible:ring-rose-400/60\"\n >\n <lucide-icon icon=\"trash\" size=\"15\"></lucide-icon>\n </button>\n </div>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-gray-700 px-4 py-8 text-center\"\n class-hidden=\"secrets.length > 0\"\n >\n <lucide-icon icon=\"key-round\" size=\"22\" class=\"mx-auto text-gray-600\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-gray-300\">No secrets configured</p>\n <p class=\"mt-1 text-xs text-gray-500\">Add one above to make it available to a workflow.</p>\n </div>\n </section>\n </main>\n\n <script setup src=\"./workflows-ui.mjs\"><\/script>\n </template>\n\n <script type=\"module\">\n import '@li3/web';\n <\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n";
|
|
12085
|
+
var Hh = "<template component=\"app-header\">\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <header class=\"mx-auto flex w-full max-w-[78rem] items-center justify-between gap-4 border-b border-gray-800 pb-5\">\n <div class=\"flex min-w-0 items-start gap-3\">\n <template if=\"back\">\n <a class=\"mt-0.5 inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-800 text-gray-400 no-underline outline-none hover:border-indigo-500 hover:text-indigo-200 focus-visible:ring-2 focus-visible:ring-indigo-400/60\" attr-href=\"back\" attr-aria-label=\"backLabel || 'Back'\" attr-title=\"backLabel || 'Back'\">\n <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" width=\"17\" height=\"17\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m15 18-6-6 6-6\"></path></svg>\n </a>\n </template>\n <div>\n <div class=\"text-[.7rem] font-bold uppercase tracking-[.16em] text-indigo-400\">Runner engine</div>\n <h1 class=\"mt-1 text-2xl font-bold tracking-tight text-white\">{{ title }}</h1>\n <template if=\"subtitle\"><p class=\"mt-1 text-xs text-gray-400\">{{ subtitle }}</p></template>\n </div>\n </div>\n <div class=\"flex items-center gap-2\">\n <template if=\"authConfigured && !authenticated\">\n <a class=\"text-xs text-indigo-300 hover:text-white\" href=\"/auth/login\">Sign in</a>\n </template>\n <template if=\"authenticated\">\n <span class=\"hidden text-xs text-gray-400 sm:inline\">{{ userName }}</span>\n <a class=\"text-xs text-indigo-300 hover:text-white\" href=\"/auth/logout\">Sign out</a>\n </template>\n <a class=\"inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-700 text-indigo-200 no-underline outline-none hover:border-indigo-500 hover:bg-gray-900 hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\" href=\"/help\" aria-label=\"Help\" title=\"Help\">\n <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"></circle><path d=\"M9.6 9a2.5 2.5 0 1 1 4.2 1.8c-1.2 1-1.8 1.4-1.8 2.7\"></path><path d=\"M12 17h.01\"></path></svg>\n </a>\n <a class=\"inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-700 text-indigo-200 no-underline outline-none hover:border-indigo-500 hover:bg-gray-900 hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\" href=\"/settings\" aria-label=\"Settings\" title=\"Settings\">\n <lucide-icon icon=\"settings\" size=\"16\"></lucide-icon>\n </a>\n </div>\n </header>\n <script setup src=\"./app-header.mjs\"><\/script>\n</template>\n", Uh = "<!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\" />\n <meta name=\"theme-color\" content=\"#030712\" />\n <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n <link rel=\"icon\" href=\"/app-icon.svg\" type=\"image/svg+xml\" />\n <script type=\"importmap\">\n { \"imports\": { \"@app/\": \"/\", \"@li3/\": \"https://cdn.li3.dev/@li3/\", \"ansi_up\": \"https://esm.sh/ansi_up@6.0.6\", \"diff\": \"https://unpkg.com/diff@9.0.0/libesm/index.js\", \"marked\": \"https://cdn.jsdelivr.net/npm/marked@18.0.12/lib/marked.esm.js\" } }\n <\/script>\n <link rel=\"component\" href=\"/app-router.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-editor.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-block.html\" />\n <title>On Workflow Runner</title>\n </head>\n <body class=\"min-h-screen bg-gray-950 text-gray-100 font-sans\">\n <template app>\n <app-router></app-router>\n </template>\n <script type=\"module\">import '@li3/web';<\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n", Wh = "<template component=\"app-router\">\n <div ref=\"outlet\" aria-live=\"polite\"></div>\n <script setup src=\"./app-router.mjs\"><\/script>\n</template>\n", Gh = "<!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 <meta name=\"theme-color\" content=\"#030712\" />\n <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n <link rel=\"icon\" href=\"/app-icon.svg\" type=\"image/svg+xml\" />\n <title>Runner Workflow Management</title>\n <script type=\"importmap\">\n { \"imports\": { \"@app/\": \"/\", \"@li3/\": \"https://cdn.li3.dev/@li3/\", \"diff\": \"https://unpkg.com/diff@9.0.0/libesm/index.js\" } }\n <\/script>\n <style>\n body[data-page='list'] .editor-panel {\n display: none;\n }\n body[data-page='workflows'] .editor-panel,\n body[data-page='workflows'] .secrets-panel,\n body[data-page='secrets'] .workflow-list,\n body[data-page='secrets'] .editor-panel {\n display: none;\n }\n body[data-page='editor'] .workflow-list,\n body[data-page='editor'] .secrets-panel {\n display: none;\n }\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/code-block.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n\n <template app>\n <main class=\"mx-auto max-w-6xl space-y-5 py-6\">\n <template if=\"page === 'secrets'\"><app-header title=\"Secrets\" subtitle=\"Write-only credentials available to workflows\" back=\"/runs\" back-label=\"Back to dashboard\"></app-header></template>\n <template if=\"page !== 'secrets'\"><app-header title=\"Workflow control room\" subtitle=\"Draft, validate, and publish revisioned YAML definitions\" back=\"/runs\" back-label=\"Back to dashboard\"></app-header></template>\n <nav class=\"flex flex-wrap gap-1 rounded-xl border border-gray-800 bg-gray-900/70 p-1\" aria-label=\"Settings navigation\">\n <a href=\"/settings/workflows\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\" bind-class=\"page === 'workflows' || page === 'editor' ? 'bg-gray-800.text-white' : ''\">Workflows</a>\n <a href=\"/settings/secrets\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\" bind-class=\"page === 'secrets' ? 'bg-gray-800.text-white' : ''\">Secrets</a>\n <a href=\"/settings/tokens\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">API tokens</a>\n <a href=\"/settings/notifications\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Notifications</a>\n </nav>\n\n <template if=\"notice\">\n <div\n role=\"status\"\n class=\"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm\"\n 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 >\n <lucide-icon bind-icon=\"noticeError ? 'circle-alert' : 'circle-check'\" size=\"17\"></lucide-icon>\n <span>{{ notice }}</span>\n </div>\n </template>\n\n <section id=\"workflow-layout\" class=\"space-y-5\">\n <nav\n class=\"workflow-list rounded-2xl border border-gray-800 bg-gray-900/80 p-4 shadow-xl shadow-black/10\"\n aria-labelledby=\"workflow-list-title\"\n >\n <div class=\"mb-4 flex items-end justify-between gap-3 border-b border-gray-800 pb-4\">\n <div>\n <p class=\"text-[11px] font-semibold uppercase tracking-[0.18em] text-indigo-400\">Definitions</p>\n <h2 id=\"workflow-list-title\" class=\"mt-1 text-lg font-semibold text-white\">Workflows</h2>\n </div>\n <a\n href=\"/workflows/new\"\n class=\"inline-flex items-center gap-2 rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white outline-none transition hover:bg-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >\n <lucide-icon icon=\"plus\" size=\"16\"></lucide-icon>\n New workflow\n </a>\n </div>\n <ul class=\"grid gap-2\" aria-label=\"Workflow definitions\">\n <template for=\"workflow of workflows\">\n <li>\n <a\n attr-href=\"'/workflows/' + workflow.id\"\n class=\"group flex min-h-16 items-center gap-3 rounded-xl border border-gray-800 bg-gray-950/50 px-3.5 py-3 text-left text-sm text-gray-300 outline-none transition hover:border-indigo-500/50 hover:bg-indigo-500/10 focus-visible:border-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/50\"\n >\n <span\n class=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition group-hover:bg-indigo-500/15 group-hover:text-indigo-300\"\n >\n <lucide-icon icon=\"workflow\" size=\"17\"></lucide-icon>\n </span>\n <span class=\"min-w-0 flex-1\">\n <span class=\"block truncate text-base font-semibold text-gray-100\">{{ workflow.name }}</span>\n <span class=\"mt-0.5 block truncate font-mono text-[11px] text-gray-500\">{{ workflow.id }}</span>\n </span>\n <span\n class=\"rounded-full border px-2 py-1 text-[10px] font-semibold uppercase tracking-wide\"\n bind-class=\"workflow.status === 'published' ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300' : workflow.status === 'archived' ? 'border-gray-700 bg-gray-800 text-gray-500' : 'border-amber-500/30 bg-amber-500/10 text-amber-300'\"\n >{{ workflow.status }}</span\n >\n <lucide-icon\n icon=\"arrow-up-right\"\n size=\"16\"\n class=\"shrink-0 text-gray-600 transition group-hover:text-indigo-300\"\n ></lucide-icon>\n </a>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-gray-700 px-4 py-8 text-center\"\n class-hidden=\"workflows.length > 0\"\n >\n <lucide-icon icon=\"workflow\" size=\"22\" class=\"mx-auto text-gray-600\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-gray-300\">No workflows yet</p>\n <p class=\"mt-1 text-xs text-gray-500\">Create a definition to start automating jobs.</p>\n </div>\n </nav>\n\n <section\n class=\"editor-panel overflow-hidden rounded-xl border border-gray-800 bg-gray-900/80 shadow-xl shadow-black/10\"\n >\n <div class=\"border-b border-gray-800 px-4 pt-3 sm:px-5\">\n <a\n href=\"/workflows\"\n class=\"inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-gray-400 outline-none transition hover:bg-gray-800 hover:text-gray-100 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >\n <lucide-icon icon=\"arrow-left\" size=\"14\"></lucide-icon>\n Back to workflows\n </a>\n </div>\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\"\n >Workflow ID\n <input\n bind-value=\"workflowId\"\n on-input=\"setWorkflowId($event)\"\n bind-disabled=\"selectedId\"\n aria-describedby=\"workflow-id-help\"\n 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\"\n placeholder=\"derived-from-workflow-name\"\n />\n <span\n id=\"workflow-id-help\"\n class=\"mt-1 block text-[11px] font-normal normal-case tracking-normal text-gray-500\"\n >The ID becomes permanent after the first save.</span\n >\n </label>\n <label class=\"inline-flex items-center gap-2 text-sm text-gray-300\">\n <input\n type=\"checkbox\"\n bind-checked=\"enabled\"\n on-change=\"setEnabled($event)\"\n class=\"h-4 w-4 rounded border-gray-700 bg-gray-950 text-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n />\n Enabled\n </label>\n <div class=\"flex flex-wrap gap-2\" class-hidden=\"readOnly\">\n <button type=\"button\" aria-label=\"Get AI workflow help\" title=\"Get AI workflow help\" on-click=\"openAiHelp()\" class=\"inline-flex items-center gap-2 rounded-lg border border-indigo-500/50 px-3 py-2.5 text-sm font-medium text-indigo-200 outline-none hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <lucide-icon icon=\"sparkles\" size=\"16\"></lucide-icon><span class=\"hidden sm:inline\">AI help</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Save workflow draft\"\n title=\"Save workflow draft\"\n on-click=\"save()\"\n bind-disabled=\"busy\"\n 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 >\n <lucide-icon icon=\"save\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm:inline\">Save draft</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Publish workflow\"\n title=\"Publish workflow\"\n on-click=\"publish()\"\n bind-disabled=\"busy || !selectedId || source !== savedSource || enabled !== savedEnabled\"\n 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 >\n <lucide-icon icon=\"rocket\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm:inline\">Publish</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Run workflow now\"\n title=\"Run saved workflow draft now\"\n on-click=\"runNow()\"\n bind-disabled=\"busy || !selectedId || source !== savedSource || !validation.valid\"\n class=\"inline-flex items-center gap-2 rounded-lg border border-sky-500/50 px-3 py-2.5 text-sm font-medium text-sky-300 transition hover:bg-sky-500/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"play\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm:inline\">Run now</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Delete workflow\"\n title=\"Delete workflow\"\n on-click=\"remove()\"\n bind-disabled=\"busy\"\n 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 >\n <lucide-icon icon=\"trash\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm:inline\">Delete</span>\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 <div class=\"flex items-center gap-2\">\n <button\n type=\"button\"\n aria-label=\"Validate workflow\"\n title=\"Validate workflow\"\n on-click=\"validate(1)\"\n bind-disabled=\"validation.running\"\n bind-class=\"{ 'hidden': readOnly, 'text-emerald-400': validation.valid, 'text-rose-400': !validation.valid }\"\n class=\"disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon\n class=\"inline-block\"\n bind-icon=\"validation.running ? 'loader-circle' : (validation.valid ? 'circle-check' : 'circle-alert')\"\n bind-class=\"validation.running ? 'animate-spin' : ''\"\n size=\"16\"\n ></lucide-icon>\n </button>\n <span class=\"font-medium\" bind-class=\"validation.error ? 'bg-rose-500/10 text-rose-100' : ''\"\n >{{ validation.error || '' }}</span\n >\n </div>\n <template if=\"revision\">\n <button\n type=\"button\"\n on-click=\"openRevisionDialog()\"\n class=\"font-mono text-indigo-300 outline-none hover:underline focus-visible:rounded focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >Revision {{ revision }}</button>\n </template>\n <template if=\"!revision\"><span class=\"font-mono\">Unsaved draft</span></template>\n </div>\n <template if=\"readOnly\">\n <code-block\n language=\"yaml\"\n theme=\"atom-one-dark\"\n class=\"block h-[calc(100vh-23rem)] min-h-[28rem] overflow-auto rounded-lg border border-gray-700 bg-gray-950 p-4\"\n bind-source=\"source\"\n ></code-block>\n </template>\n <template if=\"!readOnly\">\n <code-editor\n id=\"source-yaml\"\n language=\"yaml\"\n theme=\"atom-one-dark\"\n nostatus=\"true\"\n class=\"block h-[calc(100vh-23rem)] min-h-[28rem] overflow-hidden rounded-lg border border-gray-700 bg-gray-950\"\n bind-value=\"source\"\n on-change=\"setSource($event.detail)\"\n ></code-editor>\n </template>\n <details\n class=\"mt-4 overflow-hidden rounded-xl border border-gray-800 bg-gray-950/50\"\n on-toggle=\"loadHelp($event)\"\n >\n <summary\n class=\"flex cursor-pointer list-none items-center gap-2 px-4 py-3 text-sm font-medium text-gray-300 outline-none transition hover:bg-gray-900 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >\n <span class=\"inline-flex items-center gap-2\"\n ><lucide-icon icon=\"circle-question-mark\" size=\"16\"></lucide-icon>Syntax</span\n >\n <span class=\"ml-2 text-xs font-normal text-gray-500\">Reference, tutorials, and examples</span>\n </summary>\n <div\n ref=\"helpContent\"\n class=\"max-h-[38rem] overflow-y-auto border-t border-gray-800 bg-gray-950 px-4 py-5\"\n role=\"region\"\n aria-label=\"Workflow syntax help\"\n aria-live=\"polite\"\n >\n <p class=\"text-sm text-gray-500\">Expand to load the workflow syntax reference.</p>\n </div>\n </details>\n </div>\n </section>\n </section>\n\n <template if=\"aiDialogOpen\">\n <div class=\"fixed inset-0 z-50 flex items-center justify-center bg-gray-950/80 p-4\" role=\"presentation\">\n <div class=\"flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-gray-700 bg-gray-900 shadow-2xl\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"workflow-ai-title\">\n <div class=\"flex items-start justify-between gap-3 border-b border-gray-800 px-5 py-4\"><div><h2 id=\"workflow-ai-title\" class=\"text-lg font-semibold text-white\">AI workflow help</h2><p class=\"mt-1 text-sm text-gray-400\">Describe the change you want. The suggestion will not modify the editor until you accept it.</p></div><button type=\"button\" on-click=\"closeAiHelp()\" aria-label=\"Close AI help\" class=\"rounded-lg p-2 text-gray-400 hover:bg-gray-800\"><lucide-icon icon=\"x\" size=\"17\"></lucide-icon></button></div>\n <div class=\"min-h-0 overflow-auto p-5\">\n <label class=\"sr-only\" for=\"workflow-ai-request\">Requested workflow change</label>\n <textarea id=\"workflow-ai-request\" bind-value=\"aiRequest\" placeholder=\"Example: Add a Docker cache volume to the build step.\" class=\"min-h-24 w-full rounded-xl border border-gray-700 bg-gray-950 p-3 text-sm text-gray-200 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"></textarea>\n <p class=\"mt-2 text-xs text-rose-300\" class-hidden=\"!aiError\">{{ aiError }}</p>\n <template if=\"aiDiffHtml\">\n <div class=\"mt-4\"><p class=\"mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500\">Suggested changes</p><pre class=\"max-h-[36rem] overflow-auto rounded-xl border border-gray-700 bg-gray-950 p-4 font-mono text-xs leading-6\" bind-innerhtml=\"aiDiffHtml\"></pre></div>\n </template>\n </div>\n <div class=\"flex items-center justify-end gap-2 border-t border-gray-800 px-5 py-3\"><button type=\"button\" on-click=\"closeAiHelp()\" class=\"rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Cancel</button><template if=\"aiDiffHtml\"><button type=\"button\" on-click=\"rejectAiSuggestion()\" class=\"rounded-lg border border-rose-500/50 px-3 py-2 text-sm text-rose-300 hover:bg-rose-500/10\">Reject</button><button type=\"button\" on-click=\"acceptAiSuggestion()\" class=\"rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-400\">Accept suggestion</button></template><button type=\"button\" on-click=\"requestAiHelp()\" bind-disabled=\"aiLoading || !aiRequest.trim()\" class=\"rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-400 disabled:opacity-50\">{{ aiLoading ? 'Thinking...' : 'Suggest' }}</button></div>\n </div>\n </div>\n </template>\n\n <template if=\"revisionDialogOpen\">\n <div class=\"fixed inset-0 z-50 flex items-center justify-center bg-gray-950/80 p-4\" role=\"presentation\">\n <div\n class=\"flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-gray-700 bg-gray-900 shadow-2xl\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby=\"revision-dialog-title\"\n >\n <div class=\"flex items-center justify-between border-b border-gray-800 px-4 py-3\">\n <div>\n <h2 id=\"revision-dialog-title\" class=\"font-semibold text-white\">Workflow revision {{ revisionNumber }}</h2>\n <p class=\"text-xs text-gray-500\">Read-only source snapshot</p>\n </div>\n <button type=\"button\" on-click=\"closeRevisionDialog()\" aria-label=\"Close revision dialog\" class=\"rounded-lg p-2 text-gray-400 outline-none hover:bg-gray-800 hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <lucide-icon icon=\"x\" size=\"17\"></lucide-icon>\n </button>\n </div>\n <div class=\"min-h-0 flex-1 overflow-auto p-4\">\n <p class=\"mb-3 text-sm text-rose-300\" class-hidden=\"!revisionError\">{{ revisionError }}</p>\n <pre class=\"min-h-[20rem] overflow-auto rounded-lg border border-gray-700 bg-gray-950 p-4 font-mono text-xs leading-6\" bind-innerhtml=\"revisionDiffHtml\"></pre>\n </div>\n <div class=\"flex items-center justify-between border-t border-gray-800 px-4 py-3\">\n <button type=\"button\" on-click=\"navigateRevision(-1)\" bind-disabled=\"revisionLoading || revisionNumber <= 1\" class=\"inline-flex items-center gap-2 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-200 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-40\">\n <lucide-icon icon=\"chevron-left\" size=\"16\"></lucide-icon>Previous\n </button>\n <button type=\"button\" on-click=\"navigateRevision(1)\" bind-disabled=\"revisionLoading || revisionNumber >= revision\" class=\"inline-flex items-center gap-2 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-200 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-40\">\n Next<lucide-icon icon=\"chevron-right\" size=\"16\"></lucide-icon>\n </button>\n </div>\n </div>\n </div>\n </template>\n\n <section\n class=\"secrets-panel rounded-2xl border border-gray-800 bg-gray-900/80 p-4 shadow-xl shadow-black/10 sm:p-5\"\n aria-labelledby=\"secrets-title\"\n >\n <div class=\"mb-5 flex items-start justify-between gap-3\">\n <div class=\"flex items-start gap-3\">\n <span class=\"rounded-lg bg-indigo-500/10 p-2 text-indigo-300\"\n ><lucide-icon icon=\"key-round\" size=\"18\"></lucide-icon\n ></span>\n <div>\n <h2 id=\"secrets-title\" class=\"text-lg font-semibold text-white\">Secrets</h2>\n <p class=\"mt-0.5 text-sm text-gray-400\">Write-only credentials available to workflows.</p>\n </div>\n </div>\n <span\n class=\"rounded-full border border-gray-700 bg-gray-950 px-2.5 py-1 text-xs font-mono text-gray-400\"\n aria-label=\"Secret count\"\n >{{ secrets.length }}</span\n >\n </div>\n <details ref=\"secretForm\" class=\"mb-5 overflow-hidden rounded-xl border border-gray-800 bg-gray-950/50\">\n <summary class=\"flex cursor-pointer list-none items-center justify-between px-4 py-3 text-sm font-medium text-gray-300 outline-none hover:bg-gray-900 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <span>Add a secret</span>\n <lucide-icon icon=\"plus\" size=\"17\"></lucide-icon>\n </summary>\n <div class=\"space-y-3 border-t border-gray-800 p-4\">\n <p class=\"text-xs text-gray-500\">Saving an existing name replaces its encrypted value.</p>\n <label class=\"sr-only\" for=\"secret-name\">Secret name</label>\n <input\n id=\"secret-name\"\n bind-value=\"secretName\"\n on-input=\"setSecretName($event)\"\n class=\"rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 font-mono text-sm uppercase outline-none transition placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n placeholder=\"NPM_TOKEN\"\n />\n <label class=\"inline-flex items-center gap-2 text-sm text-gray-300\">\n <input type=\"checkbox\" bind-checked=\"fileMode\" on-change=\"setFileMode($event)\" class=\"h-4 w-4 rounded border-gray-700 bg-gray-900 text-indigo-500 focus:ring-2 focus:ring-indigo-500/30\" />\n Binary file secret\n </label>\n <template if=\"!fileMode\">\n <label class=\"sr-only\" for=\"secret-value\">Text secret value</label>\n <input\n id=\"secret-value\"\n ref=\"secretValueInput\"\n bind-value=\"secretValue\"\n on-input=\"setSecretValue($event)\"\n type=\"password\"\n class=\"rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 font-mono text-sm outline-none transition placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n placeholder=\"New secret value\"\n />\n </template>\n <template if=\"fileMode\">\n <label class=\"sr-only\" for=\"secret-file\">Binary secret file</label>\n <input\n id=\"secret-file\"\n ref=\"secretFileInput\"\n on-change=\"setSecretFile($event)\"\n type=\"file\"\n class=\"rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-xs text-gray-400 file:mr-3 file:rounded file:border-0 file:bg-gray-700 file:px-2 file:py-1 file:text-xs file:text-gray-200 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30\"\n />\n </template>\n <div>\n <button\n type=\"button\"\n aria-label=\"Save secret value\"\n title=\"Save secret value\"\n on-click=\"saveSecret()\"\n bind-disabled=\"busy\"\n 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 outline-none transition hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-50\"\n >\n <lucide-icon icon=\"lock-keyhole\" size=\"16\"></lucide-icon>Save secret\n </button>\n </div>\n </div>\n </div>\n </details>\n <ul class=\"grid gap-2\" aria-label=\"Saved secrets\">\n <template for=\"name of secrets\">\n <li>\n <div class=\"flex min-h-14 items-center gap-2 rounded-xl border border-gray-800 bg-gray-950/50 px-3.5 py-3\">\n <button\n type=\"button\"\n on-click=\"selectSecret(name)\"\n class=\"group flex min-w-0 flex-1 items-center gap-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-indigo-400/50\"\n aria-label=\"Edit secret {{ name }}\"\n >\n <span\n class=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-800 text-gray-500 group-hover:text-indigo-300\"\n ><lucide-icon icon=\"key-round\" size=\"15\"></lucide-icon\n ></span>\n <span class=\"min-w-0 flex-1 truncate font-mono text-sm text-gray-200\">{{ name }}</span>\n <span class=\"text-xs text-gray-500\">Select</span>\n <lucide-icon icon=\"chevron-right\" size=\"16\" class=\"text-gray-600 transition group-hover:text-indigo-300\"></lucide-icon>\n </button>\n <button\n type=\"button\"\n on-click=\"removeSecret(name)\"\n aria-label=\"Delete secret {{ name }}\"\n title=\"Delete secret\"\n class=\"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-rose-300 outline-none transition hover:bg-rose-500/10 focus-visible:ring-2 focus-visible:ring-rose-400/60\"\n >\n <lucide-icon icon=\"trash\" size=\"15\"></lucide-icon>\n </button>\n </div>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-gray-700 px-4 py-8 text-center\"\n class-hidden=\"secrets.length > 0\"\n >\n <lucide-icon icon=\"key-round\" size=\"22\" class=\"mx-auto text-gray-600\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-gray-300\">No secrets configured</p>\n <p class=\"mt-1 text-xs text-gray-500\">Add one above to make it available to a workflow.</p>\n </div>\n </section>\n </main>\n\n <script setup src=\"./workflows-ui.mjs\"><\/script>\n </template>\n\n <script type=\"module\">\n import '@li3/web';\n <\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n";
|
|
12488
12086
|
//#endregion
|
|
12489
12087
|
//#region src/workflows-ui.ts
|
|
12490
12088
|
function Kh(e, t = "", n) {
|
|
@@ -12636,7 +12234,7 @@ function Zh(e) {
|
|
|
12636
12234
|
}
|
|
12637
12235
|
//#endregion
|
|
12638
12236
|
//#region src/api-client.mjs?raw
|
|
12639
|
-
var Qh = "let token = sessionStorage.getItem('runner-api-token') || '';\nlet expiresAt = token ? Number.MAX_SAFE_INTEGER : 0;\n\nconst rawFetch = window.fetch.bind(window);\n\nasync function loadToken() {\n if (token && expiresAt > Date.now() + 30_000) return token;\n const response = await rawFetch('/api/auth/token', { headers: { accept: 'application/json' } });\n if (!response.ok) return '';\n const data = await response.json();\n token = data.access_token || '';\n expiresAt = Number(data.expires_at || 0);\n return token;\n}\n\nexport async function apiFetch(input, init = {}) {\n const target = new URL(input, window.location.href);\n if (target.origin !== window.location.origin || (!target.pathname.startsWith('/api/') && !target.pathname.startsWith('/restart/'))) {\n return rawFetch(input, init);\n }\n const headers = new Headers(init.headers);\n const accessToken = await loadToken();\n if (accessToken) headers.set('authorization', `Bearer ${accessToken}`);\n return rawFetch(input, { ...init, headers });\n}\n\nexport function setApiToken(value) {\n token = value || '';\n expiresAt = token ? Number.MAX_SAFE_INTEGER : 0;\n if (token) sessionStorage.setItem('runner-api-token', token);\n else sessionStorage.removeItem('runner-api-token');\n}\n", $h = "import { defineProp, onInit, ref } from '@li3/web';\n\nexport default function () {\n const authConfigured = ref(false);\n const authenticated = ref(false);\n const userName = ref('');\n onInit(async () => {\n try {\n const response = await fetch('/api/auth/session');\n if (!response.ok) return;\n const session = await response.json();\n authConfigured.value = session.configured;\n authenticated.value = session.authenticated;\n userName.value = session.user?.name || session.user?.email || '';\n } catch {}\n });\n return { title: defineProp('title'), subtitle: defineProp('subtitle'), back: defineProp('back'), backLabel: defineProp('back-label'), authConfigured, authenticated, userName };\n}\n", eg = "import { getElement, load, onDestroy, onInit, templateRef } from '@li3/web';\n\nexport default function () {\n const outlet = templateRef('outlet');\n let navigating = false;\n const route = () => {\n const url = new URL(window.location.href);\n const path = url.pathname;\n if (path === '/' || path === '/runs') return { component: 'page-dashboard', url: '/pages/dashboard.html', page: 'dashboard' };\n if (path.match(/^\\/runs\\/\\d+$/)) return { component: 'page-run', url: `/pages/run.html?jobId=${path.split('/').pop()}`, page: 'run' };\n if (path === '/help') return { component: 'page-help', url: '/pages/help.html', page: 'help' };\n if (path === '/settings/workflows') return { component: 'page-workflows', url: '/pages/workflows.html?page=workflows', page: 'workflows' };\n if (path === '/settings/secrets') return { component: 'page-secrets', url: '/pages/workflows.html?page=secrets', page: 'secrets' };\n if (path === '/settings/tokens') return { component: 'page-settings', url: '/pages/settings.html?page=tokens', page: 'tokens' };\n if (path === '/settings/notifications') return { component: 'page-settings', url: '/pages/settings.html?page=notifications', page: 'notifications' };\n if (path === '/settings/workers') return { component: 'page-settings', url: '/pages/settings.html?page=workers', page: 'workers' };\n if (path === '/settings' || path === '/workflows') return { component: 'page-workflows', url: '/pages/workflows.html?page=workflows', page: 'workflows' };\n const editor = path.match(/^\\/settings\\/workflows\\/(new|[a-z0-9-]+)$/);\n if (editor) return { component: 'page-workflow-editor', url: `/pages/workflows.html?page=editor&id=${editor[1]}${url.searchParams.get('revision') ? `&revision=${url.searchParams.get('revision')}` : ''}`, page: 'editor' };\n const legacyEditor = path.match(/^\\/workflows\\/(new|[a-z0-9-]+)$/);\n if (legacyEditor) return { component: 'page-workflow-editor', url: `/pages/workflows.html?page=editor&id=${legacyEditor[1]}${url.searchParams.get('revision') ? `&revision=${url.searchParams.get('revision')}` : ''}`, page: 'editor' };\n return { component: 'page-dashboard', url: '/pages/dashboard.html', page: 'dashboard' };\n };\n const navigate = async (replace = false) => {\n if (navigating) return;\n navigating = true;\n try {\n const current = route();\n document.body.dataset.page = current.page;\n await load(current.url);\n outlet.value.replaceChildren(document.createElement(current.component));\n if (replace) history.replaceState(null, '', window.location.href);\n } catch (error) {\n outlet.value.innerHTML = `<main class=\"mx-auto max-w-4xl p-8\"><h1 class=\"text-xl font-semibold text-white\">Unable to load page</h1><p class=\"mt-2 text-sm text-rose-300\">${String(error.message || error)}</p></main>`;\n } finally {\n navigating = false;\n }\n };\n const handleClick = (event) => {\n const anchor = event.target.closest?.('a');\n if (!anchor || anchor.target || anchor.hasAttribute('download')) return;\n const target = new URL(anchor.href, window.location.href);\n if (target.origin !== window.location.origin || !target.pathname.startsWith('/') || target.pathname.startsWith('/auth/')) return;\n if (target.pathname.startsWith('/api/') || target.pathname.startsWith('/webhooks/')) return;\n if (target.pathname === window.location.pathname && target.hash) return;\n event.preventDefault();\n history.pushState(null, '', `${target.pathname}${target.search}${target.hash}`);\n void navigate();\n };\n const element = getElement();\n onInit(() => {\n element.addEventListener('click', handleClick);\n window.addEventListener('popstate', navigate);\n void navigate(true);\n });\n onDestroy(() => {\n element.removeEventListener('click', handleClick);\n window.removeEventListener('popstate', navigate);\n });\n return {};\n}\n", tg = "import { onDestroy, onInit, ref } from '@li3/web';\nimport { apiFetch } from '@app/api-client.mjs';\n\nexport default function setup() {\n const jobs = ref([]), refreshError = ref(false), hasMore = ref(false), loadingMore = ref(false);\n const filter = ref(''), activeFilter = ref(''), pushConfigured = ref(false);\n const notificationsSupported = ref('Notification' in window && 'serviceWorker' in navigator);\n const notificationsEnabled = ref(notificationsSupported.value && Notification.permission === 'granted' && localStorage.getItem('runner-notifications') === 'enabled');\n const notificationNotice = ref('');\n let refreshTimer, eventSource, refreshing = false, refreshPending = false, searchInProgress = false, refreshGeneration = 0;\n const upper = (value) => String(value || '').toUpperCase();\n const terminalStatuses = new Set(['success', 'failed', 'cancelled']);\n const notifyCompletedJob = async (job) => {\n if (!notificationsEnabled.value || pushConfigured.value || !['success', 'failed'].includes(job.status)) return;\n try {\n const registration = await navigator.serviceWorker.ready;\n await registration.showNotification(job.status === 'success' ? `Job #${job.id} succeeded` : `Job #${job.id} failed`, { body: `${job.workflowId} finished with status ${job.status}.`, icon: '/app-icon.svg', badge: '/app-icon.svg', tag: `runner-job-${job.id}`, renotify: true, data: { url: `/runs/${job.id}` } });\n } catch (error) { console.error('Unable to show job notification', error); }\n };\n const urlBase64ToUint8Array = (value) => { const padding = '='.repeat((4 - (value.length % 4)) % 4); const raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from([...raw].map((character) => character.charCodeAt(0))); };\n const enablePushNotifications = async () => {\n try {\n if (!notificationsSupported.value) return;\n const response = await apiFetch('/api/push/public-key', { headers: { accept: 'application/json' } });\n if (!response.ok) return;\n const { publicKey } = await response.json(); pushConfigured.value = true;\n const registration = await navigator.serviceWorker.ready;\n const subscription = (await registration.pushManager.getSubscription()) || await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });\n const saved = await apiFetch('/api/push/subscriptions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(subscription) });\n if (!saved.ok) throw new Error(`Push subscription failed: ${saved.status}`);\n } catch (error) { console.error('Unable to enable background notifications', error); }\n };\n const disablePushNotifications = async () => {\n try { const subscription = await (await navigator.serviceWorker.ready).pushManager.getSubscription(); if (!subscription) return; await apiFetch('/api/push/subscriptions', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ endpoint: subscription.endpoint }) }); await subscription.unsubscribe(); }\n catch (error) { console.error('Unable to disable background notifications', error); }\n };\n const toggleNotifications = async () => {\n if (notificationsEnabled.value) { notificationsEnabled.value = false; localStorage.removeItem('runner-notifications'); await disablePushNotifications(); notificationNotice.value = 'Job notifications are paused on this device.'; return; }\n if (Notification.permission === 'denied') { notificationNotice.value = 'Notifications are blocked. Allow them in this site’s Android browser settings.'; return; }\n notificationsEnabled.value = (await Notification.requestPermission()) === 'granted';\n if (notificationsEnabled.value) { localStorage.setItem('runner-notifications', 'enabled'); await enablePushNotifications(); notificationNotice.value = 'Notifications enabled. Background delivery is available when server push is configured.'; } else notificationNotice.value = 'Notification permission was not granted.';\n };\n const refreshJobs = async (isSearch = false) => {\n if (searchInProgress && !isSearch) return; if (refreshing && !isSearch) { refreshPending = true; return; }\n refreshing = true; const generation = refreshGeneration;\n try {\n const activeIds = jobs.value.filter((job) => !terminalStatuses.has(job.status)).map((job) => Number(job.id));\n const knownIds = jobs.value.map((job) => Number(job.id));\n const afterId = activeIds.length ? Math.max(0, Math.min(...activeIds) - 1) : knownIds.length ? Math.max(...knownIds) : null;\n const filterQuery = activeFilter.value ? `&filter=${encodeURIComponent(activeFilter.value)}` : '';\n const url = afterId === null ? `/api/jobs?limit=100${filterQuery}` : `/api/jobs?afterId=${afterId}&limit=100${filterQuery}`;\n const response = await apiFetch(url, { headers: { accept: 'application/json' } }); if (!response.ok) throw new Error(`Dashboard refresh failed: ${response.status}`);\n const data = await response.json(); if (generation !== refreshGeneration) return; hasMore.value = data.hasMore;\n const previousStatuses = new Map(jobs.value.map((job) => [job.id, job.status])); const merged = new Map(jobs.value.map((job) => [job.id, job]));\n for (const job of data.jobs) { const previousStatus = previousStatuses.get(job.id); merged.set(job.id, job); if (previousStatus && !terminalStatuses.has(previousStatus) && terminalStatuses.has(job.status)) void notifyCompletedJob(job); }\n jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id)); refreshError.value = false;\n } catch (error) { console.error(error); refreshError.value = true; } finally { refreshing = false; if (refreshPending && !searchInProgress) { refreshPending = false; void refreshJobs(); } }\n };\n const loadMore = async () => { if (loadingMore.value || !hasMore.value || !jobs.value.length) return; loadingMore.value = true; try { const beforeId = Math.min(...jobs.value.map((job) => Number(job.id))); const filterQuery = activeFilter.value ? `&filter=${encodeURIComponent(activeFilter.value)}` : ''; const response = await apiFetch(`/api/jobs?beforeId=${beforeId}${filterQuery}`, { headers: { accept: 'application/json' } }); if (!response.ok) throw new Error(`Loading older jobs failed: ${response.status}`); const data = await response.json(); const merged = new Map(jobs.value.map((job) => [job.id, job])); for (const job of data.jobs) merged.set(job.id, job); jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id)); hasMore.value = data.hasMore; } catch (error) { console.error(error); refreshError.value = true; } finally { loadingMore.value = false; } };\n const updateSearchUrl = (value) => { const params = new URLSearchParams(window.location.search); if (value) params.set('search', value); else params.delete('search'); const query = params.toString(); history.pushState(null, '', `${window.location.pathname}${query ? `?${query}` : ''}`); };\n const applyFilter = async () => { searchInProgress = true; refreshGeneration++; activeFilter.value = filter.value.trim(); updateSearchUrl(activeFilter.value); jobs.value = []; hasMore.value = false; try { await refreshJobs(true); } finally { searchInProgress = false; refreshPending = false; } };\n const clearFilter = async () => { searchInProgress = true; refreshGeneration++; filter.value = ''; activeFilter.value = ''; updateSearchUrl(''); jobs.value = []; hasMore.value = false; try { await refreshJobs(true); } finally { searchInProgress = false; refreshPending = false; } };\n const setFilter = (event) => { filter.value = event.target.value; };\n onInit(() => { const initialSearch = new URLSearchParams(window.location.search).get('search')?.trim() || ''; filter.value = initialSearch; activeFilter.value = initialSearch; if (notificationsSupported.value) navigator.serviceWorker.register('/service-worker.js').then(() => { if (notificationsEnabled.value) void enablePushNotifications(); }).catch((error) => console.error('Service worker registration failed', error)); eventSource = new EventSource('/api/events'); eventSource.addEventListener('jobs.available', refreshJobs); eventSource.addEventListener('jobs.changed', refreshJobs); eventSource.onerror = () => { refreshError.value = true; }; void refreshJobs(); refreshTimer = setInterval(refreshJobs, 60000); });\n onDestroy(() => { eventSource?.close(); clearInterval(refreshTimer); });\n return { jobs, refreshError, hasMore, loadingMore, filter, activeFilter, pushConfigured, setFilter, loadMore, applyFilter, clearFilter, upper, notificationsSupported, notificationsEnabled, notificationNotice, toggleNotifications };\n}\n", ng = "\n import { computed, onDestroy, onInit, ref } from '@li3/web';\n import { AnsiUp } from 'ansi_up';\n import { marked } from 'marked';\n import { apiFetch } from '@app/api-client.mjs';\n\n export default function setup() {\n const jobId = window.location.pathname.split('/').filter(Boolean).pop() || '';\n const report = ref({ jobId });\n const previousRuns = ref([]);\n const aiMessages = ref([]);\n const aiStepId = ref('');\n const aiQuestion = ref('');\n const aiError = ref('');\n const aiLoading = ref(false);\n const manualRestartOpen = ref(false);\n const manualRestartLoading = ref(false);\n const manualInputs = ref([]);\n let previousRunsLoaded = false;\n const now = ref(Date.now());\n const ansiUp = new AnsiUp();\n ansiUp.use_classes = false;\n let eventSource;\n let refreshTimer;\n let clockTimer;\n let refreshing = false;\n let refreshPending = false;\n const cancelling = ref(false);\n\n const active = computed(() => ['pending', 'running'].includes(report.value.status));\n const inputsJson = computed(() => JSON.stringify(report.value.inputs || {}, null, 2));\n const originalInputsJson = computed(() => JSON.stringify(report.value.inputs || {}, null, 2));\n const timing = computed(() => {\n if (report.value.status === 'pending') return 'Waiting for a worker';\n if (report.value.status === 'running') {\n return `Running for ${Math.max(0, now.value - Date.parse(report.value.startedAt))}ms`;\n }\n return `Finished in ${report.value.durationMs}ms`;\n });\n\n const upper = (value) => String(value || '').toUpperCase();\n const artifactUrl = (path) => `/api/runs/${report.value.jobId}/artifacts/${encodeURIComponent(path)}`;\n const downloadArtifact = async (event, path) => {\n event.preventDefault();\n const response = await apiFetch(artifactUrl(path));\n if (!response.ok) return;\n const link = document.createElement('a');\n link.href = URL.createObjectURL(await response.blob());\n link.download = path.split('/').pop() || 'artifact';\n link.click();\n URL.revokeObjectURL(link.href);\n };\n const loadPreviousRuns = async () => {\n if (previousRunsLoaded || !report.value.parentId) return;\n const lineage = [];\n const visited = new Set();\n let parentId = report.value.parentId;\n while (parentId && !visited.has(String(parentId)) && lineage.length < 50) {\n visited.add(String(parentId));\n const response = await apiFetch(`/api/runs/${parentId}`, { headers: { accept: 'application/json' } });\n if (!response.ok) throw new Error(`Previous run request failed: ${response.status}`);\n const parent = await response.json();\n lineage.push({ id: parent.jobId, status: parent.status, startedAt: parent.startedAt });\n parentId = parent.parentId;\n }\n previousRuns.value = lineage;\n previousRunsLoaded = true;\n };\n const stepLog = (step) => {\n if (step.status === 'skipped') return '';\n if (step.status === 'running')\n return '<span class=\"text-indigo-400\">Step is running. Logs will appear after it finishes.</span>';\n if (step.status === 'pending') return '<span class=\"text-gray-500\">Waiting to run.</span>';\n if (step.logContent) return ansiUp.ansi_to_html(step.logContent);\n return '<span class=\"text-gray-500\">(No terminal log output recorded for this step)</span>';\n };\n const sanitizeAiHtml = (html) => {\n const parsed = new DOMParser().parseFromString(html, 'text/html');\n parsed.querySelectorAll('script,style,iframe,object,embed,form').forEach((node) => node.remove());\n parsed.querySelectorAll('*').forEach((element) => {\n [...element.attributes].forEach((attribute) => {\n if (attribute.name.toLowerCase().startsWith('on')) element.removeAttribute(attribute.name);\n if (['href', 'src'].includes(attribute.name.toLowerCase()) && /^(javascript|data):/i.test(attribute.value)) element.removeAttribute(attribute.name);\n });\n });\n return parsed.body.innerHTML;\n };\n const renderAiMarkdown = (content) => sanitizeAiHtml(marked.parse(content));\n const askAi = async (step, event) => {\n event.stopPropagation();\n aiStepId.value = step.id;\n aiMessages.value = [];\n aiQuestion.value = '';\n await requestAiHelp(step.id, 'Please explain why this step failed and what I should do next.');\n };\n const requestAiHelp = async (stepId, question) => {\n aiLoading.value = true;\n aiError.value = '';\n try {\n const response = await apiFetch(`/api/runs/${report.value.jobId}/ai-help`, {\n method: 'POST',\n headers: { accept: 'application/json', 'content-type': 'application/json' },\n body: JSON.stringify({ stepId, question, conversation: aiMessages.value }),\n });\n if (!response.ok) {\n const data = await response.json().catch(() => ({}));\n throw new Error(data.error || `AI help failed: ${response.status}`);\n }\n aiMessages.value = [...aiMessages.value, { role: 'user', content: question }, { role: 'assistant', content: '' }];\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const chunk = await reader.read();\n if (chunk.done) break;\n buffer += decoder.decode(chunk.value, { stream: true });\n const events = buffer.split('\\n\\n');\n buffer = events.pop() || '';\n for (const event of events) {\n const line = event.split('\\n').find((value) => value.startsWith('data: '));\n if (!line) continue;\n const data = JSON.parse(line.slice(6));\n if (data.delta) {\n const messages = [...aiMessages.value];\n messages[messages.length - 1] = { ...messages[messages.length - 1], content: messages[messages.length - 1].content + data.delta };\n aiMessages.value = messages;\n }\n if (data.error) throw new Error(data.error);\n }\n }\n const messages = [...aiMessages.value];\n const answer = messages.at(-1);\n if (answer) messages[messages.length - 1] = { ...answer, html: renderAiMarkdown(answer.content) };\n aiMessages.value = messages;\n } catch (error) {\n aiError.value = error.message;\n } finally {\n aiLoading.value = false;\n }\n };\n const askFollowup = async () => {\n const question = aiQuestion.value.trim();\n if (!question || !aiStepId.value) return;\n aiQuestion.value = '';\n await requestAiHelp(aiStepId.value, question);\n };\n const restartJob = async () => {\n const response = await apiFetch(`/restart/${report.value.jobId}`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ inputs: report.value.inputs || {} }),\n });\n if (response.ok) {\n const { id } = await response.json();\n location.href = `/runs/${id}`;\n }\n };\n const openManualRestart = (event) => {\n event.preventDefault();\n event.stopPropagation();\n manualInputs.value = [{ key: '', value: '' }];\n manualRestartOpen.value = true;\n };\n const closeManualRestart = () => { manualRestartOpen.value = false; };\n const addManualInput = () => { manualInputs.value = [...manualInputs.value, { key: '', value: '' }]; };\n const removeManualInput = (entry) => { manualInputs.value = manualInputs.value.filter((item) => item !== entry); };\n const setManualKey = (entry, event) => { entry.key = event.target.value; manualInputs.value = [...manualInputs.value]; };\n const setManualValue = (entry, event) => { entry.value = event.target.value; manualInputs.value = [...manualInputs.value]; };\n const restartWithInputs = async () => {\n const inputs = Object.fromEntries(manualInputs.value.filter((entry) => entry.key.trim()).map((entry) => [entry.key.trim(), entry.value]));\n manualRestartLoading.value = true;\n try {\n const response = await apiFetch(`/restart/${report.value.jobId}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ inputs }) });\n if (!response.ok) throw new Error(`Restart failed: ${response.status}`);\n const { id } = await response.json();\n location.href = `/runs/${id}`;\n } catch (error) {\n aiError.value = error.message;\n } finally {\n manualRestartLoading.value = false;\n }\n };\n const cancelJob = async () => {\n if (cancelling.value || report.value.status !== 'running') return;\n cancelling.value = true;\n try {\n const response = await apiFetch(`/api/jobs/${report.value.jobId}/cancel`, { method: 'POST' });\n if (!response.ok) throw new Error(`Cancel failed: ${response.status}`);\n await refreshRun();\n } catch (error) {\n console.error(error);\n } finally {\n cancelling.value = false;\n }\n };\n const refreshRun = async () => {\n if (refreshing) {\n refreshPending = true;\n return;\n }\n refreshing = true;\n\n try {\n const response = await apiFetch(`/api/runs/${jobId}`, {\n headers: { accept: 'application/json' },\n });\n if (!response.ok) throw new Error(`Run refresh failed: ${response.status}`);\n const previousStatus = report.value.status;\n const nextReport = await response.json();\n report.value = nextReport;\n document.title = `Run #${nextReport.jobId} - ${nextReport.workflowName}`;\n void loadPreviousRuns();\n if (\n ['pending', 'running'].includes(previousStatus) &&\n ['success', 'failed'].includes(nextReport.status) &&\n 'Notification' in window &&\n 'serviceWorker' in navigator &&\n Notification.permission === 'granted' &&\n localStorage.getItem('runner-notifications') === 'enabled'\n ) {\n const registration = await navigator.serviceWorker.ready;\n await registration.showNotification(\n nextReport.status === 'success'\n ? `Job #${nextReport.jobId} succeeded`\n : `Job #${nextReport.jobId} failed`,\n {\n body: `${nextReport.workflowName} finished with status ${nextReport.status}.`,\n icon: '/app-icon.svg',\n badge: '/app-icon.svg',\n tag: `runner-job-${nextReport.jobId}`,\n renotify: true,\n data: { url: `/runs/${nextReport.jobId}` },\n },\n );\n }\n } catch (error) {\n console.error(error);\n } finally {\n refreshing = false;\n if (refreshPending) {\n refreshPending = false;\n void refreshRun();\n }\n }\n };\n const handleJobChange = (event) => {\n const data = JSON.parse(event.data || '{}');\n if (!data.jobId || String(data.jobId) === String(report.value.jobId)) void refreshRun();\n };\n\n onInit(() => {\n void refreshRun();\n if ('serviceWorker' in navigator) {\n navigator.serviceWorker.register('/service-worker.js').catch((error) => {\n console.error('Service worker registration failed', error);\n });\n }\n eventSource = new EventSource('/api/events');\n eventSource.addEventListener('jobs.changed', handleJobChange);\n refreshTimer = setInterval(() => {\n if (active.value) void refreshRun();\n }, 60000);\n clockTimer = setInterval(() => {\n now.value = Date.now();\n }, 1000);\n });\n onDestroy(() => {\n eventSource?.close();\n clearInterval(refreshTimer);\n clearInterval(clockTimer);\n });\n\n return { report, previousRuns, manualRestartOpen, manualRestartLoading, manualInputs, aiMessages, aiStepId, aiQuestion, aiError, aiLoading, inputsJson, originalInputsJson, timing, upper, artifactUrl, downloadArtifact, stepLog, askAi, askFollowup, restartJob, openManualRestart, closeManualRestart, addManualInput, removeManualInput, setManualKey, setManualValue, restartWithInputs, cancelJob, cancelling };\n }\n \n", rg = "\n import { onInit, ref } from '@li3/web';\n import { apiFetch, setApiToken } from '@app/api-client.mjs';\n export default function () {\n const page = document.body.dataset.page;\n const keys = ref([]);\n const workers = ref([]);\n const keyName = ref('');\n const newToken = ref('');\n const error = ref('');\n const notificationsSupported = ref('Notification' in window && 'serviceWorker' in navigator);\n const notificationsEnabled = ref(notificationsSupported.value && Notification.permission === 'granted' && localStorage.getItem('runner-notifications') === 'enabled');\n const notificationMessage = ref('');\n const scopeOptions = ref([\n { name: 'workflows:read', label: 'Read workflows', selected: true },\n { name: 'workflows:write', label: 'Modify workflows', selected: false },\n { name: 'logs:read', label: 'Read logs', selected: false },\n { name: 'artifacts:read', label: 'Read artifacts', selected: false },\n { name: 'runs:control', label: 'Control runs', selected: false },\n { name: 'runs:dispatch', label: 'Dispatch runs', selected: false },\n ]);\n const api = async (url, options = {}) => { const response = await apiFetch(url, { headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) }, ...options }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`); return body; };\n const load = async () => { keys.value = (await api('/api/api-keys')).keys; };\n const loadWorkers = async () => { workers.value = (await api('/api/workers')).workers; };\n const issueKey = async () => { try { error.value = ''; const result = await api('/api/api-keys', { method: 'POST', body: JSON.stringify({ name: keyName.value, scopes: scopeOptions.value.filter((scope) => scope.selected).map((scope) => scope.name) }) }); newToken.value = result.token; setApiToken(result.token); keyName.value = ''; await load(); } catch (reason) { error.value = reason.message; } };\n const revokeKey = async (key) => { if (!confirm(`Revoke ${key.name}?`)) return; try { await api(`/api/api-keys/${key.id}`, { method: 'DELETE' }); await load(); } catch (reason) { error.value = reason.message; } };\n const setKeyName = (event) => { keyName.value = event.target.value; };\n const toggleScope = (scope) => { scope.selected = !scope.selected; };\n const toggleNotifications = async () => {\n if (notificationsEnabled.value) {\n notificationsEnabled.value = false;\n localStorage.removeItem('runner-notifications');\n notificationMessage.value = 'Notifications disabled on this device.';\n return;\n }\n if (!notificationsSupported.value) { notificationMessage.value = 'Notifications are not supported by this browser.'; return; }\n const permission = await Notification.requestPermission();\n notificationsEnabled.value = permission === 'granted';\n if (notificationsEnabled.value) { localStorage.setItem('runner-notifications', 'enabled'); notificationMessage.value = 'Notifications enabled.'; }\n else notificationMessage.value = 'Notification permission was not granted.';\n };\n onInit(() => { if (page === 'tokens') load().catch((reason) => { error.value = reason.message; }); if (page === 'workers') loadWorkers().catch((reason) => { error.value = reason.message; }); });\n return { page, keys, workers, keyName, newToken, error, scopeOptions, issueKey, revokeKey, setKeyName, toggleScope, notificationsEnabled, notificationMessage, toggleNotifications };\n }\n \n", ig = "\n import { onDestroy, onInit, ref, templateRef } from '@li3/web';\n import { apiFetch } from '@app/api-client.mjs';\n import { diffLines } from 'diff';\n\n export default function () {\n const page = document.body.dataset.page;\n const params = new URLSearchParams(window.location.search);\n const initialId = params.get('id') || '';\n const initialRevision = params.get('revision') ? Number(params.get('revision')) : null;\n const readOnly = Boolean(initialRevision);\n const workflows = ref([]);\n const secrets = ref([]);\n const source = ref('');\n const validation = ref({ valid: true });\n const savedSource = ref('');\n const revision = ref(null);\n const revisionDialogOpen = ref(false);\n const revisionNumber = ref(0);\n const revisionDiffHtml = ref('');\n const revisionError = ref('');\n const revisionLoading = ref(false);\n const aiDialogOpen = ref(false);\n const aiRequest = ref('');\n const aiLoading = ref(false);\n const aiError = ref('');\n const aiSuggestion = ref('');\n const aiDiffHtml = ref('');\n const workflowId = ref('');\n const enabled = ref(true);\n const savedEnabled = ref(true);\n const selectedId = ref('');\n const secretName = ref('');\n const secretValue = ref('');\n const fileMode = ref(false);\n const secretFileName = ref('');\n const secretFileData = ref('');\n const notice = ref('');\n const noticeError = ref(false);\n const busy = ref(false);\n const secretValueInput = templateRef('secretValueInput');\n const secretFileInput = templateRef('secretFileInput');\n const secretForm = templateRef('secretForm');\n const helpContent = templateRef('helpContent');\n const helpLoaded = ref(false);\n const helpLoading = ref(false);\n let validationTimer;\n\n const showNotice = (message, error = false) => {\n notice.value = message;\n noticeError.value = error;\n };\n\n const api = async (url, options = {}) => {\n const response = await apiFetch(url, {\n headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) },\n ...options,\n });\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\n const derivedId = () =>\n source.value\n .match(/^\\s*(?:id:\\s*([^\\n]+)|name:\\s*([^\\n]+))/m)\n ?.slice(1)\n .find(Boolean)\n ?.trim()\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '-')\n .replace(/(^-|-$)/g, '') || '';\n\n const run = async (action) => {\n if (busy.value) return;\n\n busy.value = true;\n\n try {\n await action();\n } catch (error) {\n showNotice(error.message, true);\n } finally {\n busy.value = false;\n }\n };\n\n const loadWorkflows = async () => {\n workflows.value = (await api('/api/workflows')).workflows;\n };\n\n const loadSecrets = async () => {\n secrets.value = (await api('/api/secrets')).secrets;\n };\n\n const openWorkflow = async (id) => {\n const workflowUrl = initialRevision\n ? `/api/workflows/${id}?revision=${initialRevision}`\n : `/api/workflows/${id}`;\n const workflow = await api(workflowUrl);\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n source.value = workflow.sourceYaml;\n savedSource.value = workflow.sourceYaml;\n enabled.value = workflow.enabled;\n savedEnabled.value = workflow.enabled;\n revision.value = workflow.revision || null;\n };\n\n const validateSource = async () => {\n return api('/api/workflows/validate', {\n method: 'POST',\n body: JSON.stringify({ sourceYaml: source.value }),\n });\n };\n\n const validate = (ms = 1000) => {\n if (page !== 'editor') return;\n\n clearTimeout(validationTimer);\n validationTimer = setTimeout(() => {\n validation.value = { ...validation.value, running: true, error: '' };\n void validateSource()\n .then((result) => {\n validation.value = { ...result, running: false };\n })\n .catch((error) => {\n validation.value = { valid: false, running: false, error: error.message };\n \n });\n }, ms);\n };\n\n const save = () =>\n 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 if (\n initialId &&\n id === selectedId.value &&\n source.value === savedSource.value &&\n enabled.value === savedEnabled.value\n ) {\n showNotice('No changes to save.');\n return;\n }\n const workflow = await api(`/api/workflows/${id}`, {\n method: 'PUT',\n body: JSON.stringify({ sourceYaml: source.value, enabled: enabled.value }),\n });\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n savedSource.value = source.value;\n savedEnabled.value = enabled.value;\n revision.value = workflow.revision;\n showNotice(`Saved ${workflow.id} as draft revision ${workflow.revision}.`);\n if (!initialId) history.replaceState(null, '', `/workflows/${workflow.id}`);\n });\n const publish = () =>\n 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 revision.value = workflow.revision;\n showNotice(`Published ${workflow.id} revision ${workflow.revision}.`);\n });\n const remove = () =>\n 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 = () =>\n run(async () => {\n const name = secretName.value.trim();\n if (!name || (fileMode.value ? !secretFileData.value : !secretValue.value)) throw new Error(fileMode.value ? 'Choose a file first.' : 'Set a secret value first.');\n await api(`/api/secrets/${name}`, {\n method: 'PUT',\n body: JSON.stringify(fileMode.value ? { value: secretFileData.value, encoding: 'base64', originalName: secretFileName.value } : { value: secretValue.value, encoding: 'utf8' }),\n });\n secretValue.value = '';\n secretFileName.value = '';\n secretFileData.value = '';\n fileMode.value = false;\n if (secretFileInput.value) secretFileInput.value.value = '';\n if (secretForm.value) secretForm.value.open = false;\n await loadSecrets();\n showNotice(`Saved ${name}.`);\n });\n const removeSecret = (requestedName = '') =>\n run(async () => {\n const name = requestedName || secretName.value.trim();\n if (!name || !confirm(`Delete ${name}?`)) return;\n await api(`/api/secrets/${name}`, { method: 'DELETE' });\n if (secretName.value.trim() === name) {\n secretName.value = '';\n secretValue.value = '';\n secretFileName.value = '';\n secretFileData.value = '';\n fileMode.value = false;\n if (secretFileInput.value) secretFileInput.value.value = '';\n }\n await loadSecrets();\n showNotice(`Deleted ${name}.`);\n });\n const setSource = (value) => {\n source.value = value;\n validate();\n };\n const setWorkflowId = (event) => {\n workflowId.value = event.target.value;\n };\n const setEnabled = (event) => {\n enabled.value = event.target.checked;\n };\n const setSecretName = (event) => {\n secretName.value = event.target.value;\n };\n const setSecretValue = (event) => {\n secretValue.value = event.target.value;\n fileMode.value = false;\n if (secretValue.value) {\n secretFileName.value = '';\n secretFileData.value = '';\n if (secretFileInput.value) secretFileInput.value.value = '';\n }\n };\n const setFileMode = (event) => {\n fileMode.value = event.target.checked;\n if (!fileMode.value) {\n secretFileName.value = '';\n secretFileData.value = '';\n if (secretFileInput.value) secretFileInput.value.value = '';\n } else {\n secretValue.value = '';\n }\n };\n const setSecretFile = async (event) => {\n const file = event.target.files?.[0];\n if (!file) return;\n const bytes = new Uint8Array(await file.arrayBuffer());\n let binary = '';\n for (let index = 0; index < bytes.length; index += 0x8000) {\n binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));\n }\n secretFileName.value = file.name;\n secretFileData.value = btoa(binary);\n secretValue.value = '';\n };\n const selectSecret = (name) => {\n secretName.value = name;\n secretValueInput.value?.focus();\n };\n const visitWorkflow = (id) => {\n window.location.href = `/workflows/${id}`;\n };\n const loadHelp = async (event) => {\n if (!event.target.open || helpLoaded.value || helpLoading.value || !helpContent.value) return;\n helpLoading.value = true;\n helpContent.value.textContent = 'Loading workflow syntax help...';\n try {\n const response = await fetch('/help?embed=1', { headers: { accept: 'text/html' } });\n if (!response.ok) throw new Error(`Help request failed: ${response.status}`);\n const html = await response.text();\n const parsed = new DOMParser().parseFromString(html, 'text/html');\n const sourceNodes = [\n ...Array.from(parsed.head.querySelectorAll('style')),\n ...Array.from(parsed.body.childNodes),\n ];\n const nodes = sourceNodes.map((node) => document.importNode(node, true));\n helpContent.value.replaceChildren(...nodes);\n helpLoaded.value = true;\n } catch (error) {\n helpContent.value.textContent = 'Unable to load workflow syntax help.';\n console.error(error);\n } finally {\n helpLoading.value = false;\n }\n };\n\n const escapeRevisionHtml = (value) => value.replace(/[&<>\"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[character] || '');\n const renderRevisionDiff = (previousSource, currentSource) => {\n if (previousSource === null) return `<span class=\"text-emerald-300\">+ ${escapeRevisionHtml(currentSource)}</span>\\n`;\n return diffLines(previousSource, currentSource).map((part) => {\n const className = part.added ? 'text-emerald-300 bg-emerald-500/10' : part.removed ? 'text-rose-300 bg-rose-500/10' : 'text-gray-300';\n const prefix = part.added ? '+ ' : part.removed ? '- ' : ' ';\n return part.value.split('\\n').filter((line, index, lines) => index < lines.length - 1 || line).map((line) => `<span class=\"block ${className}\">${prefix}${escapeRevisionHtml(line)}</span>`).join('');\n }).join('');\n };\n const openRevisionDialog = async () => {\n if (!revision.value) return;\n revisionNumber.value = revision.value;\n revisionDiffHtml.value = '';\n revisionError.value = '';\n revisionDialogOpen.value = true;\n await loadRevisionDiff(revision.value);\n };\n const closeRevisionDialog = () => {\n revisionDialogOpen.value = false;\n };\n const loadRevisionDiff = async (number) => {\n revisionLoading.value = true;\n revisionError.value = '';\n try {\n const current = await api(`/api/workflows/${selectedId.value}?revision=${number}`);\n const previous = number > 1 ? await api(`/api/workflows/${selectedId.value}?revision=${number - 1}`) : null;\n revisionDiffHtml.value = renderRevisionDiff(previous?.sourceYaml ?? null, current.sourceYaml);\n } catch (error) {\n revisionError.value = error.message;\n } finally {\n revisionLoading.value = false;\n }\n };\n const navigateRevision = async (offset) => {\n if (!selectedId.value || revisionLoading.value) return;\n const target = revisionNumber.value + offset;\n if (target < 1 || target > revision.value) return;\n revisionNumber.value = target;\n await loadRevisionDiff(target);\n };\n const openAiHelp = () => {\n aiRequest.value = '';\n aiError.value = '';\n aiSuggestion.value = '';\n aiDiffHtml.value = '';\n aiDialogOpen.value = true;\n };\n const closeAiHelp = () => { if (!aiLoading.value) aiDialogOpen.value = false; };\n const requestAiHelp = async () => {\n if (!aiRequest.value.trim() || aiLoading.value) return;\n aiLoading.value = true;\n aiError.value = '';\n aiDiffHtml.value = '';\n try {\n const response = await apiFetch('/api/ai/workflow-help', {\n method: 'POST',\n headers: { accept: 'text/event-stream', 'content-type': 'application/json' },\n body: JSON.stringify({ sourceYaml: source.value, request: aiRequest.value }),\n });\n if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.error || `AI help failed: ${response.status}`); }\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let suggestion = '';\n while (true) {\n const chunk = await reader.read();\n if (chunk.done) break;\n buffer += decoder.decode(chunk.value, { stream: true });\n const events = buffer.split('\\n\\n');\n buffer = events.pop() || '';\n for (const event of events) {\n const line = event.split('\\n').find((value) => value.startsWith('data: '));\n if (!line) continue;\n const data = JSON.parse(line.slice(6));\n if (data.error) throw new Error(data.error);\n if (data.delta) suggestion += data.delta;\n }\n }\n suggestion = suggestion.replace(/^```(?:yaml|yml)?\\s*/i, '').replace(/\\s*```$/i, '').trim();\n if (!source.value.trim()) {\n source.value = suggestion;\n aiDialogOpen.value = false;\n validate();\n } else {\n aiSuggestion.value = suggestion;\n aiDiffHtml.value = renderRevisionDiff(source.value, suggestion);\n }\n } catch (error) {\n aiError.value = error.message;\n } finally {\n aiLoading.value = false;\n }\n };\n const acceptAiSuggestion = () => { source.value = aiSuggestion.value; aiDialogOpen.value = false; validate(); };\n const rejectAiSuggestion = () => { aiSuggestion.value = ''; aiDiffHtml.value = ''; };\n\n onDestroy(() => clearTimeout(validationTimer));\n\n onInit(() => {\n const loadPage = page === 'editor'\n ? initialId ? openWorkflow(initialId) : Promise.resolve()\n : page === 'secrets' ? loadSecrets() : loadWorkflows();\n loadPage.catch((error) => showNotice(error.message, true));\n });\n\n return {\n page,\n workflows,\n secrets,\n source,\n validation,\n revision,\n revisionDialogOpen,\n revisionNumber,\n revisionDiffHtml,\n revisionError,\n revisionLoading,\n aiDialogOpen,\n aiRequest,\n aiLoading,\n aiError,\n aiDiffHtml,\n workflowId,\n enabled,\n readOnly,\n selectedId,\n secretName,\n secretValue,\n fileMode,\n secretFileName,\n secretFileData,\n notice,\n noticeError,\n busy,\n validate,\n save,\n publish,\n remove,\n saveSecret,\n removeSecret,\n setSource,\n setWorkflowId,\n setEnabled,\n setSecretName,\n setSecretValue,\n setFileMode,\n setSecretFile,\n selectSecret,\n visitWorkflow,\n loadHelp,\n openRevisionDialog,\n closeRevisionDialog,\n navigateRevision,\n openAiHelp,\n closeAiHelp,\n requestAiHelp,\n acceptAiSuggestion,\n rejectAiSuggestion,\n };\n }\n \n", ag = 50, og = 500, sg = class e {
|
|
12237
|
+
var Qh = "let token = sessionStorage.getItem('runner-api-token') || '';\nlet expiresAt = token ? Number.MAX_SAFE_INTEGER : 0;\n\nconst rawFetch = window.fetch.bind(window);\n\nasync function loadToken() {\n if (token && expiresAt > Date.now() + 30_000) return token;\n const response = await rawFetch('/api/auth/token', { headers: { accept: 'application/json' } });\n if (!response.ok) return '';\n const data = await response.json();\n token = data.access_token || '';\n expiresAt = Number(data.expires_at || 0);\n return token;\n}\n\nexport async function apiFetch(input, init = {}) {\n const target = new URL(input, window.location.href);\n if (target.origin !== window.location.origin || (!target.pathname.startsWith('/api/') && !target.pathname.startsWith('/restart/'))) {\n return rawFetch(input, init);\n }\n const headers = new Headers(init.headers);\n const accessToken = await loadToken();\n if (accessToken) headers.set('authorization', `Bearer ${accessToken}`);\n return rawFetch(input, { ...init, headers });\n}\n\nexport function setApiToken(value) {\n token = value || '';\n expiresAt = token ? Number.MAX_SAFE_INTEGER : 0;\n if (token) sessionStorage.setItem('runner-api-token', token);\n else sessionStorage.removeItem('runner-api-token');\n}\n", $h = "import { defineProp, onInit, ref } from '@li3/web';\n\nexport default function () {\n const authConfigured = ref(false);\n const authenticated = ref(false);\n const userName = ref('');\n onInit(async () => {\n try {\n const response = await fetch('/api/auth/session');\n if (!response.ok) return;\n const session = await response.json();\n authConfigured.value = session.configured;\n authenticated.value = session.authenticated;\n userName.value = session.user?.name || session.user?.email || '';\n } catch {}\n });\n return { title: defineProp('title'), subtitle: defineProp('subtitle'), back: defineProp('back'), backLabel: defineProp('back-label'), authConfigured, authenticated, userName };\n}\n", eg = "import { getElement, load, onDestroy, onInit, templateRef } from '@li3/web';\n\nexport default function () {\n const outlet = templateRef('outlet');\n let navigating = false;\n const route = () => {\n const url = new URL(window.location.href);\n const path = url.pathname;\n if (path === '/' || path === '/runs') return { component: 'page-dashboard', url: '/pages/dashboard.html', page: 'dashboard' };\n if (path.match(/^\\/runs\\/\\d+$/)) return { component: 'page-run', url: `/pages/run.html?jobId=${path.split('/').pop()}`, page: 'run' };\n if (path === '/help') return { component: 'page-help', url: '/pages/help.html', page: 'help' };\n if (path === '/settings/workflows') return { component: 'page-workflows', url: '/pages/workflows.html?page=workflows', page: 'workflows' };\n if (path === '/settings/secrets') return { component: 'page-secrets', url: '/pages/workflows.html?page=secrets', page: 'secrets' };\n if (path === '/settings/tokens') return { component: 'page-settings', url: '/pages/settings.html?page=tokens', page: 'tokens' };\n if (path === '/settings/notifications') return { component: 'page-settings', url: '/pages/settings.html?page=notifications', page: 'notifications' };\n if (path === '/settings/workers') return { component: 'page-settings', url: '/pages/settings.html?page=workers', page: 'workers' };\n if (path === '/settings' || path === '/workflows') return { component: 'page-workflows', url: '/pages/workflows.html?page=workflows', page: 'workflows' };\n const editor = path.match(/^\\/settings\\/workflows\\/(new|[a-z0-9-]+)$/);\n if (editor) return { component: 'page-workflow-editor', url: `/pages/workflows.html?page=editor&id=${editor[1]}${url.searchParams.get('revision') ? `&revision=${url.searchParams.get('revision')}` : ''}`, page: 'editor' };\n const legacyEditor = path.match(/^\\/workflows\\/(new|[a-z0-9-]+)$/);\n if (legacyEditor) return { component: 'page-workflow-editor', url: `/pages/workflows.html?page=editor&id=${legacyEditor[1]}${url.searchParams.get('revision') ? `&revision=${url.searchParams.get('revision')}` : ''}`, page: 'editor' };\n return { component: 'page-dashboard', url: '/pages/dashboard.html', page: 'dashboard' };\n };\n const navigate = async (replace = false) => {\n if (navigating) return;\n navigating = true;\n try {\n const current = route();\n document.body.dataset.page = current.page;\n await load(current.url);\n outlet.value.replaceChildren(document.createElement(current.component));\n if (replace) history.replaceState(null, '', window.location.href);\n } catch (error) {\n outlet.value.innerHTML = `<main class=\"mx-auto max-w-4xl p-8\"><h1 class=\"text-xl font-semibold text-white\">Unable to load page</h1><p class=\"mt-2 text-sm text-rose-300\">${String(error.message || error)}</p></main>`;\n } finally {\n navigating = false;\n }\n };\n const handleClick = (event) => {\n const anchor = event.target.closest?.('a');\n if (!anchor || anchor.target || anchor.hasAttribute('download')) return;\n const target = new URL(anchor.href, window.location.href);\n if (target.origin !== window.location.origin || !target.pathname.startsWith('/') || target.pathname.startsWith('/auth/')) return;\n if (target.pathname.startsWith('/api/') || target.pathname.startsWith('/webhooks/')) return;\n if (target.pathname === window.location.pathname && target.hash) return;\n event.preventDefault();\n history.pushState(null, '', `${target.pathname}${target.search}${target.hash}`);\n void navigate();\n };\n const element = getElement();\n onInit(() => {\n element.addEventListener('click', handleClick);\n window.addEventListener('popstate', navigate);\n void navigate(true);\n });\n onDestroy(() => {\n element.removeEventListener('click', handleClick);\n window.removeEventListener('popstate', navigate);\n });\n return {};\n}\n", tg = "import { onDestroy, onInit, ref } from '@li3/web';\nimport { apiFetch } from '@app/api-client.mjs';\n\nexport default function setup() {\n const jobs = ref([]), refreshError = ref(false), hasMore = ref(false), loadingMore = ref(false);\n const filter = ref(''), activeFilter = ref(''), pushConfigured = ref(false);\n const notificationsSupported = ref('Notification' in window && 'serviceWorker' in navigator);\n const notificationsEnabled = ref(notificationsSupported.value && Notification.permission === 'granted' && localStorage.getItem('runner-notifications') === 'enabled');\n const notificationNotice = ref('');\n let refreshTimer, eventSource, refreshing = false, refreshPending = false, searchInProgress = false, refreshGeneration = 0;\n const upper = (value) => String(value || '').toUpperCase();\n const terminalStatuses = new Set(['success', 'failed', 'cancelled']);\n const notifyCompletedJob = async (job) => {\n if (!notificationsEnabled.value || pushConfigured.value || !['success', 'failed'].includes(job.status)) return;\n try {\n const registration = await navigator.serviceWorker.ready;\n await registration.showNotification(job.status === 'success' ? `Job #${job.id} succeeded` : `Job #${job.id} failed`, { body: `${job.workflowId} finished with status ${job.status}.`, icon: '/app-icon.svg', badge: '/app-icon.svg', tag: `runner-job-${job.id}`, renotify: true, data: { url: `/runs/${job.id}` } });\n } catch (error) { console.error('Unable to show job notification', error); }\n };\n const urlBase64ToUint8Array = (value) => { const padding = '='.repeat((4 - (value.length % 4)) % 4); const raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from([...raw].map((character) => character.charCodeAt(0))); };\n const enablePushNotifications = async () => {\n try {\n if (!notificationsSupported.value) return;\n const response = await apiFetch('/api/push/public-key', { headers: { accept: 'application/json' } });\n if (!response.ok) return;\n const { publicKey } = await response.json(); pushConfigured.value = true;\n const registration = await navigator.serviceWorker.ready;\n const subscription = (await registration.pushManager.getSubscription()) || await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });\n const saved = await apiFetch('/api/push/subscriptions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(subscription) });\n if (!saved.ok) throw new Error(`Push subscription failed: ${saved.status}`);\n } catch (error) { console.error('Unable to enable background notifications', error); }\n };\n const disablePushNotifications = async () => {\n try { const subscription = await (await navigator.serviceWorker.ready).pushManager.getSubscription(); if (!subscription) return; await apiFetch('/api/push/subscriptions', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ endpoint: subscription.endpoint }) }); await subscription.unsubscribe(); }\n catch (error) { console.error('Unable to disable background notifications', error); }\n };\n const toggleNotifications = async () => {\n if (notificationsEnabled.value) { notificationsEnabled.value = false; localStorage.removeItem('runner-notifications'); await disablePushNotifications(); notificationNotice.value = 'Job notifications are paused on this device.'; return; }\n if (Notification.permission === 'denied') { notificationNotice.value = 'Notifications are blocked. Allow them in this site’s Android browser settings.'; return; }\n notificationsEnabled.value = (await Notification.requestPermission()) === 'granted';\n if (notificationsEnabled.value) { localStorage.setItem('runner-notifications', 'enabled'); await enablePushNotifications(); notificationNotice.value = 'Notifications enabled. Background delivery is available when server push is configured.'; } else notificationNotice.value = 'Notification permission was not granted.';\n };\n const refreshJobs = async (isSearch = false) => {\n if (searchInProgress && !isSearch) return; if (refreshing && !isSearch) { refreshPending = true; return; }\n refreshing = true; const generation = refreshGeneration;\n try {\n const activeIds = jobs.value.filter((job) => !terminalStatuses.has(job.status)).map((job) => Number(job.id));\n const knownIds = jobs.value.map((job) => Number(job.id));\n const afterId = activeIds.length ? Math.max(0, Math.min(...activeIds) - 1) : knownIds.length ? Math.max(...knownIds) : null;\n const filterQuery = activeFilter.value ? `&filter=${encodeURIComponent(activeFilter.value)}` : '';\n const url = afterId === null ? `/api/jobs?limit=100${filterQuery}` : `/api/jobs?afterId=${afterId}&limit=100${filterQuery}`;\n const response = await apiFetch(url, { headers: { accept: 'application/json' } }); if (!response.ok) throw new Error(`Dashboard refresh failed: ${response.status}`);\n const data = await response.json(); if (generation !== refreshGeneration) return; hasMore.value = data.hasMore;\n const previousStatuses = new Map(jobs.value.map((job) => [job.id, job.status])); const merged = new Map(jobs.value.map((job) => [job.id, job]));\n for (const job of data.jobs) { const previousStatus = previousStatuses.get(job.id); merged.set(job.id, job); if (previousStatus && !terminalStatuses.has(previousStatus) && terminalStatuses.has(job.status)) void notifyCompletedJob(job); }\n jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id)); refreshError.value = false;\n } catch (error) { console.error(error); refreshError.value = true; } finally { refreshing = false; if (refreshPending && !searchInProgress) { refreshPending = false; void refreshJobs(); } }\n };\n const loadMore = async () => { if (loadingMore.value || !hasMore.value || !jobs.value.length) return; loadingMore.value = true; try { const beforeId = Math.min(...jobs.value.map((job) => Number(job.id))); const filterQuery = activeFilter.value ? `&filter=${encodeURIComponent(activeFilter.value)}` : ''; const response = await apiFetch(`/api/jobs?beforeId=${beforeId}${filterQuery}`, { headers: { accept: 'application/json' } }); if (!response.ok) throw new Error(`Loading older jobs failed: ${response.status}`); const data = await response.json(); const merged = new Map(jobs.value.map((job) => [job.id, job])); for (const job of data.jobs) merged.set(job.id, job); jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id)); hasMore.value = data.hasMore; } catch (error) { console.error(error); refreshError.value = true; } finally { loadingMore.value = false; } };\n const updateSearchUrl = (value) => { const params = new URLSearchParams(window.location.search); if (value) params.set('search', value); else params.delete('search'); const query = params.toString(); history.pushState(null, '', `${window.location.pathname}${query ? `?${query}` : ''}`); };\n const applyFilter = async () => { searchInProgress = true; refreshGeneration++; activeFilter.value = filter.value.trim(); updateSearchUrl(activeFilter.value); jobs.value = []; hasMore.value = false; try { await refreshJobs(true); } finally { searchInProgress = false; refreshPending = false; } };\n const clearFilter = async () => { searchInProgress = true; refreshGeneration++; filter.value = ''; activeFilter.value = ''; updateSearchUrl(''); jobs.value = []; hasMore.value = false; try { await refreshJobs(true); } finally { searchInProgress = false; refreshPending = false; } };\n const setFilter = (event) => { filter.value = event.target.value; };\n onInit(() => { const initialSearch = new URLSearchParams(window.location.search).get('search')?.trim() || ''; filter.value = initialSearch; activeFilter.value = initialSearch; if (notificationsSupported.value) navigator.serviceWorker.register('/service-worker.js').then(() => { if (notificationsEnabled.value) void enablePushNotifications(); }).catch((error) => console.error('Service worker registration failed', error)); eventSource = new EventSource('/api/events'); eventSource.addEventListener('jobs.available', refreshJobs); eventSource.addEventListener('jobs.changed', refreshJobs); eventSource.onerror = () => { refreshError.value = true; }; void refreshJobs(); refreshTimer = setInterval(refreshJobs, 60000); });\n onDestroy(() => { eventSource?.close(); clearInterval(refreshTimer); });\n return { jobs, refreshError, hasMore, loadingMore, filter, activeFilter, pushConfigured, setFilter, loadMore, applyFilter, clearFilter, upper, notificationsSupported, notificationsEnabled, notificationNotice, toggleNotifications };\n}\n", ng = "\n import { computed, onDestroy, onInit, ref } from '@li3/web';\n import { AnsiUp } from 'ansi_up';\n import { marked } from 'marked';\n import { apiFetch } from '@app/api-client.mjs';\n\n export default function setup() {\n const jobId = window.location.pathname.split('/').filter(Boolean).pop() || '';\n const report = ref({ jobId });\n const previousRuns = ref([]);\n const aiMessages = ref([]);\n const aiStepId = ref('');\n const aiQuestion = ref('');\n const aiError = ref('');\n const aiLoading = ref(false);\n const manualRestartOpen = ref(false);\n const manualRestartLoading = ref(false);\n const manualInputs = ref([]);\n let previousRunsLoaded = false;\n const now = ref(Date.now());\n const ansiUp = new AnsiUp();\n ansiUp.use_classes = false;\n let eventSource;\n let refreshTimer;\n let clockTimer;\n let refreshing = false;\n let refreshPending = false;\n const cancelling = ref(false);\n\n const active = computed(() => ['pending', 'running'].includes(report.value.status));\n const inputsJson = computed(() => JSON.stringify(report.value.inputs || {}, null, 2));\n const originalInputsJson = computed(() => JSON.stringify(report.value.inputs || {}, null, 2));\n const timing = computed(() => {\n if (report.value.status === 'pending') return 'Waiting for a worker';\n if (report.value.status === 'running') {\n return `Running for ${Math.max(0, now.value - Date.parse(report.value.startedAt))}ms`;\n }\n return `Finished in ${report.value.durationMs}ms`;\n });\n\n const upper = (value) => String(value || '').toUpperCase();\n const artifactUrl = (path) => `/api/runs/${report.value.jobId}/artifacts/${encodeURIComponent(path)}`;\n const downloadArtifact = async (event, path) => {\n event.preventDefault();\n const response = await apiFetch(artifactUrl(path));\n if (!response.ok) return;\n const link = document.createElement('a');\n link.href = URL.createObjectURL(await response.blob());\n link.download = path.split('/').pop() || 'artifact';\n link.click();\n URL.revokeObjectURL(link.href);\n };\n const loadPreviousRuns = async () => {\n if (previousRunsLoaded || !report.value.parentId) return;\n const lineage = [];\n const visited = new Set();\n let parentId = report.value.parentId;\n while (parentId && !visited.has(String(parentId)) && lineage.length < 50) {\n visited.add(String(parentId));\n const response = await apiFetch(`/api/runs/${parentId}`, { headers: { accept: 'application/json' } });\n if (!response.ok) throw new Error(`Previous run request failed: ${response.status}`);\n const parent = await response.json();\n lineage.push({ id: parent.jobId, status: parent.status, startedAt: parent.startedAt });\n parentId = parent.parentId;\n }\n previousRuns.value = lineage;\n previousRunsLoaded = true;\n };\n const stepLog = (step) => {\n if (step.status === 'skipped') return '';\n if (step.status === 'running')\n return '<span class=\"text-indigo-400\">Step is running. Logs will appear after it finishes.</span>';\n if (step.status === 'pending') return '<span class=\"text-gray-500\">Waiting to run.</span>';\n if (step.logContent) return ansiUp.ansi_to_html(step.logContent);\n return '<span class=\"text-gray-500\">(No terminal log output recorded for this step)</span>';\n };\n const sanitizeAiHtml = (html) => {\n const parsed = new DOMParser().parseFromString(html, 'text/html');\n parsed.querySelectorAll('script,style,iframe,object,embed,form').forEach((node) => node.remove());\n parsed.querySelectorAll('*').forEach((element) => {\n [...element.attributes].forEach((attribute) => {\n if (attribute.name.toLowerCase().startsWith('on')) element.removeAttribute(attribute.name);\n if (['href', 'src'].includes(attribute.name.toLowerCase()) && /^(javascript|data):/i.test(attribute.value)) element.removeAttribute(attribute.name);\n });\n });\n return parsed.body.innerHTML;\n };\n const renderAiMarkdown = (content) => sanitizeAiHtml(marked.parse(content));\n const askAi = async (step, event) => {\n event.stopPropagation();\n aiStepId.value = step.id;\n aiMessages.value = [];\n aiQuestion.value = '';\n await requestAiHelp(step.id, 'Please explain why this step failed and what I should do next.');\n };\n const requestAiHelp = async (stepId, question) => {\n aiLoading.value = true;\n aiError.value = '';\n try {\n const response = await apiFetch(`/api/runs/${report.value.jobId}/ai-help`, {\n method: 'POST',\n headers: { accept: 'application/json', 'content-type': 'application/json' },\n body: JSON.stringify({ stepId, question, conversation: aiMessages.value }),\n });\n if (!response.ok) {\n const data = await response.json().catch(() => ({}));\n throw new Error(data.error || `AI help failed: ${response.status}`);\n }\n aiMessages.value = [...aiMessages.value, { role: 'user', content: question }, { role: 'assistant', content: '' }];\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const chunk = await reader.read();\n if (chunk.done) break;\n buffer += decoder.decode(chunk.value, { stream: true });\n const events = buffer.split('\\n\\n');\n buffer = events.pop() || '';\n for (const event of events) {\n const line = event.split('\\n').find((value) => value.startsWith('data: '));\n if (!line) continue;\n const data = JSON.parse(line.slice(6));\n if (data.delta) {\n const messages = [...aiMessages.value];\n messages[messages.length - 1] = { ...messages[messages.length - 1], content: messages[messages.length - 1].content + data.delta };\n aiMessages.value = messages;\n }\n if (data.error) throw new Error(data.error);\n }\n }\n const messages = [...aiMessages.value];\n const answer = messages.at(-1);\n if (answer) messages[messages.length - 1] = { ...answer, html: renderAiMarkdown(answer.content) };\n aiMessages.value = messages;\n } catch (error) {\n aiError.value = error.message;\n } finally {\n aiLoading.value = false;\n }\n };\n const askFollowup = async () => {\n const question = aiQuestion.value.trim();\n if (!question || !aiStepId.value) return;\n aiQuestion.value = '';\n await requestAiHelp(aiStepId.value, question);\n };\n const restartJob = async () => {\n const response = await apiFetch(`/restart/${report.value.jobId}`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ inputs: report.value.inputs || {} }),\n });\n if (response.ok) {\n const { id } = await response.json();\n location.href = `/runs/${id}`;\n }\n };\n const openManualRestart = (event) => {\n event.preventDefault();\n event.stopPropagation();\n manualInputs.value = [{ key: '', value: '' }];\n manualRestartOpen.value = true;\n };\n const closeManualRestart = () => { manualRestartOpen.value = false; };\n const addManualInput = () => { manualInputs.value = [...manualInputs.value, { key: '', value: '' }]; };\n const removeManualInput = (entry) => { manualInputs.value = manualInputs.value.filter((item) => item !== entry); };\n const setManualKey = (entry, event) => { entry.key = event.target.value; manualInputs.value = [...manualInputs.value]; };\n const setManualValue = (entry, event) => { entry.value = event.target.value; manualInputs.value = [...manualInputs.value]; };\n const restartWithInputs = async () => {\n const inputs = Object.fromEntries(manualInputs.value.filter((entry) => entry.key.trim()).map((entry) => [entry.key.trim(), entry.value]));\n manualRestartLoading.value = true;\n try {\n const response = await apiFetch(`/restart/${report.value.jobId}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ inputs }) });\n if (!response.ok) throw new Error(`Restart failed: ${response.status}`);\n const { id } = await response.json();\n location.href = `/runs/${id}`;\n } catch (error) {\n aiError.value = error.message;\n } finally {\n manualRestartLoading.value = false;\n }\n };\n const cancelJob = async () => {\n if (cancelling.value || report.value.status !== 'running') return;\n cancelling.value = true;\n try {\n const response = await apiFetch(`/api/jobs/${report.value.jobId}/cancel`, { method: 'POST' });\n if (!response.ok) throw new Error(`Cancel failed: ${response.status}`);\n await refreshRun();\n } catch (error) {\n console.error(error);\n } finally {\n cancelling.value = false;\n }\n };\n const refreshRun = async () => {\n if (refreshing) {\n refreshPending = true;\n return;\n }\n refreshing = true;\n\n try {\n const response = await apiFetch(`/api/runs/${jobId}`, {\n headers: { accept: 'application/json' },\n });\n if (!response.ok) throw new Error(`Run refresh failed: ${response.status}`);\n const previousStatus = report.value.status;\n const nextReport = await response.json();\n report.value = nextReport;\n document.title = `Run #${nextReport.jobId} - ${nextReport.workflowName}`;\n void loadPreviousRuns();\n if (\n ['pending', 'running'].includes(previousStatus) &&\n ['success', 'failed'].includes(nextReport.status) &&\n 'Notification' in window &&\n 'serviceWorker' in navigator &&\n Notification.permission === 'granted' &&\n localStorage.getItem('runner-notifications') === 'enabled'\n ) {\n const registration = await navigator.serviceWorker.ready;\n await registration.showNotification(\n nextReport.status === 'success'\n ? `Job #${nextReport.jobId} succeeded`\n : `Job #${nextReport.jobId} failed`,\n {\n body: `${nextReport.workflowName} finished with status ${nextReport.status}.`,\n icon: '/app-icon.svg',\n badge: '/app-icon.svg',\n tag: `runner-job-${nextReport.jobId}`,\n renotify: true,\n data: { url: `/runs/${nextReport.jobId}` },\n },\n );\n }\n } catch (error) {\n console.error(error);\n } finally {\n refreshing = false;\n if (refreshPending) {\n refreshPending = false;\n void refreshRun();\n }\n }\n };\n const handleJobChange = (event) => {\n const data = JSON.parse(event.data || '{}');\n if (!data.jobId || String(data.jobId) === String(report.value.jobId)) void refreshRun();\n };\n\n onInit(() => {\n void refreshRun();\n if ('serviceWorker' in navigator) {\n navigator.serviceWorker.register('/service-worker.js').catch((error) => {\n console.error('Service worker registration failed', error);\n });\n }\n eventSource = new EventSource('/api/events');\n eventSource.addEventListener('jobs.changed', handleJobChange);\n refreshTimer = setInterval(() => {\n if (active.value) void refreshRun();\n }, 60000);\n clockTimer = setInterval(() => {\n now.value = Date.now();\n }, 1000);\n });\n onDestroy(() => {\n eventSource?.close();\n clearInterval(refreshTimer);\n clearInterval(clockTimer);\n });\n\n return { report, previousRuns, manualRestartOpen, manualRestartLoading, manualInputs, aiMessages, aiStepId, aiQuestion, aiError, aiLoading, inputsJson, originalInputsJson, timing, upper, artifactUrl, downloadArtifact, stepLog, askAi, askFollowup, restartJob, openManualRestart, closeManualRestart, addManualInput, removeManualInput, setManualKey, setManualValue, restartWithInputs, cancelJob, cancelling };\n }\n \n", rg = "\n import { onInit, ref } from '@li3/web';\n import { apiFetch, setApiToken } from '@app/api-client.mjs';\n export default function () {\n const page = document.body.dataset.page;\n const keys = ref([]);\n const workers = ref([]);\n const keyName = ref('');\n const newToken = ref('');\n const error = ref('');\n const notificationsSupported = ref('Notification' in window && 'serviceWorker' in navigator);\n const notificationsEnabled = ref(notificationsSupported.value && Notification.permission === 'granted' && localStorage.getItem('runner-notifications') === 'enabled');\n const notificationMessage = ref('');\n const scopeOptions = ref([\n { name: 'workflows:read', label: 'Read workflows', selected: true },\n { name: 'workflows:write', label: 'Modify workflows', selected: false },\n { name: 'logs:read', label: 'Read logs', selected: false },\n { name: 'artifacts:read', label: 'Read artifacts', selected: false },\n { name: 'runs:control', label: 'Control runs', selected: false },\n { name: 'runs:dispatch', label: 'Dispatch runs', selected: false },\n ]);\n const api = async (url, options = {}) => { const response = await apiFetch(url, { headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) }, ...options }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`); return body; };\n const load = async () => { keys.value = (await api('/api/api-keys')).keys; };\n const loadWorkers = async () => { workers.value = (await api('/api/workers')).workers; };\n const issueKey = async () => { try { error.value = ''; const result = await api('/api/api-keys', { method: 'POST', body: JSON.stringify({ name: keyName.value, scopes: scopeOptions.value.filter((scope) => scope.selected).map((scope) => scope.name) }) }); newToken.value = result.token; setApiToken(result.token); keyName.value = ''; await load(); } catch (reason) { error.value = reason.message; } };\n const revokeKey = async (key) => { if (!confirm(`Revoke ${key.name}?`)) return; try { await api(`/api/api-keys/${key.id}`, { method: 'DELETE' }); await load(); } catch (reason) { error.value = reason.message; } };\n const setKeyName = (event) => { keyName.value = event.target.value; };\n const toggleScope = (scope) => { scope.selected = !scope.selected; };\n const toggleNotifications = async () => {\n if (notificationsEnabled.value) {\n notificationsEnabled.value = false;\n localStorage.removeItem('runner-notifications');\n notificationMessage.value = 'Notifications disabled on this device.';\n return;\n }\n if (!notificationsSupported.value) { notificationMessage.value = 'Notifications are not supported by this browser.'; return; }\n const permission = await Notification.requestPermission();\n notificationsEnabled.value = permission === 'granted';\n if (notificationsEnabled.value) { localStorage.setItem('runner-notifications', 'enabled'); notificationMessage.value = 'Notifications enabled.'; }\n else notificationMessage.value = 'Notification permission was not granted.';\n };\n onInit(() => { if (page === 'tokens') load().catch((reason) => { error.value = reason.message; }); if (page === 'workers') loadWorkers().catch((reason) => { error.value = reason.message; }); });\n return { page, keys, workers, keyName, newToken, error, scopeOptions, issueKey, revokeKey, setKeyName, toggleScope, notificationsEnabled, notificationMessage, toggleNotifications };\n }\n \n", ig = "\n import { onDestroy, onInit, ref, templateRef } from '@li3/web';\n import { apiFetch } from '@app/api-client.mjs';\n import { diffLines } from 'diff';\n\n export default function () {\n const page = document.body.dataset.page;\n const params = new URLSearchParams(window.location.search);\n const initialId = params.get('id') || '';\n const initialRevision = params.get('revision') ? Number(params.get('revision')) : null;\n const readOnly = Boolean(initialRevision);\n const workflows = ref([]);\n const secrets = ref([]);\n const source = ref('');\n const validation = ref({ valid: true });\n const savedSource = ref('');\n const revision = ref(null);\n const revisionDialogOpen = ref(false);\n const revisionNumber = ref(0);\n const revisionDiffHtml = ref('');\n const revisionError = ref('');\n const revisionLoading = ref(false);\n const aiDialogOpen = ref(false);\n const aiRequest = ref('');\n const aiLoading = ref(false);\n const aiError = ref('');\n const aiSuggestion = ref('');\n const aiDiffHtml = ref('');\n const workflowId = ref('');\n const enabled = ref(true);\n const savedEnabled = ref(true);\n const selectedId = ref('');\n const secretName = ref('');\n const secretValue = ref('');\n const fileMode = ref(false);\n const secretFileName = ref('');\n const secretFileData = ref('');\n const notice = ref('');\n const noticeError = ref(false);\n const busy = ref(false);\n const secretValueInput = templateRef('secretValueInput');\n const secretFileInput = templateRef('secretFileInput');\n const secretForm = templateRef('secretForm');\n const helpContent = templateRef('helpContent');\n const helpLoaded = ref(false);\n const helpLoading = ref(false);\n let validationTimer;\n\n const showNotice = (message, error = false) => {\n notice.value = message;\n noticeError.value = error;\n };\n\n const api = async (url, options = {}) => {\n const response = await apiFetch(url, {\n headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) },\n ...options,\n });\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\n const derivedId = () =>\n source.value\n .match(/^\\s*(?:id:\\s*([^\\n]+)|name:\\s*([^\\n]+))/m)\n ?.slice(1)\n .find(Boolean)\n ?.trim()\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '-')\n .replace(/(^-|-$)/g, '') || '';\n\n const run = async (action) => {\n if (busy.value) return;\n\n busy.value = true;\n\n try {\n await action();\n } catch (error) {\n showNotice(error.message, true);\n } finally {\n busy.value = false;\n }\n };\n\n const loadWorkflows = async () => {\n workflows.value = (await api('/api/workflows')).workflows;\n };\n\n const loadSecrets = async () => {\n secrets.value = (await api('/api/secrets')).secrets;\n };\n\n const openWorkflow = async (id) => {\n const workflowUrl = initialRevision\n ? `/api/workflows/${id}?revision=${initialRevision}`\n : `/api/workflows/${id}`;\n const workflow = await api(workflowUrl);\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n source.value = workflow.sourceYaml;\n savedSource.value = workflow.sourceYaml;\n enabled.value = workflow.enabled;\n savedEnabled.value = workflow.enabled;\n revision.value = workflow.revision || null;\n };\n\n const validateSource = async () => {\n return api('/api/workflows/validate', {\n method: 'POST',\n body: JSON.stringify({ sourceYaml: source.value }),\n });\n };\n\n const validate = (ms = 1000) => {\n if (page !== 'editor') return;\n\n clearTimeout(validationTimer);\n validationTimer = setTimeout(() => {\n validation.value = { ...validation.value, running: true, error: '' };\n void validateSource()\n .then((result) => {\n validation.value = { ...result, running: false };\n })\n .catch((error) => {\n validation.value = { valid: false, running: false, error: error.message };\n \n });\n }, ms);\n };\n\n const save = () =>\n 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 if (\n initialId &&\n id === selectedId.value &&\n source.value === savedSource.value &&\n enabled.value === savedEnabled.value\n ) {\n showNotice('No changes to save.');\n return;\n }\n const workflow = await api(`/api/workflows/${id}`, {\n method: 'PUT',\n body: JSON.stringify({ sourceYaml: source.value, enabled: enabled.value }),\n });\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n savedSource.value = source.value;\n savedEnabled.value = enabled.value;\n revision.value = workflow.revision;\n showNotice(`Saved ${workflow.id} as draft revision ${workflow.revision}.`);\n if (!initialId) history.replaceState(null, '', `/workflows/${workflow.id}`);\n });\n const publish = () =>\n 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 revision.value = workflow.revision;\n showNotice(`Published ${workflow.id} revision ${workflow.revision}.`);\n });\n const runNow = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id) throw new Error('Save the workflow before running it.');\n if (source.value !== savedSource.value) throw new Error('Save the latest draft before running it.');\n const result = await api(`/api/workflows/${id}/run`, {\n method: 'POST',\n body: JSON.stringify({}),\n });\n showNotice(`Queued ${result.jobs.length} job${result.jobs.length === 1 ? '' : 's'} from revision ${result.revision}.`);\n });\n const remove = () =>\n 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 = () =>\n run(async () => {\n const name = secretName.value.trim();\n if (!name || (fileMode.value ? !secretFileData.value : !secretValue.value)) throw new Error(fileMode.value ? 'Choose a file first.' : 'Set a secret value first.');\n await api(`/api/secrets/${name}`, {\n method: 'PUT',\n body: JSON.stringify(fileMode.value ? { value: secretFileData.value, encoding: 'base64', originalName: secretFileName.value } : { value: secretValue.value, encoding: 'utf8' }),\n });\n secretValue.value = '';\n secretFileName.value = '';\n secretFileData.value = '';\n fileMode.value = false;\n if (secretFileInput.value) secretFileInput.value.value = '';\n if (secretForm.value) secretForm.value.open = false;\n await loadSecrets();\n showNotice(`Saved ${name}.`);\n });\n const removeSecret = (requestedName = '') =>\n run(async () => {\n const name = requestedName || secretName.value.trim();\n if (!name || !confirm(`Delete ${name}?`)) return;\n await api(`/api/secrets/${name}`, { method: 'DELETE' });\n if (secretName.value.trim() === name) {\n secretName.value = '';\n secretValue.value = '';\n secretFileName.value = '';\n secretFileData.value = '';\n fileMode.value = false;\n if (secretFileInput.value) secretFileInput.value.value = '';\n }\n await loadSecrets();\n showNotice(`Deleted ${name}.`);\n });\n const setSource = (value) => {\n source.value = value;\n validate();\n };\n const setWorkflowId = (event) => {\n workflowId.value = event.target.value;\n };\n const setEnabled = (event) => {\n enabled.value = event.target.checked;\n };\n const setSecretName = (event) => {\n secretName.value = event.target.value;\n };\n const setSecretValue = (event) => {\n secretValue.value = event.target.value;\n fileMode.value = false;\n if (secretValue.value) {\n secretFileName.value = '';\n secretFileData.value = '';\n if (secretFileInput.value) secretFileInput.value.value = '';\n }\n };\n const setFileMode = (event) => {\n fileMode.value = event.target.checked;\n if (!fileMode.value) {\n secretFileName.value = '';\n secretFileData.value = '';\n if (secretFileInput.value) secretFileInput.value.value = '';\n } else {\n secretValue.value = '';\n }\n };\n const setSecretFile = async (event) => {\n const file = event.target.files?.[0];\n if (!file) return;\n const bytes = new Uint8Array(await file.arrayBuffer());\n let binary = '';\n for (let index = 0; index < bytes.length; index += 0x8000) {\n binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));\n }\n secretFileName.value = file.name;\n secretFileData.value = btoa(binary);\n secretValue.value = '';\n };\n const selectSecret = (name) => {\n secretName.value = name;\n secretValueInput.value?.focus();\n };\n const visitWorkflow = (id) => {\n window.location.href = `/workflows/${id}`;\n };\n const loadHelp = async (event) => {\n if (!event.target.open || helpLoaded.value || helpLoading.value || !helpContent.value) return;\n helpLoading.value = true;\n helpContent.value.textContent = 'Loading workflow syntax help...';\n try {\n const response = await fetch('/help?embed=1', { headers: { accept: 'text/html' } });\n if (!response.ok) throw new Error(`Help request failed: ${response.status}`);\n const html = await response.text();\n const parsed = new DOMParser().parseFromString(html, 'text/html');\n const sourceNodes = [\n ...Array.from(parsed.head.querySelectorAll('style')),\n ...Array.from(parsed.body.childNodes),\n ];\n const nodes = sourceNodes.map((node) => document.importNode(node, true));\n helpContent.value.replaceChildren(...nodes);\n helpLoaded.value = true;\n } catch (error) {\n helpContent.value.textContent = 'Unable to load workflow syntax help.';\n console.error(error);\n } finally {\n helpLoading.value = false;\n }\n };\n\n const escapeRevisionHtml = (value) => value.replace(/[&<>\"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[character] || '');\n const renderRevisionDiff = (previousSource, currentSource) => {\n if (previousSource === null) return `<span class=\"text-emerald-300\">+ ${escapeRevisionHtml(currentSource)}</span>\\n`;\n return diffLines(previousSource, currentSource).map((part) => {\n const className = part.added ? 'text-emerald-300 bg-emerald-500/10' : part.removed ? 'text-rose-300 bg-rose-500/10' : 'text-gray-300';\n const prefix = part.added ? '+ ' : part.removed ? '- ' : ' ';\n return part.value.split('\\n').filter((line, index, lines) => index < lines.length - 1 || line).map((line) => `<span class=\"block ${className}\">${prefix}${escapeRevisionHtml(line)}</span>`).join('');\n }).join('');\n };\n const openRevisionDialog = async () => {\n if (!revision.value) return;\n revisionNumber.value = revision.value;\n revisionDiffHtml.value = '';\n revisionError.value = '';\n revisionDialogOpen.value = true;\n await loadRevisionDiff(revision.value);\n };\n const closeRevisionDialog = () => {\n revisionDialogOpen.value = false;\n };\n const loadRevisionDiff = async (number) => {\n revisionLoading.value = true;\n revisionError.value = '';\n try {\n const current = await api(`/api/workflows/${selectedId.value}?revision=${number}`);\n const previous = number > 1 ? await api(`/api/workflows/${selectedId.value}?revision=${number - 1}`) : null;\n revisionDiffHtml.value = renderRevisionDiff(previous?.sourceYaml ?? null, current.sourceYaml);\n } catch (error) {\n revisionError.value = error.message;\n } finally {\n revisionLoading.value = false;\n }\n };\n const navigateRevision = async (offset) => {\n if (!selectedId.value || revisionLoading.value) return;\n const target = revisionNumber.value + offset;\n if (target < 1 || target > revision.value) return;\n revisionNumber.value = target;\n await loadRevisionDiff(target);\n };\n const openAiHelp = () => {\n aiRequest.value = '';\n aiError.value = '';\n aiSuggestion.value = '';\n aiDiffHtml.value = '';\n aiDialogOpen.value = true;\n };\n const closeAiHelp = () => { if (!aiLoading.value) aiDialogOpen.value = false; };\n const requestAiHelp = async () => {\n if (!aiRequest.value.trim() || aiLoading.value) return;\n aiLoading.value = true;\n aiError.value = '';\n aiDiffHtml.value = '';\n try {\n const response = await apiFetch('/api/ai/workflow-help', {\n method: 'POST',\n headers: { accept: 'text/event-stream', 'content-type': 'application/json' },\n body: JSON.stringify({ sourceYaml: source.value, request: aiRequest.value }),\n });\n if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.error || `AI help failed: ${response.status}`); }\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let suggestion = '';\n while (true) {\n const chunk = await reader.read();\n if (chunk.done) break;\n buffer += decoder.decode(chunk.value, { stream: true });\n const events = buffer.split('\\n\\n');\n buffer = events.pop() || '';\n for (const event of events) {\n const line = event.split('\\n').find((value) => value.startsWith('data: '));\n if (!line) continue;\n const data = JSON.parse(line.slice(6));\n if (data.error) throw new Error(data.error);\n if (data.delta) suggestion += data.delta;\n }\n }\n suggestion = suggestion.replace(/^```(?:yaml|yml)?\\s*/i, '').replace(/\\s*```$/i, '').trim();\n if (!source.value.trim()) {\n source.value = suggestion;\n aiDialogOpen.value = false;\n validate();\n } else {\n aiSuggestion.value = suggestion;\n aiDiffHtml.value = renderRevisionDiff(source.value, suggestion);\n }\n } catch (error) {\n aiError.value = error.message;\n } finally {\n aiLoading.value = false;\n }\n };\n const acceptAiSuggestion = () => { source.value = aiSuggestion.value; aiDialogOpen.value = false; validate(); };\n const rejectAiSuggestion = () => { aiSuggestion.value = ''; aiDiffHtml.value = ''; };\n\n onDestroy(() => clearTimeout(validationTimer));\n\n onInit(() => {\n const loadPage = page === 'editor'\n ? initialId ? openWorkflow(initialId) : Promise.resolve()\n : page === 'secrets' ? loadSecrets() : loadWorkflows();\n loadPage.catch((error) => showNotice(error.message, true));\n });\n\n return {\n page,\n workflows,\n secrets,\n source,\n validation,\n revision,\n revisionDialogOpen,\n revisionNumber,\n revisionDiffHtml,\n revisionError,\n revisionLoading,\n aiDialogOpen,\n aiRequest,\n aiLoading,\n aiError,\n aiDiffHtml,\n workflowId,\n enabled,\n readOnly,\n selectedId,\n secretName,\n secretValue,\n fileMode,\n secretFileName,\n secretFileData,\n notice,\n noticeError,\n busy,\n validate,\n save,\n publish,\n runNow,\n remove,\n saveSecret,\n removeSecret,\n setSource,\n setWorkflowId,\n setEnabled,\n setSecretName,\n setSecretValue,\n setFileMode,\n setSecretFile,\n selectSecret,\n visitWorkflow,\n loadHelp,\n openRevisionDialog,\n closeRevisionDialog,\n navigateRevision,\n openAiHelp,\n closeAiHelp,\n requestAiHelp,\n acceptAiSuggestion,\n rejectAiSuggestion,\n };\n }\n \n", ag = 50, og = 500, sg = class e {
|
|
12640
12238
|
server;
|
|
12641
12239
|
preprocessors = /* @__PURE__ */ new Map();
|
|
12642
12240
|
workflows = new u();
|
|
@@ -12817,25 +12415,27 @@ var Qh = "let token = sessionStorage.getItem('runner-api-token') || '';\nlet exp
|
|
|
12817
12415
|
}
|
|
12818
12416
|
let s = n.pathname.match(/^\/api\/dispatch\/([a-z0-9-]+)$/);
|
|
12819
12417
|
if (e.method === "POST" && s) return this.handleDispatch(e, t, s[1]);
|
|
12820
|
-
let c = n.pathname.match(/^\/api\/
|
|
12821
|
-
if (e.method === "
|
|
12418
|
+
let c = n.pathname.match(/^\/api\/workflows\/([a-z0-9-]+)\/run$/);
|
|
12419
|
+
if (e.method === "POST" && c) return this.handleWorkflowRun(e, t, c[1]);
|
|
12420
|
+
let l = n.pathname.match(/^\/api\/jobs\/(\d+)\/wait$/);
|
|
12421
|
+
if (e.method === "GET" && l) return this.handleJobWait(l[1], n.searchParams.get("timeout"), t);
|
|
12822
12422
|
if (e.method === "GET" && n.pathname === "/api/events") return this.events.subscribe(e, t);
|
|
12823
12423
|
if (e.method === "GET" && n.pathname === "/api/push/public-key") return this.handlePushPublicKey(t);
|
|
12824
12424
|
if (e.method === "POST" && n.pathname === "/api/push/subscriptions") return this.handlePushSubscribe(e, t);
|
|
12825
12425
|
if (e.method === "DELETE" && n.pathname === "/api/push/subscriptions") return this.handlePushUnsubscribe(e, t);
|
|
12826
12426
|
if (n.pathname === "/api/workflows/validate" && e.method === "POST") return this.handleWorkflowValidation(e, t);
|
|
12827
12427
|
if (n.pathname === "/api/secrets" && e.method === "GET") return this.handleSecretList(e, t);
|
|
12828
|
-
let
|
|
12829
|
-
if (
|
|
12830
|
-
if (
|
|
12831
|
-
let
|
|
12832
|
-
if (
|
|
12833
|
-
let
|
|
12834
|
-
if (
|
|
12428
|
+
let u = n.pathname.match(/^\/api\/secrets\/([A-Z][A-Z0-9_]*)$/);
|
|
12429
|
+
if (u && e.method === "PUT") return this.handleSecretSave(e, t, u[1]);
|
|
12430
|
+
if (u && e.method === "DELETE") return this.handleSecretDelete(e, t, u[1]);
|
|
12431
|
+
let d = n.pathname.match(/^\/api\/jobs\/(\d+)\/secrets$/);
|
|
12432
|
+
if (d && e.method === "GET") return this.handleJobSecrets(e, t, d[1]);
|
|
12433
|
+
let f = n.pathname.match(/^\/api\/jobs\/(\d+)\/cancel$/);
|
|
12434
|
+
if (f && e.method === "POST") return this.handleCancelJob(e, t, f[1]);
|
|
12835
12435
|
if (n.pathname === "/api/workflows" && e.method === "GET") return this.handleWorkflowList(e, t);
|
|
12836
|
-
let
|
|
12837
|
-
if (
|
|
12838
|
-
let [, r, i] =
|
|
12436
|
+
let p = n.pathname.match(/^\/api\/workflows\/([a-z0-9-]+)(\/publish)?$/);
|
|
12437
|
+
if (p) {
|
|
12438
|
+
let [, r, i] = p;
|
|
12839
12439
|
if (e.method === "GET" && !i) return this.handleWorkflowGet(e, t, r, n.searchParams.get("revision"));
|
|
12840
12440
|
if (e.method === "PUT" && !i) return this.handleWorkflowSave(e, t, r);
|
|
12841
12441
|
if (e.method === "DELETE" && !i) return this.handleWorkflowDelete(e, t, r);
|
|
@@ -12847,8 +12447,8 @@ var Qh = "let token = sessionStorage.getItem('runner-api-token') || '';\nlet exp
|
|
|
12847
12447
|
let r = n.pathname.replace("/runs/", "");
|
|
12848
12448
|
return this.renderRunDetails(r, t, "html", await this.hasScope(e, "logs:read"));
|
|
12849
12449
|
}
|
|
12850
|
-
let
|
|
12851
|
-
if (e.method === "POST" &&
|
|
12450
|
+
let h = n.pathname.match(/^\/api\/runs\/(\d+)\/ai-help$/);
|
|
12451
|
+
if (e.method === "POST" && h) return this.handleAiHelp(e, t, h[1]);
|
|
12852
12452
|
if (e.method === "GET" && n.pathname.startsWith("/api/runs/")) {
|
|
12853
12453
|
let r = n.pathname.match(/^\/api\/runs\/(\d+)\/diagnostics$/);
|
|
12854
12454
|
if (r) return this.handleDiagnostics(e, t, r[1]);
|
|
@@ -12895,6 +12495,36 @@ var Qh = "let token = sessionStorage.getItem('runner-api-token') || '';\nlet exp
|
|
|
12895
12495
|
provider: n
|
|
12896
12496
|
})) }));
|
|
12897
12497
|
}
|
|
12498
|
+
async handleWorkflowRun(e, t, n) {
|
|
12499
|
+
if (!await this.requireScope(e, t, "workflows:write")) return;
|
|
12500
|
+
let r = await this.readJson(e, t);
|
|
12501
|
+
if (r === null && t.headersSent) return;
|
|
12502
|
+
if (r !== null && (typeof r != "object" || Array.isArray(r))) return t.writeHead(400, { "Content-Type": "application/json" }), t.end(JSON.stringify({ error: "Request body must be an object" }));
|
|
12503
|
+
await this.workflowsLoaded;
|
|
12504
|
+
let i = await this.workflows.latestRevision(n);
|
|
12505
|
+
if (!i) return t.writeHead(404, { "Content-Type": "application/json" }), t.end(JSON.stringify({ error: "Workflow not found" }));
|
|
12506
|
+
let o = r?.inputs;
|
|
12507
|
+
if (o !== void 0 && (typeof o != "object" || !o || Array.isArray(o))) return t.writeHead(400, { "Content-Type": "application/json" }), t.end(JSON.stringify({ error: "inputs must be an object" }));
|
|
12508
|
+
let c = {
|
|
12509
|
+
...o || {},
|
|
12510
|
+
trigger: {
|
|
12511
|
+
type: "manual",
|
|
12512
|
+
source: "editor"
|
|
12513
|
+
}
|
|
12514
|
+
}, l = [];
|
|
12515
|
+
for (let e of s(i.definition)) {
|
|
12516
|
+
let t = await a(e.tags, e.matrixContext, c), r = await this.queue.enqueue(n, i.revision, {
|
|
12517
|
+
inputs: c,
|
|
12518
|
+
matrix: e.matrixContext
|
|
12519
|
+
}, t, "");
|
|
12520
|
+
r && l.push(r), this.events.publish("jobs.available", { tags: t });
|
|
12521
|
+
}
|
|
12522
|
+
return t.writeHead(202, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({
|
|
12523
|
+
workflowId: n,
|
|
12524
|
+
revision: i.revision,
|
|
12525
|
+
jobs: l
|
|
12526
|
+
}));
|
|
12527
|
+
}
|
|
12898
12528
|
async handleJobWait(e, t, n) {
|
|
12899
12529
|
let r = Math.min(12e4, Math.max(0, Number(t || 3e4))), i = Date.now(), a;
|
|
12900
12530
|
do {
|
package/dist/server.d.ts
CHANGED
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAc,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAexF,qBAAa,aAAa;IACxB,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,aAAa,CAA0C;IAC/D,OAAO,CAAC,SAAS,CAA4B;IAC7C,OAAO,CAAC,gBAAgB,CAA0B;IAClD,OAAO,CAAC,KAAK,CAAe;IAC5B,OAAO,CAAC,OAAO,CAAc;IAC7B,OAAO,CAAC,IAAI,CAAiB;IAC7B,OAAO,CAAC,OAAO,CAA0B;IACzC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,IAAI,CAAC,CAAa;IAC1B,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,eAAe,CAAgB;WAE1B,QAAQ,CAAC,OAAO,EAAE,oBAAoB,GAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE;gBAK1D,OAAO,EAAE,oBAAoB;IAczC,oBAAoB,CAAC,YAAY,EAAE,mBAAmB;YAIxC,aAAa;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAc,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAexF,qBAAa,aAAa;IACxB,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,aAAa,CAA0C;IAC/D,OAAO,CAAC,SAAS,CAA4B;IAC7C,OAAO,CAAC,gBAAgB,CAA0B;IAClD,OAAO,CAAC,KAAK,CAAe;IAC5B,OAAO,CAAC,OAAO,CAAc;IAC7B,OAAO,CAAC,IAAI,CAAiB;IAC7B,OAAO,CAAC,OAAO,CAA0B;IACzC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,IAAI,CAAC,CAAa;IAC1B,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,eAAe,CAAgB;WAE1B,QAAQ,CAAC,OAAO,EAAE,oBAAoB,GAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE;gBAK1D,OAAO,EAAE,oBAAoB;IAczC,oBAAoB,CAAC,YAAY,EAAE,mBAAmB;YAIxC,aAAa;IA2T3B;;OAEG;YACW,aAAa;YA0Bb,cAAc;YASd,iBAAiB;YAwCjB,aAAa;YAiBb,iBAAiB;YA0BjB,WAAW;YAwBX,UAAU;YAyBV,cAAc;IA8C5B,OAAO,CAAC,OAAO;IAIf,OAAO,CAAC,QAAQ;YAIF,QAAQ;YAaR,eAAe;YAaf,kBAAkB;IAgBhC,OAAO,CAAC,gBAAgB;IAMxB,OAAO,CAAC,iBAAiB;IAMzB,OAAO,CAAC,eAAe;IAUvB,OAAO,CAAC,eAAe;IAMvB,OAAO,CAAC,OAAO;YAID,cAAc;YAYd,cAAc;YAmBd,eAAe;IAM7B,OAAO,CAAC,YAAY;IAIpB,OAAO,CAAC,UAAU;YAKJ,YAAY;IAO1B,OAAO,CAAC,YAAY;IAmBpB,OAAO,CAAC,YAAY;YAUN,QAAQ;YAYR,wBAAwB;YAcxB,kBAAkB;YAOlB,iBAAiB;YAcjB,kBAAkB;YAoBlB,oBAAoB;YASpB,qBAAqB;YAWrB,gBAAgB;YAMhB,gBAAgB;YAiBhB,kBAAkB;YASlB,gBAAgB;IAc9B;;OAEG;YACW,kBAAkB;YAclB,iBAAiB;IA2B/B,OAAO,CAAC,mBAAmB;YASb,mBAAmB;YAWnB,qBAAqB;IAWnC;;OAEG;YACW,eAAe;IAK7B,OAAO,CAAC,cAAc;IAKtB,OAAO,CAAC,mBAAmB;YAcb,mBAAmB;IAwBjC;;OAEG;YACW,gBAAgB;YA+BhB,gBAAgB;YAiBhB,sBAAsB;YAetB,YAAY;YAsDZ,oBAAoB;YA4BpB,eAAe;YAkBf,cAAc;IAS5B,OAAO,CAAC,MAAM;YAMA,UAAU;IAIlB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAU5C,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAS5B"}
|
|
@@ -3,7 +3,7 @@ import { t } from "./workflow-registry-D4IciIF7.js";
|
|
|
3
3
|
import { a as n, i as r, r as i, t as a } from "./matrix-expander-BItKqWu2.js";
|
|
4
4
|
import { t as o } from "./secrets-BR6K16WY.js";
|
|
5
5
|
import { n as s } from "./events-BoODn7MY.js";
|
|
6
|
-
import { n as c } from "./workflows-
|
|
6
|
+
import { n as c } from "./workflows-nhyVGFWV.js";
|
|
7
7
|
import l from "node:fs";
|
|
8
8
|
import u from "node:path";
|
|
9
9
|
import { promisify as d } from "node:util";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as e, r as t, t as n } from "./workflows-
|
|
1
|
+
import { n as e, r as t, t as n } from "./workflows-nhyVGFWV.js";
|
|
2
2
|
export { n as DEFAULT_STEP_TIMEOUT_MS, e as WorkflowRepository, t as parseWorkflow };
|
|
@@ -4447,6 +4447,13 @@ var _r = class {
|
|
|
4447
4447
|
let r = await t.get("SELECT normalized_json FROM workflow_revisions WHERE workflow_id = ? AND revision = ?", [e, n]);
|
|
4448
4448
|
return r ? JSON.parse(r.normalized_json) : null;
|
|
4449
4449
|
}
|
|
4450
|
+
async latestRevision(e) {
|
|
4451
|
+
let n = await t.get("SELECT revision, normalized_json FROM workflow_revisions\n WHERE workflow_id = ? ORDER BY revision DESC LIMIT 1", [e]);
|
|
4452
|
+
return n ? {
|
|
4453
|
+
revision: Number(n.revision),
|
|
4454
|
+
definition: JSON.parse(n.normalized_json)
|
|
4455
|
+
} : null;
|
|
4456
|
+
}
|
|
4450
4457
|
async getRevisionSnapshot(e, n) {
|
|
4451
4458
|
let r = await t.get("SELECT w.id, w.enabled, r.source_yaml, r.normalized_json\n FROM workflows w JOIN workflow_revisions r ON r.workflow_id = w.id\n WHERE w.id = ? AND r.revision = ?", [e, n]);
|
|
4452
4459
|
if (!r) return null;
|
package/dist/workflows.d.ts
CHANGED
|
@@ -20,6 +20,10 @@ export declare class WorkflowRepository {
|
|
|
20
20
|
delete(id: string): Promise<boolean>;
|
|
21
21
|
published(): Promise<WorkflowRevision[]>;
|
|
22
22
|
getRevision(workflowId: string, revision: number): Promise<WorkflowDefinition | null>;
|
|
23
|
+
latestRevision(workflowId: string): Promise<{
|
|
24
|
+
revision: number;
|
|
25
|
+
definition: WorkflowDefinition;
|
|
26
|
+
} | null>;
|
|
23
27
|
getRevisionSnapshot(workflowId: string, revision: number): Promise<StoredWorkflow | null>;
|
|
24
28
|
claimScheduledRun(workflowId: string, triggerId: string, scheduledFor: string): Promise<boolean>;
|
|
25
29
|
}
|
package/dist/workflows.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workflows.d.ts","sourceRoot":"","sources":["../src/workflows.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEvE,eAAO,MAAM,uBAAuB,QAAS,CAAC;AAE9C,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,OAAO,GAAG,WAAW,GAAG,UAAU,CAAC;IAC3C,OAAO,EAAE,OAAO,CAAC;CAClB;AAQD,yFAAyF;AACzF,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,kBAAkB,EAAE,CAqDtE;AAED,qBAAa,kBAAkB;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAqB3B,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,kBAAkB,EAAE;IAa5C,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,UAAO,GAAG,OAAO,CAAC,cAAc,CAAC;IAalF,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;IASnD,IAAI,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IAKjC,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;IAK/C,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IASpC,SAAS,IAAI,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAMxC,WAAW,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IAKrF,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;IAmBzF,iBAAiB,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAKvG"}
|
|
1
|
+
{"version":3,"file":"workflows.d.ts","sourceRoot":"","sources":["../src/workflows.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEvE,eAAO,MAAM,uBAAuB,QAAS,CAAC;AAE9C,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,OAAO,GAAG,WAAW,GAAG,UAAU,CAAC;IAC3C,OAAO,EAAE,OAAO,CAAC;CAClB;AAQD,yFAAyF;AACzF,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,kBAAkB,EAAE,CAqDtE;AAED,qBAAa,kBAAkB;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAqB3B,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,kBAAkB,EAAE;IAa5C,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,UAAO,GAAG,OAAO,CAAC,cAAc,CAAC;IAalF,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;IASnD,IAAI,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IAKjC,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;IAK/C,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IASpC,SAAS,IAAI,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAMxC,WAAW,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IAKrF,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,kBAAkB,CAAA;KAAE,GAAG,IAAI,CAAC;IASxG,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;IAmBzF,iBAAiB,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAKvG"}
|