lastlight 0.16.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -692
- package/dist/cli-config.js.map +1 -0
- package/dist/cli-format.js.map +1 -0
- package/dist/{cli/cli-server.d.ts → cli-server.d.ts} +26 -1
- package/dist/{cli/cli-server.js → cli-server.js} +40 -5
- package/dist/cli-server.js.map +1 -0
- package/dist/cli-timeline.js.map +1 -0
- package/dist/{cli/cli.js → cli.js} +4 -3
- package/dist/cli.js.map +1 -0
- package/dist/{cli/fork-cli.d.ts → fork-cli.d.ts} +3 -3
- package/dist/{cli/fork-cli.js → fork-cli.js} +28 -27
- package/dist/fork-cli.js.map +1 -0
- package/dist/{cli/oauth-cli.js → oauth-cli.js} +1 -1
- package/dist/oauth-cli.js.map +1 -0
- package/dist/{cli/setup.js → setup.js} +10 -4
- package/dist/setup.js.map +1 -0
- package/dist/{cli/skills-install.d.ts → skills-install.d.ts} +6 -0
- package/dist/{cli/skills-install.js → skills-install.js} +6 -2
- package/dist/skills-install.js.map +1 -0
- package/package.json +20 -65
- package/plugins/lastlight/.claude-plugin/plugin.json +1 -1
- package/agent-context/rules.md +0 -80
- package/agent-context/security.md +0 -69
- package/agent-context/soul.md +0 -21
- package/config/default.yaml +0 -119
- package/deploy/.env.production.example +0 -100
- package/deploy/entrypoint.sh +0 -82
- package/deploy/native/README.md +0 -160
- package/deploy/native/install.sh +0 -110
- package/deploy/native/lastlight.env.example +0 -95
- package/deploy/native/lastlight.service +0 -50
- package/deploy/sandbox-entrypoint.sh +0 -93
- package/dist/admin/auth.d.ts +0 -51
- package/dist/admin/auth.js +0 -121
- package/dist/admin/auth.js.map +0 -1
- package/dist/admin/chat-session-reader.d.ts +0 -39
- package/dist/admin/chat-session-reader.js +0 -84
- package/dist/admin/chat-session-reader.js.map +0 -1
- package/dist/admin/docker.d.ts +0 -61
- package/dist/admin/docker.js +0 -187
- package/dist/admin/docker.js.map +0 -1
- package/dist/admin/index.d.ts +0 -9
- package/dist/admin/index.js +0 -59
- package/dist/admin/index.js.map +0 -1
- package/dist/admin/routes.d.ts +0 -81
- package/dist/admin/routes.js +0 -1381
- package/dist/admin/routes.js.map +0 -1
- package/dist/admin/sessions.d.ts +0 -89
- package/dist/admin/sessions.js +0 -204
- package/dist/admin/sessions.js.map +0 -1
- package/dist/admin/tail.d.ts +0 -12
- package/dist/admin/tail.js +0 -145
- package/dist/admin/tail.js.map +0 -1
- package/dist/admin/version.d.ts +0 -22
- package/dist/admin/version.js +0 -92
- package/dist/admin/version.js.map +0 -1
- package/dist/cli/cli-config.js.map +0 -1
- package/dist/cli/cli-format.js.map +0 -1
- package/dist/cli/cli-server.js.map +0 -1
- package/dist/cli/cli-timeline.js.map +0 -1
- package/dist/cli/cli.js.map +0 -1
- package/dist/cli/fork-cli.js.map +0 -1
- package/dist/cli/oauth-cli.js.map +0 -1
- package/dist/cli/setup.js.map +0 -1
- package/dist/cli/skills-install.js.map +0 -1
- package/dist/config/config-resolve.d.ts +0 -28
- package/dist/config/config-resolve.js +0 -47
- package/dist/config/config-resolve.js.map +0 -1
- package/dist/config/config.d.ts +0 -157
- package/dist/config/config.js +0 -581
- package/dist/config/config.js.map +0 -1
- package/dist/config/core-pin.d.ts +0 -16
- package/dist/config/core-pin.js +0 -85
- package/dist/config/core-pin.js.map +0 -1
- package/dist/config/overlay-assets.d.ts +0 -20
- package/dist/config/overlay-assets.js +0 -114
- package/dist/config/overlay-assets.js.map +0 -1
- package/dist/config/overlay-bootstrap.d.ts +0 -44
- package/dist/config/overlay-bootstrap.js +0 -304
- package/dist/config/overlay-bootstrap.js.map +0 -1
- package/dist/connectors/github-webhook.d.ts +0 -45
- package/dist/connectors/github-webhook.js +0 -321
- package/dist/connectors/github-webhook.js.map +0 -1
- package/dist/connectors/index.d.ts +0 -18
- package/dist/connectors/index.js +0 -41
- package/dist/connectors/index.js.map +0 -1
- package/dist/connectors/messaging/base.d.ts +0 -42
- package/dist/connectors/messaging/base.js +0 -139
- package/dist/connectors/messaging/base.js.map +0 -1
- package/dist/connectors/messaging/delivery.d.ts +0 -14
- package/dist/connectors/messaging/delivery.js +0 -38
- package/dist/connectors/messaging/delivery.js.map +0 -1
- package/dist/connectors/messaging/index.d.ts +0 -4
- package/dist/connectors/messaging/index.js +0 -4
- package/dist/connectors/messaging/index.js.map +0 -1
- package/dist/connectors/messaging/session-manager.d.ts +0 -53
- package/dist/connectors/messaging/session-manager.js +0 -255
- package/dist/connectors/messaging/session-manager.js.map +0 -1
- package/dist/connectors/messaging/types.d.ts +0 -58
- package/dist/connectors/messaging/types.js +0 -7
- package/dist/connectors/messaging/types.js.map +0 -1
- package/dist/connectors/slack/connector.d.ts +0 -142
- package/dist/connectors/slack/connector.js +0 -494
- package/dist/connectors/slack/connector.js.map +0 -1
- package/dist/connectors/slack/index.d.ts +0 -2
- package/dist/connectors/slack/index.js +0 -3
- package/dist/connectors/slack/index.js.map +0 -1
- package/dist/connectors/slack/mrkdwn.d.ts +0 -19
- package/dist/connectors/slack/mrkdwn.js +0 -240
- package/dist/connectors/slack/mrkdwn.js.map +0 -1
- package/dist/connectors/types.d.ts +0 -61
- package/dist/connectors/types.js +0 -2
- package/dist/connectors/types.js.map +0 -1
- package/dist/cron/fanout.d.ts +0 -33
- package/dist/cron/fanout.js +0 -55
- package/dist/cron/fanout.js.map +0 -1
- package/dist/cron/jobs.d.ts +0 -18
- package/dist/cron/jobs.js +0 -37
- package/dist/cron/jobs.js.map +0 -1
- package/dist/cron/scheduler.d.ts +0 -45
- package/dist/cron/scheduler.js +0 -105
- package/dist/cron/scheduler.js.map +0 -1
- package/dist/engine/agent-executor.d.ts +0 -34
- package/dist/engine/agent-executor.js +0 -246
- package/dist/engine/agent-executor.js.map +0 -1
- package/dist/engine/chat/chat-runner.d.ts +0 -132
- package/dist/engine/chat/chat-runner.js +0 -424
- package/dist/engine/chat/chat-runner.js.map +0 -1
- package/dist/engine/chat/chat-skills.d.ts +0 -45
- package/dist/engine/chat/chat-skills.js +0 -184
- package/dist/engine/chat/chat-skills.js.map +0 -1
- package/dist/engine/chat/chat.d.ts +0 -54
- package/dist/engine/chat/chat.js +0 -316
- package/dist/engine/chat/chat.js.map +0 -1
- package/dist/engine/chat/message-batcher.d.ts +0 -32
- package/dist/engine/chat/message-batcher.js +0 -109
- package/dist/engine/chat/message-batcher.js.map +0 -1
- package/dist/engine/dispatcher.d.ts +0 -73
- package/dist/engine/dispatcher.js +0 -663
- package/dist/engine/dispatcher.js.map +0 -1
- package/dist/engine/event-shim.d.ts +0 -117
- package/dist/engine/event-shim.js +0 -435
- package/dist/engine/event-shim.js.map +0 -1
- package/dist/engine/executors/orchestrator.d.ts +0 -81
- package/dist/engine/executors/orchestrator.js +0 -365
- package/dist/engine/executors/orchestrator.js.map +0 -1
- package/dist/engine/executors/shared.d.ts +0 -189
- package/dist/engine/executors/shared.js +0 -616
- package/dist/engine/executors/shared.js.map +0 -1
- package/dist/engine/github/git-auth.d.ts +0 -62
- package/dist/engine/github/git-auth.js +0 -161
- package/dist/engine/github/git-auth.js.map +0 -1
- package/dist/engine/github/github-app-client.d.ts +0 -23
- package/dist/engine/github/github-app-client.js +0 -33
- package/dist/engine/github/github-app-client.js.map +0 -1
- package/dist/engine/github/github-tools.d.ts +0 -18
- package/dist/engine/github/github-tools.js +0 -263
- package/dist/engine/github/github-tools.js.map +0 -1
- package/dist/engine/github/github.d.ts +0 -1193
- package/dist/engine/github/github.js +0 -357
- package/dist/engine/github/github.js.map +0 -1
- package/dist/engine/github/profiles.d.ts +0 -279
- package/dist/engine/github/profiles.js +0 -42
- package/dist/engine/github/profiles.js.map +0 -1
- package/dist/engine/github/review-poster.d.ts +0 -98
- package/dist/engine/github/review-poster.js +0 -177
- package/dist/engine/github/review-poster.js.map +0 -1
- package/dist/engine/llm.d.ts +0 -79
- package/dist/engine/llm.js +0 -269
- package/dist/engine/llm.js.map +0 -1
- package/dist/engine/oauth.d.ts +0 -43
- package/dist/engine/oauth.js +0 -103
- package/dist/engine/oauth.js.map +0 -1
- package/dist/engine/router.d.ts +0 -29
- package/dist/engine/router.js +0 -680
- package/dist/engine/router.js.map +0 -1
- package/dist/engine/screen/classifier.d.ts +0 -93
- package/dist/engine/screen/classifier.js +0 -215
- package/dist/engine/screen/classifier.js.map +0 -1
- package/dist/engine/screen/screen.d.ts +0 -41
- package/dist/engine/screen/screen.js +0 -93
- package/dist/engine/screen/screen.js.map +0 -1
- package/dist/evals-api.d.ts +0 -24
- package/dist/evals-api.js +0 -22
- package/dist/evals-api.js.map +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -940
- package/dist/index.js.map +0 -1
- package/dist/managed-repos.d.ts +0 -30
- package/dist/managed-repos.js +0 -72
- package/dist/managed-repos.js.map +0 -1
- package/dist/notify/blocks.d.ts +0 -23
- package/dist/notify/blocks.js +0 -118
- package/dist/notify/blocks.js.map +0 -1
- package/dist/notify/index.d.ts +0 -17
- package/dist/notify/index.js +0 -7
- package/dist/notify/index.js.map +0 -1
- package/dist/notify/model.d.ts +0 -57
- package/dist/notify/model.js +0 -89
- package/dist/notify/model.js.map +0 -1
- package/dist/notify/notifier.d.ts +0 -18
- package/dist/notify/notifier.js +0 -100
- package/dist/notify/notifier.js.map +0 -1
- package/dist/notify/render.d.ts +0 -21
- package/dist/notify/render.js +0 -59
- package/dist/notify/render.js.map +0 -1
- package/dist/notify/transports/github.d.ts +0 -26
- package/dist/notify/transports/github.js +0 -27
- package/dist/notify/transports/github.js.map +0 -1
- package/dist/notify/transports/slack.d.ts +0 -27
- package/dist/notify/transports/slack.js +0 -37
- package/dist/notify/transports/slack.js.map +0 -1
- package/dist/notify/types.d.ts +0 -119
- package/dist/notify/types.js +0 -16
- package/dist/notify/types.js.map +0 -1
- package/dist/providers.d.ts +0 -125
- package/dist/providers.js +0 -279
- package/dist/providers.js.map +0 -1
- package/dist/sandbox/docker.d.ts +0 -180
- package/dist/sandbox/docker.js +0 -517
- package/dist/sandbox/docker.js.map +0 -1
- package/dist/sandbox/egress-allowlist.d.ts +0 -74
- package/dist/sandbox/egress-allowlist.js +0 -164
- package/dist/sandbox/egress-allowlist.js.map +0 -1
- package/dist/sandbox/egress-firewall-config.d.ts +0 -127
- package/dist/sandbox/egress-firewall-config.js +0 -434
- package/dist/sandbox/egress-firewall-config.js.map +0 -1
- package/dist/sandbox/images.d.ts +0 -23
- package/dist/sandbox/images.js +0 -55
- package/dist/sandbox/images.js.map +0 -1
- package/dist/sandbox/index.d.ts +0 -112
- package/dist/sandbox/index.js +0 -332
- package/dist/sandbox/index.js.map +0 -1
- package/dist/sandbox/sandbox.d.ts +0 -214
- package/dist/sandbox/sandbox.js +0 -453
- package/dist/sandbox/sandbox.js.map +0 -1
- package/dist/sandbox/smol.d.ts +0 -162
- package/dist/sandbox/smol.js +0 -485
- package/dist/sandbox/smol.js.map +0 -1
- package/dist/session-log.d.ts +0 -63
- package/dist/session-log.js +0 -290
- package/dist/session-log.js.map +0 -1
- package/dist/state/approval-store.d.ts +0 -88
- package/dist/state/approval-store.js +0 -149
- package/dist/state/approval-store.js.map +0 -1
- package/dist/state/build-assets.d.ts +0 -50
- package/dist/state/build-assets.js +0 -154
- package/dist/state/build-assets.js.map +0 -1
- package/dist/state/db.d.ts +0 -81
- package/dist/state/db.js +0 -155
- package/dist/state/db.js.map +0 -1
- package/dist/state/execution-store.d.ts +0 -257
- package/dist/state/execution-store.js +0 -527
- package/dist/state/execution-store.js.map +0 -1
- package/dist/state/migrate.d.ts +0 -15
- package/dist/state/migrate.js +0 -175
- package/dist/state/migrate.js.map +0 -1
- package/dist/state/workflow-run-store.d.ts +0 -216
- package/dist/state/workflow-run-store.js +0 -403
- package/dist/state/workflow-run-store.js.map +0 -1
- package/dist/telemetry/index.d.ts +0 -38
- package/dist/telemetry/index.js +0 -253
- package/dist/telemetry/index.js.map +0 -1
- package/dist/telemetry/pi-events.d.ts +0 -12
- package/dist/telemetry/pi-events.js +0 -134
- package/dist/telemetry/pi-events.js.map +0 -1
- package/dist/workflows/dag.d.ts +0 -37
- package/dist/workflows/dag.js +0 -118
- package/dist/workflows/dag.js.map +0 -1
- package/dist/workflows/loader.d.ts +0 -53
- package/dist/workflows/loader.js +0 -421
- package/dist/workflows/loader.js.map +0 -1
- package/dist/workflows/loop-eval.d.ts +0 -28
- package/dist/workflows/loop-eval.js +0 -74
- package/dist/workflows/loop-eval.js.map +0 -1
- package/dist/workflows/phase-executor.d.ts +0 -222
- package/dist/workflows/phase-executor.js +0 -1153
- package/dist/workflows/phase-executor.js.map +0 -1
- package/dist/workflows/phase-ref.d.ts +0 -50
- package/dist/workflows/phase-ref.js +0 -91
- package/dist/workflows/phase-ref.js.map +0 -1
- package/dist/workflows/resume.d.ts +0 -58
- package/dist/workflows/resume.js +0 -313
- package/dist/workflows/resume.js.map +0 -1
- package/dist/workflows/runner.d.ts +0 -63
- package/dist/workflows/runner.js +0 -381
- package/dist/workflows/runner.js.map +0 -1
- package/dist/workflows/schema.d.ts +0 -344
- package/dist/workflows/schema.js +0 -460
- package/dist/workflows/schema.js.map +0 -1
- package/dist/workflows/simple.d.ts +0 -90
- package/dist/workflows/simple.js +0 -393
- package/dist/workflows/simple.js.map +0 -1
- package/dist/workflows/target-policy.d.ts +0 -74
- package/dist/workflows/target-policy.js +0 -80
- package/dist/workflows/target-policy.js.map +0 -1
- package/dist/workflows/templates.d.ts +0 -88
- package/dist/workflows/templates.js +0 -127
- package/dist/workflows/templates.js.map +0 -1
- package/dist/workflows/triggers.d.ts +0 -21
- package/dist/workflows/triggers.js +0 -45
- package/dist/workflows/triggers.js.map +0 -1
- package/dist/workflows/verdict.d.ts +0 -19
- package/dist/workflows/verdict.js +0 -30
- package/dist/workflows/verdict.js.map +0 -1
- package/dist/worktree/manager.d.ts +0 -43
- package/dist/worktree/manager.js +0 -127
- package/dist/worktree/manager.js.map +0 -1
- package/docker-compose.yml +0 -274
- package/sandbox.Dockerfile +0 -68
- package/skills/README.md +0 -42
- package/skills/browser-qa/SKILL.md +0 -208
- package/skills/browser-qa/scripts/agent-browser.mjs +0 -531
- package/skills/building/SKILL.md +0 -95
- package/skills/chat/SKILL.md +0 -36
- package/skills/code-review/SKILL.md +0 -82
- package/skills/debug-production/SKILL.md +0 -108
- package/skills/demo/SKILL.md +0 -228
- package/skills/demo/scripts/compose-demo.sh +0 -243
- package/skills/issue-answer/SKILL.md +0 -91
- package/skills/issue-comment/SKILL.md +0 -49
- package/skills/issue-triage/SKILL.md +0 -149
- package/skills/issue-triage/references/AGENT-BRIEF.md +0 -65
- package/skills/pr-comment/SKILL.md +0 -64
- package/skills/pr-review/SKILL.md +0 -148
- package/skills/pr-review/references/findings-schema.md +0 -89
- package/skills/qa-test/SKILL.md +0 -97
- package/skills/repo-health/SKILL.md +0 -67
- package/skills/security-feedback/SKILL.md +0 -173
- package/skills/security-feedback/references/templates.md +0 -77
- package/skills/security-review/SKILL.md +0 -215
- package/skills/security-review/references/issue-format.md +0 -302
- package/skills/verify/SKILL.md +0 -118
- package/workflows/answer.yaml +0 -44
- package/workflows/build.yaml +0 -129
- package/workflows/cron-health.yaml +0 -6
- package/workflows/cron-review.yaml +0 -8
- package/workflows/cron-security.yaml +0 -6
- package/workflows/cron-triage.yaml +0 -8
- package/workflows/demo.yaml +0 -62
- package/workflows/examples/bash-smoke.yaml +0 -41
- package/workflows/examples/parallel-review.yaml +0 -36
- package/workflows/examples/tdd-loop.yaml +0 -17
- package/workflows/explore.yaml +0 -80
- package/workflows/issue-comment.yaml +0 -15
- package/workflows/issue-triage.yaml +0 -22
- package/workflows/pr-comment.yaml +0 -16
- package/workflows/pr-fix.yaml +0 -17
- package/workflows/pr-review.yaml +0 -50
- package/workflows/prompts/answer.md +0 -68
- package/workflows/prompts/architect.md +0 -44
- package/workflows/prompts/classifier.md +0 -60
- package/workflows/prompts/classify-adds-info.md +0 -17
- package/workflows/prompts/demo.md +0 -125
- package/workflows/prompts/executor.md +0 -42
- package/workflows/prompts/explore-ask.md +0 -99
- package/workflows/prompts/explore-publish.md +0 -31
- package/workflows/prompts/explore-read.md +0 -120
- package/workflows/prompts/explore-synthesize.md +0 -88
- package/workflows/prompts/fix.md +0 -18
- package/workflows/prompts/guardrails.md +0 -96
- package/workflows/prompts/pr-fix.md +0 -26
- package/workflows/prompts/pr.md +0 -39
- package/workflows/prompts/qa-browser.md +0 -95
- package/workflows/prompts/qa-synth.md +0 -44
- package/workflows/prompts/qa-test.md +0 -60
- package/workflows/prompts/re-reviewer.md +0 -33
- package/workflows/prompts/reviewer.md +0 -60
- package/workflows/prompts/verify-browser.md +0 -93
- package/workflows/prompts/verify-synth.md +0 -44
- package/workflows/prompts/verify.md +0 -63
- package/workflows/qa-test.yaml +0 -94
- package/workflows/repo-health.yaml +0 -15
- package/workflows/security-feedback.yaml +0 -25
- package/workflows/security-review.yaml +0 -37
- package/workflows/verify.yaml +0 -93
- /package/dist/{cli/cli-config.d.ts → cli-config.d.ts} +0 -0
- /package/dist/{cli/cli-config.js → cli-config.js} +0 -0
- /package/dist/{cli/cli-format.d.ts → cli-format.d.ts} +0 -0
- /package/dist/{cli/cli-format.js → cli-format.js} +0 -0
- /package/dist/{cli/cli-timeline.d.ts → cli-timeline.d.ts} +0 -0
- /package/dist/{cli/cli-timeline.js → cli-timeline.js} +0 -0
- /package/dist/{cli/cli.d.ts → cli.d.ts} +0 -0
- /package/dist/{cli/oauth-cli.d.ts → oauth-cli.d.ts} +0 -0
- /package/dist/{cli/setup.d.ts → setup.d.ts} +0 -0
package/dist/admin/routes.js
DELETED
|
@@ -1,1381 +0,0 @@
|
|
|
1
|
-
import { Hono } from "hono";
|
|
2
|
-
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
3
|
-
import { streamSSE } from "hono/streaming";
|
|
4
|
-
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
|
5
|
-
import { Slack, GitHub } from "arctic";
|
|
6
|
-
import { tailJsonl } from "./tail.js";
|
|
7
|
-
import { listRunningContainers, killContainer, getContainerStats, listServerContainers, resolveServerContainer, getContainerLogs, streamContainerLogs, } from "./docker.js";
|
|
8
|
-
import { authMiddleware, createToken, verifyTokenForRefresh, decodeToken } from "./auth.js";
|
|
9
|
-
import { Cron } from "croner";
|
|
10
|
-
import { enumerateOverlayAssets } from "../config/overlay-assets.js";
|
|
11
|
-
import { getCronWorkflows, getWorkflow, listAgentWorkflows, loadWorkflowYamlRaw, loadPromptTemplate, loadSkillRaw, } from "../workflows/loader.js";
|
|
12
|
-
import { getWorkflowTriggers, getWorkflowTriggerKinds, } from "../workflows/triggers.js";
|
|
13
|
-
import { getManagedRepos, getInstallationRepos, getInstallationReposRefreshedAt, } from "../managed-repos.js";
|
|
14
|
-
import { getRuntimeConfig } from "../config/config.js";
|
|
15
|
-
import { getServerVersion } from "./version.js";
|
|
16
|
-
import { BuildAssetStore, buildAssetIssueKey } from "../state/build-assets.js";
|
|
17
|
-
/**
|
|
18
|
-
* Map a build-asset filename extension to a binary MIME type, or null when the
|
|
19
|
-
* file should be served as text/plain (markdown handoff docs). Binary artifacts
|
|
20
|
-
* — PNG screenshot evidence from browser QA, and `/demo`'s mp4/webm video — must
|
|
21
|
-
* be served as raw bytes, not utf-8 text, so the dashboard can render them in an
|
|
22
|
-
* <img>/<video> and GitHub can embed them.
|
|
23
|
-
*/
|
|
24
|
-
export function binaryMimeForArtifact(name) {
|
|
25
|
-
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
26
|
-
switch (ext) {
|
|
27
|
-
case ".png":
|
|
28
|
-
return "image/png";
|
|
29
|
-
case ".jpg":
|
|
30
|
-
case ".jpeg":
|
|
31
|
-
return "image/jpeg";
|
|
32
|
-
case ".gif":
|
|
33
|
-
return "image/gif";
|
|
34
|
-
case ".webp":
|
|
35
|
-
return "image/webp";
|
|
36
|
-
case ".svg":
|
|
37
|
-
return "image/svg+xml";
|
|
38
|
-
case ".mp4":
|
|
39
|
-
return "video/mp4";
|
|
40
|
-
case ".webm":
|
|
41
|
-
return "video/webm";
|
|
42
|
-
default:
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Build an HTTP response for a binary artifact buffer, honoring a `Range`
|
|
48
|
-
* request so <video> elements can seek/stream (GitHub's inline player and
|
|
49
|
-
* browsers send `Range: bytes=…`; without 206 support, seeking — and some
|
|
50
|
-
* players — break). Artifacts are small (≤ a few MB), so we slice the in-memory
|
|
51
|
-
* buffer rather than streaming from disk. Returns 206 for a satisfiable range,
|
|
52
|
-
* 416 for an unsatisfiable one, else 200 with the full body. `Accept-Ranges:
|
|
53
|
-
* bytes` is always advertised.
|
|
54
|
-
*/
|
|
55
|
-
export function rangeResponse(rangeHeader, buf, mime, cacheControl) {
|
|
56
|
-
const total = buf.length;
|
|
57
|
-
const baseHeaders = {
|
|
58
|
-
"Content-Type": mime,
|
|
59
|
-
"Accept-Ranges": "bytes",
|
|
60
|
-
"Cache-Control": cacheControl,
|
|
61
|
-
};
|
|
62
|
-
const m = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()) : null;
|
|
63
|
-
if (m) {
|
|
64
|
-
let start = m[1] ? parseInt(m[1], 10) : 0;
|
|
65
|
-
let end = m[2] ? parseInt(m[2], 10) : total - 1;
|
|
66
|
-
if (Number.isNaN(start))
|
|
67
|
-
start = 0;
|
|
68
|
-
if (Number.isNaN(end) || end >= total)
|
|
69
|
-
end = total - 1;
|
|
70
|
-
if (start > end || start >= total) {
|
|
71
|
-
return new Response(null, {
|
|
72
|
-
status: 416,
|
|
73
|
-
headers: { ...baseHeaders, "Content-Range": `bytes */${total}` },
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
const chunk = buf.subarray(start, end + 1);
|
|
77
|
-
return new Response(new Uint8Array(chunk), {
|
|
78
|
-
status: 206,
|
|
79
|
-
headers: {
|
|
80
|
-
...baseHeaders,
|
|
81
|
-
"Content-Range": `bytes ${start}-${end}/${total}`,
|
|
82
|
-
"Content-Length": String(chunk.length),
|
|
83
|
-
},
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
return new Response(new Uint8Array(buf), {
|
|
87
|
-
status: 200,
|
|
88
|
-
headers: { ...baseHeaders, "Content-Length": String(total) },
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Parse a JSON status column (`extension_status` / `skills_status`) into the
|
|
93
|
-
* object the dashboard renders. Tolerates null / malformed JSON (returns
|
|
94
|
-
* undefined) so a bad row never breaks the executions endpoint.
|
|
95
|
-
*/
|
|
96
|
-
function parseJsonColumn(raw) {
|
|
97
|
-
if (!raw)
|
|
98
|
-
return undefined;
|
|
99
|
-
try {
|
|
100
|
-
return JSON.parse(raw);
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
return undefined;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* Check if a session is live by matching against running container taskIds.
|
|
108
|
-
* Sessions are live if they were recently active (within 5 min) and a container
|
|
109
|
-
* with a matching pattern is running.
|
|
110
|
-
*/
|
|
111
|
-
/**
|
|
112
|
-
* A session is worth listing only if it actually produced something. A
|
|
113
|
-
* zero-message session is a run that died before writing any conversation
|
|
114
|
-
* (e.g. an aborted/duplicate task that left an empty jsonl behind) — it has
|
|
115
|
-
* nothing to render and previously surfaced as a phantom "live" row at the
|
|
116
|
-
* top of the list. Error-only runs still write an assistant error line, so
|
|
117
|
-
* they keep a non-zero count and remain visible.
|
|
118
|
-
*/
|
|
119
|
-
function hasContent(meta) {
|
|
120
|
-
return meta.message_count > 0;
|
|
121
|
-
}
|
|
122
|
-
function isSessionLive(meta, liveTaskIds) {
|
|
123
|
-
// Stale sessions are never live, regardless of containers.
|
|
124
|
-
const lastActivity = meta.last_message_at ?? meta.started_at;
|
|
125
|
-
const fiveMinAgo = Date.now() / 1000 - 300;
|
|
126
|
-
if (lastActivity < fiveMinAgo)
|
|
127
|
-
return false;
|
|
128
|
-
// Fallback-named sessions encode their taskId as `exec-<taskId>`, and that
|
|
129
|
-
// taskId is exactly what listRunningContainers parses from the sandbox
|
|
130
|
-
// container name — so we can match them precisely instead of guessing.
|
|
131
|
-
if (meta.id.startsWith("exec-")) {
|
|
132
|
-
return liveTaskIds.has(meta.id.slice("exec-".length));
|
|
133
|
-
}
|
|
134
|
-
// UUID-named sessions don't carry their taskId in the meta, so we can't map
|
|
135
|
-
// them to a specific container. Recent activity + at least one sandbox
|
|
136
|
-
// running (the caller gates on liveTaskIds being non-empty) is the best
|
|
137
|
-
// signal available — keeps live agent logs flowing for in-flight phases.
|
|
138
|
-
return true;
|
|
139
|
-
}
|
|
140
|
-
/**
|
|
141
|
-
* Mount the read/list/stream endpoints for a SessionSource under a given
|
|
142
|
-
* route prefix on `app`. The same handler shape is reused for the workflow
|
|
143
|
-
* "Sessions" tab (sandbox-scoped reader at `/sessions`) and the chat tab
|
|
144
|
-
* (in-process Agent SDK runs at `/chat-sessions`).
|
|
145
|
-
*/
|
|
146
|
-
function mountSessionRoutes(app, sessions, prefix) {
|
|
147
|
-
// Session list — enriched with live container status
|
|
148
|
-
app.get(`${prefix}`, async (c) => {
|
|
149
|
-
const limit = Number(c.req.query("limit") ?? 200);
|
|
150
|
-
const allIds = sessions.listSessionIds();
|
|
151
|
-
const [metas, containers] = await Promise.all([
|
|
152
|
-
Promise.all(allIds.slice(0, limit * 2).map((id) => sessions.getSessionMeta(id))),
|
|
153
|
-
listRunningContainers(),
|
|
154
|
-
]);
|
|
155
|
-
const liveTaskIds = new Set(containers.map((c) => c.taskId).filter(Boolean));
|
|
156
|
-
const valid = metas
|
|
157
|
-
.filter((m) => m !== null)
|
|
158
|
-
.filter(hasContent)
|
|
159
|
-
.sort((a, b) => b.started_at - a.started_at)
|
|
160
|
-
.slice(0, limit)
|
|
161
|
-
.map((m) => ({ ...m, live: liveTaskIds.size > 0 && isSessionLive(m, liveTaskIds) }));
|
|
162
|
-
return c.json({ sessions: valid, liveCount: containers.length });
|
|
163
|
-
});
|
|
164
|
-
// Session list SSE stream
|
|
165
|
-
app.get(`${prefix}/stream`, (c) => {
|
|
166
|
-
const limit = Number(c.req.query("limit") ?? 200);
|
|
167
|
-
return streamSSE(c, async (stream) => {
|
|
168
|
-
let prevSig = null; // null = nothing sent yet (covers empty-list initial push)
|
|
169
|
-
let stopped = false;
|
|
170
|
-
stream.onAbort(() => { stopped = true; });
|
|
171
|
-
const push = async () => {
|
|
172
|
-
const [allIds, containers] = await Promise.all([
|
|
173
|
-
Promise.resolve(sessions.listSessionIds()),
|
|
174
|
-
listRunningContainers(),
|
|
175
|
-
]);
|
|
176
|
-
const liveTaskIds = new Set(containers.map((c) => c.taskId).filter(Boolean));
|
|
177
|
-
const metas = await Promise.all(allIds.slice(0, limit * 2).map((id) => sessions.getSessionMeta(id)));
|
|
178
|
-
const valid = metas
|
|
179
|
-
.filter((m) => m !== null)
|
|
180
|
-
.filter(hasContent)
|
|
181
|
-
.sort((a, b) => b.started_at - a.started_at)
|
|
182
|
-
.slice(0, limit)
|
|
183
|
-
.map((m) => ({ ...m, live: liveTaskIds.size > 0 && isSessionLive(m, liveTaskIds) }));
|
|
184
|
-
const sig = valid
|
|
185
|
-
.map((s) => `${s.id}:${s.last_message_at ?? s.started_at}:${s.message_count}:${s.live}`)
|
|
186
|
-
.join("|");
|
|
187
|
-
if (sig !== prevSig) {
|
|
188
|
-
prevSig = sig;
|
|
189
|
-
await stream.writeSSE({ event: "sessions", data: JSON.stringify({ sessions: valid, liveCount: containers.length }) });
|
|
190
|
-
}
|
|
191
|
-
};
|
|
192
|
-
await push();
|
|
193
|
-
while (!stopped) {
|
|
194
|
-
await stream.sleep(3000);
|
|
195
|
-
if (stopped)
|
|
196
|
-
break;
|
|
197
|
-
await push();
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
// Single session
|
|
202
|
-
app.get(`${prefix}/:id`, async (c) => {
|
|
203
|
-
const id = c.req.param("id");
|
|
204
|
-
if (sessions.exists(id)) {
|
|
205
|
-
const meta = await sessions.getSessionMeta(id);
|
|
206
|
-
if (meta)
|
|
207
|
-
return c.json({ session: meta });
|
|
208
|
-
}
|
|
209
|
-
return c.json({ error: "session not found" }, 404);
|
|
210
|
-
});
|
|
211
|
-
// Messages for a session
|
|
212
|
-
app.get(`${prefix}/:id/messages`, async (c) => {
|
|
213
|
-
const id = c.req.param("id");
|
|
214
|
-
const sinceIndex = Number(c.req.query("since") ?? -1);
|
|
215
|
-
if (sessions.exists(id)) {
|
|
216
|
-
const all = await sessions.read(id);
|
|
217
|
-
const next = all.filter((x) => x.index > sinceIndex);
|
|
218
|
-
return c.json({
|
|
219
|
-
source: "jsonl",
|
|
220
|
-
messages: next.map((x) => ({ id: x.index, ...x.msg })),
|
|
221
|
-
last_id: all.length ? all[all.length - 1].index : sinceIndex,
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
return c.json({ source: "none", messages: [], last_id: sinceIndex });
|
|
225
|
-
});
|
|
226
|
-
// Live message stream for a session
|
|
227
|
-
app.get(`${prefix}/:id/stream`, async (c) => {
|
|
228
|
-
const id = c.req.param("id");
|
|
229
|
-
const sinceIndex = Number(c.req.query("since") ?? -1);
|
|
230
|
-
if (!sessions.exists(id)) {
|
|
231
|
-
return c.json({ error: "session not found" }, 404);
|
|
232
|
-
}
|
|
233
|
-
const filePath = sessions.getFilePath(id);
|
|
234
|
-
if (!filePath) {
|
|
235
|
-
return c.json({ error: "session file not found" }, 404);
|
|
236
|
-
}
|
|
237
|
-
return streamSSE(c, async (stream) => {
|
|
238
|
-
let sentReady = false;
|
|
239
|
-
let lastId = sinceIndex;
|
|
240
|
-
let stopped = false;
|
|
241
|
-
stream.onAbort(() => { stopped = true; });
|
|
242
|
-
let msgIndex = sinceIndex;
|
|
243
|
-
const tailer = await tailJsonl(filePath, (lines) => {
|
|
244
|
-
for (const { msg } of lines) {
|
|
245
|
-
const unwrapped = sessions.normalizeRawLine(msg);
|
|
246
|
-
for (const m of unwrapped) {
|
|
247
|
-
msgIndex++;
|
|
248
|
-
stream.writeSSE({ event: "message", data: JSON.stringify({ id: msgIndex, ...m }) });
|
|
249
|
-
lastId = msgIndex;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
if (!sentReady) {
|
|
253
|
-
sentReady = true;
|
|
254
|
-
stream.writeSSE({ event: "ready", data: JSON.stringify({ last_id: lastId, source: "jsonl" }) });
|
|
255
|
-
}
|
|
256
|
-
}, { sinceIndex });
|
|
257
|
-
if (!sentReady) {
|
|
258
|
-
sentReady = true;
|
|
259
|
-
await stream.writeSSE({ event: "ready", data: JSON.stringify({ last_id: sinceIndex, source: "jsonl" }) });
|
|
260
|
-
}
|
|
261
|
-
// Keep connection alive until client disconnects
|
|
262
|
-
while (!stopped) {
|
|
263
|
-
await stream.sleep(15000);
|
|
264
|
-
}
|
|
265
|
-
tailer.stop();
|
|
266
|
-
});
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
export function createAdminRoutes(db, sessions, chatSessions, config) {
|
|
270
|
-
const app = new Hono();
|
|
271
|
-
const slackOAuthEnabled = Boolean(config.slackOAuthClientId && config.slackOAuthClientSecret);
|
|
272
|
-
const githubCredsSet = Boolean(config.githubOAuthClientId && config.githubOAuthClientSecret);
|
|
273
|
-
const githubOAuthEnabled = githubCredsSet && Boolean(config.githubAllowedOrg);
|
|
274
|
-
if (githubCredsSet && !config.githubAllowedOrg) {
|
|
275
|
-
console.error("[oauth] GitHub OAuth client id/secret are set but GITHUB_ALLOWED_ORG is empty. " +
|
|
276
|
-
"Set it to a GitHub org slug to restrict login to that org, or to \"*\" to " +
|
|
277
|
-
"explicitly allow any GitHub user. GitHub OAuth is disabled until this is set.");
|
|
278
|
-
}
|
|
279
|
-
const githubAllowAnyUser = config.githubAllowedOrg === "*";
|
|
280
|
-
// Auth is required when ANY login method is configured — a password OR a
|
|
281
|
-
// working OAuth provider. Gating on the password alone left the dashboard
|
|
282
|
-
// fully open whenever ADMIN_PASSWORD was cleared, even with OAuth set up.
|
|
283
|
-
const authEnabled = Boolean(config.adminPassword) || slackOAuthEnabled || githubOAuthEnabled;
|
|
284
|
-
// Auth middleware
|
|
285
|
-
app.use("/*", authMiddleware(authEnabled, config.adminSecret));
|
|
286
|
-
app.get("/config", (c) => c.json(config.publicConfig || { default: {}, overlay: null, merged: {}, sources: {} }));
|
|
287
|
-
// Effective managed-repo list — runtime/derived state, so a dedicated endpoint
|
|
288
|
-
// rather than a field in the static config bundle. `configured` is the overlay
|
|
289
|
-
// list; `installation` is what the GitHub App can access (discovered at boot +
|
|
290
|
-
// kept live by installation webhooks); `effective` is what actually gates
|
|
291
|
-
// events (config wins when set, else installation). See src/managed-repos.ts.
|
|
292
|
-
app.get("/managed-repos", (c) => {
|
|
293
|
-
const configured = getRuntimeConfig()?.managedRepos ?? [];
|
|
294
|
-
return c.json({
|
|
295
|
-
configured,
|
|
296
|
-
installation: getInstallationRepos(),
|
|
297
|
-
effective: getManagedRepos(),
|
|
298
|
-
source: configured.length > 0 ? "config" : "installation",
|
|
299
|
-
refreshedAt: getInstallationReposRefreshedAt(),
|
|
300
|
-
});
|
|
301
|
-
});
|
|
302
|
-
// Forked/overridden assets the deployment overlay supplies — workflows,
|
|
303
|
-
// prompts, skills, agent-context — each tagged as shadowing a built-in or a
|
|
304
|
-
// fresh addition. Powers the Config → Overrides pane. Shares the enumerator
|
|
305
|
-
// with `lastlight server status`.
|
|
306
|
-
app.get("/overrides", (c) => c.json({
|
|
307
|
-
overlayDir: config.overlayDir ?? null,
|
|
308
|
-
overrides: enumerateOverlayAssets({ coreRoot: config.builtInRoot, overlayRoot: config.overlayDir }),
|
|
309
|
-
}));
|
|
310
|
-
// Auth endpoints
|
|
311
|
-
app.get("/auth-required", (c) => {
|
|
312
|
-
return c.json({
|
|
313
|
-
required: authEnabled,
|
|
314
|
-
password: Boolean(config.adminPassword),
|
|
315
|
-
slackOAuth: slackOAuthEnabled,
|
|
316
|
-
githubOAuth: githubOAuthEnabled,
|
|
317
|
-
});
|
|
318
|
-
});
|
|
319
|
-
app.post("/login", async (c) => {
|
|
320
|
-
if (!config.adminPassword) {
|
|
321
|
-
// No password set. If OAuth is the active gate, password login is simply
|
|
322
|
-
// unavailable — never hand out a token here, or anyone could bypass OAuth.
|
|
323
|
-
// Only mint the open-access token when NO auth method is configured.
|
|
324
|
-
if (authEnabled) {
|
|
325
|
-
return c.json({ error: "password login is not configured; use OAuth" }, 400);
|
|
326
|
-
}
|
|
327
|
-
return c.json({ token: createToken(config.adminSecret), authDisabled: true });
|
|
328
|
-
}
|
|
329
|
-
const body = await c.req.json();
|
|
330
|
-
if (typeof body.password !== "string") {
|
|
331
|
-
return c.json({ error: "password required" }, 400);
|
|
332
|
-
}
|
|
333
|
-
const a = Buffer.from(body.password);
|
|
334
|
-
const b = Buffer.from(config.adminPassword);
|
|
335
|
-
const ok = a.length === b.length && timingSafeEqual(a, b);
|
|
336
|
-
if (!ok) {
|
|
337
|
-
return c.json({ error: "invalid password" }, 401);
|
|
338
|
-
}
|
|
339
|
-
return c.json({ token: createToken(config.adminSecret, "password") });
|
|
340
|
-
});
|
|
341
|
-
// Slide an active session forward: mint a fresh full-TTL token. Runs OUTSIDE
|
|
342
|
-
// the strict authMiddleware (it's on the pass-through list) so a token that
|
|
343
|
-
// lapsed within REFRESH_GRACE_SECONDS can still renew — we re-check the
|
|
344
|
-
// signature + grace here via verifyTokenForRefresh. The login `method` is
|
|
345
|
-
// carried across so refreshed tokens keep their provenance.
|
|
346
|
-
app.post("/token/refresh", (c) => {
|
|
347
|
-
if (!authEnabled) {
|
|
348
|
-
// No auth configured — hand back the same open-access token login issues.
|
|
349
|
-
return c.json({ token: createToken(config.adminSecret), authDisabled: true });
|
|
350
|
-
}
|
|
351
|
-
const header = c.req.header("Authorization");
|
|
352
|
-
const current = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
|
|
353
|
-
if (!current || !verifyTokenForRefresh(current, config.adminSecret)) {
|
|
354
|
-
return c.json({ error: "unauthorized" }, 401);
|
|
355
|
-
}
|
|
356
|
-
return c.json({ token: createToken(config.adminSecret, decodeToken(current)?.method) });
|
|
357
|
-
});
|
|
358
|
-
// Slack OAuth routes (only active when Slack OAuth env vars are configured)
|
|
359
|
-
app.get("/oauth/slack/authorize", (c) => {
|
|
360
|
-
if (!slackOAuthEnabled) {
|
|
361
|
-
return c.json({ error: "Slack OAuth not configured" }, 404);
|
|
362
|
-
}
|
|
363
|
-
const slack = new Slack(config.slackOAuthClientId, config.slackOAuthClientSecret, config.slackOAuthRedirectUri ?? "");
|
|
364
|
-
const state = randomBytes(16).toString("hex");
|
|
365
|
-
setCookie(c, "slack_oauth_state", state, {
|
|
366
|
-
httpOnly: true,
|
|
367
|
-
sameSite: "Lax",
|
|
368
|
-
path: "/",
|
|
369
|
-
maxAge: 600, // 10 minutes
|
|
370
|
-
});
|
|
371
|
-
const url = slack.createAuthorizationURL(state, ["openid", "profile"]);
|
|
372
|
-
return c.redirect(url.toString());
|
|
373
|
-
});
|
|
374
|
-
app.get("/oauth/slack/callback", async (c) => {
|
|
375
|
-
if (!slackOAuthEnabled) {
|
|
376
|
-
return c.json({ error: "Slack OAuth not configured" }, 404);
|
|
377
|
-
}
|
|
378
|
-
const storedState = getCookie(c, "slack_oauth_state");
|
|
379
|
-
deleteCookie(c, "slack_oauth_state", { path: "/" });
|
|
380
|
-
const { code, state } = c.req.query();
|
|
381
|
-
if (!storedState || !state || storedState !== state) {
|
|
382
|
-
return c.json({ error: "invalid state parameter" }, 400);
|
|
383
|
-
}
|
|
384
|
-
if (!code) {
|
|
385
|
-
return c.json({ error: "missing authorization code" }, 400);
|
|
386
|
-
}
|
|
387
|
-
try {
|
|
388
|
-
const slack = new Slack(config.slackOAuthClientId, config.slackOAuthClientSecret, config.slackOAuthRedirectUri ?? "");
|
|
389
|
-
// "Sign in with Slack" issues OIDC-scoped tokens (openid + profile),
|
|
390
|
-
// which Slack's classic auth.test endpoint rejects with invalid_auth.
|
|
391
|
-
// Use the OIDC userInfo endpoint instead — it returns a JWT-style
|
|
392
|
-
// payload with claims under namespaced URLs.
|
|
393
|
-
const tokens = await slack.validateAuthorizationCode(code);
|
|
394
|
-
const accessToken = tokens.accessToken();
|
|
395
|
-
const res = await fetch("https://slack.com/api/openid.connect.userInfo", {
|
|
396
|
-
headers: { Authorization: `Bearer ${accessToken}` },
|
|
397
|
-
});
|
|
398
|
-
const userInfo = (await res.json());
|
|
399
|
-
if (userInfo.ok === false) {
|
|
400
|
-
console.error("Slack openid.connect.userInfo failed:", userInfo.error);
|
|
401
|
-
return c.json({ error: "Slack userInfo failed" }, 502);
|
|
402
|
-
}
|
|
403
|
-
const teamId = userInfo["https://slack.com/team_id"];
|
|
404
|
-
const teamDomain = userInfo["https://slack.com/team_domain"];
|
|
405
|
-
// Workspace restriction check
|
|
406
|
-
if (config.slackAllowedWorkspace) {
|
|
407
|
-
const allowed = config.slackAllowedWorkspace;
|
|
408
|
-
const matchesId = teamId === allowed;
|
|
409
|
-
const matchesDomain = teamDomain === allowed;
|
|
410
|
-
if (!matchesId && !matchesDomain) {
|
|
411
|
-
console.warn(`[oauth] Slack login rejected: workspace ${teamDomain ?? teamId ?? "unknown"} not in allowlist (${allowed})`);
|
|
412
|
-
return c.json({ error: "workspace not allowed" }, 403);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
const token = createToken(config.adminSecret, "slack");
|
|
416
|
-
// Redirect to dashboard with token in URL; App.tsx strips it immediately.
|
|
417
|
-
// Trailing slash matters: Vite serves the SPA with base "/admin/" so a
|
|
418
|
-
// bare "/admin" 404s in dev. Production static serving accepts both.
|
|
419
|
-
return c.redirect(`/admin/?token=${encodeURIComponent(token)}`);
|
|
420
|
-
}
|
|
421
|
-
catch (err) {
|
|
422
|
-
console.error("OAuth exchange failed:", err);
|
|
423
|
-
return c.json({ error: "OAuth exchange failed" }, 502);
|
|
424
|
-
}
|
|
425
|
-
});
|
|
426
|
-
// GitHub OAuth routes (only active when GitHub OAuth env vars are configured)
|
|
427
|
-
app.get("/oauth/github/authorize", (c) => {
|
|
428
|
-
if (!githubOAuthEnabled) {
|
|
429
|
-
return c.json({ error: "GitHub OAuth not configured" }, 404);
|
|
430
|
-
}
|
|
431
|
-
const github = new GitHub(config.githubOAuthClientId, config.githubOAuthClientSecret, config.githubOAuthRedirectUri ?? "");
|
|
432
|
-
const state = randomBytes(16).toString("hex");
|
|
433
|
-
setCookie(c, "github_oauth_state", state, {
|
|
434
|
-
httpOnly: true,
|
|
435
|
-
sameSite: "Lax",
|
|
436
|
-
path: "/",
|
|
437
|
-
maxAge: 600, // 10 minutes
|
|
438
|
-
});
|
|
439
|
-
// `login` on GET /user needs no scope; read:org is only needed for the
|
|
440
|
-
// org-membership check, so skip it when the allowlist is "*".
|
|
441
|
-
const scopes = githubAllowAnyUser ? [] : ["read:org"];
|
|
442
|
-
const url = github.createAuthorizationURL(state, scopes);
|
|
443
|
-
return c.redirect(url.toString());
|
|
444
|
-
});
|
|
445
|
-
app.get("/oauth/github/callback", async (c) => {
|
|
446
|
-
if (!githubOAuthEnabled) {
|
|
447
|
-
return c.json({ error: "GitHub OAuth not configured" }, 404);
|
|
448
|
-
}
|
|
449
|
-
// Redirect the user back to the dashboard login screen with a short,
|
|
450
|
-
// URL-safe error code. The SPA maps this code to a human-readable
|
|
451
|
-
// message so the user sees the login card with an inline error instead
|
|
452
|
-
// of a raw JSON body.
|
|
453
|
-
const fail = (code) => c.redirect(`/admin/?error=${encodeURIComponent(code)}`);
|
|
454
|
-
const storedState = getCookie(c, "github_oauth_state");
|
|
455
|
-
deleteCookie(c, "github_oauth_state", { path: "/" });
|
|
456
|
-
const { code, state } = c.req.query();
|
|
457
|
-
if (!storedState || !state || storedState !== state) {
|
|
458
|
-
return fail("oauth_state");
|
|
459
|
-
}
|
|
460
|
-
if (!code) {
|
|
461
|
-
return fail("oauth_code");
|
|
462
|
-
}
|
|
463
|
-
try {
|
|
464
|
-
const github = new GitHub(config.githubOAuthClientId, config.githubOAuthClientSecret, config.githubOAuthRedirectUri ?? "");
|
|
465
|
-
const tokens = await github.validateAuthorizationCode(code);
|
|
466
|
-
const accessToken = tokens.accessToken();
|
|
467
|
-
const res = await fetch("https://api.github.com/user", {
|
|
468
|
-
headers: {
|
|
469
|
-
Authorization: `Bearer ${accessToken}`,
|
|
470
|
-
"User-Agent": "lastlight-admin",
|
|
471
|
-
Accept: "application/vnd.github+json",
|
|
472
|
-
},
|
|
473
|
-
});
|
|
474
|
-
const userInfo = (await res.json());
|
|
475
|
-
let memberStatus;
|
|
476
|
-
if (userInfo.login && !githubAllowAnyUser) {
|
|
477
|
-
const org = config.githubAllowedOrg;
|
|
478
|
-
const memberRes = await fetch(`https://api.github.com/orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(userInfo.login)}`, {
|
|
479
|
-
headers: {
|
|
480
|
-
Authorization: `Bearer ${accessToken}`,
|
|
481
|
-
"User-Agent": "lastlight-admin",
|
|
482
|
-
Accept: "application/vnd.github+json",
|
|
483
|
-
},
|
|
484
|
-
redirect: "manual",
|
|
485
|
-
});
|
|
486
|
-
memberStatus = memberRes.status;
|
|
487
|
-
}
|
|
488
|
-
if (!userInfo.login) {
|
|
489
|
-
console.error("GitHub /user failed: missing login field");
|
|
490
|
-
return fail("github_userinfo");
|
|
491
|
-
}
|
|
492
|
-
const login = userInfo.login;
|
|
493
|
-
// Only 204 No Content means confirmed member. 302 means caller lacks
|
|
494
|
-
// read:org visibility; 404 means not a member. Both cases are rejected.
|
|
495
|
-
if (!githubAllowAnyUser && memberStatus !== 204) {
|
|
496
|
-
console.warn(`[oauth] GitHub login rejected: ${login} not a confirmed member of ${config.githubAllowedOrg} (status ${memberStatus})`);
|
|
497
|
-
return fail("github_org");
|
|
498
|
-
}
|
|
499
|
-
const token = createToken(config.adminSecret, "github");
|
|
500
|
-
return c.redirect(`/admin/?token=${encodeURIComponent(token)}`);
|
|
501
|
-
}
|
|
502
|
-
catch (err) {
|
|
503
|
-
console.error("GitHub OAuth exchange failed:", err);
|
|
504
|
-
return fail("oauth_exchange");
|
|
505
|
-
}
|
|
506
|
-
});
|
|
507
|
-
// Health
|
|
508
|
-
app.get("/health", (c) => {
|
|
509
|
-
return c.json({ status: "ok", stateDir: config.stateDir });
|
|
510
|
-
});
|
|
511
|
-
// Workflow / sandbox sessions and chat (in-process Agent SDK) sessions both
|
|
512
|
-
// expose the same five endpoints, just under different prefixes and backed
|
|
513
|
-
// by different on-disk slices.
|
|
514
|
-
mountSessionRoutes(app, sessions, "/sessions");
|
|
515
|
-
mountSessionRoutes(app, chatSessions, "/chat-sessions");
|
|
516
|
-
// Stats — running count uses live Docker containers, not stale DB records
|
|
517
|
-
app.get("/stats", async (c) => {
|
|
518
|
-
const [stats, containers] = await Promise.all([
|
|
519
|
-
Promise.resolve(db.executions.executionStats()),
|
|
520
|
-
listRunningContainers(),
|
|
521
|
-
]);
|
|
522
|
-
stats.running = containers.length;
|
|
523
|
-
return c.json(stats);
|
|
524
|
-
});
|
|
525
|
-
// Daily aggregated stats (last N days)
|
|
526
|
-
app.get("/stats/daily", (c) => {
|
|
527
|
-
const daysParam = c.req.query("days");
|
|
528
|
-
const days = Math.min(Math.max(1, parseInt(daysParam ?? "30", 10) || 30), 90);
|
|
529
|
-
return c.json({ daily: db.executions.dailyStats(days) });
|
|
530
|
-
});
|
|
531
|
-
// Hourly aggregated stats (rolling last N hours, default 24)
|
|
532
|
-
app.get("/stats/hourly", (c) => {
|
|
533
|
-
const hoursParam = c.req.query("hours");
|
|
534
|
-
const hours = Math.min(Math.max(1, parseInt(hoursParam ?? "24", 10) || 24), 168);
|
|
535
|
-
return c.json({ hourly: db.executions.hourlyStats(hours) });
|
|
536
|
-
});
|
|
537
|
-
// Running Docker containers
|
|
538
|
-
app.get("/containers", async (c) => {
|
|
539
|
-
const containers = await listRunningContainers();
|
|
540
|
-
return c.json({ containers });
|
|
541
|
-
});
|
|
542
|
-
// CPU/memory stats for the agent and any sandbox containers
|
|
543
|
-
app.get("/containers/stats", async (c) => {
|
|
544
|
-
const stats = await getContainerStats();
|
|
545
|
-
return c.json({ stats });
|
|
546
|
-
});
|
|
547
|
-
// ── Server logs ───────────────────────────────────────────────────────────
|
|
548
|
-
// Raw `docker logs` for the lastlight-* containers (the agent harness + the
|
|
549
|
-
// egress sidecars + otel-collector). Lets an operator read the actual
|
|
550
|
-
// server/process logs over the admin API instead of SSHing to the host. The
|
|
551
|
-
// requested container is resolved against the live container list, so an
|
|
552
|
-
// arbitrary name can never reach `docker logs`.
|
|
553
|
-
app.get("/server/containers", async (c) => {
|
|
554
|
-
return c.json({ containers: await listServerContainers() });
|
|
555
|
-
});
|
|
556
|
-
// Version + drift (core/overlay) for the dashboard "update available" banner.
|
|
557
|
-
// Best-effort: an unreachable remote yields latest=null (behind=false), never
|
|
558
|
-
// a false positive. The authoritative view is `lastlight server status`.
|
|
559
|
-
app.get("/server/info", async (c) => {
|
|
560
|
-
try {
|
|
561
|
-
return c.json(await getServerVersion());
|
|
562
|
-
}
|
|
563
|
-
catch (err) {
|
|
564
|
-
return c.json({ error: `version lookup failed: ${err.message}` }, 500);
|
|
565
|
-
}
|
|
566
|
-
});
|
|
567
|
-
app.get("/server/logs", async (c) => {
|
|
568
|
-
const name = await resolveServerContainer(c.req.query("container"));
|
|
569
|
-
if (!name)
|
|
570
|
-
return c.json({ error: "no matching lastlight container" }, 404);
|
|
571
|
-
const tail = Math.min(Math.max(parseInt(c.req.query("tail") ?? "200", 10) || 200, 1), 5000);
|
|
572
|
-
const since = c.req.query("since") || undefined;
|
|
573
|
-
try {
|
|
574
|
-
const lines = await getContainerLogs(name, { tail, since });
|
|
575
|
-
return c.json({ container: name, lines });
|
|
576
|
-
}
|
|
577
|
-
catch (err) {
|
|
578
|
-
return c.json({ error: `docker logs failed: ${err.message}` }, 500);
|
|
579
|
-
}
|
|
580
|
-
});
|
|
581
|
-
app.get("/server/logs/stream", (c) => {
|
|
582
|
-
return streamSSE(c, async (stream) => {
|
|
583
|
-
const name = await resolveServerContainer(c.req.query("container"));
|
|
584
|
-
if (!name) {
|
|
585
|
-
await stream.writeSSE({ data: JSON.stringify({ error: "no matching lastlight container" }) });
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
588
|
-
const tail = Math.min(Math.max(parseInt(c.req.query("tail") ?? "100", 10) || 100, 1), 5000);
|
|
589
|
-
let stopped = false;
|
|
590
|
-
const stop = streamContainerLogs(name, { tail }, (line) => {
|
|
591
|
-
if (!stopped)
|
|
592
|
-
void stream.writeSSE({ data: line });
|
|
593
|
-
});
|
|
594
|
-
stream.onAbort(() => { stopped = true; stop(); });
|
|
595
|
-
// Hold the SSE open until the client disconnects.
|
|
596
|
-
while (!stopped) {
|
|
597
|
-
await stream.sleep(15000);
|
|
598
|
-
}
|
|
599
|
-
stop();
|
|
600
|
-
});
|
|
601
|
-
});
|
|
602
|
-
// Kill a sandbox container and mark related DB executions as failed
|
|
603
|
-
app.delete("/containers/:name", async (c) => {
|
|
604
|
-
const name = c.req.param("name");
|
|
605
|
-
if (!name.startsWith("lastlight-sandbox-")) {
|
|
606
|
-
return c.json({ error: "can only kill sandbox containers" }, 400);
|
|
607
|
-
}
|
|
608
|
-
try {
|
|
609
|
-
await killContainer(name);
|
|
610
|
-
// Parse taskId from container name: lastlight-sandbox-{taskId}-{uuid}
|
|
611
|
-
const match = name.match(/^lastlight-sandbox-(.+?)-[a-f0-9]{8}$/);
|
|
612
|
-
if (match) {
|
|
613
|
-
const taskId = match[1];
|
|
614
|
-
// Mark any running executions with matching skill as failed. Phase
|
|
615
|
-
// skill keys are `<workflowName>:<phaseName>` — match on the colon.
|
|
616
|
-
const skills = db.executions.runningExecutions()
|
|
617
|
-
.filter((e) => e.skill.includes(":") || e.skill === "pr-fix")
|
|
618
|
-
.filter((e) => taskId.includes(e.triggerId?.replace(/[^a-z0-9]/gi, "") || "---"));
|
|
619
|
-
for (const e of skills) {
|
|
620
|
-
db.executions.recordFinish(e.id, { success: false, error: "terminated via admin dashboard" });
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
return c.json({ killed: name });
|
|
624
|
-
}
|
|
625
|
-
catch (err) {
|
|
626
|
-
return c.json({ error: err.message }, 500);
|
|
627
|
-
}
|
|
628
|
-
});
|
|
629
|
-
// Execution records from DB
|
|
630
|
-
app.get("/executions", (c) => {
|
|
631
|
-
const limit = Number(c.req.query("limit") ?? 100);
|
|
632
|
-
const offset = Number(c.req.query("offset") ?? 0);
|
|
633
|
-
const executions = db.executions.allExecutions(limit, offset);
|
|
634
|
-
return c.json({ executions });
|
|
635
|
-
});
|
|
636
|
-
// Free-text log search — the backbone of remote debugging via the CLI
|
|
637
|
-
// (`lastlight logs search`). Two scopes:
|
|
638
|
-
// - errors (default): substring match over the executions ledger
|
|
639
|
-
// (error / skill / repo) — fast, indexed-ish.
|
|
640
|
-
// - messages: grep the most-recent session transcripts for matching
|
|
641
|
-
// conversation content, returning a snippet. Bounded by
|
|
642
|
-
// `maxSessions` so a deep history can't make this unbounded.
|
|
643
|
-
// - all: both, errors first.
|
|
644
|
-
app.get("/log-search", async (c) => {
|
|
645
|
-
const q = (c.req.query("q") ?? "").trim();
|
|
646
|
-
if (!q)
|
|
647
|
-
return c.json({ error: "missing 'q' query parameter" }, 400);
|
|
648
|
-
const scope = (c.req.query("scope") ?? "errors");
|
|
649
|
-
const limit = Math.min(Math.max(parseInt(c.req.query("limit") ?? "50", 10) || 50, 1), 200);
|
|
650
|
-
const results = [];
|
|
651
|
-
if (scope === "errors" || scope === "all") {
|
|
652
|
-
for (const r of db.executions.searchErrors(q, limit)) {
|
|
653
|
-
results.push({
|
|
654
|
-
source: "error",
|
|
655
|
-
executionId: r.id,
|
|
656
|
-
sessionId: r.sessionId,
|
|
657
|
-
workflowRunId: r.workflowRunId,
|
|
658
|
-
skill: r.skill,
|
|
659
|
-
repo: r.repo,
|
|
660
|
-
startedAt: r.startedAt,
|
|
661
|
-
success: r.success,
|
|
662
|
-
snippet: r.error ?? r.skill,
|
|
663
|
-
});
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
if (scope === "messages" || scope === "all") {
|
|
667
|
-
const needle = q.toLowerCase();
|
|
668
|
-
const maxSessions = 200; // newest-first cap on transcripts scanned
|
|
669
|
-
const ids = sessions.listSessionIds().slice(0, maxSessions);
|
|
670
|
-
outer: for (const id of ids) {
|
|
671
|
-
let msgs;
|
|
672
|
-
try {
|
|
673
|
-
msgs = (await sessions.read(id));
|
|
674
|
-
}
|
|
675
|
-
catch {
|
|
676
|
-
continue;
|
|
677
|
-
}
|
|
678
|
-
for (const { index, msg } of msgs) {
|
|
679
|
-
const text = JSON.stringify(msg.content ?? "");
|
|
680
|
-
const at = text.toLowerCase().indexOf(needle);
|
|
681
|
-
if (at === -1)
|
|
682
|
-
continue;
|
|
683
|
-
const start = Math.max(0, at - 60);
|
|
684
|
-
results.push({
|
|
685
|
-
source: "message",
|
|
686
|
-
sessionId: id,
|
|
687
|
-
messageIndex: index,
|
|
688
|
-
role: msg.role,
|
|
689
|
-
snippet: text.slice(start, at + needle.length + 120),
|
|
690
|
-
});
|
|
691
|
-
if (results.length >= limit)
|
|
692
|
-
break outer;
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
return c.json({ results: results.slice(0, limit) });
|
|
697
|
-
});
|
|
698
|
-
// Workflow runs — paginated, optional filters by date, workflow name, and
|
|
699
|
-
// status. Returns `total` so the dashboard can drive a "load more" pager.
|
|
700
|
-
// `status=active` is shorthand for ('running','paused') — used by the
|
|
701
|
-
// header's "live" filter on the workflows tab.
|
|
702
|
-
app.get("/workflow-runs", (c) => {
|
|
703
|
-
const rawLimit = c.req.query("limit");
|
|
704
|
-
const rawOffset = c.req.query("offset");
|
|
705
|
-
const since = c.req.query("since") || undefined;
|
|
706
|
-
const workflowName = c.req.query("workflow") || undefined;
|
|
707
|
-
const statusParam = c.req.query("status");
|
|
708
|
-
const limit = Math.min(Math.max(parseInt(rawLimit ?? "20", 10) || 20, 1), 200);
|
|
709
|
-
const offset = Math.max(parseInt(rawOffset ?? "0", 10) || 0, 0);
|
|
710
|
-
let statuses;
|
|
711
|
-
if (statusParam === "active") {
|
|
712
|
-
statuses = ["running", "paused"];
|
|
713
|
-
}
|
|
714
|
-
else if (statusParam) {
|
|
715
|
-
statuses = statusParam.split(",").filter(Boolean);
|
|
716
|
-
}
|
|
717
|
-
const { runs, total } = db.runs.list({
|
|
718
|
-
limit,
|
|
719
|
-
offset,
|
|
720
|
-
sinceIso: since,
|
|
721
|
-
workflowName,
|
|
722
|
-
statuses,
|
|
723
|
-
});
|
|
724
|
-
return c.json({ workflowRuns: runs, total });
|
|
725
|
-
});
|
|
726
|
-
// Distinct workflow names — used to populate the dashboard's filter row.
|
|
727
|
-
app.get("/workflow-names", (c) => {
|
|
728
|
-
return c.json({ names: db.runs.distinctNames() });
|
|
729
|
-
});
|
|
730
|
-
app.get("/workflow-runs/:id", (c) => {
|
|
731
|
-
const id = c.req.param("id");
|
|
732
|
-
const run = db.runs.getRun(id);
|
|
733
|
-
if (!run)
|
|
734
|
-
return c.json({ error: "workflow run not found" }, 404);
|
|
735
|
-
return c.json({ workflowRun: run });
|
|
736
|
-
});
|
|
737
|
-
// List the executions belonging to a workflow run, ordered by start time.
|
|
738
|
-
// Used by the dashboard's pipeline-detail view to look up the session id
|
|
739
|
-
// (and usage metrics) for any phase the user clicks.
|
|
740
|
-
app.get("/workflow-runs/:id/executions", (c) => {
|
|
741
|
-
const id = c.req.param("id");
|
|
742
|
-
const run = db.runs.getRun(id);
|
|
743
|
-
if (!run)
|
|
744
|
-
return c.json({ error: "workflow run not found" }, 404);
|
|
745
|
-
const rows = db.executions.getExecutionsForWorkflowRun(run.id, run.triggerId, run.workflowName);
|
|
746
|
-
const prefix = `${run.workflowName}:`;
|
|
747
|
-
const executions = rows.map((r) => ({
|
|
748
|
-
id: r.id,
|
|
749
|
-
skill: r.skill,
|
|
750
|
-
// <workflowName>:<phaseName> → phaseName
|
|
751
|
-
phase: r.skill.startsWith(prefix) ? r.skill.slice(prefix.length) : r.skill,
|
|
752
|
-
sessionId: r.sessionId,
|
|
753
|
-
success: r.success,
|
|
754
|
-
error: r.error,
|
|
755
|
-
startedAt: r.startedAt,
|
|
756
|
-
finishedAt: r.finishedAt,
|
|
757
|
-
durationMs: r.durationMs,
|
|
758
|
-
turns: r.turns,
|
|
759
|
-
costUsd: r.costUsd,
|
|
760
|
-
inputTokens: r.inputTokens,
|
|
761
|
-
cacheCreationInputTokens: r.cacheCreationInputTokens,
|
|
762
|
-
cacheReadInputTokens: r.cacheReadInputTokens,
|
|
763
|
-
outputTokens: r.outputTokens,
|
|
764
|
-
apiDurationMs: r.apiDurationMs,
|
|
765
|
-
stopReason: r.stopReason,
|
|
766
|
-
extensions: parseJsonColumn(r.extensionStatus),
|
|
767
|
-
skills: parseJsonColumn(r.skillsStatus),
|
|
768
|
-
}));
|
|
769
|
-
return c.json({ executions });
|
|
770
|
-
});
|
|
771
|
-
// All approvals (pending + resolved) for a workflow run, oldest first. Powers
|
|
772
|
-
// the pipeline's approval-gate nodes + the detail panel's read-only approval
|
|
773
|
-
// history (status, who responded, when, and any comment). The global
|
|
774
|
-
// /approvals endpoint only lists pending ones, so it can't show history.
|
|
775
|
-
app.get("/workflow-runs/:id/approvals", (c) => {
|
|
776
|
-
const id = c.req.param("id");
|
|
777
|
-
const run = db.runs.getRun(id);
|
|
778
|
-
if (!run)
|
|
779
|
-
return c.json({ error: "workflow run not found" }, 404);
|
|
780
|
-
const approvals = db.approvals.listForWorkflow(run.id);
|
|
781
|
-
return c.json({ approvals });
|
|
782
|
-
});
|
|
783
|
-
app.post("/workflow-runs/:id/cancel", async (c) => {
|
|
784
|
-
const id = c.req.param("id");
|
|
785
|
-
const run = db.runs.getRun(id);
|
|
786
|
-
if (!run)
|
|
787
|
-
return c.json({ error: "workflow run not found" }, 404);
|
|
788
|
-
if (run.status !== "running" && run.status !== "paused") {
|
|
789
|
-
return c.json({ error: `cannot cancel a run with status '${run.status}'` }, 400);
|
|
790
|
-
}
|
|
791
|
-
db.runs.cancelRun(id);
|
|
792
|
-
// Flipping the DB row alone only stops the runner before the NEXT phase.
|
|
793
|
-
// Kill any sandbox container currently executing a phase of this run so
|
|
794
|
-
// the in-flight phase stops too. Container names are
|
|
795
|
-
// lastlight-sandbox-<taskId>-<uuid>
|
|
796
|
-
// where taskId is the linear run's taskId or the DAG's phase-scoped
|
|
797
|
-
// `<taskId>-<phaseName>`, both of which start with the stored taskId.
|
|
798
|
-
const storedTaskId = run.context?.taskId;
|
|
799
|
-
let killed = [];
|
|
800
|
-
if (typeof storedTaskId === "string" && storedTaskId) {
|
|
801
|
-
try {
|
|
802
|
-
const containers = await listRunningContainers();
|
|
803
|
-
const matches = containers.filter((ctr) => ctr.taskId && ctr.taskId.startsWith(storedTaskId));
|
|
804
|
-
await Promise.all(matches.map(async (ctr) => {
|
|
805
|
-
try {
|
|
806
|
-
await killContainer(ctr.name);
|
|
807
|
-
killed.push(ctr.name);
|
|
808
|
-
}
|
|
809
|
-
catch (err) {
|
|
810
|
-
console.warn(`[cancel] failed to kill ${ctr.name}:`, err);
|
|
811
|
-
}
|
|
812
|
-
}));
|
|
813
|
-
// Mark execution rows belonging to THIS cancelled run as failed.
|
|
814
|
-
// Matching by workflowRunId (the run's id) instead of triggerId
|
|
815
|
-
// avoids clobbering a sibling run that happens to share the same
|
|
816
|
-
// trigger — e.g. two webhook deliveries for the same PR that
|
|
817
|
-
// raced before dedup closed.
|
|
818
|
-
for (const e of db.executions.runningExecutions()) {
|
|
819
|
-
if (e.workflowRunId === id) {
|
|
820
|
-
db.executions.recordFinish(e.id, { success: false, error: "cancelled via admin dashboard" });
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
catch (err) {
|
|
825
|
-
console.warn(`[cancel] container enumeration failed:`, err);
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
return c.json({ cancelled: id, killedContainers: killed });
|
|
829
|
-
});
|
|
830
|
-
// Retry a FAILED workflow run — resume from the phase that failed with the
|
|
831
|
-
// same context. Only `failed` runs are retryable; the callback flips the row
|
|
832
|
-
// failed→running (compare-and-set) and re-dispatches via the same
|
|
833
|
-
// ledger-driven resume path the boot-recovery sweep uses. Sits under the
|
|
834
|
-
// `authMiddleware` guard above, like cancel/respond.
|
|
835
|
-
app.post("/workflow-runs/:id/retry", async (c) => {
|
|
836
|
-
const id = c.req.param("id");
|
|
837
|
-
const run = db.runs.getRun(id);
|
|
838
|
-
if (!run)
|
|
839
|
-
return c.json({ error: "workflow run not found" }, 404);
|
|
840
|
-
if (run.status !== "failed") {
|
|
841
|
-
return c.json({ error: `cannot retry a run with status '${run.status}'` }, 400);
|
|
842
|
-
}
|
|
843
|
-
if (!config.retryWorkflow) {
|
|
844
|
-
return c.json({ error: "retry not available (runner not wired)" }, 503);
|
|
845
|
-
}
|
|
846
|
-
// Fire-and-forget: restartRun (inside the callback) flips status→running
|
|
847
|
-
// atomically, so a second immediate retry click 400s on the status guard.
|
|
848
|
-
config.retryWorkflow(run, "admin").catch((err) => console.error(`[admin] retry ${id} failed:`, err));
|
|
849
|
-
return c.json({ retrying: id });
|
|
850
|
-
});
|
|
851
|
-
// ── Workflow definitions ─────────────────────────────────────────
|
|
852
|
-
//
|
|
853
|
-
// The dashboard's pipeline visualisation fetches definitions from here so
|
|
854
|
-
// it can render exactly the phases the YAML file declares — including
|
|
855
|
-
// user-defined custom workflows. No hardcoded phase list, no fallback.
|
|
856
|
-
// List all agent workflows for the dashboard's Workflows browser.
|
|
857
|
-
app.get("/workflows", (c) => {
|
|
858
|
-
const defs = listAgentWorkflows();
|
|
859
|
-
const overrides = db.getAllWorkflowOverrides();
|
|
860
|
-
const workflows = defs.map((def) => ({
|
|
861
|
-
name: def.name,
|
|
862
|
-
kind: def.kind,
|
|
863
|
-
description: def.description,
|
|
864
|
-
trigger: def.trigger,
|
|
865
|
-
phaseCount: def.phases.length,
|
|
866
|
-
hasDag: def.phases.some((p) => Array.isArray(p.depends_on) && p.depends_on.length > 0),
|
|
867
|
-
triggerKinds: getWorkflowTriggerKinds(def.name),
|
|
868
|
-
enabled: overrides.get(def.name)?.enabled ?? true,
|
|
869
|
-
}));
|
|
870
|
-
workflows.sort((a, b) => a.name.localeCompare(b.name));
|
|
871
|
-
return c.json({ workflows });
|
|
872
|
-
});
|
|
873
|
-
app.get("/workflows/:name", (c) => {
|
|
874
|
-
const name = c.req.param("name");
|
|
875
|
-
try {
|
|
876
|
-
const def = getWorkflow(name);
|
|
877
|
-
// Return only the dashboard-relevant subset (no prompt template paths,
|
|
878
|
-
// no model overrides) — keeps the surface small and stable for the
|
|
879
|
-
// run-detail pipeline. Use /workflows/:name/full for the editor.
|
|
880
|
-
return c.json({
|
|
881
|
-
workflow: {
|
|
882
|
-
name: def.name,
|
|
883
|
-
kind: def.kind,
|
|
884
|
-
description: def.description,
|
|
885
|
-
phases: def.phases.map((p) => ({
|
|
886
|
-
name: p.name,
|
|
887
|
-
label: p.label ?? p.name,
|
|
888
|
-
type: p.type,
|
|
889
|
-
hasLoop: !!p.loop || !!p.generic_loop,
|
|
890
|
-
approvalGate: p.approval_gate,
|
|
891
|
-
})),
|
|
892
|
-
},
|
|
893
|
-
});
|
|
894
|
-
}
|
|
895
|
-
catch (err) {
|
|
896
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
897
|
-
return c.json({ error: `workflow definition not found: ${name}`, detail: msg }, 404);
|
|
898
|
-
}
|
|
899
|
-
});
|
|
900
|
-
// Full structured definition: every phase field, used by the definition
|
|
901
|
-
// browser to render phase details and the diagram.
|
|
902
|
-
app.get("/workflows/:name/full", (c) => {
|
|
903
|
-
const name = c.req.param("name");
|
|
904
|
-
try {
|
|
905
|
-
const def = getWorkflow(name);
|
|
906
|
-
return c.json({
|
|
907
|
-
workflow: def,
|
|
908
|
-
triggers: getWorkflowTriggers(name),
|
|
909
|
-
enabled: db.isWorkflowEnabled(name),
|
|
910
|
-
});
|
|
911
|
-
}
|
|
912
|
-
catch (err) {
|
|
913
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
914
|
-
return c.json({ error: `workflow definition not found: ${name}`, detail: msg }, 404);
|
|
915
|
-
}
|
|
916
|
-
});
|
|
917
|
-
// Toggle the kill switch. Mirrors POST /crons/:name/toggle. Persisted to
|
|
918
|
-
// `workflow_overrides`; reads happen on every dispatch in
|
|
919
|
-
// `runSimpleWorkflow`, so the change applies to in-flight cron ticks and
|
|
920
|
-
// webhook dispatches without needing a restart.
|
|
921
|
-
app.post("/workflows/:name/toggle", async (c) => {
|
|
922
|
-
const name = c.req.param("name");
|
|
923
|
-
try {
|
|
924
|
-
// Validate the name actually exists before persisting an override.
|
|
925
|
-
getWorkflow(name);
|
|
926
|
-
}
|
|
927
|
-
catch {
|
|
928
|
-
return c.json({ error: `unknown workflow: ${name}` }, 404);
|
|
929
|
-
}
|
|
930
|
-
const current = db.isWorkflowEnabled(name);
|
|
931
|
-
const next = !current;
|
|
932
|
-
db.setWorkflowEnabled(name, next, "admin");
|
|
933
|
-
return c.json({ name, enabled: next });
|
|
934
|
-
});
|
|
935
|
-
// Raw YAML file content — preserves comments and formatting for the
|
|
936
|
-
// dashboard's syntax-highlighted YAML view.
|
|
937
|
-
app.get("/workflows/:name/yaml", (c) => {
|
|
938
|
-
const name = c.req.param("name");
|
|
939
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
940
|
-
return c.json({ error: "invalid workflow name" }, 400);
|
|
941
|
-
}
|
|
942
|
-
try {
|
|
943
|
-
const yaml = loadWorkflowYamlRaw(name);
|
|
944
|
-
return c.text(yaml, 200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
945
|
-
}
|
|
946
|
-
catch (err) {
|
|
947
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
948
|
-
return c.json({ error: `workflow yaml not found: ${name}`, detail: msg }, 404);
|
|
949
|
-
}
|
|
950
|
-
});
|
|
951
|
-
// Read a prompt template referenced by a phase (e.g. ?path=prompts/architect.md).
|
|
952
|
-
// Path is validated by `loadPromptTemplate` to live within workflowDir.
|
|
953
|
-
app.get("/workflows/:name/prompt", (c) => {
|
|
954
|
-
const name = c.req.param("name");
|
|
955
|
-
const promptPath = c.req.query("path");
|
|
956
|
-
if (!promptPath)
|
|
957
|
-
return c.json({ error: "missing ?path query" }, 400);
|
|
958
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
959
|
-
return c.json({ error: "invalid workflow name" }, 400);
|
|
960
|
-
}
|
|
961
|
-
// Restrict to the prompts/ subdirectory — workflows reference templates
|
|
962
|
-
// via `prompts/foo.md` (loader.ts:resolvePromptPath also catches escapes).
|
|
963
|
-
if (!promptPath.startsWith("prompts/") || promptPath.includes("..")) {
|
|
964
|
-
return c.json({ error: "prompt path must be under prompts/" }, 400);
|
|
965
|
-
}
|
|
966
|
-
try {
|
|
967
|
-
const text = loadPromptTemplate(promptPath);
|
|
968
|
-
return c.text(text, 200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
969
|
-
}
|
|
970
|
-
catch (err) {
|
|
971
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
972
|
-
return c.json({ error: `prompt not found`, detail: msg }, 404);
|
|
973
|
-
}
|
|
974
|
-
});
|
|
975
|
-
// Read a skill's SKILL.md file. Used by the phase detail drawer when a
|
|
976
|
-
// phase declares `skill: <name>`.
|
|
977
|
-
app.get("/skills/:name", (c) => {
|
|
978
|
-
const name = c.req.param("name");
|
|
979
|
-
try {
|
|
980
|
-
const text = loadSkillRaw(name);
|
|
981
|
-
return c.text(text, 200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
982
|
-
}
|
|
983
|
-
catch (err) {
|
|
984
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
985
|
-
return c.json({ error: `skill not found: ${name}`, detail: msg }, 404);
|
|
986
|
-
}
|
|
987
|
-
});
|
|
988
|
-
// ── Build assets (server mode) ─────────────────────────────────
|
|
989
|
-
// Read-only views of the externalized handoff docs (architect-plan.md, …)
|
|
990
|
-
// that live in the server store when `buildAssets.location: server`. The
|
|
991
|
-
// store itself rejects path traversal on every segment. With no store dir
|
|
992
|
-
// configured (repo mode) the list endpoints report empty rather than 404 so
|
|
993
|
-
// the dashboard tab degrades gracefully.
|
|
994
|
-
const buildAssetStore = config.buildAssetsDir ? new BuildAssetStore(config.buildAssetsDir) : null;
|
|
995
|
-
function summarizeApproval(approval) {
|
|
996
|
-
return {
|
|
997
|
-
id: approval.id,
|
|
998
|
-
workflowRunId: approval.workflowRunId,
|
|
999
|
-
status: approval.status,
|
|
1000
|
-
gate: approval.gate,
|
|
1001
|
-
summary: approval.summary,
|
|
1002
|
-
respondedBy: approval.respondedBy,
|
|
1003
|
-
respondedAt: approval.respondedAt,
|
|
1004
|
-
createdAt: approval.createdAt,
|
|
1005
|
-
};
|
|
1006
|
-
}
|
|
1007
|
-
function ownerRepoForRun(run) {
|
|
1008
|
-
if (!run)
|
|
1009
|
-
return {};
|
|
1010
|
-
const ctx = run.context ?? {};
|
|
1011
|
-
const ctxOwner = typeof ctx.owner === "string" ? ctx.owner : undefined;
|
|
1012
|
-
const branch = typeof ctx.branch === "string" ? ctx.branch : undefined;
|
|
1013
|
-
const repoField = typeof run.repo === "string" ? run.repo : undefined;
|
|
1014
|
-
let owner;
|
|
1015
|
-
let repo;
|
|
1016
|
-
if (repoField) {
|
|
1017
|
-
if (repoField.includes("/")) {
|
|
1018
|
-
const [maybeOwner, maybeRepo] = repoField.split("/", 2);
|
|
1019
|
-
owner = ctxOwner ?? maybeOwner;
|
|
1020
|
-
repo = maybeRepo;
|
|
1021
|
-
}
|
|
1022
|
-
else {
|
|
1023
|
-
owner = ctxOwner;
|
|
1024
|
-
repo = repoField;
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
const issueKey = buildAssetIssueKey(run.workflowName, run.issueNumber, run.id);
|
|
1028
|
-
return { owner, repo, issueKey, branch };
|
|
1029
|
-
}
|
|
1030
|
-
function runMatchesArtifactTarget(run, owner, repo, key) {
|
|
1031
|
-
if (!run)
|
|
1032
|
-
return false;
|
|
1033
|
-
const { owner: runOwner, repo: runRepo, issueKey } = ownerRepoForRun(run);
|
|
1034
|
-
if (!runOwner || !runRepo || !issueKey)
|
|
1035
|
-
return false;
|
|
1036
|
-
return runOwner === owner && runRepo === repo && issueKey === key;
|
|
1037
|
-
}
|
|
1038
|
-
function computeArtifactMetadata(owner, repo, key, doc) {
|
|
1039
|
-
const approvals = db.approvals.listByArtifact(doc);
|
|
1040
|
-
if (approvals.length === 0) {
|
|
1041
|
-
// No approval references this artifact. Docs that aren't guarded by an
|
|
1042
|
-
// approval remain editable (status.md, executor-summary.md, etc.).
|
|
1043
|
-
return { editable: true, lock: null };
|
|
1044
|
-
}
|
|
1045
|
-
const enriched = approvals.map((approval) => ({
|
|
1046
|
-
approval,
|
|
1047
|
-
run: db.runs.getRun(approval.workflowRunId),
|
|
1048
|
-
}));
|
|
1049
|
-
const matching = enriched.filter(({ run }) => runMatchesArtifactTarget(run, owner, repo, key));
|
|
1050
|
-
const latestMatching = matching[0];
|
|
1051
|
-
if (latestMatching && latestMatching.approval.status === "pending") {
|
|
1052
|
-
return { editable: true, lock: null };
|
|
1053
|
-
}
|
|
1054
|
-
if (!latestMatching) {
|
|
1055
|
-
const latest = enriched[0];
|
|
1056
|
-
return {
|
|
1057
|
-
editable: false,
|
|
1058
|
-
lock: {
|
|
1059
|
-
reason: "unverified_owner",
|
|
1060
|
-
message: "Could not verify owner/repo/issue for the approval",
|
|
1061
|
-
approval: summarizeApproval(latest.approval),
|
|
1062
|
-
},
|
|
1063
|
-
};
|
|
1064
|
-
}
|
|
1065
|
-
const reason = latestMatching.approval.status === "rejected"
|
|
1066
|
-
? "approval_rejected"
|
|
1067
|
-
: "approval_resolved";
|
|
1068
|
-
return {
|
|
1069
|
-
editable: false,
|
|
1070
|
-
lock: {
|
|
1071
|
-
reason,
|
|
1072
|
-
approval: summarizeApproval(latestMatching.approval),
|
|
1073
|
-
},
|
|
1074
|
-
};
|
|
1075
|
-
}
|
|
1076
|
-
// List the run keys (issue-N / <workflow>-<id>) stored for ?repo=owner/repo.
|
|
1077
|
-
app.get("/artifacts", (c) => {
|
|
1078
|
-
const repoParam = c.req.query("repo");
|
|
1079
|
-
if (!repoParam || !repoParam.includes("/")) {
|
|
1080
|
-
return c.json({ error: "missing or invalid ?repo=owner/repo" }, 400);
|
|
1081
|
-
}
|
|
1082
|
-
if (!buildAssetStore)
|
|
1083
|
-
return c.json({ keys: [] });
|
|
1084
|
-
const [owner, repo] = repoParam.split("/", 2);
|
|
1085
|
-
try {
|
|
1086
|
-
return c.json({ keys: buildAssetStore.listKeys(owner, repo) });
|
|
1087
|
-
}
|
|
1088
|
-
catch (err) {
|
|
1089
|
-
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
|
|
1090
|
-
}
|
|
1091
|
-
});
|
|
1092
|
-
// List the doc filenames stored for one run.
|
|
1093
|
-
app.get("/artifacts/:owner/:repo/:key", (c) => {
|
|
1094
|
-
if (!buildAssetStore)
|
|
1095
|
-
return c.json({ files: [] });
|
|
1096
|
-
const { owner, repo, key } = c.req.param();
|
|
1097
|
-
try {
|
|
1098
|
-
return c.json({ files: buildAssetStore.listFiles({ owner, repo, issueKey: key }) });
|
|
1099
|
-
}
|
|
1100
|
-
catch (err) {
|
|
1101
|
-
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
|
|
1102
|
-
}
|
|
1103
|
-
});
|
|
1104
|
-
// Read one doc. Binary artifacts (PNG screenshot evidence, /demo mp4/webm) are
|
|
1105
|
-
// served as raw bytes with the right Content-Type and Range support (so the
|
|
1106
|
-
// dashboard <video> can seek); everything else is text/plain (the dashboard
|
|
1107
|
-
// renders it with marked/DOMPurify).
|
|
1108
|
-
app.get("/artifacts/:owner/:repo/:key/:doc", (c) => {
|
|
1109
|
-
if (!buildAssetStore)
|
|
1110
|
-
return c.json({ error: "build-assets store not configured" }, 404);
|
|
1111
|
-
const { owner, repo, key, doc } = c.req.param();
|
|
1112
|
-
try {
|
|
1113
|
-
const binMime = binaryMimeForArtifact(doc);
|
|
1114
|
-
if (binMime) {
|
|
1115
|
-
const buf = buildAssetStore.readBuffer({ owner, repo, issueKey: key }, doc);
|
|
1116
|
-
if (buf === undefined)
|
|
1117
|
-
return c.json({ error: `doc not found: ${doc}` }, 404);
|
|
1118
|
-
return rangeResponse(c.req.header("range"), buf, binMime, "no-store");
|
|
1119
|
-
}
|
|
1120
|
-
const content = buildAssetStore.read({ owner, repo, issueKey: key }, doc);
|
|
1121
|
-
if (content === undefined)
|
|
1122
|
-
return c.json({ error: `doc not found: ${doc}` }, 404);
|
|
1123
|
-
return c.text(content, 200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1124
|
-
}
|
|
1125
|
-
catch (err) {
|
|
1126
|
-
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
|
|
1127
|
-
}
|
|
1128
|
-
});
|
|
1129
|
-
app.get("/artifacts/:owner/:repo/:key/:doc/metadata", (c) => {
|
|
1130
|
-
if (!buildAssetStore)
|
|
1131
|
-
return c.json({ error: "build-assets store not configured" }, 404);
|
|
1132
|
-
const { owner, repo, key, doc } = c.req.param();
|
|
1133
|
-
try {
|
|
1134
|
-
// Validate the doc path upfront so traversal attempts surface as 400s.
|
|
1135
|
-
buildAssetStore.fileFor({ owner, repo, issueKey: key }, doc);
|
|
1136
|
-
const metadata = computeArtifactMetadata(owner, repo, key, doc);
|
|
1137
|
-
return c.json(metadata);
|
|
1138
|
-
}
|
|
1139
|
-
catch (err) {
|
|
1140
|
-
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
|
|
1141
|
-
}
|
|
1142
|
-
});
|
|
1143
|
-
// Overwrite one doc with the raw markdown request body. The store creates
|
|
1144
|
-
// the run dir on demand and rejects path traversal on every segment.
|
|
1145
|
-
app.put("/artifacts/:owner/:repo/:key/:doc", async (c) => {
|
|
1146
|
-
if (!buildAssetStore)
|
|
1147
|
-
return c.json({ error: "build-assets store not configured" }, 404);
|
|
1148
|
-
const { owner, repo, key, doc } = c.req.param();
|
|
1149
|
-
try {
|
|
1150
|
-
// Validate the doc path before hitting the approval gate.
|
|
1151
|
-
buildAssetStore.fileFor({ owner, repo, issueKey: key }, doc);
|
|
1152
|
-
const metadata = computeArtifactMetadata(owner, repo, key, doc);
|
|
1153
|
-
if (!metadata.editable) {
|
|
1154
|
-
return c.json({ error: "artifact_locked", lock: metadata.lock }, 403);
|
|
1155
|
-
}
|
|
1156
|
-
const body = await c.req.text();
|
|
1157
|
-
buildAssetStore.write({ owner, repo, issueKey: key }, doc, body);
|
|
1158
|
-
return c.json({ ok: true });
|
|
1159
|
-
}
|
|
1160
|
-
catch (err) {
|
|
1161
|
-
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
|
|
1162
|
-
}
|
|
1163
|
-
});
|
|
1164
|
-
// ── Approval Gates ─────────────────────────────────────────────
|
|
1165
|
-
app.get("/approvals", (c) => {
|
|
1166
|
-
const approvals = db.approvals.listPending();
|
|
1167
|
-
return c.json({ approvals });
|
|
1168
|
-
});
|
|
1169
|
-
// Single approval, enriched with an `artifactRef` the focused approval view
|
|
1170
|
-
// uses to open the right doc. `artifactRef` is null when the gate carries no
|
|
1171
|
-
// artifact. In server mode the ref points at the editable store doc; in repo
|
|
1172
|
-
// mode it carries a GitHub blob URL (the doc is committed on the branch).
|
|
1173
|
-
app.get("/approvals/:id", (c) => {
|
|
1174
|
-
const approval = db.approvals.getById(c.req.param("id"));
|
|
1175
|
-
if (!approval)
|
|
1176
|
-
return c.json({ error: "approval not found" }, 404);
|
|
1177
|
-
const run = db.runs.getRun(approval.workflowRunId);
|
|
1178
|
-
let artifactRef = null;
|
|
1179
|
-
if (approval.artifact && run && run.repo) {
|
|
1180
|
-
// `workflow_runs.repo` is the BARE repo name and `owner` lives in
|
|
1181
|
-
// run.context (set by simple.ts) — except in tests / legacy rows that
|
|
1182
|
-
// may store "owner/repo". Handle both: prefer context.owner, else split.
|
|
1183
|
-
const ctx = run.context ?? {};
|
|
1184
|
-
const ctxOwner = typeof ctx.owner === "string" ? ctx.owner : undefined;
|
|
1185
|
-
const repo = run.repo.includes("/") ? run.repo.split("/")[1] : run.repo;
|
|
1186
|
-
const owner = ctxOwner ?? (run.repo.includes("/") ? run.repo.split("/")[0] : "");
|
|
1187
|
-
if (owner && repo) {
|
|
1188
|
-
const mode = config.buildAssets ?? "repo";
|
|
1189
|
-
const issueKey = buildAssetIssueKey(run.workflowName, run.issueNumber, run.id);
|
|
1190
|
-
const issueDir = typeof ctx.issueDir === "string" ? ctx.issueDir : `.lastlight/${issueKey}`;
|
|
1191
|
-
const branch = typeof ctx.branch === "string" ? ctx.branch : undefined;
|
|
1192
|
-
const githubUrl = mode === "repo" && branch
|
|
1193
|
-
? `https://github.com/${owner}/${repo}/blob/${encodeURIComponent(branch)}/${issueDir}/${approval.artifact}`
|
|
1194
|
-
: undefined;
|
|
1195
|
-
artifactRef = { mode, owner, repo, issueKey, doc: approval.artifact, githubUrl };
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
return c.json({ approval, artifactRef, run: run ?? null });
|
|
1199
|
-
});
|
|
1200
|
-
app.post("/approvals/:id/respond", async (c) => {
|
|
1201
|
-
const id = c.req.param("id");
|
|
1202
|
-
const body = await c.req.json();
|
|
1203
|
-
const approval = db.approvals.getById(id);
|
|
1204
|
-
if (!approval)
|
|
1205
|
-
return c.json({ error: "approval not found" }, 404);
|
|
1206
|
-
if (approval.status !== "pending")
|
|
1207
|
-
return c.json({ error: `already ${approval.status}` }, 400);
|
|
1208
|
-
if (body.decision === "rejected") {
|
|
1209
|
-
// One transaction: respond 'rejected' + fail the run.
|
|
1210
|
-
db.runs.resolveGateAndFail(id, "admin", body.reason);
|
|
1211
|
-
}
|
|
1212
|
-
else {
|
|
1213
|
-
// Record the approval, then let resumeWorkflow flip the run back to
|
|
1214
|
-
// `running` — but only as part of an actual dispatch. resumeWorkflow
|
|
1215
|
-
// validates the target (GitHub App present, triggerId is an owner/repo#N
|
|
1216
|
-
// issue) and calls setRunning right before dispatching, so a
|
|
1217
|
-
// non-resumable approval (no resumeWorkflow wired, App down, or a
|
|
1218
|
-
// non-issue trigger) leaves the run paused rather than flipping it to
|
|
1219
|
-
// `running` with no worker. We deliberately do NOT use the atomic
|
|
1220
|
-
// resolveGateAndResume here: unlike the GitHub/Slack path, the dashboard
|
|
1221
|
-
// can't prove a dispatch will follow before responding.
|
|
1222
|
-
//
|
|
1223
|
-
// respond() is a compare-and-set on the still-pending row, so a racing
|
|
1224
|
-
// responder (the status check above is a TOCTOU read) changes 0 rows.
|
|
1225
|
-
// Only the winner resumes — the loser must not dispatch a second time.
|
|
1226
|
-
const changed = db.approvals.respond(id, "approved", "admin", body.reason);
|
|
1227
|
-
if (changed !== 1) {
|
|
1228
|
-
return c.json({ error: "already resolved" }, 409);
|
|
1229
|
-
}
|
|
1230
|
-
const workflowRun = db.runs.getRun(approval.workflowRunId);
|
|
1231
|
-
if (workflowRun && config.resumeWorkflow) {
|
|
1232
|
-
config.resumeWorkflow(workflowRun, "admin").catch((err) => {
|
|
1233
|
-
console.error(`[admin] Failed to resume workflow ${workflowRun.id}:`, err);
|
|
1234
|
-
});
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
return c.json({ status: body.decision });
|
|
1238
|
-
});
|
|
1239
|
-
// ── Crons ──────────────────────────────────────────────────────
|
|
1240
|
-
// List every cron defined in workflows/cron-*.yaml, merged with the
|
|
1241
|
-
// override row (if any) and the live scheduler state.
|
|
1242
|
-
app.get("/crons", (c) => {
|
|
1243
|
-
const overrides = db.getAllCronOverrides();
|
|
1244
|
-
const liveByName = new Map((config.cronScheduler?.list() ?? []).map((j) => [j.name, j]));
|
|
1245
|
-
const defs = getCronWorkflows();
|
|
1246
|
-
const crons = defs.map((def) => {
|
|
1247
|
-
const override = overrides.get(def.name) ?? null;
|
|
1248
|
-
const enabled = override ? override.enabled : true;
|
|
1249
|
-
const live = liveByName.get(def.name) ?? null;
|
|
1250
|
-
const recentFailures = db.executions.consecutiveFailures(def.workflow);
|
|
1251
|
-
// Find the most recent workflow_run for this cron's workflow
|
|
1252
|
-
const recent = db.runs.listRecent(50).find((r) => r.workflowName === def.workflow);
|
|
1253
|
-
return {
|
|
1254
|
-
name: def.name,
|
|
1255
|
-
workflow: def.workflow,
|
|
1256
|
-
schedule: override?.schedule ?? def.schedule,
|
|
1257
|
-
originalSchedule: def.schedule,
|
|
1258
|
-
enabled,
|
|
1259
|
-
registered: !!live,
|
|
1260
|
-
nextRun: live?.nextRun?.toISOString() ?? null,
|
|
1261
|
-
lastRun: recent?.startedAt ?? null,
|
|
1262
|
-
lastStatus: recent?.status ?? null,
|
|
1263
|
-
recentFailures,
|
|
1264
|
-
context: { repos: getManagedRepos(), ...def.context },
|
|
1265
|
-
override: override
|
|
1266
|
-
? {
|
|
1267
|
-
updatedAt: override.updatedAt,
|
|
1268
|
-
updatedBy: override.updatedBy,
|
|
1269
|
-
hasScheduleOverride: override.schedule != null,
|
|
1270
|
-
}
|
|
1271
|
-
: null,
|
|
1272
|
-
};
|
|
1273
|
-
});
|
|
1274
|
-
return c.json({ crons });
|
|
1275
|
-
});
|
|
1276
|
-
// Toggle the enabled bit for a cron. Updates the scheduler in lockstep so
|
|
1277
|
-
// the change takes effect immediately (no restart).
|
|
1278
|
-
app.post("/crons/:name/toggle", async (c) => {
|
|
1279
|
-
if (!config.cronScheduler) {
|
|
1280
|
-
return c.json({ error: "cron scheduler not configured" }, 503);
|
|
1281
|
-
}
|
|
1282
|
-
const name = c.req.param("name");
|
|
1283
|
-
const def = getCronWorkflows().find((d) => d.name === name);
|
|
1284
|
-
if (!def)
|
|
1285
|
-
return c.json({ error: `cron not found: ${name}` }, 404);
|
|
1286
|
-
const override = db.getCronOverride(name);
|
|
1287
|
-
const currentlyEnabled = override ? override.enabled : true;
|
|
1288
|
-
const nextEnabled = !currentlyEnabled;
|
|
1289
|
-
db.setCronOverride(name, { enabled: nextEnabled, updatedBy: "admin" });
|
|
1290
|
-
if (nextEnabled) {
|
|
1291
|
-
// Re-register with the (possibly overridden) schedule
|
|
1292
|
-
const schedule = override?.schedule || def.schedule;
|
|
1293
|
-
if (config.cronScheduler.has(name)) {
|
|
1294
|
-
config.cronScheduler.update({
|
|
1295
|
-
name,
|
|
1296
|
-
schedule,
|
|
1297
|
-
workflow: def.workflow,
|
|
1298
|
-
context: { repos: getManagedRepos(), ...def.context },
|
|
1299
|
-
});
|
|
1300
|
-
}
|
|
1301
|
-
else {
|
|
1302
|
-
config.cronScheduler.register({
|
|
1303
|
-
name,
|
|
1304
|
-
schedule,
|
|
1305
|
-
workflow: def.workflow,
|
|
1306
|
-
context: { repos: getManagedRepos(), ...def.context },
|
|
1307
|
-
});
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
else {
|
|
1311
|
-
config.cronScheduler.unregister(name);
|
|
1312
|
-
}
|
|
1313
|
-
return c.json({ name, enabled: nextEnabled });
|
|
1314
|
-
});
|
|
1315
|
-
// Persist a schedule override and apply it to the scheduler. Validates the
|
|
1316
|
-
// expression with croner before saving so a bad expression returns 400 and
|
|
1317
|
-
// the live cron isn't disturbed.
|
|
1318
|
-
app.post("/crons/:name/schedule", async (c) => {
|
|
1319
|
-
if (!config.cronScheduler) {
|
|
1320
|
-
return c.json({ error: "cron scheduler not configured" }, 503);
|
|
1321
|
-
}
|
|
1322
|
-
const name = c.req.param("name");
|
|
1323
|
-
const def = getCronWorkflows().find((d) => d.name === name);
|
|
1324
|
-
if (!def)
|
|
1325
|
-
return c.json({ error: `cron not found: ${name}` }, 404);
|
|
1326
|
-
const body = await c.req.json();
|
|
1327
|
-
const schedule = (body.schedule ?? "").trim();
|
|
1328
|
-
if (!schedule)
|
|
1329
|
-
return c.json({ error: "schedule is required" }, 400);
|
|
1330
|
-
try {
|
|
1331
|
-
// Construct a paused Cron purely to validate the pattern, then dispose.
|
|
1332
|
-
const probe = new Cron(schedule, { paused: true }, () => { });
|
|
1333
|
-
probe.stop();
|
|
1334
|
-
}
|
|
1335
|
-
catch (err) {
|
|
1336
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1337
|
-
return c.json({ error: `invalid schedule: ${msg}` }, 400);
|
|
1338
|
-
}
|
|
1339
|
-
db.setCronOverride(name, { schedule, updatedBy: "admin" });
|
|
1340
|
-
const override = db.getCronOverride(name);
|
|
1341
|
-
if (override?.enabled !== false) {
|
|
1342
|
-
config.cronScheduler.update({
|
|
1343
|
-
name,
|
|
1344
|
-
schedule,
|
|
1345
|
-
workflow: def.workflow,
|
|
1346
|
-
context: { repos: getManagedRepos(), ...def.context },
|
|
1347
|
-
});
|
|
1348
|
-
}
|
|
1349
|
-
return c.json({ name, schedule });
|
|
1350
|
-
});
|
|
1351
|
-
// Drop the override row and re-register the cron at its YAML default.
|
|
1352
|
-
app.delete("/crons/:name/override", (c) => {
|
|
1353
|
-
if (!config.cronScheduler) {
|
|
1354
|
-
return c.json({ error: "cron scheduler not configured" }, 503);
|
|
1355
|
-
}
|
|
1356
|
-
const name = c.req.param("name");
|
|
1357
|
-
const def = getCronWorkflows().find((d) => d.name === name);
|
|
1358
|
-
if (!def)
|
|
1359
|
-
return c.json({ error: `cron not found: ${name}` }, 404);
|
|
1360
|
-
db.clearCronOverride(name);
|
|
1361
|
-
if (config.cronScheduler.has(name)) {
|
|
1362
|
-
config.cronScheduler.update({
|
|
1363
|
-
name,
|
|
1364
|
-
schedule: def.schedule,
|
|
1365
|
-
workflow: def.workflow,
|
|
1366
|
-
context: { repos: getManagedRepos(), ...def.context },
|
|
1367
|
-
});
|
|
1368
|
-
}
|
|
1369
|
-
else {
|
|
1370
|
-
config.cronScheduler.register({
|
|
1371
|
-
name,
|
|
1372
|
-
schedule: def.schedule,
|
|
1373
|
-
workflow: def.workflow,
|
|
1374
|
-
context: { repos: getManagedRepos(), ...def.context },
|
|
1375
|
-
});
|
|
1376
|
-
}
|
|
1377
|
-
return c.json({ name, schedule: def.schedule, enabled: true });
|
|
1378
|
-
});
|
|
1379
|
-
return app;
|
|
1380
|
-
}
|
|
1381
|
-
//# sourceMappingURL=routes.js.map
|