@moondeck/station 0.0.1 → 1.0.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/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/0031_first_turn_receipts.sql +9 -0
- package/db/migrations/meta/_journal.json +230 -0
- package/db/schema.ts +871 -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 +1470 -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 +937 -0
- package/lib/env.ts +195 -0
- package/lib/first-turn-receipts.ts +62 -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 +115 -8
- 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/bridge-diagnostics.ts +39 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2585 -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/enrollment-next-steps.ts +24 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2907 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6363 -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 +5550 -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/bin/moondeck-station.mjs +0 -3
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121: the `local` MachineProvider — task "machines" as containers on
|
|
3
|
+
* the local Docker daemon, so the whole machine-backed path (launch, in-env
|
|
4
|
+
* clone, SSH exec, tunnels, recovery-by-describe) is exercised on the
|
|
5
|
+
* uai-linux guinea pig VM with zero cloud spend.
|
|
6
|
+
*
|
|
7
|
+
* Machines are containers named `uai-machine-<taskId>` carrying the label
|
|
8
|
+
* `io.moondeck.machine=<taskId>`. The label is deliberately NOT `io.moondeck.task`:
|
|
9
|
+
* task-app containers own that label and every existing sweep/proof keys on
|
|
10
|
+
* it — a machine must never be mistaken for an app container by GC that
|
|
11
|
+
* predates machines.
|
|
12
|
+
*
|
|
13
|
+
* Absence grammar: `docker inspect` answers a missing object with exit 1,
|
|
14
|
+
* empty stdout, and an "Error: No such object: <id>" line naming the EXACT
|
|
15
|
+
* id. Only that whole answer proves absence (a not-found for "<id>-shadow"
|
|
16
|
+
* proves nothing about "<id>"); anything else is `unknown`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { dockerCli, type DockerResult } from "./docker-exec";
|
|
20
|
+
import type {
|
|
21
|
+
MachineInfo,
|
|
22
|
+
MachineProvider,
|
|
23
|
+
MachineSpec,
|
|
24
|
+
MachineState,
|
|
25
|
+
} from "./machine-provider";
|
|
26
|
+
|
|
27
|
+
export const MACHINE_LABEL = "io.moondeck.machine";
|
|
28
|
+
|
|
29
|
+
export function localMachineName(taskId: string): string {
|
|
30
|
+
return `uai-machine-${taskId.toLowerCase()}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Runner = (args: string[]) => Promise<DockerResult>;
|
|
34
|
+
|
|
35
|
+
const defaultRunner: Runner = (args) => dockerCli(args, { timeoutMs: 60_000 });
|
|
36
|
+
|
|
37
|
+
function machineAbsent(id: string, res: DockerResult): boolean {
|
|
38
|
+
// Daemons vary the casing of the not-found line — Docker Desktop answers
|
|
39
|
+
// "Error: No such object:", OrbStack "error: no such object:" (caught
|
|
40
|
+
// live in the first smoke run: the machine WAS gone and the proof
|
|
41
|
+
// refused it). The prefix is matched case-insensitively; the id itself
|
|
42
|
+
// stays exact — a not-found for "<id>-shadow" still proves nothing.
|
|
43
|
+
// OrbStack also answers with "[]" on stdout beside the error (Docker
|
|
44
|
+
// Desktop prints nothing) — an empty JSON array carries no confusable
|
|
45
|
+
// record, so both are admissible; anything else on stdout is not.
|
|
46
|
+
const stdout = res.stdout.trim();
|
|
47
|
+
return (
|
|
48
|
+
typeof res.status === "number" &&
|
|
49
|
+
res.status !== 0 &&
|
|
50
|
+
(stdout === "" || stdout === "[]") &&
|
|
51
|
+
res.stderr.split("\n").some((line) => {
|
|
52
|
+
const match = /^error: no such object: (.+)$/i.exec(line.trim());
|
|
53
|
+
return match !== null && match[1] === id;
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stateFromDocker(status: unknown): MachineState {
|
|
59
|
+
switch (status) {
|
|
60
|
+
case "running":
|
|
61
|
+
return "running";
|
|
62
|
+
case "created":
|
|
63
|
+
case "restarting":
|
|
64
|
+
return "pending";
|
|
65
|
+
case "paused":
|
|
66
|
+
case "exited":
|
|
67
|
+
case "dead":
|
|
68
|
+
return "stopped";
|
|
69
|
+
default:
|
|
70
|
+
return "unknown";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface InspectShape {
|
|
75
|
+
name: string;
|
|
76
|
+
taskLabel: string | null;
|
|
77
|
+
state: MachineState;
|
|
78
|
+
frozen?: true;
|
|
79
|
+
address: string | null;
|
|
80
|
+
sshPort: number | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Strict single-record parse. Exactly one record whose Name is the
|
|
84
|
+
* requested id — a confused or multi-record answer proves nothing and maps
|
|
85
|
+
* to null (the caller reports `unknown`). */
|
|
86
|
+
function parseInspect(id: string, stdout: string): InspectShape | null {
|
|
87
|
+
let parsed: unknown;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(stdout);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (!Array.isArray(parsed) || parsed.length !== 1) return null;
|
|
94
|
+
const record = parsed[0];
|
|
95
|
+
if (typeof record !== "object" || record === null) return null;
|
|
96
|
+
const rawName = (record as { Name?: unknown }).Name;
|
|
97
|
+
if (typeof rawName !== "string") return null;
|
|
98
|
+
const name = rawName.replace(/^\//, "");
|
|
99
|
+
if (name !== id) return null;
|
|
100
|
+
const stateObj = (record as { State?: { Status?: unknown } }).State;
|
|
101
|
+
const labels = (record as { Config?: { Labels?: unknown } }).Config?.Labels;
|
|
102
|
+
let taskLabel: string | null = null;
|
|
103
|
+
if (labels !== undefined && labels !== null) {
|
|
104
|
+
if (typeof labels !== "object" || Array.isArray(labels)) return null;
|
|
105
|
+
const value = (labels as Record<string, unknown>)[MACHINE_LABEL];
|
|
106
|
+
if (value !== undefined && typeof value !== "string") return null;
|
|
107
|
+
taskLabel = typeof value === "string" ? value : null;
|
|
108
|
+
}
|
|
109
|
+
const settings = (
|
|
110
|
+
record as {
|
|
111
|
+
NetworkSettings?: {
|
|
112
|
+
Networks?: Record<string, { IPAddress?: unknown }>;
|
|
113
|
+
Ports?: Record<
|
|
114
|
+
string,
|
|
115
|
+
Array<{ HostIp?: unknown; HostPort?: unknown }> | null
|
|
116
|
+
>;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
).NetworkSettings;
|
|
120
|
+
let address: string | null = null;
|
|
121
|
+
const networks = settings?.Networks;
|
|
122
|
+
if (networks && typeof networks === "object") {
|
|
123
|
+
for (const net of Object.values(networks)) {
|
|
124
|
+
const ip = net?.IPAddress;
|
|
125
|
+
if (typeof ip === "string" && ip.length > 0) {
|
|
126
|
+
address = ip;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// A published SSH port (macOS mode) supersedes the bridge IP: the bridge
|
|
132
|
+
// address exists but is unreachable from the Mac, and half-truthful
|
|
133
|
+
// reachability info is worse than none.
|
|
134
|
+
let sshPort: number | null = null;
|
|
135
|
+
const published = settings?.Ports?.["22/tcp"];
|
|
136
|
+
if (Array.isArray(published)) {
|
|
137
|
+
for (const binding of published) {
|
|
138
|
+
const port = Number(binding?.HostPort);
|
|
139
|
+
if (Number.isInteger(port) && port > 0) {
|
|
140
|
+
address = typeof binding?.HostIp === "string" && binding.HostIp.length > 0
|
|
141
|
+
? binding.HostIp
|
|
142
|
+
: "127.0.0.1";
|
|
143
|
+
sshPort = port;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
name,
|
|
150
|
+
taskLabel,
|
|
151
|
+
state: stateFromDocker(stateObj?.Status),
|
|
152
|
+
...(stateObj?.Status === "paused" ? { frozen: true as const } : {}),
|
|
153
|
+
address,
|
|
154
|
+
sshPort,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function unknown(id: string, detail: string): MachineInfo {
|
|
159
|
+
return { id, taskLabel: null, state: "unknown", address: null, detail };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function createLocalMachineProvider(
|
|
163
|
+
runner: Runner = defaultRunner,
|
|
164
|
+
): MachineProvider {
|
|
165
|
+
async function describe(id: string): Promise<MachineInfo> {
|
|
166
|
+
const res = await runner(["inspect", id]);
|
|
167
|
+
if (res.status === 0) {
|
|
168
|
+
const record = parseInspect(id, res.stdout);
|
|
169
|
+
if (record === null) {
|
|
170
|
+
return unknown(id, "inspect answered with a confusing record");
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
id,
|
|
174
|
+
taskLabel: record.taskLabel,
|
|
175
|
+
state: record.state,
|
|
176
|
+
...(record.frozen ? { frozen: true as const } : {}),
|
|
177
|
+
address: record.state === "running" ? record.address : null,
|
|
178
|
+
...(record.state === "running" && record.sshPort !== null
|
|
179
|
+
? { sshPort: record.sshPort }
|
|
180
|
+
: {}),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (machineAbsent(id, res)) {
|
|
184
|
+
return { id, taskLabel: null, state: "absent", address: null };
|
|
185
|
+
}
|
|
186
|
+
return unknown(
|
|
187
|
+
id,
|
|
188
|
+
res.stderr.trim() || `inspect exited ${res.status ?? "killed"}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
kind: "local",
|
|
194
|
+
|
|
195
|
+
async launch(spec: MachineSpec): Promise<MachineInfo> {
|
|
196
|
+
const name = localMachineName(spec.taskId);
|
|
197
|
+
const args = [
|
|
198
|
+
"run",
|
|
199
|
+
"-d",
|
|
200
|
+
"--name",
|
|
201
|
+
name,
|
|
202
|
+
"--label",
|
|
203
|
+
`${MACHINE_LABEL}=${spec.taskId}`,
|
|
204
|
+
"--cpus",
|
|
205
|
+
String(spec.cpus),
|
|
206
|
+
"--memory",
|
|
207
|
+
`${spec.memoryMiB}m`,
|
|
208
|
+
// The image's entrypoint installs this for the ssh user before sshd
|
|
209
|
+
// starts — the local analog of cloud user-data key delivery.
|
|
210
|
+
...(spec.authorizedPublicKey
|
|
211
|
+
? ["-e", `UAI_AUTHORIZED_KEY=${spec.authorizedPublicKey}`]
|
|
212
|
+
: []),
|
|
213
|
+
// macOS: bridge IPs are not host-routable; SSH rides a
|
|
214
|
+
// loopback-published ephemeral port instead (never exposed beyond
|
|
215
|
+
// 127.0.0.1).
|
|
216
|
+
...(spec.publishSsh ? ["-p", "127.0.0.1:0:22"] : []),
|
|
217
|
+
spec.image,
|
|
218
|
+
...(spec.command ?? []),
|
|
219
|
+
];
|
|
220
|
+
const res = await runner(args);
|
|
221
|
+
if (res.status !== 0) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`machine launch failed for ${name}: ${
|
|
224
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
225
|
+
}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
const info = await describe(name);
|
|
229
|
+
if (info.state === "absent" || info.state === "unknown") {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`machine ${name} launched but could not be described (${info.state})`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return info;
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async stop(id: string): Promise<void> {
|
|
238
|
+
const res = await runner(["stop", "--time", "10", id]);
|
|
239
|
+
if (res.status !== 0) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`machine stop failed for ${id}: ${
|
|
242
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
243
|
+
}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
async start(id: string): Promise<MachineInfo> {
|
|
249
|
+
const res = await runner(["start", id]);
|
|
250
|
+
if (res.status !== 0) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`machine start failed for ${id}: ${
|
|
253
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
254
|
+
}`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return describe(id);
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
async terminate(id: string): Promise<void> {
|
|
261
|
+
// Removal is allowed to fail (already gone, races) — absence is not.
|
|
262
|
+
await runner(["rm", "-f", id]);
|
|
263
|
+
const after = await describe(id);
|
|
264
|
+
if (after.state !== "absent") {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`machine ${id} could not be proven absent after terminate (${after.state}${
|
|
267
|
+
after.detail ? `: ${after.detail}` : ""
|
|
268
|
+
})`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
describe,
|
|
274
|
+
|
|
275
|
+
async list(): Promise<MachineInfo[]> {
|
|
276
|
+
const ids = await runner([
|
|
277
|
+
"ps",
|
|
278
|
+
"-a",
|
|
279
|
+
"--filter",
|
|
280
|
+
`label=${MACHINE_LABEL}`,
|
|
281
|
+
"--format",
|
|
282
|
+
"{{.Names}}",
|
|
283
|
+
]);
|
|
284
|
+
if (ids.status !== 0) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`machine listing failed: ${
|
|
287
|
+
ids.stderr.trim() || `exit ${ids.status ?? "killed"}`
|
|
288
|
+
}`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const names = ids.stdout
|
|
292
|
+
.split("\n")
|
|
293
|
+
.map((line) => line.trim())
|
|
294
|
+
.filter(Boolean);
|
|
295
|
+
if (names.length === 0) return [];
|
|
296
|
+
const inspected = await runner(["inspect", ...names]);
|
|
297
|
+
if (inspected.status !== 0) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`machine inventory inspect failed: ${
|
|
300
|
+
inspected.stderr.trim() || `exit ${inspected.status ?? "killed"}`
|
|
301
|
+
}`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
let parsed: unknown;
|
|
305
|
+
try {
|
|
306
|
+
parsed = JSON.parse(inspected.stdout);
|
|
307
|
+
} catch {
|
|
308
|
+
throw new Error("machine inventory was unparseable");
|
|
309
|
+
}
|
|
310
|
+
if (!Array.isArray(parsed) || parsed.length !== names.length) {
|
|
311
|
+
throw new Error("machine inventory was unparseable");
|
|
312
|
+
}
|
|
313
|
+
const machines: MachineInfo[] = [];
|
|
314
|
+
for (let index = 0; index < names.length; index += 1) {
|
|
315
|
+
const record = parseInspect(
|
|
316
|
+
names[index]!,
|
|
317
|
+
JSON.stringify([parsed[index]]),
|
|
318
|
+
);
|
|
319
|
+
if (record === null) {
|
|
320
|
+
// A partially-readable inventory could hide exactly the labeled
|
|
321
|
+
// machine an orphan proof needs to see — no answer at all.
|
|
322
|
+
throw new Error("machine inventory was unparseable");
|
|
323
|
+
}
|
|
324
|
+
machines.push({
|
|
325
|
+
id: record.name,
|
|
326
|
+
taskLabel: record.taskLabel,
|
|
327
|
+
state: record.state,
|
|
328
|
+
...(record.frozen ? { frozen: true as const } : {}),
|
|
329
|
+
address: record.state === "running" ? record.address : null,
|
|
330
|
+
...(record.state === "running" && record.sshPort !== null
|
|
331
|
+
? { sshPort: record.sshPort }
|
|
332
|
+
: {}),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
return machines;
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121: the per-task MACHINE lifecycle seam.
|
|
3
|
+
*
|
|
4
|
+
* A "machine" is the isolated computer a task runs on — an EC2 instance, a
|
|
5
|
+
* GCP instance, or (for the zero-cloud-spend guinea pig) an SSH-reachable
|
|
6
|
+
* container on the local daemon. The seam is deliberately small enough that
|
|
7
|
+
* nothing in it is EC2-specific: launch / stop / start / terminate /
|
|
8
|
+
* describe / list, with an ownership tag and a reachable address.
|
|
9
|
+
*
|
|
10
|
+
* State discipline (this repo's absence rule, applied to machines):
|
|
11
|
+
* - `absent` means PROVEN absent — the provider observed its backend's exact
|
|
12
|
+
* not-found answer for this exact id. Only `absent` authorizes forgetting
|
|
13
|
+
* a machine or deleting anything derived from it.
|
|
14
|
+
* - `unknown` means the query failed or answered confusingly. Unknown is
|
|
15
|
+
* never absence; callers defer and retry.
|
|
16
|
+
* - `terminate` does not return until absence is proven; an unprovable
|
|
17
|
+
* termination throws rather than reporting success.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type MachineState =
|
|
21
|
+
| "pending"
|
|
22
|
+
| "running"
|
|
23
|
+
| "stopped"
|
|
24
|
+
| "absent"
|
|
25
|
+
| "unknown";
|
|
26
|
+
|
|
27
|
+
export interface MachineSpec {
|
|
28
|
+
/** Task ownership — recorded on the machine (tag/label) so recovery and
|
|
29
|
+
* orphan sweeps key on it, mirroring `io.moondeck.task` on containers. */
|
|
30
|
+
taskId: string;
|
|
31
|
+
/** Backend image reference: AMI id, GCP image, or container image. */
|
|
32
|
+
image: string;
|
|
33
|
+
cpus: number;
|
|
34
|
+
memoryMiB: number;
|
|
35
|
+
/** Optional long-running entrypoint override (the local backend's images
|
|
36
|
+
* must keep the machine alive themselves — typically sshd). */
|
|
37
|
+
command?: string[];
|
|
38
|
+
/** Public key the machine authorizes for the orchestrator's SSH access.
|
|
39
|
+
* Cloud backends deliver it via instance user-data; the local backend via
|
|
40
|
+
* an env var its image's entrypoint installs. */
|
|
41
|
+
authorizedPublicKey?: string;
|
|
42
|
+
/** Local backend on macOS only: container bridge IPs are not
|
|
43
|
+
* host-routable there, so SSH must ride a loopback-published port. The
|
|
44
|
+
* Linux guinea pig and cloud backends dial the address directly. */
|
|
45
|
+
publishSsh?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MachineInfo {
|
|
49
|
+
/** Backend-native identifier: instance id or container id/name. */
|
|
50
|
+
id: string;
|
|
51
|
+
/** Stopped as far as work goes, yet its processes still exist (a paused
|
|
52
|
+
* container, an instance still stopping): they could run or write again
|
|
53
|
+
* without a new start. */
|
|
54
|
+
frozen?: true;
|
|
55
|
+
/** The recorded task ownership label, null when the machine carries none
|
|
56
|
+
* (a foreign machine occupying a reserved name is never ours to touch). */
|
|
57
|
+
taskLabel: string | null;
|
|
58
|
+
state: MachineState;
|
|
59
|
+
/** Address the orchestrator can dial for SSH/exec/tunnels, when running.
|
|
60
|
+
* Reachability is a deployment property: VPC-private IPs (cloud) and
|
|
61
|
+
* bridge IPs (local daemon on the SAME machine as the orchestrator) are
|
|
62
|
+
* reachable; container bridge IPs are NOT host-routable from macOS —
|
|
63
|
+
* the local backend is for the Linux guinea pig, not Mac hosts. */
|
|
64
|
+
address: string | null;
|
|
65
|
+
/** SSH port when it differs from 22 (local backend with `publishSsh`). */
|
|
66
|
+
sshPort?: number;
|
|
67
|
+
/** Present only alongside `unknown` — what the backend actually said. */
|
|
68
|
+
detail?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface MachineProvider {
|
|
72
|
+
readonly kind: "local" | "aws" | "gcp";
|
|
73
|
+
/** Create and start a machine. Rejects on a name/identity conflict —
|
|
74
|
+
* adopting an existing machine is a caller decision, never implicit. */
|
|
75
|
+
launch(spec: MachineSpec): Promise<MachineInfo>;
|
|
76
|
+
/** Graceful stop (sleep-when-idle; cloud backends keep the disk). */
|
|
77
|
+
stop(id: string): Promise<void>;
|
|
78
|
+
/** Start a stopped machine and report its (possibly new) address. */
|
|
79
|
+
start(id: string): Promise<MachineInfo>;
|
|
80
|
+
/** Destroy the machine. Resolves only after absence is PROVEN. */
|
|
81
|
+
terminate(id: string): Promise<void>;
|
|
82
|
+
/** One machine's state. `absent` only on the backend's exact not-found
|
|
83
|
+
* answer for this exact id; anything else unprovable is `unknown`. */
|
|
84
|
+
describe(id: string): Promise<MachineInfo>;
|
|
85
|
+
/** Every machine carrying this provider's ownership labels — the orphan
|
|
86
|
+
* sweep's inventory. A partially-readable answer is no answer: malformed
|
|
87
|
+
* rows reject the whole listing (they could hide exactly the labeled
|
|
88
|
+
* machine a proof needs to see). */
|
|
89
|
+
list(): Promise<MachineInfo[]>;
|
|
90
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export const MANAGED_ACTIVATION_POLL_MS = 5_000;
|
|
2
|
+
|
|
3
|
+
export interface ManagedActivationWatcherOptions {
|
|
4
|
+
readonly restartRequired: () => boolean | Promise<boolean>;
|
|
5
|
+
readonly isIdle: () => boolean;
|
|
6
|
+
readonly onReady: () => void;
|
|
7
|
+
readonly onError?: (error: unknown) => void;
|
|
8
|
+
readonly pollMs?: number;
|
|
9
|
+
readonly setTimer?: (callback: () => void, delayMs: number) => NodeJS.Timeout;
|
|
10
|
+
readonly clearTimer?: (timer: NodeJS.Timeout) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Watches the durable activation marker left by another CLI process. New work
|
|
15
|
+
* is rejected while that marker exists; this loop waits for the selected
|
|
16
|
+
* runtime to be current and all existing work to finish before asking the
|
|
17
|
+
* service supervisor to exec the stable launcher.
|
|
18
|
+
*/
|
|
19
|
+
export class ManagedActivationWatcher {
|
|
20
|
+
private timer: NodeJS.Timeout | null = null;
|
|
21
|
+
private running = false;
|
|
22
|
+
private stopped = false;
|
|
23
|
+
private readonly pollMs: number;
|
|
24
|
+
private readonly setTimer: NonNullable<ManagedActivationWatcherOptions["setTimer"]>;
|
|
25
|
+
private readonly clearTimer: NonNullable<ManagedActivationWatcherOptions["clearTimer"]>;
|
|
26
|
+
|
|
27
|
+
constructor(private readonly options: ManagedActivationWatcherOptions) {
|
|
28
|
+
this.pollMs = options.pollMs ?? MANAGED_ACTIVATION_POLL_MS;
|
|
29
|
+
if (!Number.isSafeInteger(this.pollMs) || this.pollMs <= 0) {
|
|
30
|
+
throw new Error("managed activation poll interval is invalid");
|
|
31
|
+
}
|
|
32
|
+
this.setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
33
|
+
this.clearTimer = options.clearTimer ?? clearTimeout;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
start(): void {
|
|
37
|
+
if (this.stopped || this.timer || this.running) return;
|
|
38
|
+
this.schedule();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
stop(): void {
|
|
42
|
+
this.stopped = true;
|
|
43
|
+
if (this.timer) this.clearTimer(this.timer);
|
|
44
|
+
this.timer = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private schedule(): void {
|
|
48
|
+
if (this.stopped) return;
|
|
49
|
+
this.timer = this.setTimer(() => {
|
|
50
|
+
this.timer = null;
|
|
51
|
+
void this.run();
|
|
52
|
+
}, this.pollMs);
|
|
53
|
+
this.timer.unref?.();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async run(): Promise<void> {
|
|
57
|
+
if (this.stopped || this.running) return;
|
|
58
|
+
this.running = true;
|
|
59
|
+
try {
|
|
60
|
+
if ((await this.options.restartRequired()) && this.options.isIdle()) {
|
|
61
|
+
this.stop();
|
|
62
|
+
this.options.onReady();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
this.options.onError?.(error);
|
|
67
|
+
} finally {
|
|
68
|
+
this.running = false;
|
|
69
|
+
}
|
|
70
|
+
this.schedule();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ManagedRuntimeInstallStartupState } from "./managed-runtime";
|
|
2
|
+
|
|
3
|
+
export interface ManagedInstallOwnerWatcherOptions {
|
|
4
|
+
readonly check: () => Promise<ManagedRuntimeInstallStartupState>;
|
|
5
|
+
readonly onRestart: (state: "stale" | "attested") => void;
|
|
6
|
+
readonly onUnsafe: (error: unknown) => void;
|
|
7
|
+
readonly intervalMs?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Watches only a daemon that started provisionally under an install intent.
|
|
12
|
+
* `unknown` is a transient process-identity result and must never authorize a
|
|
13
|
+
* rollback or restart. `none` means completion removed the admission fence and
|
|
14
|
+
* permanently disarms this watcher for the ordinary steady-state daemon.
|
|
15
|
+
*/
|
|
16
|
+
export class ManagedInstallOwnerWatcher {
|
|
17
|
+
private timer: NodeJS.Timeout | null = null;
|
|
18
|
+
private checking = false;
|
|
19
|
+
|
|
20
|
+
constructor(private readonly options: ManagedInstallOwnerWatcherOptions) {}
|
|
21
|
+
|
|
22
|
+
start(): void {
|
|
23
|
+
if (this.timer) return;
|
|
24
|
+
this.timer = setInterval(
|
|
25
|
+
() => void this.tick(),
|
|
26
|
+
this.options.intervalMs ?? 1_000,
|
|
27
|
+
);
|
|
28
|
+
this.timer.unref?.();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
stop(): void {
|
|
32
|
+
if (!this.timer) return;
|
|
33
|
+
clearInterval(this.timer);
|
|
34
|
+
this.timer = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private async tick(): Promise<void> {
|
|
38
|
+
if (this.checking || !this.timer) return;
|
|
39
|
+
this.checking = true;
|
|
40
|
+
try {
|
|
41
|
+
const state = await this.options.check();
|
|
42
|
+
if (state === "none") {
|
|
43
|
+
this.stop();
|
|
44
|
+
} else if (state === "stale" || state === "attested") {
|
|
45
|
+
this.stop();
|
|
46
|
+
this.options.onRestart(state);
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
this.stop();
|
|
50
|
+
this.options.onUnsafe(error);
|
|
51
|
+
} finally {
|
|
52
|
+
this.checking = false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counts asynchronous host mutations that must settle before a managed-runtime
|
|
3
|
+
* restart may terminate the process. The factory form increments first, so no
|
|
4
|
+
* synchronous setup performed by the operation can escape the drain.
|
|
5
|
+
*/
|
|
6
|
+
export class ManagedOperationDrain {
|
|
7
|
+
private active = 0;
|
|
8
|
+
private accepting = true;
|
|
9
|
+
private idleWaiters: Array<() => void> = [];
|
|
10
|
+
|
|
11
|
+
constructor(private readonly onSettled: () => void) {}
|
|
12
|
+
|
|
13
|
+
get activeCount(): number {
|
|
14
|
+
return this.active;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Refuse factories that arrive after managed shutdown has begun. Existing
|
|
19
|
+
* operations remain counted until their full promise chains (including the
|
|
20
|
+
* bridge acknowledgement) settle.
|
|
21
|
+
*/
|
|
22
|
+
stopAccepting(): void {
|
|
23
|
+
this.accepting = false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resolves once nothing tracked is under way (at once when nothing is). */
|
|
27
|
+
idle(): Promise<void> {
|
|
28
|
+
if (this.active === 0) return Promise.resolve();
|
|
29
|
+
return new Promise<void>((resolve) => this.idleWaiters.push(resolve));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
track<T>(operation: () => Promise<T>): Promise<T> {
|
|
33
|
+
if (!this.accepting) {
|
|
34
|
+
return Promise.reject(
|
|
35
|
+
new Error("managed host is shutting down; retry the operation"),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
this.active += 1;
|
|
39
|
+
let result: Promise<T>;
|
|
40
|
+
try {
|
|
41
|
+
result = operation();
|
|
42
|
+
} catch (error) {
|
|
43
|
+
this.settle();
|
|
44
|
+
return Promise.reject(error);
|
|
45
|
+
}
|
|
46
|
+
return Promise.resolve(result).finally(() => this.settle());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private settle(): void {
|
|
50
|
+
this.active -= 1;
|
|
51
|
+
if (this.active < 0) {
|
|
52
|
+
throw new Error("managed operation drain settled more work than it tracked");
|
|
53
|
+
}
|
|
54
|
+
if (this.active === 0) for (const resolve of this.idleWaiters.splice(0)) resolve();
|
|
55
|
+
this.onSettled();
|
|
56
|
+
}
|
|
57
|
+
}
|