@cloud-cli/on 1.67.0 → 1.68.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.
@@ -1879,7 +1879,7 @@ var nn = {
1879
1879
  { name: "webhooks" },
1880
1880
  { name: "notifications" }
1881
1881
  ],
1882
- paths: /* @__PURE__ */ JSON.parse("{\"/api\":{\"get\":{\"tags\":[\"discovery\"],\"summary\":\"Get this OpenAPI specification\",\"security\":[],\"responses\":{\"200\":{\"description\":\"OpenAPI JSON document\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}}}}},\"/api/api-keys\":{\"get\":{\"tags\":[\"discovery\"],\"summary\":\"List active API keys\",\"responses\":{\"200\":{\"description\":\"API key metadata\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ApiKeyList\"}}}}}},\"post\":{\"tags\":[\"discovery\"],\"summary\":\"Issue a scoped API key\",\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ApiKeyIssue\"}}}},\"responses\":{\"201\":{\"description\":\"Key metadata and one-time token\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ApiKeyCreated\"}}}}}}},\"/api/api-keys/{keyId}\":{\"delete\":{\"tags\":[\"discovery\"],\"summary\":\"Revoke an API key\",\"parameters\":[{\"name\":\"keyId\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\",\"format\":\"uuid\"}}],\"responses\":{\"204\":{\"description\":\"Key revoked\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/jobs\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"List jobs\",\"security\":[],\"parameters\":[{\"$ref\":\"#/components/parameters/AfterId\"},{\"$ref\":\"#/components/parameters/BeforeId\"},{\"$ref\":\"#/components/parameters/Limit\"},{\"$ref\":\"#/components/parameters/Filter\"},{\"$ref\":\"#/components/parameters/WorkflowFilter\"}],\"responses\":{\"200\":{\"description\":\"Dashboard job list\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/JobList\"}}}}}}},\"/api/dispatch/{trigger}\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Dispatch a workflow trigger with manual inputs\",\"security\":[{\"bearerAuth\":[]},{\"basicAuth\":[]}],\"parameters\":[{\"name\":\"trigger\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"202\":{\"description\":\"Jobs queued\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"}}}},\"/api/workflows/{workflowId}/run\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Run the latest saved workflow revision immediately\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"name\":\"workflowId\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"202\":{\"description\":\"Jobs queued\"},\"400\":{\"$ref\":\"#/components/responses/ValidationError\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"}}}},\"/api/jobs/{jobId}/wait\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Wait for a job to reach a terminal state\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"},{\"name\":\"timeout\",\"in\":\"query\",\"schema\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":120000,\"default\":30000}}],\"responses\":{\"200\":{\"description\":\"Latest job state\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/jobs/{jobId}/cancel\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Cancel an active job\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"responses\":{\"200\":{\"description\":\"Job cancelled\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/JobStatus\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"},\"409\":{\"$ref\":\"#/components/responses/Conflict\"}}}},\"/api/jobs/{jobId}/secrets\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Get secrets for an assigned worker job\",\"security\":[{\"workerBearerAuth\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"},{\"$ref\":\"#/components/parameters/WorkerId\"}],\"responses\":{\"200\":{\"description\":\"Decrypted job secrets\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Secrets\"}}}},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"}}}},\"/api/runs/{jobId}\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Get a run view\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"responses\":{\"200\":{\"description\":\"Run details\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/RunView\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/runs/{jobId}/artifacts/{path}\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Download an artifact\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"},{\"name\":\"path\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"responses\":{\"200\":{\"description\":\"Artifact bytes\",\"content\":{\"application/octet-stream\":{\"schema\":{\"type\":\"string\",\"format\":\"binary\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/runs/{jobId}/ai-help\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Ask AI to diagnose a failed step\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"stepId\"],\"properties\":{\"stepId\":{\"type\":\"string\"}}}}}},\"responses\":{\"200\":{\"description\":\"AI diagnosis\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"}}}}}},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"502\":{\"description\":\"AI provider failure\"},\"503\":{\"description\":\"AI help is not configured\"}}}},\"/api/workflows/validate\":{\"post\":{\"tags\":[\"workflows\"],\"summary\":\"Validate workflow YAML\",\"security\":[{\"oidcSessionCookie\":[]}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/WorkflowSource\"}}}},\"responses\":{\"200\":{\"description\":\"Valid workflow definitions\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ValidationResult\"}}}},\"422\":{\"$ref\":\"#/components/responses/ValidationError\"}}}},\"/api/ai/workflow-help\":{\"post\":{\"tags\":[\"workflows\"],\"summary\":\"Get a workflow YAML suggestion\",\"security\":[{\"oidcSessionCookie\":[]}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"request\"],\"properties\":{\"request\":{\"type\":\"string\"},\"sourceYaml\":{\"type\":\"string\"}}}}}},\"responses\":{\"200\":{\"description\":\"Workflow YAML suggestion stream\",\"content\":{\"text/event-stream\":{\"schema\":{\"type\":\"string\"}}}},\"400\":{\"$ref\":\"#/components/responses/ValidationError\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"502\":{\"description\":\"AI provider failure\"},\"503\":{\"description\":\"AI help is not configured\"}}}},\"/api/workflows\":{\"get\":{\"tags\":[\"workflows\"],\"summary\":\"List workflows\",\"security\":[{\"oidcSessionCookie\":[]}],\"responses\":{\"200\":{\"description\":\"Workflow list\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"workflows\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/Workflow\"}}}}}}}}}},\"/api/users\":{\"get\":{\"tags\":[\"users\"],\"summary\":\"List registered OIDC users\",\"description\":\"Administrator OIDC sessions only. Bearer and basic credentials are not accepted.\",\"security\":[{\"oidcAdminSession\":[]}],\"responses\":{\"200\":{\"description\":\"User roster\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/UserList\"}}}},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"}}}},\"/api/users/{subject}/role\":{\"put\":{\"tags\":[\"users\"],\"summary\":\"Change a user's OIDC role\",\"description\":\"Administrator OIDC sessions only. The final administrator cannot be demoted.\",\"security\":[{\"oidcAdminSession\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/UserSubject\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/UserRoleUpdate\"}}}},\"responses\":{\"200\":{\"description\":\"Role updated\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/UserRoleResponse\"}}}},\"400\":{\"$ref\":\"#/components/responses/ValidationError\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"},\"409\":{\"$ref\":\"#/components/responses/Conflict\"}}}},\"/api/workflows/{workflowId}\":{\"get\":{\"tags\":[\"workflows\"],\"summary\":\"Get a workflow or historical revision\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"},{\"$ref\":\"#/components/parameters/Revision\"}],\"responses\":{\"200\":{\"description\":\"Workflow definition\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Workflow\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}},\"put\":{\"tags\":[\"workflows\"],\"summary\":\"Save a workflow draft\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/WorkflowSave\"}}}},\"responses\":{\"200\":{\"description\":\"Draft saved\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Workflow\"}}}},\"422\":{\"$ref\":\"#/components/responses/ValidationError\"}}},\"delete\":{\"tags\":[\"workflows\"],\"summary\":\"Delete a workflow and its revisions\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"}],\"responses\":{\"204\":{\"description\":\"Workflow deleted\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/workflows/{workflowId}/publish\":{\"post\":{\"tags\":[\"workflows\"],\"summary\":\"Publish the latest workflow revision\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"}],\"responses\":{\"200\":{\"description\":\"Workflow published\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Workflow\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/secrets\":{\"get\":{\"tags\":[\"secrets\"],\"summary\":\"List secret names\",\"security\":[{\"oidcSessionCookie\":[]}],\"responses\":{\"200\":{\"description\":\"Secret names\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/SecretNames\"}}}}}}},\"/api/secrets/{name}\":{\"put\":{\"tags\":[\"secrets\"],\"summary\":\"Create or replace an encrypted secret\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/SecretName\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/SecretValue\"}}}},\"responses\":{\"204\":{\"description\":\"Secret saved\"}}},\"delete\":{\"tags\":[\"secrets\"],\"summary\":\"Delete a secret\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/SecretName\"}],\"responses\":{\"204\":{\"description\":\"Secret deleted\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/events\":{\"get\":{\"tags\":[\"events\"],\"summary\":\"Subscribe to runner events\",\"security\":[],\"responses\":{\"200\":{\"description\":\"Server-sent event stream\",\"content\":{\"text/event-stream\":{\"schema\":{\"type\":\"string\"}}}}}},\"post\":{\"tags\":[\"events\"],\"summary\":\"Publish a worker job-change event\",\"security\":[{\"workerBearerAuth\":[]}],\"requestBody\":{\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/JobEvent\"}}}},\"responses\":{\"202\":{\"description\":\"Event accepted\"}}}},\"/api/push/public-key\":{\"get\":{\"tags\":[\"notifications\"],\"summary\":\"Get the Web Push public key\",\"security\":[],\"responses\":{\"200\":{\"description\":\"VAPID public key\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"publicKey\":{\"type\":\"string\"}}}}}}}}},\"/api/push/subscriptions\":{\"post\":{\"tags\":[\"notifications\"],\"summary\":\"Register a Web Push subscription\",\"security\":[],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"201\":{\"description\":\"Subscription saved\"}}},\"delete\":{\"tags\":[\"notifications\"],\"summary\":\"Remove a Web Push subscription\",\"security\":[],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"endpoint\"],\"properties\":{\"endpoint\":{\"type\":\"string\",\"format\":\"uri\"}}}}}},\"responses\":{\"204\":{\"description\":\"Subscription removed\"}}}},\"/webhooks/{provider}\":{\"post\":{\"tags\":[\"webhooks\"],\"summary\":\"Receive a provider webhook\",\"security\":[],\"parameters\":[{\"name\":\"provider\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"202\":{\"description\":\"Webhook accepted\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"413\":{\"$ref\":\"#/components/responses/PayloadTooLarge\"}}}},\"/restart/{jobId}\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Create a new run from the active workflow revision\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"responses\":{\"201\":{\"description\":\"New job created\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"integer\"}}}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/admin/reload-secrets\":{\"post\":{\"tags\":[\"secrets\"],\"summary\":\"Reload legacy in-memory secrets\",\"responses\":{\"200\":{\"description\":\"Secrets reloaded\"},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"}}}}}"),
1882
+ paths: /* @__PURE__ */ JSON.parse("{\"/api\":{\"get\":{\"tags\":[\"discovery\"],\"summary\":\"Get this OpenAPI specification\",\"security\":[],\"responses\":{\"200\":{\"description\":\"OpenAPI JSON document\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}}}}},\"/api/api-keys\":{\"get\":{\"tags\":[\"discovery\"],\"summary\":\"List active API keys\",\"responses\":{\"200\":{\"description\":\"API key metadata\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ApiKeyList\"}}}}}},\"post\":{\"tags\":[\"discovery\"],\"summary\":\"Issue a scoped API key\",\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ApiKeyIssue\"}}}},\"responses\":{\"201\":{\"description\":\"Key metadata and one-time token\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ApiKeyCreated\"}}}}}}},\"/api/api-keys/{keyId}\":{\"delete\":{\"tags\":[\"discovery\"],\"summary\":\"Revoke an API key\",\"parameters\":[{\"name\":\"keyId\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\",\"format\":\"uuid\"}}],\"responses\":{\"204\":{\"description\":\"Key revoked\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/jobs\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"List jobs\",\"security\":[],\"parameters\":[{\"$ref\":\"#/components/parameters/AfterId\"},{\"$ref\":\"#/components/parameters/BeforeId\"},{\"$ref\":\"#/components/parameters/Limit\"},{\"$ref\":\"#/components/parameters/Filter\"},{\"$ref\":\"#/components/parameters/WorkflowFilter\"}],\"responses\":{\"200\":{\"description\":\"Dashboard job list\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/JobList\"}}}}}}},\"/api/dispatch/{trigger}\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Dispatch a workflow trigger with manual inputs\",\"security\":[{\"bearerAuth\":[]},{\"basicAuth\":[]}],\"parameters\":[{\"name\":\"trigger\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"202\":{\"description\":\"Jobs queued\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"}}}},\"/api/workflows/{workflowId}/run\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Run the latest saved workflow revision immediately\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"name\":\"workflowId\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"202\":{\"description\":\"Jobs queued\"},\"400\":{\"$ref\":\"#/components/responses/ValidationError\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"}}}},\"/api/jobs/{jobId}/wait\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Wait for a job to reach a terminal state\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"},{\"name\":\"timeout\",\"in\":\"query\",\"schema\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":120000,\"default\":30000}}],\"responses\":{\"200\":{\"description\":\"Latest job state\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/jobs/{jobId}/cancel\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Cancel an active job\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"responses\":{\"200\":{\"description\":\"Job cancelled\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/JobStatus\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"},\"409\":{\"$ref\":\"#/components/responses/Conflict\"}}}},\"/api/jobs/{jobId}/secrets\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Get secrets for an assigned worker job\",\"security\":[{\"workerBearerAuth\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"},{\"$ref\":\"#/components/parameters/WorkerId\"}],\"responses\":{\"200\":{\"description\":\"Decrypted job secrets\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Secrets\"}}}},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"}}}},\"/api/runs/{jobId}\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Get a run view\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"responses\":{\"200\":{\"description\":\"Run details\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/RunView\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/runs/{jobId}/artifacts/{path}\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Download an artifact\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"},{\"name\":\"path\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"responses\":{\"200\":{\"description\":\"Artifact bytes\",\"content\":{\"application/octet-stream\":{\"schema\":{\"type\":\"string\",\"format\":\"binary\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/runs/{jobId}/ai-help\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Ask AI to diagnose a failed step\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"stepId\"],\"properties\":{\"stepId\":{\"type\":\"string\"}}}}}},\"responses\":{\"200\":{\"description\":\"AI diagnosis\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"}}}}}},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"502\":{\"description\":\"AI provider failure\"},\"503\":{\"description\":\"AI help is not configured\"}}}},\"/api/workflows/validate\":{\"post\":{\"tags\":[\"workflows\"],\"summary\":\"Validate workflow YAML\",\"security\":[{\"oidcSessionCookie\":[]}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/WorkflowSource\"}}}},\"responses\":{\"200\":{\"description\":\"Valid workflow definitions\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ValidationResult\"}}}},\"422\":{\"$ref\":\"#/components/responses/ValidationError\"}}}},\"/api/ai/workflow-help\":{\"post\":{\"tags\":[\"workflows\"],\"summary\":\"Get a workflow YAML suggestion\",\"security\":[{\"oidcSessionCookie\":[]}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"request\"],\"properties\":{\"request\":{\"type\":\"string\"},\"sourceYaml\":{\"type\":\"string\"}}}}}},\"responses\":{\"200\":{\"description\":\"Workflow YAML suggestion stream\",\"content\":{\"text/event-stream\":{\"schema\":{\"type\":\"string\"}}}},\"400\":{\"$ref\":\"#/components/responses/ValidationError\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"502\":{\"description\":\"AI provider failure\"},\"503\":{\"description\":\"AI help is not configured\"}}}},\"/api/workflows\":{\"get\":{\"tags\":[\"workflows\"],\"summary\":\"List workflows\",\"security\":[{\"oidcSessionCookie\":[]}],\"responses\":{\"200\":{\"description\":\"Workflow list\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"workflows\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/Workflow\"}}}}}}}}}},\"/api/users\":{\"get\":{\"tags\":[\"users\"],\"summary\":\"List registered OIDC users\",\"description\":\"Administrator OIDC sessions only. Bearer and basic credentials are not accepted.\",\"security\":[{\"oidcAdminSession\":[]}],\"responses\":{\"200\":{\"description\":\"User roster\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/UserList\"}}}},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"}}}},\"/api/users/{subject}/role\":{\"put\":{\"tags\":[\"users\"],\"summary\":\"Change a user's OIDC role\",\"description\":\"Administrator OIDC sessions only. The final administrator cannot be demoted.\",\"security\":[{\"oidcAdminSession\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/UserSubject\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/UserRoleUpdate\"}}}},\"responses\":{\"200\":{\"description\":\"Role updated\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/UserRoleResponse\"}}}},\"400\":{\"$ref\":\"#/components/responses/ValidationError\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"},\"409\":{\"$ref\":\"#/components/responses/Conflict\"}}}},\"/api/preferences\":{\"get\":{\"tags\":[\"settings\"],\"summary\":\"Get the signed-in user's UI preferences\",\"security\":[{\"oidcSessionCookie\":[]}],\"responses\":{\"200\":{\"description\":\"User-specific preferences\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/UiPreferences\"}}}},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"}}},\"put\":{\"tags\":[\"settings\"],\"summary\":\"Set the signed-in user's UI preferences\",\"security\":[{\"oidcSessionCookie\":[]}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/UiPreferencesUpdate\"}}}},\"responses\":{\"200\":{\"description\":\"Updated preferences\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/UiPreferences\"}}}},\"400\":{\"$ref\":\"#/components/responses/ValidationError\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"}}}},\"/api/workflows/{workflowId}\":{\"get\":{\"tags\":[\"workflows\"],\"summary\":\"Get a workflow or historical revision\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"},{\"$ref\":\"#/components/parameters/Revision\"}],\"responses\":{\"200\":{\"description\":\"Workflow definition\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Workflow\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}},\"put\":{\"tags\":[\"workflows\"],\"summary\":\"Save a workflow draft\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/WorkflowSave\"}}}},\"responses\":{\"200\":{\"description\":\"Draft saved\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Workflow\"}}}},\"422\":{\"$ref\":\"#/components/responses/ValidationError\"}}},\"delete\":{\"tags\":[\"workflows\"],\"summary\":\"Delete a workflow and its revisions\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"}],\"responses\":{\"204\":{\"description\":\"Workflow deleted\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/workflows/{workflowId}/publish\":{\"post\":{\"tags\":[\"workflows\"],\"summary\":\"Publish the latest workflow revision\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"}],\"responses\":{\"200\":{\"description\":\"Workflow published\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Workflow\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/secrets\":{\"get\":{\"tags\":[\"secrets\"],\"summary\":\"List secret names\",\"security\":[{\"oidcSessionCookie\":[]}],\"responses\":{\"200\":{\"description\":\"Secret names\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/SecretNames\"}}}}}}},\"/api/secrets/{name}\":{\"put\":{\"tags\":[\"secrets\"],\"summary\":\"Create or replace an encrypted secret\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/SecretName\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/SecretValue\"}}}},\"responses\":{\"204\":{\"description\":\"Secret saved\"}}},\"delete\":{\"tags\":[\"secrets\"],\"summary\":\"Delete a secret\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/SecretName\"}],\"responses\":{\"204\":{\"description\":\"Secret deleted\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/events\":{\"get\":{\"tags\":[\"events\"],\"summary\":\"Subscribe to runner events\",\"security\":[],\"responses\":{\"200\":{\"description\":\"Server-sent event stream\",\"content\":{\"text/event-stream\":{\"schema\":{\"type\":\"string\"}}}}}},\"post\":{\"tags\":[\"events\"],\"summary\":\"Publish a worker job-change event\",\"security\":[{\"workerBearerAuth\":[]}],\"requestBody\":{\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/JobEvent\"}}}},\"responses\":{\"202\":{\"description\":\"Event accepted\"}}}},\"/api/push/public-key\":{\"get\":{\"tags\":[\"notifications\"],\"summary\":\"Get the Web Push public key\",\"security\":[],\"responses\":{\"200\":{\"description\":\"VAPID public key\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"publicKey\":{\"type\":\"string\"}}}}}}}}},\"/api/push/subscriptions\":{\"post\":{\"tags\":[\"notifications\"],\"summary\":\"Register a Web Push subscription\",\"security\":[],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"201\":{\"description\":\"Subscription saved\"}}},\"delete\":{\"tags\":[\"notifications\"],\"summary\":\"Remove a Web Push subscription\",\"security\":[],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"endpoint\"],\"properties\":{\"endpoint\":{\"type\":\"string\",\"format\":\"uri\"}}}}}},\"responses\":{\"204\":{\"description\":\"Subscription removed\"}}}},\"/webhooks/{provider}\":{\"post\":{\"tags\":[\"webhooks\"],\"summary\":\"Receive a provider webhook\",\"security\":[],\"parameters\":[{\"name\":\"provider\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"202\":{\"description\":\"Webhook accepted\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"413\":{\"$ref\":\"#/components/responses/PayloadTooLarge\"}}}},\"/restart/{jobId}\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Create a new run from the active workflow revision\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"responses\":{\"201\":{\"description\":\"New job created\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"integer\"}}}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/admin/reload-secrets\":{\"post\":{\"tags\":[\"secrets\"],\"summary\":\"Reload legacy in-memory secrets\",\"responses\":{\"200\":{\"description\":\"Secrets reloaded\"},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"}}}}}"),
1883
1883
  components: {
1884
1884
  securitySchemes: {
1885
1885
  bearerAuth: {
@@ -2096,6 +2096,23 @@ var nn = {
2096
2096
  required: ["user"],
2097
2097
  properties: { user: { $ref: "#/components/schemas/User" } }
2098
2098
  },
2099
+ UiPreferences: {
2100
+ type: "object",
2101
+ required: ["timezone"],
2102
+ properties: { timezone: {
2103
+ type: "string",
2104
+ nullable: !0
2105
+ } }
2106
+ },
2107
+ UiPreferencesUpdate: {
2108
+ type: "object",
2109
+ required: ["timezone"],
2110
+ properties: { timezone: {
2111
+ type: "string",
2112
+ minLength: 1,
2113
+ maxLength: 100
2114
+ } }
2115
+ },
2099
2116
  WorkflowSource: {
2100
2117
  type: "object",
2101
2118
  required: ["sourceYaml"],
@@ -12339,11 +12356,22 @@ function ng(e) {
12339
12356
  return e.replace(/\/api\/?$/, "").replace(/\/$/, "");
12340
12357
  }
12341
12358
  //#endregion
12342
- //#region src/api-client.mjs?raw
12343
- var rg = "let token = sessionStorage.getItem('runner-api-token') || '';\nlet expiresAt = token ? Number.MAX_SAFE_INTEGER : 0;\n\nconst rawFetch = window.fetch.bind(window);\n\nexport async function loadToken() {\n if (token && expiresAt > Date.now() + 30_000) return token;\n const response = await rawFetch('/api/auth/token', {\n headers: { accept: 'application/json' },\n });\n if (!response.ok) {\n if (response.status === 401) {\n // When /api/auth/token returns 401, clear OIDC in-memory token state and redirect\n // to login so the user can re-authenticate. This even applies when there was\n // no prior cached token, fixing the reported symptom where 401 was silently\n // swallowed and settings APIs stayed broken.\n token = '';\n expiresAt = 0;\n if (sessionStorage.getItem('runner-api-token')) {\n sessionStorage.removeItem('runner-api-token');\n }\n const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;\n window.location.assign(`/auth/login?url=${encodeURIComponent(returnTo)}`);\n }\n return '';\n }\n const data = await response.json();\n token = data.access_token || '';\n expiresAt = Number(data.expires_at || 0);\n return token;\n}\n\nexport async function apiFetch(input, init = {}) {\n const target = new URL(input, window.location.href);\n if (\n target.origin !== window.location.origin ||\n (!target.pathname.startsWith('/api/') && !target.pathname.startsWith('/restart/'))\n ) {\n return rawFetch(input, init);\n }\n const headers = new Headers(init.headers);\n const accessToken = await loadToken();\n if (accessToken) headers.set('authorization', `Bearer ${accessToken}`);\n return rawFetch(input, { ...init, headers });\n}\n\nexport function setApiToken(value) {\n token = value || '';\n expiresAt = token ? Number.MAX_SAFE_INTEGER : 0;\n if (token) sessionStorage.setItem('runner-api-token', token);\n else sessionStorage.removeItem('runner-api-token');\n}\n", ig = "import { defineProp, onInit, ref } from '@li3/web';\n\nexport default function () {\n const authConfigured = ref(false);\n const authenticated = ref(false);\n const userName = ref('');\n const userPhoto = ref('');\n onInit(async () => {\n try {\n const response = await fetch('/api/auth/session');\n if (!response.ok) return;\n const session = await response.json();\n authConfigured.value = session.configured;\n authenticated.value = session.authenticated;\n userName.value = session.user?.name || session.user?.email || '';\n userPhoto.value = session.user?.photo || '';\n } catch {}\n });\n return { title: defineProp('title'), subtitle: defineProp('subtitle'), back: defineProp('back'), backLabel: defineProp('back-label'), authConfigured, authenticated, userName, userPhoto };\n}\n", ag = "import { getElement, load, onDestroy, onInit, templateRef } from '@li3/web';\n\nexport default function () {\n const outlet = templateRef('outlet');\n let navigating = false;\n const route = () => {\n const url = new URL(window.location.href);\n const path = url.pathname;\n if (path === '/' || path === '/runs')\n return { component: 'page-dashboard', url: '/pages/dashboard.html', page: 'dashboard' };\n if (path.match(/^\\/runs\\/\\d+$/))\n return { component: 'page-run', url: `/pages/run.html?jobId=${path.split('/').pop()}`, page: 'run' };\n if (path === '/help') return { component: 'page-help', url: '/pages/help.html', page: 'help' };\n if (path === '/settings/workflows')\n return { component: 'page-workflows', url: '/pages/workflows.html?page=workflows', page: 'workflows' };\n if (path === '/settings/secrets')\n return { component: 'page-secrets', url: '/pages/workflows.html?page=secrets', page: 'secrets' };\n if (path === '/settings/tokens')\n return { component: 'page-settings', url: '/pages/settings.html?page=tokens', page: 'tokens' };\n if (path === '/settings/notifications')\n return { component: 'page-settings', url: '/pages/settings.html?page=notifications', page: 'notifications' };\n if (path === '/settings/workers')\n return { component: 'page-settings', url: '/pages/settings.html?page=workers', page: 'workers' };\n if (path === '/settings' || path === '/workflows')\n return { component: 'page-workflows', url: '/pages/workflows.html?page=workflows', page: 'workflows' };\n const editor = path.match(/^\\/settings\\/workflows\\/(new|[a-z0-9-]+)$/);\n if (editor)\n return {\n component: 'page-workflow-editor',\n url: `/pages/workflows.html?page=editor&id=${editor[1]}${url.searchParams.get('revision') ? `&revision=${url.searchParams.get('revision')}` : ''}`,\n page: 'editor',\n };\n const legacyEditor = path.match(/^\\/workflows\\/(new|[a-z0-9-]+)$/);\n if (legacyEditor)\n return {\n component: 'page-workflow-editor',\n url: `/pages/workflows.html?page=editor&id=${legacyEditor[1]}${url.searchParams.get('revision') ? `&revision=${url.searchParams.get('revision')}` : ''}`,\n page: 'editor',\n };\n return { component: 'page-not-found', page: 'not-found' };\n };\n const navigate = async (replace = false) => {\n if (navigating) return;\n navigating = true;\n try {\n const current = route();\n document.body.dataset.page = current.page;\n if (current.page === 'not-found') {\n outlet.value.innerHTML =\n '<main class=\"mx-auto max-w-4xl p-8\"><h1 class=\"text-xl font-semibold\">404 — Page not found</h1><p class=\"mt-2 text-sm\">The page you are looking for does not exist. <a href=\"/\">Go home</a> or <a href=\"/runs\">view runs</a>.</p></main>';\n } else {\n await load(current.url);\n outlet.value.replaceChildren(document.createElement(current.component));\n }\n if (replace) history.replaceState(null, '', window.location.href);\n } catch (error) {\n outlet.value.innerHTML = `<main class=\"mx-auto max-w-4xl p-8\"><h1 class=\"text-xl font-semibold text-white\">Unable to load page</h1><p class=\"mt-2 text-sm text-rose-300\">${String(error.message || error)}</p></main>`;\n } finally {\n navigating = false;\n }\n };\n const handleClick = (event) => {\n const anchor = event.target.closest?.('a');\n if (!anchor || anchor.target || anchor.hasAttribute('download')) return;\n const target = new URL(anchor.href, window.location.href);\n if (\n target.origin !== window.location.origin ||\n !target.pathname.startsWith('/') ||\n target.pathname.startsWith('/auth/')\n )\n return;\n if (target.pathname.startsWith('/api/') || target.pathname.startsWith('/webhooks/')) return;\n if (target.pathname === window.location.pathname && target.hash) return;\n event.preventDefault();\n history.pushState(null, '', `${target.pathname}${target.search}${target.hash}`);\n void navigate();\n };\n const element = getElement();\n onInit(() => {\n element.addEventListener('click', handleClick);\n window.addEventListener('popstate', navigate);\n void navigate(true);\n });\n onDestroy(() => {\n element.removeEventListener('click', handleClick);\n window.removeEventListener('popstate', navigate);\n });\n return {};\n}\n", og = "import { onDestroy, onInit, ref } from '@li3/web';\nimport { apiFetch } from '@app/api-client.mjs';\n\nexport default function setup() {\n const jobs = ref([]), refreshError = ref(false), hasMore = ref(false), loadingMore = ref(false);\n const filter = ref(''), activeFilter = ref(''), pushConfigured = ref(false);\n const notificationsSupported = ref('Notification' in window && 'serviceWorker' in navigator);\n const notificationsEnabled = ref(notificationsSupported.value && Notification.permission === 'granted' && localStorage.getItem('runner-notifications') === 'enabled');\n const notificationNotice = ref('');\n let refreshTimer, eventSource, refreshing = false, refreshPending = false, searchInProgress = false, refreshGeneration = 0;\n const upper = (value) => String(value || '').toUpperCase();\n const terminalStatuses = new Set(['success', 'failed', 'cancelled']);\n const notifyCompletedJob = async (job) => {\n if (!notificationsEnabled.value || pushConfigured.value || !['success', 'failed'].includes(job.status)) return;\n try {\n const registration = await navigator.serviceWorker.ready;\n await registration.showNotification(job.status === 'success' ? `Job #${job.id} succeeded` : `Job #${job.id} failed`, { body: `${job.workflowId} finished with status ${job.status}.`, icon: '/app-icon.svg', badge: '/app-icon.svg', tag: `runner-job-${job.id}`, renotify: true, data: { url: `/runs/${job.id}` } });\n } catch (error) { console.error('Unable to show job notification', error); }\n };\n const urlBase64ToUint8Array = (value) => { const padding = '='.repeat((4 - (value.length % 4)) % 4); const raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from([...raw].map((character) => character.charCodeAt(0))); };\n const enablePushNotifications = async () => {\n try {\n if (!notificationsSupported.value) return;\n const response = await apiFetch('/api/push/public-key', { headers: { accept: 'application/json' } });\n if (!response.ok) return;\n const { publicKey } = await response.json(); pushConfigured.value = true;\n const registration = await navigator.serviceWorker.ready;\n const subscription = (await registration.pushManager.getSubscription()) || await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });\n const saved = await apiFetch('/api/push/subscriptions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(subscription) });\n if (!saved.ok) throw new Error(`Push subscription failed: ${saved.status}`);\n } catch (error) { console.error('Unable to enable background notifications', error); }\n };\n const disablePushNotifications = async () => {\n try { const subscription = await (await navigator.serviceWorker.ready).pushManager.getSubscription(); if (!subscription) return; await apiFetch('/api/push/subscriptions', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ endpoint: subscription.endpoint }) }); await subscription.unsubscribe(); }\n catch (error) { console.error('Unable to disable background notifications', error); }\n };\n const toggleNotifications = async () => {\n if (notificationsEnabled.value) { notificationsEnabled.value = false; localStorage.removeItem('runner-notifications'); await disablePushNotifications(); notificationNotice.value = 'Job notifications are paused on this device.'; return; }\n if (Notification.permission === 'denied') { notificationNotice.value = 'Notifications are blocked. Allow them in this site’s Android browser settings.'; return; }\n notificationsEnabled.value = (await Notification.requestPermission()) === 'granted';\n if (notificationsEnabled.value) { localStorage.setItem('runner-notifications', 'enabled'); await enablePushNotifications(); notificationNotice.value = 'Notifications enabled. Background delivery is available when server push is configured.'; } else notificationNotice.value = 'Notification permission was not granted.';\n };\n const refreshJobs = async (isSearch = false) => {\n if (searchInProgress && !isSearch) return; if (refreshing && !isSearch) { refreshPending = true; return; }\n refreshing = true; const generation = refreshGeneration;\n try {\n const activeIds = jobs.value.filter((job) => !terminalStatuses.has(job.status)).map((job) => Number(job.id));\n const knownIds = jobs.value.map((job) => Number(job.id));\n const afterId = activeIds.length ? Math.max(0, Math.min(...activeIds) - 1) : knownIds.length ? Math.max(...knownIds) : null;\n const filterQuery = activeFilter.value ? `&filter=${encodeURIComponent(activeFilter.value)}` : '';\n const url = afterId === null ? `/api/jobs?limit=100${filterQuery}` : `/api/jobs?afterId=${afterId}&limit=100${filterQuery}`;\n const response = await apiFetch(url, { headers: { accept: 'application/json' } }); if (!response.ok) throw new Error(`Dashboard refresh failed: ${response.status}`);\n const data = await response.json(); if (generation !== refreshGeneration) return; hasMore.value = data.hasMore;\n const previousStatuses = new Map(jobs.value.map((job) => [job.id, job.status])); const merged = new Map(jobs.value.map((job) => [job.id, job]));\n for (const job of data.jobs) { const previousStatus = previousStatuses.get(job.id); merged.set(job.id, job); if (previousStatus && !terminalStatuses.has(previousStatus) && terminalStatuses.has(job.status)) void notifyCompletedJob(job); }\n jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id)); refreshError.value = false;\n } catch (error) { console.error(error); refreshError.value = true; } finally { refreshing = false; if (refreshPending && !searchInProgress) { refreshPending = false; void refreshJobs(); } }\n };\n const loadMore = async () => { if (loadingMore.value || !hasMore.value || !jobs.value.length) return; loadingMore.value = true; try { const beforeId = Math.min(...jobs.value.map((job) => Number(job.id))); const filterQuery = activeFilter.value ? `&filter=${encodeURIComponent(activeFilter.value)}` : ''; const response = await apiFetch(`/api/jobs?beforeId=${beforeId}${filterQuery}`, { headers: { accept: 'application/json' } }); if (!response.ok) throw new Error(`Loading older jobs failed: ${response.status}`); const data = await response.json(); const merged = new Map(jobs.value.map((job) => [job.id, job])); for (const job of data.jobs) merged.set(job.id, job); jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id)); hasMore.value = data.hasMore; } catch (error) { console.error(error); refreshError.value = true; } finally { loadingMore.value = false; } };\n const updateSearchUrl = (value) => { const params = new URLSearchParams(window.location.search); if (value) params.set('search', value); else params.delete('search'); const query = params.toString(); history.pushState(null, '', `${window.location.pathname}${query ? `?${query}` : ''}`); };\n const applyFilter = async () => { searchInProgress = true; refreshGeneration++; activeFilter.value = filter.value.trim(); updateSearchUrl(activeFilter.value); jobs.value = []; hasMore.value = false; try { await refreshJobs(true); } finally { searchInProgress = false; refreshPending = false; } };\n const clearFilter = async () => { searchInProgress = true; refreshGeneration++; filter.value = ''; activeFilter.value = ''; updateSearchUrl(''); jobs.value = []; hasMore.value = false; try { await refreshJobs(true); } finally { searchInProgress = false; refreshPending = false; } };\n const setFilter = (event) => { filter.value = event.target.value; };\n onInit(() => { const initialSearch = new URLSearchParams(window.location.search).get('search')?.trim() || ''; filter.value = initialSearch; activeFilter.value = initialSearch; if (notificationsSupported.value) navigator.serviceWorker.register('/service-worker.js').then(() => { if (notificationsEnabled.value) void enablePushNotifications(); }).catch((error) => console.error('Service worker registration failed', error)); eventSource = new EventSource('/api/events'); eventSource.addEventListener('jobs.available', refreshJobs); eventSource.addEventListener('jobs.changed', refreshJobs); eventSource.onerror = () => { refreshError.value = true; }; void refreshJobs(); refreshTimer = setInterval(refreshJobs, 60000); });\n onDestroy(() => { eventSource?.close(); clearInterval(refreshTimer); });\n return { jobs, refreshError, hasMore, loadingMore, filter, activeFilter, pushConfigured, setFilter, loadMore, applyFilter, clearFilter, upper, notificationsSupported, notificationsEnabled, notificationNotice, toggleNotifications };\n}\n", sg = "\n import { computed, onDestroy, onInit, ref } from '@li3/web';\n import { AnsiUp } from 'ansi_up';\n import { marked } from 'marked';\n import { apiFetch } from '@app/api-client.mjs';\n\n export default function setup() {\n const jobId = window.location.pathname.split('/').filter(Boolean).pop() || '';\n const report = ref({ jobId });\n const previousRuns = ref([]);\n const aiMessages = ref([]);\n const aiStepId = ref('');\n const aiQuestion = ref('');\n const aiError = ref('');\n const aiLoading = ref(false);\n const manualRestartOpen = ref(false);\n const manualRestartLoading = ref(false);\n const manualInputs = ref([]);\n let previousRunsLoaded = false;\n const now = ref(Date.now());\n const ansiUp = new AnsiUp();\n ansiUp.use_classes = false;\n let eventSource;\n let refreshTimer;\n let clockTimer;\n let refreshing = false;\n let refreshPending = false;\n const cancelling = ref(false);\n\n const active = computed(() => ['pending', 'running'].includes(report.value.status));\n const inputsJson = computed(() => JSON.stringify(report.value.inputs || {}, null, 2));\n const originalInputsJson = computed(() => JSON.stringify(report.value.inputs || {}, null, 2));\n const timing = computed(() => {\n if (report.value.status === 'pending') return 'Waiting for a worker';\n if (report.value.status === 'running') {\n return `Running for ${Math.max(0, now.value - Date.parse(report.value.startedAt))}ms`;\n }\n return `Finished in ${report.value.durationMs}ms`;\n });\n\n const upper = (value) => String(value || '').toUpperCase();\n const artifactUrl = (path) => `/api/runs/${report.value.jobId}/artifacts/${encodeURIComponent(path)}`;\n const downloadArtifact = async (event, path) => {\n event.preventDefault();\n const response = await apiFetch(artifactUrl(path));\n if (!response.ok) return;\n const link = document.createElement('a');\n link.href = URL.createObjectURL(await response.blob());\n link.download = path.split('/').pop() || 'artifact';\n link.click();\n URL.revokeObjectURL(link.href);\n };\n const loadPreviousRuns = async () => {\n if (previousRunsLoaded || !report.value.parentId) return;\n const lineage = [];\n const visited = new Set();\n let parentId = report.value.parentId;\n while (parentId && !visited.has(String(parentId)) && lineage.length < 50) {\n visited.add(String(parentId));\n const response = await apiFetch(`/api/runs/${parentId}`, { headers: { accept: 'application/json' } });\n if (!response.ok) throw new Error(`Previous run request failed: ${response.status}`);\n const parent = await response.json();\n lineage.push({ id: parent.jobId, status: parent.status, startedAt: parent.startedAt });\n parentId = parent.parentId;\n }\n previousRuns.value = lineage;\n previousRunsLoaded = true;\n };\n const stepLog = (step) => {\n if (step.status === 'skipped') return '';\n if (step.status === 'running')\n return '<span class=\"text-indigo-400\">Step is running. Logs will appear after it finishes.</span>';\n if (step.status === 'pending') return '<span class=\"text-gray-500\">Waiting to run.</span>';\n if (step.logContent) return ansiUp.ansi_to_html(step.logContent);\n return '<span class=\"text-gray-500\">(No terminal log output recorded for this step)</span>';\n };\n const sanitizeAiHtml = (html) => {\n const parsed = new DOMParser().parseFromString(html, 'text/html');\n parsed.querySelectorAll('script,style,iframe,object,embed,form').forEach((node) => node.remove());\n parsed.querySelectorAll('*').forEach((element) => {\n [...element.attributes].forEach((attribute) => {\n if (attribute.name.toLowerCase().startsWith('on')) element.removeAttribute(attribute.name);\n if (['href', 'src'].includes(attribute.name.toLowerCase()) && /^(javascript|data):/i.test(attribute.value)) element.removeAttribute(attribute.name);\n });\n });\n return parsed.body.innerHTML;\n };\n const renderAiMarkdown = (content) => sanitizeAiHtml(marked.parse(content));\n const askAi = async (step, event) => {\n event.stopPropagation();\n aiStepId.value = step.id;\n aiMessages.value = [];\n aiQuestion.value = '';\n await requestAiHelp(step.id, 'Please explain why this step failed and what I should do next.');\n };\n const requestAiHelp = async (stepId, question) => {\n aiLoading.value = true;\n aiError.value = '';\n try {\n const response = await apiFetch(`/api/runs/${report.value.jobId}/ai-help`, {\n method: 'POST',\n headers: { accept: 'application/json', 'content-type': 'application/json' },\n body: JSON.stringify({ stepId, question, conversation: aiMessages.value }),\n });\n if (!response.ok) {\n const data = await response.json().catch(() => ({}));\n throw new Error(data.error || `AI help failed: ${response.status}`);\n }\n aiMessages.value = [...aiMessages.value, { role: 'user', content: question }, { role: 'assistant', content: '' }];\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const chunk = await reader.read();\n if (chunk.done) break;\n buffer += decoder.decode(chunk.value, { stream: true });\n const events = buffer.split('\\n\\n');\n buffer = events.pop() || '';\n for (const event of events) {\n const line = event.split('\\n').find((value) => value.startsWith('data: '));\n if (!line) continue;\n const data = JSON.parse(line.slice(6));\n if (data.delta) {\n const messages = [...aiMessages.value];\n messages[messages.length - 1] = { ...messages[messages.length - 1], content: messages[messages.length - 1].content + data.delta };\n aiMessages.value = messages;\n }\n if (data.error) throw new Error(data.error);\n }\n }\n const messages = [...aiMessages.value];\n const answer = messages.at(-1);\n if (answer) messages[messages.length - 1] = { ...answer, html: renderAiMarkdown(answer.content) };\n aiMessages.value = messages;\n } catch (error) {\n aiError.value = error.message;\n } finally {\n aiLoading.value = false;\n }\n };\n const askFollowup = async () => {\n const question = aiQuestion.value.trim();\n if (!question || !aiStepId.value) return;\n aiQuestion.value = '';\n await requestAiHelp(aiStepId.value, question);\n };\n const restartJob = async () => {\n const response = await apiFetch(`/restart/${report.value.jobId}`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ inputs: report.value.inputs || {} }),\n });\n if (response.ok) {\n const { id } = await response.json();\n location.href = `/runs/${id}`;\n }\n };\n const openManualRestart = (event) => {\n event.preventDefault();\n event.stopPropagation();\n manualInputs.value = [{ key: '', value: '' }];\n manualRestartOpen.value = true;\n };\n const closeManualRestart = () => { manualRestartOpen.value = false; };\n const addManualInput = () => { manualInputs.value = [...manualInputs.value, { key: '', value: '' }]; };\n const removeManualInput = (entry) => { manualInputs.value = manualInputs.value.filter((item) => item !== entry); };\n const setManualKey = (entry, event) => { entry.key = event.target.value; manualInputs.value = [...manualInputs.value]; };\n const setManualValue = (entry, event) => { entry.value = event.target.value; manualInputs.value = [...manualInputs.value]; };\n const restartWithInputs = async () => {\n const inputs = Object.fromEntries(manualInputs.value.filter((entry) => entry.key.trim()).map((entry) => [entry.key.trim(), entry.value]));\n manualRestartLoading.value = true;\n try {\n const response = await apiFetch(`/restart/${report.value.jobId}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ inputs }) });\n if (!response.ok) throw new Error(`Restart failed: ${response.status}`);\n const { id } = await response.json();\n location.href = `/runs/${id}`;\n } catch (error) {\n aiError.value = error.message;\n } finally {\n manualRestartLoading.value = false;\n }\n };\n const cancelJob = async () => {\n if (cancelling.value || report.value.status !== 'running') return;\n cancelling.value = true;\n try {\n const response = await apiFetch(`/api/jobs/${report.value.jobId}/cancel`, { method: 'POST' });\n if (!response.ok) throw new Error(`Cancel failed: ${response.status}`);\n await refreshRun();\n } catch (error) {\n console.error(error);\n } finally {\n cancelling.value = false;\n }\n };\n const refreshRun = async () => {\n if (refreshing) {\n refreshPending = true;\n return;\n }\n refreshing = true;\n\n try {\n const response = await apiFetch(`/api/runs/${jobId}`, {\n headers: { accept: 'application/json' },\n });\n if (!response.ok) throw new Error(`Run refresh failed: ${response.status}`);\n const previousStatus = report.value.status;\n const nextReport = await response.json();\n report.value = nextReport;\n document.title = `Run #${nextReport.jobId} - ${nextReport.workflowName}`;\n void loadPreviousRuns();\n if (\n ['pending', 'running'].includes(previousStatus) &&\n ['success', 'failed'].includes(nextReport.status) &&\n 'Notification' in window &&\n 'serviceWorker' in navigator &&\n Notification.permission === 'granted' &&\n localStorage.getItem('runner-notifications') === 'enabled'\n ) {\n const registration = await navigator.serviceWorker.ready;\n await registration.showNotification(\n nextReport.status === 'success'\n ? `Job #${nextReport.jobId} succeeded`\n : `Job #${nextReport.jobId} failed`,\n {\n body: `${nextReport.workflowName} finished with status ${nextReport.status}.`,\n icon: '/app-icon.svg',\n badge: '/app-icon.svg',\n tag: `runner-job-${nextReport.jobId}`,\n renotify: true,\n data: { url: `/runs/${nextReport.jobId}` },\n },\n );\n }\n } catch (error) {\n console.error(error);\n } finally {\n refreshing = false;\n if (refreshPending) {\n refreshPending = false;\n void refreshRun();\n }\n }\n };\n const handleJobChange = (event) => {\n const data = JSON.parse(event.data || '{}');\n if (!data.jobId || String(data.jobId) === String(report.value.jobId)) void refreshRun();\n };\n\n onInit(() => {\n void refreshRun();\n if ('serviceWorker' in navigator) {\n navigator.serviceWorker.register('/service-worker.js').catch((error) => {\n console.error('Service worker registration failed', error);\n });\n }\n eventSource = new EventSource('/api/events');\n eventSource.addEventListener('jobs.changed', handleJobChange);\n refreshTimer = setInterval(() => {\n if (active.value) void refreshRun();\n }, 60000);\n clockTimer = setInterval(() => {\n now.value = Date.now();\n }, 1000);\n });\n onDestroy(() => {\n eventSource?.close();\n clearInterval(refreshTimer);\n clearInterval(clockTimer);\n });\n\n return { report, previousRuns, manualRestartOpen, manualRestartLoading, manualInputs, aiMessages, aiStepId, aiQuestion, aiError, aiLoading, inputsJson, originalInputsJson, timing, upper, artifactUrl, downloadArtifact, stepLog, askAi, askFollowup, restartJob, openManualRestart, closeManualRestart, addManualInput, removeManualInput, setManualKey, setManualValue, restartWithInputs, cancelJob, cancelling };\n }\n \n", cg = "\n import { onInit, ref } from '@li3/web';\n import { apiFetch, setApiToken } from '@app/api-client.mjs';\n export default function () {\n const page = document.body.dataset.page;\n const keys = ref([]);\n const workers = ref([]);\n const keyName = ref('');\n const newToken = ref('');\n const error = ref('');\n const notificationsSupported = ref('Notification' in window && 'serviceWorker' in navigator);\n const notificationsEnabled = ref(notificationsSupported.value && Notification.permission === 'granted' && localStorage.getItem('runner-notifications') === 'enabled');\n const notificationMessage = ref('');\n const scopeOptions = ref([\n { name: 'workflows:read', label: 'Read workflows', selected: true },\n { name: 'workflows:write', label: 'Modify workflows', selected: false },\n { name: 'logs:read', label: 'Read logs', selected: false },\n { name: 'artifacts:read', label: 'Read artifacts', selected: false },\n { name: 'runs:control', label: 'Control runs', selected: false },\n { name: 'runs:dispatch', label: 'Dispatch runs', selected: false },\n { name: 'workers:read', label: 'Read workers', selected: false },\n { name: 'secrets:read', label: 'Read secret names', selected: false },\n { name: 'secrets:write', label: 'Manage secrets', selected: false },\n ]);\n const api = async (url, options = {}) => { const response = await apiFetch(url, { headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) }, ...options }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`); return body; };\n const load = async () => { keys.value = (await api('/api/api-keys')).keys; };\n const loadWorkers = async () => { workers.value = (await api('/api/workers')).workers; };\n const issueKey = async () => { try { error.value = ''; const result = await api('/api/api-keys', { method: 'POST', body: JSON.stringify({ name: keyName.value, scopes: scopeOptions.value.filter((scope) => scope.selected).map((scope) => scope.name) }) }); newToken.value = result.token; setApiToken(result.token); keyName.value = ''; await load(); } catch (reason) { error.value = reason.message; } };\n const revokeKey = async (key) => { if (!confirm(`Revoke ${key.name}?`)) return; try { await api(`/api/api-keys/${key.id}`, { method: 'DELETE' }); await load(); } catch (reason) { error.value = reason.message; } };\n const setKeyName = (event) => { keyName.value = event.target.value; };\n const toggleScope = (scope) => { scope.selected = !scope.selected; };\n const toggleNotifications = async () => {\n if (notificationsEnabled.value) {\n notificationsEnabled.value = false;\n localStorage.removeItem('runner-notifications');\n notificationMessage.value = 'Notifications disabled on this device.';\n return;\n }\n if (!notificationsSupported.value) { notificationMessage.value = 'Notifications are not supported by this browser.'; return; }\n const permission = await Notification.requestPermission();\n notificationsEnabled.value = permission === 'granted';\n if (notificationsEnabled.value) { localStorage.setItem('runner-notifications', 'enabled'); notificationMessage.value = 'Notifications enabled.'; }\n else notificationMessage.value = 'Notification permission was not granted.';\n };\n onInit(() => { if (page === 'tokens') load().catch((reason) => { error.value = reason.message; }); if (page === 'workers') loadWorkers().catch((reason) => { error.value = reason.message; }); });\n return { page, keys, workers, keyName, newToken, error, scopeOptions, issueKey, revokeKey, setKeyName, toggleScope, notificationsEnabled, notificationMessage, toggleNotifications };\n }\n \n", lg = "\n import { onDestroy, onInit, ref, templateRef } from '@li3/web';\n import { diffLines } from 'diff';\n\n export default function () {\n const page = document.body.dataset.page;\n const params = new URLSearchParams(window.location.search);\n const workflowPath = window.location.pathname.match(/^\\/(?:settings\\/)?workflows\\/(new|[a-z0-9-]+)$/);\n const pathWorkflowId = workflowPath && workflowPath[1] !== \"new\" ? workflowPath[1] : \"\";\n const initialId = params.get(\"id\") || pathWorkflowId;\n const initialRevision = params.get(\"revision\") ? Number(params.get(\"revision\")) : null;\n const readOnly = Boolean(initialRevision);\n const workflows = ref([]);\n const secrets = ref([]);\n const source = ref('');\n const validation = ref({ valid: true });\n const savedSource = ref('');\n const revision = ref(null);\n const revisionDialogOpen = ref(false);\n const revisionNumber = ref(0);\n const revisionDiffHtml = ref('');\n const revisionError = ref('');\n const revisionLoading = ref(false);\n const aiDialogOpen = ref(false);\n const aiRequest = ref('');\n const aiLoading = ref(false);\n const aiError = ref('');\n const aiSuggestion = ref('');\n const aiDiffHtml = ref('');\n const workflowId = ref('');\n const enabled = ref(true);\n const savedEnabled = ref(true);\n const selectedId = ref('');\n const secretName = ref('');\n const secretValue = ref('');\n const fileMode = ref(false);\n const secretFileName = ref('');\n const secretFileData = ref('');\n const notice = ref('');\n const noticeError = ref(false);\n const busy = ref(false);\n const secretValueInput = templateRef('secretValueInput');\n const secretFileInput = templateRef('secretFileInput');\n const secretForm = templateRef('secretForm');\n const helpContent = templateRef('helpContent');\n const helpLoaded = ref(false);\n const helpLoading = ref(false);\n let validationTimer;\n\n const showNotice = (message, error = false) => {\n notice.value = message;\n noticeError.value = error;\n };\n\n const redirectToLogin = () => {\n const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;\n window.location.assign(`/auth/login?url=${encodeURIComponent(returnTo)}`);\n };\n\n const api = async (url, options = {}) => {\n const response = await fetch(url, {\n ...options,\n credentials: \"same-origin\",\n headers: {\n accept: \"application/json\",\n ...(options.body ? { \"content-type\": \"application/json\" } : {}),\n ...(options.headers || {}),\n },\n });\n if (response.status === 401) redirectToLogin();\n const body = response.status === 204 ? null : await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`);\n return body;\n };\n\n const derivedId = () =>\n source.value\n .match(/^\\s*(?:id:\\s*([^\\n]+)|name:\\s*([^\\n]+))/m)\n ?.slice(1)\n .find(Boolean)\n ?.trim()\n .toLowerCase()\n .replace(/[^a-z0-9]/g, \"-\")\n .replace(/(^-|-$)/g, \"\") || \"\";\n\n const run = async (action) => {\n if (busy.value) return;\n\n busy.value = true;\n\n try {\n await action();\n } catch (error) {\n showNotice(error.message, true);\n } finally {\n busy.value = false;\n }\n };\n\n const loadWorkflows = async () => {\n workflows.value = (await api('/api/workflows')).workflows;\n };\n\n const loadSecrets = async () => {\n secrets.value = (await api('/api/secrets')).secrets;\n };\n\n const openWorkflow = async (id) => {\n const workflowUrl = initialRevision\n ? `/api/workflows/${id}?revision=${initialRevision}`\n : `/api/workflows/${id}`;\n const workflow = await api(workflowUrl);\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n source.value = workflow.sourceYaml;\n savedSource.value = workflow.sourceYaml;\n enabled.value = workflow.enabled;\n savedEnabled.value = workflow.enabled;\n revision.value = workflow.revision || null;\n };\n\n const validateSource = async () => {\n return api('/api/workflows/validate', {\n method: 'POST',\n body: JSON.stringify({ sourceYaml: source.value }),\n });\n };\n\n const validate = (ms = 1000) => {\n if (page !== \"editor\") return;\n\n clearTimeout(validationTimer);\n validationTimer = setTimeout(() => {\n validation.value = { ...validation.value, running: true, error: \"\" };\n void validateSource()\n .then((result) => {\n validation.value = { ...result, running: false };\n })\n .catch((error) => {\n validation.value = { valid: false, running: false, error: error.message };\n });\n }, ms);\n };\n\n const save = () =>\n run(async () => {\n const id = (workflowId.value.trim() || derivedId()).toLowerCase();\n if (!id) throw new Error(\"Set an ID or add a workflow name first.\");\n if (!/^[a-z0-9-]+$/.test(id))\n throw new Error(\"Workflow IDs can contain lowercase letters, numbers, and hyphens only.\");\n if (\n initialId &&\n id === selectedId.value &&\n source.value === savedSource.value &&\n enabled.value === savedEnabled.value\n ) {\n showNotice(\"No changes to save.\");\n return;\n }\n const workflow = await api(`/api/workflows/${id}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source.value, enabled: enabled.value }),\n });\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n savedSource.value = source.value;\n savedEnabled.value = enabled.value;\n revision.value = workflow.revision;\n showNotice(`Saved ${workflow.id} as draft revision ${workflow.revision}.`);\n if (!initialId) history.replaceState(null, \"\", `/workflows/${workflow.id}`);\n });\n const publish = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id) throw new Error('Save a workflow before publishing it.');\n const workflow = await api(`/api/workflows/${id}/publish`, { method: 'POST' });\n selectedId.value = workflow.id;\n revision.value = workflow.revision;\n showNotice(`Published ${workflow.id} revision ${workflow.revision}.`);\n });\n const runNow = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id) throw new Error('Save the workflow before running it.');\n if (source.value !== savedSource.value) throw new Error('Save the latest draft before running it.');\n const result = await api(`/api/workflows/${id}/run`, {\n method: 'POST',\n body: JSON.stringify({}),\n });\n showNotice(`Queued ${result.jobs.length} job${result.jobs.length === 1 ? '' : 's'} from revision ${result.revision}.`);\n });\n const remove = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id || !confirm(`Delete ${id} and all of its revisions?`)) return;\n await api(`/api/workflows/${id}`, { method: \"DELETE\" });\n window.location.href = \"/workflows\";\n });\n const saveSecret = () =>\n run(async () => {\n const name = secretName.value.trim().toUpperCase();\n if (!/^[A-Z][A-Z0-9_]*$/.test(name))\n throw new Error(\n \"Secret names must start with a letter and contain only letters, numbers, and underscores.\",\n );\n secretName.value = name;\n if (!name || (fileMode.value ? !secretFileData.value : !secretValue.value))\n throw new Error(fileMode.value ? \"Choose a file first.\" : \"Set a secret value first.\");\n await api(`/api/secrets/${name}`, {\n method: \"PUT\",\n body: JSON.stringify(\n fileMode.value\n ? { value: secretFileData.value, encoding: \"base64\", originalName: secretFileName.value }\n : { value: secretValue.value, encoding: \"utf8\" },\n ),\n });\n secretValue.value = \"\";\n secretFileName.value = \"\";\n secretFileData.value = \"\";\n fileMode.value = false;\n if (secretFileInput.value) secretFileInput.value.value = \"\";\n if (secretForm.value) secretForm.value.open = false;\n await loadSecrets();\n showNotice(`Saved ${name}.`);\n });\n const removeSecret = (requestedName = \"\") =>\n run(async () => {\n const name = (requestedName || secretName.value.trim()).toUpperCase();\n if (!name || !confirm(`Delete ${name}?`)) return;\n await api(`/api/secrets/${name}`, { method: \"DELETE\" });\n if (secretName.value.trim().toUpperCase() === name) {\n secretName.value = \"\";\n secretValue.value = \"\";\n secretFileName.value = \"\";\n secretFileData.value = \"\";\n fileMode.value = false;\n if (secretFileInput.value) secretFileInput.value.value = \"\";\n }\n await loadSecrets();\n showNotice(`Deleted ${name}.`);\n });\n const setSource = (value) => {\n source.value = value;\n validate();\n };\n const setWorkflowId = (event) => {\n workflowId.value = event.target.value;\n };\n const setEnabled = (event) => {\n enabled.value = event.target.checked;\n };\n const setSecretName = (event) => {\n secretName.value = event.target.value;\n };\n const setSecretValue = (event) => {\n secretValue.value = event.target.value;\n fileMode.value = false;\n if (secretValue.value) {\n secretFileName.value = '';\n secretFileData.value = '';\n if (secretFileInput.value) secretFileInput.value.value = '';\n }\n };\n const setFileMode = (event) => {\n fileMode.value = event.target.checked;\n if (!fileMode.value) {\n secretFileName.value = '';\n secretFileData.value = '';\n if (secretFileInput.value) secretFileInput.value.value = '';\n } else {\n secretValue.value = '';\n }\n };\n const setSecretFile = async (event) => {\n const file = event.target.files?.[0];\n if (!file) return;\n const bytes = new Uint8Array(await file.arrayBuffer());\n let binary = '';\n for (let index = 0; index < bytes.length; index += 0x8000) {\n binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));\n }\n secretFileName.value = file.name;\n secretFileData.value = btoa(binary);\n secretValue.value = '';\n };\n const selectSecret = (name) => {\n secretName.value = name;\n secretValueInput.value?.focus();\n };\n const visitWorkflow = (id) => {\n window.location.href = `/workflows/${id}`;\n };\n const loadHelp = async (event) => {\n if (!event.target.open || helpLoaded.value || helpLoading.value || !helpContent.value) return;\n helpLoading.value = true;\n helpContent.value.textContent = 'Loading workflow syntax help...';\n try {\n const response = await fetch('/help?embed=1', { headers: { accept: 'text/html' } });\n if (!response.ok) throw new Error(`Help request failed: ${response.status}`);\n const html = await response.text();\n const parsed = new DOMParser().parseFromString(html, 'text/html');\n const sourceNodes = [\n ...Array.from(parsed.head.querySelectorAll('style')),\n ...Array.from(parsed.body.childNodes),\n ];\n const nodes = sourceNodes.map((node) => document.importNode(node, true));\n helpContent.value.replaceChildren(...nodes);\n helpLoaded.value = true;\n } catch (error) {\n helpContent.value.textContent = 'Unable to load workflow syntax help.';\n console.error(error);\n } finally {\n helpLoading.value = false;\n }\n };\n\n const escapeRevisionHtml = (value) => value.replace(/[&<>\"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' })[character] || '');\n const renderRevisionDiff = (previousSource, currentSource) => {\n if (previousSource === null) return `<span class=\"text-emerald-300\">+ ${escapeRevisionHtml(currentSource)}</span>\\n`;\n return diffLines(previousSource, currentSource).map((part) => {\n const className = part.added ? 'text-emerald-300 bg-emerald-500/10' : part.removed ? 'text-rose-300 bg-rose-500/10' : 'text-gray-300';\n const prefix = part.added ? '+ ' : part.removed ? '- ' : ' ';\n return part.value.split('\\n').filter((line, index, lines) => index < lines.length - 1 || line).map((line) => `<span class=\"block ${className}\">${prefix}${escapeRevisionHtml(line)}</span>`).join('');\n }).join('');\n };\n const openRevisionDialog = async () => {\n if (!revision.value) return;\n revisionNumber.value = revision.value;\n revisionDiffHtml.value = '';\n revisionError.value = '';\n revisionDialogOpen.value = true;\n await loadRevisionDiff(revision.value);\n };\n const closeRevisionDialog = () => {\n revisionDialogOpen.value = false;\n };\n const loadRevisionDiff = async (number) => {\n revisionLoading.value = true;\n revisionError.value = '';\n try {\n const current = await api(`/api/workflows/${selectedId.value}?revision=${number}`);\n const previous = number > 1 ? await api(`/api/workflows/${selectedId.value}?revision=${number - 1}`) : null;\n revisionDiffHtml.value = renderRevisionDiff(previous?.sourceYaml ?? null, current.sourceYaml);\n } catch (error) {\n revisionError.value = error.message;\n } finally {\n revisionLoading.value = false;\n }\n };\n const navigateRevision = async (offset) => {\n if (!selectedId.value || revisionLoading.value) return;\n const target = revisionNumber.value + offset;\n if (target < 1 || target > revision.value) return;\n revisionNumber.value = target;\n await loadRevisionDiff(target);\n };\n const openAiHelp = () => {\n aiRequest.value = '';\n aiError.value = '';\n aiSuggestion.value = '';\n aiDiffHtml.value = '';\n aiDialogOpen.value = true;\n };\n const closeAiHelp = () => { if (!aiLoading.value) aiDialogOpen.value = false; };\n const requestAiHelp = async () => {\n if (!aiRequest.value.trim() || aiLoading.value) return;\n aiLoading.value = true;\n aiError.value = \"\";\n aiDiffHtml.value = \"\";\n try {\n const response = await fetch(\"/api/ai/workflow-help\", {\n method: \"POST\",\n credentials: \"same-origin\",\n headers: { accept: \"text/event-stream\", \"content-type\": \"application/json\" },\n body: JSON.stringify({ sourceYaml: source.value, request: aiRequest.value }),\n });\n if (response.status === 401) redirectToLogin();\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(error.error || `AI help failed: ${response.status}`);\n }\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let suggestion = \"\";\n while (true) {\n const chunk = await reader.read();\n if (chunk.done) break;\n buffer += decoder.decode(chunk.value, { stream: true });\n const events = buffer.split(\"\\n\\n\");\n buffer = events.pop() || \"\";\n for (const event of events) {\n const line = event.split(\"\\n\").find((value) => value.startsWith(\"data: \"));\n if (!line) continue;\n const data = JSON.parse(line.slice(6));\n if (data.error) throw new Error(data.error);\n if (data.delta) suggestion += data.delta;\n }\n }\n suggestion = suggestion\n .replace(/^```(?:yaml|yml)?\\s*/i, \"\")\n .replace(/\\s*```$/i, \"\")\n .trim();\n if (!source.value.trim()) {\n source.value = suggestion;\n aiDialogOpen.value = false;\n validate();\n } else {\n aiSuggestion.value = suggestion;\n aiDiffHtml.value = renderRevisionDiff(source.value, suggestion);\n }\n } catch (error) {\n aiError.value = error.message;\n } finally {\n aiLoading.value = false;\n }\n };\n const acceptAiSuggestion = () => { source.value = aiSuggestion.value; aiDialogOpen.value = false; validate(); };\n const rejectAiSuggestion = () => { aiSuggestion.value = ''; aiDiffHtml.value = ''; };\n\n onDestroy(() => clearTimeout(validationTimer));\n\n onInit(() => {\n const loadPage = page === 'editor'\n ? initialId ? openWorkflow(initialId) : Promise.resolve()\n : page === 'secrets' ? loadSecrets() : loadWorkflows();\n loadPage.catch((error) => showNotice(error.message, true));\n });\n\n return {\n page,\n workflows,\n secrets,\n source,\n validation,\n revision,\n revisionDialogOpen,\n revisionNumber,\n revisionDiffHtml,\n revisionError,\n revisionLoading,\n aiDialogOpen,\n aiRequest,\n aiLoading,\n aiError,\n aiDiffHtml,\n workflowId,\n enabled,\n readOnly,\n selectedId,\n secretName,\n secretValue,\n fileMode,\n secretFileName,\n secretFileData,\n notice,\n noticeError,\n busy,\n validate,\n save,\n publish,\n runNow,\n remove,\n saveSecret,\n removeSecret,\n setSource,\n setWorkflowId,\n setEnabled,\n setSecretName,\n setSecretValue,\n setFileMode,\n setSecretFile,\n selectSecret,\n visitWorkflow,\n loadHelp,\n openRevisionDialog,\n closeRevisionDialog,\n navigateRevision,\n openAiHelp,\n closeAiHelp,\n requestAiHelp,\n acceptAiSuggestion,\n rejectAiSuggestion,\n };\n }\n \n", ug = "/* global document, window, localStorage, navigator, URL, Blob, setTimeout, clearTimeout */\n\nconst paths = {\n runs: '<rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"3\"/><path d=\"m10 8 5 4-5 4Z\"/>',\n workflow:\n '<rect x=\"3\" y=\"3\" width=\"6\" height=\"6\" rx=\"1.5\"/><rect x=\"15\" y=\"15\" width=\"6\" height=\"6\" rx=\"1.5\"/><path d=\"M6 9v6a3 3 0 0 0 3 3h6M9 6h6a3 3 0 0 1 3 3v6\"/>',\n server:\n '<rect x=\"3\" y=\"3\" width=\"18\" height=\"7\" rx=\"2\"/><rect x=\"3\" y=\"14\" width=\"18\" height=\"7\" rx=\"2\"/><path d=\"M7 6.5h.01M7 17.5h.01M11 6.5h6M11 17.5h6\"/>',\n settings:\n '<path d=\"m9 3-1 3-3 1-2 3 2 2-1 3 3 2 3-1 2 2 3-1 1-3 3-1 2-3-2-2 1-3-3-2-3 1-2-2Z\"/><circle cx=\"11.5\" cy=\"10.5\" r=\"3\"/>',\n search: '<circle cx=\"10.5\" cy=\"10.5\" r=\"6.5\"/><path d=\"m16 16 4 4\"/>',\n plus: '<path d=\"M12 5v14M5 12h14\"/>',\n arrow: '<path d=\"M5 12h14m-5-5 5 5-5 5\"/>',\n back: '<path d=\"M19 12H5m5-5-5 5 5 5\"/>',\n chevron: '<path d=\"m9 6 6 6-6 6\"/>',\n check: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m8 12 3 3 5-6\"/>',\n failed: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m9 9 6 6m0-6-6 6\"/>',\n running: '<path d=\"M20.4 8.8A9 9 0 1 1 15.2 3.6\"/><path d=\"M20.4 3.6v5.2h-5.2\"/>',\n pending: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n cancelled: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m6 6 12 12\"/>',\n skipped: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/>',\n branch:\n '<circle cx=\"6\" cy=\"5\" r=\"2\"/><circle cx=\"6\" cy=\"19\" r=\"2\"/><circle cx=\"18\" cy=\"6\" r=\"2\"/><path d=\"M6 7v10m0-4h5a7 7 0 0 0 7-5\"/>',\n clock: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n commit: '<circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M3 12h5m8 0h5\"/>',\n refresh: '<path d=\"M20 7v5h-5M4 17v-5h5\"/><path d=\"M6.1 7a7 7 0 0 1 11.5-1L20 9M4 15l2.4 3A7 7 0 0 0 18 17\"/>',\n terminal: '<path d=\"m5 6 6 6-6 6m9 0h5\"/>',\n download: '<path d=\"M12 3v12m-4-4 4 4 4-4M4 16v4h16v-4\"/>',\n copy: '<rect x=\"8\" y=\"8\" width=\"12\" height=\"12\" rx=\"2\"/><path d=\"M16 8V4H4v12h4\"/>',\n wrap: '<path d=\"M3 6h18M3 12h14a3 3 0 0 1 0 6h-5m3-3-3 3 3 3M3 18h4\"/>',\n code: '<path d=\"m8 6-6 6 6 6m8-12 6 6-6 6M14 3l-4 18\"/>',\n box: '<path d=\"m12 3 9 5v9l-9 5-9-5V8Zm0 9 9-4M3 8l9 4v10M7.5 5.5l9 5\"/>',\n history: '<path d=\"M3 3v6h6M3.6 9a9 9 0 1 1-.2 6M12 7v5l3 2\"/>',\n keyboard:\n '<rect x=\"2\" y=\"5\" width=\"20\" height=\"14\" rx=\"2\"/><path d=\"M6 9h.01M10 9h.01M14 9h.01M18 9h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M7 15h10\"/>',\n close: '<path d=\"m6 6 12 12M6 18 18 6\"/>',\n alert: '<path d=\"m12 3 10 18H2Z\"/><path d=\"M12 9v5m0 3h.01\"/>',\n lock: '<rect x=\"5\" y=\"10\" width=\"14\" height=\"11\" rx=\"2\"/><path d=\"M8 10V6a4 4 0 0 1 8 0v4m-4 5v2\"/>',\n stop: '<rect x=\"5\" y=\"5\" width=\"14\" height=\"14\" rx=\"2\"/>',\n info: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 11v6m0-10h.01\"/>',\n};\nconst icon = (name) =>\n `<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">${\n paths[name] || paths.runs\n }</svg>`;\nconst escape = (value) =>\n String(value).replace(\n /[&<>\"']/g,\n (character) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" }[character])\n );\nconst labels = {\n success: \"Passed\",\n failed: \"Failed\",\n running: \"Running\",\n pending: \"Queued\",\n cancelled: \"Cancelled\",\n skipped: \"Skipped\",\n};\nconst statusIcon = (status) =>\n `<span class=\"status-icon ${status}\">${icon(status === \"success\" ? \"check\" : status)}</span>`;\nconst badge = (status) => `<span class=\"status ${status}\">${statusIcon(status)}${labels[status]}</span>`;\nconst workflows = [\n {\n id: \"build-docker-image\",\n name: \"Build Docker image\",\n description: \"Build, test, and publish a container image to the registry.\",\n labels: [\"docker\", \"linux\"],\n steps: [\"Checkout repository\", \"Install dependencies\", \"Run tests\", \"Build image\", \"Push to registry\"],\n },\n {\n id: \"build-publish-npm\",\n name: \"Build & publish npm\",\n description: \"Validate packages and publish a new release to npm.\",\n labels: [\"node\", \"linux\"],\n steps: [\"Checkout repository\", \"Install dependencies\", \"Run tests\", \"Build package\", \"Publish to npm\"],\n },\n {\n id: \"deploy-production\",\n name: \"Deploy production\",\n description: \"Release the latest application and verify service health.\",\n labels: [\"main-server\", \"linux\"],\n steps: [\"Checkout repository\", \"Pull image\", \"Verify configuration\", \"Deploy service\", \"Health check\"],\n },\n {\n id: \"quality-checks\",\n name: \"Quality checks\",\n description: \"Type checking, linting, and tests for every pull request.\",\n labels: [\"node\", \"linux\"],\n steps: [\"Checkout repository\", \"Install dependencies\", \"Run tests\", \"Type check\", \"Lint\"],\n },\n];\nconst seed = [\n [\"running\", 0, \"feat: add multi-platform builds\", \"feat/multi-platform\", \"a8f3c12\", 93, \"Just now\", \"on\"],\n [\"pending\", 1, \"chore: prepare September release\", \"main\", \"d72e8b1\", 0, \"1 min ago\", \"lithium\"],\n [\"failed\", 0, \"fix: resolve image build context\", \"main\", \"7bc91e4\", 142, \"4 min ago\", \"on\"],\n [\"success\", 2, \"feat: improve workflow navigation\", \"main\", \"f38ac02\", 68, \"12 min ago\", \"on\"],\n [\"success\", 1, \"fix: handle reconnect gracefully\", \"main\", \"c52d190\", 104, \"18 min ago\", \"sodium\"],\n [\"success\", 3, \"feat: add keyboard navigation\", \"feat/keyboard-nav\", \"8a12e6f\", 46, \"26 min ago\", \"lithium\"],\n [\"failed\", 1, \"chore: update dependency versions\", \"main\", \"e90b721\", 38, \"34 min ago\", \"lithium\"],\n [\"success\", 0, \"fix: restore health check endpoint\", \"main\", \"2a19d63\", 127, \"42 min ago\", \"auth\"],\n [\"success\", 1, \"feat: expose workflow metadata\", \"main\", \"b94d512\", 88, \"1 hr ago\", \"on\"],\n [\"success\", 3, \"test: cover retry boundaries\", \"fix/retries\", \"6fb841a\", 59, \"1 hr ago\", \"on\"],\n [\"cancelled\", 0, \"chore: rebuild base image\", \"main\", \"51ea273\", 17, \"2 hrs ago\", \"auth\"],\n [\"success\", 2, \"fix: improve session recovery\", \"main\", \"832df1a\", 62, \"2 hrs ago\", \"auth\"],\n [\"success\", 3, \"refactor: simplify component lifecycle\", \"main\", \"27d1c98\", 71, \"3 hrs ago\", \"lithium\"],\n [\"success\", 1, \"chore: release patch version\", \"main\", \"92dc128\", 96, \"3 hrs ago\", \"sodium\"],\n [\"success\", 0, \"feat: cache dependency layers\", \"main\", \"bd5201c\", 112, \"4 hrs ago\", \"on\"],\n [\"success\", 2, \"fix: persist worker labels\", \"main\", \"876da42\", 65, \"5 hrs ago\", \"on\"],\n];\nlet jobs = seed.map(([status, workflow, message, branch, commit, seconds, time, repo], index) => ({\n id: 2856 - index,\n status,\n workflow: workflows[workflow],\n message,\n branch,\n commit,\n seconds,\n time,\n repo,\n worker: status === \"pending\" ? null : \"alpha\",\n attempt: 1,\n parent: null,\n}));\nconst state = {\n search: \"\",\n status: \"all\",\n workflow: \"all\",\n branch: \"all\",\n page: 1,\n step: null,\n detailTab: \"logs\",\n logQuery: \"\",\n wrap: false,\n};\nconst main = document.querySelector(\"#main\");\nconst dialog = document.querySelector(\"#dialog\");\nlet toastTimer;\nlet previousRoute = \"\";\n\nfunction duration(seconds) {\n if (!seconds) {\n return \"—\";\n }\n return `${Math.floor(seconds / 60) ? `${Math.floor(seconds / 60)}m ` : \"\"}${seconds % 60}s`;\n}\nfunction toast(message) {\n const element = document.querySelector(\"#toast\");\n clearTimeout(toastTimer);\n element.textContent = message;\n element.hidden = false;\n toastTimer = setTimeout(() => {\n element.hidden = true;\n }, 4500);\n}\nfunction route() {\n const [path, query = \"\"] = window.location.hash.slice(1).split(\"?\");\n return { path: path || \"/runs\", query: new URL(`https://example.test/?${query}`).searchParams };\n}\nfunction currentJob() {\n return jobs.find((job) => job.id === Number(route().path.split(\"/\")[2]));\n}\nfunction updateFilterUrl() {\n const params = new URL(\"https://example.test\").searchParams;\n for (const key of [\"search\", \"status\", \"workflow\", \"branch\", \"page\"]) {\n if (state[key] !== \"\" && state[key] !== \"all\" && state[key] !== 1) {\n params.set(key, state[key]);\n }\n }\n window.history.replaceState(\n null,\n \"\",\n `${window.location.pathname}${window.location.search}#/runs${params.size ? `?${params}` : \"\"}`\n );\n}\nfunction readFilters() {\n const { query } = route();\n state.search = query.get(\"search\") || \"\";\n state.status = [\"all\", \"running\", \"failed\", \"success\"].includes(query.get(\"status\")) ? query.get(\"status\") : \"all\";\n state.workflow = workflows.some((workflow) => workflow.id === query.get(\"workflow\")) ? query.get(\"workflow\") : \"all\";\n state.branch = [\"main\", \"feature\"].includes(query.get(\"branch\")) ? query.get(\"branch\") : \"all\";\n const page = Number(query.get(\"page\"));\n state.page = Number.isSafeInteger(page) && page > 0 ? page : 1;\n}\nfunction navigation(page) {\n document.querySelector(\"#navigation\").innerHTML = [\n [\"runs\", \"Runs\", \"runs\"],\n [\"workflows\", \"Workflows\", \"workflow\"],\n [\"workers\", \"Workers\", \"server\"],\n [\"settings\", \"Settings\", \"settings\"],\n ]\n .map(\n ([id, title, symbol]) =>\n `<a href=\"#/${id}\" class=\"nav-item ${id === page ? \"active\" : \"\"}\" ${\n id === page ? 'aria-current=\"page\"' : \"\"\n } aria-label=\"${title}\">${icon(symbol)}<span class=\"nav-label\">${title}</span>${\n id === \"runs\"\n ? `<span class=\"nav-count\">${\n jobs.filter((job) => [\"running\", \"pending\"].includes(job.status)).length\n }</span>`\n : \"\"\n }</a>`\n )\n .join(\"\");\n const title = page[0].toUpperCase() + page.slice(1);\n const job = currentJob();\n document.querySelector(\"#breadcrumbs\").innerHTML = job\n ? `<a href=\"#/runs\">Runs</a><span class=\"divider\">/</span><strong class=\"mono\">#${job.id}</strong>`\n : `<strong>${title}</strong>`;\n document.title = `${job ? `Run #${job.id}` : title} · Flow`;\n}\nfunction runsPage() {\n main.innerHTML = `<div class=\"page-heading\"><div><h1>Runs</h1><p class=\"subtitle\">Every change, from commit to complete.</p></div><button class=\"button primary\" data-action=\"new-run\">${icon(\n \"plus\"\n )}Run workflow</button></div>\n <section aria-labelledby=\"recent-runs\"><div class=\"section-heading\"><h2 id=\"recent-runs\">Runs <span class=\"count\">${\n jobs.length\n }</span></h2></div>\n <div class=\"filters\"><label class=\"search-box\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search runs</span><input id=\"run-search\" type=\"search\" value=\"${escape(\n state.search\n )}\" placeholder=\"Search runs, commits, or repositories…\" autocomplete=\"off\"/><kbd aria-hidden=\"true\">/</kbd></label>\n <select id=\"workflow-filter\" class=\"select-filter\" aria-label=\"Filter by workflow\"><option value=\"all\">All workflows</option>${workflows\n .map(\n (workflow) =>\n `<option value=\"${workflow.id}\" ${state.workflow === workflow.id ? \"selected\" : \"\"}>${workflow.name}</option>`\n )\n .join(\"\")}</select>\n <select id=\"branch-filter\" class=\"select-filter\" aria-label=\"Filter by branch\"><option value=\"all\">All branches</option><option value=\"main\" ${\n state.branch === \"main\" ? \"selected\" : \"\"\n }>main</option><option value=\"feature\" ${\n state.branch === \"feature\" ? \"selected\" : \"\"\n }>Feature branches</option></select></div>\n <div id=\"run-results\"></div></section>\n <div class=\"attention-strip\">${icon(\"alert\")}<p><strong>${\n jobs.filter((job) => job.status === \"failed\").length\n } failed runs</strong><span>A little attention goes a long way.</span></p><button class=\"text-button\" data-action=\"filter-failed\">Review failures ${icon(\n \"arrow\"\n )}</button></div>`;\n renderResults();\n}\nfunction filteredJobs(includeStatus = true) {\n return jobs.filter((job) => {\n const text = [job.id, job.message, job.workflow.id, job.workflow.name, job.repo, job.commit, job.branch]\n .join(\" \")\n .toLowerCase();\n return (\n text.includes(state.search.trim().toLowerCase()) &&\n (state.workflow === \"all\" || job.workflow.id === state.workflow) &&\n (state.branch === \"all\" || (state.branch === \"main\" ? job.branch === \"main\" : job.branch !== \"main\")) &&\n (!includeStatus ||\n state.status === \"all\" ||\n (state.status === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === state.status))\n );\n });\n}\nfunction renderResults() {\n const filtered = filteredJobs();\n const all = filteredJobs(false);\n const pages = Math.max(1, Math.ceil(filtered.length / 8));\n state.page = Math.min(pages, state.page);\n const visible = filtered.slice((state.page - 1) * 8, state.page * 8);\n document.querySelector(\n \"#run-results\"\n ).innerHTML = `<div class=\"filter-tabs\" role=\"group\" aria-label=\"Filter by run status\">${[\n [\"all\", \"All runs\"],\n [\"running\", \"In progress\"],\n [\"failed\", \"Failed\"],\n [\"success\", \"Passed\"],\n ]\n .map(\n ([value, label]) =>\n `<button class=\"filter-tab ${\n state.status === value ? \"active\" : \"\"\n }\" data-action=\"status-filter\" data-value=\"${value}\" aria-pressed=\"${\n state.status === value\n }\">${label}<span class=\"count\">${\n all.filter(\n (job) =>\n value === \"all\" ||\n (value === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === value)\n ).length\n }</span></button>`\n )\n .join(\"\")}</div>\n ${\n visible.length\n ? `<table class=\"run-table\"><caption class=\"sr-only\">Workflow runs, newest first</caption><thead><tr><th scope=\"col\">Workflow / commit</th><th scope=\"col\">Status</th><th scope=\"col\">Branch</th><th scope=\"col\">Duration</th><th scope=\"col\">Started</th></tr></thead><tbody>${visible\n .map(\n (job) =>\n `<tr><td><a class=\"run-link\" href=\"#/runs/${job.id}\" aria-label=\"Run ${job.id}: ${escape(\n job.workflow.name\n )}, ${labels[job.status]}\">${statusIcon(job.status)}<span class=\"run-copy\"><span class=\"run-title\">${\n job.workflow.name\n }</span><span class=\"run-subtitle\"><span class=\"mono\">#${\n job.id\n }</span><span class=\"separator\">·</span><span>cloud-cli/${\n job.repo\n }</span><span class=\"separator\">·</span><span class=\"mono\">${\n job.commit\n }</span></span></span></a></td><td>${badge(job.status)}</td><td><span class=\"branch\">${icon(\n \"branch\"\n )}${escape(job.branch)}</span></td><td><span class=\"mono duration\">${duration(\n job.seconds\n )}</span></td><td>${job.time}</td></tr>`\n )\n .join(\"\")}</tbody></table>`\n : `<div class=\"empty-state\">${icon(\n \"search\"\n )}<h3>No runs found</h3><p>Try a different search or clear your filters.</p><button class=\"button\" data-action=\"clear-filters\">Clear filters</button></div>`\n }\n <div class=\"table-footer\"><span role=\"status\">${\n filtered.length\n ? `${(state.page - 1) * 8 + 1}–${Math.min(state.page * 8, filtered.length)} of ${filtered.length} runs`\n : \"0 runs\"\n }</span><div class=\"pagination\"><button class=\"button\" data-action=\"prev-page\" ${\n state.page === 1 ? \"disabled\" : \"\"\n }>${icon(\"back\")}Previous</button><span>${\n state.page\n } / ${pages}</span><button class=\"button\" data-action=\"next-page\" ${\n state.page === pages ? \"disabled\" : \"\"\n }>Next${icon(\"arrow\")}</button></div></div><div class=\"table-hint\">${icon(\n \"info\"\n )}Open a run to explore its steps, logs, and execution history.</div>`;\n}\nfunction stepsFor(job) {\n return job.workflow.steps.map((name, index) => {\n let status = \"success\";\n if (job.status === \"failed\") {\n status = index < 2 ? \"success\" : index === 2 ? \"failed\" : \"skipped\";\n }\n if (job.status === \"running\") {\n status = index < 3 ? \"success\" : index === 3 ? \"running\" : \"pending\";\n }\n if (job.status === \"pending\") {\n status = \"pending\";\n }\n if (job.status === \"cancelled\") {\n status = index === 0 ? \"success\" : index === 1 ? \"cancelled\" : \"skipped\";\n }\n const seconds = [\"skipped\", \"pending\"].includes(status)\n ? 0\n : Math.round(job.seconds * [0.03, 0.24, 0.45, 0.2, 0.08][index]);\n return { name, status, seconds, index };\n });\n}\nfunction detailPage(job) {\n const steps = stepsFor(job);\n if (state.step === null) {\n state.step = Math.max(\n 0,\n steps.findIndex((step) => [\"failed\", \"running\"].includes(step.status))\n );\n }\n const active = [\"running\", \"pending\"].includes(job.status);\n main.innerHTML = `<a href=\"#/runs\" class=\"back-link\">${icon(\n \"back\"\n )}All runs</a><div class=\"page-heading run-heading\"><div><h1>${statusIcon(job.status)}${\n job.workflow.name\n }</h1><p class=\"subtitle\"><span class=\"mono\">#${job.id}</span><span>·</span><span>${escape(\n job.message\n )}</span></p></div><div class=\"heading-actions\"><button class=\"button\" data-action=\"download-logs\">${icon(\n \"download\"\n )}Download logs</button><button class=\"button ${active ? \"danger\" : \"primary\"}\" data-action=\"${\n active ? \"cancel-run\" : \"rerun\"\n }\">${icon(active ? \"stop\" : \"refresh\")}${active ? \"Cancel run\" : \"Re-run workflow\"}</button></div></div>\n <section class=\"detail-meta\" aria-label=\"Run metadata\"><div><span class=\"meta-label\">STATUS</span><span class=\"meta-value\">${badge(\n job.status\n )}</span></div><div><span class=\"meta-label\">REPOSITORY / BRANCH</span><span class=\"meta-value\">${icon(\n \"branch\"\n )}${escape(job.branch)}</span></div><div><span class=\"meta-label\">COMMIT</span><span class=\"meta-value mono\">${icon(\n \"commit\"\n )}${job.commit}</span></div><div><span class=\"meta-label\">DURATION</span><span class=\"meta-value\">${icon(\n \"clock\"\n )}${duration(job.seconds)}</span></div><div><span class=\"meta-label\">WORKER</span><span class=\"meta-value\">${icon(\n \"server\"\n )}${job.worker || \"Awaiting worker\"}${job.worker ? '<span class=\"tag\">linux · x64</span>' : \"\"}</span></div></section>\n <div class=\"detail-tabs\" role=\"group\" aria-label=\"Run detail views\">${[\n [\"logs\", \"Logs & steps\", \"terminal\"],\n [\"inputs\", \"Inputs\", \"code\"],\n [\"artifacts\", \"Artifacts\", \"box\"],\n [\"history\", \"History\", \"history\"],\n ]\n .map(\n ([value, title, symbol]) =>\n `<button data-action=\"detail-tab\" data-value=\"${value}\" class=\"${\n state.detailTab === value ? \"active\" : \"\"\n }\" aria-pressed=\"${state.detailTab === value}\">${icon(symbol)}${title}${\n value === \"history\" ? `<span class=\"count\">${job.attempt}</span>` : \"\"\n }</button>`\n )\n .join(\"\")}</div>\n <div id=\"detail-content\"></div>`;\n renderDetailContent(job);\n}\nfunction renderDetailContent(job) {\n const content = document.querySelector(\"#detail-content\");\n if (state.detailTab === \"inputs\") {\n content.innerHTML = `<section class=\"source-view\"><h3>Trigger inputs <span class=\"tag\">${\n job.parent ? \"manual restart\" : \"push event\"\n }</span></h3><pre>${escape(\n JSON.stringify(\n {\n repository: `cloud-cli/${job.repo}`,\n ref: `refs/heads/${job.branch}`,\n commit: job.commit,\n workflow: job.workflow.id,\n triggered_by: job.parent ? \"user:jamie\" : \"webhook:github\",\n },\n null,\n 2\n )\n )}</pre></section><section class=\"source-view\" style=\"margin-top:20px\"><h3>Workflow source · revision 12</h3><pre>${escape(\n workflowSource(job.workflow)\n )}</pre></section>`;\n return;\n }\n if (state.detailTab === \"artifacts\") {\n content.innerHTML =\n job.status === \"success\"\n ? `<section class=\"settings-section\"><h2>Run artifacts <span class=\"count\">1</span></h2><div class=\"setting-row\"><div><strong>${icon(\n \"box\"\n )} build-manifest.json</strong><p>Sample build metadata · JSON</p></div><button class=\"button\" data-action=\"artifact\">${icon(\n \"download\"\n )}Download</button></div></section>`\n : `<div class=\"empty-state\">${icon(\"box\")}<h3>No artifacts yet</h3><p>${\n activeStatus(job)\n ? \"Artifacts appear here when the run completes.\"\n : \"This run did not produce any artifacts.\"\n }</p></div>`;\n return;\n }\n if (state.detailTab === \"history\") {\n const history = [];\n let current = job;\n while (current) {\n history.push(current);\n current = jobs.find((candidate) => candidate.id === current.parent);\n }\n content.innerHTML = `<section class=\"settings-section\"><h2>Execution history</h2>${history\n .map(\n (entry) =>\n `<div class=\"setting-row\"><div><strong><a href=\"#/runs/${entry.id}\">Run #${entry.id} · Attempt ${\n entry.attempt\n }</a></strong><p>${\n entry.parent ? `Re-run of #${entry.parent} · Triggered by Jamie` : \"Original run · Triggered by GitHub\"\n } · ${entry.time}</p></div>${badge(entry.status)}</div>`\n )\n .join(\"\")}</section>`;\n return;\n }\n const steps = stepsFor(job);\n const selected = steps[state.step];\n content.innerHTML = `${\n job.status === \"failed\"\n ? `<div class=\"failure-banner\">${icon(\n \"alert\"\n )}<div><strong>Run tests failed with exit code 1</strong><p>One test failed. The remaining steps were skipped.</p></div><button class=\"text-button\" data-action=\"jump-error\">Jump to error ${icon(\n \"arrow\"\n )}</button></div>`\n : \"\"\n }\n <section class=\"execution\" aria-label=\"Execution steps and logs\"><aside class=\"steps-panel\" aria-label=\"Execution steps\"><div class=\"steps-heading\"><span>Execution steps</span><span class=\"mono\">${\n steps.filter((step) => step.status === \"success\").length\n } / ${steps.length}</span></div>${steps\n .map(\n (step) =>\n `<button class=\"step-button ${state.step === step.index ? \"active\" : \"\"}\" data-action=\"step\" data-value=\"${\n step.index\n }\" aria-pressed=\"${state.step === step.index}\" aria-label=\"${step.name}, ${labels[step.status]}\">${statusIcon(\n step.status\n )}<span>${step.name}</span><span class=\"mono\">${duration(step.seconds)}</span></button>`\n )\n .join(\"\")}<div class=\"step-count\">${icon(\"clock\")}Total duration ${duration(job.seconds)}</div></aside>\n <div class=\"log-panel\"><div class=\"log-heading\"><h3>${selected.name}<small>${\n selected.status === \"failed\" ? \"exit code 1\" : labels[selected.status]\n }</small></h3><div class=\"log-actions\"><button class=\"icon-button\" data-action=\"wrap\" aria-label=\"Wrap log lines\" aria-pressed=\"${\n state.wrap\n }\" title=\"Wrap log lines\">${icon(\n \"wrap\"\n )}</button><button class=\"icon-button\" data-action=\"copy-log\" aria-label=\"Copy step log\" title=\"Copy step log\">${icon(\n \"copy\"\n )}</button></div></div><label class=\"log-search\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search step log</span><input id=\"log-search\" type=\"search\" placeholder=\"Find in this step…\" value=\"${escape(\n state.logQuery\n )}\"/><span id=\"log-matches\"></span></label><div class=\"log-lines ${\n state.wrap ? \"wrap\" : \"\"\n }\" id=\"log-lines\" tabindex=\"0\" aria-label=\"Step log output\"></div><div class=\"log-bottom\"><span>${\n selected.status === \"failed\"\n ? \"Process exited with code 1\"\n : activeStatus(job)\n ? \"Sample execution output\"\n : \"End of step output\"\n }</span><span class=\"mono\">UTF-8</span></div></div></section>\n <div class=\"detail-bottom\"><span>${icon(\"lock\")}Logs are visible to workspace members.</span><span>cloud-cli/${\n job.repo\n }<span>·</span>Workflow revision 12<span>·</span>Attempt ${job.attempt}</span></div>`;\n renderLogs(job);\n}\nfunction activeStatus(job) {\n return [\"pending\", \"running\"].includes(job.status);\n}\nfunction logsFor(job, index) {\n const step = stepsFor(job)[index];\n if (step.status === \"skipped\") {\n return [{ text: \"Step skipped because an earlier step did not complete.\", type: \"dim\" }];\n }\n if (step.status === \"pending\") {\n return [{ text: \"Waiting for a worker…\", type: \"dim\" }];\n }\n if (step.status === \"failed\") {\n return [\n [\"$ pnpm test\", \"\"],\n [\"> @cloud-cli/on test\", \"dim\"],\n [\"> vitest run\", \"dim\"],\n [\"\", \"\"],\n [\" RUN v4.1.11 /workspace/cloud-cli/on\", \"\"],\n [\"\", \"\"],\n [\" ✓ src/config.spec.ts (8 tests) 14ms\", \"pass\"],\n [\" ✓ src/queue.spec.ts (12 tests) 31ms\", \"pass\"],\n [\" ❯ src/worker.spec.ts (6 tests | 1 failed) 89ms\", \"error\"],\n [\" × worker > retries a failed job\", \"error\"],\n [\"\", \"\"],\n [\" FAIL src/worker.spec.ts > worker > retries a failed job\", \"error\"],\n [\" AssertionError: expected 2 to equal 3\", \"error\"],\n [\"\", \"\"],\n [\" Expected: 3\", \"pass\"],\n [\" Received: 2\", \"error\"],\n [\"\", \"\"],\n [\" ❯ src/worker.spec.ts:142:28\", \"\"],\n [\" 140| await worker.retry(job);\", \"dim\"],\n [\" 141|\", \"dim\"],\n [\" 142| expect(job.attempts).toBe(3);\", \"error\"],\n [\" | ^\", \"error\"],\n [\"\", \"\"],\n [\" Test Files 1 failed | 2 passed (3)\", \"\"],\n [\" Tests 1 failed | 25 passed (26)\", \"\"],\n [\" Duration 1.42s\", \"dim\"],\n [\"\", \"\"],\n [\" ELIFECYCLE Test failed. See above for more details.\", \"error\"],\n ].map(([text, type]) => ({ text, type }));\n }\n const logs =\n index === 0\n ? [\n `$ git clone https://github.com/cloud-cli/${job.repo}.git .`,\n \"Cloning into '.'...\",\n \"remote: Enumerating objects: 184, done.\",\n \"remote: Compressing objects: 100% (92/92), done.\",\n \"Receiving objects: 100% (184/184), 148.2 KiB | 3.1 MiB/s, done.\",\n `$ git checkout ${job.commit}`,\n `HEAD is now at ${job.commit} ${job.message}`,\n \"\",\n \"✓ Repository ready.\",\n ]\n : index === 1\n ? [\n \"$ pnpm install --frozen-lockfile\",\n \"Lockfile is up to date, resolution step is skipped\",\n \"Progress: resolved 248, reused 248, downloaded 0, added 248\",\n \"\",\n \"✓ Dependencies installed from cache.\",\n \"Done in 2.4s.\",\n ]\n : index === 2\n ? [\n \"$ pnpm test\",\n \" RUN v4.1.11 /workspace\",\n \"\",\n \" ✓ src/config.spec.ts (8 tests)\",\n \" ✓ src/queue.spec.ts (12 tests)\",\n \" ✓ src/worker.spec.ts (6 tests)\",\n \"\",\n \" Test Files 3 passed (3)\",\n \" Tests 26 passed (26)\",\n \"\",\n \"✓ All tests passed.\",\n ]\n : [\n `$ ${job.workflow.id === \"build-docker-image\" ? \"docker build --tag app:latest .\" : \"pnpm build\"}`,\n \"#1 Loading build configuration\",\n \"#1 DONE 0.1s\",\n \"#2 Resolving dependencies\",\n \"#2 CACHED\",\n \"#3 Compiling source files\",\n \"#3 DONE 1.3s\",\n \"#4 Creating production bundle\",\n ...(step.status === \"running\" ? [\"#4 Building…\"] : [\"#4 DONE 0.8s\", \"\", \"✓ Step completed successfully.\"]),\n ];\n if (step.status === \"cancelled\") {\n logs.push(\"Run cancelled by Jamie.\");\n }\n return logs.map((text) => ({ text, type: text.startsWith(\"✓\") || text.includes(\" passed\") ? \"pass\" : \"\" }));\n}\nfunction renderLogs(job) {\n const logs = logsFor(job, state.step);\n const query = state.logQuery.toLowerCase();\n let matches = 0;\n document.querySelector(\"#log-lines\").innerHTML = logs\n .map((line, index) => {\n let text = escape(line.text);\n const position = query ? line.text.toLowerCase().indexOf(query) : -1;\n if (position >= 0) {\n matches++;\n text = `${escape(line.text.slice(0, position))}<mark>${escape(\n line.text.slice(position, position + query.length)\n )}</mark>${escape(line.text.slice(position + query.length))}`;\n }\n return `<div class=\"log-line ${line.type}\" ${\n line.type === \"error\" ? 'data-error=\"true\"' : \"\"\n }><span class=\"line-number\">${index + 1}</span><span class=\"log-time\">12:04:${String(\n 16 + Math.floor(index / 3)\n ).padStart(2, \"0\")}</span><span class=\"line-text\">${text || \" \"}</span></div>`;\n })\n .join(\"\");\n document.querySelector(\"#log-matches\").textContent = query ? `${matches} matching lines` : \"\";\n}\nfunction workflowSource(workflow) {\n return `name: ${workflow.name}\\nruns-on: [${workflow.labels.join(\n \", \"\n )}]\\n\\n# Illustrative workflow definition\\nsteps:\\n${workflow.steps\n .map(\n (name, index) =>\n ` - name: ${name}\\n run: ${\n [\n 'git checkout \"$COMMIT\"',\n \"pnpm install --frozen-lockfile\",\n \"pnpm test\",\n \"pnpm build\",\n 'echo \"Release complete\"',\n ][index]\n }`\n )\n .join(\"\\n\")}`;\n}\nfunction workflowsPage() {\n main.innerHTML = `<div class=\"page-heading\"><div><h1>Workflows</h1><p class=\"subtitle\">Your delivery process, clearly defined.</p></div><button class=\"button primary\" data-action=\"new-run\">${icon(\n \"plus\"\n )}Run workflow</button></div><div class=\"cards\">${workflows\n .map(\n (workflow, index) =>\n `<article class=\"workflow-card\"><div class=\"card-heading\">${icon(\"workflow\")}<h2>${\n workflow.name\n }</h2><span class=\"tag\">Enabled</span></div><p class=\"card-description\">${\n workflow.description\n }</p><div class=\"card-details\">${workflow.labels\n .map((label) => `<span class=\"tag mono\">${label}</span>`)\n .join(\"\")}<span class=\"tag\">${\n workflow.steps.length\n } steps</span></div><div class=\"card-footer\"><a class=\"text-button\" href=\"#/runs?workflow=${\n workflow.id\n }\">View runs ${icon(\n \"arrow\"\n )}</a><button class=\"button\" data-action=\"workflow-source\" data-value=\"${index}\">${icon(\n \"code\"\n )}View source</button></div></article>`\n )\n .join(\"\")}</div>`;\n}\nfunction workersPage() {\n main.innerHTML = `<div class=\"page-heading\"><div><h1>Workers</h1><p class=\"subtitle\">A clear view of your execution capacity.</p></div><span class=\"status success\">${icon(\n \"check\"\n )}3 operational</span></div><div class=\"cards\">${[\n [\"alpha\", \"main-server, docker\", 36, 42, \"1 / 4\"],\n [\"bravo\", \"node, linux\", 12, 28, \"0 / 4\"],\n [\"charlie\", \"docker, linux\", 8, 21, \"0 / 2\"],\n ]\n .map(\n ([name, tags, cpu, memory, slots]) =>\n `<article class=\"worker-card\"><div class=\"card-heading\">${icon(\n \"server\"\n )}<h2>${name}</h2><span class=\"status success\">Online</span></div><p class=\"card-description\">Linux x64 · systemd runner</p><div class=\"card-details\">${tags\n .split(\", \")\n .map((tag) => `<span class=\"tag mono\">${tag}</span>`)\n .join(\n \"\"\n )}</div><div class=\"worker-stats\"><div><span class=\"meta-label\">CPU <span>${cpu}%</span></span><div class=\"progress-track\"><div class=\"progress-fill\" style=\"width:${cpu}%\"></div></div></div><div><span class=\"meta-label\">Memory <span>${memory}%</span></span><div class=\"progress-track\"><div class=\"progress-fill\" style=\"width:${memory}%\"></div></div></div></div><div class=\"card-footer\"><span class=\"settings-note\">${slots} slots in use</span><span class=\"settings-note\">Sample snapshot</span></div></article>`\n )\n .join(\"\")}</div>`;\n}\nfunction getPreference(key) {\n try {\n return localStorage.getItem(`flow-concept-${key}`) === \"true\";\n } catch {\n return false;\n }\n}\nfunction settingsPage() {\n main.innerHTML = `<div class=\"page-heading\"><div><h1>Settings</h1><p class=\"subtitle\">The essentials for your workspace.</p></div></div><section class=\"settings-section\"><h2>Preferences</h2><label class=\"setting-row\"><span><strong>Compact run list</strong><span class=\"settings-note\">Fit more activity on your screen.</span></span><input type=\"checkbox\" id=\"compact-setting\" ${\n getPreference(\"compact\") ? \"checked\" : \"\"\n }/></label><label class=\"setting-row\"><span><strong>Wrap log lines</strong><span class=\"settings-note\">Keep long output within the log viewer.</span></span><input type=\"checkbox\" id=\"wrap-setting\" ${\n state.wrap ? \"checked\" : \"\"\n }/></label></section><section class=\"settings-section\"><h2>${icon(\n \"lock\"\n )} Secrets</h2><p class=\"settings-note\">Available to workflows. Values are never displayed in the dashboard.</p>${[\n \"NPM_TOKEN\",\n \"REGISTRY_PASSWORD\",\n \"DEPLOY_KEY\",\n ]\n .map(\n (name) =>\n `<div class=\"setting-row\"><div><strong class=\"mono\">${name}</strong><p>Workspace secret · Example</p></div><span class=\"secret-value\" aria-label=\"Value hidden\">••••••••••••</span></div>`\n )\n .join(\n \"\"\n )}</section><section class=\"settings-section\"><h2>About</h2><p class=\"settings-note\">An interactive design prototype based on Flow’s job list and run details. Run controls update this browser session. Display preferences are saved on this device.</p><div class=\"setting-row\"><span class=\"settings-note\">Explore without setup.</span><button class=\"button\" data-action=\"shortcuts\">${icon(\n \"keyboard\"\n )}Keyboard shortcuts</button></div></section>`;\n}\nfunction openDialog(title, body) {\n dialog.innerHTML = `<div class=\"dialog-heading\"><h2 id=\"dialog-title\">${title}</h2><button class=\"icon-button\" data-action=\"close-dialog\" aria-label=\"Close dialog\">${icon(\n \"close\"\n )}</button></div>${body}`;\n if (!dialog.open) {\n dialog.showModal();\n }\n}\nfunction newRunDialog() {\n openDialog(\n \"Run a workflow\",\n `<p class=\"dialog-description\">Choose what to run. We’ll take it from here.</p><form id=\"new-run-form\"><label class=\"form-field\"><span>Workflow</span><select name=\"workflow\">${workflows\n .map((workflow) => `<option value=\"${workflow.id}\">${workflow.name}</option>`)\n .join(\n \"\"\n )}</select></label><label class=\"form-field\"><span>Branch</span><input name=\"branch\" value=\"main\" required maxlength=\"100\" pattern=\"[a-zA-Z0-9._\\\\x2f\\\\x2d]+\"/></label><p class=\"dialog-note\">Prototype mode: creates a sample queued run in this session.</p><div class=\"dialog-footer\"><button class=\"button\" type=\"button\" data-action=\"close-dialog\">Cancel</button><button class=\"button primary\" type=\"submit\">${icon(\n \"runs\"\n )}Run workflow</button></div></form>`\n );\n}\nfunction createRun(original, workflow, branch) {\n const job = {\n id: Math.max(...jobs.map((entry) => entry.id)) + 1,\n workflow,\n branch,\n status: \"pending\",\n message: original?.message || \"Manually triggered workflow\",\n commit: original?.commit || \"a8f3c12\",\n seconds: 0,\n time: \"Just now\",\n repo: original?.repo || \"on\",\n worker: null,\n parent: original?.id || null,\n attempt: original ? original.attempt + 1 : 1,\n };\n jobs.unshift(job);\n dialog.close();\n window.location.hash = `/runs/${job.id}`;\n toast(`Sample run #${job.id} queued.`);\n}\nfunction download(name, text) {\n const url = URL.createObjectURL(new Blob([text], { type: \"text/plain;charset=utf-8\" }));\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = name;\n anchor.click();\n setTimeout(() => {\n URL.revokeObjectURL(url);\n }, 1000);\n}\nfunction render() {\n const { path } = route();\n const page = path.split(\"/\")[1] || \"runs\";\n const changed = previousRoute !== path;\n if (changed) {\n state.step = null;\n state.detailTab = \"logs\";\n state.logQuery = \"\";\n }\n previousRoute = path;\n navigation([\"runs\", \"workflows\", \"workers\", \"settings\"].includes(page) ? page : \"runs\");\n if (path === \"/runs\") {\n readFilters();\n runsPage();\n } else if (page === \"runs\" && currentJob()) {\n detailPage(currentJob());\n } else if (page === \"workflows\") {\n workflowsPage();\n } else if (page === \"workers\") {\n workersPage();\n } else if (page === \"settings\") {\n settingsPage();\n } else {\n main.innerHTML = `<div class=\"empty-state\">${icon(\n \"search\"\n )}<h1>Run not found</h1><p>This run is not part of the sample workspace.</p><a class=\"button\" href=\"#/runs\">Back to runs</a></div>`;\n }\n if (changed) {\n window.scrollTo(0, 0);\n main.focus({ preventScroll: true });\n }\n}\ndocument.addEventListener(\"click\", async (event) => {\n const button = event.target.closest(\"[data-action]\");\n if (!button) {\n return;\n }\n const { action, value } = button.dataset;\n const job = currentJob();\n if (action === \"new-run\") {\n newRunDialog();\n }\n if (action === \"close-dialog\") {\n dialog.close();\n }\n if (action === \"filter-failed\" || action === \"status-filter\") {\n state.status = action === \"filter-failed\" ? \"failed\" : value;\n state.page = 1;\n updateFilterUrl();\n renderResults();\n document.querySelector(`.filter-tab[data-value=\"${state.status}\"]`).focus({ preventScroll: true });\n }\n if (action === \"clear-filters\") {\n Object.assign(state, { search: \"\", status: \"all\", workflow: \"all\", branch: \"all\", page: 1 });\n updateFilterUrl();\n runsPage();\n document.querySelector(\"#run-search\").focus();\n }\n if (action === \"prev-page\" || action === \"next-page\") {\n state.page += action === \"next-page\" ? 1 : -1;\n updateFilterUrl();\n renderResults();\n document.querySelector(`button[data-action=\"${action}\"]:not(:disabled)`)?.focus({ preventScroll: true });\n }\n if (action === \"detail-tab\") {\n state.detailTab = value;\n detailPage(job);\n document.querySelector(`[data-action=\"detail-tab\"][data-value=\"${value}\"]`).focus({ preventScroll: true });\n }\n if (action === \"step\") {\n state.step = Number(value);\n state.logQuery = \"\";\n renderDetailContent(job);\n document.querySelector(`[data-action=\"step\"][data-value=\"${value}\"]`).focus({ preventScroll: true });\n }\n if (action === \"jump-error\") {\n state.step = 2;\n state.logQuery = \"\";\n renderDetailContent(job);\n document.querySelector(\"[data-error]\")?.scrollIntoView({ block: \"nearest\" });\n document.querySelector(\"#log-lines\").focus({ preventScroll: true });\n }\n if (action === \"wrap\") {\n state.wrap = !state.wrap;\n document.querySelector(\"#log-lines\").classList.toggle(\"wrap\", state.wrap);\n button.setAttribute(\"aria-pressed\", String(state.wrap));\n }\n if (action === \"copy-log\") {\n try {\n await navigator.clipboard.writeText(\n logsFor(job, state.step)\n .map((line) => line.text)\n .join(\"\\n\")\n );\n toast(\"Step log copied.\");\n } catch {\n toast(\"Clipboard unavailable. Use Download logs to save the output.\");\n }\n }\n if (action === \"download-logs\") {\n download(\n `flow-run-${job.id}.log`,\n job.workflow.steps\n .map(\n (name, index) =>\n `=== ${name} ===\\n${logsFor(job, index)\n .map((line) => line.text)\n .join(\"\\n\")}`\n )\n .join(\"\\n\\n\")\n );\n toast(\"Run log downloaded.\");\n }\n if (action === \"artifact\") {\n download(\n `run-${job.id}-build-manifest.json`,\n JSON.stringify({ sample: true, run: job.id, commit: job.commit, workflow: job.workflow.id }, null, 2)\n );\n }\n if (action === \"rerun\") {\n createRun(job, job.workflow, job.branch);\n }\n if (action === \"cancel-run\") {\n job.status = \"cancelled\";\n state.step = null;\n navigation(\"runs\");\n detailPage(job);\n toast(`Sample run #${job.id} cancelled.`);\n }\n if (action === \"workflow-source\") {\n const workflow = workflows[Number(value)];\n openDialog(\n workflow.name,\n `<p class=\"dialog-description\">Illustrative YAML · revision 12</p><div class=\"source-view\"><pre>${escape(\n workflowSource(workflow)\n )}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`\n );\n }\n if (action === \"preferences\") {\n window.location.hash = \"/settings\";\n }\n if (action === \"shortcuts\") {\n openDialog(\n \"A few useful shortcuts\",\n `<p class=\"dialog-description\">Less clicking. More getting things done.</p><div class=\"shortcut-row\"><span>Search runs</span><kbd>/</kbd></div><div class=\"shortcut-row\"><span>Run a workflow</span><kbd>N</kbd></div><div class=\"shortcut-row\"><span>Show shortcuts</span><kbd>?</kbd></div><div class=\"shortcut-row\"><span>Close dialog</span><kbd>Esc</kbd></div><div class=\"dialog-footer\"><button class=\"button primary\" data-action=\"close-dialog\">Got it</button></div>`\n );\n }\n});\ndocument.addEventListener(\"input\", (event) => {\n if (event.target.id === \"run-search\") {\n state.search = event.target.value;\n state.page = 1;\n updateFilterUrl();\n renderResults();\n }\n if (event.target.id === \"log-search\") {\n state.logQuery = event.target.value;\n renderLogs(currentJob());\n }\n});\ndocument.addEventListener(\"change\", (event) => {\n if ([\"workflow-filter\", \"branch-filter\"].includes(event.target.id)) {\n state[event.target.id === \"workflow-filter\" ? \"workflow\" : \"branch\"] = event.target.value;\n state.page = 1;\n updateFilterUrl();\n renderResults();\n }\n if ([\"compact-setting\", \"wrap-setting\"].includes(event.target.id)) {\n const key = event.target.id === \"compact-setting\" ? \"compact\" : \"wrap\";\n if (key === \"compact\") {\n document.body.classList.toggle(\"compact\", event.target.checked);\n } else {\n state.wrap = event.target.checked;\n }\n try {\n localStorage.setItem(`flow-concept-${key}`, event.target.checked);\n toast(\"Preference saved on this device.\");\n } catch {\n toast(\"Preference applied for this session.\");\n }\n }\n});\ndocument.addEventListener(\"submit\", (event) => {\n if (event.target.id === \"new-run-form\") {\n event.preventDefault();\n createRun(\n null,\n workflows.find((workflow) => workflow.id === event.target.elements.workflow.value),\n event.target.elements.branch.value.trim()\n );\n }\n});\ndocument.addEventListener(\"keydown\", (event) => {\n if (\n event.ctrlKey ||\n event.metaKey ||\n event.altKey ||\n event.target.closest('input,select,textarea,[contenteditable=\"true\"]') ||\n dialog.open\n ) {\n return;\n }\n if (event.key === \"/\" && route().path === \"/runs\") {\n event.preventDefault();\n document.querySelector(\"#run-search\").focus();\n }\n if (event.key.toLowerCase() === \"n\") {\n event.preventDefault();\n newRunDialog();\n }\n if (event.key === \"?\") {\n event.preventDefault();\n document.querySelector('[data-action=\"shortcuts\"]').click();\n }\n});\nwindow.addEventListener(\"hashchange\", render);\ndocument.querySelectorAll(\"[data-icon]\").forEach((element) => {\n element.innerHTML = icon(element.dataset.icon);\n});\ndocument.body.classList.toggle(\"compact\", getPreference(\"compact\"));\nstate.wrap = getPreference(\"wrap\");\nrender();\n", dg = "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"theme-color\" content=\"#f7f8f6\" />\n <meta name=\"description\" content=\"Flow — a quieter place to ship. An interactive CI dashboard design concept.\" />\n <title>Runs · Flow</title>\n <link\n rel=\"icon\"\n href=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23244336'/%3E%3Cpath d='M9 10h14M9 16h10M9 22h6' stroke='%23c4efad' stroke-width='3'/%3E%3C/svg%3E\"\n />\n <link rel=\"stylesheet\" href=\"./style.css\" />\n <script src=\"./app.js\" defer><\/script>\n </head>\n <body>\n <a href=\"#main\" class=\"skip-link\">Skip to content</a>\n <aside class=\"sidebar\" aria-label=\"Workspace navigation\">\n <a class=\"brand\" href=\"#/runs\" aria-label=\"Flow home\"\n ><span class=\"brand-mark\"><i></i><i></i><i></i></span>flow<span class=\"brand-period\">.</span></a\n >\n <div class=\"workspace\">\n <span class=\"workspace-icon\">a</span>\n <div><strong>Apphor</strong><span>Engineering workspace</span></div>\n <span class=\"workspace-label\">PRO</span>\n </div>\n <div class=\"nav-caption\">WORKSPACE</div>\n <nav id=\"navigation\"></nav>\n <div class=\"sidebar-bottom\">\n <div class=\"runner-health\">\n <span class=\"health-dot\"></span><span>Runners operational</span><span class=\"mono\">3/3</span>\n </div>\n <button class=\"profile\" data-action=\"preferences\">\n <span class=\"avatar\">JD</span><span><strong>Jamie Davis</strong><small>Workspace admin</small></span\n ><span data-icon=\"settings\"></span>\n </button>\n </div>\n </aside>\n <div class=\"app-shell\">\n <header class=\"topbar\">\n <div class=\"breadcrumbs\" id=\"breadcrumbs\"></div>\n <div class=\"topbar-right\">\n <button\n class=\"icon-button\"\n data-action=\"shortcuts\"\n aria-label=\"Keyboard shortcuts\"\n title=\"Keyboard shortcuts\"\n >\n <span data-icon=\"keyboard\"></span></button\n ><span class=\"avatar small\">JD</span>\n </div>\n </header>\n <main id=\"main\" tabindex=\"-1\"></main>\n </div>\n <dialog id=\"dialog\" aria-labelledby=\"dialog-title\"></dialog>\n <div id=\"toast\" role=\"status\" class=\"toast\" hidden></div>\n </body>\n</html>\n", fg = "@import url(\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;450;500;550;600;650;700&family=IBM+Plex+Mono:wght@400;500&display=swap\");\n\n:root {\n --bg: #f3f5f1;\n --sidebar: #e9eee6;\n --ink: #1a1e22;\n --muted: #505b52;\n --line: #cbd3c8;\n --subtle: #fafbf8;\n --green: #387045;\n --green-bg: #e8f0e5;\n --accent: #c8e6a8;\n --red: #b54138;\n --red-bg: #fee2e2;\n --blue: #4a66a0;\n --blue-bg: #e8eef6;\n --amber: #845e1c;\n font-family: \"DM Sans\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n color: var(--ink);\n font-size: 15px;\n font-synthesis: none;\n}\n* {\n box-sizing: border-box;\n}\nbody {\n margin: 0;\n background: var(--bg);\n}\nbutton,\ninput,\nselect {\n font: inherit;\n}\nbutton,\na,\ninput,\nselect,\nsummary {\n -webkit-tap-highlight-color: transparent;\n}\nbutton,\nselect,\nsummary {\n cursor: pointer;\n}\nbutton {\n color: inherit;\n}\na {\n color: inherit;\n text-decoration: none;\n}\nbutton {\n border: 0;\n background: none;\n}\nbutton:disabled {\n cursor: not-allowed;\n opacity: 0.45;\n}\n:focus-visible {\n outline: 2px solid #467b40;\n outline-offset: 4px;\n}\ninput:focus-visible,\nselect:focus-visible {\n outline-offset: 1px;\n}\nbutton,\na {\n touch-action: manipulation;\n}\nsvg {\n width: 18px;\n height: 18px;\n flex-shrink: 0;\n vertical-align: middle;\n}\nh1,\nh2,\nh3,\np {\n margin: 0;\n}\n.mono,\ncode,\nkbd,\npre {\n font-family: \"IBM Plex Mono\", monospace;\n}\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n}\n.skip-link {\n position: fixed;\n top: -60px;\n left: 20px;\n z-index: 100;\n background: var(--ink);\n color: white;\n padding: 12px;\n}\n.skip-link:focus {\n top: 10px;\n}\n.sidebar {\n width: 224px;\n position: fixed;\n inset: 0 auto 0 0;\n background: var(--sidebar);\n border-right: 1px solid var(--line);\n padding: 32px 17px 18px;\n display: flex;\n flex-direction: column;\n}\n.brand {\n display: flex;\n align-items: center;\n font-size: 33px;\n font-weight: 700;\n letter-spacing: -1.9px;\n padding: 0 13px;\n width: fit-content;\n}\n.brand-period {\n color: #729b5b;\n}\n.brand-mark {\n display: flex;\n flex-direction: column;\n justify-content: center;\n gap: 5px;\n width: 31px;\n height: 34px;\n margin-right: 10px;\n transform: skewY(-13deg);\n}\n.brand-mark i {\n display: block;\n width: 27px;\n height: 5px;\n border-radius: 2px;\n background: #547d42;\n}\n.brand-mark i:nth-child(2) {\n width: 20px;\n}\n.brand-mark i:nth-child(3) {\n width: 13px;\n}\n.workspace {\n display: flex;\n align-items: center;\n gap: 10px;\n margin: 38px 2px 30px;\n padding: 12px 10px;\n border: 1px solid var(--line);\n border-radius: 7px;\n background: var(--subtle);\n}\n.workspace-icon {\n display: grid;\n place-items: center;\n width: 30px;\n height: 31px;\n border: 1px solid #e2e7dc;\n border-radius: 5px;\n background: #edf1e7;\n font-weight: 700;\n font-size: 19px;\n}\n.workspace strong {\n display: block;\n font-size: 13px;\n font-weight: 600;\n}\n.workspace div > span {\n display: block;\n font-size: 13px;\n margin-top: 3px;\n color: var(--muted);\n}\n.workspace-label {\n margin-left: auto;\n font-size: 13px;\n border: 1px solid var(--line);\n padding: 2px 4px;\n color: var(--muted);\n border-radius: 3px;\n}\n.nav-caption {\n font-size: 13px;\n font-weight: 600;\n letter-spacing: 1.4px;\n color: var(--muted);\n padding: 0 13px;\n margin-bottom: 12px;\n}\nnav {\n display: grid;\n gap: 5px;\n}\n.nav-item {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 12px 14px;\n border-radius: 6px;\n color: var(--ink);\n font-size: 13px;\n font-weight: 500;\n}\n.nav-item:hover {\n background: var(--subtle);\n}\n.nav-item.active {\n background: #e8efdf;\n color: #344d29;\n}\n.nav-item svg {\n width: 17px;\n height: 17px;\n}\n.nav-count {\n margin-left: auto;\n font-size: 13px;\n background: #e2e8dc;\n padding: 2px 6px;\n border-radius: 4px;\n}\n.sidebar-bottom {\n margin-top: auto;\n}\n.runner-health {\n display: flex;\n align-items: center;\n gap: 7px;\n font-size: 13px;\n padding: 18px 8px;\n color: var(--muted);\n}\n.runner-health .mono {\n margin-left: auto;\n font-size: 10px;\n}\n.health-dot {\n width: 6px;\n height: 6px;\n background: #699d55;\n border-radius: 50%;\n flex-shrink: 0;\n}\n.profile {\n border-top: 1px solid var(--line);\n padding: 19px 5px 0;\n display: flex;\n align-items: center;\n gap: 10px;\n width: 100%;\n text-align: left;\n}\n.avatar {\n display: grid;\n place-items: center;\n background: #ebe6d9;\n color: #756a50;\n width: 33px;\n height: 33px;\n border-radius: 50%;\n font-size: 13px;\n font-weight: 600;\n flex-shrink: 0;\n}\n.profile strong {\n display: block;\n font-size: 13px;\n font-weight: 550;\n}\n.profile small {\n display: block;\n font-size: 13px;\n color: var(--muted);\n margin-top: 3px;\n}\n.profile > span:last-child {\n margin-left: auto;\n color: var(--muted);\n}\n.small {\n width: 27px;\n height: 27px;\n font-size: 10px;\n}\n.app-shell {\n margin-left: 224px;\n min-height: 100vh;\n display: flex;\n flex-direction: column;\n}\n.topbar {\n height: 72px;\n border-bottom: 1px solid var(--line);\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 42px;\n gap: 15px;\n}\n.breadcrumbs {\n display: flex;\n align-items: center;\n gap: 12px;\n font-size: 13px;\n color: #737a70;\n}\n.breadcrumbs a:hover {\n color: var(--ink);\n}\n.breadcrumbs .divider {\n color: #c5c9c1;\n}\n.breadcrumbs strong {\n color: #434b40;\n font-weight: 500;\n}\n.topbar-right {\n display: flex;\n align-items: center;\n gap: 20px;\n}\n.demo-label {\n display: flex;\n align-items: center;\n gap: 7px;\n font-size: 13px;\n color: var(--muted);\n}\n.demo-label > span {\n width: 5px;\n height: 5px;\n border-radius: 50%;\n background: #8c9c7f;\n}\n.icon-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 32px;\n min-height: 32px;\n border-radius: 5px;\n color: var(--muted);\n}\n.icon-button:hover {\n background: #edf0e8;\n color: var(--ink);\n}\nmain {\n width: 100%;\n max-width: 1600px;\n margin: 0 auto;\n padding: 43px 42px 30px;\n}\nmain:focus-visible {\n outline: none;\n}\n.page-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 20px;\n margin-bottom: 32px;\n}\nh1 {\n font-size: 29px;\n letter-spacing: -1px;\n font-weight: 550;\n line-height: 1.25;\n}\n.subtitle {\n color: var(--muted);\n font-size: 12px;\n margin-top: 9px;\n line-height: 1.7;\n}\n.heading-actions {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n.button {\n display: inline-flex;\n justify-content: center;\n align-items: center;\n gap: 7px;\n padding: 10px 13px;\n border: 1px solid #dfe4d9;\n border-radius: 6px;\n font-size: 13px;\n font-weight: 550;\n min-height: 36px;\n background: var(--bg);\n white-space: nowrap;\n}\n.button svg {\n width: 14px;\n height: 14px;\n}\n.button:hover {\n background: var(--subtle);\n border-color: #c8d0bf;\n}\n.button.primary {\n background: var(--accent);\n border-color: #b6d79d;\n color: #2c4320;\n}\n.button.primary:hover {\n background: #b7e198;\n}\n.button.danger {\n color: var(--red);\n border-color: #edcdc8;\n}\n.text-button {\n color: #52644b;\n font-size: 13px;\n padding: 6px 0;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n.text-button:hover {\n text-decoration: underline;\n}\n.text-button svg {\n width: 13px;\n}\n.overview {\n display: grid;\n grid-template-columns: 1.08fr 1.08fr 1fr 1fr;\n border: 1px solid var(--line);\n border-radius: 8px;\n margin-bottom: 31px;\n background: linear-gradient(110deg, #fcfdf9, #fff);\n overflow: hidden;\n}\n.metric {\n position: relative;\n padding: 22px 24px;\n min-height: 117px;\n}\n.metric + .metric::before {\n content: \"\";\n position: absolute;\n width: 1px;\n background: var(--line);\n top: 23px;\n bottom: 23px;\n left: 0;\n}\n.metric-label {\n display: flex;\n gap: 7px;\n align-items: center;\n font-size: 13px;\n color: #70786a;\n}\n.metric-label svg {\n width: 13px;\n height: 13px;\n color: #8a9482;\n}\n.metric-bottom {\n display: flex;\n align-items: baseline;\n gap: 10px;\n margin-top: 13px;\n}\n.metric-value {\n font-size: 27px;\n letter-spacing: -1px;\n font-weight: 500;\n}\n.metric-value small {\n font-size: 15px;\n letter-spacing: 0;\n}\n.metric-note {\n font-size: 13px;\n color: #69765f;\n}\n.metric-note span {\n color: var(--green);\n}\n.tiny-bars {\n display: flex;\n gap: 3px;\n align-items: end;\n height: 25px;\n margin-left: auto;\n}\n.tiny-bars i {\n width: 5px;\n background: #c4d9b2;\n border-radius: 2px;\n}\n.metric-foot {\n margin-top: 7px;\n font-size: 10px;\n color: #858b7f;\n}\n.section-heading {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 15px;\n margin-bottom: 17px;\n}\nh2 {\n font-size: 14px;\n font-weight: 550;\n letter-spacing: -0.2px;\n}\n.section-heading h2 {\n display: flex;\n gap: 9px;\n align-items: center;\n}\n.count {\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 10px;\n font-weight: 400;\n background: #f0f2ed;\n color: #747d6d;\n border: 1px solid #e7ebe1;\n border-radius: 4px;\n padding: 2px 5px;\n}\n.section-heading > span {\n color: var(--muted);\n font-size: 13px;\n display: flex;\n align-items: center;\n gap: 6px;\n}\n.section-heading > span svg {\n width: 12px;\n height: 12px;\n}\n.filters {\n display: flex;\n gap: 9px;\n align-items: center;\n margin-bottom: 15px;\n}\n.search-box {\n display: flex;\n align-items: center;\n gap: 9px;\n border: 1px solid var(--line);\n border-radius: 6px;\n height: 35px;\n padding: 0 10px;\n min-width: 0;\n background: white;\n}\n.search-box svg {\n width: 14px;\n height: 14px;\n color: #8a9184;\n}\n.search-box input {\n border: 0;\n min-width: 0;\n width: 100%;\n outline: 0;\n background: transparent;\n color: var(--ink);\n font-size: 13px;\n}\n.search-box:focus-within {\n border-color: #729859;\n box-shadow: 0 0 0 2px #edf5e8;\n}\n.search-box input::placeholder {\n color: #899080;\n}\n.filters .search-box {\n width: 300px;\n margin-right: auto;\n}\nkbd {\n font-size: 10px;\n border: 1px solid #e6e9e1;\n padding: 1px 4px;\n color: #838b7a;\n border-radius: 3px;\n}\n.select-filter {\n border: 1px solid var(--line);\n background: white;\n border-radius: 6px;\n padding: 8px 28px 8px 11px;\n color: #666f5e;\n font-size: 13px;\n height: 35px;\n max-width: 170px;\n}\n.filter-tabs {\n display: flex;\n gap: 23px;\n border-bottom: 1px solid var(--line);\n margin-bottom: 0;\n}\n.filter-tab {\n padding: 1px 0 13px;\n font-size: 13px;\n color: var(--muted);\n border-bottom: 2px solid transparent;\n display: flex;\n align-items: center;\n gap: 7px;\n margin-bottom: -1px;\n}\n.filter-tab.active {\n color: var(--ink);\n border-bottom-color: #5c7a46;\n font-weight: 550;\n}\n.filter-tab .count {\n border: 0;\n background: #f0f2ec;\n padding: 1px 5px;\n font-size: 10px;\n}\n.filter-tab.active .count {\n background: #eaf1e1;\n color: #536d40;\n}\n.run-table {\n width: 100%;\n border-collapse: collapse;\n text-align: left;\n table-layout: fixed;\n}\n.run-table th {\n font-weight: 400;\n font-size: 12px;\n color: #808877;\n padding: 14px 10px;\n background: #fafbf8;\n border-bottom: 1px solid var(--line);\n}\n.run-table th:first-child {\n padding-left: 15px;\n width: 44%;\n}\n.run-table th:nth-child(2) {\n width: 16%;\n}\n.run-table th:nth-child(3) {\n width: 17%;\n}\n.run-table th:nth-child(4) {\n width: 11%;\n}\n.run-table th:last-child {\n width: 12%;\n text-align: right;\n padding-right: 17px;\n}\n.run-table td {\n padding: 18px 10px;\n border-bottom: 1px solid var(--line);\n font-size: 13px;\n color: #747c6c;\n}\n.run-table td:first-child {\n padding-left: 15px;\n}\n.run-table td:last-child {\n text-align: right;\n padding-right: 17px;\n}\n.run-table tr:hover td {\n background: #fafbf8;\n}\n.run-link {\n display: flex;\n align-items: center;\n gap: 12px;\n min-width: 0;\n}\n.run-title {\n display: block;\n font-size: 13px;\n color: #30382c;\n font-weight: 550;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.run-copy {\n min-width: 0;\n}\n.run-subtitle {\n font-size: 13px;\n color: #818878;\n margin-top: 5px;\n display: flex;\n gap: 6px;\n align-items: center;\n}\n.run-subtitle .mono {\n font-size: 10px;\n}\n.run-subtitle .separator {\n color: #bdc4b5;\n}\n.status-icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: 21px;\n height: 21px;\n}\n.status-icon svg {\n width: 18px;\n height: 18px;\n}\n.status-icon.success {\n color: #6b9355;\n}\n.status-icon.failed {\n color: #cc7064;\n}\n.status-icon.running {\n color: #647fab;\n}\n.status-icon.pending {\n color: #af955e;\n}\n.status-icon.cancelled,\n.status-icon.skipped {\n color: #91998a;\n}\n.status {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n font-size: 13px;\n border-radius: 5px;\n padding: 4px 7px;\n white-space: nowrap;\n}\n.status .status-icon {\n width: 11px;\n height: 11px;\n}\n.status .status-icon svg {\n width: 11px;\n height: 11px;\n}\n.status.success {\n background: var(--green-bg);\n color: var(--green);\n}\n.status.failed {\n background: var(--red-bg);\n color: var(--red);\n}\n.status.running {\n background: var(--blue-bg);\n color: var(--blue);\n}\n.status.pending {\n background: #faf4e6;\n color: var(--amber);\n}\n.status.cancelled,\n.status.skipped {\n background: #f0f2ed;\n color: #737c68;\n}\n.branch {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n font-size: 13px;\n}\n.branch svg {\n width: 12px;\n height: 12px;\n}\n.duration {\n font-size: 13px;\n color: var(--muted);\n}\n.table-footer {\n padding: 18px 0;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 10px;\n font-size: 13px;\n color: #838b7b;\n}\n.pagination {\n display: flex;\n align-items: center;\n gap: 14px;\n}\n.pagination .button {\n padding: 6px 9px;\n min-height: 28px;\n font-size: 13px;\n}\n.table-hint {\n display: flex;\n align-items: center;\n gap: 5px;\n color: #8b9186;\n font-size: 10px;\n}\n.table-hint svg {\n width: 11px;\n height: 11px;\n}\n.attention-strip {\n display: flex;\n align-items: center;\n gap: 12px;\n border: 1px solid #e8e4d8;\n border-radius: 7px;\n padding: 15px 17px;\n margin-top: 18px;\n background: #fcfbf7;\n}\n.attention-strip > svg {\n color: #aa8c50;\n width: 17px;\n}\n.attention-strip p {\n font-size: 13px;\n color: #5f6153;\n}\n.attention-strip p span {\n color: #89907f;\n margin-left: 5px;\n}\n.attention-strip .text-button {\n margin-left: auto;\n white-space: nowrap;\n}\n.page-footer {\n margin: auto 42px 0;\n padding: 25px 0;\n display: flex;\n align-items: center;\n justify-content: space-between;\n color: #8b9382;\n font-size: 10px;\n border-top: 1px solid var(--line);\n}\n.footer-mark {\n font-size: 19px;\n color: #45643c;\n margin-right: 5px;\n vertical-align: middle;\n}\n.footer-dot {\n margin: 0 7px;\n color: #b4bdae;\n}\n.back-link {\n display: inline-flex;\n gap: 7px;\n align-items: center;\n font-size: 13px;\n color: var(--muted);\n margin-bottom: 22px;\n}\n.back-link svg {\n width: 13px;\n height: 13px;\n}\n.run-heading h1 {\n display: flex;\n align-items: center;\n gap: 10px;\n font-size: 26px;\n}\n.run-heading h1 .status-icon {\n width: 27px;\n height: 27px;\n}\n.run-heading h1 .status-icon svg {\n width: 25px;\n height: 25px;\n}\n.run-heading .subtitle {\n display: flex;\n flex-wrap: wrap;\n gap: 7px;\n align-items: center;\n margin-left: 38px;\n font-size: 13px;\n}\n.run-heading .subtitle .mono {\n font-size: 13px;\n}\n.detail-meta {\n display: grid;\n grid-template-columns: repeat(5, 1fr);\n border: 1px solid var(--line);\n border-radius: 7px;\n padding: 19px 22px;\n gap: 20px;\n margin-bottom: 26px;\n}\n.meta-label {\n display: block;\n font-size: 10px;\n color: #7b8571;\n margin-bottom: 10px;\n}\n.meta-value {\n font-size: 13px;\n display: flex;\n align-items: center;\n gap: 6px;\n}\n.meta-value svg {\n width: 13px;\n height: 13px;\n color: #939c88;\n}\n.detail-tabs {\n display: flex;\n gap: 25px;\n border-bottom: 1px solid var(--line);\n margin-bottom: 22px;\n}\n.detail-tabs button {\n padding: 0 1px 13px;\n display: flex;\n align-items: center;\n gap: 7px;\n font-size: 13px;\n color: #7b8373;\n border-bottom: 2px solid transparent;\n}\n.detail-tabs button svg {\n width: 14px;\n height: 14px;\n}\n.detail-tabs button.active {\n color: #34422b;\n border-bottom-color: #657d4f;\n}\n.failure-banner {\n padding: 14px 17px;\n background: #fdf6f3;\n border: 1px solid #f0ddd5;\n display: flex;\n gap: 12px;\n align-items: center;\n border-radius: 6px;\n margin-bottom: 20px;\n}\n.failure-banner > svg {\n color: #bf7864;\n}\n.failure-banner strong {\n font-size: 13px;\n font-weight: 550;\n color: #8e5746;\n}\n.failure-banner p {\n font-size: 13px;\n color: #9b7d6c;\n margin-top: 4px;\n}\n.failure-banner button {\n margin-left: auto;\n color: #9f5e4b;\n white-space: nowrap;\n}\n.execution {\n display: grid;\n grid-template-columns: 226px minmax(0, 1fr);\n border: 1px solid var(--line);\n border-radius: 7px;\n overflow: hidden;\n min-height: 385px;\n}\n.steps-panel {\n background: #fafbf8;\n border-right: 1px solid var(--line);\n padding: 17px 10px;\n}\n.steps-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 2px 10px 16px;\n font-size: 13px;\n color: #656e5d;\n}\n.steps-heading .mono {\n font-size: 10px;\n color: #8e9685;\n}\n.step-button {\n display: flex;\n align-items: center;\n width: 100%;\n gap: 9px;\n padding: 12px 10px;\n border: 1px solid transparent;\n border-radius: 5px;\n text-align: left;\n margin-bottom: 3px;\n font-size: 13px;\n color: #626c58;\n}\n.step-button .status-icon,\n.step-button .status-icon svg {\n width: 14px;\n height: 14px;\n}\n.step-button .mono {\n margin-left: auto;\n font-size: 10px;\n color: #87907c;\n}\n.step-button.active {\n border-color: #e2e5dc;\n background: white;\n box-shadow: 0 1px 3px #23351204;\n color: #303e25;\n}\n.step-button:hover {\n background: #f0f3eb;\n}\n.step-count {\n border-top: 1px solid var(--line);\n padding: 16px 10px 0;\n margin-top: 18px;\n font-size: 10px;\n color: #7d8672;\n display: flex;\n gap: 6px;\n align-items: center;\n}\n.step-count svg {\n width: 12px;\n height: 12px;\n}\n.log-panel {\n min-width: 0;\n background: #1c211e;\n color: #c9d0c5;\n display: flex;\n flex-direction: column;\n}\n.log-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 16px 19px;\n border-bottom: 1px solid #343b31;\n background: #252b26;\n gap: 10px;\n}\n.log-heading h3 {\n font-size: 13px;\n font-weight: 500;\n color: #dce2d7;\n}\n.log-heading h3 small {\n margin-left: 8px;\n color: #adba9f;\n font-size: 10px;\n font-weight: 400;\n}\n.log-actions {\n display: flex;\n gap: 6px;\n align-items: center;\n}\n.log-actions .icon-button {\n color: #a6b09e;\n min-height: 25px;\n min-width: 25px;\n}\n.log-actions .icon-button:hover {\n background: #3d453a;\n color: white;\n}\n.log-actions svg {\n width: 14px;\n height: 14px;\n}\n.log-search {\n border-bottom: 1px solid #343b31;\n padding: 9px 17px;\n display: flex;\n align-items: center;\n gap: 7px;\n}\n.log-search svg {\n color: #8b9983;\n width: 13px;\n}\n.log-search input {\n width: 100%;\n min-width: 0;\n background: none;\n border: 0;\n color: #d6dfce;\n outline: none;\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 13px;\n}\n.log-search input::placeholder {\n color: #8d9b85;\n}\n.log-search:focus-within {\n box-shadow: inset 0 0 0 1px #a0c983;\n}\n.log-search span {\n font-size: 10px;\n white-space: nowrap;\n color: #a6b299;\n}\n.log-lines {\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 13px;\n line-height: 2.15;\n overflow: auto;\n padding: 15px 0;\n flex: 1;\n min-height: 260px;\n max-height: 490px;\n}\n.log-line {\n display: flex;\n white-space: pre;\n padding: 0 18px 0 0;\n min-width: max-content;\n}\n.line-number {\n color: #86927d;\n width: 44px;\n padding-right: 15px;\n text-align: right;\n user-select: none;\n flex-shrink: 0;\n}\n.log-time {\n color: #8a9782;\n margin-right: 16px;\n}\n.log-line.error {\n background: #59332c4a;\n color: #f0a28e;\n}\n.log-line.pass {\n color: #b9d49e;\n}\n.log-line.dim {\n color: #98a48f;\n}\n.log-line mark {\n background: #bdcb92;\n color: #202923;\n}\n.log-lines.wrap .log-line {\n white-space: pre-wrap;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.log-lines.wrap .line-text {\n min-width: 0;\n}\n.log-bottom {\n font-size: 10px;\n color: #a1af95;\n padding: 11px 17px;\n border-top: 1px solid #343b31;\n display: flex;\n justify-content: space-between;\n gap: 12px;\n}\n.detail-bottom {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px 0 7px;\n color: #838d78;\n font-size: 10px;\n}\n.detail-bottom span {\n display: flex;\n align-items: center;\n gap: 5px;\n}\n.detail-bottom svg {\n width: 12px;\n height: 12px;\n}\n.source-view {\n border: 1px solid var(--line);\n border-radius: 7px;\n overflow: hidden;\n}\n.source-view h3 {\n font-size: 13px;\n font-weight: 500;\n background: var(--subtle);\n padding: 15px 20px;\n border-bottom: 1px solid var(--line);\n}\n.source-view pre {\n margin: 0;\n background: #1c211e;\n color: #c2d3b5;\n padding: 25px;\n font-size: 12px;\n line-height: 1.9;\n overflow: auto;\n}\n.empty-state {\n text-align: center;\n padding: 55px 20px;\n color: var(--muted);\n}\n.empty-state svg {\n width: 27px;\n height: 27px;\n color: #99aa8a;\n margin-bottom: 15px;\n}\n.empty-state h3 {\n font-size: 14px;\n color: var(--ink);\n font-weight: 500;\n margin-bottom: 8px;\n}\n.empty-state p {\n font-size: 13px;\n line-height: 1.8;\n}\n.empty-state .button {\n margin-top: 14px;\n}\n.cards {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 16px;\n}\n.workflow-card,\n.worker-card {\n border: 1px solid var(--line);\n border-radius: 8px;\n padding: 20px;\n background: #fff;\n}\n.card-heading {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-bottom: 19px;\n}\n.card-heading > svg {\n width: 19px;\n height: 19px;\n color: #7e9468;\n}\n.card-heading h2 {\n font-size: 13px;\n}\n.card-heading .status {\n margin-left: auto;\n}\n.card-description {\n color: #7e8874;\n font-size: 13px;\n line-height: 1.8;\n}\n.card-details {\n display: flex;\n gap: 7px;\n margin-top: 14px;\n flex-wrap: wrap;\n}\n.tag {\n background: #f2f4ee;\n padding: 4px 7px;\n border-radius: 4px;\n font-size: 10px;\n color: #6b795e;\n}\n.card-footer {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-top: 23px;\n padding-top: 16px;\n border-top: 1px solid var(--line);\n}\n.card-footer .text-button {\n font-size: 13px;\n}\n.worker-stats {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 20px;\n margin-top: 23px;\n}\n.progress-track {\n height: 4px;\n border-radius: 3px;\n background: #edf0e7;\n margin-top: 10px;\n overflow: hidden;\n}\n.progress-fill {\n height: 100%;\n background: #a1bf85;\n border-radius: 3px;\n}\n.worker-stats .meta-label {\n display: flex;\n justify-content: space-between;\n}\n.status-tabs {\n display: flex;\n gap: 6px;\n overflow-x: auto;\n border-bottom: 1px solid var(--line);\n margin: 20px 0 0;\n}\n.status-tab {\n display: inline-flex;\n align-items: center;\n gap: 7px;\n padding: 10px 12px;\n color: #727b6e;\n white-space: nowrap;\n border-bottom: 2px solid transparent;\n}\n.status-tab:hover,\n.status-tab.active {\n color: #334334;\n}\n.status-tab.active {\n border-bottom-color: #718a62;\n}\n.status-tab .count {\n min-width: 18px;\n text-align: center;\n}\n.workflow-list {\n border: 1px solid var(--line);\n border-radius: 7px;\n overflow: hidden;\n background: #fff;\n}\n.workflow-item {\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto;\n align-items: center;\n gap: 5px 16px;\n padding: 13px 16px;\n border-bottom: 1px solid var(--line);\n}\n.workflow-item:last-child {\n border-bottom: 0;\n}\n.workflow-header,\n.workflow-meta,\n.workflow-actions {\n display: flex;\n align-items: center;\n gap: 10px;\n min-width: 0;\n}\n.workflow-header {\n grid-column: 1;\n}\n.workflow-icon svg {\n width: 17px;\n height: 17px;\n color: #7e9468;\n}\n.workflow-name {\n overflow: hidden;\n font-weight: 600;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.workflow-status {\n padding: 3px 7px;\n border-radius: 4px;\n background: #f2f4ee;\n color: #68745d;\n font-size: 11px;\n text-transform: capitalize;\n}\n.workflow-status.success {\n background: #edf5e8;\n color: #4d7040;\n}\n.workflow-meta {\n grid-column: 1;\n padding-left: 27px;\n color: #7e8874;\n font-size: 11px;\n}\n.workflow-actions {\n grid-column: 2;\n grid-row: 1 / span 2;\n flex-wrap: wrap;\n justify-content: flex-end;\n}\n.workflow-actions .button {\n padding: 6px 9px;\n font-size: 11px;\n}\n.settings-section {\n border: 1px solid var(--line);\n border-radius: 7px;\n margin-bottom: 16px;\n padding: 18px 20px;\n background: #fff;\n}\n.settings-section h2 {\n margin-bottom: 15px;\n}\n.setting-row {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 12px 0;\n gap: 16px;\n}\n.setting-row + .setting-row {\n border-top: 1px solid var(--line);\n}\n.setting-row strong {\n display: block;\n font-size: 12px;\n font-weight: 550;\n}\n.setting-row p {\n font-size: 11px;\n color: var(--muted);\n margin-top: 4px;\n}\n.setting-row input[type=\"checkbox\"] {\n accent-color: #678951;\n width: 17px;\n height: 17px;\n}\n.secret-value {\n color: var(--muted);\n font-size: 12px;\n}\n.settings-note {\n color: var(--muted);\n font-size: 13px;\n line-height: 1.8;\n}\ndialog {\n width: min(470px, calc(100% - 32px));\n padding: 27px;\n border: 1px solid #dde4d5;\n border-radius: 11px;\n box-shadow: 0 20px 80px #17261530;\n color: var(--ink);\n}\ndialog::backdrop {\n background: #17211755;\n backdrop-filter: blur(3px);\n}\n.dialog-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 10px;\n}\n.dialog-heading h2 {\n font-size: 19px;\n letter-spacing: -0.6px;\n}\n.dialog-description {\n font-size: 12px;\n line-height: 1.8;\n color: var(--muted);\n margin-bottom: 22px;\n}\n.form-field {\n display: block;\n margin: 17px 0;\n}\n.form-field > span {\n display: block;\n font-size: 13px;\n font-weight: 500;\n margin-bottom: 8px;\n}\n.form-field input,\n.form-field select {\n display: block;\n width: 100%;\n border: 1px solid #dfe5d8;\n border-radius: 6px;\n padding: 11px;\n background: white;\n font-size: 12px;\n color: var(--ink);\n}\n.dialog-footer {\n display: flex;\n justify-content: flex-end;\n gap: 9px;\n margin-top: 24px;\n}\n.dialog-note {\n color: #849078;\n font-size: 13px;\n line-height: 1.7;\n}\n.shortcut-row {\n display: flex;\n justify-content: space-between;\n padding: 12px 0;\n font-size: 12px;\n border-bottom: 1px solid var(--line);\n}\n.toast {\n position: fixed;\n bottom: 25px;\n left: calc(50% + 112px);\n transform: translateX(-50%);\n max-width: calc(100vw - 40px);\n background: #273321;\n color: #e6f0de;\n padding: 13px 19px;\n border: 1px solid #4f6342;\n border-radius: 7px;\n font-size: 12px;\n box-shadow: 0 8px 30px #15220c20;\n z-index: 100;\n}\nbody.compact .run-table td {\n padding-top: 12px;\n padding-bottom: 12px;\n}\n\n/* Shared secondary text stays readable on white and tinted surfaces. */\n.workspace-label,\n.nav-caption,\n.nav-item,\n.breadcrumbs,\n.metric-label,\n.metric-foot,\n.count,\n.run-subtitle,\n.run-table th,\n.run-table td,\n.table-footer,\n.table-hint,\n.attention-strip p span,\n.page-footer,\n.meta-label,\n.detail-tabs button,\n.steps-heading .mono,\n.step-button .mono,\n.step-count,\n.detail-bottom,\n.card-description,\n.tag,\n.dialog-note,\nkbd {\n color: var(--muted);\n}\n.avatar {\n color: #675c44;\n}\n.failure-banner p {\n color: #85634f;\n}\n.search-box input::placeholder {\n color: #69745f;\n}\n.subtitle,\n.nav-item,\n.button,\n.filter-tab,\n.meta-value,\n.card-description,\n.settings-note {\n font-size: 12px;\n}\n.run-subtitle,\n.run-table th,\n.select-filter,\n.search-box input,\n.status,\n.step-button {\n font-size: 13px;\n}\n.log-lines {\n font-size: 13px;\n}\n.detail-meta {\n overflow-wrap: anywhere;\n}\n@media (min-width: 1500px) {\n main {\n padding-top: 50px;\n }\n .run-table td {\n padding-top: 22px;\n padding-bottom: 22px;\n }\n}\n@media (max-width: 1180px) {\n .sidebar {\n width: 192px;\n padding-inline: 12px;\n }\n .app-shell {\n margin-left: 192px;\n }\n .topbar {\n padding-inline: 28px;\n }\n main {\n padding-inline: 28px;\n }\n .page-footer {\n margin-inline: 28px;\n }\n .metric {\n padding: 19px 16px;\n }\n .tiny-bars {\n display: none;\n }\n .metric-note {\n font-size: 10px;\n }\n .metric-value {\n font-size: 25px;\n }\n .run-table th:first-child {\n width: 40%;\n }\n .run-table th:nth-child(3) {\n width: 18%;\n }\n .run-table th:nth-child(2) {\n width: 18%;\n }\n .execution {\n grid-template-columns: 195px minmax(0, 1fr);\n }\n .toast {\n left: calc(50% + 96px);\n }\n .workspace {\n gap: 7px;\n }\n .workspace-label {\n display: none;\n }\n}\n@media (max-width: 900px) {\n .sidebar {\n width: 72px;\n align-items: center;\n padding: 27px 9px 15px;\n }\n .brand {\n font-size: 0;\n padding: 0;\n }\n .brand-mark {\n margin: 0;\n }\n .workspace,\n .nav-caption,\n .nav-label,\n .nav-count,\n .runner-health,\n .profile > span:not(.avatar) {\n display: none;\n }\n nav {\n margin-top: 40px;\n width: 100%;\n }\n .nav-item {\n justify-content: center;\n padding: 13px 10px;\n }\n .profile {\n padding-top: 15px;\n }\n .app-shell {\n margin-left: 72px;\n }\n .metric {\n padding: 17px 14px;\n }\n .metric-bottom {\n gap: 6px;\n }\n .metric-note {\n display: none;\n }\n .filters .search-box {\n width: auto;\n flex: 1;\n }\n .select-filter {\n max-width: 150px;\n }\n .run-table th:nth-child(3),\n .run-table td:nth-child(3) {\n display: none;\n }\n .run-table th:first-child {\n width: 48%;\n }\n .run-table th:nth-child(2) {\n width: 21%;\n }\n .run-table th:nth-child(4) {\n width: 14%;\n }\n .run-table th:last-child {\n width: 17%;\n }\n .detail-meta {\n grid-template-columns: repeat(3, 1fr);\n row-gap: 22px;\n }\n .run-heading {\n align-items: flex-start;\n }\n .run-heading h1 {\n font-size: 23px;\n }\n .run-heading .heading-actions {\n flex-direction: column;\n align-items: stretch;\n }\n .execution {\n grid-template-columns: 175px minmax(0, 1fr);\n }\n .step-button {\n gap: 6px;\n padding-inline: 6px;\n font-size: 10px;\n }\n .step-button .mono {\n font-size: 8px;\n }\n .log-time {\n display: none;\n }\n .attention-strip p span {\n display: none;\n }\n .toast {\n left: calc(50% + 36px);\n }\n}\n@media (max-width: 600px) {\n .sidebar {\n position: fixed;\n inset: auto 0 0;\n width: 100%;\n height: 62px;\n padding: 0 12px env(safe-area-inset-bottom);\n z-index: 20;\n border-right: 0;\n border-top: 1px solid var(--line);\n background: #fafbf8f5;\n backdrop-filter: blur(15px);\n }\n .sidebar .brand,\n .sidebar-bottom {\n display: none;\n }\n nav {\n display: flex;\n width: 100%;\n margin: 0;\n height: 100%;\n justify-content: space-around;\n }\n .nav-item {\n flex: 1;\n padding: 9px 6px 6px;\n flex-direction: column;\n gap: 4px;\n border-radius: 0;\n font-size: 10px;\n }\n .nav-item.active {\n background: none;\n color: #41602f;\n }\n .nav-label {\n display: block;\n }\n .nav-item svg {\n width: 18px;\n height: 18px;\n }\n .app-shell {\n margin-left: 0;\n padding-bottom: 62px;\n }\n .topbar {\n height: 58px;\n padding: 0 20px;\n }\n .topbar-right {\n gap: 9px;\n }\n .topbar-right .icon-button {\n display: none;\n }\n .demo-label {\n display: none;\n }\n .breadcrumbs {\n gap: 7px;\n font-size: 13px;\n }\n main {\n padding: 28px 20px 20px;\n }\n .page-heading {\n margin-bottom: 24px;\n gap: 10px;\n }\n h1 {\n font-size: 27px;\n }\n .subtitle {\n font-size: 13px;\n max-width: 210px;\n }\n .workflow-item {\n grid-template-columns: minmax(0, 1fr);\n gap: 8px;\n }\n .workflow-header,\n .workflow-meta,\n .workflow-actions {\n grid-column: 1;\n grid-row: auto;\n }\n .workflow-meta {\n padding-left: 27px;\n }\n .workflow-actions {\n justify-content: flex-start;\n padding-left: 27px;\n }\n .button {\n font-size: 13px;\n padding: 9px 10px;\n }\n .overview {\n grid-template-columns: 1fr 1fr;\n margin-bottom: 28px;\n }\n .metric {\n min-height: 97px;\n padding: 16px;\n }\n .metric-label {\n font-size: 13px;\n }\n .metric-value {\n font-size: 25px;\n }\n .metric-bottom {\n margin-top: 9px;\n }\n .metric:nth-child(3),\n .metric:nth-child(4) {\n border-top: 1px solid var(--line);\n }\n .metric:nth-child(3)::before {\n display: none;\n }\n .metric + .metric::before {\n top: 16px;\n bottom: 16px;\n }\n .metric-note {\n display: inline;\n }\n .tiny-bars {\n display: flex;\n }\n .metric-foot {\n display: none;\n }\n .section-heading {\n margin-bottom: 14px;\n }\n .section-heading > span {\n font-size: 10px;\n }\n .filters {\n flex-wrap: wrap;\n gap: 7px;\n }\n .filters .search-box {\n flex: 1 0 100%;\n }\n .select-filter {\n flex: 1;\n max-width: none;\n }\n .filter-tabs {\n gap: 19px;\n margin-top: 19px;\n }\n .filter-tab {\n font-size: 13px;\n gap: 4px;\n }\n .filter-tab .count {\n font-size: 8px;\n }\n .run-table th {\n font-size: 10px;\n padding-inline: 5px;\n }\n .run-table th:first-child {\n width: 67%;\n padding-left: 2px;\n }\n .run-table th:nth-child(2) {\n width: 33%;\n text-align: right;\n padding-right: 3px;\n }\n .run-table td {\n padding: 18px 3px;\n }\n .run-table td:first-child {\n padding-left: 2px;\n }\n .run-table td:nth-child(2) {\n text-align: right;\n padding-right: 3px;\n }\n .run-table th:nth-child(4),\n .run-table td:nth-child(4),\n .run-table th:last-child,\n .run-table td:last-child {\n display: none;\n }\n .run-link {\n gap: 7px;\n }\n .run-title {\n font-size: 13px;\n }\n .run-subtitle {\n font-size: 10px;\n gap: 4px;\n }\n .run-subtitle .mono {\n font-size: 8px;\n }\n .status {\n font-size: 10px;\n padding: 4px 5px;\n }\n .table-footer {\n flex-wrap: wrap;\n font-size: 10px;\n }\n .pagination {\n gap: 9px;\n }\n .table-hint {\n display: none;\n }\n .attention-strip {\n padding: 12px;\n gap: 7px;\n }\n .attention-strip p {\n font-size: 13px;\n }\n .attention-strip .text-button {\n font-size: 10px;\n }\n .page-footer {\n margin-inline: 20px;\n font-size: 8px;\n flex-wrap: wrap;\n gap: 7px;\n padding-block: 19px;\n }\n .footer-mark {\n font-size: 15px;\n }\n .toast {\n left: 50%;\n bottom: 78px;\n width: max-content;\n text-align: center;\n font-size: 13px;\n }\n .back-link {\n margin-bottom: 20px;\n }\n .run-heading {\n flex-direction: column;\n gap: 18px;\n }\n .run-heading h1 {\n font-size: 23px;\n }\n .run-heading .subtitle {\n margin-left: 0;\n max-width: none;\n font-size: 13px;\n }\n .run-heading .heading-actions {\n flex-direction: row;\n width: 100%;\n }\n .run-heading .heading-actions > * {\n flex: 1;\n max-width: none;\n }\n .detail-meta {\n grid-template-columns: repeat(2, 1fr);\n padding: 16px;\n gap: 20px 15px;\n }\n .detail-meta > div:last-child {\n grid-column: span 2;\n }\n .detail-tabs {\n gap: 20px;\n }\n .detail-tabs button {\n font-size: 13px;\n gap: 5px;\n }\n .detail-tabs button svg {\n width: 12px;\n }\n .failure-banner {\n padding: 12px;\n align-items: flex-start;\n flex-wrap: wrap;\n gap: 7px;\n }\n .failure-banner > div {\n flex: 1;\n }\n .failure-banner strong {\n font-size: 13px;\n }\n .failure-banner p {\n font-size: 10px;\n line-height: 1.6;\n }\n .failure-banner button {\n margin-left: 26px;\n font-size: 13px;\n }\n .execution {\n grid-template-columns: minmax(0, 1fr);\n }\n .steps-panel {\n border-right: 0;\n border-bottom: 1px solid var(--line);\n padding: 12px;\n }\n .steps-heading {\n padding: 3px 5px 10px;\n }\n .step-button {\n font-size: 13px;\n padding: 9px;\n margin-bottom: 1px;\n }\n .step-button .mono {\n font-size: 10px;\n }\n .step-count {\n display: none;\n }\n .log-heading {\n padding: 13px;\n }\n .log-heading h3 {\n font-size: 13px;\n }\n .log-heading h3 small {\n font-size: 8px;\n }\n .log-lines {\n font-size: 10px;\n min-height: 290px;\n }\n .log-search {\n padding-inline: 13px;\n }\n .line-number {\n width: 32px;\n padding-right: 10px;\n }\n .detail-bottom {\n gap: 10px;\n flex-wrap: wrap;\n font-size: 8px;\n }\n .cards {\n grid-template-columns: 1fr;\n }\n .workflow-card,\n .worker-card {\n padding: 20px;\n }\n .setting-row {\n gap: 14px;\n }\n .settings-section {\n padding: 18px;\n }\n .setting-row p {\n font-size: 13px;\n }\n .source-view pre {\n font-size: 13px;\n padding: 17px;\n }\n .topbar-right .avatar {\n display: none;\n }\n}\n@media (prefers-reduced-motion: no-preference) {\n .status-icon.running svg {\n animation: spin 2.5s linear infinite;\n }\n @keyframes spin {\n to {\n transform: rotate(360deg);\n }\n }\n}\n";
12359
+ //#region src/user-preferences.ts
12360
+ var rg = class {
12361
+ async init() {
12362
+ await r.exec("\n CREATE TABLE IF NOT EXISTS user_preferences (\n user_id TEXT NOT NULL,\n preference TEXT NOT NULL,\n value TEXT NOT NULL,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (user_id, preference)\n )\n ");
12363
+ }
12364
+ async getTimezone(e) {
12365
+ let t = await r.get("SELECT value FROM user_preferences WHERE user_id = ? AND preference = 'timezone'", [e]);
12366
+ return typeof t?.value == "string" ? t.value : null;
12367
+ }
12368
+ async setTimezone(e, t) {
12369
+ await r.run("INSERT INTO user_preferences (user_id, preference, value) VALUES (?, 'timezone', ?)\n ON CONFLICT(user_id, preference) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP", [e, t]);
12370
+ }
12371
+ }, ig = "let token = sessionStorage.getItem('runner-api-token') || '';\nlet expiresAt = token ? Number.MAX_SAFE_INTEGER : 0;\n\nconst rawFetch = window.fetch.bind(window);\n\nexport async function loadToken() {\n if (token && expiresAt > Date.now() + 30_000) return token;\n const response = await rawFetch('/api/auth/token', {\n headers: { accept: 'application/json' },\n });\n if (!response.ok) {\n if (response.status === 401) {\n // When /api/auth/token returns 401, clear OIDC in-memory token state and redirect\n // to login so the user can re-authenticate. This even applies when there was\n // no prior cached token, fixing the reported symptom where 401 was silently\n // swallowed and settings APIs stayed broken.\n token = '';\n expiresAt = 0;\n if (sessionStorage.getItem('runner-api-token')) {\n sessionStorage.removeItem('runner-api-token');\n }\n const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;\n window.location.assign(`/auth/login?url=${encodeURIComponent(returnTo)}`);\n }\n return '';\n }\n const data = await response.json();\n token = data.access_token || '';\n expiresAt = Number(data.expires_at || 0);\n return token;\n}\n\nexport async function apiFetch(input, init = {}) {\n const target = new URL(input, window.location.href);\n if (\n target.origin !== window.location.origin ||\n (!target.pathname.startsWith('/api/') && !target.pathname.startsWith('/restart/'))\n ) {\n return rawFetch(input, init);\n }\n const headers = new Headers(init.headers);\n const accessToken = await loadToken();\n if (accessToken) headers.set('authorization', `Bearer ${accessToken}`);\n return rawFetch(input, { ...init, headers });\n}\n\nexport function setApiToken(value) {\n token = value || '';\n expiresAt = token ? Number.MAX_SAFE_INTEGER : 0;\n if (token) sessionStorage.setItem('runner-api-token', token);\n else sessionStorage.removeItem('runner-api-token');\n}\n", ag = "import { defineProp, onInit, ref } from '@li3/web';\n\nexport default function () {\n const authConfigured = ref(false);\n const authenticated = ref(false);\n const userName = ref('');\n const userPhoto = ref('');\n onInit(async () => {\n try {\n const response = await fetch('/api/auth/session');\n if (!response.ok) return;\n const session = await response.json();\n authConfigured.value = session.configured;\n authenticated.value = session.authenticated;\n userName.value = session.user?.name || session.user?.email || '';\n userPhoto.value = session.user?.photo || '';\n } catch {}\n });\n return { title: defineProp('title'), subtitle: defineProp('subtitle'), back: defineProp('back'), backLabel: defineProp('back-label'), authConfigured, authenticated, userName, userPhoto };\n}\n", og = "import { getElement, load, onDestroy, onInit, templateRef } from '@li3/web';\n\nexport default function () {\n const outlet = templateRef('outlet');\n let navigating = false;\n const route = () => {\n const url = new URL(window.location.href);\n const path = url.pathname;\n if (path === '/' || path === '/runs')\n return { component: 'page-dashboard', url: '/pages/dashboard.html', page: 'dashboard' };\n if (path.match(/^\\/runs\\/\\d+$/))\n return { component: 'page-run', url: `/pages/run.html?jobId=${path.split('/').pop()}`, page: 'run' };\n if (path === '/help') return { component: 'page-help', url: '/pages/help.html', page: 'help' };\n if (path === '/settings/workflows')\n return { component: 'page-workflows', url: '/pages/workflows.html?page=workflows', page: 'workflows' };\n if (path === '/settings/secrets')\n return { component: 'page-secrets', url: '/pages/workflows.html?page=secrets', page: 'secrets' };\n if (path === '/settings/tokens')\n return { component: 'page-settings', url: '/pages/settings.html?page=tokens', page: 'tokens' };\n if (path === '/settings/notifications')\n return { component: 'page-settings', url: '/pages/settings.html?page=notifications', page: 'notifications' };\n if (path === '/settings/workers')\n return { component: 'page-settings', url: '/pages/settings.html?page=workers', page: 'workers' };\n if (path === '/settings' || path === '/workflows')\n return { component: 'page-workflows', url: '/pages/workflows.html?page=workflows', page: 'workflows' };\n const editor = path.match(/^\\/settings\\/workflows\\/(new|[a-z0-9-]+)$/);\n if (editor)\n return {\n component: 'page-workflow-editor',\n url: `/pages/workflows.html?page=editor&id=${editor[1]}${url.searchParams.get('revision') ? `&revision=${url.searchParams.get('revision')}` : ''}`,\n page: 'editor',\n };\n const legacyEditor = path.match(/^\\/workflows\\/(new|[a-z0-9-]+)$/);\n if (legacyEditor)\n return {\n component: 'page-workflow-editor',\n url: `/pages/workflows.html?page=editor&id=${legacyEditor[1]}${url.searchParams.get('revision') ? `&revision=${url.searchParams.get('revision')}` : ''}`,\n page: 'editor',\n };\n return { component: 'page-not-found', page: 'not-found' };\n };\n const navigate = async (replace = false) => {\n if (navigating) return;\n navigating = true;\n try {\n const current = route();\n document.body.dataset.page = current.page;\n if (current.page === 'not-found') {\n outlet.value.innerHTML =\n '<main class=\"mx-auto max-w-4xl p-8\"><h1 class=\"text-xl font-semibold\">404 — Page not found</h1><p class=\"mt-2 text-sm\">The page you are looking for does not exist. <a href=\"/\">Go home</a> or <a href=\"/runs\">view runs</a>.</p></main>';\n } else {\n await load(current.url);\n outlet.value.replaceChildren(document.createElement(current.component));\n }\n if (replace) history.replaceState(null, '', window.location.href);\n } catch (error) {\n outlet.value.innerHTML = `<main class=\"mx-auto max-w-4xl p-8\"><h1 class=\"text-xl font-semibold text-white\">Unable to load page</h1><p class=\"mt-2 text-sm text-rose-300\">${String(error.message || error)}</p></main>`;\n } finally {\n navigating = false;\n }\n };\n const handleClick = (event) => {\n const anchor = event.target.closest?.('a');\n if (!anchor || anchor.target || anchor.hasAttribute('download')) return;\n const target = new URL(anchor.href, window.location.href);\n if (\n target.origin !== window.location.origin ||\n !target.pathname.startsWith('/') ||\n target.pathname.startsWith('/auth/')\n )\n return;\n if (target.pathname.startsWith('/api/') || target.pathname.startsWith('/webhooks/')) return;\n if (target.pathname === window.location.pathname && target.hash) return;\n event.preventDefault();\n history.pushState(null, '', `${target.pathname}${target.search}${target.hash}`);\n void navigate();\n };\n const element = getElement();\n onInit(() => {\n element.addEventListener('click', handleClick);\n window.addEventListener('popstate', navigate);\n void navigate(true);\n });\n onDestroy(() => {\n element.removeEventListener('click', handleClick);\n window.removeEventListener('popstate', navigate);\n });\n return {};\n}\n", sg = "import { onDestroy, onInit, ref } from '@li3/web';\nimport { apiFetch } from '@app/api-client.mjs';\n\nexport default function setup() {\n const jobs = ref([]), refreshError = ref(false), hasMore = ref(false), loadingMore = ref(false);\n const filter = ref(''), activeFilter = ref(''), pushConfigured = ref(false);\n const notificationsSupported = ref('Notification' in window && 'serviceWorker' in navigator);\n const notificationsEnabled = ref(notificationsSupported.value && Notification.permission === 'granted' && localStorage.getItem('runner-notifications') === 'enabled');\n const notificationNotice = ref('');\n let refreshTimer, eventSource, refreshing = false, refreshPending = false, searchInProgress = false, refreshGeneration = 0;\n const upper = (value) => String(value || '').toUpperCase();\n const terminalStatuses = new Set(['success', 'failed', 'cancelled']);\n const notifyCompletedJob = async (job) => {\n if (!notificationsEnabled.value || pushConfigured.value || !['success', 'failed'].includes(job.status)) return;\n try {\n const registration = await navigator.serviceWorker.ready;\n await registration.showNotification(job.status === 'success' ? `Job #${job.id} succeeded` : `Job #${job.id} failed`, { body: `${job.workflowId} finished with status ${job.status}.`, icon: '/app-icon.svg', badge: '/app-icon.svg', tag: `runner-job-${job.id}`, renotify: true, data: { url: `/runs/${job.id}` } });\n } catch (error) { console.error('Unable to show job notification', error); }\n };\n const urlBase64ToUint8Array = (value) => { const padding = '='.repeat((4 - (value.length % 4)) % 4); const raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from([...raw].map((character) => character.charCodeAt(0))); };\n const enablePushNotifications = async () => {\n try {\n if (!notificationsSupported.value) return;\n const response = await apiFetch('/api/push/public-key', { headers: { accept: 'application/json' } });\n if (!response.ok) return;\n const { publicKey } = await response.json(); pushConfigured.value = true;\n const registration = await navigator.serviceWorker.ready;\n const subscription = (await registration.pushManager.getSubscription()) || await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });\n const saved = await apiFetch('/api/push/subscriptions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(subscription) });\n if (!saved.ok) throw new Error(`Push subscription failed: ${saved.status}`);\n } catch (error) { console.error('Unable to enable background notifications', error); }\n };\n const disablePushNotifications = async () => {\n try { const subscription = await (await navigator.serviceWorker.ready).pushManager.getSubscription(); if (!subscription) return; await apiFetch('/api/push/subscriptions', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ endpoint: subscription.endpoint }) }); await subscription.unsubscribe(); }\n catch (error) { console.error('Unable to disable background notifications', error); }\n };\n const toggleNotifications = async () => {\n if (notificationsEnabled.value) { notificationsEnabled.value = false; localStorage.removeItem('runner-notifications'); await disablePushNotifications(); notificationNotice.value = 'Job notifications are paused on this device.'; return; }\n if (Notification.permission === 'denied') { notificationNotice.value = 'Notifications are blocked. Allow them in this site’s Android browser settings.'; return; }\n notificationsEnabled.value = (await Notification.requestPermission()) === 'granted';\n if (notificationsEnabled.value) { localStorage.setItem('runner-notifications', 'enabled'); await enablePushNotifications(); notificationNotice.value = 'Notifications enabled. Background delivery is available when server push is configured.'; } else notificationNotice.value = 'Notification permission was not granted.';\n };\n const refreshJobs = async (isSearch = false) => {\n if (searchInProgress && !isSearch) return; if (refreshing && !isSearch) { refreshPending = true; return; }\n refreshing = true; const generation = refreshGeneration;\n try {\n const activeIds = jobs.value.filter((job) => !terminalStatuses.has(job.status)).map((job) => Number(job.id));\n const knownIds = jobs.value.map((job) => Number(job.id));\n const afterId = activeIds.length ? Math.max(0, Math.min(...activeIds) - 1) : knownIds.length ? Math.max(...knownIds) : null;\n const filterQuery = activeFilter.value ? `&filter=${encodeURIComponent(activeFilter.value)}` : '';\n const url = afterId === null ? `/api/jobs?limit=100${filterQuery}` : `/api/jobs?afterId=${afterId}&limit=100${filterQuery}`;\n const response = await apiFetch(url, { headers: { accept: 'application/json' } }); if (!response.ok) throw new Error(`Dashboard refresh failed: ${response.status}`);\n const data = await response.json(); if (generation !== refreshGeneration) return; hasMore.value = data.hasMore;\n const previousStatuses = new Map(jobs.value.map((job) => [job.id, job.status])); const merged = new Map(jobs.value.map((job) => [job.id, job]));\n for (const job of data.jobs) { const previousStatus = previousStatuses.get(job.id); merged.set(job.id, job); if (previousStatus && !terminalStatuses.has(previousStatus) && terminalStatuses.has(job.status)) void notifyCompletedJob(job); }\n jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id)); refreshError.value = false;\n } catch (error) { console.error(error); refreshError.value = true; } finally { refreshing = false; if (refreshPending && !searchInProgress) { refreshPending = false; void refreshJobs(); } }\n };\n const loadMore = async () => { if (loadingMore.value || !hasMore.value || !jobs.value.length) return; loadingMore.value = true; try { const beforeId = Math.min(...jobs.value.map((job) => Number(job.id))); const filterQuery = activeFilter.value ? `&filter=${encodeURIComponent(activeFilter.value)}` : ''; const response = await apiFetch(`/api/jobs?beforeId=${beforeId}${filterQuery}`, { headers: { accept: 'application/json' } }); if (!response.ok) throw new Error(`Loading older jobs failed: ${response.status}`); const data = await response.json(); const merged = new Map(jobs.value.map((job) => [job.id, job])); for (const job of data.jobs) merged.set(job.id, job); jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id)); hasMore.value = data.hasMore; } catch (error) { console.error(error); refreshError.value = true; } finally { loadingMore.value = false; } };\n const updateSearchUrl = (value) => { const params = new URLSearchParams(window.location.search); if (value) params.set('search', value); else params.delete('search'); const query = params.toString(); history.pushState(null, '', `${window.location.pathname}${query ? `?${query}` : ''}`); };\n const applyFilter = async () => { searchInProgress = true; refreshGeneration++; activeFilter.value = filter.value.trim(); updateSearchUrl(activeFilter.value); jobs.value = []; hasMore.value = false; try { await refreshJobs(true); } finally { searchInProgress = false; refreshPending = false; } };\n const clearFilter = async () => { searchInProgress = true; refreshGeneration++; filter.value = ''; activeFilter.value = ''; updateSearchUrl(''); jobs.value = []; hasMore.value = false; try { await refreshJobs(true); } finally { searchInProgress = false; refreshPending = false; } };\n const setFilter = (event) => { filter.value = event.target.value; };\n onInit(() => { const initialSearch = new URLSearchParams(window.location.search).get('search')?.trim() || ''; filter.value = initialSearch; activeFilter.value = initialSearch; if (notificationsSupported.value) navigator.serviceWorker.register('/service-worker.js').then(() => { if (notificationsEnabled.value) void enablePushNotifications(); }).catch((error) => console.error('Service worker registration failed', error)); eventSource = new EventSource('/api/events'); eventSource.addEventListener('jobs.available', refreshJobs); eventSource.addEventListener('jobs.changed', refreshJobs); eventSource.onerror = () => { refreshError.value = true; }; void refreshJobs(); refreshTimer = setInterval(refreshJobs, 60000); });\n onDestroy(() => { eventSource?.close(); clearInterval(refreshTimer); });\n return { jobs, refreshError, hasMore, loadingMore, filter, activeFilter, pushConfigured, setFilter, loadMore, applyFilter, clearFilter, upper, notificationsSupported, notificationsEnabled, notificationNotice, toggleNotifications };\n}\n", cg = "\n import { computed, onDestroy, onInit, ref } from '@li3/web';\n import { AnsiUp } from 'ansi_up';\n import { marked } from 'marked';\n import { apiFetch } from '@app/api-client.mjs';\n\n export default function setup() {\n const jobId = window.location.pathname.split('/').filter(Boolean).pop() || '';\n const report = ref({ jobId });\n const previousRuns = ref([]);\n const aiMessages = ref([]);\n const aiStepId = ref('');\n const aiQuestion = ref('');\n const aiError = ref('');\n const aiLoading = ref(false);\n const manualRestartOpen = ref(false);\n const manualRestartLoading = ref(false);\n const manualInputs = ref([]);\n let previousRunsLoaded = false;\n const now = ref(Date.now());\n const ansiUp = new AnsiUp();\n ansiUp.use_classes = false;\n let eventSource;\n let refreshTimer;\n let clockTimer;\n let refreshing = false;\n let refreshPending = false;\n const cancelling = ref(false);\n\n const active = computed(() => ['pending', 'running'].includes(report.value.status));\n const inputsJson = computed(() => JSON.stringify(report.value.inputs || {}, null, 2));\n const originalInputsJson = computed(() => JSON.stringify(report.value.inputs || {}, null, 2));\n const timing = computed(() => {\n if (report.value.status === 'pending') return 'Waiting for a worker';\n if (report.value.status === 'running') {\n return `Running for ${Math.max(0, now.value - Date.parse(report.value.startedAt))}ms`;\n }\n return `Finished in ${report.value.durationMs}ms`;\n });\n\n const upper = (value) => String(value || '').toUpperCase();\n const artifactUrl = (path) => `/api/runs/${report.value.jobId}/artifacts/${encodeURIComponent(path)}`;\n const downloadArtifact = async (event, path) => {\n event.preventDefault();\n const response = await apiFetch(artifactUrl(path));\n if (!response.ok) return;\n const link = document.createElement('a');\n link.href = URL.createObjectURL(await response.blob());\n link.download = path.split('/').pop() || 'artifact';\n link.click();\n URL.revokeObjectURL(link.href);\n };\n const loadPreviousRuns = async () => {\n if (previousRunsLoaded || !report.value.parentId) return;\n const lineage = [];\n const visited = new Set();\n let parentId = report.value.parentId;\n while (parentId && !visited.has(String(parentId)) && lineage.length < 50) {\n visited.add(String(parentId));\n const response = await apiFetch(`/api/runs/${parentId}`, { headers: { accept: 'application/json' } });\n if (!response.ok) throw new Error(`Previous run request failed: ${response.status}`);\n const parent = await response.json();\n lineage.push({ id: parent.jobId, status: parent.status, startedAt: parent.startedAt });\n parentId = parent.parentId;\n }\n previousRuns.value = lineage;\n previousRunsLoaded = true;\n };\n const stepLog = (step) => {\n if (step.status === 'skipped') return '';\n if (step.status === 'running')\n return '<span class=\"text-indigo-400\">Step is running. Logs will appear after it finishes.</span>';\n if (step.status === 'pending') return '<span class=\"text-gray-500\">Waiting to run.</span>';\n if (step.logContent) return ansiUp.ansi_to_html(step.logContent);\n return '<span class=\"text-gray-500\">(No terminal log output recorded for this step)</span>';\n };\n const sanitizeAiHtml = (html) => {\n const parsed = new DOMParser().parseFromString(html, 'text/html');\n parsed.querySelectorAll('script,style,iframe,object,embed,form').forEach((node) => node.remove());\n parsed.querySelectorAll('*').forEach((element) => {\n [...element.attributes].forEach((attribute) => {\n if (attribute.name.toLowerCase().startsWith('on')) element.removeAttribute(attribute.name);\n if (['href', 'src'].includes(attribute.name.toLowerCase()) && /^(javascript|data):/i.test(attribute.value)) element.removeAttribute(attribute.name);\n });\n });\n return parsed.body.innerHTML;\n };\n const renderAiMarkdown = (content) => sanitizeAiHtml(marked.parse(content));\n const askAi = async (step, event) => {\n event.stopPropagation();\n aiStepId.value = step.id;\n aiMessages.value = [];\n aiQuestion.value = '';\n await requestAiHelp(step.id, 'Please explain why this step failed and what I should do next.');\n };\n const requestAiHelp = async (stepId, question) => {\n aiLoading.value = true;\n aiError.value = '';\n try {\n const response = await apiFetch(`/api/runs/${report.value.jobId}/ai-help`, {\n method: 'POST',\n headers: { accept: 'application/json', 'content-type': 'application/json' },\n body: JSON.stringify({ stepId, question, conversation: aiMessages.value }),\n });\n if (!response.ok) {\n const data = await response.json().catch(() => ({}));\n throw new Error(data.error || `AI help failed: ${response.status}`);\n }\n aiMessages.value = [...aiMessages.value, { role: 'user', content: question }, { role: 'assistant', content: '' }];\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const chunk = await reader.read();\n if (chunk.done) break;\n buffer += decoder.decode(chunk.value, { stream: true });\n const events = buffer.split('\\n\\n');\n buffer = events.pop() || '';\n for (const event of events) {\n const line = event.split('\\n').find((value) => value.startsWith('data: '));\n if (!line) continue;\n const data = JSON.parse(line.slice(6));\n if (data.delta) {\n const messages = [...aiMessages.value];\n messages[messages.length - 1] = { ...messages[messages.length - 1], content: messages[messages.length - 1].content + data.delta };\n aiMessages.value = messages;\n }\n if (data.error) throw new Error(data.error);\n }\n }\n const messages = [...aiMessages.value];\n const answer = messages.at(-1);\n if (answer) messages[messages.length - 1] = { ...answer, html: renderAiMarkdown(answer.content) };\n aiMessages.value = messages;\n } catch (error) {\n aiError.value = error.message;\n } finally {\n aiLoading.value = false;\n }\n };\n const askFollowup = async () => {\n const question = aiQuestion.value.trim();\n if (!question || !aiStepId.value) return;\n aiQuestion.value = '';\n await requestAiHelp(aiStepId.value, question);\n };\n const restartJob = async () => {\n const response = await apiFetch(`/restart/${report.value.jobId}`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ inputs: report.value.inputs || {} }),\n });\n if (response.ok) {\n const { id } = await response.json();\n location.href = `/runs/${id}`;\n }\n };\n const openManualRestart = (event) => {\n event.preventDefault();\n event.stopPropagation();\n manualInputs.value = [{ key: '', value: '' }];\n manualRestartOpen.value = true;\n };\n const closeManualRestart = () => { manualRestartOpen.value = false; };\n const addManualInput = () => { manualInputs.value = [...manualInputs.value, { key: '', value: '' }]; };\n const removeManualInput = (entry) => { manualInputs.value = manualInputs.value.filter((item) => item !== entry); };\n const setManualKey = (entry, event) => { entry.key = event.target.value; manualInputs.value = [...manualInputs.value]; };\n const setManualValue = (entry, event) => { entry.value = event.target.value; manualInputs.value = [...manualInputs.value]; };\n const restartWithInputs = async () => {\n const inputs = Object.fromEntries(manualInputs.value.filter((entry) => entry.key.trim()).map((entry) => [entry.key.trim(), entry.value]));\n manualRestartLoading.value = true;\n try {\n const response = await apiFetch(`/restart/${report.value.jobId}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ inputs }) });\n if (!response.ok) throw new Error(`Restart failed: ${response.status}`);\n const { id } = await response.json();\n location.href = `/runs/${id}`;\n } catch (error) {\n aiError.value = error.message;\n } finally {\n manualRestartLoading.value = false;\n }\n };\n const cancelJob = async () => {\n if (cancelling.value || report.value.status !== 'running') return;\n cancelling.value = true;\n try {\n const response = await apiFetch(`/api/jobs/${report.value.jobId}/cancel`, { method: 'POST' });\n if (!response.ok) throw new Error(`Cancel failed: ${response.status}`);\n await refreshRun();\n } catch (error) {\n console.error(error);\n } finally {\n cancelling.value = false;\n }\n };\n const refreshRun = async () => {\n if (refreshing) {\n refreshPending = true;\n return;\n }\n refreshing = true;\n\n try {\n const response = await apiFetch(`/api/runs/${jobId}`, {\n headers: { accept: 'application/json' },\n });\n if (!response.ok) throw new Error(`Run refresh failed: ${response.status}`);\n const previousStatus = report.value.status;\n const nextReport = await response.json();\n report.value = nextReport;\n document.title = `Run #${nextReport.jobId} - ${nextReport.workflowName}`;\n void loadPreviousRuns();\n if (\n ['pending', 'running'].includes(previousStatus) &&\n ['success', 'failed'].includes(nextReport.status) &&\n 'Notification' in window &&\n 'serviceWorker' in navigator &&\n Notification.permission === 'granted' &&\n localStorage.getItem('runner-notifications') === 'enabled'\n ) {\n const registration = await navigator.serviceWorker.ready;\n await registration.showNotification(\n nextReport.status === 'success'\n ? `Job #${nextReport.jobId} succeeded`\n : `Job #${nextReport.jobId} failed`,\n {\n body: `${nextReport.workflowName} finished with status ${nextReport.status}.`,\n icon: '/app-icon.svg',\n badge: '/app-icon.svg',\n tag: `runner-job-${nextReport.jobId}`,\n renotify: true,\n data: { url: `/runs/${nextReport.jobId}` },\n },\n );\n }\n } catch (error) {\n console.error(error);\n } finally {\n refreshing = false;\n if (refreshPending) {\n refreshPending = false;\n void refreshRun();\n }\n }\n };\n const handleJobChange = (event) => {\n const data = JSON.parse(event.data || '{}');\n if (!data.jobId || String(data.jobId) === String(report.value.jobId)) void refreshRun();\n };\n\n onInit(() => {\n void refreshRun();\n if ('serviceWorker' in navigator) {\n navigator.serviceWorker.register('/service-worker.js').catch((error) => {\n console.error('Service worker registration failed', error);\n });\n }\n eventSource = new EventSource('/api/events');\n eventSource.addEventListener('jobs.changed', handleJobChange);\n refreshTimer = setInterval(() => {\n if (active.value) void refreshRun();\n }, 60000);\n clockTimer = setInterval(() => {\n now.value = Date.now();\n }, 1000);\n });\n onDestroy(() => {\n eventSource?.close();\n clearInterval(refreshTimer);\n clearInterval(clockTimer);\n });\n\n return { report, previousRuns, manualRestartOpen, manualRestartLoading, manualInputs, aiMessages, aiStepId, aiQuestion, aiError, aiLoading, inputsJson, originalInputsJson, timing, upper, artifactUrl, downloadArtifact, stepLog, askAi, askFollowup, restartJob, openManualRestart, closeManualRestart, addManualInput, removeManualInput, setManualKey, setManualValue, restartWithInputs, cancelJob, cancelling };\n }\n \n", lg = "\n import { onInit, ref } from '@li3/web';\n import { apiFetch, setApiToken } from '@app/api-client.mjs';\n export default function () {\n const page = document.body.dataset.page;\n const keys = ref([]);\n const workers = ref([]);\n const keyName = ref('');\n const newToken = ref('');\n const error = ref('');\n const notificationsSupported = ref('Notification' in window && 'serviceWorker' in navigator);\n const notificationsEnabled = ref(notificationsSupported.value && Notification.permission === 'granted' && localStorage.getItem('runner-notifications') === 'enabled');\n const notificationMessage = ref('');\n const scopeOptions = ref([\n { name: 'workflows:read', label: 'Read workflows', selected: true },\n { name: 'workflows:write', label: 'Modify workflows', selected: false },\n { name: 'logs:read', label: 'Read logs', selected: false },\n { name: 'artifacts:read', label: 'Read artifacts', selected: false },\n { name: 'runs:control', label: 'Control runs', selected: false },\n { name: 'runs:dispatch', label: 'Dispatch runs', selected: false },\n { name: 'workers:read', label: 'Read workers', selected: false },\n { name: 'secrets:read', label: 'Read secret names', selected: false },\n { name: 'secrets:write', label: 'Manage secrets', selected: false },\n ]);\n const api = async (url, options = {}) => { const response = await apiFetch(url, { headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) }, ...options }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`); return body; };\n const load = async () => { keys.value = (await api('/api/api-keys')).keys; };\n const loadWorkers = async () => { workers.value = (await api('/api/workers')).workers; };\n const issueKey = async () => { try { error.value = ''; const result = await api('/api/api-keys', { method: 'POST', body: JSON.stringify({ name: keyName.value, scopes: scopeOptions.value.filter((scope) => scope.selected).map((scope) => scope.name) }) }); newToken.value = result.token; setApiToken(result.token); keyName.value = ''; await load(); } catch (reason) { error.value = reason.message; } };\n const revokeKey = async (key) => { if (!confirm(`Revoke ${key.name}?`)) return; try { await api(`/api/api-keys/${key.id}`, { method: 'DELETE' }); await load(); } catch (reason) { error.value = reason.message; } };\n const setKeyName = (event) => { keyName.value = event.target.value; };\n const toggleScope = (scope) => { scope.selected = !scope.selected; };\n const toggleNotifications = async () => {\n if (notificationsEnabled.value) {\n notificationsEnabled.value = false;\n localStorage.removeItem('runner-notifications');\n notificationMessage.value = 'Notifications disabled on this device.';\n return;\n }\n if (!notificationsSupported.value) { notificationMessage.value = 'Notifications are not supported by this browser.'; return; }\n const permission = await Notification.requestPermission();\n notificationsEnabled.value = permission === 'granted';\n if (notificationsEnabled.value) { localStorage.setItem('runner-notifications', 'enabled'); notificationMessage.value = 'Notifications enabled.'; }\n else notificationMessage.value = 'Notification permission was not granted.';\n };\n onInit(() => { if (page === 'tokens') load().catch((reason) => { error.value = reason.message; }); if (page === 'workers') loadWorkers().catch((reason) => { error.value = reason.message; }); });\n return { page, keys, workers, keyName, newToken, error, scopeOptions, issueKey, revokeKey, setKeyName, toggleScope, notificationsEnabled, notificationMessage, toggleNotifications };\n }\n \n", ug = "\n import { onDestroy, onInit, ref, templateRef } from '@li3/web';\n import { diffLines } from 'diff';\n\n export default function () {\n const page = document.body.dataset.page;\n const params = new URLSearchParams(window.location.search);\n const workflowPath = window.location.pathname.match(/^\\/(?:settings\\/)?workflows\\/(new|[a-z0-9-]+)$/);\n const pathWorkflowId = workflowPath && workflowPath[1] !== \"new\" ? workflowPath[1] : \"\";\n const initialId = params.get(\"id\") || pathWorkflowId;\n const initialRevision = params.get(\"revision\") ? Number(params.get(\"revision\")) : null;\n const readOnly = Boolean(initialRevision);\n const workflows = ref([]);\n const secrets = ref([]);\n const source = ref('');\n const validation = ref({ valid: true });\n const savedSource = ref('');\n const revision = ref(null);\n const revisionDialogOpen = ref(false);\n const revisionNumber = ref(0);\n const revisionDiffHtml = ref('');\n const revisionError = ref('');\n const revisionLoading = ref(false);\n const aiDialogOpen = ref(false);\n const aiRequest = ref('');\n const aiLoading = ref(false);\n const aiError = ref('');\n const aiSuggestion = ref('');\n const aiDiffHtml = ref('');\n const workflowId = ref('');\n const enabled = ref(true);\n const savedEnabled = ref(true);\n const selectedId = ref('');\n const secretName = ref('');\n const secretValue = ref('');\n const fileMode = ref(false);\n const secretFileName = ref('');\n const secretFileData = ref('');\n const notice = ref('');\n const noticeError = ref(false);\n const busy = ref(false);\n const secretValueInput = templateRef('secretValueInput');\n const secretFileInput = templateRef('secretFileInput');\n const secretForm = templateRef('secretForm');\n const helpContent = templateRef('helpContent');\n const helpLoaded = ref(false);\n const helpLoading = ref(false);\n let validationTimer;\n\n const showNotice = (message, error = false) => {\n notice.value = message;\n noticeError.value = error;\n };\n\n const redirectToLogin = () => {\n const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;\n window.location.assign(`/auth/login?url=${encodeURIComponent(returnTo)}`);\n };\n\n const api = async (url, options = {}) => {\n const response = await fetch(url, {\n ...options,\n credentials: \"same-origin\",\n headers: {\n accept: \"application/json\",\n ...(options.body ? { \"content-type\": \"application/json\" } : {}),\n ...(options.headers || {}),\n },\n });\n if (response.status === 401) redirectToLogin();\n const body = response.status === 204 ? null : await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`);\n return body;\n };\n\n const derivedId = () =>\n source.value\n .match(/^\\s*(?:id:\\s*([^\\n]+)|name:\\s*([^\\n]+))/m)\n ?.slice(1)\n .find(Boolean)\n ?.trim()\n .toLowerCase()\n .replace(/[^a-z0-9]/g, \"-\")\n .replace(/(^-|-$)/g, \"\") || \"\";\n\n const run = async (action) => {\n if (busy.value) return;\n\n busy.value = true;\n\n try {\n await action();\n } catch (error) {\n showNotice(error.message, true);\n } finally {\n busy.value = false;\n }\n };\n\n const loadWorkflows = async () => {\n workflows.value = (await api('/api/workflows')).workflows;\n };\n\n const loadSecrets = async () => {\n secrets.value = (await api('/api/secrets')).secrets;\n };\n\n const openWorkflow = async (id) => {\n const workflowUrl = initialRevision\n ? `/api/workflows/${id}?revision=${initialRevision}`\n : `/api/workflows/${id}`;\n const workflow = await api(workflowUrl);\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n source.value = workflow.sourceYaml;\n savedSource.value = workflow.sourceYaml;\n enabled.value = workflow.enabled;\n savedEnabled.value = workflow.enabled;\n revision.value = workflow.revision || null;\n };\n\n const validateSource = async () => {\n return api('/api/workflows/validate', {\n method: 'POST',\n body: JSON.stringify({ sourceYaml: source.value }),\n });\n };\n\n const validate = (ms = 1000) => {\n if (page !== \"editor\") return;\n\n clearTimeout(validationTimer);\n validationTimer = setTimeout(() => {\n validation.value = { ...validation.value, running: true, error: \"\" };\n void validateSource()\n .then((result) => {\n validation.value = { ...result, running: false };\n })\n .catch((error) => {\n validation.value = { valid: false, running: false, error: error.message };\n });\n }, ms);\n };\n\n const save = () =>\n run(async () => {\n const id = (workflowId.value.trim() || derivedId()).toLowerCase();\n if (!id) throw new Error(\"Set an ID or add a workflow name first.\");\n if (!/^[a-z0-9-]+$/.test(id))\n throw new Error(\"Workflow IDs can contain lowercase letters, numbers, and hyphens only.\");\n if (\n initialId &&\n id === selectedId.value &&\n source.value === savedSource.value &&\n enabled.value === savedEnabled.value\n ) {\n showNotice(\"No changes to save.\");\n return;\n }\n const workflow = await api(`/api/workflows/${id}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source.value, enabled: enabled.value }),\n });\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n savedSource.value = source.value;\n savedEnabled.value = enabled.value;\n revision.value = workflow.revision;\n showNotice(`Saved ${workflow.id} as draft revision ${workflow.revision}.`);\n if (!initialId) history.replaceState(null, \"\", `/workflows/${workflow.id}`);\n });\n const publish = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id) throw new Error('Save a workflow before publishing it.');\n const workflow = await api(`/api/workflows/${id}/publish`, { method: 'POST' });\n selectedId.value = workflow.id;\n revision.value = workflow.revision;\n showNotice(`Published ${workflow.id} revision ${workflow.revision}.`);\n });\n const runNow = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id) throw new Error('Save the workflow before running it.');\n if (source.value !== savedSource.value) throw new Error('Save the latest draft before running it.');\n const result = await api(`/api/workflows/${id}/run`, {\n method: 'POST',\n body: JSON.stringify({}),\n });\n showNotice(`Queued ${result.jobs.length} job${result.jobs.length === 1 ? '' : 's'} from revision ${result.revision}.`);\n });\n const remove = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id || !confirm(`Delete ${id} and all of its revisions?`)) return;\n await api(`/api/workflows/${id}`, { method: \"DELETE\" });\n window.location.href = \"/workflows\";\n });\n const saveSecret = () =>\n run(async () => {\n const name = secretName.value.trim().toUpperCase();\n if (!/^[A-Z][A-Z0-9_]*$/.test(name))\n throw new Error(\n \"Secret names must start with a letter and contain only letters, numbers, and underscores.\",\n );\n secretName.value = name;\n if (!name || (fileMode.value ? !secretFileData.value : !secretValue.value))\n throw new Error(fileMode.value ? \"Choose a file first.\" : \"Set a secret value first.\");\n await api(`/api/secrets/${name}`, {\n method: \"PUT\",\n body: JSON.stringify(\n fileMode.value\n ? { value: secretFileData.value, encoding: \"base64\", originalName: secretFileName.value }\n : { value: secretValue.value, encoding: \"utf8\" },\n ),\n });\n secretValue.value = \"\";\n secretFileName.value = \"\";\n secretFileData.value = \"\";\n fileMode.value = false;\n if (secretFileInput.value) secretFileInput.value.value = \"\";\n if (secretForm.value) secretForm.value.open = false;\n await loadSecrets();\n showNotice(`Saved ${name}.`);\n });\n const removeSecret = (requestedName = \"\") =>\n run(async () => {\n const name = (requestedName || secretName.value.trim()).toUpperCase();\n if (!name || !confirm(`Delete ${name}?`)) return;\n await api(`/api/secrets/${name}`, { method: \"DELETE\" });\n if (secretName.value.trim().toUpperCase() === name) {\n secretName.value = \"\";\n secretValue.value = \"\";\n secretFileName.value = \"\";\n secretFileData.value = \"\";\n fileMode.value = false;\n if (secretFileInput.value) secretFileInput.value.value = \"\";\n }\n await loadSecrets();\n showNotice(`Deleted ${name}.`);\n });\n const setSource = (value) => {\n source.value = value;\n validate();\n };\n const setWorkflowId = (event) => {\n workflowId.value = event.target.value;\n };\n const setEnabled = (event) => {\n enabled.value = event.target.checked;\n };\n const setSecretName = (event) => {\n secretName.value = event.target.value;\n };\n const setSecretValue = (event) => {\n secretValue.value = event.target.value;\n fileMode.value = false;\n if (secretValue.value) {\n secretFileName.value = '';\n secretFileData.value = '';\n if (secretFileInput.value) secretFileInput.value.value = '';\n }\n };\n const setFileMode = (event) => {\n fileMode.value = event.target.checked;\n if (!fileMode.value) {\n secretFileName.value = '';\n secretFileData.value = '';\n if (secretFileInput.value) secretFileInput.value.value = '';\n } else {\n secretValue.value = '';\n }\n };\n const setSecretFile = async (event) => {\n const file = event.target.files?.[0];\n if (!file) return;\n const bytes = new Uint8Array(await file.arrayBuffer());\n let binary = '';\n for (let index = 0; index < bytes.length; index += 0x8000) {\n binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));\n }\n secretFileName.value = file.name;\n secretFileData.value = btoa(binary);\n secretValue.value = '';\n };\n const selectSecret = (name) => {\n secretName.value = name;\n secretValueInput.value?.focus();\n };\n const visitWorkflow = (id) => {\n window.location.href = `/workflows/${id}`;\n };\n const loadHelp = async (event) => {\n if (!event.target.open || helpLoaded.value || helpLoading.value || !helpContent.value) return;\n helpLoading.value = true;\n helpContent.value.textContent = 'Loading workflow syntax help...';\n try {\n const response = await fetch('/help?embed=1', { headers: { accept: 'text/html' } });\n if (!response.ok) throw new Error(`Help request failed: ${response.status}`);\n const html = await response.text();\n const parsed = new DOMParser().parseFromString(html, 'text/html');\n const sourceNodes = [\n ...Array.from(parsed.head.querySelectorAll('style')),\n ...Array.from(parsed.body.childNodes),\n ];\n const nodes = sourceNodes.map((node) => document.importNode(node, true));\n helpContent.value.replaceChildren(...nodes);\n helpLoaded.value = true;\n } catch (error) {\n helpContent.value.textContent = 'Unable to load workflow syntax help.';\n console.error(error);\n } finally {\n helpLoading.value = false;\n }\n };\n\n const escapeRevisionHtml = (value) => value.replace(/[&<>\"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' })[character] || '');\n const renderRevisionDiff = (previousSource, currentSource) => {\n if (previousSource === null) return `<span class=\"text-emerald-300\">+ ${escapeRevisionHtml(currentSource)}</span>\\n`;\n return diffLines(previousSource, currentSource).map((part) => {\n const className = part.added ? 'text-emerald-300 bg-emerald-500/10' : part.removed ? 'text-rose-300 bg-rose-500/10' : 'text-gray-300';\n const prefix = part.added ? '+ ' : part.removed ? '- ' : ' ';\n return part.value.split('\\n').filter((line, index, lines) => index < lines.length - 1 || line).map((line) => `<span class=\"block ${className}\">${prefix}${escapeRevisionHtml(line)}</span>`).join('');\n }).join('');\n };\n const openRevisionDialog = async () => {\n if (!revision.value) return;\n revisionNumber.value = revision.value;\n revisionDiffHtml.value = '';\n revisionError.value = '';\n revisionDialogOpen.value = true;\n await loadRevisionDiff(revision.value);\n };\n const closeRevisionDialog = () => {\n revisionDialogOpen.value = false;\n };\n const loadRevisionDiff = async (number) => {\n revisionLoading.value = true;\n revisionError.value = '';\n try {\n const current = await api(`/api/workflows/${selectedId.value}?revision=${number}`);\n const previous = number > 1 ? await api(`/api/workflows/${selectedId.value}?revision=${number - 1}`) : null;\n revisionDiffHtml.value = renderRevisionDiff(previous?.sourceYaml ?? null, current.sourceYaml);\n } catch (error) {\n revisionError.value = error.message;\n } finally {\n revisionLoading.value = false;\n }\n };\n const navigateRevision = async (offset) => {\n if (!selectedId.value || revisionLoading.value) return;\n const target = revisionNumber.value + offset;\n if (target < 1 || target > revision.value) return;\n revisionNumber.value = target;\n await loadRevisionDiff(target);\n };\n const openAiHelp = () => {\n aiRequest.value = '';\n aiError.value = '';\n aiSuggestion.value = '';\n aiDiffHtml.value = '';\n aiDialogOpen.value = true;\n };\n const closeAiHelp = () => { if (!aiLoading.value) aiDialogOpen.value = false; };\n const requestAiHelp = async () => {\n if (!aiRequest.value.trim() || aiLoading.value) return;\n aiLoading.value = true;\n aiError.value = \"\";\n aiDiffHtml.value = \"\";\n try {\n const response = await fetch(\"/api/ai/workflow-help\", {\n method: \"POST\",\n credentials: \"same-origin\",\n headers: { accept: \"text/event-stream\", \"content-type\": \"application/json\" },\n body: JSON.stringify({ sourceYaml: source.value, request: aiRequest.value }),\n });\n if (response.status === 401) redirectToLogin();\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(error.error || `AI help failed: ${response.status}`);\n }\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let suggestion = \"\";\n while (true) {\n const chunk = await reader.read();\n if (chunk.done) break;\n buffer += decoder.decode(chunk.value, { stream: true });\n const events = buffer.split(\"\\n\\n\");\n buffer = events.pop() || \"\";\n for (const event of events) {\n const line = event.split(\"\\n\").find((value) => value.startsWith(\"data: \"));\n if (!line) continue;\n const data = JSON.parse(line.slice(6));\n if (data.error) throw new Error(data.error);\n if (data.delta) suggestion += data.delta;\n }\n }\n suggestion = suggestion\n .replace(/^```(?:yaml|yml)?\\s*/i, \"\")\n .replace(/\\s*```$/i, \"\")\n .trim();\n if (!source.value.trim()) {\n source.value = suggestion;\n aiDialogOpen.value = false;\n validate();\n } else {\n aiSuggestion.value = suggestion;\n aiDiffHtml.value = renderRevisionDiff(source.value, suggestion);\n }\n } catch (error) {\n aiError.value = error.message;\n } finally {\n aiLoading.value = false;\n }\n };\n const acceptAiSuggestion = () => { source.value = aiSuggestion.value; aiDialogOpen.value = false; validate(); };\n const rejectAiSuggestion = () => { aiSuggestion.value = ''; aiDiffHtml.value = ''; };\n\n onDestroy(() => clearTimeout(validationTimer));\n\n onInit(() => {\n const loadPage = page === 'editor'\n ? initialId ? openWorkflow(initialId) : Promise.resolve()\n : page === 'secrets' ? loadSecrets() : loadWorkflows();\n loadPage.catch((error) => showNotice(error.message, true));\n });\n\n return {\n page,\n workflows,\n secrets,\n source,\n validation,\n revision,\n revisionDialogOpen,\n revisionNumber,\n revisionDiffHtml,\n revisionError,\n revisionLoading,\n aiDialogOpen,\n aiRequest,\n aiLoading,\n aiError,\n aiDiffHtml,\n workflowId,\n enabled,\n readOnly,\n selectedId,\n secretName,\n secretValue,\n fileMode,\n secretFileName,\n secretFileData,\n notice,\n noticeError,\n busy,\n validate,\n save,\n publish,\n runNow,\n remove,\n saveSecret,\n removeSecret,\n setSource,\n setWorkflowId,\n setEnabled,\n setSecretName,\n setSecretValue,\n setFileMode,\n setSecretFile,\n selectSecret,\n visitWorkflow,\n loadHelp,\n openRevisionDialog,\n closeRevisionDialog,\n navigateRevision,\n openAiHelp,\n closeAiHelp,\n requestAiHelp,\n acceptAiSuggestion,\n rejectAiSuggestion,\n };\n }\n \n", dg = "/* global document, window, localStorage, navigator, URL, Blob, setTimeout, clearTimeout */\n\nconst paths = {\n runs: '<rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"3\"/><path d=\"m10 8 5 4-5 4Z\"/>',\n workflow:\n '<rect x=\"3\" y=\"3\" width=\"6\" height=\"6\" rx=\"1.5\"/><rect x=\"15\" y=\"15\" width=\"6\" height=\"6\" rx=\"1.5\"/><path d=\"M6 9v6a3 3 0 0 0 3 3h6M9 6h6a3 3 0 0 1 3 3v6\"/>',\n server:\n '<rect x=\"3\" y=\"3\" width=\"18\" height=\"7\" rx=\"2\"/><rect x=\"3\" y=\"14\" width=\"18\" height=\"7\" rx=\"2\"/><path d=\"M7 6.5h.01M7 17.5h.01M11 6.5h6M11 17.5h6\"/>',\n settings:\n '<path d=\"m9 3-1 3-3 1-2 3 2 2-1 3 3 2 3-1 2 2 3-1 1-3 3-1 2-3-2-2 1-3-3-2-3 1-2-2Z\"/><circle cx=\"11.5\" cy=\"10.5\" r=\"3\"/>',\n search: '<circle cx=\"10.5\" cy=\"10.5\" r=\"6.5\"/><path d=\"m16 16 4 4\"/>',\n plus: '<path d=\"M12 5v14M5 12h14\"/>',\n arrow: '<path d=\"M5 12h14m-5-5 5 5-5 5\"/>',\n back: '<path d=\"M19 12H5m5-5-5 5 5 5\"/>',\n chevron: '<path d=\"m9 6 6 6-6 6\"/>',\n check: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m8 12 3 3 5-6\"/>',\n failed: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m9 9 6 6m0-6-6 6\"/>',\n running: '<path d=\"M20.4 8.8A9 9 0 1 1 15.2 3.6\"/><path d=\"M20.4 3.6v5.2h-5.2\"/>',\n pending: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n cancelled: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m6 6 12 12\"/>',\n skipped: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/>',\n branch:\n '<circle cx=\"6\" cy=\"5\" r=\"2\"/><circle cx=\"6\" cy=\"19\" r=\"2\"/><circle cx=\"18\" cy=\"6\" r=\"2\"/><path d=\"M6 7v10m0-4h5a7 7 0 0 0 7-5\"/>',\n clock: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n commit: '<circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M3 12h5m8 0h5\"/>',\n refresh: '<path d=\"M20 7v5h-5M4 17v-5h5\"/><path d=\"M6.1 7a7 7 0 0 1 11.5-1L20 9M4 15l2.4 3A7 7 0 0 0 18 17\"/>',\n terminal: '<path d=\"m5 6 6 6-6 6m9 0h5\"/>',\n download: '<path d=\"M12 3v12m-4-4 4 4 4-4M4 16v4h16v-4\"/>',\n copy: '<rect x=\"8\" y=\"8\" width=\"12\" height=\"12\" rx=\"2\"/><path d=\"M16 8V4H4v12h4\"/>',\n wrap: '<path d=\"M3 6h18M3 12h14a3 3 0 0 1 0 6h-5m3-3-3 3 3 3M3 18h4\"/>',\n code: '<path d=\"m8 6-6 6 6 6m8-12 6 6-6 6M14 3l-4 18\"/>',\n box: '<path d=\"m12 3 9 5v9l-9 5-9-5V8Zm0 9 9-4M3 8l9 4v10M7.5 5.5l9 5\"/>',\n history: '<path d=\"M3 3v6h6M3.6 9a9 9 0 1 1-.2 6M12 7v5l3 2\"/>',\n keyboard:\n '<rect x=\"2\" y=\"5\" width=\"20\" height=\"14\" rx=\"2\"/><path d=\"M6 9h.01M10 9h.01M14 9h.01M18 9h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M7 15h10\"/>',\n close: '<path d=\"m6 6 12 12M6 18 18 6\"/>',\n alert: '<path d=\"m12 3 10 18H2Z\"/><path d=\"M12 9v5m0 3h.01\"/>',\n lock: '<rect x=\"5\" y=\"10\" width=\"14\" height=\"11\" rx=\"2\"/><path d=\"M8 10V6a4 4 0 0 1 8 0v4m-4 5v2\"/>',\n stop: '<rect x=\"5\" y=\"5\" width=\"14\" height=\"14\" rx=\"2\"/>',\n info: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 11v6m0-10h.01\"/>',\n};\nconst icon = (name) =>\n `<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">${\n paths[name] || paths.runs\n }</svg>`;\nconst escape = (value) =>\n String(value).replace(\n /[&<>\"']/g,\n (character) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" }[character])\n );\nconst labels = {\n success: \"Passed\",\n failed: \"Failed\",\n running: \"Running\",\n pending: \"Queued\",\n cancelled: \"Cancelled\",\n skipped: \"Skipped\",\n};\nconst statusIcon = (status) =>\n `<span class=\"status-icon ${status}\">${icon(status === \"success\" ? \"check\" : status)}</span>`;\nconst badge = (status) => `<span class=\"status ${status}\">${statusIcon(status)}${labels[status]}</span>`;\nconst workflows = [\n {\n id: \"build-docker-image\",\n name: \"Build Docker image\",\n description: \"Build, test, and publish a container image to the registry.\",\n labels: [\"docker\", \"linux\"],\n steps: [\"Checkout repository\", \"Install dependencies\", \"Run tests\", \"Build image\", \"Push to registry\"],\n },\n {\n id: \"build-publish-npm\",\n name: \"Build & publish npm\",\n description: \"Validate packages and publish a new release to npm.\",\n labels: [\"node\", \"linux\"],\n steps: [\"Checkout repository\", \"Install dependencies\", \"Run tests\", \"Build package\", \"Publish to npm\"],\n },\n {\n id: \"deploy-production\",\n name: \"Deploy production\",\n description: \"Release the latest application and verify service health.\",\n labels: [\"main-server\", \"linux\"],\n steps: [\"Checkout repository\", \"Pull image\", \"Verify configuration\", \"Deploy service\", \"Health check\"],\n },\n {\n id: \"quality-checks\",\n name: \"Quality checks\",\n description: \"Type checking, linting, and tests for every pull request.\",\n labels: [\"node\", \"linux\"],\n steps: [\"Checkout repository\", \"Install dependencies\", \"Run tests\", \"Type check\", \"Lint\"],\n },\n];\nconst seed = [\n [\"running\", 0, \"feat: add multi-platform builds\", \"feat/multi-platform\", \"a8f3c12\", 93, \"Just now\", \"on\"],\n [\"pending\", 1, \"chore: prepare September release\", \"main\", \"d72e8b1\", 0, \"1 min ago\", \"lithium\"],\n [\"failed\", 0, \"fix: resolve image build context\", \"main\", \"7bc91e4\", 142, \"4 min ago\", \"on\"],\n [\"success\", 2, \"feat: improve workflow navigation\", \"main\", \"f38ac02\", 68, \"12 min ago\", \"on\"],\n [\"success\", 1, \"fix: handle reconnect gracefully\", \"main\", \"c52d190\", 104, \"18 min ago\", \"sodium\"],\n [\"success\", 3, \"feat: add keyboard navigation\", \"feat/keyboard-nav\", \"8a12e6f\", 46, \"26 min ago\", \"lithium\"],\n [\"failed\", 1, \"chore: update dependency versions\", \"main\", \"e90b721\", 38, \"34 min ago\", \"lithium\"],\n [\"success\", 0, \"fix: restore health check endpoint\", \"main\", \"2a19d63\", 127, \"42 min ago\", \"auth\"],\n [\"success\", 1, \"feat: expose workflow metadata\", \"main\", \"b94d512\", 88, \"1 hr ago\", \"on\"],\n [\"success\", 3, \"test: cover retry boundaries\", \"fix/retries\", \"6fb841a\", 59, \"1 hr ago\", \"on\"],\n [\"cancelled\", 0, \"chore: rebuild base image\", \"main\", \"51ea273\", 17, \"2 hrs ago\", \"auth\"],\n [\"success\", 2, \"fix: improve session recovery\", \"main\", \"832df1a\", 62, \"2 hrs ago\", \"auth\"],\n [\"success\", 3, \"refactor: simplify component lifecycle\", \"main\", \"27d1c98\", 71, \"3 hrs ago\", \"lithium\"],\n [\"success\", 1, \"chore: release patch version\", \"main\", \"92dc128\", 96, \"3 hrs ago\", \"sodium\"],\n [\"success\", 0, \"feat: cache dependency layers\", \"main\", \"bd5201c\", 112, \"4 hrs ago\", \"on\"],\n [\"success\", 2, \"fix: persist worker labels\", \"main\", \"876da42\", 65, \"5 hrs ago\", \"on\"],\n];\nlet jobs = seed.map(([status, workflow, message, branch, commit, seconds, time, repo], index) => ({\n id: 2856 - index,\n status,\n workflow: workflows[workflow],\n message,\n branch,\n commit,\n seconds,\n time,\n repo,\n worker: status === \"pending\" ? null : \"alpha\",\n attempt: 1,\n parent: null,\n}));\nconst state = {\n search: \"\",\n status: \"all\",\n workflow: \"all\",\n branch: \"all\",\n page: 1,\n step: null,\n detailTab: \"logs\",\n logQuery: \"\",\n wrap: false,\n};\nconst main = document.querySelector(\"#main\");\nconst dialog = document.querySelector(\"#dialog\");\nlet toastTimer;\nlet previousRoute = \"\";\n\nfunction duration(seconds) {\n if (!seconds) {\n return \"—\";\n }\n return `${Math.floor(seconds / 60) ? `${Math.floor(seconds / 60)}m ` : \"\"}${seconds % 60}s`;\n}\nfunction toast(message) {\n const element = document.querySelector(\"#toast\");\n clearTimeout(toastTimer);\n element.textContent = message;\n element.hidden = false;\n toastTimer = setTimeout(() => {\n element.hidden = true;\n }, 4500);\n}\nfunction route() {\n const [path, query = \"\"] = window.location.hash.slice(1).split(\"?\");\n return { path: path || \"/runs\", query: new URL(`https://example.test/?${query}`).searchParams };\n}\nfunction currentJob() {\n return jobs.find((job) => job.id === Number(route().path.split(\"/\")[2]));\n}\nfunction updateFilterUrl() {\n const params = new URL(\"https://example.test\").searchParams;\n for (const key of [\"search\", \"status\", \"workflow\", \"branch\", \"page\"]) {\n if (state[key] !== \"\" && state[key] !== \"all\" && state[key] !== 1) {\n params.set(key, state[key]);\n }\n }\n window.history.replaceState(\n null,\n \"\",\n `${window.location.pathname}${window.location.search}#/runs${params.size ? `?${params}` : \"\"}`\n );\n}\nfunction readFilters() {\n const { query } = route();\n state.search = query.get(\"search\") || \"\";\n state.status = [\"all\", \"running\", \"failed\", \"success\"].includes(query.get(\"status\")) ? query.get(\"status\") : \"all\";\n state.workflow = workflows.some((workflow) => workflow.id === query.get(\"workflow\")) ? query.get(\"workflow\") : \"all\";\n state.branch = [\"main\", \"feature\"].includes(query.get(\"branch\")) ? query.get(\"branch\") : \"all\";\n const page = Number(query.get(\"page\"));\n state.page = Number.isSafeInteger(page) && page > 0 ? page : 1;\n}\nfunction navigation(page) {\n document.querySelector(\"#navigation\").innerHTML = [\n [\"runs\", \"Runs\", \"runs\"],\n [\"workflows\", \"Workflows\", \"workflow\"],\n [\"workers\", \"Workers\", \"server\"],\n [\"settings\", \"Settings\", \"settings\"],\n ]\n .map(\n ([id, title, symbol]) =>\n `<a href=\"#/${id}\" class=\"nav-item ${id === page ? \"active\" : \"\"}\" ${\n id === page ? 'aria-current=\"page\"' : \"\"\n } aria-label=\"${title}\">${icon(symbol)}<span class=\"nav-label\">${title}</span>${\n id === \"runs\"\n ? `<span class=\"nav-count\">${\n jobs.filter((job) => [\"running\", \"pending\"].includes(job.status)).length\n }</span>`\n : \"\"\n }</a>`\n )\n .join(\"\");\n const title = page[0].toUpperCase() + page.slice(1);\n const job = currentJob();\n document.querySelector(\"#breadcrumbs\").innerHTML = job\n ? `<a href=\"#/runs\">Runs</a><span class=\"divider\">/</span><strong class=\"mono\">#${job.id}</strong>`\n : `<strong>${title}</strong>`;\n document.title = `${job ? `Run #${job.id}` : title} · Flow`;\n}\nfunction runsPage() {\n main.innerHTML = `<div class=\"page-heading\"><div><h1>Runs</h1><p class=\"subtitle\">Every change, from commit to complete.</p></div><button class=\"button primary\" data-action=\"new-run\">${icon(\n \"plus\"\n )}Run workflow</button></div>\n <section aria-labelledby=\"recent-runs\"><div class=\"section-heading\"><h2 id=\"recent-runs\">Runs <span class=\"count\">${\n jobs.length\n }</span></h2></div>\n <div class=\"filters\"><label class=\"search-box\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search runs</span><input id=\"run-search\" type=\"search\" value=\"${escape(\n state.search\n )}\" placeholder=\"Search runs, commits, or repositories…\" autocomplete=\"off\"/><kbd aria-hidden=\"true\">/</kbd></label>\n <select id=\"workflow-filter\" class=\"select-filter\" aria-label=\"Filter by workflow\"><option value=\"all\">All workflows</option>${workflows\n .map(\n (workflow) =>\n `<option value=\"${workflow.id}\" ${state.workflow === workflow.id ? \"selected\" : \"\"}>${workflow.name}</option>`\n )\n .join(\"\")}</select>\n <select id=\"branch-filter\" class=\"select-filter\" aria-label=\"Filter by branch\"><option value=\"all\">All branches</option><option value=\"main\" ${\n state.branch === \"main\" ? \"selected\" : \"\"\n }>main</option><option value=\"feature\" ${\n state.branch === \"feature\" ? \"selected\" : \"\"\n }>Feature branches</option></select></div>\n <div id=\"run-results\"></div></section>\n <div class=\"attention-strip\">${icon(\"alert\")}<p><strong>${\n jobs.filter((job) => job.status === \"failed\").length\n } failed runs</strong><span>A little attention goes a long way.</span></p><button class=\"text-button\" data-action=\"filter-failed\">Review failures ${icon(\n \"arrow\"\n )}</button></div>`;\n renderResults();\n}\nfunction filteredJobs(includeStatus = true) {\n return jobs.filter((job) => {\n const text = [job.id, job.message, job.workflow.id, job.workflow.name, job.repo, job.commit, job.branch]\n .join(\" \")\n .toLowerCase();\n return (\n text.includes(state.search.trim().toLowerCase()) &&\n (state.workflow === \"all\" || job.workflow.id === state.workflow) &&\n (state.branch === \"all\" || (state.branch === \"main\" ? job.branch === \"main\" : job.branch !== \"main\")) &&\n (!includeStatus ||\n state.status === \"all\" ||\n (state.status === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === state.status))\n );\n });\n}\nfunction renderResults() {\n const filtered = filteredJobs();\n const all = filteredJobs(false);\n const pages = Math.max(1, Math.ceil(filtered.length / 8));\n state.page = Math.min(pages, state.page);\n const visible = filtered.slice((state.page - 1) * 8, state.page * 8);\n document.querySelector(\n \"#run-results\"\n ).innerHTML = `<div class=\"filter-tabs\" role=\"group\" aria-label=\"Filter by run status\">${[\n [\"all\", \"All runs\"],\n [\"running\", \"In progress\"],\n [\"failed\", \"Failed\"],\n [\"success\", \"Passed\"],\n ]\n .map(\n ([value, label]) =>\n `<button class=\"filter-tab ${\n state.status === value ? \"active\" : \"\"\n }\" data-action=\"status-filter\" data-value=\"${value}\" aria-pressed=\"${\n state.status === value\n }\">${label}<span class=\"count\">${\n all.filter(\n (job) =>\n value === \"all\" ||\n (value === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === value)\n ).length\n }</span></button>`\n )\n .join(\"\")}</div>\n ${\n visible.length\n ? `<table class=\"run-table\"><caption class=\"sr-only\">Workflow runs, newest first</caption><thead><tr><th scope=\"col\">Workflow / commit</th><th scope=\"col\">Status</th><th scope=\"col\">Branch</th><th scope=\"col\">Duration</th><th scope=\"col\">Started</th></tr></thead><tbody>${visible\n .map(\n (job) =>\n `<tr><td><a class=\"run-link\" href=\"#/runs/${job.id}\" aria-label=\"Run ${job.id}: ${escape(\n job.workflow.name\n )}, ${labels[job.status]}\">${statusIcon(job.status)}<span class=\"run-copy\"><span class=\"run-title\">${\n job.workflow.name\n }</span><span class=\"run-subtitle\"><span class=\"mono\">#${\n job.id\n }</span><span class=\"separator\">·</span><span>cloud-cli/${\n job.repo\n }</span><span class=\"separator\">·</span><span class=\"mono\">${\n job.commit\n }</span></span></span></a></td><td>${badge(job.status)}</td><td><span class=\"branch\">${icon(\n \"branch\"\n )}${escape(job.branch)}</span></td><td><span class=\"mono duration\">${duration(\n job.seconds\n )}</span></td><td>${job.time}</td></tr>`\n )\n .join(\"\")}</tbody></table>`\n : `<div class=\"empty-state\">${icon(\n \"search\"\n )}<h3>No runs found</h3><p>Try a different search or clear your filters.</p><button class=\"button\" data-action=\"clear-filters\">Clear filters</button></div>`\n }\n <div class=\"table-footer\"><span role=\"status\">${\n filtered.length\n ? `${(state.page - 1) * 8 + 1}–${Math.min(state.page * 8, filtered.length)} of ${filtered.length} runs`\n : \"0 runs\"\n }</span><div class=\"pagination\"><button class=\"button\" data-action=\"prev-page\" ${\n state.page === 1 ? \"disabled\" : \"\"\n }>${icon(\"back\")}Previous</button><span>${\n state.page\n } / ${pages}</span><button class=\"button\" data-action=\"next-page\" ${\n state.page === pages ? \"disabled\" : \"\"\n }>Next${icon(\"arrow\")}</button></div></div><div class=\"table-hint\">${icon(\n \"info\"\n )}Open a run to explore its steps, logs, and execution history.</div>`;\n}\nfunction stepsFor(job) {\n return job.workflow.steps.map((name, index) => {\n let status = \"success\";\n if (job.status === \"failed\") {\n status = index < 2 ? \"success\" : index === 2 ? \"failed\" : \"skipped\";\n }\n if (job.status === \"running\") {\n status = index < 3 ? \"success\" : index === 3 ? \"running\" : \"pending\";\n }\n if (job.status === \"pending\") {\n status = \"pending\";\n }\n if (job.status === \"cancelled\") {\n status = index === 0 ? \"success\" : index === 1 ? \"cancelled\" : \"skipped\";\n }\n const seconds = [\"skipped\", \"pending\"].includes(status)\n ? 0\n : Math.round(job.seconds * [0.03, 0.24, 0.45, 0.2, 0.08][index]);\n return { name, status, seconds, index };\n });\n}\nfunction detailPage(job) {\n const steps = stepsFor(job);\n if (state.step === null) {\n state.step = Math.max(\n 0,\n steps.findIndex((step) => [\"failed\", \"running\"].includes(step.status))\n );\n }\n const active = [\"running\", \"pending\"].includes(job.status);\n main.innerHTML = `<a href=\"#/runs\" class=\"back-link\">${icon(\n \"back\"\n )}All runs</a><div class=\"page-heading run-heading\"><div><h1>${statusIcon(job.status)}${\n job.workflow.name\n }</h1><p class=\"subtitle\"><span class=\"mono\">#${job.id}</span><span>·</span><span>${escape(\n job.message\n )}</span></p></div><div class=\"heading-actions\"><button class=\"button\" data-action=\"download-logs\">${icon(\n \"download\"\n )}Download logs</button><button class=\"button ${active ? \"danger\" : \"primary\"}\" data-action=\"${\n active ? \"cancel-run\" : \"rerun\"\n }\">${icon(active ? \"stop\" : \"refresh\")}${active ? \"Cancel run\" : \"Re-run workflow\"}</button></div></div>\n <section class=\"detail-meta\" aria-label=\"Run metadata\"><div><span class=\"meta-label\">STATUS</span><span class=\"meta-value\">${badge(\n job.status\n )}</span></div><div><span class=\"meta-label\">REPOSITORY / BRANCH</span><span class=\"meta-value\">${icon(\n \"branch\"\n )}${escape(job.branch)}</span></div><div><span class=\"meta-label\">COMMIT</span><span class=\"meta-value mono\">${icon(\n \"commit\"\n )}${job.commit}</span></div><div><span class=\"meta-label\">DURATION</span><span class=\"meta-value\">${icon(\n \"clock\"\n )}${duration(job.seconds)}</span></div><div><span class=\"meta-label\">WORKER</span><span class=\"meta-value\">${icon(\n \"server\"\n )}${job.worker || \"Awaiting worker\"}${job.worker ? '<span class=\"tag\">linux · x64</span>' : \"\"}</span></div></section>\n <div class=\"detail-tabs\" role=\"group\" aria-label=\"Run detail views\">${[\n [\"logs\", \"Logs & steps\", \"terminal\"],\n [\"inputs\", \"Inputs\", \"code\"],\n [\"artifacts\", \"Artifacts\", \"box\"],\n [\"history\", \"History\", \"history\"],\n ]\n .map(\n ([value, title, symbol]) =>\n `<button data-action=\"detail-tab\" data-value=\"${value}\" class=\"${\n state.detailTab === value ? \"active\" : \"\"\n }\" aria-pressed=\"${state.detailTab === value}\">${icon(symbol)}${title}${\n value === \"history\" ? `<span class=\"count\">${job.attempt}</span>` : \"\"\n }</button>`\n )\n .join(\"\")}</div>\n <div id=\"detail-content\"></div>`;\n renderDetailContent(job);\n}\nfunction renderDetailContent(job) {\n const content = document.querySelector(\"#detail-content\");\n if (state.detailTab === \"inputs\") {\n content.innerHTML = `<section class=\"source-view\"><h3>Trigger inputs <span class=\"tag\">${\n job.parent ? \"manual restart\" : \"push event\"\n }</span></h3><pre>${escape(\n JSON.stringify(\n {\n repository: `cloud-cli/${job.repo}`,\n ref: `refs/heads/${job.branch}`,\n commit: job.commit,\n workflow: job.workflow.id,\n triggered_by: job.parent ? \"user:jamie\" : \"webhook:github\",\n },\n null,\n 2\n )\n )}</pre></section><section class=\"source-view\" style=\"margin-top:20px\"><h3>Workflow source · revision 12</h3><pre>${escape(\n workflowSource(job.workflow)\n )}</pre></section>`;\n return;\n }\n if (state.detailTab === \"artifacts\") {\n content.innerHTML =\n job.status === \"success\"\n ? `<section class=\"settings-section\"><h2>Run artifacts <span class=\"count\">1</span></h2><div class=\"setting-row\"><div><strong>${icon(\n \"box\"\n )} build-manifest.json</strong><p>Sample build metadata · JSON</p></div><button class=\"button\" data-action=\"artifact\">${icon(\n \"download\"\n )}Download</button></div></section>`\n : `<div class=\"empty-state\">${icon(\"box\")}<h3>No artifacts yet</h3><p>${\n activeStatus(job)\n ? \"Artifacts appear here when the run completes.\"\n : \"This run did not produce any artifacts.\"\n }</p></div>`;\n return;\n }\n if (state.detailTab === \"history\") {\n const history = [];\n let current = job;\n while (current) {\n history.push(current);\n current = jobs.find((candidate) => candidate.id === current.parent);\n }\n content.innerHTML = `<section class=\"settings-section\"><h2>Execution history</h2>${history\n .map(\n (entry) =>\n `<div class=\"setting-row\"><div><strong><a href=\"#/runs/${entry.id}\">Run #${entry.id} · Attempt ${\n entry.attempt\n }</a></strong><p>${\n entry.parent ? `Re-run of #${entry.parent} · Triggered by Jamie` : \"Original run · Triggered by GitHub\"\n } · ${entry.time}</p></div>${badge(entry.status)}</div>`\n )\n .join(\"\")}</section>`;\n return;\n }\n const steps = stepsFor(job);\n const selected = steps[state.step];\n content.innerHTML = `${\n job.status === \"failed\"\n ? `<div class=\"failure-banner\">${icon(\n \"alert\"\n )}<div><strong>Run tests failed with exit code 1</strong><p>One test failed. The remaining steps were skipped.</p></div><button class=\"text-button\" data-action=\"jump-error\">Jump to error ${icon(\n \"arrow\"\n )}</button></div>`\n : \"\"\n }\n <section class=\"execution\" aria-label=\"Execution steps and logs\"><aside class=\"steps-panel\" aria-label=\"Execution steps\"><div class=\"steps-heading\"><span>Execution steps</span><span class=\"mono\">${\n steps.filter((step) => step.status === \"success\").length\n } / ${steps.length}</span></div>${steps\n .map(\n (step) =>\n `<button class=\"step-button ${state.step === step.index ? \"active\" : \"\"}\" data-action=\"step\" data-value=\"${\n step.index\n }\" aria-pressed=\"${state.step === step.index}\" aria-label=\"${step.name}, ${labels[step.status]}\">${statusIcon(\n step.status\n )}<span>${step.name}</span><span class=\"mono\">${duration(step.seconds)}</span></button>`\n )\n .join(\"\")}<div class=\"step-count\">${icon(\"clock\")}Total duration ${duration(job.seconds)}</div></aside>\n <div class=\"log-panel\"><div class=\"log-heading\"><h3>${selected.name}<small>${\n selected.status === \"failed\" ? \"exit code 1\" : labels[selected.status]\n }</small></h3><div class=\"log-actions\"><button class=\"icon-button\" data-action=\"wrap\" aria-label=\"Wrap log lines\" aria-pressed=\"${\n state.wrap\n }\" title=\"Wrap log lines\">${icon(\n \"wrap\"\n )}</button><button class=\"icon-button\" data-action=\"copy-log\" aria-label=\"Copy step log\" title=\"Copy step log\">${icon(\n \"copy\"\n )}</button></div></div><label class=\"log-search\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search step log</span><input id=\"log-search\" type=\"search\" placeholder=\"Find in this step…\" value=\"${escape(\n state.logQuery\n )}\"/><span id=\"log-matches\"></span></label><div class=\"log-lines ${\n state.wrap ? \"wrap\" : \"\"\n }\" id=\"log-lines\" tabindex=\"0\" aria-label=\"Step log output\"></div><div class=\"log-bottom\"><span>${\n selected.status === \"failed\"\n ? \"Process exited with code 1\"\n : activeStatus(job)\n ? \"Sample execution output\"\n : \"End of step output\"\n }</span><span class=\"mono\">UTF-8</span></div></div></section>\n <div class=\"detail-bottom\"><span>${icon(\"lock\")}Logs are visible to workspace members.</span><span>cloud-cli/${\n job.repo\n }<span>·</span>Workflow revision 12<span>·</span>Attempt ${job.attempt}</span></div>`;\n renderLogs(job);\n}\nfunction activeStatus(job) {\n return [\"pending\", \"running\"].includes(job.status);\n}\nfunction logsFor(job, index) {\n const step = stepsFor(job)[index];\n if (step.status === \"skipped\") {\n return [{ text: \"Step skipped because an earlier step did not complete.\", type: \"dim\" }];\n }\n if (step.status === \"pending\") {\n return [{ text: \"Waiting for a worker…\", type: \"dim\" }];\n }\n if (step.status === \"failed\") {\n return [\n [\"$ pnpm test\", \"\"],\n [\"> @cloud-cli/on test\", \"dim\"],\n [\"> vitest run\", \"dim\"],\n [\"\", \"\"],\n [\" RUN v4.1.11 /workspace/cloud-cli/on\", \"\"],\n [\"\", \"\"],\n [\" ✓ src/config.spec.ts (8 tests) 14ms\", \"pass\"],\n [\" ✓ src/queue.spec.ts (12 tests) 31ms\", \"pass\"],\n [\" ❯ src/worker.spec.ts (6 tests | 1 failed) 89ms\", \"error\"],\n [\" × worker > retries a failed job\", \"error\"],\n [\"\", \"\"],\n [\" FAIL src/worker.spec.ts > worker > retries a failed job\", \"error\"],\n [\" AssertionError: expected 2 to equal 3\", \"error\"],\n [\"\", \"\"],\n [\" Expected: 3\", \"pass\"],\n [\" Received: 2\", \"error\"],\n [\"\", \"\"],\n [\" ❯ src/worker.spec.ts:142:28\", \"\"],\n [\" 140| await worker.retry(job);\", \"dim\"],\n [\" 141|\", \"dim\"],\n [\" 142| expect(job.attempts).toBe(3);\", \"error\"],\n [\" | ^\", \"error\"],\n [\"\", \"\"],\n [\" Test Files 1 failed | 2 passed (3)\", \"\"],\n [\" Tests 1 failed | 25 passed (26)\", \"\"],\n [\" Duration 1.42s\", \"dim\"],\n [\"\", \"\"],\n [\" ELIFECYCLE Test failed. See above for more details.\", \"error\"],\n ].map(([text, type]) => ({ text, type }));\n }\n const logs =\n index === 0\n ? [\n `$ git clone https://github.com/cloud-cli/${job.repo}.git .`,\n \"Cloning into '.'...\",\n \"remote: Enumerating objects: 184, done.\",\n \"remote: Compressing objects: 100% (92/92), done.\",\n \"Receiving objects: 100% (184/184), 148.2 KiB | 3.1 MiB/s, done.\",\n `$ git checkout ${job.commit}`,\n `HEAD is now at ${job.commit} ${job.message}`,\n \"\",\n \"✓ Repository ready.\",\n ]\n : index === 1\n ? [\n \"$ pnpm install --frozen-lockfile\",\n \"Lockfile is up to date, resolution step is skipped\",\n \"Progress: resolved 248, reused 248, downloaded 0, added 248\",\n \"\",\n \"✓ Dependencies installed from cache.\",\n \"Done in 2.4s.\",\n ]\n : index === 2\n ? [\n \"$ pnpm test\",\n \" RUN v4.1.11 /workspace\",\n \"\",\n \" ✓ src/config.spec.ts (8 tests)\",\n \" ✓ src/queue.spec.ts (12 tests)\",\n \" ✓ src/worker.spec.ts (6 tests)\",\n \"\",\n \" Test Files 3 passed (3)\",\n \" Tests 26 passed (26)\",\n \"\",\n \"✓ All tests passed.\",\n ]\n : [\n `$ ${job.workflow.id === \"build-docker-image\" ? \"docker build --tag app:latest .\" : \"pnpm build\"}`,\n \"#1 Loading build configuration\",\n \"#1 DONE 0.1s\",\n \"#2 Resolving dependencies\",\n \"#2 CACHED\",\n \"#3 Compiling source files\",\n \"#3 DONE 1.3s\",\n \"#4 Creating production bundle\",\n ...(step.status === \"running\" ? [\"#4 Building…\"] : [\"#4 DONE 0.8s\", \"\", \"✓ Step completed successfully.\"]),\n ];\n if (step.status === \"cancelled\") {\n logs.push(\"Run cancelled by Jamie.\");\n }\n return logs.map((text) => ({ text, type: text.startsWith(\"✓\") || text.includes(\" passed\") ? \"pass\" : \"\" }));\n}\nfunction renderLogs(job) {\n const logs = logsFor(job, state.step);\n const query = state.logQuery.toLowerCase();\n let matches = 0;\n document.querySelector(\"#log-lines\").innerHTML = logs\n .map((line, index) => {\n let text = escape(line.text);\n const position = query ? line.text.toLowerCase().indexOf(query) : -1;\n if (position >= 0) {\n matches++;\n text = `${escape(line.text.slice(0, position))}<mark>${escape(\n line.text.slice(position, position + query.length)\n )}</mark>${escape(line.text.slice(position + query.length))}`;\n }\n return `<div class=\"log-line ${line.type}\" ${\n line.type === \"error\" ? 'data-error=\"true\"' : \"\"\n }><span class=\"line-number\">${index + 1}</span><span class=\"log-time\">12:04:${String(\n 16 + Math.floor(index / 3)\n ).padStart(2, \"0\")}</span><span class=\"line-text\">${text || \" \"}</span></div>`;\n })\n .join(\"\");\n document.querySelector(\"#log-matches\").textContent = query ? `${matches} matching lines` : \"\";\n}\nfunction workflowSource(workflow) {\n return `name: ${workflow.name}\\nruns-on: [${workflow.labels.join(\n \", \"\n )}]\\n\\n# Illustrative workflow definition\\nsteps:\\n${workflow.steps\n .map(\n (name, index) =>\n ` - name: ${name}\\n run: ${\n [\n 'git checkout \"$COMMIT\"',\n \"pnpm install --frozen-lockfile\",\n \"pnpm test\",\n \"pnpm build\",\n 'echo \"Release complete\"',\n ][index]\n }`\n )\n .join(\"\\n\")}`;\n}\nfunction workflowsPage() {\n main.innerHTML = `<div class=\"page-heading\"><div><h1>Workflows</h1><p class=\"subtitle\">Your delivery process, clearly defined.</p></div><button class=\"button primary\" data-action=\"new-run\">${icon(\n \"plus\"\n )}Run workflow</button></div><div class=\"cards\">${workflows\n .map(\n (workflow, index) =>\n `<article class=\"workflow-card\"><div class=\"card-heading\">${icon(\"workflow\")}<h2>${\n workflow.name\n }</h2><span class=\"tag\">Enabled</span></div><p class=\"card-description\">${\n workflow.description\n }</p><div class=\"card-details\">${workflow.labels\n .map((label) => `<span class=\"tag mono\">${label}</span>`)\n .join(\"\")}<span class=\"tag\">${\n workflow.steps.length\n } steps</span></div><div class=\"card-footer\"><a class=\"text-button\" href=\"#/runs?workflow=${\n workflow.id\n }\">View runs ${icon(\n \"arrow\"\n )}</a><button class=\"button\" data-action=\"workflow-source\" data-value=\"${index}\">${icon(\n \"code\"\n )}View source</button></div></article>`\n )\n .join(\"\")}</div>`;\n}\nfunction workersPage() {\n main.innerHTML = `<div class=\"page-heading\"><div><h1>Workers</h1><p class=\"subtitle\">A clear view of your execution capacity.</p></div><span class=\"status success\">${icon(\n \"check\"\n )}3 operational</span></div><div class=\"cards\">${[\n [\"alpha\", \"main-server, docker\", 36, 42, \"1 / 4\"],\n [\"bravo\", \"node, linux\", 12, 28, \"0 / 4\"],\n [\"charlie\", \"docker, linux\", 8, 21, \"0 / 2\"],\n ]\n .map(\n ([name, tags, cpu, memory, slots]) =>\n `<article class=\"worker-card\"><div class=\"card-heading\">${icon(\n \"server\"\n )}<h2>${name}</h2><span class=\"status success\">Online</span></div><p class=\"card-description\">Linux x64 · systemd runner</p><div class=\"card-details\">${tags\n .split(\", \")\n .map((tag) => `<span class=\"tag mono\">${tag}</span>`)\n .join(\n \"\"\n )}</div><div class=\"worker-stats\"><div><span class=\"meta-label\">CPU <span>${cpu}%</span></span><div class=\"progress-track\"><div class=\"progress-fill\" style=\"width:${cpu}%\"></div></div></div><div><span class=\"meta-label\">Memory <span>${memory}%</span></span><div class=\"progress-track\"><div class=\"progress-fill\" style=\"width:${memory}%\"></div></div></div></div><div class=\"card-footer\"><span class=\"settings-note\">${slots} slots in use</span><span class=\"settings-note\">Sample snapshot</span></div></article>`\n )\n .join(\"\")}</div>`;\n}\nfunction getPreference(key) {\n try {\n return localStorage.getItem(`flow-concept-${key}`) === \"true\";\n } catch {\n return false;\n }\n}\nfunction settingsPage() {\n main.innerHTML = `<div class=\"page-heading\"><div><h1>Settings</h1><p class=\"subtitle\">The essentials for your workspace.</p></div></div><section class=\"settings-section\"><h2>Preferences</h2><label class=\"setting-row\"><span><strong>Compact run list</strong><span class=\"settings-note\">Fit more activity on your screen.</span></span><input type=\"checkbox\" id=\"compact-setting\" ${\n getPreference(\"compact\") ? \"checked\" : \"\"\n }/></label><label class=\"setting-row\"><span><strong>Wrap log lines</strong><span class=\"settings-note\">Keep long output within the log viewer.</span></span><input type=\"checkbox\" id=\"wrap-setting\" ${\n state.wrap ? \"checked\" : \"\"\n }/></label></section><section class=\"settings-section\"><h2>${icon(\n \"lock\"\n )} Secrets</h2><p class=\"settings-note\">Available to workflows. Values are never displayed in the dashboard.</p>${[\n \"NPM_TOKEN\",\n \"REGISTRY_PASSWORD\",\n \"DEPLOY_KEY\",\n ]\n .map(\n (name) =>\n `<div class=\"setting-row\"><div><strong class=\"mono\">${name}</strong><p>Workspace secret · Example</p></div><span class=\"secret-value\" aria-label=\"Value hidden\">••••••••••••</span></div>`\n )\n .join(\n \"\"\n )}</section><section class=\"settings-section\"><h2>About</h2><p class=\"settings-note\">An interactive design prototype based on Flow’s job list and run details. Run controls update this browser session. Display preferences are saved on this device.</p><div class=\"setting-row\"><span class=\"settings-note\">Explore without setup.</span><button class=\"button\" data-action=\"shortcuts\">${icon(\n \"keyboard\"\n )}Keyboard shortcuts</button></div></section>`;\n}\nfunction openDialog(title, body) {\n dialog.innerHTML = `<div class=\"dialog-heading\"><h2 id=\"dialog-title\">${title}</h2><button class=\"icon-button\" data-action=\"close-dialog\" aria-label=\"Close dialog\">${icon(\n \"close\"\n )}</button></div>${body}`;\n if (!dialog.open) {\n dialog.showModal();\n }\n}\nfunction newRunDialog() {\n openDialog(\n \"Run a workflow\",\n `<p class=\"dialog-description\">Choose what to run. We’ll take it from here.</p><form id=\"new-run-form\"><label class=\"form-field\"><span>Workflow</span><select name=\"workflow\">${workflows\n .map((workflow) => `<option value=\"${workflow.id}\">${workflow.name}</option>`)\n .join(\n \"\"\n )}</select></label><label class=\"form-field\"><span>Branch</span><input name=\"branch\" value=\"main\" required maxlength=\"100\" pattern=\"[a-zA-Z0-9._\\\\x2f\\\\x2d]+\"/></label><p class=\"dialog-note\">Prototype mode: creates a sample queued run in this session.</p><div class=\"dialog-footer\"><button class=\"button\" type=\"button\" data-action=\"close-dialog\">Cancel</button><button class=\"button primary\" type=\"submit\">${icon(\n \"runs\"\n )}Run workflow</button></div></form>`\n );\n}\nfunction createRun(original, workflow, branch) {\n const job = {\n id: Math.max(...jobs.map((entry) => entry.id)) + 1,\n workflow,\n branch,\n status: \"pending\",\n message: original?.message || \"Manually triggered workflow\",\n commit: original?.commit || \"a8f3c12\",\n seconds: 0,\n time: \"Just now\",\n repo: original?.repo || \"on\",\n worker: null,\n parent: original?.id || null,\n attempt: original ? original.attempt + 1 : 1,\n };\n jobs.unshift(job);\n dialog.close();\n window.location.hash = `/runs/${job.id}`;\n toast(`Sample run #${job.id} queued.`);\n}\nfunction download(name, text) {\n const url = URL.createObjectURL(new Blob([text], { type: \"text/plain;charset=utf-8\" }));\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = name;\n anchor.click();\n setTimeout(() => {\n URL.revokeObjectURL(url);\n }, 1000);\n}\nfunction render() {\n const { path } = route();\n const page = path.split(\"/\")[1] || \"runs\";\n const changed = previousRoute !== path;\n if (changed) {\n state.step = null;\n state.detailTab = \"logs\";\n state.logQuery = \"\";\n }\n previousRoute = path;\n navigation([\"runs\", \"workflows\", \"workers\", \"settings\"].includes(page) ? page : \"runs\");\n if (path === \"/runs\") {\n readFilters();\n runsPage();\n } else if (page === \"runs\" && currentJob()) {\n detailPage(currentJob());\n } else if (page === \"workflows\") {\n workflowsPage();\n } else if (page === \"workers\") {\n workersPage();\n } else if (page === \"settings\") {\n settingsPage();\n } else {\n main.innerHTML = `<div class=\"empty-state\">${icon(\n \"search\"\n )}<h1>Run not found</h1><p>This run is not part of the sample workspace.</p><a class=\"button\" href=\"#/runs\">Back to runs</a></div>`;\n }\n if (changed) {\n window.scrollTo(0, 0);\n main.focus({ preventScroll: true });\n }\n}\ndocument.addEventListener(\"click\", async (event) => {\n const button = event.target.closest(\"[data-action]\");\n if (!button) {\n return;\n }\n const { action, value } = button.dataset;\n const job = currentJob();\n if (action === \"new-run\") {\n newRunDialog();\n }\n if (action === \"close-dialog\") {\n dialog.close();\n }\n if (action === \"filter-failed\" || action === \"status-filter\") {\n state.status = action === \"filter-failed\" ? \"failed\" : value;\n state.page = 1;\n updateFilterUrl();\n renderResults();\n document.querySelector(`.filter-tab[data-value=\"${state.status}\"]`).focus({ preventScroll: true });\n }\n if (action === \"clear-filters\") {\n Object.assign(state, { search: \"\", status: \"all\", workflow: \"all\", branch: \"all\", page: 1 });\n updateFilterUrl();\n runsPage();\n document.querySelector(\"#run-search\").focus();\n }\n if (action === \"prev-page\" || action === \"next-page\") {\n state.page += action === \"next-page\" ? 1 : -1;\n updateFilterUrl();\n renderResults();\n document.querySelector(`button[data-action=\"${action}\"]:not(:disabled)`)?.focus({ preventScroll: true });\n }\n if (action === \"detail-tab\") {\n state.detailTab = value;\n detailPage(job);\n document.querySelector(`[data-action=\"detail-tab\"][data-value=\"${value}\"]`).focus({ preventScroll: true });\n }\n if (action === \"step\") {\n state.step = Number(value);\n state.logQuery = \"\";\n renderDetailContent(job);\n document.querySelector(`[data-action=\"step\"][data-value=\"${value}\"]`).focus({ preventScroll: true });\n }\n if (action === \"jump-error\") {\n state.step = 2;\n state.logQuery = \"\";\n renderDetailContent(job);\n document.querySelector(\"[data-error]\")?.scrollIntoView({ block: \"nearest\" });\n document.querySelector(\"#log-lines\").focus({ preventScroll: true });\n }\n if (action === \"wrap\") {\n state.wrap = !state.wrap;\n document.querySelector(\"#log-lines\").classList.toggle(\"wrap\", state.wrap);\n button.setAttribute(\"aria-pressed\", String(state.wrap));\n }\n if (action === \"copy-log\") {\n try {\n await navigator.clipboard.writeText(\n logsFor(job, state.step)\n .map((line) => line.text)\n .join(\"\\n\")\n );\n toast(\"Step log copied.\");\n } catch {\n toast(\"Clipboard unavailable. Use Download logs to save the output.\");\n }\n }\n if (action === \"download-logs\") {\n download(\n `flow-run-${job.id}.log`,\n job.workflow.steps\n .map(\n (name, index) =>\n `=== ${name} ===\\n${logsFor(job, index)\n .map((line) => line.text)\n .join(\"\\n\")}`\n )\n .join(\"\\n\\n\")\n );\n toast(\"Run log downloaded.\");\n }\n if (action === \"artifact\") {\n download(\n `run-${job.id}-build-manifest.json`,\n JSON.stringify({ sample: true, run: job.id, commit: job.commit, workflow: job.workflow.id }, null, 2)\n );\n }\n if (action === \"rerun\") {\n createRun(job, job.workflow, job.branch);\n }\n if (action === \"cancel-run\") {\n job.status = \"cancelled\";\n state.step = null;\n navigation(\"runs\");\n detailPage(job);\n toast(`Sample run #${job.id} cancelled.`);\n }\n if (action === \"workflow-source\") {\n const workflow = workflows[Number(value)];\n openDialog(\n workflow.name,\n `<p class=\"dialog-description\">Illustrative YAML · revision 12</p><div class=\"source-view\"><pre>${escape(\n workflowSource(workflow)\n )}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`\n );\n }\n if (action === \"preferences\") {\n window.location.hash = \"/settings\";\n }\n if (action === \"shortcuts\") {\n openDialog(\n \"A few useful shortcuts\",\n `<p class=\"dialog-description\">Less clicking. More getting things done.</p><div class=\"shortcut-row\"><span>Search runs</span><kbd>/</kbd></div><div class=\"shortcut-row\"><span>Run a workflow</span><kbd>N</kbd></div><div class=\"shortcut-row\"><span>Show shortcuts</span><kbd>?</kbd></div><div class=\"shortcut-row\"><span>Close dialog</span><kbd>Esc</kbd></div><div class=\"dialog-footer\"><button class=\"button primary\" data-action=\"close-dialog\">Got it</button></div>`\n );\n }\n});\ndocument.addEventListener(\"input\", (event) => {\n if (event.target.id === \"run-search\") {\n state.search = event.target.value;\n state.page = 1;\n updateFilterUrl();\n renderResults();\n }\n if (event.target.id === \"log-search\") {\n state.logQuery = event.target.value;\n renderLogs(currentJob());\n }\n});\ndocument.addEventListener(\"change\", (event) => {\n if ([\"workflow-filter\", \"branch-filter\"].includes(event.target.id)) {\n state[event.target.id === \"workflow-filter\" ? \"workflow\" : \"branch\"] = event.target.value;\n state.page = 1;\n updateFilterUrl();\n renderResults();\n }\n if ([\"compact-setting\", \"wrap-setting\"].includes(event.target.id)) {\n const key = event.target.id === \"compact-setting\" ? \"compact\" : \"wrap\";\n if (key === \"compact\") {\n document.body.classList.toggle(\"compact\", event.target.checked);\n } else {\n state.wrap = event.target.checked;\n }\n try {\n localStorage.setItem(`flow-concept-${key}`, event.target.checked);\n toast(\"Preference saved on this device.\");\n } catch {\n toast(\"Preference applied for this session.\");\n }\n }\n});\ndocument.addEventListener(\"submit\", (event) => {\n if (event.target.id === \"new-run-form\") {\n event.preventDefault();\n createRun(\n null,\n workflows.find((workflow) => workflow.id === event.target.elements.workflow.value),\n event.target.elements.branch.value.trim()\n );\n }\n});\ndocument.addEventListener(\"keydown\", (event) => {\n if (\n event.ctrlKey ||\n event.metaKey ||\n event.altKey ||\n event.target.closest('input,select,textarea,[contenteditable=\"true\"]') ||\n dialog.open\n ) {\n return;\n }\n if (event.key === \"/\" && route().path === \"/runs\") {\n event.preventDefault();\n document.querySelector(\"#run-search\").focus();\n }\n if (event.key.toLowerCase() === \"n\") {\n event.preventDefault();\n newRunDialog();\n }\n if (event.key === \"?\") {\n event.preventDefault();\n document.querySelector('[data-action=\"shortcuts\"]').click();\n }\n});\nwindow.addEventListener(\"hashchange\", render);\ndocument.querySelectorAll(\"[data-icon]\").forEach((element) => {\n element.innerHTML = icon(element.dataset.icon);\n});\ndocument.body.classList.toggle(\"compact\", getPreference(\"compact\"));\nstate.wrap = getPreference(\"wrap\");\nrender();\n", fg = "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"theme-color\" content=\"#f7f8f6\" />\n <meta name=\"description\" content=\"Flow — a quieter place to ship. An interactive CI dashboard design concept.\" />\n <title>Runs · Flow</title>\n <link\n rel=\"icon\"\n href=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23244336'/%3E%3Cpath d='M9 10h14M9 16h10M9 22h6' stroke='%23c4efad' stroke-width='3'/%3E%3C/svg%3E\"\n />\n <link rel=\"stylesheet\" href=\"./style.css\" />\n <script src=\"./app.js\" defer><\/script>\n </head>\n <body>\n <a href=\"#main\" class=\"skip-link\">Skip to content</a>\n <aside class=\"sidebar\" aria-label=\"Workspace navigation\">\n <a class=\"brand\" href=\"#/runs\" aria-label=\"Flow home\"\n ><span class=\"brand-mark\"><i></i><i></i><i></i></span>flow<span class=\"brand-period\">.</span></a\n >\n <div class=\"workspace\">\n <span class=\"workspace-icon\">a</span>\n <div><strong>Apphor</strong><span>Engineering workspace</span></div>\n <span class=\"workspace-label\">PRO</span>\n </div>\n <div class=\"nav-caption\">WORKSPACE</div>\n <nav id=\"navigation\"></nav>\n <div class=\"sidebar-bottom\">\n <div class=\"runner-health\">\n <span class=\"health-dot\"></span><span>Runners operational</span><span class=\"mono\">3/3</span>\n </div>\n <button class=\"profile\" data-action=\"preferences\">\n <span class=\"avatar\">JD</span><span><strong>Jamie Davis</strong><small>Workspace admin</small></span\n ><span data-icon=\"settings\"></span>\n </button>\n </div>\n </aside>\n <div class=\"app-shell\">\n <header class=\"topbar\">\n <div class=\"breadcrumbs\" id=\"breadcrumbs\"></div>\n <div class=\"topbar-right\">\n <button\n class=\"icon-button\"\n data-action=\"shortcuts\"\n aria-label=\"Keyboard shortcuts\"\n title=\"Keyboard shortcuts\"\n >\n <span data-icon=\"keyboard\"></span></button\n ><span class=\"avatar small\">JD</span>\n </div>\n </header>\n <main id=\"main\" tabindex=\"-1\"></main>\n </div>\n <dialog id=\"dialog\" aria-labelledby=\"dialog-title\"></dialog>\n <div id=\"toast\" role=\"status\" class=\"toast\" hidden></div>\n </body>\n</html>\n", pg = "@import url(\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;450;500;550;600;650;700&family=IBM+Plex+Mono:wght@400;500&display=swap\");\n\n:root {\n --bg: #f3f5f1;\n --sidebar: #e9eee6;\n --ink: #1a1e22;\n --muted: #505b52;\n --line: #cbd3c8;\n --subtle: #fafbf8;\n --green: #387045;\n --green-bg: #e8f0e5;\n --accent: #c8e6a8;\n --red: #b54138;\n --red-bg: #fee2e2;\n --blue: #4a66a0;\n --blue-bg: #e8eef6;\n --amber: #845e1c;\n font-family: \"DM Sans\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n color: var(--ink);\n font-size: 15px;\n font-synthesis: none;\n}\n* {\n box-sizing: border-box;\n}\nbody {\n margin: 0;\n background: var(--bg);\n}\nbutton,\ninput,\nselect {\n font: inherit;\n}\nbutton,\na,\ninput,\nselect,\nsummary {\n -webkit-tap-highlight-color: transparent;\n}\nbutton,\nselect,\nsummary {\n cursor: pointer;\n}\nbutton {\n color: inherit;\n}\na {\n color: inherit;\n text-decoration: none;\n}\nbutton {\n border: 0;\n background: none;\n}\nbutton:disabled {\n cursor: not-allowed;\n opacity: 0.45;\n}\n:focus-visible {\n outline: 2px solid #467b40;\n outline-offset: 4px;\n}\ninput:focus-visible,\nselect:focus-visible {\n outline-offset: 1px;\n}\nbutton,\na {\n touch-action: manipulation;\n}\nsvg {\n width: 18px;\n height: 18px;\n flex-shrink: 0;\n vertical-align: middle;\n}\nh1,\nh2,\nh3,\np {\n margin: 0;\n}\n.mono,\ncode,\nkbd,\npre {\n font-family: \"IBM Plex Mono\", monospace;\n}\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n}\n.skip-link {\n position: fixed;\n top: -60px;\n left: 20px;\n z-index: 100;\n background: var(--ink);\n color: white;\n padding: 12px;\n}\n.skip-link:focus {\n top: 10px;\n}\n.sidebar {\n width: 224px;\n position: fixed;\n inset: 0 auto 0 0;\n background: var(--sidebar);\n border-right: 1px solid var(--line);\n padding: 32px 17px 18px;\n display: flex;\n flex-direction: column;\n}\n.brand {\n display: flex;\n align-items: center;\n font-size: 33px;\n font-weight: 700;\n letter-spacing: -1.9px;\n padding: 0 13px;\n width: fit-content;\n}\n.brand-period {\n color: #729b5b;\n}\n.brand-mark {\n display: flex;\n flex-direction: column;\n justify-content: center;\n gap: 5px;\n width: 31px;\n height: 34px;\n margin-right: 10px;\n transform: skewY(-13deg);\n}\n.brand-mark i {\n display: block;\n width: 27px;\n height: 5px;\n border-radius: 2px;\n background: #547d42;\n}\n.brand-mark i:nth-child(2) {\n width: 20px;\n}\n.brand-mark i:nth-child(3) {\n width: 13px;\n}\n.workspace {\n display: flex;\n align-items: center;\n gap: 10px;\n margin: 38px 2px 30px;\n padding: 12px 10px;\n border: 1px solid var(--line);\n border-radius: 7px;\n background: var(--subtle);\n}\n.workspace-icon {\n display: grid;\n place-items: center;\n width: 30px;\n height: 31px;\n border: 1px solid #e2e7dc;\n border-radius: 5px;\n background: #edf1e7;\n font-weight: 700;\n font-size: 19px;\n}\n.workspace strong {\n display: block;\n font-size: 13px;\n font-weight: 600;\n}\n.workspace div > span {\n display: block;\n font-size: 13px;\n margin-top: 3px;\n color: var(--muted);\n}\n.workspace-label {\n margin-left: auto;\n font-size: 13px;\n border: 1px solid var(--line);\n padding: 2px 4px;\n color: var(--muted);\n border-radius: 3px;\n}\n.nav-caption {\n font-size: 13px;\n font-weight: 600;\n letter-spacing: 1.4px;\n color: var(--muted);\n padding: 0 13px;\n margin-bottom: 12px;\n}\nnav {\n display: grid;\n gap: 5px;\n}\n.nav-item {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 12px 14px;\n border-radius: 6px;\n color: var(--ink);\n font-size: 13px;\n font-weight: 500;\n}\n.nav-item:hover {\n background: var(--subtle);\n}\n.nav-item.active {\n background: #e8efdf;\n color: #344d29;\n}\n.nav-item svg {\n width: 17px;\n height: 17px;\n}\n.nav-count {\n margin-left: auto;\n font-size: 13px;\n background: #e2e8dc;\n padding: 2px 6px;\n border-radius: 4px;\n}\n.sidebar-bottom {\n margin-top: auto;\n}\n.runner-health {\n display: flex;\n align-items: center;\n gap: 7px;\n font-size: 13px;\n padding: 18px 8px;\n color: var(--muted);\n}\n.runner-health .mono {\n margin-left: auto;\n font-size: 10px;\n}\n.health-dot {\n width: 6px;\n height: 6px;\n background: #699d55;\n border-radius: 50%;\n flex-shrink: 0;\n}\n.profile {\n border-top: 1px solid var(--line);\n padding: 19px 5px 0;\n display: flex;\n align-items: center;\n gap: 10px;\n width: 100%;\n text-align: left;\n}\n.avatar {\n display: grid;\n place-items: center;\n background: #ebe6d9;\n color: #756a50;\n width: 33px;\n height: 33px;\n border-radius: 50%;\n font-size: 13px;\n font-weight: 600;\n flex-shrink: 0;\n}\n.profile strong {\n display: block;\n font-size: 13px;\n font-weight: 550;\n}\n.profile small {\n display: block;\n font-size: 13px;\n color: var(--muted);\n margin-top: 3px;\n}\n.profile > span:last-child {\n margin-left: auto;\n color: var(--muted);\n}\n.small {\n width: 27px;\n height: 27px;\n font-size: 10px;\n}\n.app-shell {\n margin-left: 224px;\n min-height: 100vh;\n display: flex;\n flex-direction: column;\n}\n.topbar {\n height: 72px;\n border-bottom: 1px solid var(--line);\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 42px;\n gap: 15px;\n}\n.breadcrumbs {\n display: flex;\n align-items: center;\n gap: 12px;\n font-size: 13px;\n color: #737a70;\n}\n.breadcrumbs a:hover {\n color: var(--ink);\n}\n.breadcrumbs .divider {\n color: #c5c9c1;\n}\n.breadcrumbs strong {\n color: #434b40;\n font-weight: 500;\n}\n.topbar-right {\n display: flex;\n align-items: center;\n gap: 20px;\n}\n.demo-label {\n display: flex;\n align-items: center;\n gap: 7px;\n font-size: 13px;\n color: var(--muted);\n}\n.demo-label > span {\n width: 5px;\n height: 5px;\n border-radius: 50%;\n background: #8c9c7f;\n}\n.icon-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 32px;\n min-height: 32px;\n border-radius: 5px;\n color: var(--muted);\n}\n.icon-button:hover {\n background: #edf0e8;\n color: var(--ink);\n}\nmain {\n width: 100%;\n max-width: 1600px;\n margin: 0 auto;\n padding: 43px 42px 30px;\n}\nmain:focus-visible {\n outline: none;\n}\n.page-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 20px;\n margin-bottom: 32px;\n}\nh1 {\n font-size: 29px;\n letter-spacing: -1px;\n font-weight: 550;\n line-height: 1.25;\n}\n.subtitle {\n color: var(--muted);\n font-size: 12px;\n margin-top: 9px;\n line-height: 1.7;\n}\n.heading-actions {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n.button {\n display: inline-flex;\n justify-content: center;\n align-items: center;\n gap: 7px;\n padding: 10px 13px;\n border: 1px solid #dfe4d9;\n border-radius: 6px;\n font-size: 13px;\n font-weight: 550;\n min-height: 36px;\n background: var(--bg);\n white-space: nowrap;\n}\n.button svg {\n width: 14px;\n height: 14px;\n}\n.button:hover {\n background: var(--subtle);\n border-color: #c8d0bf;\n}\n.button.primary {\n background: var(--accent);\n border-color: #b6d79d;\n color: #2c4320;\n}\n.button.primary:hover {\n background: #b7e198;\n}\n.button.danger {\n color: var(--red);\n border-color: #edcdc8;\n}\n.text-button {\n color: #52644b;\n font-size: 13px;\n padding: 6px 0;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n.text-button:hover {\n text-decoration: underline;\n}\n.text-button svg {\n width: 13px;\n}\n.overview {\n display: grid;\n grid-template-columns: 1.08fr 1.08fr 1fr 1fr;\n border: 1px solid var(--line);\n border-radius: 8px;\n margin-bottom: 31px;\n background: linear-gradient(110deg, #fcfdf9, #fff);\n overflow: hidden;\n}\n.metric {\n position: relative;\n padding: 22px 24px;\n min-height: 117px;\n}\n.metric + .metric::before {\n content: \"\";\n position: absolute;\n width: 1px;\n background: var(--line);\n top: 23px;\n bottom: 23px;\n left: 0;\n}\n.metric-label {\n display: flex;\n gap: 7px;\n align-items: center;\n font-size: 13px;\n color: #70786a;\n}\n.metric-label svg {\n width: 13px;\n height: 13px;\n color: #8a9482;\n}\n.metric-bottom {\n display: flex;\n align-items: baseline;\n gap: 10px;\n margin-top: 13px;\n}\n.metric-value {\n font-size: 27px;\n letter-spacing: -1px;\n font-weight: 500;\n}\n.metric-value small {\n font-size: 15px;\n letter-spacing: 0;\n}\n.metric-note {\n font-size: 13px;\n color: #69765f;\n}\n.metric-note span {\n color: var(--green);\n}\n.tiny-bars {\n display: flex;\n gap: 3px;\n align-items: end;\n height: 25px;\n margin-left: auto;\n}\n.tiny-bars i {\n width: 5px;\n background: #c4d9b2;\n border-radius: 2px;\n}\n.metric-foot {\n margin-top: 7px;\n font-size: 10px;\n color: #858b7f;\n}\n.section-heading {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 15px;\n margin-bottom: 17px;\n}\nh2 {\n font-size: 14px;\n font-weight: 550;\n letter-spacing: -0.2px;\n}\n.section-heading h2 {\n display: flex;\n gap: 9px;\n align-items: center;\n}\n.count {\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 10px;\n font-weight: 400;\n background: #f0f2ed;\n color: #747d6d;\n border: 1px solid #e7ebe1;\n border-radius: 4px;\n padding: 2px 5px;\n}\n.section-heading > span {\n color: var(--muted);\n font-size: 13px;\n display: flex;\n align-items: center;\n gap: 6px;\n}\n.section-heading > span svg {\n width: 12px;\n height: 12px;\n}\n.filters {\n display: flex;\n gap: 9px;\n align-items: center;\n margin-bottom: 15px;\n}\n.search-box {\n display: flex;\n align-items: center;\n gap: 9px;\n border: 1px solid var(--line);\n border-radius: 6px;\n height: 35px;\n padding: 0 10px;\n min-width: 0;\n background: white;\n}\n.search-box svg {\n width: 14px;\n height: 14px;\n color: #8a9184;\n}\n.search-box input {\n border: 0;\n min-width: 0;\n width: 100%;\n outline: 0;\n background: transparent;\n color: var(--ink);\n font-size: 13px;\n}\n.search-box:focus-within {\n border-color: #729859;\n box-shadow: 0 0 0 2px #edf5e8;\n}\n.search-box input::placeholder {\n color: #899080;\n}\n.filters .search-box {\n width: 300px;\n margin-right: auto;\n}\nkbd {\n font-size: 10px;\n border: 1px solid #e6e9e1;\n padding: 1px 4px;\n color: #838b7a;\n border-radius: 3px;\n}\n.select-filter {\n border: 1px solid var(--line);\n background: white;\n border-radius: 6px;\n padding: 8px 28px 8px 11px;\n color: #666f5e;\n font-size: 13px;\n height: 35px;\n max-width: 170px;\n}\n.filter-tabs {\n display: flex;\n gap: 23px;\n border-bottom: 1px solid var(--line);\n margin-bottom: 0;\n}\n.filter-tab {\n padding: 1px 0 13px;\n font-size: 13px;\n color: var(--muted);\n border-bottom: 2px solid transparent;\n display: flex;\n align-items: center;\n gap: 7px;\n margin-bottom: -1px;\n}\n.filter-tab.active {\n color: var(--ink);\n border-bottom-color: #5c7a46;\n font-weight: 550;\n}\n.filter-tab .count {\n border: 0;\n background: #f0f2ec;\n padding: 1px 5px;\n font-size: 10px;\n}\n.filter-tab.active .count {\n background: #eaf1e1;\n color: #536d40;\n}\n.run-table {\n width: 100%;\n border-collapse: collapse;\n text-align: left;\n table-layout: fixed;\n}\n.run-table th {\n font-weight: 400;\n font-size: 12px;\n color: #808877;\n padding: 14px 10px;\n background: #fafbf8;\n border-bottom: 1px solid var(--line);\n}\n.run-table th:first-child {\n padding-left: 15px;\n width: 44%;\n}\n.run-table th:nth-child(2) {\n width: 16%;\n}\n.run-table th:nth-child(3) {\n width: 17%;\n}\n.run-table th:nth-child(4) {\n width: 11%;\n}\n.run-table th:last-child {\n width: 12%;\n text-align: right;\n padding-right: 17px;\n}\n.run-table td {\n padding: 18px 10px;\n border-bottom: 1px solid var(--line);\n font-size: 13px;\n color: #747c6c;\n}\n.run-table td:first-child {\n padding-left: 15px;\n}\n.run-table td:last-child {\n text-align: right;\n padding-right: 17px;\n}\n.run-table tr:hover td {\n background: #fafbf8;\n}\n.run-link {\n display: flex;\n align-items: center;\n gap: 12px;\n min-width: 0;\n}\n.run-title {\n display: block;\n font-size: 13px;\n color: #30382c;\n font-weight: 550;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.run-copy {\n min-width: 0;\n}\n.run-subtitle {\n font-size: 13px;\n color: #818878;\n margin-top: 5px;\n display: flex;\n gap: 6px;\n align-items: center;\n}\n.run-subtitle .mono {\n font-size: 10px;\n}\n.run-subtitle .separator {\n color: #bdc4b5;\n}\n.status-icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: 21px;\n height: 21px;\n}\n.status-icon svg {\n width: 18px;\n height: 18px;\n}\n.status-icon.success {\n color: #6b9355;\n}\n.status-icon.failed {\n color: #cc7064;\n}\n.status-icon.running {\n color: #647fab;\n}\n.status-icon.pending {\n color: #af955e;\n}\n.status-icon.cancelled,\n.status-icon.skipped {\n color: #91998a;\n}\n.status {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n font-size: 13px;\n border-radius: 5px;\n padding: 4px 7px;\n white-space: nowrap;\n}\n.status .status-icon {\n width: 11px;\n height: 11px;\n}\n.status .status-icon svg {\n width: 11px;\n height: 11px;\n}\n.status.success {\n background: var(--green-bg);\n color: var(--green);\n}\n.status.failed {\n background: var(--red-bg);\n color: var(--red);\n}\n.status.running {\n background: var(--blue-bg);\n color: var(--blue);\n}\n.status.pending {\n background: #faf4e6;\n color: var(--amber);\n}\n.status.cancelled,\n.status.skipped {\n background: #f0f2ed;\n color: #737c68;\n}\n.branch {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n font-size: 13px;\n}\n.branch svg {\n width: 12px;\n height: 12px;\n}\n.duration {\n font-size: 13px;\n color: var(--muted);\n}\n.table-footer {\n padding: 18px 0;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 10px;\n font-size: 13px;\n color: #838b7b;\n}\n.pagination {\n display: flex;\n align-items: center;\n gap: 14px;\n}\n.pagination .button {\n padding: 6px 9px;\n min-height: 28px;\n font-size: 13px;\n}\n.table-hint {\n display: flex;\n align-items: center;\n gap: 5px;\n color: #8b9186;\n font-size: 10px;\n}\n.table-hint svg {\n width: 11px;\n height: 11px;\n}\n.attention-strip {\n display: flex;\n align-items: center;\n gap: 12px;\n border: 1px solid #e8e4d8;\n border-radius: 7px;\n padding: 15px 17px;\n margin-top: 18px;\n background: #fcfbf7;\n}\n.attention-strip > svg {\n color: #aa8c50;\n width: 17px;\n}\n.attention-strip p {\n font-size: 13px;\n color: #5f6153;\n}\n.attention-strip p span {\n color: #89907f;\n margin-left: 5px;\n}\n.attention-strip .text-button {\n margin-left: auto;\n white-space: nowrap;\n}\n.page-footer {\n margin: auto 42px 0;\n padding: 25px 0;\n display: flex;\n align-items: center;\n justify-content: space-between;\n color: #8b9382;\n font-size: 10px;\n border-top: 1px solid var(--line);\n}\n.footer-mark {\n font-size: 19px;\n color: #45643c;\n margin-right: 5px;\n vertical-align: middle;\n}\n.footer-dot {\n margin: 0 7px;\n color: #b4bdae;\n}\n.back-link {\n display: inline-flex;\n gap: 7px;\n align-items: center;\n font-size: 13px;\n color: var(--muted);\n margin-bottom: 22px;\n}\n.back-link svg {\n width: 13px;\n height: 13px;\n}\n.run-heading h1 {\n display: flex;\n align-items: center;\n gap: 10px;\n font-size: 26px;\n}\n.run-heading h1 .status-icon {\n width: 27px;\n height: 27px;\n}\n.run-heading h1 .status-icon svg {\n width: 25px;\n height: 25px;\n}\n.run-heading .subtitle {\n display: flex;\n flex-wrap: wrap;\n gap: 7px;\n align-items: center;\n margin-left: 38px;\n font-size: 13px;\n}\n.run-heading .subtitle .mono {\n font-size: 13px;\n}\n.detail-meta {\n display: grid;\n grid-template-columns: repeat(5, 1fr);\n border: 1px solid var(--line);\n border-radius: 7px;\n padding: 19px 22px;\n gap: 20px;\n margin-bottom: 26px;\n}\n.meta-label {\n display: block;\n font-size: 10px;\n color: #7b8571;\n margin-bottom: 10px;\n}\n.meta-value {\n font-size: 13px;\n display: flex;\n align-items: center;\n gap: 6px;\n}\n.meta-value svg {\n width: 13px;\n height: 13px;\n color: #939c88;\n}\n.detail-tabs {\n display: flex;\n gap: 25px;\n border-bottom: 1px solid var(--line);\n margin-bottom: 22px;\n}\n.detail-tabs button {\n padding: 0 1px 13px;\n display: flex;\n align-items: center;\n gap: 7px;\n font-size: 13px;\n color: #7b8373;\n border-bottom: 2px solid transparent;\n}\n.detail-tabs button svg {\n width: 14px;\n height: 14px;\n}\n.detail-tabs button.active {\n color: #34422b;\n border-bottom-color: #657d4f;\n}\n.failure-banner {\n padding: 14px 17px;\n background: #fdf6f3;\n border: 1px solid #f0ddd5;\n display: flex;\n gap: 12px;\n align-items: center;\n border-radius: 6px;\n margin-bottom: 20px;\n}\n.failure-banner > svg {\n color: #bf7864;\n}\n.failure-banner strong {\n font-size: 13px;\n font-weight: 550;\n color: #8e5746;\n}\n.failure-banner p {\n font-size: 13px;\n color: #9b7d6c;\n margin-top: 4px;\n}\n.failure-banner button {\n margin-left: auto;\n color: #9f5e4b;\n white-space: nowrap;\n}\n.execution {\n display: grid;\n grid-template-columns: 226px minmax(0, 1fr);\n border: 1px solid var(--line);\n border-radius: 7px;\n overflow: hidden;\n min-height: 385px;\n}\n.steps-panel {\n background: #fafbf8;\n border-right: 1px solid var(--line);\n padding: 17px 10px;\n}\n.steps-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 2px 10px 16px;\n font-size: 13px;\n color: #656e5d;\n}\n.steps-heading .mono {\n font-size: 10px;\n color: #8e9685;\n}\n.step-button {\n display: flex;\n align-items: center;\n width: 100%;\n gap: 9px;\n padding: 12px 10px;\n border: 1px solid transparent;\n border-radius: 5px;\n text-align: left;\n margin-bottom: 3px;\n font-size: 13px;\n color: #626c58;\n}\n.step-button .status-icon,\n.step-button .status-icon svg {\n width: 14px;\n height: 14px;\n}\n.step-button .mono {\n margin-left: auto;\n font-size: 10px;\n color: #87907c;\n}\n.step-button.active {\n border-color: #e2e5dc;\n background: white;\n box-shadow: 0 1px 3px #23351204;\n color: #303e25;\n}\n.step-button:hover {\n background: #f0f3eb;\n}\n.step-count {\n border-top: 1px solid var(--line);\n padding: 16px 10px 0;\n margin-top: 18px;\n font-size: 10px;\n color: #7d8672;\n display: flex;\n gap: 6px;\n align-items: center;\n}\n.step-count svg {\n width: 12px;\n height: 12px;\n}\n.log-panel {\n min-width: 0;\n background: #1c211e;\n color: #c9d0c5;\n display: flex;\n flex-direction: column;\n}\n.log-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 16px 19px;\n border-bottom: 1px solid #343b31;\n background: #252b26;\n gap: 10px;\n}\n.log-heading h3 {\n font-size: 13px;\n font-weight: 500;\n color: #dce2d7;\n}\n.log-heading h3 small {\n margin-left: 8px;\n color: #adba9f;\n font-size: 10px;\n font-weight: 400;\n}\n.log-actions {\n display: flex;\n gap: 6px;\n align-items: center;\n}\n.log-actions .icon-button {\n color: #a6b09e;\n min-height: 25px;\n min-width: 25px;\n}\n.log-actions .icon-button:hover {\n background: #3d453a;\n color: white;\n}\n.log-actions svg {\n width: 14px;\n height: 14px;\n}\n.log-search {\n border-bottom: 1px solid #343b31;\n padding: 9px 17px;\n display: flex;\n align-items: center;\n gap: 7px;\n}\n.log-search svg {\n color: #8b9983;\n width: 13px;\n}\n.log-search input {\n width: 100%;\n min-width: 0;\n background: none;\n border: 0;\n color: #d6dfce;\n outline: none;\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 13px;\n}\n.log-search input::placeholder {\n color: #8d9b85;\n}\n.log-search:focus-within {\n box-shadow: inset 0 0 0 1px #a0c983;\n}\n.log-search span {\n font-size: 10px;\n white-space: nowrap;\n color: #a6b299;\n}\n.log-lines {\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 13px;\n line-height: 2.15;\n overflow: auto;\n padding: 15px 0;\n flex: 1;\n min-height: 260px;\n max-height: 490px;\n}\n.log-line {\n display: flex;\n white-space: pre;\n padding: 0 18px 0 0;\n min-width: max-content;\n}\n.line-number {\n color: #86927d;\n width: 44px;\n padding-right: 15px;\n text-align: right;\n user-select: none;\n flex-shrink: 0;\n}\n.log-time {\n color: #8a9782;\n margin-right: 16px;\n}\n.log-line.error {\n background: #59332c4a;\n color: #f0a28e;\n}\n.log-line.pass {\n color: #b9d49e;\n}\n.log-line.dim {\n color: #98a48f;\n}\n.log-line mark {\n background: #bdcb92;\n color: #202923;\n}\n.log-lines.wrap .log-line {\n white-space: pre-wrap;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.log-lines.wrap .line-text {\n min-width: 0;\n}\n.log-bottom {\n font-size: 10px;\n color: #a1af95;\n padding: 11px 17px;\n border-top: 1px solid #343b31;\n display: flex;\n justify-content: space-between;\n gap: 12px;\n}\n.detail-bottom {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px 0 7px;\n color: #838d78;\n font-size: 10px;\n}\n.detail-bottom span {\n display: flex;\n align-items: center;\n gap: 5px;\n}\n.detail-bottom svg {\n width: 12px;\n height: 12px;\n}\n.source-view {\n border: 1px solid var(--line);\n border-radius: 7px;\n overflow: hidden;\n}\n.source-view h3 {\n font-size: 13px;\n font-weight: 500;\n background: var(--subtle);\n padding: 15px 20px;\n border-bottom: 1px solid var(--line);\n}\n.source-view pre {\n margin: 0;\n background: #1c211e;\n color: #c2d3b5;\n padding: 25px;\n font-size: 12px;\n line-height: 1.9;\n overflow: auto;\n}\n.empty-state {\n text-align: center;\n padding: 55px 20px;\n color: var(--muted);\n}\n.empty-state svg {\n width: 27px;\n height: 27px;\n color: #99aa8a;\n margin-bottom: 15px;\n}\n.empty-state h3 {\n font-size: 14px;\n color: var(--ink);\n font-weight: 500;\n margin-bottom: 8px;\n}\n.empty-state p {\n font-size: 13px;\n line-height: 1.8;\n}\n.empty-state .button {\n margin-top: 14px;\n}\n.cards {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 16px;\n}\n.workflow-card,\n.worker-card {\n border: 1px solid var(--line);\n border-radius: 8px;\n padding: 20px;\n background: #fff;\n}\n.card-heading {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-bottom: 19px;\n}\n.card-heading > svg {\n width: 19px;\n height: 19px;\n color: #7e9468;\n}\n.card-heading h2 {\n font-size: 13px;\n}\n.card-heading .status {\n margin-left: auto;\n}\n.card-description {\n color: #7e8874;\n font-size: 13px;\n line-height: 1.8;\n}\n.card-details {\n display: flex;\n gap: 7px;\n margin-top: 14px;\n flex-wrap: wrap;\n}\n.tag {\n background: #f2f4ee;\n padding: 4px 7px;\n border-radius: 4px;\n font-size: 10px;\n color: #6b795e;\n}\n.card-footer {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-top: 23px;\n padding-top: 16px;\n border-top: 1px solid var(--line);\n}\n.card-footer .text-button {\n font-size: 13px;\n}\n.worker-stats {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 20px;\n margin-top: 23px;\n}\n.progress-track {\n height: 4px;\n border-radius: 3px;\n background: #edf0e7;\n margin-top: 10px;\n overflow: hidden;\n}\n.progress-fill {\n height: 100%;\n background: #a1bf85;\n border-radius: 3px;\n}\n.worker-stats .meta-label {\n display: flex;\n justify-content: space-between;\n}\n.status-tabs {\n display: flex;\n gap: 6px;\n overflow-x: auto;\n border-bottom: 1px solid var(--line);\n margin: 20px 0 0;\n}\n.status-tab {\n display: inline-flex;\n align-items: center;\n gap: 7px;\n padding: 10px 12px;\n color: #727b6e;\n white-space: nowrap;\n border-bottom: 2px solid transparent;\n}\n.status-tab:hover,\n.status-tab.active {\n color: #334334;\n}\n.status-tab.active {\n border-bottom-color: #718a62;\n}\n.status-tab .count {\n min-width: 18px;\n text-align: center;\n}\n.workflow-list {\n border: 1px solid var(--line);\n border-radius: 7px;\n overflow: hidden;\n background: #fff;\n}\n.workflow-item {\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto;\n align-items: center;\n gap: 5px 16px;\n padding: 13px 16px;\n border-bottom: 1px solid var(--line);\n}\n.workflow-item:last-child {\n border-bottom: 0;\n}\n.workflow-header,\n.workflow-meta,\n.workflow-actions {\n display: flex;\n align-items: center;\n gap: 10px;\n min-width: 0;\n}\n.workflow-header {\n grid-column: 1;\n}\n.workflow-icon svg {\n width: 17px;\n height: 17px;\n color: #7e9468;\n}\n.workflow-name {\n overflow: hidden;\n font-weight: 600;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.workflow-status {\n padding: 3px 7px;\n border-radius: 4px;\n background: #f2f4ee;\n color: #68745d;\n font-size: 11px;\n text-transform: capitalize;\n}\n.workflow-status.success {\n background: #edf5e8;\n color: #4d7040;\n}\n.workflow-meta {\n grid-column: 1;\n padding-left: 27px;\n color: #7e8874;\n font-size: 11px;\n}\n.workflow-actions {\n grid-column: 2;\n grid-row: 1 / span 2;\n flex-wrap: wrap;\n justify-content: flex-end;\n}\n.workflow-actions .button {\n padding: 6px 9px;\n font-size: 11px;\n}\n.settings-section {\n border: 1px solid var(--line);\n border-radius: 7px;\n margin-bottom: 16px;\n padding: 18px 20px;\n background: #fff;\n}\n.settings-section h2 {\n margin-bottom: 15px;\n}\n.setting-row {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 12px 0;\n gap: 16px;\n}\n.setting-row + .setting-row {\n border-top: 1px solid var(--line);\n}\n.setting-row strong {\n display: block;\n font-size: 12px;\n font-weight: 550;\n}\n.setting-row p {\n font-size: 11px;\n color: var(--muted);\n margin-top: 4px;\n}\n.setting-row input[type=\"checkbox\"] {\n accent-color: #678951;\n width: 17px;\n height: 17px;\n}\n.secret-value {\n color: var(--muted);\n font-size: 12px;\n}\n.settings-note {\n color: var(--muted);\n font-size: 13px;\n line-height: 1.8;\n}\ndialog {\n width: min(470px, calc(100% - 32px));\n padding: 27px;\n border: 1px solid #dde4d5;\n border-radius: 11px;\n box-shadow: 0 20px 80px #17261530;\n color: var(--ink);\n}\ndialog::backdrop {\n background: #17211755;\n backdrop-filter: blur(3px);\n}\n.dialog-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 10px;\n}\n.dialog-heading h2 {\n font-size: 19px;\n letter-spacing: -0.6px;\n}\n.dialog-description {\n font-size: 12px;\n line-height: 1.8;\n color: var(--muted);\n margin-bottom: 22px;\n}\n.form-field {\n display: block;\n margin: 17px 0;\n}\n.form-field > span {\n display: block;\n font-size: 13px;\n font-weight: 500;\n margin-bottom: 8px;\n}\n.form-field input,\n.form-field select {\n display: block;\n width: 100%;\n border: 1px solid #dfe5d8;\n border-radius: 6px;\n padding: 11px;\n background: white;\n font-size: 12px;\n color: var(--ink);\n}\n.dialog-footer {\n display: flex;\n justify-content: flex-end;\n gap: 9px;\n margin-top: 24px;\n}\n.dialog-note {\n color: #849078;\n font-size: 13px;\n line-height: 1.7;\n}\n.shortcut-row {\n display: flex;\n justify-content: space-between;\n padding: 12px 0;\n font-size: 12px;\n border-bottom: 1px solid var(--line);\n}\n.toast {\n position: fixed;\n bottom: 25px;\n left: calc(50% + 112px);\n transform: translateX(-50%);\n max-width: calc(100vw - 40px);\n background: #273321;\n color: #e6f0de;\n padding: 13px 19px;\n border: 1px solid #4f6342;\n border-radius: 7px;\n font-size: 12px;\n box-shadow: 0 8px 30px #15220c20;\n z-index: 100;\n}\nbody.compact .run-table td {\n padding-top: 12px;\n padding-bottom: 12px;\n}\n\n/* Shared secondary text stays readable on white and tinted surfaces. */\n.workspace-label,\n.nav-caption,\n.nav-item,\n.breadcrumbs,\n.metric-label,\n.metric-foot,\n.count,\n.run-subtitle,\n.run-table th,\n.run-table td,\n.table-footer,\n.table-hint,\n.attention-strip p span,\n.page-footer,\n.meta-label,\n.detail-tabs button,\n.steps-heading .mono,\n.step-button .mono,\n.step-count,\n.detail-bottom,\n.card-description,\n.tag,\n.dialog-note,\nkbd {\n color: var(--muted);\n}\n.avatar {\n color: #675c44;\n}\n.failure-banner p {\n color: #85634f;\n}\n.search-box input::placeholder {\n color: #69745f;\n}\n.subtitle,\n.nav-item,\n.button,\n.filter-tab,\n.meta-value,\n.card-description,\n.settings-note {\n font-size: 12px;\n}\n.run-subtitle,\n.run-table th,\n.select-filter,\n.search-box input,\n.status,\n.step-button {\n font-size: 13px;\n}\n.log-lines {\n font-size: 13px;\n}\n.detail-meta {\n overflow-wrap: anywhere;\n}\n@media (min-width: 1500px) {\n main {\n padding-top: 50px;\n }\n .run-table td {\n padding-top: 22px;\n padding-bottom: 22px;\n }\n}\n@media (max-width: 1180px) {\n .sidebar {\n width: 192px;\n padding-inline: 12px;\n }\n .app-shell {\n margin-left: 192px;\n }\n .topbar {\n padding-inline: 28px;\n }\n main {\n padding-inline: 28px;\n }\n .page-footer {\n margin-inline: 28px;\n }\n .metric {\n padding: 19px 16px;\n }\n .tiny-bars {\n display: none;\n }\n .metric-note {\n font-size: 10px;\n }\n .metric-value {\n font-size: 25px;\n }\n .run-table th:first-child {\n width: 40%;\n }\n .run-table th:nth-child(3) {\n width: 18%;\n }\n .run-table th:nth-child(2) {\n width: 18%;\n }\n .execution {\n grid-template-columns: 195px minmax(0, 1fr);\n }\n .toast {\n left: calc(50% + 96px);\n }\n .workspace {\n gap: 7px;\n }\n .workspace-label {\n display: none;\n }\n}\n@media (max-width: 900px) {\n .sidebar {\n width: 72px;\n align-items: center;\n padding: 27px 9px 15px;\n }\n .brand {\n font-size: 0;\n padding: 0;\n }\n .brand-mark {\n margin: 0;\n }\n .workspace,\n .nav-caption,\n .nav-label,\n .nav-count,\n .runner-health,\n .profile > span:not(.avatar) {\n display: none;\n }\n nav {\n margin-top: 40px;\n width: 100%;\n }\n .nav-item {\n justify-content: center;\n padding: 13px 10px;\n }\n .profile {\n padding-top: 15px;\n }\n .app-shell {\n margin-left: 72px;\n }\n .metric {\n padding: 17px 14px;\n }\n .metric-bottom {\n gap: 6px;\n }\n .metric-note {\n display: none;\n }\n .filters .search-box {\n width: auto;\n flex: 1;\n }\n .select-filter {\n max-width: 150px;\n }\n .run-table th:nth-child(3),\n .run-table td:nth-child(3) {\n display: none;\n }\n .run-table th:first-child {\n width: 48%;\n }\n .run-table th:nth-child(2) {\n width: 21%;\n }\n .run-table th:nth-child(4) {\n width: 14%;\n }\n .run-table th:last-child {\n width: 17%;\n }\n .detail-meta {\n grid-template-columns: repeat(3, 1fr);\n row-gap: 22px;\n }\n .run-heading {\n align-items: flex-start;\n }\n .run-heading h1 {\n font-size: 23px;\n }\n .run-heading .heading-actions {\n flex-direction: column;\n align-items: stretch;\n }\n .execution {\n grid-template-columns: 175px minmax(0, 1fr);\n }\n .step-button {\n gap: 6px;\n padding-inline: 6px;\n font-size: 10px;\n }\n .step-button .mono {\n font-size: 8px;\n }\n .log-time {\n display: none;\n }\n .attention-strip p span {\n display: none;\n }\n .toast {\n left: calc(50% + 36px);\n }\n}\n@media (max-width: 600px) {\n .sidebar {\n position: fixed;\n inset: auto 0 0;\n width: 100%;\n height: 62px;\n padding: 0 12px env(safe-area-inset-bottom);\n z-index: 20;\n border-right: 0;\n border-top: 1px solid var(--line);\n background: #fafbf8f5;\n backdrop-filter: blur(15px);\n }\n .sidebar .brand,\n .sidebar-bottom {\n display: none;\n }\n nav {\n display: flex;\n width: 100%;\n margin: 0;\n height: 100%;\n justify-content: space-around;\n }\n .nav-item {\n flex: 1;\n padding: 9px 6px 6px;\n flex-direction: column;\n gap: 4px;\n border-radius: 0;\n font-size: 10px;\n }\n .nav-item.active {\n background: none;\n color: #41602f;\n }\n .nav-label {\n display: block;\n }\n .nav-item svg {\n width: 18px;\n height: 18px;\n }\n .app-shell {\n margin-left: 0;\n padding-bottom: 62px;\n }\n .topbar {\n height: 58px;\n padding: 0 20px;\n }\n .topbar-right {\n gap: 9px;\n }\n .topbar-right .icon-button {\n display: none;\n }\n .demo-label {\n display: none;\n }\n .breadcrumbs {\n gap: 7px;\n font-size: 13px;\n }\n main {\n padding: 28px 20px 20px;\n }\n .page-heading {\n margin-bottom: 24px;\n gap: 10px;\n }\n h1 {\n font-size: 27px;\n }\n .subtitle {\n font-size: 13px;\n max-width: 210px;\n }\n .workflow-item {\n grid-template-columns: minmax(0, 1fr);\n gap: 8px;\n }\n .workflow-header,\n .workflow-meta,\n .workflow-actions {\n grid-column: 1;\n grid-row: auto;\n }\n .workflow-meta {\n padding-left: 27px;\n }\n .workflow-actions {\n justify-content: flex-start;\n padding-left: 27px;\n }\n .button {\n font-size: 13px;\n padding: 9px 10px;\n }\n .overview {\n grid-template-columns: 1fr 1fr;\n margin-bottom: 28px;\n }\n .metric {\n min-height: 97px;\n padding: 16px;\n }\n .metric-label {\n font-size: 13px;\n }\n .metric-value {\n font-size: 25px;\n }\n .metric-bottom {\n margin-top: 9px;\n }\n .metric:nth-child(3),\n .metric:nth-child(4) {\n border-top: 1px solid var(--line);\n }\n .metric:nth-child(3)::before {\n display: none;\n }\n .metric + .metric::before {\n top: 16px;\n bottom: 16px;\n }\n .metric-note {\n display: inline;\n }\n .tiny-bars {\n display: flex;\n }\n .metric-foot {\n display: none;\n }\n .section-heading {\n margin-bottom: 14px;\n }\n .section-heading > span {\n font-size: 10px;\n }\n .filters {\n flex-wrap: wrap;\n gap: 7px;\n }\n .filters .search-box {\n flex: 1 0 100%;\n }\n .select-filter {\n flex: 1;\n max-width: none;\n }\n .filter-tabs {\n gap: 19px;\n margin-top: 19px;\n }\n .filter-tab {\n font-size: 13px;\n gap: 4px;\n }\n .filter-tab .count {\n font-size: 8px;\n }\n .run-table th {\n font-size: 10px;\n padding-inline: 5px;\n }\n .run-table th:first-child {\n width: 67%;\n padding-left: 2px;\n }\n .run-table th:nth-child(2) {\n width: 33%;\n text-align: right;\n padding-right: 3px;\n }\n .run-table td {\n padding: 18px 3px;\n }\n .run-table td:first-child {\n padding-left: 2px;\n }\n .run-table td:nth-child(2) {\n text-align: right;\n padding-right: 3px;\n }\n .run-table th:nth-child(4),\n .run-table td:nth-child(4),\n .run-table th:last-child,\n .run-table td:last-child {\n display: none;\n }\n .run-link {\n gap: 7px;\n }\n .run-title {\n font-size: 13px;\n }\n .run-subtitle {\n font-size: 10px;\n gap: 4px;\n }\n .run-subtitle .mono {\n font-size: 8px;\n }\n .status {\n font-size: 10px;\n padding: 4px 5px;\n }\n .table-footer {\n flex-wrap: wrap;\n font-size: 10px;\n }\n .pagination {\n gap: 9px;\n }\n .table-hint {\n display: none;\n }\n .attention-strip {\n padding: 12px;\n gap: 7px;\n }\n .attention-strip p {\n font-size: 13px;\n }\n .attention-strip .text-button {\n font-size: 10px;\n }\n .page-footer {\n margin-inline: 20px;\n font-size: 8px;\n flex-wrap: wrap;\n gap: 7px;\n padding-block: 19px;\n }\n .footer-mark {\n font-size: 15px;\n }\n .toast {\n left: 50%;\n bottom: 78px;\n width: max-content;\n text-align: center;\n font-size: 13px;\n }\n .back-link {\n margin-bottom: 20px;\n }\n .run-heading {\n flex-direction: column;\n gap: 18px;\n }\n .run-heading h1 {\n font-size: 23px;\n }\n .run-heading .subtitle {\n margin-left: 0;\n max-width: none;\n font-size: 13px;\n }\n .run-heading .heading-actions {\n flex-direction: row;\n width: 100%;\n }\n .run-heading .heading-actions > * {\n flex: 1;\n max-width: none;\n }\n .detail-meta {\n grid-template-columns: repeat(2, 1fr);\n padding: 16px;\n gap: 20px 15px;\n }\n .detail-meta > div:last-child {\n grid-column: span 2;\n }\n .detail-tabs {\n gap: 20px;\n }\n .detail-tabs button {\n font-size: 13px;\n gap: 5px;\n }\n .detail-tabs button svg {\n width: 12px;\n }\n .failure-banner {\n padding: 12px;\n align-items: flex-start;\n flex-wrap: wrap;\n gap: 7px;\n }\n .failure-banner > div {\n flex: 1;\n }\n .failure-banner strong {\n font-size: 13px;\n }\n .failure-banner p {\n font-size: 10px;\n line-height: 1.6;\n }\n .failure-banner button {\n margin-left: 26px;\n font-size: 13px;\n }\n .execution {\n grid-template-columns: minmax(0, 1fr);\n }\n .steps-panel {\n border-right: 0;\n border-bottom: 1px solid var(--line);\n padding: 12px;\n }\n .steps-heading {\n padding: 3px 5px 10px;\n }\n .step-button {\n font-size: 13px;\n padding: 9px;\n margin-bottom: 1px;\n }\n .step-button .mono {\n font-size: 10px;\n }\n .step-count {\n display: none;\n }\n .log-heading {\n padding: 13px;\n }\n .log-heading h3 {\n font-size: 13px;\n }\n .log-heading h3 small {\n font-size: 8px;\n }\n .log-lines {\n font-size: 10px;\n min-height: 290px;\n }\n .log-search {\n padding-inline: 13px;\n }\n .line-number {\n width: 32px;\n padding-right: 10px;\n }\n .detail-bottom {\n gap: 10px;\n flex-wrap: wrap;\n font-size: 8px;\n }\n .cards {\n grid-template-columns: 1fr;\n }\n .workflow-card,\n .worker-card {\n padding: 20px;\n }\n .setting-row {\n gap: 14px;\n }\n .settings-section {\n padding: 18px;\n }\n .setting-row p {\n font-size: 13px;\n }\n .source-view pre {\n font-size: 13px;\n padding: 17px;\n }\n .topbar-right .avatar {\n display: none;\n }\n}\n@media (prefers-reduced-motion: no-preference) {\n .status-icon.running svg {\n animation: spin 2.5s linear infinite;\n }\n @keyframes spin {\n to {\n transform: rotate(360deg);\n }\n }\n}\n";
12344
12372
  //#endregion
12345
12373
  //#region src/ui-concept.ts
12346
- function pg(e, t, n) {
12374
+ function mg(e, t, n) {
12347
12375
  let r = `const isDemoRequested = new URLSearchParams(window.location.search).get("demo") === "1";
12348
12376
  const isLocalPreview = window.location.protocol === "file:" || /localhost|127\\.0\\.0\\.1|::1/.test(window.location.hostname);
12349
12377
 
@@ -12371,7 +12399,7 @@ ${n}
12371
12399
  }`;
12372
12400
  return e.replace("<link rel=\"stylesheet\" href=\"./style.css\" />", `<style>${t}</style>`).replace("<script src=\"./app.js\" defer><\/script>", "").replace("</body>", `<script>${r}<\/script></body>`);
12373
12401
  }
12374
- var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigator, URL, URLSearchParams, Headers, EventSource, Blob */\n\nconst icons = {\n runs: '<rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"3\"/><path d=\"m10 8 5 4-5 4Z\"/>',\n workflow:\n '<rect x=\"3\" y=\"3\" width=\"6\" height=\"6\" rx=\"1.5\"/><rect x=\"15\" y=\"15\" width=\"6\" height=\"6\" rx=\"1.5\"/><path d=\"M6 9v6a3 3 0 0 0 3 3h6M9 6h6a3 3 0 0 1 3 3v6\"/>',\n server:\n '<rect x=\"3\" y=\"3\" width=\"18\" height=\"7\" rx=\"2\"/><rect x=\"3\" y=\"14\" width=\"18\" height=\"7\" rx=\"2\"/><path d=\"M7 6.5h.01M7 17.5h.01M11 6.5h6M11 17.5h6\"/>',\n settings:\n '<path d=\"m9 3-1 3-3 1-2 3 2 2-1 3 3 2 3-1 2 2 3-1 1-3 3-1 2-3-2-2 1-3-3-2-3 1-2-2Z\"/><circle cx=\"11.5\" cy=\"10.5\" r=\"3\"/>',\n search: '<circle cx=\"10.5\" cy=\"10.5\" r=\"6.5\"/><path d=\"m16 16 4 4\"/>',\n plus: '<path d=\"M12 5v14M5 12h14\"/>',\n arrow: '<path d=\"M5 12h14m-5-5 5 5-5 5\"/>',\n back: '<path d=\"M19 12H5m5-5-5 5 5 5\"/>',\n check: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m8 12 3 3 5-6\"/>',\n failed: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m9 9 6 6m0-6-6 6\"/>',\n running: '<path d=\"M20.4 8.8A9 9 0 1 1 15.2 3.6\"/><path d=\"M20.4 3.6v5.2h-5.2\"/>',\n pending: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n cancelled: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m6 6 12 12\"/>',\n skipped: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/>',\n branch:\n '<circle cx=\"6\" cy=\"5\" r=\"2\"/><circle cx=\"6\" cy=\"19\" r=\"2\"/><circle cx=\"18\" cy=\"6\" r=\"2\"/><path d=\"M6 7v10m0-4h5a7 7 0 0 0 7-5\"/>',\n clock: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n commit: '<circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M3 12h5m8 0h5\"/>',\n refresh: '<path d=\"M20 7v5h-5M4 17v-5h5\"/><path d=\"M6.1 7a7 7 0 0 1 11.5-1L20 9M4 15l2.4 3A7 7 0 0 0 18 17\"/>',\n terminal: '<path d=\"m5 6 6 6-6 6m9 0h5\"/>',\n download: '<path d=\"M12 3v12m-4-4 4 4 4-4M4 16v4h16v-4\"/>',\n copy: '<rect x=\"8\" y=\"8\" width=\"12\" height=\"12\" rx=\"2\"/><path d=\"M16 8V4H4v12h4\"/>',\n wrap: '<path d=\"M3 6h18M3 12h14a3 3 0 0 1 0 6h-5m3-3-3 3 3 3M3 18h4\"/>',\n code: '<path d=\"m8 6-6 6 6 6m8-12 6 6-6 6M14 3l-4 18\"/>',\n box: '<path d=\"m12 3 9 5v9l-9 5-9-5V8Zm0 9 9-4M3 8l9 4v10M7.5 5.5l9 5\"/>',\n history: '<path d=\"M3 3v6h6M3.6 9a9 9 0 1 1-.2 6M12 7v5l3 2\"/>',\n alert: '<path d=\"m12 3 10 18H2Z\"/><path d=\"M12 9v5m0 3h.01\"/>',\n lock: '<rect x=\"5\" y=\"10\" width=\"14\" height=\"11\" rx=\"2\"/><path d=\"M8 10V6a4 4 0 0 1 8 0v4m-4 5v2\"/>',\n stop: '<rect x=\"5\" y=\"5\" width=\"14\" height=\"14\" rx=\"2\"/>',\n};\n\nconst icon = (name) =>\n `<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">${\n icons[name] || icons.runs\n }</svg>`;\nconst escape = (value) =>\n String(value ?? \"\").replace(\n /[&<>\"']/g,\n (character) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" }[character])\n );\nconst labels = {\n success: \"Passed\",\n failed: \"Failed\",\n running: \"Running\",\n pending: \"Queued\",\n cancelled: \"Cancelled\",\n skipped: \"Skipped\",\n};\nconst statusIcon = (status) =>\n `<span class=\"status-icon ${escape(status)}\">${icon(status === \"success\" ? \"check\" : status)}</span>`;\nconst iconForStatus = statusIcon;\nconst badge = (status) =>\n `<span class=\"status ${escape(status)}\">${statusIcon(status)}${escape(labels[status] || status || \"Unknown\")}</span>`;\n\nexport function mountLivePreview() {\n const main = document.querySelector(\"#main\");\n const dialog = document.querySelector(\"#dialog\");\n const state = {\n jobs: [],\n workflows: [],\n workers: [],\n secrets: [],\n hasMore: false,\n loadingJobs: false,\n jobsLoaded: false,\n workflowsLoaded: false,\n workersLoaded: false,\n secretsLoaded: false,\n errors: {},\n search: \"\",\n status: \"all\",\n workflow: \"all\",\n worker: \"all\",\n workflowFilter: \"all\",\n page: 1,\n run: null,\n mutating: false,\n detailTab: \"logs\",\n step: 0,\n logSearch: \"\",\n wrap: false,\n };\n let apiClient;\n let events;\n let previousRoute = \"\";\n let jobsLoadPromise;\n let refreshJobsAfterCurrentLoad = false;\n\n const route = () => {\n const [path, query = \"\"] = window.location.hash.slice(1).split(\"?\");\n return { path: path || \"/runs\", query: new URLSearchParams(query) };\n };\n\n const client = async () => {\n apiClient ??= import(\"/api-client.mjs\");\n return apiClient;\n };\n\n const apiJson = async (path, options = {}) => {\n const { apiFetch } = await client();\n const headers = new Headers(options.headers || {});\n headers.set(\"accept\", \"application/json\");\n if (options.body && !headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n const response = await apiFetch(path, { ...options, headers });\n if (!response.ok) {\n const payload = await response.json().catch(() => ({}));\n const error = new Error(payload.error || `Request failed (${response.status})`);\n error.status = response.status;\n throw error;\n }\n return response.status === 204 ? null : response.json();\n };\n\n const apiBlob = async (path) => {\n const { apiFetch } = await client();\n const response = await apiFetch(path);\n if (!response.ok) {\n const payload = await response.json().catch(() => ({}));\n throw new Error(payload.error || `Download failed (${response.status})`);\n }\n return response.blob();\n };\n\n const showToast = (message) => {\n const toast = document.querySelector(\"#toast\");\n toast.textContent = message;\n toast.hidden = false;\n window.clearTimeout(showToast.timer);\n showToast.timer = window.setTimeout(() => {\n toast.hidden = true;\n }, 4500);\n };\n\n const dateValue = (value) => {\n if (!value) {\n return null;\n }\n const text = String(value);\n const date = new Date(text.includes(\"T\") ? text : `${text.replace(\" \", \"T\")}Z`);\n return Number.isNaN(date.getTime()) ? null : date;\n };\n\n const formatDate = (value) => {\n const date = dateValue(value);\n return date ? new Intl.DateTimeFormat(undefined, { dateStyle: \"medium\", timeStyle: \"short\" }).format(date) : \"—\";\n };\n\n const formatDuration = (milliseconds) => {\n const value = Number(milliseconds);\n if (!Number.isFinite(value) || value < 0) {\n return \"—\";\n }\n const seconds = Math.floor(value / 1000);\n return `${Math.floor(seconds / 60) ? `${Math.floor(seconds / 60)}m ` : \"\"}${seconds % 60}s`;\n };\n\n const workflowName = (workflowId) =>\n state.workflows.find((workflow) => workflow.id === workflowId)?.name || workflowId || \"Unknown workflow\";\n const pageHeading = (title, subtitle, action = \"\") =>\n `<div class=\"page-heading\"><div><h1>${escape(title)}</h1>${\n subtitle ? `<p class=\"subtitle\">${escape(subtitle)}</p>` : \"\"\n }</div>${action}</div>`;\n const runWorkflowButton = () =>\n `<button class=\"button primary\" data-action=\"new-run\">${icon(\"plus\")}Run workflow</button>`;\n const permissionMessage = (error, scope) =>\n [401, 403].includes(error?.status)\n ? `Your account needs the ${scope} permission to view this data.`\n : error?.message || \"The Flow API could not be reached.\";\n\n const setNavigation = (page, jobId = \"\") => {\n const currentJob = jobId ? state.jobs.find((job) => String(job.id) === String(jobId)) : null;\n const currentJobId = currentJob?.id ?? jobId;\n document.querySelector(\"#navigation\").innerHTML = [\n [\"runs\", \"Runs\", \"runs\"],\n [\"workflows\", \"Workflows\", \"workflow\"],\n [\"workers\", \"Workers\", \"server\"],\n [\"settings\", \"Settings\", \"settings\"],\n ]\n .map(\n ([id, title, name]) =>\n `<a href=\"#/${id}\" class=\"nav-item ${page === id ? \"active\" : \"\"}\" ${\n page === id ? 'aria-current=\"page\"' : \"\"\n }>${icon(name)}<span class=\"nav-label\">${title}</span>${\n id === \"runs\"\n ? `<span class=\"nav-count\">${\n state.jobs.filter((job) => [\"running\", \"pending\"].includes(job.status)).length\n }</span>`\n : \"\"\n }</a>`\n )\n .join(\"\");\n document.querySelector(\"#breadcrumbs\").innerHTML = `${\n jobId\n ? `<a href=\"#/runs\">Runs</a><span class=\"divider\">/</span><strong class=\"mono\">#${escape(\n currentJobId\n )}</strong>`\n : `<strong>${escape(page[0].toUpperCase() + page.slice(1))}</strong>`\n }`;\n document.title = `${jobId ? `Run #${currentJobId}` : page[0].toUpperCase() + page.slice(1)} · Flow`;\n };\n const setBreadcrumbs = (page, jobId = null) => setNavigation(page, jobId);\n\n const loadJobs = async ({ older = false } = {}) => {\n if (jobsLoadPromise) {\n if (!older) {\n refreshJobsAfterCurrentLoad = true;\n }\n await jobsLoadPromise;\n if (refreshJobsAfterCurrentLoad) {\n refreshJobsAfterCurrentLoad = false;\n await loadJobs();\n }\n return;\n }\n state.loadingJobs = true;\n const beforeId = older && state.jobs.length ? Math.min(...state.jobs.map((job) => Number(job.id))) : null;\n jobsLoadPromise = (async () => {\n try {\n const query = new URLSearchParams({ limit: older ? \"50\" : \"100\" });\n if (beforeId) {\n query.set(\"beforeId\", String(beforeId));\n }\n if (state.workflow !== \"all\") {\n query.set(\"workflowId\", state.workflow);\n }\n const data = await apiJson(`/api/jobs?${query}`);\n if (older) {\n const merged = new Map(state.jobs.map((job) => [String(job.id), job]));\n for (const job of data.jobs || []) {\n merged.set(String(job.id), job);\n }\n state.jobs = [...merged.values()].sort((a, b) => Number(b.id) - Number(a.id));\n } else {\n state.jobs = data.jobs || [];\n }\n state.hasMore = Boolean(data.hasMore);\n state.errors.jobs = null;\n state.jobsLoaded = true;\n } catch (error) {\n state.errors.jobs = error;\n state.jobsLoaded = true;\n } finally {\n state.loadingJobs = false;\n }\n })();\n try {\n await jobsLoadPromise;\n } finally {\n jobsLoadPromise = undefined;\n if (refreshJobsAfterCurrentLoad) {\n refreshJobsAfterCurrentLoad = false;\n await loadJobs();\n }\n }\n };\n\n const loadWorkflows = async ({ retry = false } = {}) => {\n if (state.workflowsLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/workflows\");\n state.workflows = data.workflows || [];\n state.errors.workflows = null;\n } catch (error) {\n state.errors.workflows = error;\n } finally {\n state.workflowsLoaded = true;\n }\n };\n\n const loadWorkers = async ({ retry = false } = {}) => {\n if (state.workersLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/workers\");\n state.workers = data.workers || [];\n state.errors.workers = null;\n } catch (error) {\n state.errors.workers = error;\n } finally {\n state.workersLoaded = true;\n }\n };\n\n const loadSecrets = async ({ retry = false } = {}) => {\n if (state.secretsLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/secrets\");\n state.secrets = data.secrets || [];\n state.errors.secrets = null;\n } catch (error) {\n state.errors.secrets = error;\n } finally {\n state.secretsLoaded = true;\n }\n };\n\n const workflowFormSource = () => {\n const source = document.getElementById(\"new-workflow-source\")?.value.trim() || \"\";\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n if (!source || !name) {\n return source;\n }\n const yamlName = `name: ${JSON.stringify(name)}`;\n return /^name\\s*:/m.test(source) ? source.replace(/^name\\s*:[^\\r\\n]*/m, yamlName) : `${yamlName}\\n${source}`;\n };\n\n const filteredJobs = () =>\n state.jobs.filter((job) => {\n const text = [job.id, job.workflowId, workflowName(job.workflowId), job.status, job.workerId]\n .join(\" \")\n .toLowerCase();\n return (\n text.includes(state.search.trim().toLowerCase()) &&\n (state.status === \"all\" ||\n (state.status === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === state.status)) &&\n (state.workflow === \"all\" || job.workflowId === state.workflow) &&\n (state.worker === \"all\" || (state.worker === \"queued\" ? !job.workerId : job.workerId === state.worker))\n );\n });\n\n const renderRunsTable = () => {\n const filtered = filteredJobs();\n const pages = Math.max(1, Math.ceil(filtered.length / 8));\n state.page = Math.min(state.page, pages);\n const rows = filtered.slice((state.page - 1) * 8, state.page * 8);\n const target = document.querySelector(\"#run-results\");\n if (!target) {\n return;\n }\n target.innerHTML = `<div class=\"filter-tabs\" role=\"group\" aria-label=\"Filter by run status\">${[\n [\"all\", \"All runs\"],\n [\"running\", \"In progress\"],\n [\"failed\", \"Failed\"],\n [\"success\", \"Passed\"],\n ]\n .map(\n ([value, label]) =>\n `<button class=\"filter-tab ${\n state.status === value ? \"active\" : \"\"\n }\" data-action=\"status-filter\" data-value=\"${value}\" aria-pressed=\"${\n state.status === value\n }\">${label}<span class=\"count\">${\n state.jobs.filter(\n (job) =>\n value === \"all\" ||\n (value === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === value)\n ).length\n }</span></button>`\n )\n .join(\"\")}</div>\n ${\n rows.length\n ? `<table class=\"run-table\"><caption class=\"sr-only\">Live Flow runs, newest first</caption><thead><tr><th scope=\"col\">Workflow / run</th><th scope=\"col\">Status</th><th scope=\"col\">Worker</th><th scope=\"col\">Updated</th><th scope=\"col\">Created</th></tr></thead><tbody>${rows\n .map(\n (job) =>\n `<tr><td><a class=\"run-link\" href=\"#/runs/${encodeURIComponent(job.id)}\" aria-label=\"Run ${escape(\n job.id\n )}: ${escape(workflowName(job.workflowId))}, ${escape(\n labels[job.status] || job.status\n )}\">${statusIcon(job.status)}<span class=\"run-copy\"><span class=\"run-title\">${escape(\n workflowName(job.workflowId)\n )}</span><span class=\"run-subtitle\"><span class=\"mono\">#${escape(\n job.id\n )}</span><span class=\"separator\">·</span><span class=\"mono\">${escape(\n job.workflowId\n )}</span></span></span></a></td><td>${badge(job.status)}</td><td><span class=\"branch\">${icon(\n \"server\"\n )}${escape(job.workerId || \"Queued\")}</span></td><td>${escape(\n formatDate(job.updatedAt)\n )}</td><td>${escape(formatDate(job.createdAt))}</td></tr>`\n )\n .join(\"\")}</tbody></table>`\n : `<div class=\"empty-state\">${icon(\"search\")}<h3>${\n state.errors.jobs ? \"Runs unavailable\" : \"No runs found\"\n }</h3><p>${escape(state.errors.jobs?.message || \"Try a different search or filter.\")}</p>${\n state.errors.jobs\n ? '<button class=\"button\" data-action=\"retry-runs\">Retry</button>'\n : '<button class=\"button\" data-action=\"clear-filters\">Clear filters</button>'\n }</div>`\n }\n <div class=\"table-footer\"><div class=\"pagination\"><button class=\"button\" data-action=\"prev-page\" ${\n state.page === 1 ? \"disabled\" : \"\"\n }>${icon(\"back\")}Previous</button><button class=\"button\" data-action=\"next-page\" ${\n state.page === pages && !state.hasMore ? \"disabled\" : \"\"\n }>Next${icon(\"arrow\")}</button></div></div>${\n state.hasMore ? '<div class=\"table-hint\">Older Flow runs are available.</div>' : \"\"\n }`;\n };\n\n const renderRuns = () => {\n setBreadcrumbs(\"runs\");\n main.innerHTML = `${pageHeading(\"Runs\", \"\", runWorkflowButton())}${\n state.loadingJobs && !state.jobs.length ? '<div class=\"empty-state\"><p>Loading runs…</p></div>' : \"\"\n }<section aria-labelledby=\"recent-runs\"><div class=\"section-heading\"><h2 id=\"recent-runs\">Recent runs</h2></div><div class=\"filters\"><label class=\"search-box\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search runs</span><input id=\"run-search\" type=\"search\" value=\"${escape(\n state.search\n )}\" placeholder=\"Search runs, workflows, or workers…\" autocomplete=\"off\"/><kbd aria-hidden=\"true\">/</kbd></label><select id=\"workflow-filter\" class=\"select-filter\" aria-label=\"Filter by workflow\"><option value=\"all\">All workflows</option>${state.workflows\n .map(\n (workflow) =>\n `<option value=\"${escape(workflow.id)}\" ${state.workflow === workflow.id ? \"selected\" : \"\"}>${escape(\n workflow.name\n )}</option>`\n )\n .join(\n \"\"\n )}</select><select id=\"worker-filter\" class=\"select-filter\" aria-label=\"Filter by worker\"><option value=\"all\">All workers</option><option value=\"queued\" ${\n state.worker === \"queued\" ? \"selected\" : \"\"\n }>Queued</option>${[...new Set(state.jobs.map((job) => job.workerId).filter(Boolean))]\n .map(\n (worker) =>\n `<option value=\"${escape(worker)}\" ${state.worker === worker ? \"selected\" : \"\"}>${escape(worker)}</option>`\n )\n .join(\"\")}</select></div><div id=\"run-results\"></div></section>`;\n renderRunsTable();\n };\n\n const renderRunDetails = (run) => {\n setBreadcrumbs(\"runs\", run.jobId);\n const inputs = run.inputs || {};\n const failedStep = (run.steps || []).find((step) => step.status === \"failed\");\n main.innerHTML = `<a href=\"#/runs\" class=\"back-link\">${icon(\"back\")}All runs</a>\n <div class=\"page-heading run-heading\"><div><h1>${iconForStatus(run.status)}${escape(\n run.workflowName || workflowName(run.workflowId)\n )}</h1><p class=\"subtitle\"><span class=\"mono\">#${escape(run.jobId)}</span><span>·</span><span>${escape(\n inputs.full_name || inputs.repo || run.workflowId || \"Workflow run\"\n )}</span></p></div>\n <div class=\"heading-actions\">${\n run.canViewLogs\n ? `<button class=\"button\" data-action=\"download-logs\">${icon(\"download\")}Download logs</button>`\n : \"\"\n }<button class=\"button ${[\"running\", \"pending\"].includes(run.status) ? \"danger\" : \"primary\"}\" data-action=\"${\n [\"running\", \"pending\"].includes(run.status) ? \"cancel\" : \"rerun\"\n }\" ${state.mutating ? \"disabled\" : \"\"}>${icon([\"running\", \"pending\"].includes(run.status) ? \"stop\" : \"refresh\")}${\n [\"running\", \"pending\"].includes(run.status) ? \"Cancel run\" : \"Re-run workflow\"\n }</button></div></div>\n ${\n failedStep\n ? `<div class=\"failure-banner\">${icon(\"alert\")}<div><strong>${escape(failedStep.name || \"Step\")} failed${\n failedStep.exitCode !== undefined ? ` with exit code ${escape(failedStep.exitCode)}` : \"\"\n }</strong><p>${escape(\n failedStep.error || \"See the failed step output.\"\n )}</p></div><button class=\"text-button\" data-action=\"jump-error\">Jump to error ${icon(\n \"arrow\"\n )}</button></div>`\n : \"\"\n }\n <section class=\"detail-meta\" aria-label=\"Run metadata\"><div><span class=\"meta-label\">STATUS</span><span class=\"meta-value\">${badge(\n run.status\n )}</span></div><div><span class=\"meta-label\">BRANCH</span><span class=\"meta-value\">${icon(\"branch\")}${escape(\n inputs.branch || inputs.ref?.replace(/^refs\\/heads\\//, \"\") || \"—\"\n )}</span></div><div><span class=\"meta-label\">COMMIT</span><span class=\"meta-value mono\">${icon(\"commit\")}${escape(\n (inputs.commit_sha || inputs.commit || \"—\").slice(0, 8)\n )}</span></div><div><span class=\"meta-label\">DURATION</span><span class=\"meta-value\">${icon(\n \"clock\"\n )}${formatDuration(\n run.durationMs\n )}</span></div><div><span class=\"meta-label\">WORKER</span><span class=\"meta-value\">${icon(\"server\")}${escape(\n run.workerId || \"Queued\"\n )}</span></div></section>\n <div class=\"detail-tabs\" role=\"group\" aria-label=\"Run detail views\">${[\n [\"logs\", \"Logs & steps\", \"terminal\"],\n [\"inputs\", \"Inputs\", \"code\"],\n [\"artifacts\", \"Artifacts\", \"box\"],\n [\"history\", \"History\", \"history\"],\n ]\n .map(\n ([value, title, symbol]) =>\n `<button data-action=\"detail-tab\" data-value=\"${value}\" class=\"${\n state.detailTab === value ? \"active\" : \"\"\n }\" aria-pressed=\"${state.detailTab === value}\">${icon(symbol)}${title}</button>`\n )\n .join(\"\")}</div>\n <div id=\"detail-content\"></div>`;\n void renderRunTab(run);\n };\n\n const liveRunPage = async (id) => {\n main.innerHTML = `<div class=\"empty-state\">${icon(\"clock\")}<p>Loading run #${escape(id)}…</p></div>`;\n try {\n const run = await apiJson(`/api/runs/${encodeURIComponent(id)}`);\n state.errors.run = null;\n state.run = run;\n const importantStep = run.steps?.findIndex((step) => [\"failed\", \"running\"].includes(step.status)) ?? -1;\n if (importantStep >= 0) {\n state.step = importantStep;\n }\n renderRunDetails(run);\n } catch (error) {\n state.errors.run = error;\n main.innerHTML = `<div class=\"empty-state\">${icon(\"alert\")}<h1>Run unavailable</h1><p>${escape(\n error.message\n )}</p><a class=\"button\" href=\"#/runs\">Back to runs</a></div>`;\n }\n };\n\n const renderRunTab = async (run) => {\n const content = document.querySelector(\"#detail-content\");\n if (!content) {\n return;\n }\n if (state.detailTab === \"inputs\") {\n let source = run.workflowSourceYaml || \"\";\n if (!source && run.workflowId) {\n try {\n const workflow = await apiJson(\n `/api/workflows/${encodeURIComponent(run.workflowId)}?revision=${encodeURIComponent(\n run.workflowRevision || \"\"\n )}`\n );\n source = workflow.sourceYaml || \"\";\n } catch {\n /* Workflow source has separate permission checks. */\n }\n }\n content.innerHTML = `<section class=\"source-view\"><h3>Trigger inputs</h3><pre>${escape(\n JSON.stringify(run.inputs || {}, null, 2)\n )}</pre></section>${\n source\n ? `<section class=\"source-view\" style=\"margin-top:16px\"><h3>Workflow source · revision ${escape(\n run.workflowRevision || \"—\"\n )}</h3><pre>${escape(source)}</pre></section>`\n : '<p class=\"settings-note\" style=\"margin-top:14px\">Workflow source is unavailable for this run.</p>'\n }`;\n return;\n }\n if (state.detailTab === \"artifacts\") {\n const artifacts = run.artifacts || [];\n content.innerHTML = artifacts.length\n ? `<section class=\"settings-section\"><h2>Run artifacts <span class=\"count\">${\n artifacts.length\n }</span></h2>${artifacts\n .map(\n (path, index) =>\n `<div class=\"setting-row\"><div><strong class=\"mono\">${escape(\n path.split(\"/\").pop() || path\n )}</strong><p>${escape(\n path\n )}</p></div><button class=\"button\" data-action=\"download-artifact\" data-value=\"${index}\">${icon(\n \"download\"\n )}Download</button></div>`\n )\n .join(\"\")}</section>`\n : '<div class=\"empty-state\"><h3>No artifacts</h3><p>This run did not publish any artifacts.</p></div>';\n return;\n }\n if (state.detailTab === \"history\") {\n const history = await loadRunHistory(run);\n content.innerHTML =\n history.length > 1\n ? `<section class=\"settings-section\"><h2>Execution history</h2>${history\n .map(\n (entry) =>\n `<div class=\"setting-row\"><div><strong><a href=\"#/runs/${encodeURIComponent(\n entry.jobId\n )}\">Run #${escape(entry.jobId)}</a></strong><p>${escape(\n entry.workflowName || entry.workflowId || \"Workflow\"\n )} · ${formatDate(entry.startedAt)}</p></div>${badge(entry.status)}</div>`\n )\n .join(\"\")}</section>`\n : '<div class=\"empty-state\"><h3>No previous attempts</h3><p>This run has no parent run linked to it.</p></div>';\n return;\n }\n const steps = run.steps || [];\n if (!steps.length) {\n content.innerHTML =\n '<div class=\"empty-state\"><h3>No execution steps yet</h3><p>Step details will appear when Flow records them.</p></div>';\n return;\n }\n state.step = Math.max(0, Math.min(state.step, steps.length - 1));\n const selected = steps[state.step];\n const stepButtons = steps\n .map(\n (step, index) =>\n `<button class=\"step-button ${\n state.step === index ? \"active\" : \"\"\n }\" data-action=\"step\" data-value=\"${index}\" aria-pressed=\"${state.step === index}\" aria-label=\"${escape(\n step.name\n )}, ${escape(labels[step.status] || step.status)}\">${iconForStatus(step.status)}<span>${escape(\n step.name\n )}</span><span class=\"mono\">${formatDuration(step.durationMs)}</span></button>`\n )\n .join(\"\");\n if (!run.canViewLogs) {\n content.innerHTML = `<section class=\"execution\" aria-label=\"Execution steps and logs\"><aside class=\"steps-panel\" aria-label=\"Execution steps\"><div class=\"steps-heading\"><span>Execution steps</span><span class=\"mono\">${\n steps.filter((step) => step.status === \"success\").length\n } / ${steps.length}</span></div>${stepButtons}</aside><div class=\"log-panel\"><div class=\"empty-state\">${icon(\n \"lock\"\n )}<h3>Logs require permission</h3><p>Your account needs <span class=\"mono\">logs:read</span> to view step output.</p></div></div></section>`;\n return;\n }\n content.innerHTML = `<section class=\"execution\" aria-label=\"Execution steps and logs\"><aside class=\"steps-panel\" aria-label=\"Execution steps\"><div class=\"steps-heading\"><span>Execution steps</span><span class=\"mono\">${\n steps.filter((step) => step.status === \"success\").length\n } / ${steps.length}</span></div>${steps\n .map(\n (step, index) =>\n `<button class=\"step-button ${\n state.step === index ? \"active\" : \"\"\n }\" data-action=\"step\" data-value=\"${index}\" aria-pressed=\"${state.step === index}\" aria-label=\"${escape(\n step.name\n )}, ${escape(labels[step.status] || step.status)}\">${iconForStatus(step.status)}<span>${escape(\n step.name\n )}</span><span class=\"mono\">${formatDuration(step.durationMs)}</span></button>`\n )\n .join(\"\")}</aside><div class=\"log-panel\"><div class=\"log-heading\"><h3>${escape(selected.name)}<small>${\n selected.exitCode !== undefined\n ? `exit code ${escape(selected.exitCode)}`\n : escape(labels[selected.status] || selected.status)\n }</small></h3><div class=\"log-actions\"><button class=\"icon-button\" data-action=\"wrap\" aria-label=\"Wrap log lines\" aria-pressed=\"${\n state.wrap\n }\">${icon(\"wrap\")}</button><button class=\"icon-button\" data-action=\"copy-log\" aria-label=\"Copy step log\">${icon(\n \"copy\"\n )}</button></div></div><label class=\"log-search\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search step log</span><input id=\"log-search\" type=\"search\" placeholder=\"Find in this step…\" value=\"${escape(\n state.logSearch\n )}\"/><span id=\"log-matches\"></span></label><div class=\"log-lines ${\n state.wrap ? \"wrap\" : \"\"\n }\" id=\"log-lines\" tabindex=\"0\" aria-label=\"Step log output\"></div><div class=\"log-bottom\"><span>${\n selected.status === \"failed\"\n ? \"Step failed\"\n : selected.status === \"running\"\n ? \"Step is running\"\n : \"End of step output\"\n }</span><span class=\"mono\">UTF-8</span></div></div></section>`;\n renderLog(selected.logContent || \"\");\n };\n\n const loadRunHistory = async (run) => {\n const entries = [run];\n const visited = new Set([String(run.jobId)]);\n let parent = run.parentId;\n while (parent && !visited.has(String(parent)) && entries.length < 25) {\n visited.add(String(parent));\n try {\n const entry = await apiJson(`/api/runs/${encodeURIComponent(parent)}`);\n entries.push(entry);\n parent = entry.parentId;\n } catch {\n break;\n }\n }\n return entries;\n };\n\n const renderLog = (text) => {\n const log = document.querySelector(\"#log-lines\");\n if (!log) {\n return;\n }\n const query = state.logSearch.toLowerCase();\n let matches = 0;\n log.innerHTML = String(text)\n .split(\"\\n\")\n .map((line, index) => {\n let content = escape(line) || \" \";\n const start = query ? line.toLowerCase().indexOf(query) : -1;\n if (start >= 0) {\n matches++;\n content = `${escape(line.slice(0, start))}<mark>${escape(\n line.slice(start, start + query.length)\n )}</mark>${escape(line.slice(start + query.length))}`;\n }\n const type = /\\b(error|failed|fatal)\\b/i.test(line)\n ? \"error\"\n : /\\b(success|passed|done)\\b/i.test(line)\n ? \"pass\"\n : \"\";\n return `<div class=\"log-line ${type}\"><span class=\"line-number\">${\n index + 1\n }</span><span class=\"line-text\">${content}</span></div>`;\n })\n .join(\"\");\n const result = document.querySelector(\"#log-matches\");\n if (result) {\n result.textContent = query ? `${matches} matching lines` : \"\";\n }\n };\n const renderLiveLog = renderLog;\n\n const downloadBlob = (blob, filename) => {\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = filename;\n anchor.click();\n window.setTimeout(() => URL.revokeObjectURL(url), 1000);\n };\n\n const renderRevisionDiff = (previousSource, currentSource) => {\n const before = previousSource === null ? [] : previousSource.split(\"\\n\");\n const after = String(currentSource ?? \"\").split(\"\\n\");\n let prefixLength = 0;\n while (\n prefixLength < before.length &&\n prefixLength < after.length &&\n before[prefixLength] === after[prefixLength]\n ) {\n prefixLength++;\n }\n let suffixLength = 0;\n while (\n suffixLength < before.length - prefixLength &&\n suffixLength < after.length - prefixLength &&\n before[before.length - suffixLength - 1] === after[after.length - suffixLength - 1]\n ) {\n suffixLength++;\n }\n const renderLines = (lines, className, prefix) =>\n lines\n .filter((line, index) => line || index < lines.length - 1)\n .map((line) => `<span class=\"block ${className}\">${prefix}${escape(line)}</span>`)\n .join(\"\");\n return [\n renderLines(before.slice(0, prefixLength), \"text-gray-300\", \" \"),\n renderLines(before.slice(prefixLength, before.length - suffixLength), \"text-rose-300 bg-rose-500/10\", \"- \"),\n renderLines(after.slice(prefixLength, after.length - suffixLength), \"text-emerald-300 bg-emerald-500/10\", \"+ \"),\n renderLines(before.slice(before.length - suffixLength), \"text-gray-300\", \" \"),\n ].join(\"\");\n };\n\n const loadRevisionDiff = async (workflowId, revision) => {\n try {\n const current = await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}?revision=${revision}`);\n const previous =\n revision > 1\n ? await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}?revision=${revision - 1}`)\n : null;\n const sourceDiff = renderRevisionDiff(previous?.sourceYaml ?? null, current.sourceYaml);\n openDialog(\n `Workflow ${current.name || current.id}`,\n `<p class=\"dialog-description\">${escape(current.name || current.id)} — Revision ${escape(\n current.revision\n )}</p><div class=\"source-view\"><pre>${sourceDiff}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`\n );\n } catch (error) {\n showToast(`Failed to load revision diff: ${error.message}`);\n }\n };\n\n const liveWorkflowsPage = async () => {\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n setBreadcrumbs(\"workflows\");\n const tabs = [\n { id: \"all\", label: \"All\", filter: () => true },\n { id: \"published\", label: \"Published\", filter: (workflow) => workflow.status === \"published\" },\n { id: \"draft\", label: \"Drafts\", filter: (workflow) => workflow.status === \"draft\" },\n { id: \"archived\", label: \"Archived\", filter: (workflow) => workflow.status === \"archived\" },\n ];\n const selectedTab = tabs.find((tab) => tab.id === state.workflowFilter) || tabs[0];\n const visibleWorkflows = state.workflows.filter(selectedTab.filter);\n const tabButtons = tabs\n .map((tab) => {\n const count = state.workflows.filter(tab.filter).length;\n return `<button class=\"status-tab ${\n state.workflowFilter === tab.id ? \"active\" : \"\"\n }\" data-action=\"workflow-status-tab\" data-value=\"${tab.id}\" aria-pressed=\"${state.workflowFilter === tab.id}\">${\n tab.label\n }<span class=\"count\">${count}</span></button>`;\n })\n .join(\"\");\n const workflowRows = visibleWorkflows\n .map((workflow) => {\n const index = state.workflows.indexOf(workflow);\n return `<div class=\"workflow-item\"><div class=\"workflow-header\"><span class=\"workflow-icon\">${icon(\n \"workflow\"\n )}</span><span class=\"workflow-name\">${escape(\n workflow.name || workflow.id\n )}</span><span class=\"workflow-status ${\n workflow.status === \"published\" ? \"success\" : workflow.status === \"archived\" ? \"disabled\" : \"enabled\"\n }\">${escape(workflow.status)}</span></div><div class=\"workflow-meta\"><span>Revision ${escape(\n workflow.revision || \"—\"\n )}</span><span class=\"workflow-id mono\">${escape(\n workflow.id\n )}</span></div><div class=\"workflow-actions\"><a class=\"text-button\" href=\"#/runs?workflow=${encodeURIComponent(\n workflow.id\n )}\">View runs ${icon(\"arrow\")}</a><a class=\"button\" href=\"/settings/workflows/${encodeURIComponent(\n workflow.id\n )}\" target=\"_top\">Edit ${icon(\n \"arrow\"\n )}</a><button class=\"button\" data-action=\"workflow-source\" data-value=\"${index}\">${icon(\n \"code\"\n )}Source</button>${\n workflow.revision > 1\n ? `<button class=\"button small\" data-action=\"workflow-revision\" data-value=\"${index}\" data-dir=\"-1\">Previous revision</button>`\n : \"\"\n }${\n workflow.status !== \"published\"\n ? `<button class=\"button small success\" data-action=\"workflow-publish\" data-value=\"${index}\">Publish ${icon(\n \"rocket\"\n )}</button>`\n : \"\"\n }</div></div>`;\n })\n .join(\"\");\n const content =\n workflowRows ||\n `<div class=\"empty-state\"><h3>No ${escape(selectedTab.label.toLowerCase())} workflows</h3><p>${\n state.workflows.length\n ? \"Choose another status tab or create a workflow.\"\n : \"Flow has no workflow definitions to show.\"\n }</p></div>`;\n main.innerHTML = `${pageHeading(\"Workflows\", \"Workflow definitions\", runWorkflowButton())}${\n state.errors.workflows\n ? `<div class=\"empty-state\">${icon(\"lock\")}<h3>Workflows unavailable</h3><p>${escape(\n permissionMessage(state.errors.workflows, \"workflows:read\")\n )}</p><button class=\"button\" data-action=\"retry-workflows\">Retry</button></div>`\n : `<div class=\"status-tabs\" role=\"group\" aria-label=\"Filter workflows by status\">${tabButtons}</div><div class=\"workflow-list\">${content}</div>`\n }`;\n };\n\n const liveWorkersPage = async () => {\n if (!state.workersLoaded) {\n await loadWorkers();\n }\n setBreadcrumbs(\"workers\");\n main.innerHTML = `${pageHeading(\"Workers\", \"Live runner presence from Flow.\")}${\n state.errors.workers\n ? `<div class=\"empty-state\">${icon(\"lock\")}<h3>Worker status unavailable</h3><p>${escape(\n permissionMessage(state.errors.workers, \"workers:read\")\n )}</p><button class=\"button\" data-action=\"retry-workers\">Retry</button></div>`\n : `<div class=\"cards\">${\n state.workers\n .map(\n (worker) =>\n `<article class=\"worker-card\"><div class=\"card-heading\">${icon(\"server\")}<h2>${escape(\n worker.workerId || \"Unknown worker\"\n )}</h2><span class=\"status ${worker.online ? \"success\" : \"cancelled\"}\">${\n worker.online ? \"Online\" : \"Offline\"\n }</span></div><div class=\"card-details\">${\n (Array.isArray(worker.tags) ? worker.tags : [])\n .map((tag) => `<span class=\"tag mono\">${escape(tag)}</span>`)\n .join(\"\") || '<span class=\"settings-note\">No labels</span>'\n }</div><div class=\"worker-stats\"><div><span class=\"meta-label\">Active jobs</span><strong>${escape(\n worker.activeJobs ?? 0\n )}</strong></div><div><span class=\"meta-label\">Concurrency</span><strong>${escape(\n worker.concurrency ?? \"—\"\n )}</strong></div></div><div class=\"card-footer\"><span class=\"settings-note\">${\n worker.version ? `Runner ${escape(worker.version)}` : \"Runner version unavailable\"\n }</span><span class=\"settings-note\">Seen ${escape(\n formatDate(worker.lastSeen)\n )}</span></div></article>`\n )\n .join(\"\") ||\n '<div class=\"empty-state\"><h3>No workers reported</h3><p>Flow has no worker-presence records.</p></div>'\n }</div>`\n }`;\n };\n\n const liveSettingsPage = async () => {\n if (!state.secretsLoaded) {\n await loadSecrets();\n }\n setBreadcrumbs(\"settings\");\n main.innerHTML = `${pageHeading(\n \"Settings\",\n \"Preferences and secret names from Flow.\"\n )}<section class=\"settings-section\"><h2>Preferences</h2><label class=\"setting-row\"><span><strong>Compact run list</strong><span class=\"settings-note\">Fit more activity on your screen.</span></span><input type=\"checkbox\" id=\"compact-setting\" ${\n localStorage.getItem(\"flow-concept-compact\") === \"true\" ? \"checked\" : \"\"\n }/></label><label class=\"setting-row\"><span><strong>Wrap log lines</strong><span class=\"settings-note\">Keep long output within the log viewer.</span></span><input type=\"checkbox\" id=\"wrap-setting\" ${\n state.wrap ? \"checked\" : \"\"\n }/></label></section><section class=\"settings-section\"><h2>${icon(\n \"lock\"\n )} Secret names</h2><p class=\"settings-note\">Values are never requested or displayed in this preview.</p>${\n state.errors.secrets\n ? `<p class=\"settings-note\">${escape(\n permissionMessage(state.errors.secrets, \"secrets:read\")\n )}</p><button class=\"button\" data-action=\"retry-secrets\">Retry</button>`\n : state.secrets\n .map(\n (name) =>\n `<div class=\"setting-row\"><strong class=\"mono\">${escape(\n name\n )}</strong><span class=\"secret-value\" aria-label=\"Secret value hidden\">••••••••••••</span><button class=\"button tiny danger\" data-action=\"remove-secret\" data-value=\"${name}\">Remove</button></div>`\n )\n .join(\"\") || '<p class=\"settings-note\">No secret names are configured.</p>'\n }${\n !state.errors.secrets && state.secrets.length < 5\n ? '<div class=\"setting-row\"><button class=\"button tiny\" data-action=\"add-secret\">Add secret</button></div>'\n : \"\"\n }</section><section class=\"settings-section\"><h2>Workflow editing</h2><p class=\"settings-note\">Create and edit workflow definitions.</p><form id=\"new-workflow-form\"><label class=\"form-field\"><span>Workflow name</span><input id=\"new-workflow-name\" type=\"text\" placeholder=\"e.g. build-docker-image\" required/></label><label class=\"form-field\"><span>Workflow ID</span><input id=\"new-workflow-id\" type=\"text\" placeholder=\"derived-from-name\" required/></label><label class=\"form-field\"><span>Enabled</span><input type=\"checkbox\" id=\"new-workflow-enabled\" checked/></label><div class=\"form-field\"><span>YAML source</span><textarea id=\"new-workflow-source\" rows=\"8\" placeholder=\"name: build-docker-image\\nruns-on: [main-server, docker]\\n\\nsteps:\\n - name: Checkout repository\\n run: git checkout '$COMMIT'\\n - name: Install dependencies\\n run: pnpm install --frozen-lockfile\\n - name: Run tests\\n run: pnpm test\\n - name: Build and push\\n run: pnpm publish\"></textarea></label></div><div class=\"form-footer\"><button type=\"button\" class=\"button\" data-action=\"validate-workflow\">Validate workflow</button><button type=\"button\" class=\"button\" data-action=\"run-now\">Run now</button><button type=\"submit\" class=\"button primary\">${icon(\n \"plus\"\n )}Create workflow</button><button type=\"button\" class=\"button\" data-action=\"close-dialog\">Cancel</button></div></form><a class=\"button secondary\" href=\"/settings/workflows\" target=\"_top\">Open full workflow settings ${icon(\n \"arrow\"\n )}</a></section>`;\n };\n\n const showNotFound = () => {\n setBreadcrumbs(\"runs\");\n main.innerHTML = `<div class=\"empty-state\"><h1>404 — Page not found</h1><p>This preview route does not exist.</p><a class=\"button\" href=\"#/runs\">Go to runs</a></div>`;\n };\n\n const openDialog = (title, content) => {\n dialog.innerHTML = `<div class=\"dialog-heading\"><h2 id=\"dialog-title\">${escape(\n title\n )}</h2><button class=\"icon-button\" data-action=\"close-dialog\" aria-label=\"Close dialog\">${icon(\n \"close\"\n )}</button></div>${content}`;\n if (!dialog.open) {\n dialog.showModal();\n }\n };\n\n const openDispatchDialog = async () => {\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n if (state.errors.workflows) {\n showToast(permissionMessage(state.errors.workflows, \"workflows:read\"));\n return;\n }\n openDialog(\n \"Run a workflow\",\n `<p class=\"dialog-description\">This will enqueue a real Flow job.</p><label class=\"form-field\"><span>Workflow</span><select id=\"dispatch-workflow\">${state.workflows\n .map(\n (workflow) =>\n `<option value=\"${escape(workflow.id)}\" ${workflow.enabled === false ? \"disabled\" : \"\"}>${escape(\n workflow.name || workflow.id\n )}${workflow.enabled === false ? \" (disabled)\" : \"\"}</option>`\n )\n .join(\n \"\"\n )}</select></label><label class=\"form-field\"><span>Inputs (JSON object)</span><textarea id=\"dispatch-inputs\" rows=\"6\">{}</textarea></label><p class=\"dialog-note\">Running a workflow requires write permission and creates real job(s).</p><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Cancel</button><button class=\"button primary\" data-action=\"dispatch-confirm\">${icon(\n \"runs\"\n )}Review and run</button></div>`\n );\n };\n\n const dispatchWorkflow = async () => {\n if (state.mutating) {\n return;\n }\n const workflowId = document.querySelector(\"#dispatch-workflow\")?.value;\n if (!workflowId) {\n showToast(\"Select a workflow first.\");\n return;\n }\n let inputs;\n try {\n inputs = JSON.parse(document.querySelector(\"#dispatch-inputs\")?.value || \"{}\");\n if (!inputs || typeof inputs !== \"object\" || Array.isArray(inputs)) {\n throw new Error(\"Inputs must be a JSON object.\");\n }\n } catch (error) {\n showToast(error.message);\n return;\n }\n const workflow = state.workflows.find((item) => item.id === workflowId);\n if (workflow?.enabled === false) {\n showToast(\"This workflow is disabled and cannot be run.\");\n return;\n }\n state.mutating = true;\n const dispatchButton = document.querySelector('[data-action=\"dispatch-confirm\"]');\n if (dispatchButton) {\n dispatchButton.disabled = true;\n }\n if (!window.confirm(`Run ${workflow?.name || workflowId} now? This creates real job(s).`)) {\n state.mutating = false;\n if (dispatchButton) {\n dispatchButton.disabled = false;\n }\n return;\n }\n try {\n const result = await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}/run`, {\n method: \"POST\",\n body: JSON.stringify({ inputs }),\n });\n dialog.close();\n state.jobs = [];\n state.jobsLoaded = false;\n await loadJobs();\n showToast(`Queued ${result.jobs?.length || 0} real job(s).`);\n if (result.jobs?.length) {\n window.location.hash = `/runs/${result.jobs[0]}`;\n }\n } catch (error) {\n showToast(`Could not run workflow: ${error.message}`);\n } finally {\n state.mutating = false;\n if (dispatchButton) {\n dispatchButton.disabled = false;\n }\n }\n };\n\n const render = async () => {\n const { path, query } = route();\n const [section, id] = path.split(\"/\").slice(1);\n const page = section || \"runs\";\n const changed = previousRoute !== window.location.hash;\n if (changed) {\n state.step = 0;\n state.detailTab = \"logs\";\n state.logSearch = \"\";\n if (section !== \"runs\" || !id) {\n state.run = null;\n }\n }\n previousRoute = window.location.hash;\n setNavigation(page === \"runs\" ? \"runs\" : [\"workflows\", \"workers\", \"settings\"].includes(page) ? page : \"runs\", id);\n if (path === \"/runs\" || path === \"\") {\n const workflowFilter = query.get(\"workflow\") || \"all\";\n const searchFilter = query.get(\"search\") || \"\";\n if (workflowFilter !== state.workflow || searchFilter !== state.search) {\n state.jobs = [];\n state.jobsLoaded = false;\n state.hasMore = false;\n }\n state.workflow = workflowFilter;\n state.search = searchFilter;\n const statusFilter = query.get(\"status\");\n if ([\"all\", \"running\", \"failed\", \"success\"].includes(statusFilter)) {\n state.status = statusFilter;\n }\n if (!state.jobsLoaded) {\n await loadJobs();\n }\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n renderRuns();\n if (changed) {\n window.scrollTo(0, 0);\n }\n return;\n }\n if (section === \"runs\" && id) {\n await liveRunPage(id);\n return;\n }\n if (path === \"/workflows\") {\n await liveWorkflowsPage();\n return;\n }\n if (path === \"/workers\") {\n await liveWorkersPage();\n return;\n }\n if (path === \"/settings\") {\n await liveSettingsPage();\n return;\n }\n showNotFound();\n };\n\n const action = async (event) => {\n const button = event.target.closest(\"[data-action]\");\n if (!button) {\n return false;\n }\n const { action: name, value } = button.dataset;\n if (name === \"new-run\") {\n await openDispatchDialog();\n return true;\n }\n if (name === \"close-dialog\") {\n dialog.close();\n return true;\n }\n if (name === \"dispatch-confirm\") {\n await dispatchWorkflow();\n return true;\n }\n if (name === \"retry-runs\") {\n state.jobsLoaded = false;\n state.errors.jobs = null;\n await loadJobs();\n renderRuns();\n return true;\n }\n if (name === \"retry-workflows\") {\n state.workflowsLoaded = false;\n state.errors.workflows = null;\n await liveWorkflowsPage();\n return true;\n }\n if (name === \"workflow-status-tab\") {\n state.workflowFilter = value;\n render();\n return true;\n }\n if (name === \"retry-workers\") {\n state.workersLoaded = false;\n state.errors.workers = null;\n await liveWorkersPage();\n return true;\n }\n if (name === \"retry-secrets\") {\n state.secretsLoaded = false;\n state.errors.secrets = null;\n await liveSettingsPage();\n return true;\n }\n if (name === \"add-secret\") {\n const name = window.prompt(\"Enter secret name (uppercase letters, numbers, underscores):\");\n if (!name) {\n return true;\n }\n if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {\n showToast(\"Secret name must start with a letter and contain only letters, numbers, and underscores.\");\n return true;\n }\n const secretValue = window.prompt(`Enter the value for ${name}:`);\n if (secretValue === null || secretValue.length === 0) {\n showToast(\"Secret value cannot be empty.\");\n return true;\n }\n try {\n await apiJson(`/api/secrets/${name}`, {\n method: \"PUT\",\n body: JSON.stringify({ value: secretValue, encoding: \"utf8\" }),\n });\n showToast(`Secret ${name} added.`);\n state.secretsLoaded = false;\n await loadSecrets();\n await liveSettingsPage();\n } catch (error) {\n showToast(`Failed to add secret: ${error.message}`);\n }\n return true;\n }\n if (name === \"remove-secret\") {\n const name = value;\n if (!window.confirm(`Delete secret ${name}?`)) {\n return true;\n }\n try {\n await apiJson(`/api/secrets/${name}`, { method: \"DELETE\" });\n showToast(`Secret ${name} removed.`);\n state.secretsLoaded = false;\n await loadSecrets();\n await liveSettingsPage();\n } catch (error) {\n showToast(`Failed to remove secret: ${error.message}`);\n }\n return true;\n }\n if (name === \"status-filter\") {\n state.status = value;\n state.page = 1;\n renderRunsTable();\n button.focus({ preventScroll: true });\n return true;\n }\n if (name === \"clear-filters\") {\n state.search = \"\";\n state.status = \"all\";\n state.workflow = \"all\";\n state.worker = \"all\";\n state.page = 1;\n renderRuns();\n document.querySelector(\"#run-search\")?.focus();\n return true;\n }\n if (name === \"prev-page\" || name === \"next-page\") {\n const pages = Math.max(1, Math.ceil(filteredJobs().length / 8));\n if (name === \"next-page\" && state.page === pages && state.hasMore) {\n await loadJobs({ older: true });\n }\n state.page = Math.max(1, state.page + (name === \"next-page\" ? 1 : -1));\n renderRunsTable();\n return true;\n }\n if (name === \"detail-tab\" && state.run) {\n state.detailTab = value;\n await renderRunTab(state.run);\n return true;\n }\n if (name === \"step\" && state.run) {\n state.step = Number(value);\n await renderRunTab(state.run);\n return true;\n }\n if (name === \"jump-error\" && state.run) {\n const failed = state.run.steps?.findIndex((step) => step.status === \"failed\") ?? -1;\n if (failed >= 0) {\n state.step = failed;\n await renderRunTab(state.run);\n }\n return true;\n }\n if (name === \"wrap\") {\n state.wrap = !state.wrap;\n document.querySelector(\"#log-lines\")?.classList.toggle(\"wrap\", state.wrap);\n button.setAttribute(\"aria-pressed\", String(state.wrap));\n return true;\n }\n if (name === \"copy-log\" && state.run) {\n try {\n await navigator.clipboard.writeText(state.run.steps?.[state.step]?.logContent || \"\");\n showToast(\"Step log copied.\");\n } catch {\n showToast(\"Clipboard unavailable.\");\n }\n return true;\n }\n if (name === \"download-logs\" && state.run) {\n const text = (state.run.steps || []).map((step) => `=== ${step.name} ===\\n${step.logContent || \"\"}`).join(\"\\n\\n\");\n downloadBlob(new Blob([text], { type: \"text/plain;charset=utf-8\" }), `flow-run-${state.run.jobId}.log`);\n return true;\n }\n if (name === \"download-artifact\" && state.run) {\n const artifact = state.run.artifacts?.[Number(value)];\n if (!artifact) {\n return true;\n }\n const encoded = String(artifact).split(\"/\").map(encodeURIComponent).join(\"/\");\n try {\n const blob = await apiBlob(`/api/runs/${encodeURIComponent(state.run.jobId)}/artifacts/${encoded}`);\n downloadBlob(blob, String(artifact).split(\"/\").pop() || \"artifact\");\n } catch (error) {\n showToast(`Download failed: ${error.message}`);\n }\n return true;\n }\n if (name === \"workflow-source\") {\n const workflow = state.workflows[Number(value)];\n if (workflow) {\n openDialog(\n workflow.name || workflow.id,\n `<p class=\"dialog-description\">Revision ${escape(workflow.revision || \"—\")} · ${escape(\n workflow.status || \"\"\n )}</p><div class=\"source-view\"><pre>${escape(\n workflow.sourceYaml || \"Workflow source unavailable.\"\n )}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`\n );\n }\n return true;\n }\n if (name === \"validate-workflow\") {\n const source = workflowFormSource();\n if (!source) {\n showToast(\"Please enter workflow source YAML first.\");\n return true;\n }\n apiJson(`/api/workflows/validate`, {\n method: \"POST\",\n body: JSON.stringify({ sourceYaml: source }),\n })\n .then((result) => {\n const status = result.valid ? \"passed\" : \"failed\";\n showToast(`Validation ${status}: ${result.error || \"\"}`);\n })\n .catch((error) => {\n showToast(`Validation failed: ${error.message}`);\n });\n return true;\n }\n if (name === \"run-now\") {\n const id = document.getElementById(\"new-workflow-id\")?.value.trim();\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n const source = workflowFormSource();\n const enabled = document.getElementById(\"new-workflow-enabled\")?.checked;\n if (!id || !name) {\n showToast(\"Please enter a workflow name and ID first.\");\n return true;\n }\n if (!source) {\n showToast(\"Please enter workflow source YAML first.\");\n return true;\n }\n if (!/^[a-z0-9-]+$/.test(id)) {\n showToast(\"Workflow ID can only contain lowercase letters, numbers, and hyphens.\");\n return true;\n }\n if (!enabled) {\n showToast(\"Enable the workflow before running it.\");\n return true;\n }\n void (async () => {\n try {\n await apiJson(`/api/workflows/${encodeURIComponent(id)}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source, enabled }),\n });\n const result = await apiJson(`/api/workflows/${encodeURIComponent(id)}/run`, {\n method: \"POST\",\n body: JSON.stringify({}),\n });\n showToast(\n `Queued ${result.jobs?.length || 0} job${result.jobs?.length === 1 ? \"\" : \"s\"} from revision ${\n result.revision\n }.`\n );\n } catch (error) {\n showToast(`Failed to run workflow: ${error.message}`);\n }\n })();\n return true;\n }\n if (name === \"workflow-revision\") {\n const workflow = state.workflows[Number(value)];\n if (!workflow) {\n return true;\n }\n const dir = Number(button.dataset.dir);\n const targetRev = (workflow.revision || 1) + dir;\n if (targetRev < 1) {\n return true;\n }\n loadRevisionDiff(workflow.id, targetRev);\n return true;\n }\n if (name === \"workflow-publish\") {\n const workflow = state.workflows[Number(value)];\n if (!workflow) {\n return true;\n }\n if (!window.confirm(`Publish workflow ${workflow.name || workflow.id}? This will make it available for runs.`)) {\n return true;\n }\n apiJson(`/api/workflows/${workflow.id}/publish`, { method: \"POST\" })\n .then((result) => {\n showToast(`Published ${result.id || workflow.id} revision ${result.revision}.`);\n state.workflowsLoaded = false;\n void loadWorkflows();\n render();\n })\n .catch((error) => {\n showToast(`Publish failed: ${error.message}`);\n });\n return true;\n }\n if (name === \"rerun\" && state.run) {\n if (state.mutating) {\n return true;\n }\n state.mutating = true;\n button.disabled = true;\n if (\n !window.confirm(\n `Re-run ${\n state.run.workflowName || state.run.workflowId || \"this workflow\"\n }? This creates a new job from the active workflow revision.`\n )\n ) {\n state.mutating = false;\n button.disabled = false;\n return true;\n }\n try {\n const result = await apiJson(`/restart/${encodeURIComponent(state.run.jobId)}`, {\n method: \"POST\",\n body: JSON.stringify({}),\n });\n state.jobsLoaded = false;\n await loadJobs();\n showToast(`Re-run queued: #${result.id}`);\n window.location.hash = `/runs/${result.id}`;\n } catch (error) {\n showToast(`Re-run failed: ${error.message}`);\n } finally {\n state.mutating = false;\n button.disabled = false;\n }\n return true;\n }\n if (name === \"cancel\" && state.run) {\n if (state.mutating) {\n return true;\n }\n if (\n ![\"pending\", \"running\"].includes(state.run.status) ||\n !window.confirm(`Cancel live run #${state.run.jobId}?`)\n ) {\n return true;\n }\n state.mutating = true;\n button.disabled = true;\n try {\n await apiJson(`/api/jobs/${encodeURIComponent(state.run.jobId)}/cancel`, { method: \"POST\" });\n showToast(`Run #${state.run.jobId} cancelled.`);\n await liveRunPage(state.run.jobId);\n state.jobsLoaded = false;\n await loadJobs();\n } catch (error) {\n showToast(`Cancel failed: ${error.message}`);\n } finally {\n state.mutating = false;\n button.disabled = false;\n }\n return true;\n }\n return false;\n };\n\n const setupEvents = () => {\n if (events) {\n return;\n }\n events = new EventSource(\"/api/events\");\n events.addEventListener(\"jobs.available\", () => {\n state.jobsLoaded = false;\n void loadJobs().then(() => {\n if (route().path === \"/runs\") {\n renderRuns();\n }\n });\n });\n events.addEventListener(\"jobs.changed\", (event) => {\n let data = {};\n try {\n data = JSON.parse(event.data || \"{}\");\n } catch {\n /* Refresh the list even if an event payload is malformed. */\n }\n state.jobsLoaded = false;\n void loadJobs().then(() => {\n if (route().path === \"/runs\") {\n renderRuns();\n }\n });\n if (state.run && (!data.jobId || String(data.jobId) === String(state.run.jobId))) {\n void liveRunPage(state.run.jobId);\n }\n });\n events.onerror = () => {\n /* EventSource retries transient disconnects automatically. */\n };\n };\n\n document.addEventListener(\"click\", async (event) => {\n const handled = await action(event);\n if (handled) {\n event.preventDefault();\n }\n });\n document.addEventListener(\"submit\", (event) => {\n if (event.target?.id === \"new-workflow-form\") {\n event.preventDefault();\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n const id = document.getElementById(\"new-workflow-id\")?.value.trim().toLowerCase();\n const enabled = document.getElementById(\"new-workflow-enabled\")?.checked;\n const source = workflowFormSource();\n if (!name || !id || !source) {\n showToast(\"Please fill in all fields (name, id, and source).\");\n return;\n }\n if (!/^[a-z0-9-]+$/.test(id)) {\n showToast(\"Workflow ID can only contain lowercase letters, numbers, and hyphens.\");\n return;\n }\n apiJson(`/api/workflows/${encodeURIComponent(id)}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source, enabled }),\n })\n .then(() => {\n showToast(`Created workflow ${id}.`);\n state.workflowsLoaded = false;\n void loadWorkflows();\n render();\n })\n .catch((error) => {\n showToast(`Failed to create workflow: ${error.message}`);\n });\n }\n });\n document.addEventListener(\"input\", (event) => {\n if (event.target.id === \"run-search\") {\n state.search = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"log-search\") {\n state.logSearch = event.target.value;\n if (state.run) {\n renderLiveLog(state.run.steps?.[state.step]?.logContent || \"\");\n }\n }\n });\n document.addEventListener(\"change\", (event) => {\n if (event.target.id === \"workflow-filter\") {\n state.workflow = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"worker-filter\") {\n state.worker = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"compact-setting\") {\n document.body.classList.toggle(\"compact\", event.target.checked);\n localStorage.setItem(\"flow-concept-compact\", String(event.target.checked));\n }\n if (event.target.id === \"wrap-setting\") {\n state.wrap = event.target.checked;\n localStorage.setItem(\"flow-concept-wrap\", String(event.target.checked));\n }\n });\n window.addEventListener(\"hashchange\", () => {\n state.run = null;\n void render();\n });\n window.addEventListener(\"beforeunload\", () => {\n events?.close();\n });\n document.querySelectorAll(\"[data-icon]\").forEach((node) => {\n node.innerHTML = icon(node.dataset.icon);\n });\n document.body.classList.toggle(\"compact\", localStorage.getItem(\"flow-concept-compact\") === \"true\");\n state.wrap = localStorage.getItem(\"flow-concept-wrap\") === \"true\";\n const initialQuery = route().query;\n state.workflow = initialQuery.get(\"workflow\") || \"all\";\n state.search = initialQuery.get(\"search\") || \"\";\n if ([\"all\", \"running\", \"failed\", \"success\"].includes(initialQuery.get(\"status\"))) {\n state.status = initialQuery.get(\"status\");\n }\n main.innerHTML = `${pageHeading(\"Runs\", \"Runs\")}<div class=\"empty-state\">${icon(\"clock\")}<p>Loading runs…</p></div>`;\n setupEvents();\n void Promise.all([loadWorkflows(), loadJobs()]).then(() => {\n state.jobsLoaded = true;\n void render();\n });\n}\n", gg = 50, _g = 500, vg = class e {
12402
+ var hg = mg(fg, pg, dg), gg = "/* global document, window, localStorage, navigator, URL, URLSearchParams, Headers, EventSource, Blob */\n\nconst icons = {\n runs: '<rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"3\"/><path d=\"m10 8 5 4-5 4Z\"/>',\n workflow:\n '<rect x=\"3\" y=\"3\" width=\"6\" height=\"6\" rx=\"1.5\"/><rect x=\"15\" y=\"15\" width=\"6\" height=\"6\" rx=\"1.5\"/><path d=\"M6 9v6a3 3 0 0 0 3 3h6M9 6h6a3 3 0 0 1 3 3v6\"/>',\n server:\n '<rect x=\"3\" y=\"3\" width=\"18\" height=\"7\" rx=\"2\"/><rect x=\"3\" y=\"14\" width=\"18\" height=\"7\" rx=\"2\"/><path d=\"M7 6.5h.01M7 17.5h.01M11 6.5h6M11 17.5h6\"/>',\n settings:\n '<path d=\"m9 3-1 3-3 1-2 3 2 2-1 3 3 2 3-1 2 2 3-1 1-3 3-1 2-3-2-2 1-3-3-2-3 1-2-2Z\"/><circle cx=\"11.5\" cy=\"10.5\" r=\"3\"/>',\n search: '<circle cx=\"10.5\" cy=\"10.5\" r=\"6.5\"/><path d=\"m16 16 4 4\"/>',\n plus: '<path d=\"M12 5v14M5 12h14\"/>',\n arrow: '<path d=\"M5 12h14m-5-5 5 5-5 5\"/>',\n back: '<path d=\"M19 12H5m5-5-5 5 5 5\"/>',\n check: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m8 12 3 3 5-6\"/>',\n failed: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m9 9 6 6m0-6-6 6\"/>',\n running: '<path d=\"M20.4 8.8A9 9 0 1 1 15.2 3.6\"/><path d=\"M20.4 3.6v5.2h-5.2\"/>',\n pending: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n cancelled: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m6 6 12 12\"/>',\n skipped: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/>',\n branch:\n '<circle cx=\"6\" cy=\"5\" r=\"2\"/><circle cx=\"6\" cy=\"19\" r=\"2\"/><circle cx=\"18\" cy=\"6\" r=\"2\"/><path d=\"M6 7v10m0-4h5a7 7 0 0 0 7-5\"/>',\n clock: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n commit: '<circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M3 12h5m8 0h5\"/>',\n refresh: '<path d=\"M20 7v5h-5M4 17v-5h5\"/><path d=\"M6.1 7a7 7 0 0 1 11.5-1L20 9M4 15l2.4 3A7 7 0 0 0 18 17\"/>',\n terminal: '<path d=\"m5 6 6 6-6 6m9 0h5\"/>',\n download: '<path d=\"M12 3v12m-4-4 4 4 4-4M4 16v4h16v-4\"/>',\n copy: '<rect x=\"8\" y=\"8\" width=\"12\" height=\"12\" rx=\"2\"/><path d=\"M16 8V4H4v12h4\"/>',\n wrap: '<path d=\"M3 6h18M3 12h14a3 3 0 0 1 0 6h-5m3-3-3 3 3 3M3 18h4\"/>',\n code: '<path d=\"m8 6-6 6 6 6m8-12 6 6-6 6M14 3l-4 18\"/>',\n box: '<path d=\"m12 3 9 5v9l-9 5-9-5V8Zm0 9 9-4M3 8l9 4v10M7.5 5.5l9 5\"/>',\n history: '<path d=\"M3 3v6h6M3.6 9a9 9 0 1 1-.2 6M12 7v5l3 2\"/>',\n alert: '<path d=\"m12 3 10 18H2Z\"/><path d=\"M12 9v5m0 3h.01\"/>',\n lock: '<rect x=\"5\" y=\"10\" width=\"14\" height=\"11\" rx=\"2\"/><path d=\"M8 10V6a4 4 0 0 1 8 0v4m-4 5v2\"/>',\n stop: '<rect x=\"5\" y=\"5\" width=\"14\" height=\"14\" rx=\"2\"/>',\n};\n\nconst icon = (name) =>\n `<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">${\n icons[name] || icons.runs\n }</svg>`;\nconst escape = (value) =>\n String(value ?? \"\").replace(\n /[&<>\"']/g,\n (character) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" }[character])\n );\nconst labels = {\n success: \"Passed\",\n failed: \"Failed\",\n running: \"Running\",\n pending: \"Queued\",\n cancelled: \"Cancelled\",\n skipped: \"Skipped\",\n};\nconst statusIcon = (status) =>\n `<span class=\"status-icon ${escape(status)}\">${icon(status === \"success\" ? \"check\" : status)}</span>`;\nconst iconForStatus = statusIcon;\nconst badge = (status) =>\n `<span class=\"status ${escape(status)}\">${statusIcon(status)}${escape(labels[status] || status || \"Unknown\")}</span>`;\n\nexport function mountLivePreview() {\n const main = document.querySelector(\"#main\");\n const dialog = document.querySelector(\"#dialog\");\n const state = {\n jobs: [],\n workflows: [],\n workers: [],\n secrets: [],\n hasMore: false,\n loadingJobs: false,\n jobsLoaded: false,\n workflowsLoaded: false,\n workersLoaded: false,\n secretsLoaded: false,\n errors: {},\n search: \"\",\n status: \"all\",\n workflow: \"all\",\n worker: \"all\",\n workflowFilter: \"all\",\n page: 1,\n run: null,\n mutating: false,\n detailTab: \"logs\",\n step: 0,\n logSearch: \"\",\n wrap: false,\n };\n let apiClient;\n let events;\n let previousRoute = \"\";\n let jobsLoadPromise;\n let refreshJobsAfterCurrentLoad = false;\n\n const route = () => {\n const [path, query = \"\"] = window.location.hash.slice(1).split(\"?\");\n return { path: path || \"/runs\", query: new URLSearchParams(query) };\n };\n\n const client = async () => {\n apiClient ??= import(\"/api-client.mjs\");\n return apiClient;\n };\n\n const apiJson = async (path, options = {}) => {\n const { apiFetch } = await client();\n const headers = new Headers(options.headers || {});\n headers.set(\"accept\", \"application/json\");\n if (options.body && !headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n const response = await apiFetch(path, { ...options, headers });\n if (!response.ok) {\n const payload = await response.json().catch(() => ({}));\n const error = new Error(payload.error || `Request failed (${response.status})`);\n error.status = response.status;\n throw error;\n }\n return response.status === 204 ? null : response.json();\n };\n\n const apiBlob = async (path) => {\n const { apiFetch } = await client();\n const response = await apiFetch(path);\n if (!response.ok) {\n const payload = await response.json().catch(() => ({}));\n throw new Error(payload.error || `Download failed (${response.status})`);\n }\n return response.blob();\n };\n\n const showToast = (message) => {\n const toast = document.querySelector(\"#toast\");\n toast.textContent = message;\n toast.hidden = false;\n window.clearTimeout(showToast.timer);\n showToast.timer = window.setTimeout(() => {\n toast.hidden = true;\n }, 4500);\n };\n\n const dateValue = (value) => {\n if (!value) {\n return null;\n }\n const text = String(value);\n const date = new Date(text.includes(\"T\") ? text : `${text.replace(\" \", \"T\")}Z`);\n return Number.isNaN(date.getTime()) ? null : date;\n };\n\n const formatDate = (value) => {\n const date = dateValue(value);\n return date ? new Intl.DateTimeFormat(undefined, { dateStyle: \"medium\", timeStyle: \"short\" }).format(date) : \"—\";\n };\n\n const formatDuration = (milliseconds) => {\n const value = Number(milliseconds);\n if (!Number.isFinite(value) || value < 0) {\n return \"—\";\n }\n const seconds = Math.floor(value / 1000);\n return `${Math.floor(seconds / 60) ? `${Math.floor(seconds / 60)}m ` : \"\"}${seconds % 60}s`;\n };\n\n const workflowName = (workflowId) =>\n state.workflows.find((workflow) => workflow.id === workflowId)?.name || workflowId || \"Unknown workflow\";\n const pageHeading = (title, subtitle, action = \"\") =>\n `<div class=\"page-heading\"><div><h1>${escape(title)}</h1>${\n subtitle ? `<p class=\"subtitle\">${escape(subtitle)}</p>` : \"\"\n }</div>${action}</div>`;\n const runWorkflowButton = () =>\n `<button class=\"button primary\" data-action=\"new-run\">${icon(\"plus\")}Run workflow</button>`;\n const permissionMessage = (error, scope) =>\n [401, 403].includes(error?.status)\n ? `Your account needs the ${scope} permission to view this data.`\n : error?.message || \"The Flow API could not be reached.\";\n\n const setNavigation = (page, jobId = \"\") => {\n const currentJob = jobId ? state.jobs.find((job) => String(job.id) === String(jobId)) : null;\n const currentJobId = currentJob?.id ?? jobId;\n document.querySelector(\"#navigation\").innerHTML = [\n [\"runs\", \"Runs\", \"runs\"],\n [\"workflows\", \"Workflows\", \"workflow\"],\n [\"workers\", \"Workers\", \"server\"],\n [\"settings\", \"Settings\", \"settings\"],\n ]\n .map(\n ([id, title, name]) =>\n `<a href=\"#/${id}\" class=\"nav-item ${page === id ? \"active\" : \"\"}\" ${\n page === id ? 'aria-current=\"page\"' : \"\"\n }>${icon(name)}<span class=\"nav-label\">${title}</span>${\n id === \"runs\"\n ? `<span class=\"nav-count\">${\n state.jobs.filter((job) => [\"running\", \"pending\"].includes(job.status)).length\n }</span>`\n : \"\"\n }</a>`\n )\n .join(\"\");\n document.querySelector(\"#breadcrumbs\").innerHTML = `${\n jobId\n ? `<a href=\"#/runs\">Runs</a><span class=\"divider\">/</span><strong class=\"mono\">#${escape(\n currentJobId\n )}</strong>`\n : `<strong>${escape(page[0].toUpperCase() + page.slice(1))}</strong>`\n }`;\n document.title = `${jobId ? `Run #${currentJobId}` : page[0].toUpperCase() + page.slice(1)} · Flow`;\n };\n const setBreadcrumbs = (page, jobId = null) => setNavigation(page, jobId);\n\n const loadJobs = async ({ older = false } = {}) => {\n if (jobsLoadPromise) {\n if (!older) {\n refreshJobsAfterCurrentLoad = true;\n }\n await jobsLoadPromise;\n if (refreshJobsAfterCurrentLoad) {\n refreshJobsAfterCurrentLoad = false;\n await loadJobs();\n }\n return;\n }\n state.loadingJobs = true;\n const beforeId = older && state.jobs.length ? Math.min(...state.jobs.map((job) => Number(job.id))) : null;\n jobsLoadPromise = (async () => {\n try {\n const query = new URLSearchParams({ limit: older ? \"50\" : \"100\" });\n if (beforeId) {\n query.set(\"beforeId\", String(beforeId));\n }\n if (state.workflow !== \"all\") {\n query.set(\"workflowId\", state.workflow);\n }\n const data = await apiJson(`/api/jobs?${query}`);\n if (older) {\n const merged = new Map(state.jobs.map((job) => [String(job.id), job]));\n for (const job of data.jobs || []) {\n merged.set(String(job.id), job);\n }\n state.jobs = [...merged.values()].sort((a, b) => Number(b.id) - Number(a.id));\n } else {\n state.jobs = data.jobs || [];\n }\n state.hasMore = Boolean(data.hasMore);\n state.errors.jobs = null;\n state.jobsLoaded = true;\n } catch (error) {\n state.errors.jobs = error;\n state.jobsLoaded = true;\n } finally {\n state.loadingJobs = false;\n }\n })();\n try {\n await jobsLoadPromise;\n } finally {\n jobsLoadPromise = undefined;\n if (refreshJobsAfterCurrentLoad) {\n refreshJobsAfterCurrentLoad = false;\n await loadJobs();\n }\n }\n };\n\n const loadWorkflows = async ({ retry = false } = {}) => {\n if (state.workflowsLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/workflows\");\n state.workflows = data.workflows || [];\n state.errors.workflows = null;\n } catch (error) {\n state.errors.workflows = error;\n } finally {\n state.workflowsLoaded = true;\n }\n };\n\n const loadWorkers = async ({ retry = false } = {}) => {\n if (state.workersLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/workers\");\n state.workers = data.workers || [];\n state.errors.workers = null;\n } catch (error) {\n state.errors.workers = error;\n } finally {\n state.workersLoaded = true;\n }\n };\n\n const loadSecrets = async ({ retry = false } = {}) => {\n if (state.secretsLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/secrets\");\n state.secrets = data.secrets || [];\n state.errors.secrets = null;\n } catch (error) {\n state.errors.secrets = error;\n } finally {\n state.secretsLoaded = true;\n }\n };\n\n const workflowFormSource = () => {\n const source = document.getElementById(\"new-workflow-source\")?.value.trim() || \"\";\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n if (!source || !name) {\n return source;\n }\n const yamlName = `name: ${JSON.stringify(name)}`;\n return /^name\\s*:/m.test(source) ? source.replace(/^name\\s*:[^\\r\\n]*/m, yamlName) : `${yamlName}\\n${source}`;\n };\n\n const filteredJobs = () =>\n state.jobs.filter((job) => {\n const text = [job.id, job.workflowId, workflowName(job.workflowId), job.status, job.workerId]\n .join(\" \")\n .toLowerCase();\n return (\n text.includes(state.search.trim().toLowerCase()) &&\n (state.status === \"all\" ||\n (state.status === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === state.status)) &&\n (state.workflow === \"all\" || job.workflowId === state.workflow) &&\n (state.worker === \"all\" || (state.worker === \"queued\" ? !job.workerId : job.workerId === state.worker))\n );\n });\n\n const renderRunsTable = () => {\n const filtered = filteredJobs();\n const pages = Math.max(1, Math.ceil(filtered.length / 8));\n state.page = Math.min(state.page, pages);\n const rows = filtered.slice((state.page - 1) * 8, state.page * 8);\n const target = document.querySelector(\"#run-results\");\n if (!target) {\n return;\n }\n target.innerHTML = `<div class=\"filter-tabs\" role=\"group\" aria-label=\"Filter by run status\">${[\n [\"all\", \"All runs\"],\n [\"running\", \"In progress\"],\n [\"failed\", \"Failed\"],\n [\"success\", \"Passed\"],\n ]\n .map(\n ([value, label]) =>\n `<button class=\"filter-tab ${\n state.status === value ? \"active\" : \"\"\n }\" data-action=\"status-filter\" data-value=\"${value}\" aria-pressed=\"${\n state.status === value\n }\">${label}<span class=\"count\">${\n state.jobs.filter(\n (job) =>\n value === \"all\" ||\n (value === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === value)\n ).length\n }</span></button>`\n )\n .join(\"\")}</div>\n ${\n rows.length\n ? `<table class=\"run-table\"><caption class=\"sr-only\">Live Flow runs, newest first</caption><thead><tr><th scope=\"col\">Workflow / run</th><th scope=\"col\">Status</th><th scope=\"col\">Worker</th><th scope=\"col\">Updated</th><th scope=\"col\">Created</th></tr></thead><tbody>${rows\n .map(\n (job) =>\n `<tr><td><a class=\"run-link\" href=\"#/runs/${encodeURIComponent(job.id)}\" aria-label=\"Run ${escape(\n job.id\n )}: ${escape(workflowName(job.workflowId))}, ${escape(\n labels[job.status] || job.status\n )}\">${statusIcon(job.status)}<span class=\"run-copy\"><span class=\"run-title\">${escape(\n workflowName(job.workflowId)\n )}</span><span class=\"run-subtitle\"><span class=\"mono\">#${escape(\n job.id\n )}</span><span class=\"separator\">·</span><span class=\"mono\">${escape(\n job.workflowId\n )}</span></span></span></a></td><td>${badge(job.status)}</td><td><span class=\"branch\">${icon(\n \"server\"\n )}${escape(job.workerId || \"Queued\")}</span></td><td>${escape(\n formatDate(job.updatedAt)\n )}</td><td>${escape(formatDate(job.createdAt))}</td></tr>`\n )\n .join(\"\")}</tbody></table>`\n : `<div class=\"empty-state\">${icon(\"search\")}<h3>${\n state.errors.jobs ? \"Runs unavailable\" : \"No runs found\"\n }</h3><p>${escape(state.errors.jobs?.message || \"Try a different search or filter.\")}</p>${\n state.errors.jobs\n ? '<button class=\"button\" data-action=\"retry-runs\">Retry</button>'\n : '<button class=\"button\" data-action=\"clear-filters\">Clear filters</button>'\n }</div>`\n }\n <div class=\"table-footer\"><div class=\"pagination\"><button class=\"button\" data-action=\"prev-page\" ${\n state.page === 1 ? \"disabled\" : \"\"\n }>${icon(\"back\")}Previous</button><button class=\"button\" data-action=\"next-page\" ${\n state.page === pages && !state.hasMore ? \"disabled\" : \"\"\n }>Next${icon(\"arrow\")}</button></div></div>${\n state.hasMore ? '<div class=\"table-hint\">Older Flow runs are available.</div>' : \"\"\n }`;\n };\n\n const renderRuns = () => {\n setBreadcrumbs(\"runs\");\n main.innerHTML = `${pageHeading(\"Runs\", \"\", runWorkflowButton())}${\n state.loadingJobs && !state.jobs.length ? '<div class=\"empty-state\"><p>Loading runs…</p></div>' : \"\"\n }<section aria-labelledby=\"recent-runs\"><div class=\"section-heading\"><h2 id=\"recent-runs\">Recent runs</h2></div><div class=\"filters\"><label class=\"search-box\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search runs</span><input id=\"run-search\" type=\"search\" value=\"${escape(\n state.search\n )}\" placeholder=\"Search runs, workflows, or workers…\" autocomplete=\"off\"/><kbd aria-hidden=\"true\">/</kbd></label><select id=\"workflow-filter\" class=\"select-filter\" aria-label=\"Filter by workflow\"><option value=\"all\">All workflows</option>${state.workflows\n .map(\n (workflow) =>\n `<option value=\"${escape(workflow.id)}\" ${state.workflow === workflow.id ? \"selected\" : \"\"}>${escape(\n workflow.name\n )}</option>`\n )\n .join(\n \"\"\n )}</select><select id=\"worker-filter\" class=\"select-filter\" aria-label=\"Filter by worker\"><option value=\"all\">All workers</option><option value=\"queued\" ${\n state.worker === \"queued\" ? \"selected\" : \"\"\n }>Queued</option>${[...new Set(state.jobs.map((job) => job.workerId).filter(Boolean))]\n .map(\n (worker) =>\n `<option value=\"${escape(worker)}\" ${state.worker === worker ? \"selected\" : \"\"}>${escape(worker)}</option>`\n )\n .join(\"\")}</select></div><div id=\"run-results\"></div></section>`;\n renderRunsTable();\n };\n\n const renderRunDetails = (run) => {\n setBreadcrumbs(\"runs\", run.jobId);\n const inputs = run.inputs || {};\n const failedStep = (run.steps || []).find((step) => step.status === \"failed\");\n main.innerHTML = `<a href=\"#/runs\" class=\"back-link\">${icon(\"back\")}All runs</a>\n <div class=\"page-heading run-heading\"><div><h1>${iconForStatus(run.status)}${escape(\n run.workflowName || workflowName(run.workflowId)\n )}</h1><p class=\"subtitle\"><span class=\"mono\">#${escape(run.jobId)}</span><span>·</span><span>${escape(\n inputs.full_name || inputs.repo || run.workflowId || \"Workflow run\"\n )}</span></p></div>\n <div class=\"heading-actions\">${\n run.canViewLogs\n ? `<button class=\"button\" data-action=\"download-logs\">${icon(\"download\")}Download logs</button>`\n : \"\"\n }<button class=\"button ${[\"running\", \"pending\"].includes(run.status) ? \"danger\" : \"primary\"}\" data-action=\"${\n [\"running\", \"pending\"].includes(run.status) ? \"cancel\" : \"rerun\"\n }\" ${state.mutating ? \"disabled\" : \"\"}>${icon([\"running\", \"pending\"].includes(run.status) ? \"stop\" : \"refresh\")}${\n [\"running\", \"pending\"].includes(run.status) ? \"Cancel run\" : \"Re-run workflow\"\n }</button></div></div>\n ${\n failedStep\n ? `<div class=\"failure-banner\">${icon(\"alert\")}<div><strong>${escape(failedStep.name || \"Step\")} failed${\n failedStep.exitCode !== undefined ? ` with exit code ${escape(failedStep.exitCode)}` : \"\"\n }</strong><p>${escape(\n failedStep.error || \"See the failed step output.\"\n )}</p></div><button class=\"text-button\" data-action=\"jump-error\">Jump to error ${icon(\n \"arrow\"\n )}</button></div>`\n : \"\"\n }\n <section class=\"detail-meta\" aria-label=\"Run metadata\"><div><span class=\"meta-label\">STATUS</span><span class=\"meta-value\">${badge(\n run.status\n )}</span></div><div><span class=\"meta-label\">BRANCH</span><span class=\"meta-value\">${icon(\"branch\")}${escape(\n inputs.branch || inputs.ref?.replace(/^refs\\/heads\\//, \"\") || \"—\"\n )}</span></div><div><span class=\"meta-label\">COMMIT</span><span class=\"meta-value mono\">${icon(\"commit\")}${escape(\n (inputs.commit_sha || inputs.commit || \"—\").slice(0, 8)\n )}</span></div><div><span class=\"meta-label\">DURATION</span><span class=\"meta-value\">${icon(\n \"clock\"\n )}${formatDuration(\n run.durationMs\n )}</span></div><div><span class=\"meta-label\">WORKER</span><span class=\"meta-value\">${icon(\"server\")}${escape(\n run.workerId || \"Queued\"\n )}</span></div></section>\n <div class=\"detail-tabs\" role=\"group\" aria-label=\"Run detail views\">${[\n [\"logs\", \"Logs & steps\", \"terminal\"],\n [\"inputs\", \"Inputs\", \"code\"],\n [\"artifacts\", \"Artifacts\", \"box\"],\n [\"history\", \"History\", \"history\"],\n ]\n .map(\n ([value, title, symbol]) =>\n `<button data-action=\"detail-tab\" data-value=\"${value}\" class=\"${\n state.detailTab === value ? \"active\" : \"\"\n }\" aria-pressed=\"${state.detailTab === value}\">${icon(symbol)}${title}</button>`\n )\n .join(\"\")}</div>\n <div id=\"detail-content\"></div>`;\n void renderRunTab(run);\n };\n\n const liveRunPage = async (id) => {\n main.innerHTML = `<div class=\"empty-state\">${icon(\"clock\")}<p>Loading run #${escape(id)}…</p></div>`;\n try {\n const run = await apiJson(`/api/runs/${encodeURIComponent(id)}`);\n state.errors.run = null;\n state.run = run;\n const importantStep = run.steps?.findIndex((step) => [\"failed\", \"running\"].includes(step.status)) ?? -1;\n if (importantStep >= 0) {\n state.step = importantStep;\n }\n renderRunDetails(run);\n } catch (error) {\n state.errors.run = error;\n main.innerHTML = `<div class=\"empty-state\">${icon(\"alert\")}<h1>Run unavailable</h1><p>${escape(\n error.message\n )}</p><a class=\"button\" href=\"#/runs\">Back to runs</a></div>`;\n }\n };\n\n const renderRunTab = async (run) => {\n const content = document.querySelector(\"#detail-content\");\n if (!content) {\n return;\n }\n if (state.detailTab === \"inputs\") {\n let source = run.workflowSourceYaml || \"\";\n if (!source && run.workflowId) {\n try {\n const workflow = await apiJson(\n `/api/workflows/${encodeURIComponent(run.workflowId)}?revision=${encodeURIComponent(\n run.workflowRevision || \"\"\n )}`\n );\n source = workflow.sourceYaml || \"\";\n } catch {\n /* Workflow source has separate permission checks. */\n }\n }\n content.innerHTML = `<section class=\"source-view\"><h3>Trigger inputs</h3><pre>${escape(\n JSON.stringify(run.inputs || {}, null, 2)\n )}</pre></section>${\n source\n ? `<section class=\"source-view\" style=\"margin-top:16px\"><h3>Workflow source · revision ${escape(\n run.workflowRevision || \"—\"\n )}</h3><pre>${escape(source)}</pre></section>`\n : '<p class=\"settings-note\" style=\"margin-top:14px\">Workflow source is unavailable for this run.</p>'\n }`;\n return;\n }\n if (state.detailTab === \"artifacts\") {\n const artifacts = run.artifacts || [];\n content.innerHTML = artifacts.length\n ? `<section class=\"settings-section\"><h2>Run artifacts <span class=\"count\">${\n artifacts.length\n }</span></h2>${artifacts\n .map(\n (path, index) =>\n `<div class=\"setting-row\"><div><strong class=\"mono\">${escape(\n path.split(\"/\").pop() || path\n )}</strong><p>${escape(\n path\n )}</p></div><button class=\"button\" data-action=\"download-artifact\" data-value=\"${index}\">${icon(\n \"download\"\n )}Download</button></div>`\n )\n .join(\"\")}</section>`\n : '<div class=\"empty-state\"><h3>No artifacts</h3><p>This run did not publish any artifacts.</p></div>';\n return;\n }\n if (state.detailTab === \"history\") {\n const history = await loadRunHistory(run);\n content.innerHTML =\n history.length > 1\n ? `<section class=\"settings-section\"><h2>Execution history</h2>${history\n .map(\n (entry) =>\n `<div class=\"setting-row\"><div><strong><a href=\"#/runs/${encodeURIComponent(\n entry.jobId\n )}\">Run #${escape(entry.jobId)}</a></strong><p>${escape(\n entry.workflowName || entry.workflowId || \"Workflow\"\n )} · ${formatDate(entry.startedAt)}</p></div>${badge(entry.status)}</div>`\n )\n .join(\"\")}</section>`\n : '<div class=\"empty-state\"><h3>No previous attempts</h3><p>This run has no parent run linked to it.</p></div>';\n return;\n }\n const steps = run.steps || [];\n if (!steps.length) {\n content.innerHTML =\n '<div class=\"empty-state\"><h3>No execution steps yet</h3><p>Step details will appear when Flow records them.</p></div>';\n return;\n }\n state.step = Math.max(0, Math.min(state.step, steps.length - 1));\n const selected = steps[state.step];\n const stepButtons = steps\n .map(\n (step, index) =>\n `<button class=\"step-button ${\n state.step === index ? \"active\" : \"\"\n }\" data-action=\"step\" data-value=\"${index}\" aria-pressed=\"${state.step === index}\" aria-label=\"${escape(\n step.name\n )}, ${escape(labels[step.status] || step.status)}\">${iconForStatus(step.status)}<span>${escape(\n step.name\n )}</span><span class=\"mono\">${formatDuration(step.durationMs)}</span></button>`\n )\n .join(\"\");\n if (!run.canViewLogs) {\n content.innerHTML = `<section class=\"execution\" aria-label=\"Execution steps and logs\"><aside class=\"steps-panel\" aria-label=\"Execution steps\"><div class=\"steps-heading\"><span>Execution steps</span><span class=\"mono\">${\n steps.filter((step) => step.status === \"success\").length\n } / ${steps.length}</span></div>${stepButtons}</aside><div class=\"log-panel\"><div class=\"empty-state\">${icon(\n \"lock\"\n )}<h3>Logs require permission</h3><p>Your account needs <span class=\"mono\">logs:read</span> to view step output.</p></div></div></section>`;\n return;\n }\n content.innerHTML = `<section class=\"execution\" aria-label=\"Execution steps and logs\"><aside class=\"steps-panel\" aria-label=\"Execution steps\"><div class=\"steps-heading\"><span>Execution steps</span><span class=\"mono\">${\n steps.filter((step) => step.status === \"success\").length\n } / ${steps.length}</span></div>${steps\n .map(\n (step, index) =>\n `<button class=\"step-button ${\n state.step === index ? \"active\" : \"\"\n }\" data-action=\"step\" data-value=\"${index}\" aria-pressed=\"${state.step === index}\" aria-label=\"${escape(\n step.name\n )}, ${escape(labels[step.status] || step.status)}\">${iconForStatus(step.status)}<span>${escape(\n step.name\n )}</span><span class=\"mono\">${formatDuration(step.durationMs)}</span></button>`\n )\n .join(\"\")}</aside><div class=\"log-panel\"><div class=\"log-heading\"><h3>${escape(selected.name)}<small>${\n selected.exitCode !== undefined\n ? `exit code ${escape(selected.exitCode)}`\n : escape(labels[selected.status] || selected.status)\n }</small></h3><div class=\"log-actions\"><button class=\"icon-button\" data-action=\"wrap\" aria-label=\"Wrap log lines\" aria-pressed=\"${\n state.wrap\n }\">${icon(\"wrap\")}</button><button class=\"icon-button\" data-action=\"copy-log\" aria-label=\"Copy step log\">${icon(\n \"copy\"\n )}</button></div></div><label class=\"log-search\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search step log</span><input id=\"log-search\" type=\"search\" placeholder=\"Find in this step…\" value=\"${escape(\n state.logSearch\n )}\"/><span id=\"log-matches\"></span></label><div class=\"log-lines ${\n state.wrap ? \"wrap\" : \"\"\n }\" id=\"log-lines\" tabindex=\"0\" aria-label=\"Step log output\"></div><div class=\"log-bottom\"><span>${\n selected.status === \"failed\"\n ? \"Step failed\"\n : selected.status === \"running\"\n ? \"Step is running\"\n : \"End of step output\"\n }</span><span class=\"mono\">UTF-8</span></div></div></section>`;\n renderLog(selected.logContent || \"\");\n };\n\n const loadRunHistory = async (run) => {\n const entries = [run];\n const visited = new Set([String(run.jobId)]);\n let parent = run.parentId;\n while (parent && !visited.has(String(parent)) && entries.length < 25) {\n visited.add(String(parent));\n try {\n const entry = await apiJson(`/api/runs/${encodeURIComponent(parent)}`);\n entries.push(entry);\n parent = entry.parentId;\n } catch {\n break;\n }\n }\n return entries;\n };\n\n const renderLog = (text) => {\n const log = document.querySelector(\"#log-lines\");\n if (!log) {\n return;\n }\n const query = state.logSearch.toLowerCase();\n let matches = 0;\n log.innerHTML = String(text)\n .split(\"\\n\")\n .map((line, index) => {\n let content = escape(line) || \" \";\n const start = query ? line.toLowerCase().indexOf(query) : -1;\n if (start >= 0) {\n matches++;\n content = `${escape(line.slice(0, start))}<mark>${escape(\n line.slice(start, start + query.length)\n )}</mark>${escape(line.slice(start + query.length))}`;\n }\n const type = /\\b(error|failed|fatal)\\b/i.test(line)\n ? \"error\"\n : /\\b(success|passed|done)\\b/i.test(line)\n ? \"pass\"\n : \"\";\n return `<div class=\"log-line ${type}\"><span class=\"line-number\">${\n index + 1\n }</span><span class=\"line-text\">${content}</span></div>`;\n })\n .join(\"\");\n const result = document.querySelector(\"#log-matches\");\n if (result) {\n result.textContent = query ? `${matches} matching lines` : \"\";\n }\n };\n const renderLiveLog = renderLog;\n\n const downloadBlob = (blob, filename) => {\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = filename;\n anchor.click();\n window.setTimeout(() => URL.revokeObjectURL(url), 1000);\n };\n\n const renderRevisionDiff = (previousSource, currentSource) => {\n const before = previousSource === null ? [] : previousSource.split(\"\\n\");\n const after = String(currentSource ?? \"\").split(\"\\n\");\n let prefixLength = 0;\n while (\n prefixLength < before.length &&\n prefixLength < after.length &&\n before[prefixLength] === after[prefixLength]\n ) {\n prefixLength++;\n }\n let suffixLength = 0;\n while (\n suffixLength < before.length - prefixLength &&\n suffixLength < after.length - prefixLength &&\n before[before.length - suffixLength - 1] === after[after.length - suffixLength - 1]\n ) {\n suffixLength++;\n }\n const renderLines = (lines, className, prefix) =>\n lines\n .filter((line, index) => line || index < lines.length - 1)\n .map((line) => `<span class=\"block ${className}\">${prefix}${escape(line)}</span>`)\n .join(\"\");\n return [\n renderLines(before.slice(0, prefixLength), \"text-gray-300\", \" \"),\n renderLines(before.slice(prefixLength, before.length - suffixLength), \"text-rose-300 bg-rose-500/10\", \"- \"),\n renderLines(after.slice(prefixLength, after.length - suffixLength), \"text-emerald-300 bg-emerald-500/10\", \"+ \"),\n renderLines(before.slice(before.length - suffixLength), \"text-gray-300\", \" \"),\n ].join(\"\");\n };\n\n const loadRevisionDiff = async (workflowId, revision) => {\n try {\n const current = await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}?revision=${revision}`);\n const previous =\n revision > 1\n ? await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}?revision=${revision - 1}`)\n : null;\n const sourceDiff = renderRevisionDiff(previous?.sourceYaml ?? null, current.sourceYaml);\n openDialog(\n `Workflow ${current.name || current.id}`,\n `<p class=\"dialog-description\">${escape(current.name || current.id)} — Revision ${escape(\n current.revision\n )}</p><div class=\"source-view\"><pre>${sourceDiff}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`\n );\n } catch (error) {\n showToast(`Failed to load revision diff: ${error.message}`);\n }\n };\n\n const liveWorkflowsPage = async () => {\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n setBreadcrumbs(\"workflows\");\n const tabs = [\n { id: \"all\", label: \"All\", filter: () => true },\n { id: \"published\", label: \"Published\", filter: (workflow) => workflow.status === \"published\" },\n { id: \"draft\", label: \"Drafts\", filter: (workflow) => workflow.status === \"draft\" },\n { id: \"archived\", label: \"Archived\", filter: (workflow) => workflow.status === \"archived\" },\n ];\n const selectedTab = tabs.find((tab) => tab.id === state.workflowFilter) || tabs[0];\n const visibleWorkflows = state.workflows.filter(selectedTab.filter);\n const tabButtons = tabs\n .map((tab) => {\n const count = state.workflows.filter(tab.filter).length;\n return `<button class=\"status-tab ${\n state.workflowFilter === tab.id ? \"active\" : \"\"\n }\" data-action=\"workflow-status-tab\" data-value=\"${tab.id}\" aria-pressed=\"${state.workflowFilter === tab.id}\">${\n tab.label\n }<span class=\"count\">${count}</span></button>`;\n })\n .join(\"\");\n const workflowRows = visibleWorkflows\n .map((workflow) => {\n const index = state.workflows.indexOf(workflow);\n return `<div class=\"workflow-item\"><div class=\"workflow-header\"><span class=\"workflow-icon\">${icon(\n \"workflow\"\n )}</span><span class=\"workflow-name\">${escape(\n workflow.name || workflow.id\n )}</span><span class=\"workflow-status ${\n workflow.status === \"published\" ? \"success\" : workflow.status === \"archived\" ? \"disabled\" : \"enabled\"\n }\">${escape(workflow.status)}</span></div><div class=\"workflow-meta\"><span>Revision ${escape(\n workflow.revision || \"—\"\n )}</span><span class=\"workflow-id mono\">${escape(\n workflow.id\n )}</span></div><div class=\"workflow-actions\"><a class=\"text-button\" href=\"#/runs?workflow=${encodeURIComponent(\n workflow.id\n )}\">View runs ${icon(\"arrow\")}</a><a class=\"button\" href=\"/settings/workflows/${encodeURIComponent(\n workflow.id\n )}\" target=\"_top\">Edit ${icon(\n \"arrow\"\n )}</a><button class=\"button\" data-action=\"workflow-source\" data-value=\"${index}\">${icon(\n \"code\"\n )}Source</button>${\n workflow.revision > 1\n ? `<button class=\"button small\" data-action=\"workflow-revision\" data-value=\"${index}\" data-dir=\"-1\">Previous revision</button>`\n : \"\"\n }${\n workflow.status !== \"published\"\n ? `<button class=\"button small success\" data-action=\"workflow-publish\" data-value=\"${index}\">Publish ${icon(\n \"rocket\"\n )}</button>`\n : \"\"\n }</div></div>`;\n })\n .join(\"\");\n const content =\n workflowRows ||\n `<div class=\"empty-state\"><h3>No ${escape(selectedTab.label.toLowerCase())} workflows</h3><p>${\n state.workflows.length\n ? \"Choose another status tab or create a workflow.\"\n : \"Flow has no workflow definitions to show.\"\n }</p></div>`;\n main.innerHTML = `${pageHeading(\"Workflows\", \"Workflow definitions\", runWorkflowButton())}${\n state.errors.workflows\n ? `<div class=\"empty-state\">${icon(\"lock\")}<h3>Workflows unavailable</h3><p>${escape(\n permissionMessage(state.errors.workflows, \"workflows:read\")\n )}</p><button class=\"button\" data-action=\"retry-workflows\">Retry</button></div>`\n : `<div class=\"status-tabs\" role=\"group\" aria-label=\"Filter workflows by status\">${tabButtons}</div><div class=\"workflow-list\">${content}</div>`\n }`;\n };\n\n const liveWorkersPage = async () => {\n if (!state.workersLoaded) {\n await loadWorkers();\n }\n setBreadcrumbs(\"workers\");\n main.innerHTML = `${pageHeading(\"Workers\", \"Live runner presence from Flow.\")}${\n state.errors.workers\n ? `<div class=\"empty-state\">${icon(\"lock\")}<h3>Worker status unavailable</h3><p>${escape(\n permissionMessage(state.errors.workers, \"workers:read\")\n )}</p><button class=\"button\" data-action=\"retry-workers\">Retry</button></div>`\n : `<div class=\"cards\">${\n state.workers\n .map(\n (worker) =>\n `<article class=\"worker-card\"><div class=\"card-heading\">${icon(\"server\")}<h2>${escape(\n worker.workerId || \"Unknown worker\"\n )}</h2><span class=\"status ${worker.online ? \"success\" : \"cancelled\"}\">${\n worker.online ? \"Online\" : \"Offline\"\n }</span></div><div class=\"card-details\">${\n (Array.isArray(worker.tags) ? worker.tags : [])\n .map((tag) => `<span class=\"tag mono\">${escape(tag)}</span>`)\n .join(\"\") || '<span class=\"settings-note\">No labels</span>'\n }</div><div class=\"worker-stats\"><div><span class=\"meta-label\">Active jobs</span><strong>${escape(\n worker.activeJobs ?? 0\n )}</strong></div><div><span class=\"meta-label\">Concurrency</span><strong>${escape(\n worker.concurrency ?? \"—\"\n )}</strong></div></div><div class=\"card-footer\"><span class=\"settings-note\">${\n worker.version ? `Runner ${escape(worker.version)}` : \"Runner version unavailable\"\n }</span><span class=\"settings-note\">Seen ${escape(\n formatDate(worker.lastSeen)\n )}</span></div></article>`\n )\n .join(\"\") ||\n '<div class=\"empty-state\"><h3>No workers reported</h3><p>Flow has no worker-presence records.</p></div>'\n }</div>`\n }`;\n };\n\n const liveSettingsPage = async () => {\n if (!state.secretsLoaded) {\n await loadSecrets();\n }\n setBreadcrumbs(\"settings\");\n main.innerHTML = `${pageHeading(\n \"Settings\",\n \"Preferences and secret names from Flow.\"\n )}<section class=\"settings-section\"><h2>Preferences</h2><label class=\"setting-row\"><span><strong>Compact run list</strong><span class=\"settings-note\">Fit more activity on your screen.</span></span><input type=\"checkbox\" id=\"compact-setting\" ${\n localStorage.getItem(\"flow-concept-compact\") === \"true\" ? \"checked\" : \"\"\n }/></label><label class=\"setting-row\"><span><strong>Wrap log lines</strong><span class=\"settings-note\">Keep long output within the log viewer.</span></span><input type=\"checkbox\" id=\"wrap-setting\" ${\n state.wrap ? \"checked\" : \"\"\n }/></label></section><section class=\"settings-section\"><h2>${icon(\n \"lock\"\n )} Secret names</h2><p class=\"settings-note\">Values are never requested or displayed in this preview.</p>${\n state.errors.secrets\n ? `<p class=\"settings-note\">${escape(\n permissionMessage(state.errors.secrets, \"secrets:read\")\n )}</p><button class=\"button\" data-action=\"retry-secrets\">Retry</button>`\n : state.secrets\n .map(\n (name) =>\n `<div class=\"setting-row\"><strong class=\"mono\">${escape(\n name\n )}</strong><span class=\"secret-value\" aria-label=\"Secret value hidden\">••••••••••••</span><button class=\"button tiny danger\" data-action=\"remove-secret\" data-value=\"${name}\">Remove</button></div>`\n )\n .join(\"\") || '<p class=\"settings-note\">No secret names are configured.</p>'\n }${\n !state.errors.secrets && state.secrets.length < 5\n ? '<div class=\"setting-row\"><button class=\"button tiny\" data-action=\"add-secret\">Add secret</button></div>'\n : \"\"\n }</section><section class=\"settings-section\"><h2>Workflow editing</h2><p class=\"settings-note\">Create and edit workflow definitions.</p><form id=\"new-workflow-form\"><label class=\"form-field\"><span>Workflow name</span><input id=\"new-workflow-name\" type=\"text\" placeholder=\"e.g. build-docker-image\" required/></label><label class=\"form-field\"><span>Workflow ID</span><input id=\"new-workflow-id\" type=\"text\" placeholder=\"derived-from-name\" required/></label><label class=\"form-field\"><span>Enabled</span><input type=\"checkbox\" id=\"new-workflow-enabled\" checked/></label><div class=\"form-field\"><span>YAML source</span><textarea id=\"new-workflow-source\" rows=\"8\" placeholder=\"name: build-docker-image\\nruns-on: [main-server, docker]\\n\\nsteps:\\n - name: Checkout repository\\n run: git checkout '$COMMIT'\\n - name: Install dependencies\\n run: pnpm install --frozen-lockfile\\n - name: Run tests\\n run: pnpm test\\n - name: Build and push\\n run: pnpm publish\"></textarea></label></div><div class=\"form-footer\"><button type=\"button\" class=\"button\" data-action=\"validate-workflow\">Validate workflow</button><button type=\"button\" class=\"button\" data-action=\"run-now\">Run now</button><button type=\"submit\" class=\"button primary\">${icon(\n \"plus\"\n )}Create workflow</button><button type=\"button\" class=\"button\" data-action=\"close-dialog\">Cancel</button></div></form><a class=\"button secondary\" href=\"/settings/workflows\" target=\"_top\">Open full workflow settings ${icon(\n \"arrow\"\n )}</a></section>`;\n };\n\n const showNotFound = () => {\n setBreadcrumbs(\"runs\");\n main.innerHTML = `<div class=\"empty-state\"><h1>404 — Page not found</h1><p>This preview route does not exist.</p><a class=\"button\" href=\"#/runs\">Go to runs</a></div>`;\n };\n\n const openDialog = (title, content) => {\n dialog.innerHTML = `<div class=\"dialog-heading\"><h2 id=\"dialog-title\">${escape(\n title\n )}</h2><button class=\"icon-button\" data-action=\"close-dialog\" aria-label=\"Close dialog\">${icon(\n \"close\"\n )}</button></div>${content}`;\n if (!dialog.open) {\n dialog.showModal();\n }\n };\n\n const openDispatchDialog = async () => {\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n if (state.errors.workflows) {\n showToast(permissionMessage(state.errors.workflows, \"workflows:read\"));\n return;\n }\n openDialog(\n \"Run a workflow\",\n `<p class=\"dialog-description\">This will enqueue a real Flow job.</p><label class=\"form-field\"><span>Workflow</span><select id=\"dispatch-workflow\">${state.workflows\n .map(\n (workflow) =>\n `<option value=\"${escape(workflow.id)}\" ${workflow.enabled === false ? \"disabled\" : \"\"}>${escape(\n workflow.name || workflow.id\n )}${workflow.enabled === false ? \" (disabled)\" : \"\"}</option>`\n )\n .join(\n \"\"\n )}</select></label><label class=\"form-field\"><span>Inputs (JSON object)</span><textarea id=\"dispatch-inputs\" rows=\"6\">{}</textarea></label><p class=\"dialog-note\">Running a workflow requires write permission and creates real job(s).</p><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Cancel</button><button class=\"button primary\" data-action=\"dispatch-confirm\">${icon(\n \"runs\"\n )}Review and run</button></div>`\n );\n };\n\n const dispatchWorkflow = async () => {\n if (state.mutating) {\n return;\n }\n const workflowId = document.querySelector(\"#dispatch-workflow\")?.value;\n if (!workflowId) {\n showToast(\"Select a workflow first.\");\n return;\n }\n let inputs;\n try {\n inputs = JSON.parse(document.querySelector(\"#dispatch-inputs\")?.value || \"{}\");\n if (!inputs || typeof inputs !== \"object\" || Array.isArray(inputs)) {\n throw new Error(\"Inputs must be a JSON object.\");\n }\n } catch (error) {\n showToast(error.message);\n return;\n }\n const workflow = state.workflows.find((item) => item.id === workflowId);\n if (workflow?.enabled === false) {\n showToast(\"This workflow is disabled and cannot be run.\");\n return;\n }\n state.mutating = true;\n const dispatchButton = document.querySelector('[data-action=\"dispatch-confirm\"]');\n if (dispatchButton) {\n dispatchButton.disabled = true;\n }\n if (!window.confirm(`Run ${workflow?.name || workflowId} now? This creates real job(s).`)) {\n state.mutating = false;\n if (dispatchButton) {\n dispatchButton.disabled = false;\n }\n return;\n }\n try {\n const result = await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}/run`, {\n method: \"POST\",\n body: JSON.stringify({ inputs }),\n });\n dialog.close();\n state.jobs = [];\n state.jobsLoaded = false;\n await loadJobs();\n showToast(`Queued ${result.jobs?.length || 0} real job(s).`);\n if (result.jobs?.length) {\n window.location.hash = `/runs/${result.jobs[0]}`;\n }\n } catch (error) {\n showToast(`Could not run workflow: ${error.message}`);\n } finally {\n state.mutating = false;\n if (dispatchButton) {\n dispatchButton.disabled = false;\n }\n }\n };\n\n const render = async () => {\n const { path, query } = route();\n const [section, id] = path.split(\"/\").slice(1);\n const page = section || \"runs\";\n const changed = previousRoute !== window.location.hash;\n if (changed) {\n state.step = 0;\n state.detailTab = \"logs\";\n state.logSearch = \"\";\n if (section !== \"runs\" || !id) {\n state.run = null;\n }\n }\n previousRoute = window.location.hash;\n setNavigation(page === \"runs\" ? \"runs\" : [\"workflows\", \"workers\", \"settings\"].includes(page) ? page : \"runs\", id);\n if (path === \"/runs\" || path === \"\") {\n const workflowFilter = query.get(\"workflow\") || \"all\";\n const searchFilter = query.get(\"search\") || \"\";\n if (workflowFilter !== state.workflow || searchFilter !== state.search) {\n state.jobs = [];\n state.jobsLoaded = false;\n state.hasMore = false;\n }\n state.workflow = workflowFilter;\n state.search = searchFilter;\n const statusFilter = query.get(\"status\");\n if ([\"all\", \"running\", \"failed\", \"success\"].includes(statusFilter)) {\n state.status = statusFilter;\n }\n if (!state.jobsLoaded) {\n await loadJobs();\n }\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n renderRuns();\n if (changed) {\n window.scrollTo(0, 0);\n }\n return;\n }\n if (section === \"runs\" && id) {\n await liveRunPage(id);\n return;\n }\n if (path === \"/workflows\") {\n await liveWorkflowsPage();\n return;\n }\n if (path === \"/workers\") {\n await liveWorkersPage();\n return;\n }\n if (path === \"/settings\") {\n await liveSettingsPage();\n return;\n }\n showNotFound();\n };\n\n const action = async (event) => {\n const button = event.target.closest(\"[data-action]\");\n if (!button) {\n return false;\n }\n const { action: name, value } = button.dataset;\n if (name === \"new-run\") {\n await openDispatchDialog();\n return true;\n }\n if (name === \"close-dialog\") {\n dialog.close();\n return true;\n }\n if (name === \"dispatch-confirm\") {\n await dispatchWorkflow();\n return true;\n }\n if (name === \"retry-runs\") {\n state.jobsLoaded = false;\n state.errors.jobs = null;\n await loadJobs();\n renderRuns();\n return true;\n }\n if (name === \"retry-workflows\") {\n state.workflowsLoaded = false;\n state.errors.workflows = null;\n await liveWorkflowsPage();\n return true;\n }\n if (name === \"workflow-status-tab\") {\n state.workflowFilter = value;\n render();\n return true;\n }\n if (name === \"retry-workers\") {\n state.workersLoaded = false;\n state.errors.workers = null;\n await liveWorkersPage();\n return true;\n }\n if (name === \"retry-secrets\") {\n state.secretsLoaded = false;\n state.errors.secrets = null;\n await liveSettingsPage();\n return true;\n }\n if (name === \"add-secret\") {\n const name = window.prompt(\"Enter secret name (uppercase letters, numbers, underscores):\");\n if (!name) {\n return true;\n }\n if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {\n showToast(\"Secret name must start with a letter and contain only letters, numbers, and underscores.\");\n return true;\n }\n const secretValue = window.prompt(`Enter the value for ${name}:`);\n if (secretValue === null || secretValue.length === 0) {\n showToast(\"Secret value cannot be empty.\");\n return true;\n }\n try {\n await apiJson(`/api/secrets/${name}`, {\n method: \"PUT\",\n body: JSON.stringify({ value: secretValue, encoding: \"utf8\" }),\n });\n showToast(`Secret ${name} added.`);\n state.secretsLoaded = false;\n await loadSecrets();\n await liveSettingsPage();\n } catch (error) {\n showToast(`Failed to add secret: ${error.message}`);\n }\n return true;\n }\n if (name === \"remove-secret\") {\n const name = value;\n if (!window.confirm(`Delete secret ${name}?`)) {\n return true;\n }\n try {\n await apiJson(`/api/secrets/${name}`, { method: \"DELETE\" });\n showToast(`Secret ${name} removed.`);\n state.secretsLoaded = false;\n await loadSecrets();\n await liveSettingsPage();\n } catch (error) {\n showToast(`Failed to remove secret: ${error.message}`);\n }\n return true;\n }\n if (name === \"status-filter\") {\n state.status = value;\n state.page = 1;\n renderRunsTable();\n button.focus({ preventScroll: true });\n return true;\n }\n if (name === \"clear-filters\") {\n state.search = \"\";\n state.status = \"all\";\n state.workflow = \"all\";\n state.worker = \"all\";\n state.page = 1;\n renderRuns();\n document.querySelector(\"#run-search\")?.focus();\n return true;\n }\n if (name === \"prev-page\" || name === \"next-page\") {\n const pages = Math.max(1, Math.ceil(filteredJobs().length / 8));\n if (name === \"next-page\" && state.page === pages && state.hasMore) {\n await loadJobs({ older: true });\n }\n state.page = Math.max(1, state.page + (name === \"next-page\" ? 1 : -1));\n renderRunsTable();\n return true;\n }\n if (name === \"detail-tab\" && state.run) {\n state.detailTab = value;\n await renderRunTab(state.run);\n return true;\n }\n if (name === \"step\" && state.run) {\n state.step = Number(value);\n await renderRunTab(state.run);\n return true;\n }\n if (name === \"jump-error\" && state.run) {\n const failed = state.run.steps?.findIndex((step) => step.status === \"failed\") ?? -1;\n if (failed >= 0) {\n state.step = failed;\n await renderRunTab(state.run);\n }\n return true;\n }\n if (name === \"wrap\") {\n state.wrap = !state.wrap;\n document.querySelector(\"#log-lines\")?.classList.toggle(\"wrap\", state.wrap);\n button.setAttribute(\"aria-pressed\", String(state.wrap));\n return true;\n }\n if (name === \"copy-log\" && state.run) {\n try {\n await navigator.clipboard.writeText(state.run.steps?.[state.step]?.logContent || \"\");\n showToast(\"Step log copied.\");\n } catch {\n showToast(\"Clipboard unavailable.\");\n }\n return true;\n }\n if (name === \"download-logs\" && state.run) {\n const text = (state.run.steps || []).map((step) => `=== ${step.name} ===\\n${step.logContent || \"\"}`).join(\"\\n\\n\");\n downloadBlob(new Blob([text], { type: \"text/plain;charset=utf-8\" }), `flow-run-${state.run.jobId}.log`);\n return true;\n }\n if (name === \"download-artifact\" && state.run) {\n const artifact = state.run.artifacts?.[Number(value)];\n if (!artifact) {\n return true;\n }\n const encoded = String(artifact).split(\"/\").map(encodeURIComponent).join(\"/\");\n try {\n const blob = await apiBlob(`/api/runs/${encodeURIComponent(state.run.jobId)}/artifacts/${encoded}`);\n downloadBlob(blob, String(artifact).split(\"/\").pop() || \"artifact\");\n } catch (error) {\n showToast(`Download failed: ${error.message}`);\n }\n return true;\n }\n if (name === \"workflow-source\") {\n const workflow = state.workflows[Number(value)];\n if (workflow) {\n openDialog(\n workflow.name || workflow.id,\n `<p class=\"dialog-description\">Revision ${escape(workflow.revision || \"—\")} · ${escape(\n workflow.status || \"\"\n )}</p><div class=\"source-view\"><pre>${escape(\n workflow.sourceYaml || \"Workflow source unavailable.\"\n )}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`\n );\n }\n return true;\n }\n if (name === \"validate-workflow\") {\n const source = workflowFormSource();\n if (!source) {\n showToast(\"Please enter workflow source YAML first.\");\n return true;\n }\n apiJson(`/api/workflows/validate`, {\n method: \"POST\",\n body: JSON.stringify({ sourceYaml: source }),\n })\n .then((result) => {\n const status = result.valid ? \"passed\" : \"failed\";\n showToast(`Validation ${status}: ${result.error || \"\"}`);\n })\n .catch((error) => {\n showToast(`Validation failed: ${error.message}`);\n });\n return true;\n }\n if (name === \"run-now\") {\n const id = document.getElementById(\"new-workflow-id\")?.value.trim();\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n const source = workflowFormSource();\n const enabled = document.getElementById(\"new-workflow-enabled\")?.checked;\n if (!id || !name) {\n showToast(\"Please enter a workflow name and ID first.\");\n return true;\n }\n if (!source) {\n showToast(\"Please enter workflow source YAML first.\");\n return true;\n }\n if (!/^[a-z0-9-]+$/.test(id)) {\n showToast(\"Workflow ID can only contain lowercase letters, numbers, and hyphens.\");\n return true;\n }\n if (!enabled) {\n showToast(\"Enable the workflow before running it.\");\n return true;\n }\n void (async () => {\n try {\n await apiJson(`/api/workflows/${encodeURIComponent(id)}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source, enabled }),\n });\n const result = await apiJson(`/api/workflows/${encodeURIComponent(id)}/run`, {\n method: \"POST\",\n body: JSON.stringify({}),\n });\n showToast(\n `Queued ${result.jobs?.length || 0} job${result.jobs?.length === 1 ? \"\" : \"s\"} from revision ${\n result.revision\n }.`\n );\n } catch (error) {\n showToast(`Failed to run workflow: ${error.message}`);\n }\n })();\n return true;\n }\n if (name === \"workflow-revision\") {\n const workflow = state.workflows[Number(value)];\n if (!workflow) {\n return true;\n }\n const dir = Number(button.dataset.dir);\n const targetRev = (workflow.revision || 1) + dir;\n if (targetRev < 1) {\n return true;\n }\n loadRevisionDiff(workflow.id, targetRev);\n return true;\n }\n if (name === \"workflow-publish\") {\n const workflow = state.workflows[Number(value)];\n if (!workflow) {\n return true;\n }\n if (!window.confirm(`Publish workflow ${workflow.name || workflow.id}? This will make it available for runs.`)) {\n return true;\n }\n apiJson(`/api/workflows/${workflow.id}/publish`, { method: \"POST\" })\n .then((result) => {\n showToast(`Published ${result.id || workflow.id} revision ${result.revision}.`);\n state.workflowsLoaded = false;\n void loadWorkflows();\n render();\n })\n .catch((error) => {\n showToast(`Publish failed: ${error.message}`);\n });\n return true;\n }\n if (name === \"rerun\" && state.run) {\n if (state.mutating) {\n return true;\n }\n state.mutating = true;\n button.disabled = true;\n if (\n !window.confirm(\n `Re-run ${\n state.run.workflowName || state.run.workflowId || \"this workflow\"\n }? This creates a new job from the active workflow revision.`\n )\n ) {\n state.mutating = false;\n button.disabled = false;\n return true;\n }\n try {\n const result = await apiJson(`/restart/${encodeURIComponent(state.run.jobId)}`, {\n method: \"POST\",\n body: JSON.stringify({}),\n });\n state.jobsLoaded = false;\n await loadJobs();\n showToast(`Re-run queued: #${result.id}`);\n window.location.hash = `/runs/${result.id}`;\n } catch (error) {\n showToast(`Re-run failed: ${error.message}`);\n } finally {\n state.mutating = false;\n button.disabled = false;\n }\n return true;\n }\n if (name === \"cancel\" && state.run) {\n if (state.mutating) {\n return true;\n }\n if (\n ![\"pending\", \"running\"].includes(state.run.status) ||\n !window.confirm(`Cancel live run #${state.run.jobId}?`)\n ) {\n return true;\n }\n state.mutating = true;\n button.disabled = true;\n try {\n await apiJson(`/api/jobs/${encodeURIComponent(state.run.jobId)}/cancel`, { method: \"POST\" });\n showToast(`Run #${state.run.jobId} cancelled.`);\n await liveRunPage(state.run.jobId);\n state.jobsLoaded = false;\n await loadJobs();\n } catch (error) {\n showToast(`Cancel failed: ${error.message}`);\n } finally {\n state.mutating = false;\n button.disabled = false;\n }\n return true;\n }\n return false;\n };\n\n const setupEvents = () => {\n if (events) {\n return;\n }\n events = new EventSource(\"/api/events\");\n events.addEventListener(\"jobs.available\", () => {\n state.jobsLoaded = false;\n void loadJobs().then(() => {\n if (route().path === \"/runs\") {\n renderRuns();\n }\n });\n });\n events.addEventListener(\"jobs.changed\", (event) => {\n let data = {};\n try {\n data = JSON.parse(event.data || \"{}\");\n } catch {\n /* Refresh the list even if an event payload is malformed. */\n }\n state.jobsLoaded = false;\n void loadJobs().then(() => {\n if (route().path === \"/runs\") {\n renderRuns();\n }\n });\n if (state.run && (!data.jobId || String(data.jobId) === String(state.run.jobId))) {\n void liveRunPage(state.run.jobId);\n }\n });\n events.onerror = () => {\n /* EventSource retries transient disconnects automatically. */\n };\n };\n\n document.addEventListener(\"click\", async (event) => {\n const handled = await action(event);\n if (handled) {\n event.preventDefault();\n }\n });\n document.addEventListener(\"submit\", (event) => {\n if (event.target?.id === \"new-workflow-form\") {\n event.preventDefault();\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n const id = document.getElementById(\"new-workflow-id\")?.value.trim().toLowerCase();\n const enabled = document.getElementById(\"new-workflow-enabled\")?.checked;\n const source = workflowFormSource();\n if (!name || !id || !source) {\n showToast(\"Please fill in all fields (name, id, and source).\");\n return;\n }\n if (!/^[a-z0-9-]+$/.test(id)) {\n showToast(\"Workflow ID can only contain lowercase letters, numbers, and hyphens.\");\n return;\n }\n apiJson(`/api/workflows/${encodeURIComponent(id)}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source, enabled }),\n })\n .then(() => {\n showToast(`Created workflow ${id}.`);\n state.workflowsLoaded = false;\n void loadWorkflows();\n render();\n })\n .catch((error) => {\n showToast(`Failed to create workflow: ${error.message}`);\n });\n }\n });\n document.addEventListener(\"input\", (event) => {\n if (event.target.id === \"run-search\") {\n state.search = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"log-search\") {\n state.logSearch = event.target.value;\n if (state.run) {\n renderLiveLog(state.run.steps?.[state.step]?.logContent || \"\");\n }\n }\n });\n document.addEventListener(\"change\", (event) => {\n if (event.target.id === \"workflow-filter\") {\n state.workflow = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"worker-filter\") {\n state.worker = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"compact-setting\") {\n document.body.classList.toggle(\"compact\", event.target.checked);\n localStorage.setItem(\"flow-concept-compact\", String(event.target.checked));\n }\n if (event.target.id === \"wrap-setting\") {\n state.wrap = event.target.checked;\n localStorage.setItem(\"flow-concept-wrap\", String(event.target.checked));\n }\n });\n window.addEventListener(\"hashchange\", () => {\n state.run = null;\n void render();\n });\n window.addEventListener(\"beforeunload\", () => {\n events?.close();\n });\n document.querySelectorAll(\"[data-icon]\").forEach((node) => {\n node.innerHTML = icon(node.dataset.icon);\n });\n document.body.classList.toggle(\"compact\", localStorage.getItem(\"flow-concept-compact\") === \"true\");\n state.wrap = localStorage.getItem(\"flow-concept-wrap\") === \"true\";\n const initialQuery = route().query;\n state.workflow = initialQuery.get(\"workflow\") || \"all\";\n state.search = initialQuery.get(\"search\") || \"\";\n if ([\"all\", \"running\", \"failed\", \"success\"].includes(initialQuery.get(\"status\"))) {\n state.status = initialQuery.get(\"status\");\n }\n main.innerHTML = `${pageHeading(\"Runs\", \"Runs\")}<div class=\"empty-state\">${icon(\"clock\")}<p>Loading runs…</p></div>`;\n setupEvents();\n void Promise.all([loadWorkflows(), loadJobs()]).then(() => {\n state.jobsLoaded = true;\n void render();\n });\n}\n", _g = 50, vg = 500, yg = class e {
12375
12403
  server;
12376
12404
  preprocessors = /* @__PURE__ */ new Map();
12377
12405
  workflows = new d();
@@ -12384,6 +12412,7 @@ var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigat
12384
12412
  workerToken;
12385
12413
  oidc;
12386
12414
  oidcUsers = new i();
12415
+ userPreferences = new rg();
12387
12416
  events = new u();
12388
12417
  workflowsLoaded;
12389
12418
  static async withPort(t) {
@@ -12395,7 +12424,8 @@ var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigat
12395
12424
  this.workflows.init(),
12396
12425
  this.secretRepository.init(),
12397
12426
  this.apiKeys.init(),
12398
- this.oidcUsers.init()
12427
+ this.oidcUsers.init(),
12428
+ this.userPreferences.init()
12399
12429
  ]).then(() => void 0), this.registerPreprocessor(new ie()), this.server = m.createServer((e, t) => this.handleRequest(e, t));
12400
12430
  }
12401
12431
  registerPreprocessor(e) {
@@ -12414,7 +12444,7 @@ var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigat
12414
12444
  "Cache-Control": "no-store",
12415
12445
  "Content-Type": "text/html; charset=utf-8",
12416
12446
  "X-Robots-Tag": "noindex, nofollow"
12417
- }), t.end(mg);
12447
+ }), t.end(hg);
12418
12448
  if (e.method === "GET" && n.pathname === "/manifest.webmanifest") return t.writeHead(200, {
12419
12449
  "Cache-Control": "public, max-age=3600",
12420
12450
  "Content-Type": "application/manifest+json"
@@ -12426,7 +12456,7 @@ var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigat
12426
12456
  if (e.method === "GET" && n.pathname === "/preview-live.mjs") return t.writeHead(200, {
12427
12457
  "Cache-Control": "no-cache",
12428
12458
  "Content-Type": "text/javascript; charset=utf-8"
12429
- }), t.end(hg);
12459
+ }), t.end(gg);
12430
12460
  if (e.method === "GET" && n.pathname === "/service-worker.js") return t.writeHead(200, {
12431
12461
  "Cache-Control": "no-cache",
12432
12462
  "Content-Type": "text/javascript; charset=utf-8"
@@ -12439,31 +12469,31 @@ var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigat
12439
12469
  if (e.method === "GET" && n.pathname === "/api-client.mjs") return t.writeHead(200, {
12440
12470
  "Cache-Control": "no-cache",
12441
12471
  "Content-Type": "text/javascript; charset=utf-8"
12442
- }), t.end(rg);
12472
+ }), t.end(ig);
12443
12473
  if (e.method === "GET" && n.pathname === "/app-header.mjs") return t.writeHead(200, {
12444
12474
  "Cache-Control": "no-cache",
12445
12475
  "Content-Type": "text/javascript; charset=utf-8"
12446
- }), t.end(ig);
12476
+ }), t.end(ag);
12447
12477
  if (e.method === "GET" && n.pathname === "/app-router.mjs") return t.writeHead(200, {
12448
12478
  "Cache-Control": "no-cache",
12449
12479
  "Content-Type": "text/javascript; charset=utf-8"
12450
- }), t.end(ag);
12480
+ }), t.end(og);
12451
12481
  if (e.method === "GET" && (n.pathname === "/dashboard.mjs" || n.pathname === "/pages/dashboard.mjs")) return t.writeHead(200, {
12452
12482
  "Cache-Control": "no-cache",
12453
12483
  "Content-Type": "text/javascript; charset=utf-8"
12454
- }), t.end(og);
12484
+ }), t.end(sg);
12455
12485
  if (e.method === "GET" && (n.pathname === "/run.mjs" || n.pathname === "/pages/run.mjs")) return t.writeHead(200, {
12456
12486
  "Cache-Control": "no-cache",
12457
12487
  "Content-Type": "text/javascript; charset=utf-8"
12458
- }), t.end(sg);
12488
+ }), t.end(cg);
12459
12489
  if (e.method === "GET" && (n.pathname === "/settings-ui.mjs" || n.pathname === "/pages/settings-ui.mjs")) return t.writeHead(200, {
12460
12490
  "Cache-Control": "no-cache",
12461
12491
  "Content-Type": "text/javascript; charset=utf-8"
12462
- }), t.end(cg);
12492
+ }), t.end(lg);
12463
12493
  if (e.method === "GET" && (n.pathname === "/workflows-ui.mjs" || n.pathname === "/pages/workflows-ui.mjs")) return t.writeHead(200, {
12464
12494
  "Cache-Control": "no-cache",
12465
12495
  "Content-Type": "text/javascript; charset=utf-8"
12466
- }), t.end(lg);
12496
+ }), t.end(ug);
12467
12497
  if (e.method === "GET" && n.pathname === "/app-header.html") return t.writeHead(200, {
12468
12498
  "Content-Type": "text/html; charset=utf-8",
12469
12499
  "Cache-Control": "no-store"
@@ -12524,6 +12554,32 @@ var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigat
12524
12554
  } else if (!this.requireAdmin(e, t)) return;
12525
12555
  return this.renderAppShell(t);
12526
12556
  }
12557
+ if (n.pathname === "/api/preferences" && e.method === "GET") {
12558
+ if (!this.requireAuthenticatedUser(e, t)) return;
12559
+ let n = this.oidc?.userFromCookie(e.headers.cookie);
12560
+ return n ? (t.writeHead(200, {
12561
+ "Cache-Control": "no-store",
12562
+ "Content-Type": "application/json; charset=utf-8"
12563
+ }), t.end(JSON.stringify({ timezone: await this.userPreferences.getTimezone(n.id) }))) : void 0;
12564
+ }
12565
+ if (n.pathname === "/api/preferences" && e.method === "PUT") {
12566
+ if (!this.requireAuthenticatedUser(e, t)) return;
12567
+ let n = this.oidc?.userFromCookie(e.headers.cookie);
12568
+ if (!n) return;
12569
+ let r = await this.readJson(e, t);
12570
+ if (!r || t.headersSent) return;
12571
+ let i = typeof r.timezone == "string" ? r.timezone.trim() : "";
12572
+ if (!i || i.length > 100) return t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "timezone must be a valid IANA timezone" }));
12573
+ try {
12574
+ new Intl.DateTimeFormat("en-US", { timeZone: i });
12575
+ } catch {
12576
+ return t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "timezone must be a valid IANA timezone" }));
12577
+ }
12578
+ return await this.userPreferences.setTimezone(n.id, i), t.writeHead(200, {
12579
+ "Cache-Control": "no-store",
12580
+ "Content-Type": "application/json; charset=utf-8"
12581
+ }), t.end(JSON.stringify({ timezone: i }));
12582
+ }
12527
12583
  if (e.method === "GET" && n.pathname === "/api/users") return this.requireAdminSession(e, t) ? (await this.workflowsLoaded, t.writeHead(200, {
12528
12584
  "Cache-Control": "no-store",
12529
12585
  "Content-Type": "application/json; charset=utf-8"
@@ -12595,8 +12651,8 @@ var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigat
12595
12651
  return t.writeHead(302, { Location: `${i}${n.search}` }), t.end();
12596
12652
  }
12597
12653
  if (e.method === "GET" && n.pathname === "/api/jobs") {
12598
- 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 ? gg : Number(i);
12599
- 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 > _g ? (t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: `limit must be an integer from 1 to ${_g}` }))) : 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);
12654
+ 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 ? _g : Number(i);
12655
+ 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 > vg ? (t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: `limit must be an integer from 1 to ${vg}` }))) : 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);
12600
12656
  }
12601
12657
  let l = n.pathname.match(/^\/api\/dispatch\/([a-z0-9-]+)$/);
12602
12658
  if (e.method === "POST" && l) return this.handleDispatch(e, t, l[1]);
@@ -13295,4 +13351,4 @@ var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigat
13295
13351
  }
13296
13352
  };
13297
13353
  //#endregion
13298
- export { vg as WebhookServer };
13354
+ export { yg as WebhookServer };