@moondeck/station 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/meta/_journal.json +223 -0
- package/db/schema.ts +850 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1453 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +891 -0
- package/lib/env.ts +195 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +131 -4
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2583 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2899 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6349 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5545 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
package/src/ui/server.ts
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local host UI HTTP server (ADR-028, docs/host-ui.md).
|
|
3
|
+
*
|
|
4
|
+
* Bound to 127.0.0.1 ONLY — never 0.0.0.0, never the tailnet. No auth (the
|
|
5
|
+
* localhost binding is the perimeter — see ADR-028 security stance), no CORS,
|
|
6
|
+
* read-only. Serves the static UI from `host-agent/ui/` and a small JSON API
|
|
7
|
+
* under `/api/*`. Every JSON body is parsed through its zod schema before send
|
|
8
|
+
* so the API can never emit an off-contract shape.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
13
|
+
import {
|
|
14
|
+
createServer,
|
|
15
|
+
type IncomingMessage,
|
|
16
|
+
type Server,
|
|
17
|
+
type ServerResponse,
|
|
18
|
+
} from "node:http";
|
|
19
|
+
import type { AddressInfo } from "node:net";
|
|
20
|
+
import { hostname } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
import { desc, isNull } from "drizzle-orm";
|
|
24
|
+
import type { ZodType } from "zod";
|
|
25
|
+
|
|
26
|
+
import { agentMode } from "../../lib/agents/mode";
|
|
27
|
+
import { schema, type Db } from "../../lib/db";
|
|
28
|
+
import { whileOnOwnCredentials } from "../../lib/cloud-credential-host";
|
|
29
|
+
import { getOrchestrator } from "../../lib/orchestrator";
|
|
30
|
+
import { parsePreviewPortRuntimes } from "../../lib/preview-ports";
|
|
31
|
+
import { getCloudState } from "../../lib/cloud-state";
|
|
32
|
+
import { dockerCli } from "../../lib/docker-exec";
|
|
33
|
+
import { STATION } from "../station-identity";
|
|
34
|
+
import type { ContainerRuntimeState } from "../../lib/container-runtime";
|
|
35
|
+
import {
|
|
36
|
+
connectEngine,
|
|
37
|
+
disconnectEngine,
|
|
38
|
+
engineCliStatuses,
|
|
39
|
+
installEngineCli,
|
|
40
|
+
engineCatalog,
|
|
41
|
+
engineStatuses,
|
|
42
|
+
isEngineKind,
|
|
43
|
+
type EngineSeams,
|
|
44
|
+
} from "../../lib/engines";
|
|
45
|
+
import {
|
|
46
|
+
addEngineAccount,
|
|
47
|
+
listEngineAccounts,
|
|
48
|
+
removeEngineAccount,
|
|
49
|
+
} from "../../lib/engine-accounts";
|
|
50
|
+
import { ensureStandardImage } from "../../lib/standard-image";
|
|
51
|
+
import {
|
|
52
|
+
CloudResponse,
|
|
53
|
+
ContainerRuntimeResponse,
|
|
54
|
+
EngineOpResponse,
|
|
55
|
+
EnginesResponse,
|
|
56
|
+
EventsResponse,
|
|
57
|
+
StatusResponse,
|
|
58
|
+
TasksResponse,
|
|
59
|
+
UsersResponse,
|
|
60
|
+
type TaskRow,
|
|
61
|
+
} from "./types";
|
|
62
|
+
|
|
63
|
+
const HOST = "127.0.0.1";
|
|
64
|
+
const MAX_PORT_ATTEMPTS = 10;
|
|
65
|
+
export const RUNTIME_RECHECK_CONTROL_HEADER = "runtime-recheck-v1";
|
|
66
|
+
|
|
67
|
+
export interface UiServerOptions {
|
|
68
|
+
/** Host runtime DB (read-only here). */
|
|
69
|
+
db: Db;
|
|
70
|
+
/** Static asset directory (`host-agent/ui`). */
|
|
71
|
+
uiDir: string;
|
|
72
|
+
/** File to persist the chosen port to (`$UAI_DATA_DIR/host.port`). */
|
|
73
|
+
portFilePath: string;
|
|
74
|
+
/**
|
|
75
|
+
* ADR-136 sign-in box: answer only the read-only status trio
|
|
76
|
+
* (`/api/status`, `/api/cloud`, `/api/tasks`) so `uai-host status` — and
|
|
77
|
+
* the managed installer's post-start check, which parses it — can see the
|
|
78
|
+
* box come up and connect. Every write and every other read (engines,
|
|
79
|
+
* users, events, the static UI) is 404: the box holds no credential at
|
|
80
|
+
* rest and runs no tasks, and nobody sits at it.
|
|
81
|
+
*/
|
|
82
|
+
monitorOnly?: boolean;
|
|
83
|
+
/** First port to try (5876 or UAI_HOST_UI_PORT). 0 lets the OS pick (tests). */
|
|
84
|
+
startPort: number;
|
|
85
|
+
// Facts for /api/status:
|
|
86
|
+
version: string;
|
|
87
|
+
cloudUrl: string;
|
|
88
|
+
hostId: string;
|
|
89
|
+
logPath: string;
|
|
90
|
+
/** Best-effort container memory by compose project; null on failure. */
|
|
91
|
+
taskMemory?: (composeProject: string) => Promise<number | null>;
|
|
92
|
+
/**
|
|
93
|
+
* Re-advertise host capabilities to the cloud (ADR-021). Wired from main.ts
|
|
94
|
+
* (`sendCapabilities`) so an engine connect/disconnect updates the cloud's
|
|
95
|
+
* task picker promptly. Optional (tests omit it).
|
|
96
|
+
*/
|
|
97
|
+
readvertise?: () => void;
|
|
98
|
+
/**
|
|
99
|
+
* Stop a task's containers (resumable) — wired from main.ts to the
|
|
100
|
+
* orchestrator. The host operator's "reclaim my machine" control (ADR-028).
|
|
101
|
+
* Optional (tests omit it).
|
|
102
|
+
*/
|
|
103
|
+
stopTask?: (taskId: string) => Promise<{ ok: boolean; error?: string }>;
|
|
104
|
+
/**
|
|
105
|
+
* Engine probe seams for /api/engines. Production omits this and gets the
|
|
106
|
+
* real filesystem + login-shell probes; tests pin `loginShellPath` so the
|
|
107
|
+
* endpoint never spawns a login shell (which is bounded, but real, I/O).
|
|
108
|
+
*/
|
|
109
|
+
engineSeams?: Partial<EngineSeams>;
|
|
110
|
+
/** ADR-101 local control/read surface. Optional for embedding/tests. */
|
|
111
|
+
runtimeState?: () => ContainerRuntimeState;
|
|
112
|
+
recheckRuntime?: () => Promise<ContainerRuntimeState>;
|
|
113
|
+
/** False while the selected backend is unavailable or still recovering.
|
|
114
|
+
* Credential edits remain usable, but must not kick Docker maintenance. */
|
|
115
|
+
containersOperational?: () => boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface UiServerHandle {
|
|
119
|
+
server: Server;
|
|
120
|
+
port: number;
|
|
121
|
+
close: () => Promise<void>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The GET routes a `monitorOnly` server still answers (see UiServerOptions). */
|
|
125
|
+
const MONITOR_ONLY_ROUTES: ReadonlySet<string> = new Set([
|
|
126
|
+
"/api/status",
|
|
127
|
+
"/api/cloud",
|
|
128
|
+
"/api/tasks",
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
/** Start the local UI server, binding to 127.0.0.1 with port fallback. */
|
|
132
|
+
export async function startUiServer(
|
|
133
|
+
opts: UiServerOptions,
|
|
134
|
+
): Promise<UiServerHandle> {
|
|
135
|
+
const memory = opts.taskMemory ? taskMemorySampler(opts.taskMemory) : null;
|
|
136
|
+
const server = createServer((req, res) => {
|
|
137
|
+
void handle(req, res, opts, memory);
|
|
138
|
+
});
|
|
139
|
+
const port = await listenWithFallback(server, opts.startPort);
|
|
140
|
+
await writeFile(opts.portFilePath, `${port}\n`, "utf8");
|
|
141
|
+
return {
|
|
142
|
+
server,
|
|
143
|
+
port,
|
|
144
|
+
close: () =>
|
|
145
|
+
new Promise<void>((resolve) => server.close(() => resolve())),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function listenWithFallback(
|
|
150
|
+
server: Server,
|
|
151
|
+
startPort: number,
|
|
152
|
+
): Promise<number> {
|
|
153
|
+
let lastErr: NodeJS.ErrnoException | null = null;
|
|
154
|
+
for (let i = 0; i < MAX_PORT_ATTEMPTS; i += 1) {
|
|
155
|
+
const port = startPort === 0 ? 0 : startPort + i;
|
|
156
|
+
try {
|
|
157
|
+
await new Promise<void>((resolve, reject) => {
|
|
158
|
+
const onError = (err: NodeJS.ErrnoException): void => {
|
|
159
|
+
server.removeListener("listening", onListening);
|
|
160
|
+
reject(err);
|
|
161
|
+
};
|
|
162
|
+
const onListening = (): void => {
|
|
163
|
+
server.removeListener("error", onError);
|
|
164
|
+
resolve();
|
|
165
|
+
};
|
|
166
|
+
server.once("error", onError);
|
|
167
|
+
server.once("listening", onListening);
|
|
168
|
+
// 127.0.0.1 ONLY. Binding to 0.0.0.0 here would expose the UI off-box.
|
|
169
|
+
server.listen(port, HOST);
|
|
170
|
+
});
|
|
171
|
+
return (server.address() as AddressInfo).port;
|
|
172
|
+
} catch (err) {
|
|
173
|
+
lastErr = err as NodeJS.ErrnoException;
|
|
174
|
+
if (lastErr.code === "EADDRINUSE" && startPort !== 0) continue;
|
|
175
|
+
throw lastErr;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
throw new Error(
|
|
179
|
+
`no free UI port in [${startPort}, ${startPort + MAX_PORT_ATTEMPTS - 1}]` +
|
|
180
|
+
(lastErr ? `: ${lastErr.message}` : ""),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The local UI's writes of an engine credential (a sign-in, a key, an
|
|
185
|
+
* account): refused once the runner is off its own credentials. */
|
|
186
|
+
const ENGINE_CREDENTIAL_ROUTES: ReadonlySet<string> = new Set([
|
|
187
|
+
"/api/engines/connect",
|
|
188
|
+
"/api/engines/disconnect",
|
|
189
|
+
"/api/engines/accounts/connect",
|
|
190
|
+
"/api/engines/accounts/add",
|
|
191
|
+
"/api/engines/accounts/remove",
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
async function handleEngineCredentialWrite(
|
|
195
|
+
path: string,
|
|
196
|
+
req: IncomingMessage,
|
|
197
|
+
res: ServerResponse,
|
|
198
|
+
opts: UiServerOptions,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
switch (path) {
|
|
201
|
+
case "/api/engines/connect":
|
|
202
|
+
return handleEngineConnect(req, res, opts);
|
|
203
|
+
case "/api/engines/disconnect":
|
|
204
|
+
return handleEngineDisconnect(req, res, opts);
|
|
205
|
+
case "/api/engines/accounts/connect":
|
|
206
|
+
return handleEngineAccountConnect(req, res, opts);
|
|
207
|
+
case "/api/engines/accounts/add":
|
|
208
|
+
return handleEngineAccountAdd(req, res, opts);
|
|
209
|
+
case "/api/engines/accounts/remove":
|
|
210
|
+
return handleEngineAccountRemove(req, res, opts);
|
|
211
|
+
default:
|
|
212
|
+
return sendError(res, 404, `no such endpoint: ${path}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function handle(
|
|
217
|
+
req: IncomingMessage,
|
|
218
|
+
res: ServerResponse,
|
|
219
|
+
opts: UiServerOptions,
|
|
220
|
+
memory: TaskMemorySampler | null,
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
const path = (req.url ?? "/").split("?")[0] ?? "/";
|
|
223
|
+
const method = req.method ?? "GET";
|
|
224
|
+
try {
|
|
225
|
+
if (opts.monitorOnly && !MONITOR_ONLY_ROUTES.has(path)) {
|
|
226
|
+
return sendError(res, 404, `no such endpoint: ${path}`);
|
|
227
|
+
}
|
|
228
|
+
// POST — engine connect/disconnect (the only writes; localhost-bound, so
|
|
229
|
+
// the loopback binding is the perimeter, ADR-028).
|
|
230
|
+
if (method === "POST") {
|
|
231
|
+
// ADR-154 §13: an engine credential is written here only while the
|
|
232
|
+
// runner is on its own credentials, as work on them that leaving them
|
|
233
|
+
// waits for; off them it keeps none, and Connections in Uai Cloud
|
|
234
|
+
// holds them.
|
|
235
|
+
if (ENGINE_CREDENTIAL_ROUTES.has(path)) {
|
|
236
|
+
const write = whileOnOwnCredentials(() => handleEngineCredentialWrite(path, req, res, opts));
|
|
237
|
+
if (write === null) {
|
|
238
|
+
return sendError(
|
|
239
|
+
res,
|
|
240
|
+
409,
|
|
241
|
+
"this station reads its credentials from Moondeck Cloud; manage them in Connections",
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
return await write;
|
|
245
|
+
}
|
|
246
|
+
switch (path) {
|
|
247
|
+
case "/api/engines/install":
|
|
248
|
+
return await handleEngineInstall(req, res);
|
|
249
|
+
case "/api/tasks/stop":
|
|
250
|
+
return await handleTaskStop(req, res, opts);
|
|
251
|
+
case "/api/runtime/recheck": {
|
|
252
|
+
// Loopback is not a browser CSRF boundary. Requiring a non-simple
|
|
253
|
+
// header is deliberate even though its value is not a secret:
|
|
254
|
+
// hostile pages cannot attach it without a CORS preflight, and this
|
|
255
|
+
// server grants no CORS permission. The local CLI is the caller.
|
|
256
|
+
if (
|
|
257
|
+
req.headers["x-uai-local-control"] !==
|
|
258
|
+
RUNTIME_RECHECK_CONTROL_HEADER
|
|
259
|
+
) {
|
|
260
|
+
return sendError(res, 403, "local control header required");
|
|
261
|
+
}
|
|
262
|
+
if (!opts.recheckRuntime) {
|
|
263
|
+
return sendError(res, 501, "runtime recheck is unavailable");
|
|
264
|
+
}
|
|
265
|
+
return sendJson(
|
|
266
|
+
res,
|
|
267
|
+
ContainerRuntimeResponse,
|
|
268
|
+
await opts.recheckRuntime(),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return sendError(res, 404, `no such endpoint: ${path}`);
|
|
273
|
+
}
|
|
274
|
+
if (method !== "GET") {
|
|
275
|
+
return sendError(res, 405, "method not allowed");
|
|
276
|
+
}
|
|
277
|
+
switch (path) {
|
|
278
|
+
case "/api/status":
|
|
279
|
+
return sendJson(res, StatusResponse, {
|
|
280
|
+
pid: process.pid,
|
|
281
|
+
uptime: process.uptime(),
|
|
282
|
+
version: opts.version,
|
|
283
|
+
logPath: opts.logPath,
|
|
284
|
+
cloudUrl: opts.cloudUrl,
|
|
285
|
+
hostName: hostname(),
|
|
286
|
+
hostId: opts.hostId,
|
|
287
|
+
agentMode: agentMode(),
|
|
288
|
+
containerRuntime: opts.runtimeState?.(),
|
|
289
|
+
product: STATION.packageName,
|
|
290
|
+
});
|
|
291
|
+
case "/api/cloud":
|
|
292
|
+
return sendJson(res, CloudResponse, getCloudState());
|
|
293
|
+
case "/api/tasks":
|
|
294
|
+
return sendJson(res, TasksResponse, await tasksBody(opts, memory));
|
|
295
|
+
case "/api/events":
|
|
296
|
+
return sendJson(res, EventsResponse, eventsBody(opts));
|
|
297
|
+
case "/api/users":
|
|
298
|
+
return sendJson(res, UsersResponse, usersBody(opts));
|
|
299
|
+
case "/api/engines":
|
|
300
|
+
return sendJson(res, EnginesResponse, await enginesBody(opts.engineSeams));
|
|
301
|
+
}
|
|
302
|
+
if (path.startsWith("/api/")) {
|
|
303
|
+
return sendError(res, 404, `no such endpoint: ${path}`);
|
|
304
|
+
}
|
|
305
|
+
return await serveStatic(res, opts.uiDir, path);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return sendError(res, 500, err instanceof Error ? err.message : "internal error");
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// --- engines ----------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
/** Engine kinds that participate in the multi-account model (ADR-076). */
|
|
314
|
+
const ACCOUNT_KINDS = ["claude", "codex", "opencode"] as const;
|
|
315
|
+
|
|
316
|
+
async function enginesBody(
|
|
317
|
+
seams: Partial<EngineSeams> = {},
|
|
318
|
+
): Promise<EnginesResponse> {
|
|
319
|
+
const accounts: Record<string, ReturnType<typeof listEngineAccounts>> = {};
|
|
320
|
+
for (const kind of ACCOUNT_KINDS) {
|
|
321
|
+
accounts[kind] = listEngineAccounts(kind);
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
catalog: engineCatalog(),
|
|
325
|
+
statuses: engineStatuses(seams),
|
|
326
|
+
cli: await engineCliStatuses(seams),
|
|
327
|
+
accounts,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* POST /api/engines/install `{kind}` — run the engine's pinned installer,
|
|
333
|
+
* streaming NDJSON like connect: `{"line":…}` frames, then `{done,ok,message}`.
|
|
334
|
+
* No re-advertise or image rebuild here: installing a CLI connects nothing;
|
|
335
|
+
* that happens on the connect that follows.
|
|
336
|
+
*/
|
|
337
|
+
async function handleEngineInstall(
|
|
338
|
+
req: IncomingMessage,
|
|
339
|
+
res: ServerResponse,
|
|
340
|
+
): Promise<void> {
|
|
341
|
+
const body = await readJsonBody(req);
|
|
342
|
+
const kind = body?.kind;
|
|
343
|
+
if (!isEngineKind(kind)) {
|
|
344
|
+
return sendError(res, 400, "unknown or missing engine kind");
|
|
345
|
+
}
|
|
346
|
+
res.writeHead(200, {
|
|
347
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
348
|
+
"cache-control": "no-store",
|
|
349
|
+
});
|
|
350
|
+
const emit = (obj: unknown): void => {
|
|
351
|
+
res.write(`${JSON.stringify(obj)}\n`);
|
|
352
|
+
};
|
|
353
|
+
let result: { ok: boolean; message: string };
|
|
354
|
+
try {
|
|
355
|
+
result = await installEngineCli(kind, (line) => emit({ line }));
|
|
356
|
+
} catch (err) {
|
|
357
|
+
result = {
|
|
358
|
+
ok: false,
|
|
359
|
+
message: err instanceof Error ? err.message : "install failed",
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
emit({ done: true, ok: result.ok, message: result.message });
|
|
363
|
+
res.end();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* POST /api/engines/connect `{kind, apiKey?, pastedToken?}`.
|
|
368
|
+
*
|
|
369
|
+
* Streams the connect as newline-delimited JSON (`application/x-ndjson`): each
|
|
370
|
+
* live CLI log line is `{"line":"…"}`, and a final `{"done":true,"ok":…,
|
|
371
|
+
* "message":"…"}` carries the result. api-key/paste connects emit no log lines
|
|
372
|
+
* and just the final frame. On success we re-advertise and kick a (best-effort)
|
|
373
|
+
* standard-image rebuild so a newly-configured engine's CLI gets installed.
|
|
374
|
+
*/
|
|
375
|
+
async function handleEngineConnect(
|
|
376
|
+
req: IncomingMessage,
|
|
377
|
+
res: ServerResponse,
|
|
378
|
+
opts: UiServerOptions,
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
const body = await readJsonBody(req);
|
|
381
|
+
const kind = body?.kind;
|
|
382
|
+
if (!isEngineKind(kind)) {
|
|
383
|
+
return sendError(res, 400, "unknown or missing engine kind");
|
|
384
|
+
}
|
|
385
|
+
const apiKey = typeof body?.apiKey === "string" ? body.apiKey : undefined;
|
|
386
|
+
const pastedToken =
|
|
387
|
+
typeof body?.pastedToken === "string" ? body.pastedToken : undefined;
|
|
388
|
+
|
|
389
|
+
res.writeHead(200, {
|
|
390
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
391
|
+
"cache-control": "no-store",
|
|
392
|
+
});
|
|
393
|
+
const emit = (obj: unknown): void => {
|
|
394
|
+
res.write(`${JSON.stringify(obj)}\n`);
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
let result: { ok: boolean; message: string };
|
|
398
|
+
try {
|
|
399
|
+
result = await connectEngine(
|
|
400
|
+
kind,
|
|
401
|
+
{ apiKey, pastedToken },
|
|
402
|
+
(line) => emit({ line }),
|
|
403
|
+
);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
result = {
|
|
406
|
+
ok: false,
|
|
407
|
+
message: err instanceof Error ? err.message : "connect failed",
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
if (result.ok) {
|
|
411
|
+
// Re-advertise so the cloud picker offers the engine; rebuild the image so
|
|
412
|
+
// an optional engine's CLI (kimi/grok/cursor) is installed. Best-effort.
|
|
413
|
+
opts.readvertise?.();
|
|
414
|
+
if (opts.containersOperational?.() !== false) {
|
|
415
|
+
void ensureStandardImage();
|
|
416
|
+
// A re-login only reaches a RUNNING task once its agents re-spawn — the
|
|
417
|
+
// credential is injected at spawn (Claude's token via `docker exec -e`).
|
|
418
|
+
// Refresh running agents of this kind so a reconnect self-heals tasks
|
|
419
|
+
// stuck on a revoked/expired token, instead of needing Stop/Resume.
|
|
420
|
+
void getOrchestrator()
|
|
421
|
+
.refreshEngineAccountAgents(kind)
|
|
422
|
+
.catch(() => {});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
emit({ done: true, ok: result.ok, message: result.message });
|
|
426
|
+
res.end();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** POST /api/engines/disconnect `{kind}` → forget the credential + re-advertise. */
|
|
430
|
+
async function handleEngineDisconnect(
|
|
431
|
+
req: IncomingMessage,
|
|
432
|
+
res: ServerResponse,
|
|
433
|
+
opts: UiServerOptions,
|
|
434
|
+
): Promise<void> {
|
|
435
|
+
const body = await readJsonBody(req);
|
|
436
|
+
const kind = body?.kind;
|
|
437
|
+
if (!isEngineKind(kind)) {
|
|
438
|
+
return sendError(res, 400, "unknown or missing engine kind");
|
|
439
|
+
}
|
|
440
|
+
disconnectEngine(kind);
|
|
441
|
+
opts.readvertise?.();
|
|
442
|
+
return sendJson(res, EngineOpResponse, { ok: true });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* POST /api/engines/accounts/add `{kind, label, apiKey?|token?}` → register an
|
|
447
|
+
* EXTRA account (ADR-076) by pasting a token / API key, then re-advertise (the
|
|
448
|
+
* kind may not have been available before) + rebuild the image if needed.
|
|
449
|
+
*/
|
|
450
|
+
async function handleEngineAccountAdd(
|
|
451
|
+
req: IncomingMessage,
|
|
452
|
+
res: ServerResponse,
|
|
453
|
+
opts: UiServerOptions,
|
|
454
|
+
): Promise<void> {
|
|
455
|
+
const body = await readJsonBody(req);
|
|
456
|
+
const kind = body?.kind;
|
|
457
|
+
if (!isEngineKind(kind)) {
|
|
458
|
+
return sendError(res, 400, "unknown or missing engine kind");
|
|
459
|
+
}
|
|
460
|
+
const label = typeof body?.label === "string" ? body.label : "";
|
|
461
|
+
const apiKey = typeof body?.apiKey === "string" ? body.apiKey : undefined;
|
|
462
|
+
const token = typeof body?.token === "string" ? body.token : undefined;
|
|
463
|
+
const result = addEngineAccount(kind, label, { apiKey, token });
|
|
464
|
+
if (result.ok) {
|
|
465
|
+
opts.readvertise?.();
|
|
466
|
+
if (opts.containersOperational?.() !== false) {
|
|
467
|
+
void ensureStandardImage();
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return sendJson(res, EngineOpResponse, {
|
|
471
|
+
ok: result.ok,
|
|
472
|
+
message: result.message,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* POST /api/engines/accounts/connect `{kind, label}` → run the engine's
|
|
478
|
+
* token-command sign-in (streamed NDJSON exactly like /api/engines/connect) and
|
|
479
|
+
* store the captured token as an EXTRA account (ADR-076) rather than the default
|
|
480
|
+
* credential. Only meaningful for token-command kinds (Claude); the browser
|
|
481
|
+
* OAuth authorizes whichever account you sign in with, so run it signed-out /
|
|
482
|
+
* incognito for the browser to prompt for the second account.
|
|
483
|
+
*/
|
|
484
|
+
async function handleEngineAccountConnect(
|
|
485
|
+
req: IncomingMessage,
|
|
486
|
+
res: ServerResponse,
|
|
487
|
+
opts: UiServerOptions,
|
|
488
|
+
): Promise<void> {
|
|
489
|
+
const body = await readJsonBody(req);
|
|
490
|
+
const kind = body?.kind;
|
|
491
|
+
if (!isEngineKind(kind)) {
|
|
492
|
+
return sendError(res, 400, "unknown or missing engine kind");
|
|
493
|
+
}
|
|
494
|
+
const label = typeof body?.label === "string" ? body.label.trim() : "";
|
|
495
|
+
if (!label) {
|
|
496
|
+
return sendError(res, 400, "give the account a label");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
res.writeHead(200, {
|
|
500
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
501
|
+
"cache-control": "no-store",
|
|
502
|
+
});
|
|
503
|
+
const emit = (obj: unknown): void => {
|
|
504
|
+
res.write(`${JSON.stringify(obj)}\n`);
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
let result: { ok: boolean; message: string };
|
|
508
|
+
try {
|
|
509
|
+
result = await connectEngine(
|
|
510
|
+
kind,
|
|
511
|
+
{ onToken: (token) => addEngineAccount(kind, label, { token }) },
|
|
512
|
+
(line) => emit({ line }),
|
|
513
|
+
);
|
|
514
|
+
} catch (err) {
|
|
515
|
+
result = {
|
|
516
|
+
ok: false,
|
|
517
|
+
message: err instanceof Error ? err.message : "sign-in failed",
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
if (result.ok) {
|
|
521
|
+
opts.readvertise?.();
|
|
522
|
+
if (opts.containersOperational?.() !== false) {
|
|
523
|
+
void ensureStandardImage();
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
emit({ done: true, ok: result.ok, message: result.message });
|
|
527
|
+
res.end();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** POST /api/engines/accounts/remove `{id}` → forget an EXTRA account. */
|
|
531
|
+
async function handleEngineAccountRemove(
|
|
532
|
+
req: IncomingMessage,
|
|
533
|
+
res: ServerResponse,
|
|
534
|
+
opts: UiServerOptions,
|
|
535
|
+
): Promise<void> {
|
|
536
|
+
const body = await readJsonBody(req);
|
|
537
|
+
const id = body?.id;
|
|
538
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
539
|
+
return sendError(res, 400, "missing account id");
|
|
540
|
+
}
|
|
541
|
+
const result = removeEngineAccount(id);
|
|
542
|
+
if (result.ok) opts.readvertise?.();
|
|
543
|
+
return sendJson(res, EngineOpResponse, {
|
|
544
|
+
ok: result.ok,
|
|
545
|
+
message: result.message,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** POST /api/tasks/stop `{taskId}` → compose-stop the task (resumable). */
|
|
550
|
+
async function handleTaskStop(
|
|
551
|
+
req: IncomingMessage,
|
|
552
|
+
res: ServerResponse,
|
|
553
|
+
opts: UiServerOptions,
|
|
554
|
+
): Promise<void> {
|
|
555
|
+
const body = await readJsonBody(req);
|
|
556
|
+
const taskId = body?.taskId;
|
|
557
|
+
if (typeof taskId !== "string" || taskId.length === 0) {
|
|
558
|
+
return sendError(res, 400, "missing taskId");
|
|
559
|
+
}
|
|
560
|
+
if (!opts.stopTask) {
|
|
561
|
+
return sendError(res, 501, "stop not available on this host");
|
|
562
|
+
}
|
|
563
|
+
const result = await opts.stopTask(taskId);
|
|
564
|
+
return sendJson(res, EngineOpResponse, {
|
|
565
|
+
ok: result.ok,
|
|
566
|
+
message: result.error,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Read a request body and parse it as a JSON object; null on empty/invalid. */
|
|
571
|
+
async function readJsonBody(
|
|
572
|
+
req: IncomingMessage,
|
|
573
|
+
): Promise<Record<string, unknown> | null> {
|
|
574
|
+
const chunks: Buffer[] = [];
|
|
575
|
+
let size = 0;
|
|
576
|
+
for await (const chunk of req) {
|
|
577
|
+
const buf = chunk as Buffer;
|
|
578
|
+
size += buf.length;
|
|
579
|
+
if (size > 64 * 1024) throw new Error("request body too large");
|
|
580
|
+
chunks.push(buf);
|
|
581
|
+
}
|
|
582
|
+
if (chunks.length === 0) return null;
|
|
583
|
+
try {
|
|
584
|
+
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
585
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
586
|
+
? (parsed as Record<string, unknown>)
|
|
587
|
+
: null;
|
|
588
|
+
} catch {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// --- handlers ---------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
async function tasksBody(
|
|
596
|
+
opts: UiServerOptions,
|
|
597
|
+
memory: TaskMemorySampler | null,
|
|
598
|
+
): Promise<TasksResponse> {
|
|
599
|
+
const rows = opts.db
|
|
600
|
+
.select()
|
|
601
|
+
.from(schema.hostTasks)
|
|
602
|
+
.where(isNull(schema.hostTasks.endedAt))
|
|
603
|
+
.all();
|
|
604
|
+
const now = Date.now();
|
|
605
|
+
const memoryByProject = memory
|
|
606
|
+
? await memory.read(rows.flatMap((r) => (r.composeProject ? [r.composeProject] : [])))
|
|
607
|
+
: new Map<string, number | null>();
|
|
608
|
+
const tasks: TaskRow[] = rows.map((r) => ({
|
|
609
|
+
taskId: r.taskId,
|
|
610
|
+
status: r.statusMirror,
|
|
611
|
+
ownerEmail: r.ownerEmail,
|
|
612
|
+
projectSlugs: parseSlugs(r.projectSlugs),
|
|
613
|
+
startedAt: r.startedAt,
|
|
614
|
+
ageMs: r.startedAt != null ? now - r.startedAt : null,
|
|
615
|
+
composeProject: r.composeProject,
|
|
616
|
+
codeServerPort: r.codeServerPort,
|
|
617
|
+
previewPorts: parsePreviewPortRuntimes(r.previewPorts).map((p) => ({
|
|
618
|
+
name: p.name,
|
|
619
|
+
hostPort: p.hostPort ?? null,
|
|
620
|
+
})),
|
|
621
|
+
memoryBytes: r.composeProject ? (memoryByProject.get(r.composeProject) ?? null) : null,
|
|
622
|
+
}));
|
|
623
|
+
return { tasks };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** How long `/api/tasks` waits for fresh memory samples before it answers
|
|
627
|
+
* with the last ones it has (null for a task never sampled yet). */
|
|
628
|
+
export const TASK_MEMORY_WAIT_MS = 250;
|
|
629
|
+
/** A sample this recent is served without asking Docker again. */
|
|
630
|
+
export const TASK_MEMORY_FRESH_MS = 15_000;
|
|
631
|
+
|
|
632
|
+
export interface TaskMemorySampler {
|
|
633
|
+
/** The latest memory sample of each compose project asked for. */
|
|
634
|
+
read(projects: readonly string[]): Promise<Map<string, number | null>>;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Memory for `/api/tasks` without making the response wait on Docker.
|
|
639
|
+
* `docker stats --no-stream` takes about two seconds per task once tasks
|
|
640
|
+
* run, so `/api/tasks` took ~2 s and `uai-host status`, which allowed it one,
|
|
641
|
+
* said "service unreachable" on a healthy runner (2026-10-30). Each project
|
|
642
|
+
* is sampled in the background, one sample at a time, at most once per
|
|
643
|
+
* `freshMs`; a read waits at most `waitMs` for samples under way, then
|
|
644
|
+
* answers with what it has. A project no longer asked for is forgotten.
|
|
645
|
+
*/
|
|
646
|
+
export function taskMemorySampler(
|
|
647
|
+
sample: (composeProject: string) => Promise<number | null>,
|
|
648
|
+
options: { now?: () => number; waitMs?: number; freshMs?: number } = {},
|
|
649
|
+
): TaskMemorySampler {
|
|
650
|
+
const now = options.now ?? Date.now;
|
|
651
|
+
const waitMs = options.waitMs ?? TASK_MEMORY_WAIT_MS;
|
|
652
|
+
const freshMs = options.freshMs ?? TASK_MEMORY_FRESH_MS;
|
|
653
|
+
const entries = new Map<
|
|
654
|
+
string,
|
|
655
|
+
{ value: number | null; sampledAt: number | null; sampling: Promise<void> | null }
|
|
656
|
+
>();
|
|
657
|
+
return {
|
|
658
|
+
async read(projects) {
|
|
659
|
+
const wanted = new Set(projects);
|
|
660
|
+
for (const project of entries.keys()) {
|
|
661
|
+
if (!wanted.has(project)) entries.delete(project);
|
|
662
|
+
}
|
|
663
|
+
const sampling: Promise<void>[] = [];
|
|
664
|
+
for (const project of wanted) {
|
|
665
|
+
let entry = entries.get(project);
|
|
666
|
+
if (!entry) {
|
|
667
|
+
entry = { value: null, sampledAt: null, sampling: null };
|
|
668
|
+
entries.set(project, entry);
|
|
669
|
+
}
|
|
670
|
+
if (entry.sampling === null && (entry.sampledAt === null || now() - entry.sampledAt >= freshMs)) {
|
|
671
|
+
const current = entry;
|
|
672
|
+
current.sampling = sample(project)
|
|
673
|
+
.catch(() => null)
|
|
674
|
+
.then((value) => {
|
|
675
|
+
current.value = value;
|
|
676
|
+
current.sampledAt = now();
|
|
677
|
+
current.sampling = null;
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
if (entry.sampling !== null) sampling.push(entry.sampling);
|
|
681
|
+
}
|
|
682
|
+
if (sampling.length > 0) {
|
|
683
|
+
let timer: NodeJS.Timeout | undefined;
|
|
684
|
+
await Promise.race([
|
|
685
|
+
Promise.all(sampling),
|
|
686
|
+
new Promise<void>((resolve) => {
|
|
687
|
+
timer = setTimeout(resolve, waitMs);
|
|
688
|
+
timer.unref?.();
|
|
689
|
+
}),
|
|
690
|
+
]);
|
|
691
|
+
clearTimeout(timer);
|
|
692
|
+
}
|
|
693
|
+
return new Map([...wanted].map((project) => [project, entries.get(project)?.value ?? null]));
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function eventsBody(opts: UiServerOptions): EventsResponse {
|
|
699
|
+
const rows = opts.db
|
|
700
|
+
.select()
|
|
701
|
+
.from(schema.hostEvents)
|
|
702
|
+
.orderBy(desc(schema.hostEvents.ts))
|
|
703
|
+
.limit(50)
|
|
704
|
+
.all();
|
|
705
|
+
return {
|
|
706
|
+
events: rows
|
|
707
|
+
.filter((r) => isKnownEventKind(r.kind))
|
|
708
|
+
.map((r) => ({
|
|
709
|
+
id: r.id,
|
|
710
|
+
taskId: r.taskId,
|
|
711
|
+
kind: r.kind as EventsResponse["events"][number]["kind"],
|
|
712
|
+
ts: r.ts,
|
|
713
|
+
detail: r.detail,
|
|
714
|
+
})),
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function usersBody(opts: UiServerOptions): UsersResponse {
|
|
719
|
+
const rows = opts.db
|
|
720
|
+
.select()
|
|
721
|
+
.from(schema.hostTasks)
|
|
722
|
+
.where(isNull(schema.hostTasks.endedAt))
|
|
723
|
+
.all();
|
|
724
|
+
const byUser = new Map<string, { email: string | null; count: number }>();
|
|
725
|
+
for (const r of rows) {
|
|
726
|
+
if (!r.ownerUserId) continue;
|
|
727
|
+
const cur = byUser.get(r.ownerUserId) ?? { email: r.ownerEmail, count: 0 };
|
|
728
|
+
cur.count += 1;
|
|
729
|
+
if (!cur.email && r.ownerEmail) cur.email = r.ownerEmail;
|
|
730
|
+
byUser.set(r.ownerUserId, cur);
|
|
731
|
+
}
|
|
732
|
+
return {
|
|
733
|
+
users: [...byUser].map(([userId, v]) => ({
|
|
734
|
+
userId,
|
|
735
|
+
email: v.email,
|
|
736
|
+
activeTasks: v.count,
|
|
737
|
+
})),
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// --- static -----------------------------------------------------------------
|
|
742
|
+
|
|
743
|
+
const STATIC_FILES: Record<string, string> = {
|
|
744
|
+
"/": "index.html",
|
|
745
|
+
"/index.html": "index.html",
|
|
746
|
+
"/style.css": "style.css",
|
|
747
|
+
"/app.js": "app.js",
|
|
748
|
+
"/uai-wheel.svg": "uai-wheel.svg",
|
|
749
|
+
"/uai-favicon.svg": "uai-favicon.svg",
|
|
750
|
+
"/uai-logo-black.svg": "uai-logo-black.svg",
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
async function serveStatic(
|
|
754
|
+
res: ServerResponse,
|
|
755
|
+
uiDir: string,
|
|
756
|
+
path: string,
|
|
757
|
+
): Promise<void> {
|
|
758
|
+
const file = STATIC_FILES[path];
|
|
759
|
+
if (!file) return sendError(res, 404, `not found: ${path}`);
|
|
760
|
+
const full = join(uiDir, file);
|
|
761
|
+
if (!existsSync(full)) return sendError(res, 404, `not found: ${path}`);
|
|
762
|
+
const body = await readFile(full);
|
|
763
|
+
res.writeHead(200, { "content-type": contentType(file) });
|
|
764
|
+
res.end(body);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function contentType(file: string): string {
|
|
768
|
+
if (file.endsWith(".html")) return "text/html; charset=utf-8";
|
|
769
|
+
if (file.endsWith(".css")) return "text/css; charset=utf-8";
|
|
770
|
+
if (file.endsWith(".js")) return "text/javascript; charset=utf-8";
|
|
771
|
+
if (file.endsWith(".svg")) return "image/svg+xml";
|
|
772
|
+
return "application/octet-stream";
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// --- response helpers -------------------------------------------------------
|
|
776
|
+
|
|
777
|
+
function sendJson<T>(res: ServerResponse, shape: ZodType<T>, body: T): void {
|
|
778
|
+
// Parse before send: the API can never emit an off-contract body.
|
|
779
|
+
const validated = shape.parse(body);
|
|
780
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
781
|
+
res.end(JSON.stringify(validated));
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function sendError(res: ServerResponse, status: number, message: string): void {
|
|
785
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
786
|
+
res.end(JSON.stringify({ error: message }));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// --- misc -------------------------------------------------------------------
|
|
790
|
+
|
|
791
|
+
function parseSlugs(raw: string): string[] {
|
|
792
|
+
try {
|
|
793
|
+
const parsed: unknown = JSON.parse(raw);
|
|
794
|
+
return Array.isArray(parsed)
|
|
795
|
+
? parsed.filter((s): s is string => typeof s === "string")
|
|
796
|
+
: [];
|
|
797
|
+
} catch {
|
|
798
|
+
return [];
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function isKnownEventKind(kind: string): boolean {
|
|
803
|
+
return (
|
|
804
|
+
kind === "task.created" ||
|
|
805
|
+
kind === "task.started" ||
|
|
806
|
+
kind === "task.ended" ||
|
|
807
|
+
kind === "task.ship"
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Best-effort container memory for a task's compose project via `docker stats`.
|
|
813
|
+
* Returns null on any failure (docker down, container gone, parse miss).
|
|
814
|
+
*/
|
|
815
|
+
export async function dockerMemoryBytes(
|
|
816
|
+
composeProject: string,
|
|
817
|
+
): Promise<number | null> {
|
|
818
|
+
try {
|
|
819
|
+
// Async (not spawnSync): this runs per task on every status request, on the
|
|
820
|
+
// SAME event loop as the cloud-bridge heartbeat. `docker stats --no-stream`
|
|
821
|
+
// samples for ~1-2s; doing it synchronously for N tasks blocked the loop
|
|
822
|
+
// long enough that the bridge heartbeat timed out and the cloud dropped the
|
|
823
|
+
// host into a reconnect loop.
|
|
824
|
+
const res = await dockerCli(
|
|
825
|
+
["stats", "--no-stream", "--format", "{{.MemUsage}}", `${composeProject}-app-1`],
|
|
826
|
+
{ timeoutMs: 3_000 },
|
|
827
|
+
);
|
|
828
|
+
if (res.status !== 0 || !res.stdout) return null;
|
|
829
|
+
const used = res.stdout.trim().split("/")[0]?.trim();
|
|
830
|
+
return used ? parseMemUsage(used) : null;
|
|
831
|
+
} catch {
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function parseMemUsage(text: string): number | null {
|
|
837
|
+
const m = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(text);
|
|
838
|
+
if (!m || !m[1] || !m[2]) return null;
|
|
839
|
+
const value = Number(m[1]);
|
|
840
|
+
const unit = m[2].toLowerCase();
|
|
841
|
+
const scale: Record<string, number> = {
|
|
842
|
+
b: 1,
|
|
843
|
+
kb: 1e3,
|
|
844
|
+
mb: 1e6,
|
|
845
|
+
gb: 1e9,
|
|
846
|
+
kib: 1024,
|
|
847
|
+
mib: 1024 ** 2,
|
|
848
|
+
gib: 1024 ** 3,
|
|
849
|
+
};
|
|
850
|
+
const factor = scale[unit];
|
|
851
|
+
return factor ? Math.round(value * factor) : null;
|
|
852
|
+
}
|