@nylorun/runtime 0.23.0-beta → 0.25.0-beta
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/CHANGELOG.md +252 -0
- package/README.md +87 -28
- package/dist/adapters/execution/restate.js +1 -1
- package/dist/adapters/replication/pgoutput.d.ts +2 -0
- package/dist/adapters/replication/pgoutput.js +6 -3
- package/dist/adapters/sandbox/local.d.ts +9 -1
- package/dist/adapters/sandbox/local.js +16 -4
- package/dist/adapters/sandbox/virtual.js +4 -1
- package/dist/api/a2a/card.d.ts +11 -1
- package/dist/api/a2a/card.js +26 -2
- package/dist/api/a2a/endpoint.js +9 -3
- package/dist/api/a2a/protocol.d.ts +25 -2
- package/dist/api/a2a/protocol.js +20 -3
- package/dist/api/a2a/routes.d.ts +6 -1
- package/dist/api/a2a/routes.js +67 -9
- package/dist/api/a2a/tasks.js +2 -1
- package/dist/api/ag-ui/endpoint.js +8 -3
- package/dist/api/ag-ui/history.d.ts +6 -1
- package/dist/api/ag-ui/history.js +39 -3
- package/dist/api/ag-ui/routes.d.ts +4 -1
- package/dist/api/ag-ui/routes.js +63 -20
- package/dist/api/ag-ui/translate.js +3 -2
- package/dist/api/components.d.ts +5327 -1091
- package/dist/api/components.js +40 -16
- package/dist/api/gate/keys.d.ts +2 -1
- package/dist/api/gate/keys.js +14 -1
- package/dist/api/gate/routes.d.ts +6 -0
- package/dist/api/gate/routes.js +52 -3
- package/dist/api/http/app.js +17 -6
- package/dist/api/http/body.d.ts +3 -3
- package/dist/api/http/body.js +7 -7
- package/dist/api/http/define.d.ts +6 -0
- package/dist/api/http/define.js +13 -1
- package/dist/api/http/respond.js +1 -1
- package/dist/api/http/routes/artifacts.js +35 -8
- package/dist/api/http/routes/coverage.d.ts +9 -0
- package/dist/api/http/routes/coverage.js +34 -0
- package/dist/api/http/routes/environment-manifests.d.ts +9 -0
- package/dist/api/http/routes/environment-manifests.js +118 -0
- package/dist/api/http/routes/environments.d.ts +11 -0
- package/dist/api/http/routes/environments.js +332 -0
- package/dist/api/http/routes/mcp.d.ts +1 -1
- package/dist/api/http/routes/mcp.js +2 -2
- package/dist/api/http/routes/me.js +1 -1
- package/dist/api/http/routes/reads.d.ts +94 -9
- package/dist/api/http/routes/reads.js +54 -5
- package/dist/api/http/routes/sessions.js +24 -9
- package/dist/api/http/routes/tenant.js +65 -15
- package/dist/api/inbound-files.d.ts +65 -0
- package/dist/api/inbound-files.js +115 -0
- package/dist/api/openapi.js +48 -28
- package/dist/artifacts/disk.d.ts +59 -0
- package/dist/artifacts/disk.js +569 -0
- package/dist/artifacts/export.d.ts +1 -1
- package/dist/artifacts/export.js +2 -3
- package/dist/artifacts/folders.d.ts +6 -3
- package/dist/artifacts/folders.js +17 -12
- package/dist/artifacts/media-types.d.ts +15 -0
- package/dist/artifacts/media-types.js +56 -4
- package/dist/artifacts/parts.d.ts +12 -3
- package/dist/artifacts/parts.js +15 -4
- package/dist/artifacts/service.d.ts +2 -1
- package/dist/artifacts/service.js +6 -1
- package/dist/artifacts/workspace.d.ts +5 -5
- package/dist/artifacts/workspace.js +7 -19
- package/dist/core/flow-host.d.ts +3 -4
- package/dist/core/flow-host.js +5 -2
- package/dist/core/provider.d.ts +3 -2
- package/dist/core/provider.js +5 -37
- package/dist/core/sandbox-routes.d.ts +2 -21
- package/dist/core/sandbox-routes.js +11 -44
- package/dist/core/turn-manifest.d.ts +3 -2
- package/dist/core/turn-manifest.js +29 -5
- package/dist/execution/types.d.ts +1 -1
- package/dist/gates/contract.d.ts +3 -24
- package/dist/gates/contract.js +1 -36
- package/dist/gates/egress-credentials.d.ts +9 -0
- package/dist/gates/egress-credentials.js +11 -4
- package/dist/gates/egress.d.ts +22 -1
- package/dist/gates/egress.js +116 -10
- package/dist/gates/fixture-model.d.ts +28 -0
- package/dist/gates/fixture-model.js +92 -0
- package/dist/gates/handler.js +4 -4
- package/dist/gates/http-tool.d.ts +9 -6
- package/dist/gates/http-tool.js +11 -17
- package/dist/gates/in-process.d.ts +5 -0
- package/dist/gates/in-process.js +12 -1
- package/dist/gates/journal.d.ts +30 -0
- package/dist/gates/journal.js +51 -0
- package/dist/gates/mcp-handler.d.ts +4 -0
- package/dist/gates/mcp-handler.js +7 -4
- package/dist/gates/meter.d.ts +10 -2
- package/dist/gates/meter.js +20 -5
- package/dist/gates/policy.d.ts +49 -0
- package/dist/gates/policy.js +132 -0
- package/dist/gates/tenant-vaults.d.ts +7 -7
- package/dist/gates/tenant-vaults.js +5 -5
- package/dist/gates/tool-calls.d.ts +10 -5
- package/dist/gates/tool-calls.js +28 -10
- package/dist/gates/tool-client.d.ts +8 -0
- package/dist/gates/tool-client.js +27 -11
- package/dist/gates/tool-contract.d.ts +11 -69
- package/dist/gates/tool-contract.js +1 -85
- package/dist/gates/tool-gate.d.ts +1 -1
- package/dist/harness/calls.d.ts +14 -7
- package/dist/harness/calls.js +30 -24
- package/dist/harness/executors.js +12 -9
- package/dist/harness/main.js +24 -5
- package/dist/harness/pod.d.ts +39 -0
- package/dist/harness/pod.js +46 -0
- package/dist/harness/service.d.ts +0 -1
- package/dist/harness/service.js +17 -9
- package/dist/harness/setup.d.ts +29 -0
- package/dist/harness/setup.js +174 -0
- package/dist/harness/ws-client.js +3 -2
- package/dist/harness/ws-port.d.ts +1 -5
- package/dist/harness/ws-port.js +1 -5
- package/dist/harness-api/in-process.js +3 -3
- package/dist/harness-api/record.d.ts +15 -1
- package/dist/harness-api/record.js +180 -50
- package/dist/harness-api/redact.d.ts +3 -0
- package/dist/harness-api/redact.js +45 -0
- package/dist/harness-api/server.d.ts +35 -4
- package/dist/harness-api/server.js +130 -17
- package/dist/harness-api/start.d.ts +10 -3
- package/dist/harness-api/start.js +40 -10
- package/dist/harness-api/tool-journal.d.ts +105 -0
- package/dist/harness-api/tool-journal.js +132 -0
- package/dist/harness-api/tool-status.d.ts +13 -0
- package/dist/harness-api/tool-status.js +26 -0
- package/dist/harness-api/workspace.d.ts +12 -7
- package/dist/harness-api/workspace.js +47 -25
- package/dist/harness-api/ws-server.d.ts +11 -3
- package/dist/harness-api/ws-server.js +94 -15
- package/dist/host/gates.d.ts +2 -1
- package/dist/host/gates.js +10 -2
- package/dist/host/main.js +54 -5
- package/dist/host/operate.js +67 -5
- package/dist/host/stack-config.d.ts +64 -0
- package/dist/host/stack-config.js +62 -2
- package/dist/keys/client.js +4 -1
- package/dist/keys/keys.d.ts +13 -6
- package/dist/keys/keys.js +9 -8
- package/dist/management-openapi.json +1264 -2171
- package/dist/mcp/connect.d.ts +26 -22
- package/dist/mcp/connect.js +30 -34
- package/dist/mcp/pool.d.ts +3 -6
- package/dist/mcp/pool.js +14 -28
- package/dist/mcp/preview.d.ts +3 -3
- package/dist/mcp/preview.js +10 -21
- package/dist/mcp/snapshot.d.ts +16 -24
- package/dist/mcp/snapshot.js +33 -45
- package/dist/model/capabilities.d.ts +26 -0
- package/dist/model/capabilities.js +44 -0
- package/dist/model/catalog.d.ts +34 -3
- package/dist/model/catalog.js +38 -2
- package/dist/model/models.d.ts +2 -0
- package/dist/model/models.js +1 -1
- package/dist/model/native-blocks.d.ts +35 -0
- package/dist/model/native-blocks.js +170 -0
- package/dist/model/pi-model.js +114 -94
- package/dist/openapi.json +12822 -3943
- package/dist/reads/types.d.ts +16 -3
- package/dist/record/append.d.ts +3 -1
- package/dist/record/append.js +1 -0
- package/dist/record/envelope.d.ts +5 -1
- package/dist/record/envelope.js +7 -3
- package/dist/record/environment.d.ts +25 -0
- package/dist/record/environment.js +44 -0
- package/dist/record/index.d.ts +1 -1
- package/dist/record/index.js +1 -1
- package/dist/sandbox/disk-fs.d.ts +95 -0
- package/dist/sandbox/disk-fs.js +206 -0
- package/dist/sandbox/join.d.ts +17 -18
- package/dist/sandbox/join.js +46 -15
- package/dist/sandbox/manager.d.ts +47 -21
- package/dist/sandbox/manager.js +162 -107
- package/dist/sandbox/placement.d.ts +14 -13
- package/dist/sandbox/placement.js +31 -14
- package/dist/sandbox/pods/client.d.ts +9 -2
- package/dist/sandbox/pods/lifecycle.d.ts +36 -27
- package/dist/sandbox/pods/lifecycle.js +29 -26
- package/dist/sandbox/pods/reconcile.js +18 -12
- package/dist/sandbox/pods/spec.d.ts +15 -13
- package/dist/sandbox/pods/spec.js +17 -20
- package/dist/sandbox/records.d.ts +2 -2
- package/dist/sandbox/records.js +3 -3
- package/dist/sandbox/resolve.d.ts +37 -27
- package/dist/sandbox/resolve.js +73 -41
- package/dist/sandbox/runtime-tools.d.ts +9 -0
- package/dist/sandbox/runtime-tools.js +16 -0
- package/dist/sandbox/session-sandbox.d.ts +4 -6
- package/dist/sandbox/session-sandbox.js +47 -94
- package/dist/sandbox/setup.d.ts +64 -0
- package/dist/sandbox/setup.js +160 -0
- package/dist/sandbox/share.d.ts +21 -26
- package/dist/sandbox/share.js +36 -63
- package/dist/sandbox/skills.d.ts +8 -6
- package/dist/sandbox/skills.js +24 -23
- package/dist/sandbox/tenant-config.d.ts +12 -14
- package/dist/sandbox/tenant-config.js +12 -17
- package/dist/sandbox/types.d.ts +6 -0
- package/dist/store/postgres/drizzle/0020_environments.sql +63 -0
- package/dist/store/postgres/drizzle/0021_disk_versions.sql +7 -0
- package/dist/store/postgres/drizzle/0022_model_usage_timing.sql +5 -0
- package/dist/store/postgres/drizzle/0023_artifact_pages.sql +1 -0
- package/dist/store/postgres/drizzle/0024_flat_manifest.sql +7 -0
- package/dist/store/postgres/drizzle/0025_environment_manifests.sql +14 -0
- package/dist/store/postgres/drizzle/0026_environment_forks.sql +1 -0
- package/dist/store/postgres/drizzle/0027_environment_image_digest.sql +1 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +56 -0
- package/dist/store/postgres/reads.js +123 -17
- package/dist/store/postgres/record-writer.d.ts +4 -4
- package/dist/store/postgres/record-writer.js +9 -9
- package/dist/store/postgres/record.d.ts +7 -0
- package/dist/store/postgres/record.js +23 -2
- package/dist/store/postgres/schema.d.ts +1126 -736
- package/dist/store/postgres/schema.js +136 -73
- package/dist/store/postgres/store.js +195 -67
- package/dist/store/types.d.ts +165 -50
- package/dist/tenant/advance.d.ts +7 -6
- package/dist/tenant/advance.js +118 -42
- package/dist/tenant/artifact-tool.d.ts +1 -1
- package/dist/tenant/artifact-tool.js +46 -26
- package/dist/tenant/commands.d.ts +15 -2
- package/dist/tenant/commands.js +115 -14
- package/dist/tenant/context.d.ts +56 -24
- package/dist/tenant/context.js +4 -15
- package/dist/tenant/effects.d.ts +1 -1
- package/dist/tenant/effects.js +44 -29
- package/dist/tenant/environment-manifests.d.ts +57 -0
- package/dist/tenant/environment-manifests.js +200 -0
- package/dist/tenant/environments.d.ts +174 -0
- package/dist/tenant/environments.js +1044 -0
- package/dist/tenant/harness-tokens.d.ts +21 -0
- package/dist/tenant/harness-tokens.js +61 -0
- package/dist/tenant/host-token.d.ts +1 -1
- package/dist/tenant/host-token.js +2 -4
- package/dist/tenant/identity-file.d.ts +4 -4
- package/dist/tenant/identity-file.js +17 -8
- package/dist/tenant/issuers.js +3 -3
- package/dist/tenant/model-setting.d.ts +14 -0
- package/dist/tenant/model-setting.js +51 -0
- package/dist/tenant/principals.js +1 -1
- package/dist/tenant/reset.js +3 -3
- package/dist/tenant/runtime.d.ts +6 -1
- package/dist/tenant/runtime.js +7 -1
- package/dist/tenant/session.d.ts +3 -3
- package/dist/tenant/session.js +0 -14
- package/dist/tenant/sessions.d.ts +18 -14
- package/dist/tenant/sessions.js +99 -143
- package/dist/tenant/signing-keys.d.ts +5 -1
- package/dist/tenant/skill-tool.d.ts +3 -3
- package/dist/tenant/skill-tool.js +6 -11
- package/dist/tenant/status.js +3 -3
- package/dist/tenant/sweep.js +13 -7
- package/dist/tenant/tool-results.js +1 -1
- package/dist/tenant/tool-search.d.ts +7 -11
- package/dist/tenant/tool-search.js +8 -5
- package/dist/tenant/transcript.d.ts +245 -36
- package/dist/tenant/transcript.js +64 -12
- package/dist/tenant/turn-timing.d.ts +43 -0
- package/dist/tenant/turn-timing.js +39 -0
- package/dist/tenant/types.d.ts +2 -0
- package/dist/traces/ids.d.ts +41 -0
- package/dist/traces/ids.js +81 -0
- package/dist/traces/key.d.ts +6 -0
- package/dist/traces/key.js +37 -0
- package/dist/traces/map.d.ts +113 -0
- package/dist/traces/map.js +289 -0
- package/dist/traces/options.d.ts +7 -0
- package/dist/traces/options.js +1 -0
- package/dist/traces/otlp.d.ts +42 -0
- package/dist/traces/otlp.js +84 -0
- package/dist/traces/projector.d.ts +58 -0
- package/dist/traces/projector.js +200 -0
- package/dist/traces/read.d.ts +23 -0
- package/dist/traces/read.js +16 -0
- package/dist/vault/coverage.d.ts +26 -0
- package/dist/vault/coverage.js +62 -0
- package/dist/vault/error.d.ts +11 -1
- package/dist/vault/error.js +5 -1
- package/dist/vault/host-model.d.ts +4 -1
- package/dist/vault/host-model.js +10 -2
- package/dist/vault/service.d.ts +92 -48
- package/dist/vault/service.js +462 -376
- package/dist/vault/sources.d.ts +5 -16
- package/dist/vault/sources.js +6 -8
- package/dist/version.d.ts +1 -1
- package/dist/version.js +2 -2
- package/package.json +13 -13
- package/dist/api/http/routes/sandboxes.d.ts +0 -9
- package/dist/api/http/routes/sandboxes.js +0 -153
- package/dist/api/http/routes/vaults.d.ts +0 -10
- package/dist/api/http/routes/vaults.js +0 -139
- package/dist/record/sandbox.d.ts +0 -25
- package/dist/record/sandbox.js +0 -43
- package/dist/sandbox/capability.d.ts +0 -3
- package/dist/sandbox/capability.js +0 -10
- package/dist/tenant/sandboxes.d.ts +0 -123
- package/dist/tenant/sandboxes.js +0 -566
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environments (D55, D56, protocol 11): `PUT`, `GET` and `DELETE /v1/environments/{id}`, the
|
|
3
|
+
* list by label, stop, reset and the lifecycle stream; sessions naming one with `environment`,
|
|
4
|
+
* and the checks every turn start makes on it.
|
|
5
|
+
*
|
|
6
|
+
* - **No scope.** The Runtime decides nothing about who uses an Environment: the app maps its
|
|
7
|
+
* people and agents to ids. It stores the id, kind, recipe, labels and the optional owner
|
|
8
|
+
* guard; the session body names the Environment (`Session.environmentId`), and deleting a
|
|
9
|
+
* session (a sessions reset) only detaches it.
|
|
10
|
+
* - **One sandbox, one vault.** Kind `virtual` (a just-bash workspace in the harness container,
|
|
11
|
+
* keyed by the Environment id and created on the first tool call), `pod` (an agent-sandbox pod
|
|
12
|
+
* on the Tenant's cluster, Host feature `sandbox-pods`, created at once) or `none` (no compute).
|
|
13
|
+
* Each has one vault (`vault/service.ts`), created and deleted with it in one transaction.
|
|
14
|
+
* - **Made once, never changed** (protocol 14, D63). An Environment is made from one version of
|
|
15
|
+
* an environment manifest (`manifest: "name"` or `"name@n"`, `environment-manifests.ts`) or
|
|
16
|
+
* from an inline manifest (the fields of the `PUT`, its `recipe`), and keeps what it runs for
|
|
17
|
+
* life: a `PUT` on an existing id may change its labels and owner guard, a field that matches
|
|
18
|
+
* what it runs is accepted (so get-or-create stays one call), and anything else is
|
|
19
|
+
* `409 environment_immutable`, naming the fork that makes a new Environment instead.
|
|
20
|
+
* `version` and `appliedVersion` stay 1 for an Environment made since then.
|
|
21
|
+
* - **Secret slots** (D64): when what it runs declares `secrets`, a secret write sends only the
|
|
22
|
+
* value, the binding comes from the slot, and a name it does not declare is
|
|
23
|
+
* `409 secret_not_in_manifest` (`secretRequestFor`).
|
|
24
|
+
* - **Access.** An application key reaches every Environment, and so does an application key
|
|
25
|
+
* acting for a subject (`Nylorun-Subject`), which needs `environments:write` to change one. A
|
|
26
|
+
* token caller reaches only the ids its grants match, and changes them only with
|
|
27
|
+
* `environments:write`; any other id is the 404 of a missing one.
|
|
28
|
+
* - **The owner guard.** When `ownerUserId` is set, only that owner's sessions may name the
|
|
29
|
+
* Environment, checked when a session opens and at every turn start (`403
|
|
30
|
+
* environment_owner_mismatch`; the opaque 404 to a caller acting for a person).
|
|
31
|
+
* - **Parallel turns (D66).** Sessions on one Environment run their turns at the same time and
|
|
32
|
+
* share its `/workspace`; one turn per session stays. Which sessions work on one disk, and when,
|
|
33
|
+
* is the app's call. Disk work (snapshot, restore, stop, reset) waits until no turn runs
|
|
34
|
+
* (`409 environment_busy`), and a turn waits while a seed copies a disk.
|
|
35
|
+
* - **Lifecycle.** `POST .../stop` suspends a pod (the volume is kept; the next turn starts it),
|
|
36
|
+
* `POST .../reset` gives it a new pod on a new volume. A lost pod refuses turns
|
|
37
|
+
* (`environment_lost`) until a reset. `lifecycle.deleteAfter` deletes an Environment unused
|
|
38
|
+
* for that long (the Tenant sweep, `sweepUnusedEnvironments`). Deleting a pod Environment keeps
|
|
39
|
+
* its row (`desired: deleted`, invisible here) until its Sandbox is gone; its vault goes at once.
|
|
40
|
+
* - **Lifecycle events** go to the Environment's own stream in the record (`Tx.environmentEvent`,
|
|
41
|
+
* `record/environment.ts`); the session's log records `environment.attached` too.
|
|
42
|
+
*/
|
|
43
|
+
import { randomUUID } from "node:crypto";
|
|
44
|
+
import { environmentGranted, parseEnvironmentManifestRef, PutEnvironmentSecretRequestSchema, PutEnvironmentSecretValueRequestSchema, } from "@nylorun/core/contracts";
|
|
45
|
+
import { hashManifest } from "@nylorun/core/compatibility";
|
|
46
|
+
import { isWorkflowManifest, parseSandboxDuration } from "@nylorun/core/define";
|
|
47
|
+
import { canonical } from "../store/canonical.js";
|
|
48
|
+
import { resolveRecipe } from "../sandbox/resolve.js";
|
|
49
|
+
import { sandboxWorkspaceKey } from "../sandbox/records.js";
|
|
50
|
+
import { effectiveSandboxConfig, readSandboxConfig } from "../sandbox/tenant-config.js";
|
|
51
|
+
import { podName } from "../sandbox/pods/name.js";
|
|
52
|
+
import { podLifecycleConfig } from "../sandbox/pods/spec.js";
|
|
53
|
+
import { SETUP_FAILED } from "../sandbox/pods/lifecycle.js";
|
|
54
|
+
import { requirePods } from "../sandbox/placement.js";
|
|
55
|
+
import { deleteDiskArtifact, forkSeedOf, runPendingSeed, snapshotEnvironment } from "../artifacts/disk.js";
|
|
56
|
+
import { manifestChanges, manifestRecipeRequest } from "./environment-manifests.js";
|
|
57
|
+
import { harnessOf, sessionOf } from "./context.js";
|
|
58
|
+
import { fail } from "./http.js";
|
|
59
|
+
/** The Environment grants that limit `scope`, or undefined when it reaches every Environment. */
|
|
60
|
+
export function environmentGrantsOf(scope) {
|
|
61
|
+
return scope.kind === "token" ? (scope.environments ?? []) : undefined;
|
|
62
|
+
}
|
|
63
|
+
/** The subject a request acts for, when it acts for one. */
|
|
64
|
+
function subjectOf(scope) {
|
|
65
|
+
return scope.kind === "token" || scope.kind === "subject" ? scope.subject : undefined;
|
|
66
|
+
}
|
|
67
|
+
/** Who the vault's audit rows name for a request: the subject, else the application. */
|
|
68
|
+
export function actorOf(scope) {
|
|
69
|
+
return subjectOf(scope) ?? "application";
|
|
70
|
+
}
|
|
71
|
+
const notFound = () => fail(404, "Environment not found");
|
|
72
|
+
/** The Environment, when `grants` reach it; otherwise the 404 of a missing one. */
|
|
73
|
+
function reachable(grants, id) {
|
|
74
|
+
if (grants !== undefined && !environmentGranted(grants, id))
|
|
75
|
+
notFound();
|
|
76
|
+
}
|
|
77
|
+
/** Refuses (the 404 of a missing one) Environment `id` when the caller's grants miss it. */
|
|
78
|
+
export function assertReaches(scope, id) {
|
|
79
|
+
reachable(environmentGrantsOf(scope), id);
|
|
80
|
+
}
|
|
81
|
+
/** A pod Environment being deleted is no longer an Environment, here. */
|
|
82
|
+
function live(row) {
|
|
83
|
+
return row?.pod?.desired === "deleted" ? undefined : row;
|
|
84
|
+
}
|
|
85
|
+
/** An Environment, unless it is gone or being deleted. */
|
|
86
|
+
export async function liveEnvironment(t, id, options = {}) {
|
|
87
|
+
return live(await t.environment(id, options));
|
|
88
|
+
}
|
|
89
|
+
/** The compute state a pod's view reports. */
|
|
90
|
+
function podState(pod) {
|
|
91
|
+
if (pod.observed === "running")
|
|
92
|
+
return "running";
|
|
93
|
+
if (pod.observed === "creating")
|
|
94
|
+
return "creating";
|
|
95
|
+
return "stopped";
|
|
96
|
+
}
|
|
97
|
+
async function viewOf(ctx, t, environment, owner) {
|
|
98
|
+
const sessions = await t.sessionsOnEnvironment(environment.id);
|
|
99
|
+
const record = environment.kind === "virtual"
|
|
100
|
+
? await t.get("sandboxes", sandboxWorkspaceKey(ctx.config.tenantId, environment.id))
|
|
101
|
+
: undefined;
|
|
102
|
+
const pod = environment.pod;
|
|
103
|
+
const slots = await slotsOf(t, environment);
|
|
104
|
+
return {
|
|
105
|
+
id: environment.id,
|
|
106
|
+
kind: environment.kind,
|
|
107
|
+
labels: environment.labels,
|
|
108
|
+
manifest: environment.recipe,
|
|
109
|
+
...(environment.manifestName === undefined ? {} : { manifestName: environment.manifestName }),
|
|
110
|
+
...(environment.manifestVersion === undefined ? {} : { manifestVersion: environment.manifestVersion }),
|
|
111
|
+
...(environment.manifestHash === undefined ? {} : { manifestHash: environment.manifestHash }),
|
|
112
|
+
...(environment.forkedFrom === undefined ? {} : { forkedFrom: environment.forkedFrom }),
|
|
113
|
+
...(slots === undefined ? {} : { slots }),
|
|
114
|
+
version: environment.version,
|
|
115
|
+
appliedVersion: environment.appliedVersion,
|
|
116
|
+
ownerUserId: environment.ownerUserId ?? null,
|
|
117
|
+
state: pod ? podState(pod) : environment.kind === "none" ? "ready" : (record?.state ?? "ready"),
|
|
118
|
+
...(pod
|
|
119
|
+
? {
|
|
120
|
+
pod: {
|
|
121
|
+
desired: pod.desired,
|
|
122
|
+
observed: pod.observed,
|
|
123
|
+
volumeGeneration: pod.volumeGen,
|
|
124
|
+
hostEpoch: pod.hostEpoch,
|
|
125
|
+
...(pod.reason ? { reason: pod.reason } : {}),
|
|
126
|
+
...(pod.imageDigest ? { imageDigest: pod.imageDigest } : {}),
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
: {}),
|
|
130
|
+
lastUsedAt: environment.lastUsedAt,
|
|
131
|
+
...(environment.kind === "none"
|
|
132
|
+
? {}
|
|
133
|
+
: {
|
|
134
|
+
disk: {
|
|
135
|
+
latestVersion: (await t.diskArtifact(environment.id))?.latestVersion || null,
|
|
136
|
+
...(diskHeld(environment)
|
|
137
|
+
? {
|
|
138
|
+
operation: {
|
|
139
|
+
kind: environment.diskOp.kind,
|
|
140
|
+
...(environment.diskOp.version === undefined ? {} : { version: environment.diskOp.version }),
|
|
141
|
+
startedAt: environment.diskOp.startedAt,
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
: {}),
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
sessions: sessions
|
|
148
|
+
.filter((session) => owner === undefined || session.ownerUserId === owner)
|
|
149
|
+
.map((session) => ({ id: session.id, activeTurnId: session.activeTurnId })),
|
|
150
|
+
createdAt: environment.createdAt,
|
|
151
|
+
updatedAt: environment.updatedAt,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Each secret slot `environment` declares, and whether its vault holds a value for it. */
|
|
155
|
+
async function slotsOf(t, environment) {
|
|
156
|
+
const declared = environment.recipe.secrets;
|
|
157
|
+
if (declared === undefined)
|
|
158
|
+
return undefined;
|
|
159
|
+
const vault = await t.vaultOfEnvironment(environment.id);
|
|
160
|
+
const held = new Set(vault ? (await t.credentialsForVault(vault.id)).map((row) => row.name) : []);
|
|
161
|
+
return Object.entries(declared)
|
|
162
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
163
|
+
.map(([name, slot]) => ({ name, type: slot.type, required: slot.required ?? false, set: held.has(name) }));
|
|
164
|
+
}
|
|
165
|
+
/** The inline fields of a `PUT`: what it would run, besides labels, owner and `from`. */
|
|
166
|
+
const INLINE_FIELDS = [
|
|
167
|
+
"kind",
|
|
168
|
+
"image",
|
|
169
|
+
"resources",
|
|
170
|
+
"storage",
|
|
171
|
+
"network",
|
|
172
|
+
"harness",
|
|
173
|
+
"lifecycle",
|
|
174
|
+
"setup",
|
|
175
|
+
"files",
|
|
176
|
+
"secrets",
|
|
177
|
+
];
|
|
178
|
+
/** The hash of what an Environment runs, recorded when it is made. */
|
|
179
|
+
function recipeHash(recipe) {
|
|
180
|
+
return hashManifest(recipe);
|
|
181
|
+
}
|
|
182
|
+
/** What a `PUT` asks of the recipe: its fields over `existing`'s (fixed `storage` kept). */
|
|
183
|
+
function recipeRequest(body, existing) {
|
|
184
|
+
const recipe = existing?.recipe;
|
|
185
|
+
const pick = (key, sent) => sent !== undefined ? { [key]: sent } : recipe?.[key] !== undefined ? { [key]: recipe[key] } : {};
|
|
186
|
+
return {
|
|
187
|
+
...pick("image", body.image),
|
|
188
|
+
...pick("resources", body.resources),
|
|
189
|
+
...(recipe?.storage !== undefined
|
|
190
|
+
? { storage: recipe.storage }
|
|
191
|
+
: body.storage !== undefined
|
|
192
|
+
? { storage: body.storage }
|
|
193
|
+
: {}),
|
|
194
|
+
...pick("network", body.network),
|
|
195
|
+
...pick("harness", body.harness),
|
|
196
|
+
...pick("lifecycle", body.lifecycle),
|
|
197
|
+
...pick("setup", body.setup),
|
|
198
|
+
...pick("files", body.files),
|
|
199
|
+
...pick("secrets", body.secrets),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Why input files cannot be used: each that is not a version of a Tenant-wide artifact (a
|
|
204
|
+
* session's artifacts go with the session). Pins nothing else: the version is the one named.
|
|
205
|
+
*/
|
|
206
|
+
export async function fileErrors(t, files) {
|
|
207
|
+
const errors = [];
|
|
208
|
+
for (const file of files ?? []) {
|
|
209
|
+
if ("files" in file) {
|
|
210
|
+
// Files by hash (Q6): each a definition file the Tenant holds.
|
|
211
|
+
const wanted = [...new Set(Object.values(file.files))];
|
|
212
|
+
const held = await t.heldDefinitionFiles(wanted);
|
|
213
|
+
for (const [path, sha256] of Object.entries(file.files))
|
|
214
|
+
if (!held.has(sha256))
|
|
215
|
+
errors.push(`files: ${file.path}/${path} names ${sha256}, which the Tenant does not hold: upload it first (PUT /v1/files/${sha256}).`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const artifact = await t.artifact(file.artifactId);
|
|
219
|
+
if (!artifact || artifact.sessionId !== null)
|
|
220
|
+
errors.push(`files: ${file.artifactId} is not an artifact of the Tenant (an artifact of a session cannot be one).`);
|
|
221
|
+
else if (!(await t.artifactVersion(file.artifactId, file.version)))
|
|
222
|
+
errors.push(`files: ${file.artifactId} has no version ${file.version}.`);
|
|
223
|
+
}
|
|
224
|
+
return errors;
|
|
225
|
+
}
|
|
226
|
+
/** Refuses input files that cannot be used (`fileErrors`). */
|
|
227
|
+
async function checkFiles(t, files) {
|
|
228
|
+
const errors = await fileErrors(t, files);
|
|
229
|
+
if (errors.length > 0)
|
|
230
|
+
fail(400, errors.join(" "));
|
|
231
|
+
}
|
|
232
|
+
/** Refuses an owner guard a caller acting for a person may not set. */
|
|
233
|
+
function checkGuard(scope, asked, current) {
|
|
234
|
+
const subject = subjectOf(scope);
|
|
235
|
+
if (subject === undefined || asked === undefined)
|
|
236
|
+
return;
|
|
237
|
+
if (asked === null ? (current !== undefined && current !== subject) : asked !== subject)
|
|
238
|
+
fail(403, "A request acting for a person may set ownerUserId only to that person.");
|
|
239
|
+
}
|
|
240
|
+
/** Asks for a pod's reconcile once the transaction commits (the sandbox signal outbox). */
|
|
241
|
+
function reconcileAfter(t, id) {
|
|
242
|
+
return t.signalSandbox(id, { kind: "reconcile" });
|
|
243
|
+
}
|
|
244
|
+
/** Tells every process that pod Environment `id`'s host epoch moved to `epoch`, at commit. */
|
|
245
|
+
function revokeHost(t, id, epoch) {
|
|
246
|
+
return t.signal({ type: "host.revoked", sandboxId: id, epoch });
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Asks the pod to run (again), with the recipe applied: a new `rev`, so the service applies it.
|
|
250
|
+
* Only a stopped or failed pod is asked, so image and resources never change mid-turn (Q8).
|
|
251
|
+
*
|
|
252
|
+
* A pod that runs but whose disk setup failed (R2d step 2) with its recipe unchanged keeps its pod:
|
|
253
|
+
* the failure is cleared and nothing applied, so its engine, which polls `GET host/setup` with its
|
|
254
|
+
* host token, is told to run the setup again (a new apply would end that token).
|
|
255
|
+
*/
|
|
256
|
+
async function wantRunning(t, environment, now) {
|
|
257
|
+
const pod = environment.pod;
|
|
258
|
+
if (pod.desired === "running" &&
|
|
259
|
+
pod.observed === "failed" &&
|
|
260
|
+
pod.reason?.startsWith(SETUP_FAILED) &&
|
|
261
|
+
environment.appliedVersion === environment.version) {
|
|
262
|
+
await t.updateEnvironmentPod(environment.id, { observed: "creating", startedAt: now, lastActiveAt: now, reason: null }, now);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (environment.appliedVersion !== environment.version)
|
|
266
|
+
await t.updateEnvironment(environment.id, { applied: environment.recipe, appliedVersion: environment.version }, now);
|
|
267
|
+
await t.updateEnvironmentPod(environment.id, {
|
|
268
|
+
desired: "running",
|
|
269
|
+
rev: environment.pod.rev + 1,
|
|
270
|
+
observed: "creating",
|
|
271
|
+
startedAt: now,
|
|
272
|
+
lastActiveAt: now,
|
|
273
|
+
reason: null,
|
|
274
|
+
}, now);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* `PUT /v1/environments/{id}`: creates the Environment and its vault (within the Tenant's limit
|
|
278
|
+
* on their number) from an environment manifest's version or from the inline fields, or finds
|
|
279
|
+
* the one with this id, so "get or create" is one call. An existing one never changes what it
|
|
280
|
+
* runs (D63): its labels and owner guard may change, fields that match are accepted, and any
|
|
281
|
+
* other difference is `409 environment_immutable`. Every `PUT` counts as the Environment's use.
|
|
282
|
+
*/
|
|
283
|
+
export async function putEnvironment(ctx, id, body, scope, options = {}) {
|
|
284
|
+
const fork = options.fork;
|
|
285
|
+
reachable(environmentGrantsOf(scope), id);
|
|
286
|
+
const ref = body.manifest === undefined ? undefined : parseEnvironmentManifestRef(body.manifest);
|
|
287
|
+
if (body.manifest !== undefined) {
|
|
288
|
+
if (ref === undefined)
|
|
289
|
+
fail(400, "manifest is an environment manifest's name, or name@version such as analyst-box@3.");
|
|
290
|
+
const inline = INLINE_FIELDS.filter((field) => body[field] !== undefined);
|
|
291
|
+
if (inline.length > 0)
|
|
292
|
+
fail(400, `An Environment made from manifest ${body.manifest} runs what the manifest says: send no ${inline.join(", ")} with it.`);
|
|
293
|
+
}
|
|
294
|
+
if (body.kind === "pod")
|
|
295
|
+
await requirePods(ctx.pods, "A pod Environment");
|
|
296
|
+
const immutable = (fields, existing) => fail(409, `Environment ${id} exists and never changes what it runs (${fields.join(", ")} differ${fields.length === 1 ? "s" : ""}): fork it into a new Environment with POST /v1/environments/${encodeURIComponent(id)}/fork, or use another id.`, {
|
|
297
|
+
code: "environment_immutable",
|
|
298
|
+
details: {
|
|
299
|
+
fields: [...fields],
|
|
300
|
+
...(existing.manifestName === undefined
|
|
301
|
+
? {}
|
|
302
|
+
: { manifest: `${existing.manifestName}@${existing.manifestVersion}` }),
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
const view = await ctx.store.tx(async (t) => {
|
|
306
|
+
const now = new Date().toISOString();
|
|
307
|
+
const config = effectiveSandboxConfig(await readSandboxConfig(t));
|
|
308
|
+
const resolve = (kind, request) => {
|
|
309
|
+
const resolved = resolveRecipe(kind, request, config);
|
|
310
|
+
return resolved.kind === "error" ? fail(400, resolved.errors.join(" ")) : resolved.recipe;
|
|
311
|
+
};
|
|
312
|
+
const deleting = (row) => {
|
|
313
|
+
if (row && !live(row))
|
|
314
|
+
fail(409, `Environment ${id} is being deleted. Try again once it is gone.`, {
|
|
315
|
+
code: "environment_busy",
|
|
316
|
+
details: { retryAfterSeconds: 5 },
|
|
317
|
+
}, { "retry-after": "5" });
|
|
318
|
+
};
|
|
319
|
+
const found = async (existing) => {
|
|
320
|
+
if (fork !== undefined)
|
|
321
|
+
fail(409, `Environment ${id} exists: a fork makes a new Environment, so name another id.`);
|
|
322
|
+
if (body.from !== undefined)
|
|
323
|
+
fail(409, `Environment ${id} exists: from applies only when a PUT creates one.`);
|
|
324
|
+
if (ref !== undefined) {
|
|
325
|
+
// A bare name matches the manifest it was made from, whatever its latest version is now.
|
|
326
|
+
if (ref.name !== existing.manifestName)
|
|
327
|
+
immutable(["manifest"], existing);
|
|
328
|
+
if (ref.version !== undefined && ref.version !== existing.manifestVersion)
|
|
329
|
+
immutable(["manifest"], existing);
|
|
330
|
+
}
|
|
331
|
+
else if (INLINE_FIELDS.some((field) => body[field] !== undefined)) {
|
|
332
|
+
if (body.kind !== undefined && body.kind !== existing.kind)
|
|
333
|
+
immutable(["kind"], existing);
|
|
334
|
+
if (body.storage !== undefined && body.storage !== existing.recipe.storage)
|
|
335
|
+
immutable(["storage"], existing);
|
|
336
|
+
const recipe = resolve(existing.kind, recipeRequest(body, existing));
|
|
337
|
+
const differing = Object.keys({ ...recipe, ...existing.recipe })
|
|
338
|
+
.filter((field) => canonical(recipe[field]) !== canonical(existing.recipe[field]))
|
|
339
|
+
.sort();
|
|
340
|
+
if (differing.length > 0)
|
|
341
|
+
immutable(differing, existing);
|
|
342
|
+
}
|
|
343
|
+
checkGuard(scope, body.ownerUserId, existing.ownerUserId);
|
|
344
|
+
const patch = { lastUsedAt: now };
|
|
345
|
+
if (body.labels !== undefined && canonical(body.labels) !== canonical(existing.labels))
|
|
346
|
+
patch.labels = body.labels;
|
|
347
|
+
if (body.ownerUserId !== undefined && (body.ownerUserId ?? undefined) !== existing.ownerUserId)
|
|
348
|
+
patch.ownerUserId = body.ownerUserId;
|
|
349
|
+
await t.updateEnvironment(id, patch, now);
|
|
350
|
+
return viewOf(ctx, t, (await t.environment(id)) ?? existing, subjectOf(scope));
|
|
351
|
+
};
|
|
352
|
+
const existing = await t.environment(id, { lock: true });
|
|
353
|
+
deleting(existing);
|
|
354
|
+
if (existing)
|
|
355
|
+
return found(existing);
|
|
356
|
+
checkGuard(scope, body.ownerUserId);
|
|
357
|
+
const manifest = ref === undefined
|
|
358
|
+
? undefined
|
|
359
|
+
: ((await t.environmentManifest(ref.name, ref.version)) ??
|
|
360
|
+
fail(404, ref.version === undefined
|
|
361
|
+
? `No environment manifest is named ${ref.name}: save it first (PUT /v1/environment-manifests/${ref.name}).`
|
|
362
|
+
: `Environment manifest ${ref.name} has no version ${ref.version}.`, { code: "manifest_not_found" }));
|
|
363
|
+
const kind = manifest ? (manifest.body.kind ?? "virtual") : (body.kind ?? "virtual");
|
|
364
|
+
if (manifest && kind === "pod")
|
|
365
|
+
await requirePods(ctx.pods, `Environment manifest ${manifest.name} is of kind pod, and a pod Environment`);
|
|
366
|
+
const recipe = resolve(kind, manifest ? manifestRecipeRequest(manifest.body) : recipeRequest(body));
|
|
367
|
+
await checkFiles(t, recipe.files);
|
|
368
|
+
const from = body.from === undefined ? undefined : await forkSource(t, scope, kind, body.from);
|
|
369
|
+
// A fork onto other setup runs it on the written disk (Q17).
|
|
370
|
+
const seed = from && fork?.setup ? { ...from, setup: true } : from;
|
|
371
|
+
const row = {
|
|
372
|
+
id,
|
|
373
|
+
kind,
|
|
374
|
+
labels: body.labels ?? {},
|
|
375
|
+
recipe,
|
|
376
|
+
applied: recipe,
|
|
377
|
+
version: 1,
|
|
378
|
+
appliedVersion: 1,
|
|
379
|
+
...(body.ownerUserId ? { ownerUserId: body.ownerUserId } : {}),
|
|
380
|
+
lastUsedAt: now,
|
|
381
|
+
createdAt: now,
|
|
382
|
+
updatedAt: now,
|
|
383
|
+
...(manifest ? { manifestName: manifest.name, manifestVersion: manifest.version } : {}),
|
|
384
|
+
manifestHash: recipeHash(recipe),
|
|
385
|
+
...(fork ? { forkedFrom: { environment: fork.source, disk: seed?.version ?? null } } : {}),
|
|
386
|
+
...(seed
|
|
387
|
+
? {
|
|
388
|
+
diskSeed: seed,
|
|
389
|
+
diskOp: {
|
|
390
|
+
kind: "seed",
|
|
391
|
+
version: seed.version,
|
|
392
|
+
startedAt: now,
|
|
393
|
+
until: new Date(Date.now() + SEED_LEASE_MS).toISOString(),
|
|
394
|
+
},
|
|
395
|
+
}
|
|
396
|
+
: {}),
|
|
397
|
+
...(kind === "pod"
|
|
398
|
+
? {
|
|
399
|
+
pod: {
|
|
400
|
+
k8sName: podName(ctx.config.tenantId, id, 0),
|
|
401
|
+
volumeGen: 0,
|
|
402
|
+
desired: "running",
|
|
403
|
+
observed: "creating",
|
|
404
|
+
hostEpoch: 0,
|
|
405
|
+
rev: 1,
|
|
406
|
+
startedAt: now,
|
|
407
|
+
lastActiveAt: now,
|
|
408
|
+
},
|
|
409
|
+
}
|
|
410
|
+
: {}),
|
|
411
|
+
};
|
|
412
|
+
const created = await t.createEnvironment(row, config.limits.environments);
|
|
413
|
+
if (created === "limit")
|
|
414
|
+
fail(409, `This Tenant allows at most ${config.limits.environments} Environments.`, {
|
|
415
|
+
code: "limit_exceeded",
|
|
416
|
+
details: { limit: "environments", max: config.limits.environments },
|
|
417
|
+
});
|
|
418
|
+
if (created === "exists") {
|
|
419
|
+
// Created by another request since the read above: answer as for an existing one.
|
|
420
|
+
const again = (await t.environment(id, { lock: true })) ?? notFound();
|
|
421
|
+
deleting(again);
|
|
422
|
+
return found(again);
|
|
423
|
+
}
|
|
424
|
+
const vaultId = await ctx.vault.createEnvironmentVault(t, id, actorOf(scope), fork?.sealed?.vaultId);
|
|
425
|
+
if (fork?.sealed)
|
|
426
|
+
fork.copied = await ctx.vault.insertEnvironmentSecretCopies(t, vaultId, fork.source, actorOf(scope), fork.sealed.copies);
|
|
427
|
+
const madeFrom = manifest ? { manifest: { name: manifest.name, version: manifest.version } } : {};
|
|
428
|
+
await t.environmentEvent(id, "environment.created", {
|
|
429
|
+
kind,
|
|
430
|
+
labels: row.labels,
|
|
431
|
+
...madeFrom,
|
|
432
|
+
...(seed ? { from: { environment: body.from.environment, version: seed.version } } : {}),
|
|
433
|
+
...(row.forkedFrom ? { forkedFrom: row.forkedFrom } : {}),
|
|
434
|
+
});
|
|
435
|
+
if (fork)
|
|
436
|
+
await t.environmentEvent(fork.source, "environment.forked", {
|
|
437
|
+
to: id,
|
|
438
|
+
disk: seed?.version ?? null,
|
|
439
|
+
...madeFrom,
|
|
440
|
+
secrets: fork.copied?.copied.length ?? 0,
|
|
441
|
+
});
|
|
442
|
+
if (row.pod) {
|
|
443
|
+
// Created at once; idle from now if no turn comes.
|
|
444
|
+
await reconcileAfter(t, id);
|
|
445
|
+
const idleAt = Date.parse(now) + podLifecycleConfig(await readSandboxConfig(t), recipe).idleMs;
|
|
446
|
+
await t.signalSandbox(id, { kind: "arm", timer: "idle", at: idleAt });
|
|
447
|
+
}
|
|
448
|
+
return viewOf(ctx, t, row, subjectOf(scope));
|
|
449
|
+
});
|
|
450
|
+
// A virtual fork's workspace is written at once (a pod's once its engine connects).
|
|
451
|
+
if (body.from !== undefined && view.kind === "virtual") {
|
|
452
|
+
await runPendingSeed(ctx, id);
|
|
453
|
+
return getEnvironment(ctx, id, scope);
|
|
454
|
+
}
|
|
455
|
+
return view;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* The secret write `raw` asks of Environment `id` (D64). When what it runs declares secret
|
|
459
|
+
* slots, the name must be one (`409 secret_not_in_manifest`), and the body is `{ value }` (or
|
|
460
|
+
* `{ headers }` for a `headers` slot) with the binding from the slot; a full typed body is
|
|
461
|
+
* accepted only when its binding is the slot's. Without slots, today's typed body.
|
|
462
|
+
*/
|
|
463
|
+
export async function secretRequestFor(ctx, id, name, raw) {
|
|
464
|
+
const environment = await ctx.store.tx((t) => liveEnvironment(t, id));
|
|
465
|
+
const slots = environment?.recipe.secrets;
|
|
466
|
+
if (!environment || slots === undefined)
|
|
467
|
+
return PutEnvironmentSecretRequestSchema.parse(raw);
|
|
468
|
+
const slot = slots[name] ??
|
|
469
|
+
fail(409, `What Environment ${id} runs declares no secret ${name}${environment.manifestName ? ` (manifest ${environment.manifestName}@${environment.manifestVersion})` : ""}: add the slot to the manifest, then make or fork an Environment from the new version.`, { code: "secret_not_in_manifest", details: { declared: Object.keys(slots).sort() } });
|
|
470
|
+
const full = slotRequest(slot);
|
|
471
|
+
if (raw !== null && typeof raw === "object" && "type" in raw) {
|
|
472
|
+
const body = PutEnvironmentSecretRequestSchema.parse(raw);
|
|
473
|
+
const { requestId: _, value: _v, headers: _h, ...binding } = body;
|
|
474
|
+
if (canonical(binding) !== canonical(full))
|
|
475
|
+
fail(409, `Secret ${name}'s type and binding come from what Environment ${id} runs: send only its value ({ "value": "…" }).`, {
|
|
476
|
+
code: "secret_not_in_manifest",
|
|
477
|
+
});
|
|
478
|
+
return body;
|
|
479
|
+
}
|
|
480
|
+
const { requestId, value, headers } = PutEnvironmentSecretValueRequestSchema.parse(raw);
|
|
481
|
+
const withId = requestId === undefined ? {} : { requestId };
|
|
482
|
+
if (slot.type === "headers") {
|
|
483
|
+
if (headers === undefined)
|
|
484
|
+
fail(400, `Secret ${name} is a headers credential: send { "headers": { … } }.`);
|
|
485
|
+
return PutEnvironmentSecretRequestSchema.parse({ ...withId, ...full, headers });
|
|
486
|
+
}
|
|
487
|
+
if (value === undefined)
|
|
488
|
+
fail(400, `Secret ${name} takes a value: send { "value": "…" }.`);
|
|
489
|
+
return PutEnvironmentSecretRequestSchema.parse({ ...withId, ...full, value });
|
|
490
|
+
}
|
|
491
|
+
/** A slot's type and binding, as a secret write's fields (without the value). */
|
|
492
|
+
function slotRequest(slot) {
|
|
493
|
+
const { required: _r, description: _d, ...binding } = slot;
|
|
494
|
+
return binding;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* The disk version a create's `from` names (R2d step 3): the caller's grants must reach the
|
|
498
|
+
* source, a caller acting for a person must be its guard's owner, and kind `none` has no disk.
|
|
499
|
+
*/
|
|
500
|
+
async function forkSource(t, scope, kind, from) {
|
|
501
|
+
if (kind === "none")
|
|
502
|
+
fail(400, "from is for an Environment with a disk: kind none has none.");
|
|
503
|
+
reachable(environmentGrantsOf(scope), from.environment);
|
|
504
|
+
const source = (await liveEnvironment(t, from.environment)) ?? notFound();
|
|
505
|
+
const subject = subjectOf(scope);
|
|
506
|
+
if (subject !== undefined && source.ownerUserId !== undefined && source.ownerUserId !== subject)
|
|
507
|
+
notFound();
|
|
508
|
+
return forkSeedOf(t, from);
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* `POST /v1/environments/{id}/fork` (Q15): how an Environment gets a new manifest version. A new
|
|
512
|
+
* Environment `body.id` is made from the manifest version (default the source's manifest at its
|
|
513
|
+
* latest), its disk started from a version of the source's (`latest` takes a snapshot first,
|
|
514
|
+
* refused during a turn), and its secrets copied inside the Runtime. A fork onto other setup
|
|
515
|
+
* runs that setup on the copied disk before its first turn (Q17). The source keeps what it runs,
|
|
516
|
+
* its disk and its sessions. With `dryRun`, the plan only.
|
|
517
|
+
*/
|
|
518
|
+
export async function forkEnvironment(ctx, sourceId, body, scope) {
|
|
519
|
+
const grants = environmentGrantsOf(scope);
|
|
520
|
+
reachable(grants, sourceId);
|
|
521
|
+
reachable(grants, body.id);
|
|
522
|
+
if (body.id === sourceId)
|
|
523
|
+
fail(400, "A fork is a new Environment: give it another id.");
|
|
524
|
+
const { plan: planned, slots } = await ctx.store.tx(async (t) => {
|
|
525
|
+
const source = (await liveEnvironment(t, sourceId)) ?? notFound();
|
|
526
|
+
const subject = subjectOf(scope);
|
|
527
|
+
if (subject !== undefined && source.ownerUserId !== undefined && source.ownerUserId !== subject)
|
|
528
|
+
notFound();
|
|
529
|
+
const ref = body.manifest === undefined ? undefined : parseEnvironmentManifestRef(body.manifest);
|
|
530
|
+
if (body.manifest !== undefined && ref === undefined)
|
|
531
|
+
fail(400, "manifest is an environment manifest's name, or name@version such as analyst-box@3.");
|
|
532
|
+
const name = ref?.name ??
|
|
533
|
+
source.manifestName ??
|
|
534
|
+
fail(400, `Environment ${sourceId} was made from inline fields, not a manifest: name the manifest to fork it onto (manifest).`);
|
|
535
|
+
const manifest = (await t.environmentManifest(name, ref?.version)) ??
|
|
536
|
+
fail(404, ref?.version === undefined
|
|
537
|
+
? `No environment manifest is named ${name}.`
|
|
538
|
+
: `Environment manifest ${name} has no version ${ref.version}.`, { code: "manifest_not_found" });
|
|
539
|
+
const kind = manifest.body.kind ?? "virtual";
|
|
540
|
+
const resolved = resolveRecipe(kind, manifestRecipeRequest(manifest.body), effectiveSandboxConfig(await readSandboxConfig(t)));
|
|
541
|
+
if (resolved.kind === "error")
|
|
542
|
+
return fail(400, resolved.errors.join(" "));
|
|
543
|
+
const recipe = resolved.recipe;
|
|
544
|
+
const asked = body.disk ?? "latest";
|
|
545
|
+
const disk = kind === "none" || source.kind === "none" || asked === "empty"
|
|
546
|
+
? null
|
|
547
|
+
: asked === "latest"
|
|
548
|
+
? "latest"
|
|
549
|
+
: (await forkSeedOf(t, { environment: sourceId, version: asked })).version;
|
|
550
|
+
const vault = await t.vaultOfEnvironment(sourceId);
|
|
551
|
+
const names = vault
|
|
552
|
+
? (await t.credentialsForVault(vault.id)).filter((row) => row.type !== "model").map((row) => row.name).sort()
|
|
553
|
+
: [];
|
|
554
|
+
const copied = body.secrets === "none" ? [] : names.filter((item) => recipe.secrets === undefined || item in recipe.secrets);
|
|
555
|
+
const plan = {
|
|
556
|
+
source: sourceId,
|
|
557
|
+
id: body.id,
|
|
558
|
+
kind,
|
|
559
|
+
manifest: { name: manifest.name, version: manifest.version },
|
|
560
|
+
disk,
|
|
561
|
+
setup: kind === "pod" &&
|
|
562
|
+
disk !== null &&
|
|
563
|
+
recipeHash(recipe) !== source.manifestHash &&
|
|
564
|
+
Boolean(recipe.setup?.length || recipe.files?.length),
|
|
565
|
+
secrets: {
|
|
566
|
+
copied,
|
|
567
|
+
dropped: names.filter((item) => !copied.includes(item)),
|
|
568
|
+
missing: Object.entries(recipe.secrets ?? {})
|
|
569
|
+
.filter(([item, slot]) => slot.required === true && !copied.includes(item))
|
|
570
|
+
.map(([item]) => item)
|
|
571
|
+
.sort(),
|
|
572
|
+
},
|
|
573
|
+
changes: manifestChanges(source.recipe, recipe),
|
|
574
|
+
sessions: (await t.sessionsOnEnvironment(sourceId)).length,
|
|
575
|
+
};
|
|
576
|
+
const slots = recipe.secrets === undefined
|
|
577
|
+
? null
|
|
578
|
+
: Object.fromEntries(Object.entries(recipe.secrets).map(([item, slot]) => [item, slotRequest(slot)]));
|
|
579
|
+
return { plan, slots };
|
|
580
|
+
});
|
|
581
|
+
if (body.dryRun)
|
|
582
|
+
return { dryRun: true, plan: planned };
|
|
583
|
+
// `latest`: a version of the disk as it is now, between turns (an unchanged disk makes none).
|
|
584
|
+
const disk = planned.disk === "latest" ? (await snapshotEnvironment(ctx, sourceId, scope)).version : planned.disk;
|
|
585
|
+
// Keys holds the vault key (F4.2): it seals the copies first, outside the create's transaction,
|
|
586
|
+
// and the create inserts them, so the fork is still one commit.
|
|
587
|
+
const vaultId = randomUUID();
|
|
588
|
+
const sealed = (body.secrets ?? "copy") === "copy"
|
|
589
|
+
? { vaultId, copies: await ctx.keys.sealEnvironmentSecretCopies(sourceId, vaultId, slots) }
|
|
590
|
+
: undefined;
|
|
591
|
+
const fork = { source: sourceId, setup: planned.setup, ...(sealed ? { sealed } : {}) };
|
|
592
|
+
const environment = await putEnvironment(ctx, body.id, {
|
|
593
|
+
...(body.requestId === undefined ? {} : { requestId: body.requestId }),
|
|
594
|
+
manifest: `${planned.manifest.name}@${planned.manifest.version}`,
|
|
595
|
+
...(body.labels === undefined ? {} : { labels: body.labels }),
|
|
596
|
+
...(body.ownerUserId === undefined ? {} : { ownerUserId: body.ownerUserId }),
|
|
597
|
+
...(disk === null ? {} : { from: { environment: sourceId, version: disk } }),
|
|
598
|
+
}, scope, { fork });
|
|
599
|
+
const copied = fork.copied ?? { copied: [], dropped: planned.secrets.copied };
|
|
600
|
+
const dropped = [...new Set([...planned.secrets.dropped, ...copied.dropped])].sort();
|
|
601
|
+
return {
|
|
602
|
+
environment,
|
|
603
|
+
plan: {
|
|
604
|
+
...planned,
|
|
605
|
+
disk,
|
|
606
|
+
secrets: {
|
|
607
|
+
copied: copied.copied,
|
|
608
|
+
dropped,
|
|
609
|
+
missing: Object.entries(environment.manifest.secrets ?? {})
|
|
610
|
+
.filter(([item, slot]) => slot.required === true && !copied.copied.includes(item))
|
|
611
|
+
.map(([item]) => item)
|
|
612
|
+
.sort(),
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
/** `GET /v1/environments/{id}`. */
|
|
618
|
+
export async function getEnvironment(ctx, id, scope) {
|
|
619
|
+
reachable(environmentGrantsOf(scope), id);
|
|
620
|
+
return ctx.store.tx(async (t) => {
|
|
621
|
+
const environment = (await liveEnvironment(t, id)) ?? notFound();
|
|
622
|
+
return viewOf(ctx, t, environment, subjectOf(scope));
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
/** `GET /v1/environments?label=k=v`: every Environment with all the labels, that the caller reaches. */
|
|
626
|
+
export async function listEnvironments(ctx, labels, scope) {
|
|
627
|
+
const grants = environmentGrantsOf(scope);
|
|
628
|
+
return ctx.store.tx(async (t) => {
|
|
629
|
+
const found = await t.listEnvironments({ labels });
|
|
630
|
+
const views = [];
|
|
631
|
+
for (const environment of found)
|
|
632
|
+
if (live(environment) && (grants === undefined || environmentGranted(grants, environment.id)))
|
|
633
|
+
views.push(await viewOf(ctx, t, environment, subjectOf(scope)));
|
|
634
|
+
return { environments: views };
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Asks pod Environment `id` to run when it is stopped or failed (a disk copy needs its engine),
|
|
639
|
+
* with its recipe applied, as a turn start would.
|
|
640
|
+
*/
|
|
641
|
+
export async function wakePod(ctx, id) {
|
|
642
|
+
if (!ctx.pods)
|
|
643
|
+
fail(409, `Environment ${id} is a pod Environment, and this Runtime has no sandbox pods.`, { code: "environment_unavailable" });
|
|
644
|
+
await ctx.store.tx(async (t) => {
|
|
645
|
+
const environment = await liveEnvironment(t, id, { lock: true });
|
|
646
|
+
const pod = environment?.pod;
|
|
647
|
+
if (!environment || !pod)
|
|
648
|
+
return;
|
|
649
|
+
if (pod.observed === "lost")
|
|
650
|
+
fail(409, `Environment ${id} was lost (${pod.reason ?? "its volume is gone"}). Reset or restore it.`, {
|
|
651
|
+
code: "environment_lost",
|
|
652
|
+
});
|
|
653
|
+
const now = new Date().toISOString();
|
|
654
|
+
if (pod.desired !== "running" || pod.observed === "failed") {
|
|
655
|
+
await wantRunning(t, environment, now);
|
|
656
|
+
await reconcileAfter(t, id);
|
|
657
|
+
}
|
|
658
|
+
else
|
|
659
|
+
await t.updateEnvironmentPod(id, { lastActiveAt: now }, now);
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
/** Whether a disk lease (R2d step 3) holds `environment` now. */
|
|
663
|
+
export function diskHeld(environment, now = Date.now()) {
|
|
664
|
+
return environment.diskOp !== undefined && Date.parse(environment.diskOp.until) > now;
|
|
665
|
+
}
|
|
666
|
+
/** Refused while a snapshot or restore copies the disk (a pending seed does not count). */
|
|
667
|
+
export function refuseDiskCopy(environment) {
|
|
668
|
+
if (diskHeld(environment) && environment.diskOp.kind !== "seed")
|
|
669
|
+
fail(409, `Environment ${environment.id}'s disk is being copied (${environment.diskOp.kind}). Try again shortly.`, {
|
|
670
|
+
code: "environment_busy",
|
|
671
|
+
details: { retryAfterSeconds: 5 },
|
|
672
|
+
}, { "retry-after": "5" });
|
|
673
|
+
}
|
|
674
|
+
/** Refused while a session that names Environment `id` has a turn running. */
|
|
675
|
+
async function refuseBusy(t, id) {
|
|
676
|
+
const busy = (await t.sessionsOnEnvironment(id)).some((session) => session.activeTurnId !== null);
|
|
677
|
+
if (busy)
|
|
678
|
+
fail(409, `Environment ${id} has a turn running. Cancel it, or wait for it to end.`, {
|
|
679
|
+
code: "environment_busy",
|
|
680
|
+
details: { retryAfterSeconds: 5 },
|
|
681
|
+
}, { "retry-after": "5" });
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Deletes `environment` (locked, live) in `t`: its vault and credentials at once, its workspace
|
|
685
|
+
* after the commit (the caller's), and the row, or for a pod, the row once its Sandbox is gone.
|
|
686
|
+
*/
|
|
687
|
+
async function deleteIn(ctx, t, environment, actor, reason) {
|
|
688
|
+
const id = environment.id;
|
|
689
|
+
await t.environmentEvent(id, "environment.deleted", { reason });
|
|
690
|
+
await ctx.vault.deleteEnvironmentVault(t, id, actor);
|
|
691
|
+
// Its disk versions go with it; their manifests' bytes are the caller's, after the commit.
|
|
692
|
+
const keys = await deleteDiskArtifact(ctx, t, id);
|
|
693
|
+
if (environment.pod) {
|
|
694
|
+
const epoch = environment.pod.hostEpoch + 1;
|
|
695
|
+
await t.updateEnvironmentPod(id, { desired: "deleted", observed: "deleting", rev: environment.pod.rev + 1, hostEpoch: epoch }, new Date().toISOString());
|
|
696
|
+
await reconcileAfter(t, id);
|
|
697
|
+
await revokeHost(t, id, epoch);
|
|
698
|
+
}
|
|
699
|
+
else
|
|
700
|
+
await t.deleteEnvironment(id);
|
|
701
|
+
return keys;
|
|
702
|
+
}
|
|
703
|
+
/** Deletes the Object store keys a deletion returned, after its commit. */
|
|
704
|
+
async function deleteKeys(ctx, keys) {
|
|
705
|
+
for (const key of keys)
|
|
706
|
+
await ctx.blobs.delete(key).catch(() => undefined);
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* `DELETE /v1/environments/{id}`: deletes the Environment, its vault and its workspace. Refused
|
|
710
|
+
* while a session that names it has an active turn. Sessions keep its id: their next turn is
|
|
711
|
+
* refused (`environment_unavailable`) unless an Environment with this id exists again.
|
|
712
|
+
*/
|
|
713
|
+
export async function deleteEnvironment(ctx, id, scope) {
|
|
714
|
+
reachable(environmentGrantsOf(scope), id);
|
|
715
|
+
const deleted = await ctx.store.tx(async (t) => {
|
|
716
|
+
const environment = await liveEnvironment(t, id, { lock: true });
|
|
717
|
+
if (!environment)
|
|
718
|
+
return undefined;
|
|
719
|
+
await refuseBusy(t, id);
|
|
720
|
+
refuseDiskCopy(environment);
|
|
721
|
+
return deleteIn(ctx, t, environment, actorOf(scope), "delete");
|
|
722
|
+
});
|
|
723
|
+
// After the commit: a turn that starts now finds no Environment and is refused.
|
|
724
|
+
if (deleted) {
|
|
725
|
+
await deleteKeys(ctx, deleted);
|
|
726
|
+
await ctx.sandbox.removeSandbox(id);
|
|
727
|
+
}
|
|
728
|
+
return { id, deleted: deleted !== undefined };
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Deletes every Environment unused for its `lifecycle.deleteAfter` (Q13): no turn, recipe
|
|
732
|
+
* change, secret write, stop or reset since. One with a turn running waits for the next sweep.
|
|
733
|
+
* Run by the Tenant sweep.
|
|
734
|
+
*/
|
|
735
|
+
export async function sweepUnusedEnvironments(ctx, now = Date.now()) {
|
|
736
|
+
const due = (await ctx.store.tx((t) => t.listEnvironments())).filter((environment) => {
|
|
737
|
+
const after = environment.recipe.lifecycle.deleteAfter;
|
|
738
|
+
const ms = after === undefined ? undefined : parseSandboxDuration(after);
|
|
739
|
+
return live(environment) && ms !== undefined && Date.parse(environment.lastUsedAt) + ms <= now;
|
|
740
|
+
});
|
|
741
|
+
for (const candidate of due) {
|
|
742
|
+
try {
|
|
743
|
+
const deleted = await ctx.store.tx(async (t) => {
|
|
744
|
+
const environment = await liveEnvironment(t, candidate.id, { lock: true });
|
|
745
|
+
const after = environment?.recipe.lifecycle.deleteAfter;
|
|
746
|
+
const ms = after === undefined ? undefined : parseSandboxDuration(after);
|
|
747
|
+
if (!environment || ms === undefined || Date.parse(environment.lastUsedAt) + ms > now)
|
|
748
|
+
return undefined;
|
|
749
|
+
const busy = (await t.sessionsOnEnvironment(environment.id)).some((session) => session.activeTurnId !== null);
|
|
750
|
+
if (busy || diskHeld(environment))
|
|
751
|
+
return undefined;
|
|
752
|
+
return deleteIn(ctx, t, environment, "sweep", "deleteAfter");
|
|
753
|
+
});
|
|
754
|
+
if (deleted) {
|
|
755
|
+
await deleteKeys(ctx, deleted);
|
|
756
|
+
await ctx.sandbox.removeSandbox(candidate.id);
|
|
757
|
+
ctx.config.logger.info("environment deleted after lifecycle.deleteAfter", {
|
|
758
|
+
environmentId: candidate.id,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
ctx.config.logger.warn("environment deleteAfter failed", {
|
|
764
|
+
environmentId: candidate.id,
|
|
765
|
+
message: error instanceof Error ? error.message : String(error),
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/** The pod Environment `id` under the row lock, or a 404 or 400. */
|
|
771
|
+
async function podEnvironment(t, id) {
|
|
772
|
+
const environment = (await liveEnvironment(t, id, { lock: true })) ?? notFound();
|
|
773
|
+
if (!environment.pod)
|
|
774
|
+
return fail(400, `Environment ${id} is of kind ${environment.kind}: only a pod Environment stops and resets`);
|
|
775
|
+
return environment;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* `POST /v1/environments/{id}/stop`: suspends a pod Environment. Its volume is kept, and the next
|
|
779
|
+
* turn of a session that names it starts it again. Refused during a turn.
|
|
780
|
+
*/
|
|
781
|
+
export async function stopEnvironment(ctx, id, scope) {
|
|
782
|
+
reachable(environmentGrantsOf(scope), id);
|
|
783
|
+
return ctx.store.tx(async (t) => {
|
|
784
|
+
const environment = await podEnvironment(t, id);
|
|
785
|
+
await refuseBusy(t, id);
|
|
786
|
+
const pod = environment.pod;
|
|
787
|
+
const now = new Date().toISOString();
|
|
788
|
+
if (pod.desired === "running" && pod.observed !== "lost") {
|
|
789
|
+
const epoch = pod.hostEpoch + 1;
|
|
790
|
+
await t.updateEnvironmentPod(id, { desired: "suspended", rev: pod.rev + 1, hostEpoch: epoch }, now);
|
|
791
|
+
await t.environmentEvent(id, "environment.suspended", { reason: "stop" });
|
|
792
|
+
await reconcileAfter(t, id);
|
|
793
|
+
await revokeHost(t, id, epoch);
|
|
794
|
+
}
|
|
795
|
+
await t.updateEnvironment(id, { lastUsedAt: now }, now);
|
|
796
|
+
return viewOf(ctx, t, (await t.environment(id)) ?? environment, subjectOf(scope));
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* `POST /v1/environments/{id}/reset`: a new pod on a new, empty volume (the next volume
|
|
801
|
+
* generation), with a new join token and the recipe applied; the old Sandbox and volume are
|
|
802
|
+
* deleted. Leaves `lost` and `failed`. Refused during a turn, and while an earlier reset's
|
|
803
|
+
* Sandbox is still being deleted.
|
|
804
|
+
*/
|
|
805
|
+
export async function resetEnvironment(ctx, id, scope) {
|
|
806
|
+
reachable(environmentGrantsOf(scope), id);
|
|
807
|
+
// A missing Environment is a 404, and one of another kind a 400, with or without sandbox pods.
|
|
808
|
+
await ctx.store.tx((t) => podEnvironment(t, id));
|
|
809
|
+
await requirePods(ctx.pods, "Resetting a pod Environment");
|
|
810
|
+
return ctx.store.tx(async (t) => {
|
|
811
|
+
const environment = await podEnvironment(t, id);
|
|
812
|
+
await refuseBusy(t, id);
|
|
813
|
+
refuseDiskCopy(environment);
|
|
814
|
+
const pod = environment.pod;
|
|
815
|
+
if (pod.retiring !== undefined)
|
|
816
|
+
fail(409, `Environment ${id} is still deleting its previous volume. Try again shortly.`, {
|
|
817
|
+
code: "environment_busy",
|
|
818
|
+
details: { retryAfterSeconds: 5 },
|
|
819
|
+
}, { "retry-after": "5" });
|
|
820
|
+
await newVolume(ctx, t, environment);
|
|
821
|
+
return viewOf(ctx, t, (await t.environment(id)) ?? environment, subjectOf(scope));
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Gives pod Environment `environment` (locked) a new pod on a new, empty volume, the next volume
|
|
826
|
+
* generation, with its recipe applied: the old Sandbox and volume are retired. With `seed`, the
|
|
827
|
+
* new volume starts from that disk version before any turn (R2d step 3): a restore, or the
|
|
828
|
+
* recovery of a lost volume.
|
|
829
|
+
*/
|
|
830
|
+
export async function newVolume(ctx, t, environment, options = {}) {
|
|
831
|
+
const id = environment.id;
|
|
832
|
+
const pod = environment.pod;
|
|
833
|
+
const now = new Date().toISOString();
|
|
834
|
+
const volumeGen = pod.volumeGen + 1;
|
|
835
|
+
const epoch = pod.hostEpoch + 1;
|
|
836
|
+
await t.updateEnvironment(id, {
|
|
837
|
+
applied: environment.recipe,
|
|
838
|
+
appliedVersion: environment.version,
|
|
839
|
+
lastUsedAt: now,
|
|
840
|
+
...(!options.seed
|
|
841
|
+
? { diskSeed: null, diskOp: null }
|
|
842
|
+
: {
|
|
843
|
+
diskSeed: options.seed,
|
|
844
|
+
diskOp: {
|
|
845
|
+
kind: "seed",
|
|
846
|
+
version: options.seed.version,
|
|
847
|
+
startedAt: now,
|
|
848
|
+
until: new Date(Date.now() + SEED_LEASE_MS).toISOString(),
|
|
849
|
+
},
|
|
850
|
+
}),
|
|
851
|
+
}, now);
|
|
852
|
+
await t.updateEnvironmentPod(id, {
|
|
853
|
+
k8sName: podName(ctx.config.tenantId, id, volumeGen),
|
|
854
|
+
volumeGen,
|
|
855
|
+
retiring: pod.k8sName,
|
|
856
|
+
desired: "running",
|
|
857
|
+
observed: "creating",
|
|
858
|
+
rev: pod.rev + 1,
|
|
859
|
+
hostEpoch: epoch,
|
|
860
|
+
joinTokenHash: null,
|
|
861
|
+
podUid: null,
|
|
862
|
+
volumeId: null,
|
|
863
|
+
startedAt: now,
|
|
864
|
+
lastActiveAt: now,
|
|
865
|
+
reason: null,
|
|
866
|
+
}, now);
|
|
867
|
+
await t.environmentEvent(id, "environment.reset", {
|
|
868
|
+
volumeGeneration: volumeGen,
|
|
869
|
+
...(options.reason ? { reason: options.reason } : {}),
|
|
870
|
+
...(options.seed ? { from: { version: options.seed.version } } : {}),
|
|
871
|
+
});
|
|
872
|
+
await reconcileAfter(t, id);
|
|
873
|
+
await revokeHost(t, id, epoch);
|
|
874
|
+
return volumeGen;
|
|
875
|
+
}
|
|
876
|
+
/** How long a pending seed holds the disk before another process may take it over. */
|
|
877
|
+
export const SEED_LEASE_MS = 10 * 60_000;
|
|
878
|
+
/** `GET /v1/environments/{id}/events`: the Environment's lifecycle stream. */
|
|
879
|
+
export async function environmentEventsOf(ctx, id, scope, query = {}) {
|
|
880
|
+
reachable(environmentGrantsOf(scope), id);
|
|
881
|
+
return ctx.store.tx(async (t) => ({ events: await t.environmentEvents(id, query) }));
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* The owner guard (Q7): refuses a session of `ownerUserId` on an Environment guarded for
|
|
885
|
+
* another owner. A caller acting for a person (`opaque`) gets the 404 of a missing one.
|
|
886
|
+
*/
|
|
887
|
+
function checkOwner(environment, ownerUserId, opaque) {
|
|
888
|
+
if (environment.ownerUserId === undefined || environment.ownerUserId === ownerUserId)
|
|
889
|
+
return;
|
|
890
|
+
if (opaque)
|
|
891
|
+
notFound();
|
|
892
|
+
fail(403, `Environment ${environment.id} is for another owner's sessions (its ownerUserId guard).`, { code: "environment_owner_mismatch" });
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Opens a new session on Environment `id` (`PutSessionRequest.environment`): it must exist, be
|
|
896
|
+
* reachable with `grants` and pass its owner guard. Runs in the session's transaction, after
|
|
897
|
+
* its session row lock; holds the Environment's row lock.
|
|
898
|
+
*/
|
|
899
|
+
export async function openEnvironment(t, id, options) {
|
|
900
|
+
reachable(options.grants, id);
|
|
901
|
+
const environment = (await liveEnvironment(t, id, { lock: true })) ?? notFound();
|
|
902
|
+
checkOwner(environment, options.ownerUserId, options.opaque);
|
|
903
|
+
return environment;
|
|
904
|
+
}
|
|
905
|
+
/** Records the attachment on the session's log and on the Environment's stream. */
|
|
906
|
+
export async function recordAttachment(t, sessionId, environmentId) {
|
|
907
|
+
await t.event(sessionId, null, "environment.attached", { environmentId });
|
|
908
|
+
await t.environmentEvent(environmentId, "environment.attached", { sessionId });
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Checks a turn start on a session that names an Environment, in the command's transaction
|
|
912
|
+
* after the session row lock: the Environment still exists, a token caller's grants reach it,
|
|
913
|
+
* its owner guard admits the session's owner, and no disk copy holds it. Other sessions' turns
|
|
914
|
+
* do not stop it (D66). Holds the Environment row's lock until the transaction ends, so a turn
|
|
915
|
+
* start and disk work on one Environment are decided one after the other.
|
|
916
|
+
*
|
|
917
|
+
* A pod Environment must also be usable: not lost (`environment_lost`), on a Runtime with
|
|
918
|
+
* sandbox pods (`environment_unavailable`). A stopped or failed pod is asked to run again, with
|
|
919
|
+
* its recipe applied, after the commit.
|
|
920
|
+
*/
|
|
921
|
+
export async function checkEnvironmentTurn(t, session, scope, ctx) {
|
|
922
|
+
const id = session.environmentId;
|
|
923
|
+
if (id === undefined)
|
|
924
|
+
return;
|
|
925
|
+
const grants = environmentGrantsOf(scope);
|
|
926
|
+
if (grants !== undefined && !environmentGranted(grants, id))
|
|
927
|
+
fail(403, `This token does not reach Environment ${id}.`, { code: "environment_not_granted" });
|
|
928
|
+
const environment = await liveEnvironment(t, id, { lock: true });
|
|
929
|
+
if (!environment)
|
|
930
|
+
return fail(409, `Environment ${id} was deleted. Create it again to continue this session.`, {
|
|
931
|
+
code: "environment_unavailable",
|
|
932
|
+
});
|
|
933
|
+
if (environment.ownerUserId !== undefined && environment.ownerUserId !== session.ownerUserId)
|
|
934
|
+
fail(403, `Environment ${id} is for another owner's sessions (its ownerUserId guard).`, {
|
|
935
|
+
code: "environment_owner_mismatch",
|
|
936
|
+
});
|
|
937
|
+
// The harness the turn would run on (R4.1, Q9): a pod about to wake runs its recipe's. A
|
|
938
|
+
// session never moves to another harness: its state is that harness's.
|
|
939
|
+
const waking = environment.pod !== undefined && (environment.pod.desired !== "running" || environment.pod.observed === "failed");
|
|
940
|
+
const runs = waking ? environment.recipe.harness : environment.applied.harness;
|
|
941
|
+
if (!isWorkflowManifest(session.manifest) && runs !== harnessOf(session))
|
|
942
|
+
fail(409, `Environment ${id} now runs the ${runs} harness, and this session runs ${harnessOf(session)}: open a new session to run on it.`, {
|
|
943
|
+
code: "harness_mismatch",
|
|
944
|
+
details: { harness: harnessOf(session), environmentHarness: runs },
|
|
945
|
+
});
|
|
946
|
+
if (environment.kind === "none")
|
|
947
|
+
return;
|
|
948
|
+
// A snapshot or restore copies the disk between turns (R2d step 3); a seed's turn waits.
|
|
949
|
+
refuseDiskCopy(environment);
|
|
950
|
+
const pod = environment.pod;
|
|
951
|
+
if (!pod)
|
|
952
|
+
return;
|
|
953
|
+
if (!ctx?.pods)
|
|
954
|
+
fail(409, `Environment ${id} is a pod Environment, and this Runtime has no sandbox pods: run \`nylorun local sandbox enable --context <name>\`.`, {
|
|
955
|
+
code: "environment_unavailable",
|
|
956
|
+
});
|
|
957
|
+
if (pod.observed === "lost")
|
|
958
|
+
fail(409, `Environment ${id} was lost (${pod.reason ?? "its volume is gone"}). Reset it to continue.`, {
|
|
959
|
+
code: "environment_lost",
|
|
960
|
+
});
|
|
961
|
+
const now = new Date().toISOString();
|
|
962
|
+
if (pod.desired !== "running" || pod.observed === "failed") {
|
|
963
|
+
await wantRunning(t, environment, now);
|
|
964
|
+
await reconcileAfter(t, id);
|
|
965
|
+
}
|
|
966
|
+
else
|
|
967
|
+
await t.updateEnvironmentPod(id, { lastActiveAt: now }, now);
|
|
968
|
+
}
|
|
969
|
+
/** A sessions reset detaches every session from its Environment: each one's stream says so. */
|
|
970
|
+
export async function detachAllSessions(t) {
|
|
971
|
+
for (const environment of await t.listEnvironments()) {
|
|
972
|
+
const sessions = await t.sessionsOnEnvironment(environment.id);
|
|
973
|
+
if (sessions.length === 0)
|
|
974
|
+
continue;
|
|
975
|
+
await t.environment(environment.id, { lock: true });
|
|
976
|
+
for (const session of sessions)
|
|
977
|
+
await t.environmentEvent(environment.id, "environment.detached", {
|
|
978
|
+
sessionId: session.id,
|
|
979
|
+
reason: "reset",
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Marks the end of a turn's segment on Environment `id`: it was used now (`deleteAfter` counts
|
|
985
|
+
* from here), and for a running pod idle counts from now and the idle timer is armed (D34).
|
|
986
|
+
* Best effort.
|
|
987
|
+
*/
|
|
988
|
+
export async function touchEnvironment(ctx, id) {
|
|
989
|
+
try {
|
|
990
|
+
await ctx.store.tx(async (t) => {
|
|
991
|
+
const environment = await liveEnvironment(t, id, { lock: true });
|
|
992
|
+
if (!environment)
|
|
993
|
+
return;
|
|
994
|
+
const now = new Date();
|
|
995
|
+
const iso = now.toISOString();
|
|
996
|
+
await t.updateEnvironment(id, { lastUsedAt: iso }, iso);
|
|
997
|
+
if (!environment.pod || environment.pod.desired !== "running")
|
|
998
|
+
return;
|
|
999
|
+
await t.updateEnvironmentPod(id, { lastActiveAt: iso }, iso);
|
|
1000
|
+
const idleMs = podLifecycleConfig(await readSandboxConfig(t), environment.recipe).idleMs;
|
|
1001
|
+
await t.signalSandbox(id, { kind: "arm", timer: "idle", at: now.getTime() + idleMs });
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
catch (error) {
|
|
1005
|
+
ctx.config.logger.warn("environment use not recorded", {
|
|
1006
|
+
environmentId: id,
|
|
1007
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Re-arms the idle timer of every running pod after the Tenant's settings change, so a shorter
|
|
1013
|
+
* `limits.idle` applies to pods already idle (D36: the settings are read whenever a timer is
|
|
1014
|
+
* set). Best effort.
|
|
1015
|
+
*/
|
|
1016
|
+
export async function rearmPodTimers(ctx) {
|
|
1017
|
+
if (!ctx.pods)
|
|
1018
|
+
return;
|
|
1019
|
+
try {
|
|
1020
|
+
const { rows, config } = await ctx.store.tx(async (t) => ({
|
|
1021
|
+
rows: await t.listEnvironments(),
|
|
1022
|
+
config: await readSandboxConfig(t),
|
|
1023
|
+
}));
|
|
1024
|
+
for (const row of rows)
|
|
1025
|
+
if (row.pod?.desired === "running")
|
|
1026
|
+
await ctx.sandboxSignal(row.id, {
|
|
1027
|
+
kind: "arm",
|
|
1028
|
+
timer: "idle",
|
|
1029
|
+
at: Date.parse(row.pod.lastActiveAt ?? row.createdAt) + podLifecycleConfig(config, row.recipe).idleMs,
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
ctx.config.logger.warn("pod idle timers not re-armed", {
|
|
1034
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
/** Session lookups for the sandbox route handlers (`core/sandbox-routes.ts`). */
|
|
1039
|
+
export function sandboxRouteDeps(ctx) {
|
|
1040
|
+
return {
|
|
1041
|
+
sandbox: ctx.sandbox,
|
|
1042
|
+
session: (id) => ctx.store.tx((t) => sessionOf(t, id)),
|
|
1043
|
+
};
|
|
1044
|
+
}
|