@cloud-cli/on 1.55.1 → 1.56.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/dist/api-key-repository.d.ts +2 -2
- package/dist/api-key-repository.d.ts.map +1 -1
- package/dist/on.js +144 -33
- package/dist/queue.d.ts +1 -1
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const API_KEY_SCOPES: readonly ["workflows:read", "workflows:write", "logs:read", "artifacts:read", "runs:control"];
|
|
1
|
+
export declare const API_KEY_SCOPES: readonly ["workflows:read", "workflows:write", "logs:read", "artifacts:read", "runs:control", "runs:dispatch"];
|
|
2
2
|
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
|
3
3
|
export declare class ApiKeyRepository {
|
|
4
4
|
init(): Promise<void>;
|
|
@@ -6,7 +6,7 @@ export declare class ApiKeyRepository {
|
|
|
6
6
|
issue(name: string, scopes: string[]): Promise<{
|
|
7
7
|
id: `${string}-${string}-${string}-${string}-${string}`;
|
|
8
8
|
name: string;
|
|
9
|
-
scopes: ("workflows:read" | "workflows:write" | "logs:read" | "artifacts:read" | "runs:control")[];
|
|
9
|
+
scopes: ("workflows:read" | "workflows:write" | "logs:read" | "artifacts:read" | "runs:control" | "runs:dispatch")[];
|
|
10
10
|
token: string;
|
|
11
11
|
}>;
|
|
12
12
|
scopesForToken(token: string): Promise<string[] | null>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-key-repository.d.ts","sourceRoot":"","sources":["../src/api-key-repository.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,cAAc
|
|
1
|
+
{"version":3,"file":"api-key-repository.d.ts","sourceRoot":"","sources":["../src/api-key-repository.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,cAAc,gHAAiH,CAAC;AAC7I,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC;AAE1D,qBAAa,gBAAgB;IACrB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAYrB,IAAI;IAKJ,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;;;;;;IAUpC,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC;IAQvD,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAI3C"}
|
package/dist/on.js
CHANGED
|
@@ -227,13 +227,15 @@ var ye = class extends l {
|
|
|
227
227
|
await this.createTables(), await this.clearStaleJobs();
|
|
228
228
|
}
|
|
229
229
|
async enqueue(e, t, n, r = [], i) {
|
|
230
|
-
|
|
230
|
+
i && await C.run("UPDATE jobs SET status = 'cancelled' WHERE concurrency_key = ? AND status IN ('pending', 'running');", [i]);
|
|
231
|
+
let a = await C.run("INSERT INTO jobs (workflow_id, workflow_revision, required_tags, concurrency_key, payload) VALUES (?, ?, ?, ?, ?);", [
|
|
231
232
|
e,
|
|
232
233
|
t,
|
|
233
234
|
JSON.stringify(r),
|
|
234
235
|
i || "",
|
|
235
236
|
JSON.stringify(n)
|
|
236
237
|
]);
|
|
238
|
+
return Number(a?.lastInsertRowid ?? a?.id ?? 0);
|
|
237
239
|
}
|
|
238
240
|
async claimNextJob(e = []) {
|
|
239
241
|
return await C.get("\n UPDATE jobs\n SET\n status = 'running',\n worker_id = ?,\n started_at = CURRENT_TIMESTAMP\n WHERE id = (\n SELECT id FROM jobs\n WHERE status = 'pending'\n AND NOT EXISTS (\n SELECT 1\n FROM json_each(COALESCE(jobs.required_tags, '[]')) AS required_tag\n WHERE required_tag.value NOT IN (SELECT value FROM json_each(?))\n )\n ORDER BY created_at ASC\n LIMIT 1\n )\n RETURNING *;\n ", [this.workerId, JSON.stringify(e)]) || null;
|
|
@@ -515,7 +517,7 @@ var je = class {
|
|
|
515
517
|
type: "image/svg+xml",
|
|
516
518
|
purpose: "any maskable"
|
|
517
519
|
}]
|
|
518
|
-
}), Ne = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 512 512\">\n <rect width=\"512\" height=\"512\" rx=\"112\" fill=\"#030712\"/>\n <path d=\"M128 144h256v224H128z\" fill=\"#111827\" stroke=\"#6366f1\" stroke-width=\"24\"/>\n <path d=\"m174 218 62 38-62 38M260 300h78\" fill=\"none\" stroke=\"#f9fafb\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"24\"/>\n <circle cx=\"365\" cy=\"147\" r=\"47\" fill=\"#10b981\" stroke=\"#030712\" stroke-width=\"18\"/>\n</svg>", Pe = "self.addEventListener('install', () => self.skipWaiting());\nself.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));\nself.addEventListener('push', (event) => {\n const data = event.data?.json() || {};\n event.waitUntil(self.registration.showNotification(data.title || 'Runner Engine', {\n body: data.body || 'A job status changed.',\n icon: '/app-icon.svg',\n badge: '/app-icon.svg',\n data: { url: data.url || '/runs' },\n }));\n});\nself.addEventListener('notificationclick', (event) => {\n event.notification.close();\n const target = event.notification.data?.url || '/runs';\n event.waitUntil((async () => {\n const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });\n const existing = windows.find((client) => new URL(client.url).pathname === target);\n if (existing) return existing.focus();\n return self.clients.openWindow(target);\n })());\n});", Fe = "<!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 <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-block.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n <title>Workflow Run</title>\n <script type=\"importmap\">\n {\n \"imports\": {\n \"@li3/\": \"https://cdn.li3.dev/@li3/\",\n \"ansi_up\": \"https://esm.sh/ansi_up@6.0.6\",\n \"marked\": \"https://cdn.jsdelivr.net/npm/marked@18.0.12/lib/marked.esm.js\"\n }\n }\n <\/script>\n <style>\n </style>\n </head>\n <body class=\"bg-gray-950 text-gray-100 min-h-screen p-4 md:p-6 font-sans\">\n <template app>\n <div class=\"max-w-7xl mx-auto space-y-4 py-6\">\n <app-header bind-title=\"report.workflowName\" bind-subtitle=\"'Run #' + report.jobId\" back=\"/runs\" back-label=\"Back to jobs\"></app-header>\n <div class=\"flex flex-wrap items-center justify-between gap-3 border-b border-gray-800 pb-4\">\n <p class=\"text-xs text-gray-400\">\n Started {{ report.startedAt }} · {{ timing }}\n <template if=\"report.workerId\"> · worker: {{ report.workerId }}</template>\n <template if=\"report.parentId\"> · from #{{ report.parentId }}</template>\n </p>\n <div class=\"flex items-center gap-2 shrink-0\">\n <span\n class=\"px-4 py-1.5 rounded-full text-sm font-semibold border\"\n bind-class=\"{\n 'bg-emerald-500/10.text-emerald-400.border-emerald-500/20': report.status === 'success',\n 'bg-rose-500/10.text-rose-400.border-rose-500/20': report.status === 'failed',\n 'bg-indigo-500/10.text-indigo-400.border-indigo-500/20': report.status === 'running',\n 'bg-amber-500/10.text-amber-400.border-amber-500/20': !['success', 'failed', 'running'].includes(report.status)\n }\"\n >\n {{ upper(report.status) }}\n </span>\n <template if=\"report.status === 'running'\">\n <button\n class=\"text-sm text-rose-300 px-4 py-1.5 border border-rose-500/50 hover:border-rose-400 rounded-full disabled:opacity-50\"\n on-click=\"cancelJob()\"\n bind-disabled.bool=\"cancelling\"\n >\n {{ cancelling ? 'Cancelling...' : 'Cancel' }}\n </button>\n </template>\n <template if=\"report.status !== 'running'\">\n <div class=\"relative flex items-center gap-1\">\n <button class=\"text-sm text-white px-4 py-1.5 border border-gray-600 hover:border-gray-400 rounded-full\" on-click=\"restartJob()\">Restart</button>\n <details class=\"relative\">\n <summary class=\"cursor-pointer list-none rounded-full border border-gray-600 p-2 text-gray-300 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\" aria-label=\"More restart options\">\n <lucide-icon icon=\"chevron-down\" size=\"14\"></lucide-icon>\n </summary>\n <div class=\"absolute right-0 z-20 mt-2 w-56 rounded-xl border border-gray-700 bg-gray-900 p-2 shadow-2xl\">\n <button type=\"button\" on-click=\"openManualRestart($event)\" class=\"w-full rounded-lg px-3 py-2 text-left text-sm text-gray-200 hover:bg-gray-800\">Restart with inputs</button>\n </div>\n </details>\n </div>\n </template>\n </div>\n </div>\n <template if=\"manualRestartOpen\">\n <div class=\"fixed inset-0 z-50 flex items-center justify-center bg-gray-950/80 p-4\" role=\"presentation\">\n <div class=\"w-full max-w-lg rounded-2xl border border-gray-700 bg-gray-900 p-5 shadow-2xl\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"manual-restart-title\">\n <div class=\"flex items-start justify-between gap-3\"><div><h2 id=\"manual-restart-title\" class=\"text-lg font-semibold text-white\">Restart with inputs</h2><p class=\"mt-1 text-sm text-gray-400\">These values override the original trigger inputs for the new run.</p></div><button type=\"button\" on-click=\"closeManualRestart()\" aria-label=\"Close restart dialog\" class=\"rounded-lg p-2 text-gray-400 hover:bg-gray-800\"><lucide-icon icon=\"x\" size=\"17\"></lucide-icon></button></div>\n <details class=\"mt-5 overflow-hidden rounded-xl border border-gray-800 bg-gray-950/60\">\n <summary class=\"cursor-pointer px-3 py-2 text-sm font-medium text-gray-300 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">Original inputs</summary>\n <code-block language=\"json\" theme=\"atom-one-dark\" bind-source=\"originalInputsJson\" class=\"block max-h-64 overflow-auto border-t border-gray-800 p-3\"></code-block>\n </details>\n <div class=\"mt-4 space-y-3\">\n <p class=\"text-xs text-gray-500\">Additional inputs override matching original values without changing the original payload.</p>\n <template for=\"entry of manualInputs\">\n <div class=\"grid grid-cols-[1fr_1fr_auto] gap-2\"><input bind-value=\"entry.key\" on-input=\"setManualKey(entry, $event)\" placeholder=\"Key\" class=\"rounded-lg border border-gray-700 bg-gray-950 px-3 py-2 text-sm text-gray-200\" /><input bind-value=\"entry.value\" on-input=\"setManualValue(entry, $event)\" placeholder=\"Value\" class=\"rounded-lg border border-gray-700 bg-gray-950 px-3 py-2 text-sm text-gray-200\" /><button type=\"button\" on-click=\"removeManualInput(entry)\" aria-label=\"Remove input\" class=\"rounded-lg px-2 text-rose-300 hover:bg-rose-500/10\"><lucide-icon icon=\"x\" size=\"15\"></lucide-icon></button></div>\n </template>\n <button type=\"button\" on-click=\"addManualInput()\" class=\"text-sm text-indigo-300 hover:underline\">+ Add input</button>\n </div>\n <div class=\"mt-6 flex justify-end gap-2\"><button type=\"button\" on-click=\"closeManualRestart()\" class=\"rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Cancel</button><button type=\"button\" on-click=\"restartWithInputs()\" bind-disabled=\"manualRestartLoading\" class=\"rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-400 disabled:opacity-50\">{{ manualRestartLoading ? 'Restarting...' : 'Restart' }}</button></div>\n </div>\n </div>\n </template>\n <template if=\"previousRuns.length\">\n <details class=\"rounded-xl border border-gray-800 bg-gray-900/70\">\n <summary class=\"cursor-pointer px-4 py-3 text-sm font-medium text-gray-300 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Previous runs\n </summary>\n <ul class=\"divide-y divide-gray-800 border-t border-gray-800\">\n <template for=\"run of previousRuns\">\n <li>\n <a attr-href=\"'/runs/' + run.id\" class=\"flex items-center justify-between gap-3 px-4 py-3 text-sm outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <span><span class=\"font-mono text-indigo-300\">#{{ run.id }}</span><span class=\"ml-3 text-gray-400\">{{ run.startedAt }}</span></span>\n <span class=\"font-semibold uppercase text-xs text-gray-400\">{{ upper(run.status) }}</span>\n </a>\n </li>\n </template>\n </ul>\n </details>\n </template>\n\n <section class=\"rounded overflow-hidden border-b border-gray-800\">\n <h2 class=\"sr-only\">Execution Steps</h2>\n <template for=\"[step, index] of report.steps\">\n <details class=\"border border-b-0 border-gray-800 bg-gray-900/50 text-sm\">\n <summary\n class=\"flex flex-col md:flex-row md:items-center justify-between gap-2 p-3 bg-gray-800/40 border-b border-gray-800 cursor-pointer\"\n >\n <div class=\"flex items-center gap-3 min-w-0\">\n <lucide-icon\n class=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-700/70\"\n bind-class=\"{\n 'text-emerald-400': step.status === 'success',\n 'text-rose-400': step.status === 'failed',\n 'text-indigo-400.animate-spin': step.status === 'running',\n 'text-gray-400': !['success', 'failed', 'running'].includes(step.status)\n }\"\n bind-icon=\"\n step.status === 'success'\n ? 'circle-check'\n : step.status === 'failed'\n ? 'circle-x'\n : step.status === 'running'\n ? 'loader-circle'\n : 'circle'\n \"\n size=\"15\"\n ></lucide-icon>\n <template if=\"step.exitCode !== undefined && step.exitCode !== 0\">\n <span class=\"font-mono text-gray-500\">({{ step.exitCode }})</span>\n </template>\n <h3 class=\"font-semibold text-gray-200 truncate min-w-0 flex-1\">{{ step.name }}</h3>\n <div class=\"font-mono text-gray-400 shrink-0\">{{ step.durationMs }}ms</div>\n <span\n class=\"sr-only px-2.5 py-0.5 rounded-full font-medium\"\n bind-class=\"{\n 'text-emerald-400.bg-emerald-500/10': step.status === 'success',\n 'text-rose-400.bg-rose-500/10': step.status === 'failed',\n 'text-indigo-400.bg-indigo-500/10': step.status === 'running',\n 'text-gray-400.bg-gray-500/10': !['success', 'failed', 'running'].includes(step.status)\n }\"\n >\n {{ upper(step.status) }}\n </span>\n </div>\n </summary>\n <template if=\"report.canViewLogs\">\n <div class=\"relative p-4 bg-gray-950 font-mono text-gray-300 leading-relaxed overflow-auto w-full\">\n <template if=\"step.status === 'failed'\">\n <button type=\"button\" on-click=\"askAi(step, $event)\" aria-label=\"Get AI help for failed step\" title=\"Get AI help\" class=\"absolute right-3 top-3 z-10 inline-flex h-7 w-7 items-center justify-center rounded-lg bg-gray-900 text-indigo-300 outline-none hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <lucide-icon bind-icon=\"aiLoading && aiStepId === step.id ? 'loader-circle' : 'sparkles'\" bind-class=\"aiLoading && aiStepId === step.id ? 'animate-spin' : ''\" size=\"16\"></lucide-icon>\n </button>\n </template>\n <pre class=\"whitespace-pre-wrap\" bind-innerhtml=\"stepLog(step)\"></pre>\n </div>\n </template>\n <template if=\"!report.canViewLogs\">\n <div class=\"p-4 bg-gray-950 text-sm text-gray-500\">Logs are available to authenticated users.</div>\n </template>\n </details>\n </template>\n </section>\n\n <details class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden\">\n <summary class=\"cursor-pointer px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-400 outline-none transition hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Trigger Inputs\n </summary>\n <code-block\n language=\"json\"\n theme=\"atom-one-dark\"\n bind-source=\"inputsJson\"\n class=\"block max-h-[38rem] overflow-auto border-t border-gray-800 bg-gray-950 p-4\"\n ></code-block>\n </details>\n <template if=\"report.workflowSourceYaml\">\n <details class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden\">\n <summary class=\"cursor-pointer px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-400 outline-none transition hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Workflow source YAML · revision {{ report.workflowRevision }}\n </summary>\n <code-block\n language=\"yaml\"\n theme=\"atom-one-dark\"\n bind-source=\"report.workflowSourceYaml\"\n class=\"block max-h-[38rem] overflow-auto border-t border-gray-800 bg-gray-950 p-4\"\n ></code-block>\n </details>\n </template>\n <template if=\"report.canViewLogs && aiStepId\">\n <aside class=\"fixed bottom-4 left-4 right-4 z-40 mt-4 flex max-h-[70vh] flex-col rounded-2xl border border-indigo-500/30 bg-gray-900/95 p-4 shadow-2xl backdrop-blur lg:left-auto lg:w-[28rem]\" aria-labelledby=\"ai-help-title\">\n <div class=\"flex items-start justify-between gap-3 border-b border-gray-800 pb-3\">\n <div><h2 id=\"ai-help-title\" class=\"font-semibold text-white\">AI help</h2><p class=\"mt-1 text-xs text-gray-500\">Ask about a failed step.</p></div>\n <lucide-icon icon=\"sparkles\" size=\"18\" class=\"text-indigo-300\"></lucide-icon>\n </div>\n <div class=\"min-h-0 flex-1 space-y-3 overflow-y-auto py-3\" aria-live=\"polite\">\n <p class=\"flex items-center gap-2 text-sm text-indigo-300\" class-hidden=\"!aiLoading\">\n <lucide-icon icon=\"loader-circle\" size=\"16\" class=\"animate-spin\"></lucide-icon>\n Thinking through the failed step...\n </p>\n <p class=\"text-sm text-gray-500\" class-hidden=\"aiMessages.length > 0\">Select the help icon beside a failed step to start.</p>\n <template for=\"message of aiMessages\">\n <div class=\"rounded-lg p-3 text-sm\" bind-class=\"message.role === 'assistant' ? 'bg-indigo-500/10.text-gray-200' : 'bg-gray-800.text-gray-300'\">\n <p class=\"mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500\">{{ message.role }}</p>\n <template if=\"message.html\"><div class=\"ai-markdown text-sm leading-relaxed\" bind-innerhtml=\"message.html\"></div></template>\n <template if=\"!message.html\"><pre class=\"whitespace-pre-wrap font-sans leading-relaxed\">{{ message.content }}</pre></template>\n </div>\n </template>\n <p class=\"text-sm text-rose-300\" class-hidden=\"!aiError\">{{ aiError }}</p>\n </div>\n <form class=\"border-t border-gray-800 pt-3\" on-submit.prevent=\"askFollowup()\">\n <label class=\"sr-only\" for=\"ai-question\">Ask a follow-up question</label>\n <div class=\"flex gap-2\">\n <input id=\"ai-question\" bind-value=\"aiQuestion\" placeholder=\"Ask a follow-up...\" bind-disabled=\"!aiStepId || aiLoading\" class=\"min-w-0 flex-1 rounded-lg border border-gray-700 bg-gray-950 px-3 py-2 text-sm text-gray-200 outline-none placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30 disabled:opacity-50\" />\n <button type=\"submit\" aria-label=\"Ask AI\" bind-disabled=\"!aiStepId || aiLoading\" class=\"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-500 text-white outline-none hover:bg-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-50\"><lucide-icon icon=\"arrow-up\" size=\"16\"></lucide-icon></button>\n </div>\n </form>\n </aside>\n </template>\n <template if=\"report.canViewLogs && report.artifacts.length\">\n <details class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden\">\n <summary class=\"cursor-pointer px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-400 outline-none transition hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Artifacts\n </summary>\n <ul class=\"divide-y divide-gray-800 border-t border-gray-800\">\n <template for=\"artifact of report.artifacts\">\n <li class=\"px-4 py-3\">\n <a\n attr-href=\"artifactUrl(artifact)\"\n class=\"font-mono text-sm text-indigo-300 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >{{ artifact }}</a>\n </li>\n </template>\n </ul>\n </details>\n </template>\n </div>\n\n <script setup>\n import { computed, onDestroy, onInit, ref } from '@li3/web';\n import { AnsiUp } from 'ansi_up';\n import { marked } from 'marked';\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 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 fetch(`/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 fetch(`/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 fetch(`/restart/${report.value.jobId}`, { method: 'POST' });\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 fetch(`/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 fetch(`/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 fetch(`/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, stepLog, askAi, askFollowup, restartJob, openManualRestart, closeManualRestart, addManualInput, removeManualInput, setManualKey, setManualValue, restartWithInputs, cancelJob, cancelling };\n }\n <\/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", Ie = /(?:^|[_-])auth(?:entication)?(?:$|[_-])|access[_-]?key|api[_-]?key|authorization|cookie|credential|passphrase|password|private[_-]?key|secret|session(?:id)?|signing[_-]?key|token/i;
|
|
520
|
+
}), Ne = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 512 512\">\n <rect width=\"512\" height=\"512\" rx=\"112\" fill=\"#030712\"/>\n <path d=\"M128 144h256v224H128z\" fill=\"#111827\" stroke=\"#6366f1\" stroke-width=\"24\"/>\n <path d=\"m174 218 62 38-62 38M260 300h78\" fill=\"none\" stroke=\"#f9fafb\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"24\"/>\n <circle cx=\"365\" cy=\"147\" r=\"47\" fill=\"#10b981\" stroke=\"#030712\" stroke-width=\"18\"/>\n</svg>", Pe = "self.addEventListener('install', () => self.skipWaiting());\nself.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));\nself.addEventListener('push', (event) => {\n const data = event.data?.json() || {};\n event.waitUntil(self.registration.showNotification(data.title || 'Runner Engine', {\n body: data.body || 'A job status changed.',\n icon: '/app-icon.svg',\n badge: '/app-icon.svg',\n data: { url: data.url || '/runs' },\n }));\n});\nself.addEventListener('notificationclick', (event) => {\n event.notification.close();\n const target = event.notification.data?.url || '/runs';\n event.waitUntil((async () => {\n const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });\n const existing = windows.find((client) => new URL(client.url).pathname === target);\n if (existing) return existing.focus();\n return self.clients.openWindow(target);\n })());\n});", Fe = "<!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 <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-block.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n <title>Workflow Run</title>\n <script type=\"importmap\">\n {\n \"imports\": {\n \"@li3/\": \"https://cdn.li3.dev/@li3/\",\n \"ansi_up\": \"https://esm.sh/ansi_up@6.0.6\",\n \"marked\": \"https://cdn.jsdelivr.net/npm/marked@18.0.12/lib/marked.esm.js\"\n }\n }\n <\/script>\n <style>\n </style>\n </head>\n <body class=\"bg-gray-950 text-gray-100 min-h-screen p-4 md:p-6 font-sans\">\n <template app>\n <div class=\"max-w-7xl mx-auto space-y-4 py-6\">\n <app-header bind-title=\"report.workflowName\" bind-subtitle=\"'Run #' + report.jobId\" back=\"/runs\" back-label=\"Back to jobs\"></app-header>\n <div class=\"flex flex-wrap items-center justify-between gap-3 border-b border-gray-800 pb-4\">\n <p class=\"text-xs text-gray-400\">\n Started {{ report.startedAt }} · {{ timing }}\n <template if=\"report.workerId\"> · worker: {{ report.workerId }}</template>\n <template if=\"report.parentId\"> · from #{{ report.parentId }}</template>\n </p>\n <div class=\"flex items-center gap-2 shrink-0\">\n <span\n class=\"px-4 py-1.5 rounded-full text-sm font-semibold border\"\n bind-class=\"{\n 'bg-emerald-500/10.text-emerald-400.border-emerald-500/20': report.status === 'success',\n 'bg-rose-500/10.text-rose-400.border-rose-500/20': report.status === 'failed',\n 'bg-indigo-500/10.text-indigo-400.border-indigo-500/20': report.status === 'running',\n 'bg-amber-500/10.text-amber-400.border-amber-500/20': !['success', 'failed', 'running'].includes(report.status)\n }\"\n >\n {{ upper(report.status) }}\n </span>\n <template if=\"report.status === 'running'\">\n <button\n class=\"text-sm text-rose-300 px-4 py-1.5 border border-rose-500/50 hover:border-rose-400 rounded-full disabled:opacity-50\"\n on-click=\"cancelJob()\"\n bind-disabled.bool=\"cancelling\"\n >\n {{ cancelling ? 'Cancelling...' : 'Cancel' }}\n </button>\n </template>\n <template if=\"report.status !== 'running'\">\n <div class=\"relative flex items-center gap-1\">\n <button class=\"text-sm text-white px-4 py-1.5 border border-gray-600 hover:border-gray-400 rounded-full\" on-click=\"restartJob()\">Restart</button>\n <details class=\"relative\">\n <summary class=\"cursor-pointer list-none rounded-full border border-gray-600 p-2 text-gray-300 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\" aria-label=\"More restart options\">\n <lucide-icon icon=\"chevron-down\" size=\"14\"></lucide-icon>\n </summary>\n <div class=\"absolute right-0 z-20 mt-2 w-56 rounded-xl border border-gray-700 bg-gray-900 p-2 shadow-2xl\">\n <button type=\"button\" on-click=\"openManualRestart($event)\" class=\"w-full rounded-lg px-3 py-2 text-left text-sm text-gray-200 hover:bg-gray-800\">Restart with inputs</button>\n </div>\n </details>\n </div>\n </template>\n </div>\n </div>\n <template if=\"manualRestartOpen\">\n <div class=\"fixed inset-0 z-50 flex items-center justify-center bg-gray-950/80 p-4\" role=\"presentation\">\n <div class=\"w-full max-w-lg rounded-2xl border border-gray-700 bg-gray-900 p-5 shadow-2xl\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"manual-restart-title\">\n <div class=\"flex items-start justify-between gap-3\"><div><h2 id=\"manual-restart-title\" class=\"text-lg font-semibold text-white\">Restart with inputs</h2><p class=\"mt-1 text-sm text-gray-400\">These values override the original trigger inputs for the new run.</p></div><button type=\"button\" on-click=\"closeManualRestart()\" aria-label=\"Close restart dialog\" class=\"rounded-lg p-2 text-gray-400 hover:bg-gray-800\"><lucide-icon icon=\"x\" size=\"17\"></lucide-icon></button></div>\n <details class=\"mt-5 overflow-hidden rounded-xl border border-gray-800 bg-gray-950/60\">\n <summary class=\"cursor-pointer px-3 py-2 text-sm font-medium text-gray-300 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">Original inputs</summary>\n <code-block language=\"json\" theme=\"atom-one-dark\" bind-source=\"originalInputsJson\" class=\"block max-h-64 overflow-auto border-t border-gray-800 p-3\"></code-block>\n </details>\n <div class=\"mt-4 space-y-3\">\n <p class=\"text-xs text-gray-500\">Additional inputs override matching original values without changing the original payload.</p>\n <template for=\"entry of manualInputs\">\n <div class=\"grid grid-cols-[1fr_1fr_auto] gap-2\"><input bind-value=\"entry.key\" on-input=\"setManualKey(entry, $event)\" placeholder=\"Key\" class=\"rounded-lg border border-gray-700 bg-gray-950 px-3 py-2 text-sm text-gray-200\" /><input bind-value=\"entry.value\" on-input=\"setManualValue(entry, $event)\" placeholder=\"Value\" class=\"rounded-lg border border-gray-700 bg-gray-950 px-3 py-2 text-sm text-gray-200\" /><button type=\"button\" on-click=\"removeManualInput(entry)\" aria-label=\"Remove input\" class=\"rounded-lg px-2 text-rose-300 hover:bg-rose-500/10\"><lucide-icon icon=\"x\" size=\"15\"></lucide-icon></button></div>\n </template>\n <button type=\"button\" on-click=\"addManualInput()\" class=\"text-sm text-indigo-300 hover:underline\">+ Add input</button>\n </div>\n <div class=\"mt-6 flex justify-end gap-2\"><button type=\"button\" on-click=\"closeManualRestart()\" class=\"rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Cancel</button><button type=\"button\" on-click=\"restartWithInputs()\" bind-disabled=\"manualRestartLoading\" class=\"rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-400 disabled:opacity-50\">{{ manualRestartLoading ? 'Restarting...' : 'Restart' }}</button></div>\n </div>\n </div>\n </template>\n <template if=\"previousRuns.length\">\n <details class=\"rounded-xl border border-gray-800 bg-gray-900/70\">\n <summary class=\"cursor-pointer px-4 py-3 text-sm font-medium text-gray-300 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Previous runs\n </summary>\n <ul class=\"divide-y divide-gray-800 border-t border-gray-800\">\n <template for=\"run of previousRuns\">\n <li>\n <a attr-href=\"'/runs/' + run.id\" class=\"flex items-center justify-between gap-3 px-4 py-3 text-sm outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <span><span class=\"font-mono text-indigo-300\">#{{ run.id }}</span><span class=\"ml-3 text-gray-400\">{{ run.startedAt }}</span></span>\n <span class=\"font-semibold uppercase text-xs text-gray-400\">{{ upper(run.status) }}</span>\n </a>\n </li>\n </template>\n </ul>\n </details>\n </template>\n\n <section class=\"rounded overflow-hidden border-b border-gray-800\">\n <h2 class=\"sr-only\">Execution Steps</h2>\n <template for=\"[step, index] of report.steps\">\n <details class=\"border border-b-0 border-gray-800 bg-gray-900/50 text-sm\">\n <summary\n class=\"flex flex-col md:flex-row md:items-center justify-between gap-2 p-3 bg-gray-800/40 border-b border-gray-800 cursor-pointer\"\n >\n <div class=\"flex items-center gap-3 min-w-0\">\n <lucide-icon\n class=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-700/70\"\n bind-class=\"{\n 'text-emerald-400': step.status === 'success',\n 'text-rose-400': step.status === 'failed',\n 'text-indigo-400.animate-spin': step.status === 'running',\n 'text-gray-400': !['success', 'failed', 'running'].includes(step.status)\n }\"\n bind-icon=\"\n step.status === 'success'\n ? 'circle-check'\n : step.status === 'failed'\n ? 'circle-x'\n : step.status === 'running'\n ? 'loader-circle'\n : 'circle'\n \"\n size=\"15\"\n ></lucide-icon>\n <template if=\"step.exitCode !== undefined && step.exitCode !== 0\">\n <span class=\"font-mono text-gray-500\">({{ step.exitCode }})</span>\n </template>\n <h3 class=\"font-semibold text-gray-200 truncate min-w-0 flex-1\">{{ step.name }}</h3>\n <div class=\"font-mono text-gray-400 shrink-0\">{{ step.durationMs }}ms</div>\n <span\n class=\"sr-only px-2.5 py-0.5 rounded-full font-medium\"\n bind-class=\"{\n 'text-emerald-400.bg-emerald-500/10': step.status === 'success',\n 'text-rose-400.bg-rose-500/10': step.status === 'failed',\n 'text-indigo-400.bg-indigo-500/10': step.status === 'running',\n 'text-gray-400.bg-gray-500/10': !['success', 'failed', 'running'].includes(step.status)\n }\"\n >\n {{ upper(step.status) }}\n </span>\n </div>\n </summary>\n <template if=\"report.canViewLogs\">\n <div class=\"relative p-4 bg-gray-950 font-mono text-gray-300 leading-relaxed overflow-auto w-full\">\n <template if=\"step.status === 'failed'\">\n <button type=\"button\" on-click=\"askAi(step, $event)\" aria-label=\"Get AI help for failed step\" title=\"Get AI help\" class=\"absolute right-3 top-3 z-10 inline-flex h-7 w-7 items-center justify-center rounded-lg bg-gray-900 text-indigo-300 outline-none hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <lucide-icon bind-icon=\"aiLoading && aiStepId === step.id ? 'loader-circle' : 'sparkles'\" bind-class=\"aiLoading && aiStepId === step.id ? 'animate-spin' : ''\" size=\"16\"></lucide-icon>\n </button>\n </template>\n <pre class=\"whitespace-pre-wrap\" bind-innerhtml=\"stepLog(step)\"></pre>\n </div>\n </template>\n <template if=\"!report.canViewLogs\">\n <div class=\"p-4 bg-gray-950 text-sm text-gray-500\">Logs are available to authenticated users.</div>\n </template>\n </details>\n </template>\n </section>\n\n <details class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden\">\n <summary class=\"cursor-pointer px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-400 outline-none transition hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Trigger Inputs\n </summary>\n <code-block\n language=\"json\"\n theme=\"atom-one-dark\"\n bind-source=\"inputsJson\"\n class=\"block max-h-[38rem] overflow-auto border-t border-gray-800 bg-gray-950 p-4\"\n ></code-block>\n </details>\n <template if=\"report.workflowSourceYaml\">\n <details class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden\">\n <summary class=\"cursor-pointer px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-400 outline-none transition hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Workflow source YAML · revision {{ report.workflowRevision }}\n </summary>\n <code-block\n language=\"yaml\"\n theme=\"atom-one-dark\"\n bind-source=\"report.workflowSourceYaml\"\n class=\"block max-h-[38rem] overflow-auto border-t border-gray-800 bg-gray-950 p-4\"\n ></code-block>\n </details>\n </template>\n <template if=\"report.canViewLogs && aiStepId\">\n <aside class=\"fixed bottom-4 left-4 right-4 z-40 mt-4 flex max-h-[70vh] flex-col rounded-2xl border border-indigo-500/30 bg-gray-900/95 p-4 shadow-2xl backdrop-blur lg:left-auto lg:w-[28rem]\" aria-labelledby=\"ai-help-title\">\n <div class=\"flex items-start justify-between gap-3 border-b border-gray-800 pb-3\">\n <div><h2 id=\"ai-help-title\" class=\"font-semibold text-white\">AI help</h2><p class=\"mt-1 text-xs text-gray-500\">Ask about a failed step.</p></div>\n <lucide-icon icon=\"sparkles\" size=\"18\" class=\"text-indigo-300\"></lucide-icon>\n </div>\n <div class=\"min-h-0 flex-1 space-y-3 overflow-y-auto py-3\" aria-live=\"polite\">\n <p class=\"flex items-center gap-2 text-sm text-indigo-300\" class-hidden=\"!aiLoading\">\n <lucide-icon icon=\"loader-circle\" size=\"16\" class=\"animate-spin\"></lucide-icon>\n Thinking through the failed step...\n </p>\n <p class=\"text-sm text-gray-500\" class-hidden=\"aiMessages.length > 0\">Select the help icon beside a failed step to start.</p>\n <template for=\"message of aiMessages\">\n <div class=\"rounded-lg p-3 text-sm\" bind-class=\"message.role === 'assistant' ? 'bg-indigo-500/10.text-gray-200' : 'bg-gray-800.text-gray-300'\">\n <p class=\"mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500\">{{ message.role }}</p>\n <template if=\"message.html\"><div class=\"ai-markdown text-sm leading-relaxed\" bind-innerhtml=\"message.html\"></div></template>\n <template if=\"!message.html\"><pre class=\"whitespace-pre-wrap font-sans leading-relaxed\">{{ message.content }}</pre></template>\n </div>\n </template>\n <p class=\"text-sm text-rose-300\" class-hidden=\"!aiError\">{{ aiError }}</p>\n </div>\n <form class=\"border-t border-gray-800 pt-3\" on-submit.prevent=\"askFollowup()\">\n <label class=\"sr-only\" for=\"ai-question\">Ask a follow-up question</label>\n <div class=\"flex gap-2\">\n <input id=\"ai-question\" bind-value=\"aiQuestion\" placeholder=\"Ask a follow-up...\" bind-disabled=\"!aiStepId || aiLoading\" class=\"min-w-0 flex-1 rounded-lg border border-gray-700 bg-gray-950 px-3 py-2 text-sm text-gray-200 outline-none placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30 disabled:opacity-50\" />\n <button type=\"submit\" aria-label=\"Ask AI\" bind-disabled=\"!aiStepId || aiLoading\" class=\"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-500 text-white outline-none hover:bg-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-50\"><lucide-icon icon=\"arrow-up\" size=\"16\"></lucide-icon></button>\n </div>\n </form>\n </aside>\n </template>\n <template if=\"report.canViewLogs && report.artifacts.length\">\n <details class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden\">\n <summary class=\"cursor-pointer px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-400 outline-none transition hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Artifacts\n </summary>\n <ul class=\"divide-y divide-gray-800 border-t border-gray-800\">\n <template for=\"artifact of report.artifacts\">\n <li class=\"px-4 py-3\">\n <a\n attr-href=\"artifactUrl(artifact)\"\n class=\"font-mono text-sm text-indigo-300 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >{{ artifact }}</a>\n </li>\n </template>\n </ul>\n </details>\n </template>\n </div>\n\n <script setup>\n import { computed, onDestroy, onInit, ref } from '@li3/web';\n import { AnsiUp } from 'ansi_up';\n import { marked } from 'marked';\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 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 fetch(`/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 fetch(`/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 fetch(`/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 fetch(`/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 fetch(`/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 fetch(`/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, stepLog, askAi, askFollowup, restartJob, openManualRestart, closeManualRestart, addManualInput, removeManualInput, setManualKey, setManualValue, restartWithInputs, cancelJob, cancelling };\n }\n <\/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", Ie = /(?:^|[_-])auth(?:entication)?(?:$|[_-])|access[_-]?key|api[_-]?key|authorization|cookie|credential|passphrase|password|private[_-]?key|secret|session(?:id)?|signing[_-]?key|token/i;
|
|
519
521
|
function Le(e, t, n, r = [], i = !0, a) {
|
|
520
522
|
let o = JSON.parse(e.payload), s = e.report ? JSON.parse(e.report) : ze(e, o, r), c = e.status;
|
|
521
523
|
return {
|
|
@@ -5739,6 +5741,49 @@ var oa = {
|
|
|
5739
5741
|
content: { "application/json": { schema: { $ref: "#/components/schemas/JobList" } } }
|
|
5740
5742
|
} }
|
|
5741
5743
|
} },
|
|
5744
|
+
"/api/dispatch/{trigger}": { post: {
|
|
5745
|
+
tags: ["jobs"],
|
|
5746
|
+
summary: "Dispatch a workflow trigger with manual inputs",
|
|
5747
|
+
security: [{ bearerAuth: [] }, { basicAuth: [] }],
|
|
5748
|
+
parameters: [{
|
|
5749
|
+
name: "trigger",
|
|
5750
|
+
in: "path",
|
|
5751
|
+
required: !0,
|
|
5752
|
+
schema: { type: "string" }
|
|
5753
|
+
}],
|
|
5754
|
+
requestBody: {
|
|
5755
|
+
required: !0,
|
|
5756
|
+
content: { "application/json": { schema: { type: "object" } } }
|
|
5757
|
+
},
|
|
5758
|
+
responses: {
|
|
5759
|
+
202: {
|
|
5760
|
+
description: "Jobs queued",
|
|
5761
|
+
content: { "application/json": { schema: { type: "object" } } }
|
|
5762
|
+
},
|
|
5763
|
+
401: { $ref: "#/components/responses/Unauthorized" }
|
|
5764
|
+
}
|
|
5765
|
+
} },
|
|
5766
|
+
"/api/jobs/{jobId}/wait": { get: {
|
|
5767
|
+
tags: ["jobs"],
|
|
5768
|
+
summary: "Wait for a job to reach a terminal state",
|
|
5769
|
+
parameters: [{ $ref: "#/components/parameters/JobId" }, {
|
|
5770
|
+
name: "timeout",
|
|
5771
|
+
in: "query",
|
|
5772
|
+
schema: {
|
|
5773
|
+
type: "integer",
|
|
5774
|
+
minimum: 0,
|
|
5775
|
+
maximum: 12e4,
|
|
5776
|
+
default: 3e4
|
|
5777
|
+
}
|
|
5778
|
+
}],
|
|
5779
|
+
responses: {
|
|
5780
|
+
200: {
|
|
5781
|
+
description: "Latest job state",
|
|
5782
|
+
content: { "application/json": { schema: { type: "object" } } }
|
|
5783
|
+
},
|
|
5784
|
+
404: { $ref: "#/components/responses/NotFound" }
|
|
5785
|
+
}
|
|
5786
|
+
} },
|
|
5742
5787
|
"/api/jobs/{jobId}/cancel": { post: {
|
|
5743
5788
|
tags: ["jobs"],
|
|
5744
5789
|
summary: "Cancel an active job",
|
|
@@ -6262,7 +6307,8 @@ var oa = {
|
|
|
6262
6307
|
"workflows:write",
|
|
6263
6308
|
"logs:read",
|
|
6264
6309
|
"artifacts:read",
|
|
6265
|
-
"runs:control"
|
|
6310
|
+
"runs:control",
|
|
6311
|
+
"runs:dispatch"
|
|
6266
6312
|
]
|
|
6267
6313
|
}
|
|
6268
6314
|
}
|
|
@@ -6293,7 +6339,8 @@ var oa = {
|
|
|
6293
6339
|
"workflows:write",
|
|
6294
6340
|
"logs:read",
|
|
6295
6341
|
"artifacts:read",
|
|
6296
|
-
"runs:control"
|
|
6342
|
+
"runs:control",
|
|
6343
|
+
"runs:dispatch"
|
|
6297
6344
|
], ca = class {
|
|
6298
6345
|
async init() {
|
|
6299
6346
|
await C.exec("CREATE TABLE IF NOT EXISTS api_keys (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n key_hash TEXT NOT NULL UNIQUE,\n scopes TEXT NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n last_used_at DATETIME,\n revoked_at DATETIME\n )");
|
|
@@ -6328,7 +6375,7 @@ var oa = {
|
|
|
6328
6375
|
let t = await C.run("UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked_at IS NULL", [e]);
|
|
6329
6376
|
return Number(t?.changes || 0) > 0;
|
|
6330
6377
|
}
|
|
6331
|
-
}, la = "<!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 <title>Runner settings</title>\n <script type=\"importmap\">\n { \"imports\": { \"@li3/\": \"https://cdn.li3.dev/@li3/\" } }\n <\/script>\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n </head>\n <body data-page=\"__SETTINGS_PAGE__\" class=\"min-h-screen bg-gray-950 p-4 font-sans text-gray-100 md:p-6\">\n <template app>\n <main class=\"mx-auto max-w-6xl space-y-5 py-6\">\n <app-header title=\"Settings\" subtitle=\"Human-admin controls and automation credentials\" back=\"/runs\" back-label=\"Back to jobs\"></app-header>\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\">Workflows</a>\n <a href=\"/settings/secrets\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Secrets</a>\n <a href=\"/settings/tokens\" bind-class=\"page === 'tokens' ? 'bg-gray-800.text-white' : ''\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">API tokens</a>\n <a href=\"/settings/notifications\" bind-class=\"page === 'notifications' ? 'bg-gray-800.text-white' : ''\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Notifications</a>\n </nav>\n\n <template if=\"page === 'tokens'\">\n </template>\n <template if=\"page === 'notifications'\">\n <section class=\"rounded-2xl border border-gray-800 bg-gray-900/80 p-5\" aria-labelledby=\"notifications-title\">\n <h2 id=\"notifications-title\" class=\"text-lg font-semibold text-white\">Notifications</h2>\n <p class=\"mt-1 text-sm text-gray-400\">Receive job completion notifications on this device.</p>\n <button type=\"button\" on-click=\"toggleNotifications()\" class=\"mt-5 inline-flex items-center gap-2 rounded-lg border border-indigo-500/60 px-4 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 bind-icon=\"notificationsEnabled ? 'bell-off' : 'bell-ring'\" size=\"16\"></lucide-icon>\n {{ notificationsEnabled ? 'Disable notifications' : 'Enable notifications' }}\n </button>\n <p class=\"mt-3 text-sm text-gray-400\" role=\"status\">{{ notificationMessage }}</p>\n </section>\n </template>\n\n <template if=\"page === 'tokens'\">\n <section class=\"rounded-2xl border border-gray-800 bg-gray-900/80 p-5\" aria-labelledby=\"keys-title\">\n <div class=\"flex items-start justify-between gap-3\">\n <div>\n <h2 id=\"keys-title\" class=\"text-lg font-semibold text-white\">API keys</h2>\n <p class=\"mt-1 text-sm text-gray-400\">Issue narrowly scoped credentials for automation tools. Keys are shown only once.</p>\n </div>\n </div>\n <div class=\"mt-5 grid gap-3 rounded-xl border border-gray-800 bg-gray-950/50 p-4 md:grid-cols-[1fr_auto]\">\n <label class=\"sr-only\" for=\"key-name\">Key name</label>\n <input id=\"key-name\" bind-value=\"keyName\" on-input=\"setKeyName($event)\" placeholder=\"Documentation bot\" class=\"rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\" />\n <button type=\"button\" on-click=\"issueKey()\" class=\"inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-500 px-4 py-2.5 text-sm font-semibold text-white outline-none hover:bg-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"><lucide-icon icon=\"key-round\" size=\"16\"></lucide-icon>Issue key</button>\n <fieldset class=\"md:col-span-2\">\n <legend class=\"mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500\">Scopes</legend>\n <div class=\"space-y-2\">\n <template for=\"scope of scopeOptions\">\n <label class=\"group flex cursor-pointer items-center gap-3 text-sm text-gray-300\">\n <input type=\"checkbox\" bind-checked=\"scope.selected\" on-change=\"toggleScope(scope)\" class=\"peer sr-only\" />\n <span aria-hidden=\"true\" class=\"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-gray-600 bg-gray-900 text-transparent transition peer-checked:border-indigo-400 peer-checked:bg-indigo-500 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-400/70 peer-disabled:opacity-50\">\n <svg viewBox=\"0 0 16 16\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m3 8 3 3 7-7\"></path></svg>\n </span>\n <span class=\"group-hover:text-white\">{{ scope.label }}</span>\n </label>\n </template>\n </div>\n </fieldset>\n </div>\n <template if=\"newToken\">\n <div class=\"mt-4 rounded-xl border border-amber-500/40 bg-amber-500/10 p-4\" role=\"alert\">\n <p class=\"text-sm font-semibold text-amber-200\">Copy this key now. It cannot be shown again.</p>\n <code class=\"mt-2 block select-all break-all rounded bg-gray-950 p-3 font-mono text-xs text-amber-100\">{{ newToken }}</code>\n </div>\n </template>\n <h3 class=\"mt-5 text-xs font-semibold uppercase tracking-wider text-gray-500\">Active tokens</h3>\n <ul class=\"mt-2 space-y-2\" aria-label=\"Active API keys\">\n <template for=\"key of keys\">\n <li class=\"flex items-center justify-between gap-3 rounded-xl border border-gray-800 bg-gray-950/60 px-4 py-3\">\n <div class=\"min-w-0\"><p class=\"truncate text-sm font-medium text-gray-200\">{{ key.name }}</p><p class=\"text-xs text-gray-500\">{{ key.scopes.join(', ') }} · created {{ key.created_at }}</p></div>\n <button type=\"button\" on-click=\"revokeKey(key)\" class=\"shrink-0 rounded-lg p-2 text-rose-300 outline-none hover:bg-rose-500/10 focus-visible:ring-2 focus-visible:ring-rose-400/60\" aria-label=\"Revoke {{ key.name }}\" title=\"Revoke key\"><lucide-icon icon=\"trash\" size=\"16\"></lucide-icon></button>\n </li>\n </template>\n </ul>\n </section>\n </template>\n <p class=\"text-sm text-rose-300\" role=\"alert\" class-hidden=\"!error\">{{ error }}</p>\n </main>\n <script setup>\n import { onInit, ref } from '@li3/web';\n export default function () {\n const page = document.body.dataset.page;\n const keys = 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 ]);\n const api = async (url, options = {}) => { const response = await fetch(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 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; 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; }); });\n return { page, keys, keyName, newToken, error, scopeOptions, issueKey, revokeKey, setKeyName, toggleScope, notificationsEnabled, notificationMessage, toggleNotifications };\n }\n <\/script>\n </template>\n <script type=\"module\">import '@li3/web';<\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n";
|
|
6378
|
+
}, la = "<!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 <title>Runner settings</title>\n <script type=\"importmap\">\n { \"imports\": { \"@li3/\": \"https://cdn.li3.dev/@li3/\" } }\n <\/script>\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n </head>\n <body data-page=\"__SETTINGS_PAGE__\" class=\"min-h-screen bg-gray-950 p-4 font-sans text-gray-100 md:p-6\">\n <template app>\n <main class=\"mx-auto max-w-6xl space-y-5 py-6\">\n <app-header title=\"Settings\" subtitle=\"Human-admin controls and automation credentials\" back=\"/runs\" back-label=\"Back to jobs\"></app-header>\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\">Workflows</a>\n <a href=\"/settings/secrets\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Secrets</a>\n <a href=\"/settings/tokens\" bind-class=\"page === 'tokens' ? 'bg-gray-800.text-white' : ''\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">API tokens</a>\n <a href=\"/settings/notifications\" bind-class=\"page === 'notifications' ? 'bg-gray-800.text-white' : ''\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Notifications</a>\n </nav>\n\n <template if=\"page === 'tokens'\">\n </template>\n <template if=\"page === 'notifications'\">\n <section class=\"rounded-2xl border border-gray-800 bg-gray-900/80 p-5\" aria-labelledby=\"notifications-title\">\n <h2 id=\"notifications-title\" class=\"text-lg font-semibold text-white\">Notifications</h2>\n <p class=\"mt-1 text-sm text-gray-400\">Receive job completion notifications on this device.</p>\n <button type=\"button\" on-click=\"toggleNotifications()\" class=\"mt-5 inline-flex items-center gap-2 rounded-lg border border-indigo-500/60 px-4 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 bind-icon=\"notificationsEnabled ? 'bell-off' : 'bell-ring'\" size=\"16\"></lucide-icon>\n {{ notificationsEnabled ? 'Disable notifications' : 'Enable notifications' }}\n </button>\n <p class=\"mt-3 text-sm text-gray-400\" role=\"status\">{{ notificationMessage }}</p>\n </section>\n </template>\n\n <template if=\"page === 'tokens'\">\n <section class=\"rounded-2xl border border-gray-800 bg-gray-900/80 p-5\" aria-labelledby=\"keys-title\">\n <div class=\"flex items-start justify-between gap-3\">\n <div>\n <h2 id=\"keys-title\" class=\"text-lg font-semibold text-white\">API keys</h2>\n <p class=\"mt-1 text-sm text-gray-400\">Issue narrowly scoped credentials for automation tools. Keys are shown only once.</p>\n </div>\n </div>\n <div class=\"mt-5 grid gap-3 rounded-xl border border-gray-800 bg-gray-950/50 p-4 md:grid-cols-[1fr_auto]\">\n <label class=\"sr-only\" for=\"key-name\">Key name</label>\n <input id=\"key-name\" bind-value=\"keyName\" on-input=\"setKeyName($event)\" placeholder=\"Documentation bot\" class=\"rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\" />\n <button type=\"button\" on-click=\"issueKey()\" class=\"inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-500 px-4 py-2.5 text-sm font-semibold text-white outline-none hover:bg-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"><lucide-icon icon=\"key-round\" size=\"16\"></lucide-icon>Issue key</button>\n <fieldset class=\"md:col-span-2\">\n <legend class=\"mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500\">Scopes</legend>\n <div class=\"space-y-2\">\n <template for=\"scope of scopeOptions\">\n <label class=\"group flex cursor-pointer items-center gap-3 text-sm text-gray-300\">\n <input type=\"checkbox\" bind-checked=\"scope.selected\" on-change=\"toggleScope(scope)\" class=\"peer sr-only\" />\n <span aria-hidden=\"true\" class=\"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-gray-600 bg-gray-900 text-transparent transition peer-checked:border-indigo-400 peer-checked:bg-indigo-500 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-400/70 peer-disabled:opacity-50\">\n <svg viewBox=\"0 0 16 16\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m3 8 3 3 7-7\"></path></svg>\n </span>\n <span class=\"group-hover:text-white\">{{ scope.label }}</span>\n </label>\n </template>\n </div>\n </fieldset>\n </div>\n <template if=\"newToken\">\n <div class=\"mt-4 rounded-xl border border-amber-500/40 bg-amber-500/10 p-4\" role=\"alert\">\n <p class=\"text-sm font-semibold text-amber-200\">Copy this key now. It cannot be shown again.</p>\n <code class=\"mt-2 block select-all break-all rounded bg-gray-950 p-3 font-mono text-xs text-amber-100\">{{ newToken }}</code>\n </div>\n </template>\n <h3 class=\"mt-5 text-xs font-semibold uppercase tracking-wider text-gray-500\">Active tokens</h3>\n <ul class=\"mt-2 space-y-2\" aria-label=\"Active API keys\">\n <template for=\"key of keys\">\n <li class=\"flex items-center justify-between gap-3 rounded-xl border border-gray-800 bg-gray-950/60 px-4 py-3\">\n <div class=\"min-w-0\"><p class=\"truncate text-sm font-medium text-gray-200\">{{ key.name }}</p><p class=\"text-xs text-gray-500\">{{ key.scopes.join(', ') }} · created {{ key.created_at }}</p></div>\n <button type=\"button\" on-click=\"revokeKey(key)\" class=\"shrink-0 rounded-lg p-2 text-rose-300 outline-none hover:bg-rose-500/10 focus-visible:ring-2 focus-visible:ring-rose-400/60\" aria-label=\"Revoke {{ key.name }}\" title=\"Revoke key\"><lucide-icon icon=\"trash\" size=\"16\"></lucide-icon></button>\n </li>\n </template>\n </ul>\n </section>\n </template>\n <p class=\"text-sm text-rose-300\" role=\"alert\" class-hidden=\"!error\">{{ error }}</p>\n </main>\n <script setup>\n import { onInit, ref } from '@li3/web';\n export default function () {\n const page = document.body.dataset.page;\n const keys = 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 fetch(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 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; 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; }); });\n return { page, keys, keyName, newToken, error, scopeOptions, issueKey, revokeKey, setKeyName, toggleScope, notificationsEnabled, notificationMessage, toggleNotifications };\n }\n <\/script>\n </template>\n <script type=\"module\">import '@li3/web';<\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n";
|
|
6332
6379
|
//#endregion
|
|
6333
6380
|
//#region src/settings-ui.ts
|
|
6334
6381
|
function ua(e = "tokens") {
|
|
@@ -20859,23 +20906,27 @@ var SC = class {
|
|
|
20859
20906
|
let e = n.searchParams.get("afterId"), r = n.searchParams.get("beforeId"), i = n.searchParams.get("limit"), a = n.searchParams.get("filter")?.trim() || void 0, o = n.searchParams.get("workflowId")?.trim() || void 0, s = e === null ? void 0 : Number(e), c = r === null ? void 0 : Number(r), l = i === null ? CC : Number(i);
|
|
20860
20907
|
return s !== void 0 && (!Number.isSafeInteger(s) || s < 0) ? (t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "afterId must be a non-negative integer" }))) : c !== void 0 && (!Number.isSafeInteger(c) || c < 1) ? (t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "beforeId must be a positive integer" }))) : !Number.isSafeInteger(l) || l < 1 || l > wC ? (t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: `limit must be an integer from 1 to ${wC}` }))) : a && a.length > 200 ? (t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "filter must be at most 200 characters" }))) : this.renderDashboardJobs(t, l, s, c, a, o);
|
|
20861
20908
|
}
|
|
20909
|
+
let s = n.pathname.match(/^\/api\/dispatch\/([a-z0-9-]+)$/);
|
|
20910
|
+
if (e.method === "POST" && s) return this.handleDispatch(e, t, s[1]);
|
|
20911
|
+
let c = n.pathname.match(/^\/api\/jobs\/(\d+)\/wait$/);
|
|
20912
|
+
if (e.method === "GET" && c) return this.handleJobWait(c[1], n.searchParams.get("timeout"), t);
|
|
20862
20913
|
if (e.method === "GET" && n.pathname === "/api/events") return this.events.subscribe(e, t);
|
|
20863
20914
|
if (e.method === "GET" && n.pathname === "/api/push/public-key") return this.handlePushPublicKey(t);
|
|
20864
20915
|
if (e.method === "POST" && n.pathname === "/api/push/subscriptions") return this.handlePushSubscribe(e, t);
|
|
20865
20916
|
if (e.method === "DELETE" && n.pathname === "/api/push/subscriptions") return this.handlePushUnsubscribe(e, t);
|
|
20866
20917
|
if (n.pathname === "/api/workflows/validate" && e.method === "POST") return this.handleWorkflowValidation(e, t);
|
|
20867
20918
|
if (n.pathname === "/api/secrets" && e.method === "GET") return this.handleSecretList(e, t);
|
|
20868
|
-
let
|
|
20869
|
-
if (
|
|
20870
|
-
if (
|
|
20871
|
-
let
|
|
20872
|
-
if (
|
|
20873
|
-
let
|
|
20874
|
-
if (
|
|
20919
|
+
let l = n.pathname.match(/^\/api\/secrets\/([A-Z][A-Z0-9_]*)$/);
|
|
20920
|
+
if (l && e.method === "PUT") return this.handleSecretSave(e, t, l[1]);
|
|
20921
|
+
if (l && e.method === "DELETE") return this.handleSecretDelete(e, t, l[1]);
|
|
20922
|
+
let u = n.pathname.match(/^\/api\/jobs\/(\d+)\/secrets$/);
|
|
20923
|
+
if (u && e.method === "GET") return this.handleJobSecrets(e, t, u[1]);
|
|
20924
|
+
let d = n.pathname.match(/^\/api\/jobs\/(\d+)\/cancel$/);
|
|
20925
|
+
if (d && e.method === "POST") return this.handleCancelJob(e, t, d[1]);
|
|
20875
20926
|
if (n.pathname === "/api/workflows" && e.method === "GET") return this.handleWorkflowList(e, t);
|
|
20876
|
-
let
|
|
20877
|
-
if (
|
|
20878
|
-
let [, r, i] =
|
|
20927
|
+
let p = n.pathname.match(/^\/api\/workflows\/([a-z0-9-]+)(\/publish)?$/);
|
|
20928
|
+
if (p) {
|
|
20929
|
+
let [, r, i] = p;
|
|
20879
20930
|
if (e.method === "GET" && !i) return this.handleWorkflowGet(e, t, r, n.searchParams.get("revision"));
|
|
20880
20931
|
if (e.method === "PUT" && !i) return this.handleWorkflowSave(e, t, r);
|
|
20881
20932
|
if (e.method === "DELETE" && !i) return this.handleWorkflowDelete(e, t, r);
|
|
@@ -20886,13 +20937,15 @@ var SC = class {
|
|
|
20886
20937
|
let r = n.pathname.replace("/runs/", "");
|
|
20887
20938
|
return this.renderRunDetails(r, t, "html", await this.hasScope(e, "logs:read"));
|
|
20888
20939
|
}
|
|
20889
|
-
let
|
|
20890
|
-
if (e.method === "POST" &&
|
|
20940
|
+
let m = n.pathname.match(/^\/api\/runs\/(\d+)\/ai-help$/);
|
|
20941
|
+
if (e.method === "POST" && m) return this.handleAiHelp(e, t, m[1]);
|
|
20891
20942
|
if (e.method === "GET" && n.pathname.startsWith("/api/runs/")) {
|
|
20892
|
-
let r = n.pathname.match(/^\/api\/runs\/(\d+)\/
|
|
20893
|
-
if (r) return this.
|
|
20894
|
-
let i = n.pathname.
|
|
20895
|
-
return this.
|
|
20943
|
+
let r = n.pathname.match(/^\/api\/runs\/(\d+)\/diagnostics$/);
|
|
20944
|
+
if (r) return this.handleDiagnostics(e, t, r[1]);
|
|
20945
|
+
let i = n.pathname.match(/^\/api\/runs\/(\d+)\/artifacts\/(.+)$/);
|
|
20946
|
+
if (i) return this.handleArtifactDownload(e, t, i[1], decodeURIComponent(i[2]));
|
|
20947
|
+
let a = n.pathname.replace("/api/runs/", "");
|
|
20948
|
+
return this.renderRunDetails(a, t, "json", await this.hasScope(e, "logs:read"));
|
|
20896
20949
|
}
|
|
20897
20950
|
if (e.method === "POST" && n.pathname.startsWith("/restart/")) {
|
|
20898
20951
|
let r = n.pathname.replace("/restart/", "");
|
|
@@ -20922,6 +20975,62 @@ var SC = class {
|
|
|
20922
20975
|
}));
|
|
20923
20976
|
}
|
|
20924
20977
|
}
|
|
20978
|
+
async handleDispatch(e, t, n) {
|
|
20979
|
+
if (!await this.requireScope(e, t, "runs:dispatch")) return;
|
|
20980
|
+
let r = await this.readJson(e, t);
|
|
20981
|
+
if (!r || typeof r != "object" || Array.isArray(r)) return;
|
|
20982
|
+
let i = await this.matchWorkflows(n, r, await this.workflows.published(), await this.currentSecrets());
|
|
20983
|
+
t.writeHead(202, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ jobs: i.map((e) => ({
|
|
20984
|
+
id: e,
|
|
20985
|
+
provider: n
|
|
20986
|
+
})) }));
|
|
20987
|
+
}
|
|
20988
|
+
async handleJobWait(e, t, n) {
|
|
20989
|
+
let r = Math.min(12e4, Math.max(0, Number(t || 3e4))), i = Date.now(), a;
|
|
20990
|
+
do {
|
|
20991
|
+
if (a = await this.queue.getJob(e), !a || [
|
|
20992
|
+
"success",
|
|
20993
|
+
"failed",
|
|
20994
|
+
"cancelled"
|
|
20995
|
+
].includes(a.status) || Date.now() - i >= r) break;
|
|
20996
|
+
await new Promise((e) => setTimeout(e, 500));
|
|
20997
|
+
} while (1);
|
|
20998
|
+
return a ? (n.writeHead(200, {
|
|
20999
|
+
"Cache-Control": "no-store",
|
|
21000
|
+
"Content-Type": "application/json; charset=utf-8"
|
|
21001
|
+
}), n.end(JSON.stringify({
|
|
21002
|
+
job: we([a])[0],
|
|
21003
|
+
terminal: [
|
|
21004
|
+
"success",
|
|
21005
|
+
"failed",
|
|
21006
|
+
"cancelled"
|
|
21007
|
+
].includes(a.status)
|
|
21008
|
+
}))) : (n.writeHead(404, { "Content-Type": "application/json" }), n.end(JSON.stringify({ error: "Job not found" })));
|
|
21009
|
+
}
|
|
21010
|
+
async handleDiagnostics(e, t, n) {
|
|
21011
|
+
if (!await this.hasScope(e, "logs:read")) return this.requireScope(e, t, "logs:read");
|
|
21012
|
+
let r = await this.queue.getJob(n);
|
|
21013
|
+
if (!r) return t.writeHead(404, { "Content-Type": "application/json" }), t.end(JSON.stringify({ error: "Run not found" }));
|
|
21014
|
+
let i = r.report ? JSON.parse(r.report) : null, a = await this.workflows.getRevision(r.workflow_id, r.workflow_revision), o = await this.workflows.getRevisionSnapshot(r.workflow_id, r.workflow_revision), s = await this.queue.getJobLogs(n), c = i?.steps?.findIndex((e) => e.status === "failed") ?? -1;
|
|
21015
|
+
return t.writeHead(200, {
|
|
21016
|
+
"Cache-Control": "no-store",
|
|
21017
|
+
"Content-Type": "application/json; charset=utf-8"
|
|
21018
|
+
}), t.end(JSON.stringify({
|
|
21019
|
+
jobId: String(r.id),
|
|
21020
|
+
workflow: {
|
|
21021
|
+
id: r.workflow_id,
|
|
21022
|
+
revision: r.workflow_revision,
|
|
21023
|
+
sourceYaml: o?.sourceYaml
|
|
21024
|
+
},
|
|
21025
|
+
status: r.status,
|
|
21026
|
+
inputs: i?.inputs || {},
|
|
21027
|
+
failedStep: c >= 0 ? i.steps[c] : null,
|
|
21028
|
+
steps: c >= 0 ? i.steps.slice(0, c + 1) : i?.steps || [],
|
|
21029
|
+
logs: s,
|
|
21030
|
+
artifacts: i?.artifacts || [],
|
|
21031
|
+
workflowFound: !!a
|
|
21032
|
+
}));
|
|
21033
|
+
}
|
|
20925
21034
|
async readRequest(e, t) {
|
|
20926
21035
|
let n = [], r = 0;
|
|
20927
21036
|
for await (let i of e) {
|
|
@@ -20967,36 +21076,38 @@ var SC = class {
|
|
|
20967
21076
|
}
|
|
20968
21077
|
}
|
|
20969
21078
|
async matchWorkflows(e, t, n, r = {}) {
|
|
20970
|
-
|
|
20971
|
-
|
|
21079
|
+
let i = [];
|
|
21080
|
+
for (let { definition: a, revision: o } of n) {
|
|
21081
|
+
if (a.on.provider !== e) continue;
|
|
20972
21082
|
let n = this.preprocessors.get(e);
|
|
20973
|
-
if (n?.filter && !n.filter(t,
|
|
20974
|
-
dr(`⏩ Skipped ${
|
|
21083
|
+
if (n?.filter && !n.filter(t, a.on).isValid) {
|
|
21084
|
+
dr(`⏩ Skipped ${a.id} based on ${e} webhook filters`, { inputs: t });
|
|
20975
21085
|
continue;
|
|
20976
21086
|
}
|
|
20977
|
-
if (
|
|
21087
|
+
if (a.on.if) try {
|
|
20978
21088
|
let e = n?.conditionContext?.(t, r) || {};
|
|
20979
|
-
if (!await mr.evaluateConditions(
|
|
21089
|
+
if (!await mr.evaluateConditions(a.on.if, {
|
|
20980
21090
|
inputs: t,
|
|
20981
21091
|
...e
|
|
20982
21092
|
})) {
|
|
20983
|
-
dr(`⏩ Skipped ${
|
|
21093
|
+
dr(`⏩ Skipped ${a.id} based on conditions: ${a.on.if}`, { inputs: t });
|
|
20984
21094
|
continue;
|
|
20985
21095
|
}
|
|
20986
21096
|
} catch (e) {
|
|
20987
|
-
console.error(`⚠️ Condition evaluation error in workflow [${
|
|
21097
|
+
console.error(`⚠️ Condition evaluation error in workflow [${a.id}]:`, e.message);
|
|
20988
21098
|
continue;
|
|
20989
21099
|
}
|
|
20990
|
-
for (let e of hr(
|
|
21100
|
+
for (let e of hr(a)) {
|
|
20991
21101
|
let n = await gr(e.tags, e.matrixContext, t), r = "";
|
|
20992
21102
|
e.concurrency?.group && (r = await mr.evaluateValue(e.concurrency.group, { inputs: t }));
|
|
20993
|
-
let
|
|
21103
|
+
let s = {
|
|
20994
21104
|
inputs: t,
|
|
20995
21105
|
matrix: e.matrixContext
|
|
20996
|
-
};
|
|
20997
|
-
|
|
21106
|
+
}, c = await this.queue.enqueue(a.id, o, s, n, r);
|
|
21107
|
+
c && i.push(c), this.events.publish("jobs.available", { tags: n });
|
|
20998
21108
|
}
|
|
20999
21109
|
}
|
|
21110
|
+
return i;
|
|
21000
21111
|
}
|
|
21001
21112
|
isAdmin(e) {
|
|
21002
21113
|
return this.matchesToken(e, this.adminToken, !0);
|
package/dist/queue.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export declare class QueueManager {
|
|
|
8
8
|
* Enqueues a new job into the database.
|
|
9
9
|
* Includes simple GitHub-style concurrency cancellation.
|
|
10
10
|
*/
|
|
11
|
-
enqueue(workflowId: string, workflowRevision: number, payload: JobPayload, requiredTags?: string[], concurrencyKey?: string): Promise<
|
|
11
|
+
enqueue(workflowId: string, workflowRevision: number, payload: JobPayload, requiredTags?: string[], concurrencyKey?: string): Promise<number>;
|
|
12
12
|
/**
|
|
13
13
|
* Atomically claims the oldest pending job compatible with this worker.
|
|
14
14
|
* Requires SQLite >= 3.35 for the RETURNING clause.
|
package/dist/server.d.ts
CHANGED
|
@@ -22,6 +22,9 @@ export declare class WebhookServer {
|
|
|
22
22
|
* Processes incoming HTTP webhooks
|
|
23
23
|
*/
|
|
24
24
|
private handleWebhook;
|
|
25
|
+
private handleDispatch;
|
|
26
|
+
private handleJobWait;
|
|
27
|
+
private handleDiagnostics;
|
|
25
28
|
private readRequest;
|
|
26
29
|
private preprocess;
|
|
27
30
|
private matchWorkflows;
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAc,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAOxF,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,MAAM,CAAqB;IACnC,OAAO,CAAC,eAAe,CAAgB;WAE1B,QAAQ,CAAC,OAAO,EAAE,oBAAoB,GAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE;gBAK1D,OAAO,EAAE,oBAAoB;IAazC,oBAAoB,CAAC,YAAY,EAAE,mBAAmB;YAIxC,aAAa;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAc,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAOxF,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,MAAM,CAAqB;IACnC,OAAO,CAAC,eAAe,CAAgB;WAE1B,QAAQ,CAAC,OAAO,EAAE,oBAAoB,GAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE;gBAK1D,OAAO,EAAE,oBAAoB;IAazC,oBAAoB,CAAC,YAAY,EAAE,mBAAmB;YAIxC,aAAa;IAgP3B;;OAEG;YACW,aAAa;YA0Bb,cAAc;YASd,aAAa;YAiBb,iBAAiB;YA0BjB,WAAW;YAwBX,UAAU;YAyBV,cAAc;IA8C5B,OAAO,CAAC,OAAO;IAIf,OAAO,CAAC,QAAQ;YAIF,QAAQ;YAQR,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,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"}
|