@nylorun/runtime 0.23.0-beta → 0.24.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 +148 -0
- package/README.md +79 -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/endpoint.js +5 -1
- package/dist/api/a2a/routes.d.ts +2 -0
- package/dist/api/a2a/routes.js +2 -2
- package/dist/api/a2a/tasks.js +2 -1
- package/dist/api/ag-ui/endpoint.js +4 -2
- package/dist/api/ag-ui/history.js +4 -1
- package/dist/api/ag-ui/routes.d.ts +4 -1
- package/dist/api/ag-ui/routes.js +11 -10
- package/dist/api/ag-ui/translate.js +3 -2
- package/dist/api/components.d.ts +4953 -1204
- package/dist/api/components.js +25 -16
- package/dist/api/gate/keys.js +7 -1
- package/dist/api/gate/routes.d.ts +6 -0
- package/dist/api/gate/routes.js +39 -3
- package/dist/api/http/app.js +4 -4
- 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/environments.d.ts +11 -0
- package/dist/api/http/routes/environments.js +283 -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 +45 -9
- package/dist/api/http/routes/reads.js +54 -5
- package/dist/api/http/routes/sessions.js +11 -6
- package/dist/api/http/routes/tenant.js +32 -10
- package/dist/api/openapi.js +31 -26
- package/dist/artifacts/disk.d.ts +59 -0
- package/dist/artifacts/disk.js +563 -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/service.d.ts +2 -1
- package/dist/artifacts/service.js +6 -1
- package/dist/artifacts/workspace.d.ts +1 -1
- package/dist/artifacts/workspace.js +1 -1
- package/dist/core/flow-host.d.ts +2 -3
- package/dist/core/flow-host.js +3 -0
- 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 +10 -43
- 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 +28 -1
- package/dist/gates/egress.js +115 -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 +6 -2
- package/dist/gates/http-tool.js +3 -5
- 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 +52 -0
- package/dist/gates/mcp-handler.d.ts +4 -0
- package/dist/gates/mcp-handler.js +5 -1
- 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 +134 -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 +10 -68
- package/dist/gates/tool-contract.js +1 -85
- package/dist/gates/tool-gate.d.ts +1 -1
- package/dist/harness/calls.d.ts +12 -5
- package/dist/harness/calls.js +21 -12
- package/dist/harness/executors.js +12 -9
- package/dist/harness/main.js +18 -5
- package/dist/harness/pod.d.ts +34 -0
- package/dist/harness/pod.js +41 -0
- package/dist/harness/service.d.ts +0 -1
- package/dist/harness/service.js +13 -5
- package/dist/harness/setup.d.ts +27 -0
- package/dist/harness/setup.js +150 -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 +174 -44
- package/dist/harness-api/redact.d.ts +3 -0
- package/dist/harness-api/redact.js +45 -0
- package/dist/harness-api/server.d.ts +8 -0
- package/dist/harness-api/server.js +93 -13
- 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 +96 -0
- package/dist/harness-api/tool-journal.js +102 -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 +6 -3
- package/dist/harness-api/workspace.js +27 -11
- 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 +61 -1
- package/dist/keys/client.js +4 -1
- package/dist/keys/keys.d.ts +5 -5
- package/dist/keys/keys.js +5 -7
- package/dist/management-openapi.json +999 -2211
- package/dist/mcp/connect.d.ts +25 -20
- package/dist/mcp/connect.js +29 -30
- package/dist/mcp/pool.d.ts +2 -2
- package/dist/mcp/pool.js +1 -1
- package/dist/mcp/preview.d.ts +3 -3
- package/dist/mcp/preview.js +10 -20
- package/dist/mcp/snapshot.d.ts +5 -11
- package/dist/mcp/snapshot.js +6 -7
- package/dist/model/pi-model.js +8 -0
- package/dist/openapi.json +6955 -2526
- 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 +42 -15
- package/dist/sandbox/manager.d.ts +16 -8
- package/dist/sandbox/manager.js +33 -17
- package/dist/sandbox/placement.d.ts +13 -12
- package/dist/sandbox/placement.js +29 -12
- package/dist/sandbox/pods/client.d.ts +6 -1
- package/dist/sandbox/pods/lifecycle.d.ts +28 -27
- package/dist/sandbox/pods/lifecycle.js +14 -26
- package/dist/sandbox/pods/reconcile.js +18 -12
- package/dist/sandbox/pods/spec.d.ts +15 -13
- package/dist/sandbox/pods/spec.js +16 -20
- package/dist/sandbox/records.d.ts +2 -2
- package/dist/sandbox/records.js +3 -3
- package/dist/sandbox/resolve.d.ts +35 -27
- package/dist/sandbox/resolve.js +66 -41
- package/dist/sandbox/session-sandbox.js +1 -1
- package/dist/sandbox/setup.d.ts +61 -0
- package/dist/sandbox/setup.js +123 -0
- package/dist/sandbox/share.d.ts +11 -21
- package/dist/sandbox/share.js +12 -41
- 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/meta/_journal.json +28 -0
- package/dist/store/postgres/reads.js +117 -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 +934 -761
- package/dist/store/postgres/schema.js +102 -73
- package/dist/store/postgres/store.js +119 -66
- package/dist/store/types.d.ts +109 -48
- package/dist/tenant/advance.d.ts +7 -6
- package/dist/tenant/advance.js +115 -42
- package/dist/tenant/artifact-tool.js +3 -4
- package/dist/tenant/commands.d.ts +15 -2
- package/dist/tenant/commands.js +93 -12
- package/dist/tenant/context.d.ts +48 -22
- package/dist/tenant/context.js +4 -15
- package/dist/tenant/effects.d.ts +1 -1
- package/dist/tenant/effects.js +37 -20
- package/dist/tenant/environments.d.ts +167 -0
- package/dist/tenant/environments.js +823 -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/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 +13 -13
- package/dist/tenant/sessions.js +61 -138
- package/dist/tenant/signing-keys.d.ts +5 -1
- package/dist/tenant/status.js +3 -3
- package/dist/tenant/sweep.js +12 -6
- package/dist/tenant/transcript.d.ts +210 -20
- package/dist/tenant/transcript.js +62 -11
- 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 +42 -0
- package/dist/traces/ids.js +85 -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 +317 -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 +64 -0
- package/dist/vault/error.d.ts +11 -1
- package/dist/vault/error.js +5 -1
- package/dist/vault/service.d.ts +47 -48
- package/dist/vault/service.js +321 -382
- 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/tenant/sandboxes.d.ts +0 -123
- package/dist/tenant/sandboxes.js +0 -566
|
@@ -0,0 +1,823 @@
|
|
|
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
|
+
* - **A recipe that changes.** `PUT` on an existing id compares the fields sent: `kind` and
|
|
15
|
+
* `storage` are fixed (`409`), labels replace, and any other change writes the recipe and bumps
|
|
16
|
+
* `version`. A pod runs `applied`: image and resources reach it when it is next asked to run
|
|
17
|
+
* from stopped (`wantRunning`), so nothing swaps them mid-turn; so does `harness`, which picks
|
|
18
|
+
* what the pod runs (R4.4). Network and lifecycle apply at once. A virtual or `none`
|
|
19
|
+
* Environment applies every change at once.
|
|
20
|
+
* - **Access.** An application key reaches every Environment, and so does an application key
|
|
21
|
+
* acting for a subject (`Nylorun-Subject`), which needs `environments:write` to change one. A
|
|
22
|
+
* token caller reaches only the ids its grants match, and changes them only with
|
|
23
|
+
* `environments:write`; any other id is the 404 of a missing one.
|
|
24
|
+
* - **The owner guard.** When `ownerUserId` is set, only that owner's sessions may name the
|
|
25
|
+
* Environment, checked when a session opens and at every turn start (`403
|
|
26
|
+
* environment_owner_mismatch`; the opaque 404 to a caller acting for a person).
|
|
27
|
+
* - **Serial turns.** One turn at a time per Environment with a sandbox: a session whose
|
|
28
|
+
* Environment another session's turn holds is refused (`409 environment_busy`).
|
|
29
|
+
* - **Lifecycle.** `POST .../stop` suspends a pod (the volume is kept; the next turn starts it),
|
|
30
|
+
* `POST .../reset` gives it a new pod on a new volume. A lost pod refuses turns
|
|
31
|
+
* (`environment_lost`) until a reset. `lifecycle.deleteAfter` deletes an Environment unused
|
|
32
|
+
* for that long (the Tenant sweep, `sweepUnusedEnvironments`). Deleting a pod Environment keeps
|
|
33
|
+
* its row (`desired: deleted`, invisible here) until its Sandbox is gone; its vault goes at once.
|
|
34
|
+
* - **Lifecycle events** go to the Environment's own stream in the record (`Tx.environmentEvent`,
|
|
35
|
+
* `record/environment.ts`); the session's log records `environment.attached` too.
|
|
36
|
+
*/
|
|
37
|
+
import { environmentGranted, } from "@nylorun/core/contracts";
|
|
38
|
+
import { isWorkflowManifest, parseSandboxDuration } from "@nylorun/core/define";
|
|
39
|
+
import { canonical } from "../store/canonical.js";
|
|
40
|
+
import { resolveRecipe } from "../sandbox/resolve.js";
|
|
41
|
+
import { sandboxWorkspaceKey } from "../sandbox/records.js";
|
|
42
|
+
import { effectiveSandboxConfig, readSandboxConfig } from "../sandbox/tenant-config.js";
|
|
43
|
+
import { podName } from "../sandbox/pods/name.js";
|
|
44
|
+
import { podLifecycleConfig } from "../sandbox/pods/spec.js";
|
|
45
|
+
import { SETUP_FAILED } from "../sandbox/pods/lifecycle.js";
|
|
46
|
+
import { requirePods } from "../sandbox/placement.js";
|
|
47
|
+
import { deleteDiskArtifact, forkSeedOf, runPendingSeed } from "../artifacts/disk.js";
|
|
48
|
+
import { harnessOf, sessionOf } from "./context.js";
|
|
49
|
+
import { fail } from "./http.js";
|
|
50
|
+
/** The Environment grants that limit `scope`, or undefined when it reaches every Environment. */
|
|
51
|
+
export function environmentGrantsOf(scope) {
|
|
52
|
+
return scope.kind === "token" ? (scope.environments ?? []) : undefined;
|
|
53
|
+
}
|
|
54
|
+
/** The subject a request acts for, when it acts for one. */
|
|
55
|
+
function subjectOf(scope) {
|
|
56
|
+
return scope.kind === "token" || scope.kind === "subject" ? scope.subject : undefined;
|
|
57
|
+
}
|
|
58
|
+
/** Who the vault's audit rows name for a request: the subject, else the application. */
|
|
59
|
+
export function actorOf(scope) {
|
|
60
|
+
return subjectOf(scope) ?? "application";
|
|
61
|
+
}
|
|
62
|
+
const notFound = () => fail(404, "Environment not found");
|
|
63
|
+
/** The Environment, when `grants` reach it; otherwise the 404 of a missing one. */
|
|
64
|
+
function reachable(grants, id) {
|
|
65
|
+
if (grants !== undefined && !environmentGranted(grants, id))
|
|
66
|
+
notFound();
|
|
67
|
+
}
|
|
68
|
+
/** Refuses (the 404 of a missing one) Environment `id` when the caller's grants miss it. */
|
|
69
|
+
export function assertReaches(scope, id) {
|
|
70
|
+
reachable(environmentGrantsOf(scope), id);
|
|
71
|
+
}
|
|
72
|
+
/** A pod Environment being deleted is no longer an Environment, here. */
|
|
73
|
+
function live(row) {
|
|
74
|
+
return row?.pod?.desired === "deleted" ? undefined : row;
|
|
75
|
+
}
|
|
76
|
+
/** An Environment, unless it is gone or being deleted. */
|
|
77
|
+
export async function liveEnvironment(t, id, options = {}) {
|
|
78
|
+
return live(await t.environment(id, options));
|
|
79
|
+
}
|
|
80
|
+
/** The compute state a pod's view reports. */
|
|
81
|
+
function podState(pod) {
|
|
82
|
+
if (pod.observed === "running")
|
|
83
|
+
return "running";
|
|
84
|
+
if (pod.observed === "creating")
|
|
85
|
+
return "creating";
|
|
86
|
+
return "stopped";
|
|
87
|
+
}
|
|
88
|
+
async function viewOf(ctx, t, environment, owner) {
|
|
89
|
+
const sessions = await t.sessionsOnEnvironment(environment.id);
|
|
90
|
+
const record = environment.kind === "virtual"
|
|
91
|
+
? await t.get("sandboxes", sandboxWorkspaceKey(ctx.config.tenantId, environment.id))
|
|
92
|
+
: undefined;
|
|
93
|
+
const pod = environment.pod;
|
|
94
|
+
return {
|
|
95
|
+
id: environment.id,
|
|
96
|
+
kind: environment.kind,
|
|
97
|
+
labels: environment.labels,
|
|
98
|
+
recipe: environment.recipe,
|
|
99
|
+
version: environment.version,
|
|
100
|
+
appliedVersion: environment.appliedVersion,
|
|
101
|
+
ownerUserId: environment.ownerUserId ?? null,
|
|
102
|
+
state: pod ? podState(pod) : environment.kind === "none" ? "ready" : (record?.state ?? "ready"),
|
|
103
|
+
...(pod
|
|
104
|
+
? {
|
|
105
|
+
pod: {
|
|
106
|
+
desired: pod.desired,
|
|
107
|
+
observed: pod.observed,
|
|
108
|
+
volumeGeneration: pod.volumeGen,
|
|
109
|
+
hostEpoch: pod.hostEpoch,
|
|
110
|
+
...(pod.reason ? { reason: pod.reason } : {}),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
: {}),
|
|
114
|
+
lastUsedAt: environment.lastUsedAt,
|
|
115
|
+
...(environment.kind === "none"
|
|
116
|
+
? {}
|
|
117
|
+
: {
|
|
118
|
+
disk: {
|
|
119
|
+
latestVersion: (await t.diskArtifact(environment.id))?.latestVersion || null,
|
|
120
|
+
...(diskHeld(environment)
|
|
121
|
+
? {
|
|
122
|
+
operation: {
|
|
123
|
+
kind: environment.diskOp.kind,
|
|
124
|
+
...(environment.diskOp.version === undefined ? {} : { version: environment.diskOp.version }),
|
|
125
|
+
startedAt: environment.diskOp.startedAt,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
: {}),
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
sessions: sessions
|
|
132
|
+
.filter((session) => owner === undefined || session.ownerUserId === owner)
|
|
133
|
+
.map((session) => ({ id: session.id, activeTurnId: session.activeTurnId })),
|
|
134
|
+
createdAt: environment.createdAt,
|
|
135
|
+
updatedAt: environment.updatedAt,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** What a `PUT` asks of the recipe: its fields over `existing`'s (fixed `storage` kept). */
|
|
139
|
+
function recipeRequest(body, existing) {
|
|
140
|
+
const recipe = existing?.recipe;
|
|
141
|
+
const pick = (key, sent) => sent !== undefined ? { [key]: sent } : recipe?.[key] !== undefined ? { [key]: recipe[key] } : {};
|
|
142
|
+
return {
|
|
143
|
+
...pick("image", body.image),
|
|
144
|
+
...pick("resources", body.resources),
|
|
145
|
+
...(recipe?.storage !== undefined
|
|
146
|
+
? { storage: recipe.storage }
|
|
147
|
+
: body.storage !== undefined
|
|
148
|
+
? { storage: body.storage }
|
|
149
|
+
: {}),
|
|
150
|
+
...pick("network", body.network),
|
|
151
|
+
...pick("harness", body.harness),
|
|
152
|
+
...pick("lifecycle", body.lifecycle),
|
|
153
|
+
...pick("setup", body.setup),
|
|
154
|
+
...pick("files", body.files),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Refuses input files that are not a version of a Tenant-wide artifact (a session's artifacts
|
|
159
|
+
* go with the session); pins nothing else: the version is the one named.
|
|
160
|
+
*/
|
|
161
|
+
async function checkFiles(t, files) {
|
|
162
|
+
for (const file of files ?? []) {
|
|
163
|
+
const artifact = await t.artifact(file.artifactId);
|
|
164
|
+
if (!artifact || artifact.sessionId !== null)
|
|
165
|
+
fail(400, `files: ${file.artifactId} is not an artifact of the Tenant (an artifact of a session cannot be one).`);
|
|
166
|
+
if (!(await t.artifactVersion(file.artifactId, file.version)))
|
|
167
|
+
fail(400, `files: ${file.artifactId} has no version ${file.version}.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Refuses an owner guard a caller acting for a person may not set. */
|
|
171
|
+
function checkGuard(scope, asked, current) {
|
|
172
|
+
const subject = subjectOf(scope);
|
|
173
|
+
if (subject === undefined || asked === undefined)
|
|
174
|
+
return;
|
|
175
|
+
if (asked === null ? (current !== undefined && current !== subject) : asked !== subject)
|
|
176
|
+
fail(403, "A request acting for a person may set ownerUserId only to that person.");
|
|
177
|
+
}
|
|
178
|
+
/** Asks for a pod's reconcile once the transaction commits (the sandbox signal outbox). */
|
|
179
|
+
function reconcileAfter(t, id) {
|
|
180
|
+
return t.signalSandbox(id, { kind: "reconcile" });
|
|
181
|
+
}
|
|
182
|
+
/** Tells every process that pod Environment `id`'s host epoch moved to `epoch`, at commit. */
|
|
183
|
+
function revokeHost(t, id, epoch) {
|
|
184
|
+
return t.signal({ type: "host.revoked", sandboxId: id, epoch });
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Asks the pod to run (again), with the recipe applied: a new `rev`, so the service applies it.
|
|
188
|
+
* Only a stopped or failed pod is asked, so image and resources never change mid-turn (Q8).
|
|
189
|
+
*
|
|
190
|
+
* A pod that runs but whose disk setup failed (R2d step 2) with its recipe unchanged keeps its pod:
|
|
191
|
+
* the failure is cleared and nothing applied, so its engine, which polls `GET host/setup` with its
|
|
192
|
+
* host token, is told to run the setup again (a new apply would end that token).
|
|
193
|
+
*/
|
|
194
|
+
async function wantRunning(t, environment, now) {
|
|
195
|
+
const pod = environment.pod;
|
|
196
|
+
if (pod.desired === "running" &&
|
|
197
|
+
pod.observed === "failed" &&
|
|
198
|
+
pod.reason?.startsWith(SETUP_FAILED) &&
|
|
199
|
+
environment.appliedVersion === environment.version) {
|
|
200
|
+
await t.updateEnvironmentPod(environment.id, { observed: "creating", startedAt: now, lastActiveAt: now, reason: null }, now);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (environment.appliedVersion !== environment.version)
|
|
204
|
+
await t.updateEnvironment(environment.id, { applied: environment.recipe, appliedVersion: environment.version }, now);
|
|
205
|
+
await t.updateEnvironmentPod(environment.id, {
|
|
206
|
+
desired: "running",
|
|
207
|
+
rev: environment.pod.rev + 1,
|
|
208
|
+
observed: "creating",
|
|
209
|
+
startedAt: now,
|
|
210
|
+
lastActiveAt: now,
|
|
211
|
+
reason: null,
|
|
212
|
+
}, now);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* `PUT /v1/environments/{id}`: creates the Environment and its vault (within the Tenant's limit
|
|
216
|
+
* on their number), or finds the one with this id, so "get or create" is one call. On an
|
|
217
|
+
* existing one, `kind` and `storage` must match, labels replace, the owner guard is set or
|
|
218
|
+
* cleared, and a recipe change bumps `version`. Every `PUT` counts as the Environment's use.
|
|
219
|
+
*/
|
|
220
|
+
export async function putEnvironment(ctx, id, body, scope) {
|
|
221
|
+
reachable(environmentGrantsOf(scope), id);
|
|
222
|
+
if (body.kind === "pod")
|
|
223
|
+
await requirePods(ctx.pods, "A pod Environment");
|
|
224
|
+
const conflict = (field) => fail(409, `Environment ${id} exists with another ${field}, which is fixed: delete it to create it again.`);
|
|
225
|
+
const view = await ctx.store.tx(async (t) => {
|
|
226
|
+
const now = new Date().toISOString();
|
|
227
|
+
const config = effectiveSandboxConfig(await readSandboxConfig(t));
|
|
228
|
+
const resolve = (kind, request) => {
|
|
229
|
+
const resolved = resolveRecipe(kind, request, config);
|
|
230
|
+
return resolved.kind === "error" ? fail(400, resolved.errors.join(" ")) : resolved.recipe;
|
|
231
|
+
};
|
|
232
|
+
const deleting = (row) => {
|
|
233
|
+
if (row && !live(row))
|
|
234
|
+
fail(409, `Environment ${id} is being deleted. Try again once it is gone.`, {
|
|
235
|
+
code: "environment_busy",
|
|
236
|
+
details: { retryAfterSeconds: 5 },
|
|
237
|
+
}, { "retry-after": "5" });
|
|
238
|
+
};
|
|
239
|
+
const found = async (existing) => {
|
|
240
|
+
if (body.from !== undefined)
|
|
241
|
+
fail(409, `Environment ${id} exists: from applies only when a PUT creates one.`);
|
|
242
|
+
if (body.kind !== undefined && body.kind !== existing.kind)
|
|
243
|
+
conflict("kind");
|
|
244
|
+
if (body.storage !== undefined && body.storage !== existing.recipe.storage)
|
|
245
|
+
conflict("storage");
|
|
246
|
+
checkGuard(scope, body.ownerUserId, existing.ownerUserId);
|
|
247
|
+
const recipe = resolve(existing.kind, recipeRequest(body, existing));
|
|
248
|
+
if (body.files !== undefined)
|
|
249
|
+
await checkFiles(t, recipe.files);
|
|
250
|
+
const patch = { lastUsedAt: now };
|
|
251
|
+
if (body.labels !== undefined && canonical(body.labels) !== canonical(existing.labels))
|
|
252
|
+
patch.labels = body.labels;
|
|
253
|
+
if (body.ownerUserId !== undefined && (body.ownerUserId ?? undefined) !== existing.ownerUserId)
|
|
254
|
+
patch.ownerUserId = body.ownerUserId;
|
|
255
|
+
if (canonical(recipe) !== canonical(existing.recipe)) {
|
|
256
|
+
const version = existing.version + 1;
|
|
257
|
+
patch.recipe = recipe;
|
|
258
|
+
patch.version = version;
|
|
259
|
+
// What waits for the pod to start from stopped; setup and files run only on a new disk.
|
|
260
|
+
// The harness is what a pod runs (R4.4): it changes with the pod, like its image.
|
|
261
|
+
const compute = (r) => canonical({
|
|
262
|
+
image: r.image ?? null,
|
|
263
|
+
resources: r.resources ?? null,
|
|
264
|
+
setup: r.setup ?? null,
|
|
265
|
+
files: r.files ?? null,
|
|
266
|
+
harness: r.harness,
|
|
267
|
+
});
|
|
268
|
+
if (existing.kind !== "pod" || compute(recipe) === compute(existing.applied)) {
|
|
269
|
+
// Nothing waits for a wake: apply all of it.
|
|
270
|
+
patch.applied = recipe;
|
|
271
|
+
patch.appliedVersion = version;
|
|
272
|
+
}
|
|
273
|
+
else
|
|
274
|
+
// Network and lifecycle apply at once; image, resources and harness at the next wake.
|
|
275
|
+
patch.applied = {
|
|
276
|
+
...existing.applied,
|
|
277
|
+
...(recipe.network === undefined ? {} : { network: recipe.network }),
|
|
278
|
+
lifecycle: recipe.lifecycle,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
await t.updateEnvironment(id, patch, now);
|
|
282
|
+
if (patch.recipe !== undefined && existing.pod?.desired === "running")
|
|
283
|
+
// A new `lifecycle.idle` applies to the running pod's idle timer.
|
|
284
|
+
await t.signalSandbox(id, {
|
|
285
|
+
kind: "arm",
|
|
286
|
+
timer: "idle",
|
|
287
|
+
at: Date.parse(existing.pod.lastActiveAt ?? existing.createdAt) +
|
|
288
|
+
podLifecycleConfig(await readSandboxConfig(t), patch.recipe).idleMs,
|
|
289
|
+
});
|
|
290
|
+
return viewOf(ctx, t, (await t.environment(id)) ?? existing, subjectOf(scope));
|
|
291
|
+
};
|
|
292
|
+
const existing = await t.environment(id, { lock: true });
|
|
293
|
+
deleting(existing);
|
|
294
|
+
if (existing)
|
|
295
|
+
return found(existing);
|
|
296
|
+
checkGuard(scope, body.ownerUserId);
|
|
297
|
+
const kind = body.kind ?? "virtual";
|
|
298
|
+
const recipe = resolve(kind, recipeRequest(body));
|
|
299
|
+
await checkFiles(t, recipe.files);
|
|
300
|
+
const seed = body.from === undefined ? undefined : await forkSource(t, scope, kind, body.from);
|
|
301
|
+
const row = {
|
|
302
|
+
id,
|
|
303
|
+
kind,
|
|
304
|
+
labels: body.labels ?? {},
|
|
305
|
+
recipe,
|
|
306
|
+
applied: recipe,
|
|
307
|
+
version: 1,
|
|
308
|
+
appliedVersion: 1,
|
|
309
|
+
...(body.ownerUserId ? { ownerUserId: body.ownerUserId } : {}),
|
|
310
|
+
lastUsedAt: now,
|
|
311
|
+
createdAt: now,
|
|
312
|
+
updatedAt: now,
|
|
313
|
+
...(seed
|
|
314
|
+
? {
|
|
315
|
+
diskSeed: seed,
|
|
316
|
+
diskOp: {
|
|
317
|
+
kind: "seed",
|
|
318
|
+
version: seed.version,
|
|
319
|
+
startedAt: now,
|
|
320
|
+
until: new Date(Date.now() + SEED_LEASE_MS).toISOString(),
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
: {}),
|
|
324
|
+
...(kind === "pod"
|
|
325
|
+
? {
|
|
326
|
+
pod: {
|
|
327
|
+
k8sName: podName(ctx.config.tenantId, id, 0),
|
|
328
|
+
volumeGen: 0,
|
|
329
|
+
desired: "running",
|
|
330
|
+
observed: "creating",
|
|
331
|
+
hostEpoch: 0,
|
|
332
|
+
rev: 1,
|
|
333
|
+
startedAt: now,
|
|
334
|
+
lastActiveAt: now,
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
: {}),
|
|
338
|
+
};
|
|
339
|
+
const created = await t.createEnvironment(row, config.limits.environments);
|
|
340
|
+
if (created === "limit")
|
|
341
|
+
fail(409, `This Tenant allows at most ${config.limits.environments} Environments.`, {
|
|
342
|
+
code: "limit_exceeded",
|
|
343
|
+
details: { limit: "environments", max: config.limits.environments },
|
|
344
|
+
});
|
|
345
|
+
if (created === "exists") {
|
|
346
|
+
// Created by another request since the read above: answer as for an existing one.
|
|
347
|
+
const again = (await t.environment(id, { lock: true })) ?? notFound();
|
|
348
|
+
deleting(again);
|
|
349
|
+
return found(again);
|
|
350
|
+
}
|
|
351
|
+
await ctx.vault.createEnvironmentVault(t, id, actorOf(scope));
|
|
352
|
+
await t.environmentEvent(id, "environment.created", {
|
|
353
|
+
kind,
|
|
354
|
+
labels: row.labels,
|
|
355
|
+
...(seed ? { from: { environment: body.from.environment, version: seed.version } } : {}),
|
|
356
|
+
});
|
|
357
|
+
if (row.pod) {
|
|
358
|
+
// Created at once; idle from now if no turn comes.
|
|
359
|
+
await reconcileAfter(t, id);
|
|
360
|
+
const idleAt = Date.parse(now) + podLifecycleConfig(await readSandboxConfig(t), recipe).idleMs;
|
|
361
|
+
await t.signalSandbox(id, { kind: "arm", timer: "idle", at: idleAt });
|
|
362
|
+
}
|
|
363
|
+
return viewOf(ctx, t, row, subjectOf(scope));
|
|
364
|
+
});
|
|
365
|
+
// A virtual fork's workspace is written at once (a pod's once its engine connects).
|
|
366
|
+
if (body.from !== undefined && view.kind === "virtual") {
|
|
367
|
+
await runPendingSeed(ctx, id);
|
|
368
|
+
return getEnvironment(ctx, id, scope);
|
|
369
|
+
}
|
|
370
|
+
return view;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The disk version a create's `from` names (R2d step 3): the caller's grants must reach the
|
|
374
|
+
* source, a caller acting for a person must be its guard's owner, and kind `none` has no disk.
|
|
375
|
+
*/
|
|
376
|
+
async function forkSource(t, scope, kind, from) {
|
|
377
|
+
if (kind === "none")
|
|
378
|
+
fail(400, "from is for an Environment with a disk: kind none has none.");
|
|
379
|
+
reachable(environmentGrantsOf(scope), from.environment);
|
|
380
|
+
const source = (await liveEnvironment(t, from.environment)) ?? notFound();
|
|
381
|
+
const subject = subjectOf(scope);
|
|
382
|
+
if (subject !== undefined && source.ownerUserId !== undefined && source.ownerUserId !== subject)
|
|
383
|
+
notFound();
|
|
384
|
+
return forkSeedOf(t, from);
|
|
385
|
+
}
|
|
386
|
+
/** `GET /v1/environments/{id}`. */
|
|
387
|
+
export async function getEnvironment(ctx, id, scope) {
|
|
388
|
+
reachable(environmentGrantsOf(scope), id);
|
|
389
|
+
return ctx.store.tx(async (t) => {
|
|
390
|
+
const environment = (await liveEnvironment(t, id)) ?? notFound();
|
|
391
|
+
return viewOf(ctx, t, environment, subjectOf(scope));
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
/** `GET /v1/environments?label=k=v`: every Environment with all the labels, that the caller reaches. */
|
|
395
|
+
export async function listEnvironments(ctx, labels, scope) {
|
|
396
|
+
const grants = environmentGrantsOf(scope);
|
|
397
|
+
return ctx.store.tx(async (t) => {
|
|
398
|
+
const found = await t.listEnvironments({ labels });
|
|
399
|
+
const views = [];
|
|
400
|
+
for (const environment of found)
|
|
401
|
+
if (live(environment) && (grants === undefined || environmentGranted(grants, environment.id)))
|
|
402
|
+
views.push(await viewOf(ctx, t, environment, subjectOf(scope)));
|
|
403
|
+
return { environments: views };
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Asks pod Environment `id` to run when it is stopped or failed (a disk copy needs its engine),
|
|
408
|
+
* with its recipe applied, as a turn start would.
|
|
409
|
+
*/
|
|
410
|
+
export async function wakePod(ctx, id) {
|
|
411
|
+
if (!ctx.pods)
|
|
412
|
+
fail(409, `Environment ${id} is a pod Environment, and this Runtime has no sandbox pods.`, { code: "environment_unavailable" });
|
|
413
|
+
await ctx.store.tx(async (t) => {
|
|
414
|
+
const environment = await liveEnvironment(t, id, { lock: true });
|
|
415
|
+
const pod = environment?.pod;
|
|
416
|
+
if (!environment || !pod)
|
|
417
|
+
return;
|
|
418
|
+
if (pod.observed === "lost")
|
|
419
|
+
fail(409, `Environment ${id} was lost (${pod.reason ?? "its volume is gone"}). Reset or restore it.`, {
|
|
420
|
+
code: "environment_lost",
|
|
421
|
+
});
|
|
422
|
+
const now = new Date().toISOString();
|
|
423
|
+
if (pod.desired !== "running" || pod.observed === "failed") {
|
|
424
|
+
await wantRunning(t, environment, now);
|
|
425
|
+
await reconcileAfter(t, id);
|
|
426
|
+
}
|
|
427
|
+
else
|
|
428
|
+
await t.updateEnvironmentPod(id, { lastActiveAt: now }, now);
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
/** Whether a disk lease (R2d step 3) holds `environment` now. */
|
|
432
|
+
export function diskHeld(environment, now = Date.now()) {
|
|
433
|
+
return environment.diskOp !== undefined && Date.parse(environment.diskOp.until) > now;
|
|
434
|
+
}
|
|
435
|
+
/** Refused while a snapshot or restore copies the disk (a pending seed does not count). */
|
|
436
|
+
export function refuseDiskCopy(environment) {
|
|
437
|
+
if (diskHeld(environment) && environment.diskOp.kind !== "seed")
|
|
438
|
+
fail(409, `Environment ${environment.id}'s disk is being copied (${environment.diskOp.kind}). Try again shortly.`, {
|
|
439
|
+
code: "environment_busy",
|
|
440
|
+
details: { retryAfterSeconds: 5 },
|
|
441
|
+
}, { "retry-after": "5" });
|
|
442
|
+
}
|
|
443
|
+
/** Refused while a session that names Environment `id` has a turn running. */
|
|
444
|
+
async function refuseBusy(t, id) {
|
|
445
|
+
const busy = (await t.sessionsOnEnvironment(id)).some((session) => session.activeTurnId !== null);
|
|
446
|
+
if (busy)
|
|
447
|
+
fail(409, `Environment ${id} has a turn running. Cancel it, or wait for it to end.`, {
|
|
448
|
+
code: "environment_busy",
|
|
449
|
+
details: { retryAfterSeconds: 5 },
|
|
450
|
+
}, { "retry-after": "5" });
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Deletes `environment` (locked, live) in `t`: its vault and credentials at once, its workspace
|
|
454
|
+
* after the commit (the caller's), and the row, or for a pod, the row once its Sandbox is gone.
|
|
455
|
+
*/
|
|
456
|
+
async function deleteIn(ctx, t, environment, actor, reason) {
|
|
457
|
+
const id = environment.id;
|
|
458
|
+
await t.environmentEvent(id, "environment.deleted", { reason });
|
|
459
|
+
await ctx.vault.deleteEnvironmentVault(t, id, actor);
|
|
460
|
+
// Its disk versions go with it; their manifests' bytes are the caller's, after the commit.
|
|
461
|
+
const keys = await deleteDiskArtifact(ctx, t, id);
|
|
462
|
+
if (environment.pod) {
|
|
463
|
+
const epoch = environment.pod.hostEpoch + 1;
|
|
464
|
+
await t.updateEnvironmentPod(id, { desired: "deleted", observed: "deleting", rev: environment.pod.rev + 1, hostEpoch: epoch }, new Date().toISOString());
|
|
465
|
+
await reconcileAfter(t, id);
|
|
466
|
+
await revokeHost(t, id, epoch);
|
|
467
|
+
}
|
|
468
|
+
else
|
|
469
|
+
await t.deleteEnvironment(id);
|
|
470
|
+
return keys;
|
|
471
|
+
}
|
|
472
|
+
/** Deletes the Object store keys a deletion returned, after its commit. */
|
|
473
|
+
async function deleteKeys(ctx, keys) {
|
|
474
|
+
for (const key of keys)
|
|
475
|
+
await ctx.blobs.delete(key).catch(() => undefined);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* `DELETE /v1/environments/{id}`: deletes the Environment, its vault and its workspace. Refused
|
|
479
|
+
* while a session that names it has an active turn. Sessions keep its id: their next turn is
|
|
480
|
+
* refused (`environment_unavailable`) unless an Environment with this id exists again.
|
|
481
|
+
*/
|
|
482
|
+
export async function deleteEnvironment(ctx, id, scope) {
|
|
483
|
+
reachable(environmentGrantsOf(scope), id);
|
|
484
|
+
const deleted = await ctx.store.tx(async (t) => {
|
|
485
|
+
const environment = await liveEnvironment(t, id, { lock: true });
|
|
486
|
+
if (!environment)
|
|
487
|
+
return undefined;
|
|
488
|
+
await refuseBusy(t, id);
|
|
489
|
+
refuseDiskCopy(environment);
|
|
490
|
+
return deleteIn(ctx, t, environment, actorOf(scope), "delete");
|
|
491
|
+
});
|
|
492
|
+
// After the commit: a turn that starts now finds no Environment and is refused.
|
|
493
|
+
if (deleted) {
|
|
494
|
+
await deleteKeys(ctx, deleted);
|
|
495
|
+
await ctx.sandbox.removeSandbox(id);
|
|
496
|
+
}
|
|
497
|
+
return { id, deleted: deleted !== undefined };
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Deletes every Environment unused for its `lifecycle.deleteAfter` (Q13): no turn, recipe
|
|
501
|
+
* change, secret write, stop or reset since. One with a turn running waits for the next sweep.
|
|
502
|
+
* Run by the Tenant sweep.
|
|
503
|
+
*/
|
|
504
|
+
export async function sweepUnusedEnvironments(ctx, now = Date.now()) {
|
|
505
|
+
const due = (await ctx.store.tx((t) => t.listEnvironments())).filter((environment) => {
|
|
506
|
+
const after = environment.recipe.lifecycle.deleteAfter;
|
|
507
|
+
const ms = after === undefined ? undefined : parseSandboxDuration(after);
|
|
508
|
+
return live(environment) && ms !== undefined && Date.parse(environment.lastUsedAt) + ms <= now;
|
|
509
|
+
});
|
|
510
|
+
for (const candidate of due) {
|
|
511
|
+
try {
|
|
512
|
+
const deleted = await ctx.store.tx(async (t) => {
|
|
513
|
+
const environment = await liveEnvironment(t, candidate.id, { lock: true });
|
|
514
|
+
const after = environment?.recipe.lifecycle.deleteAfter;
|
|
515
|
+
const ms = after === undefined ? undefined : parseSandboxDuration(after);
|
|
516
|
+
if (!environment || ms === undefined || Date.parse(environment.lastUsedAt) + ms > now)
|
|
517
|
+
return undefined;
|
|
518
|
+
const busy = (await t.sessionsOnEnvironment(environment.id)).some((session) => session.activeTurnId !== null);
|
|
519
|
+
if (busy || diskHeld(environment))
|
|
520
|
+
return undefined;
|
|
521
|
+
return deleteIn(ctx, t, environment, "sweep", "deleteAfter");
|
|
522
|
+
});
|
|
523
|
+
if (deleted) {
|
|
524
|
+
await deleteKeys(ctx, deleted);
|
|
525
|
+
await ctx.sandbox.removeSandbox(candidate.id);
|
|
526
|
+
ctx.config.logger.info("environment deleted after lifecycle.deleteAfter", {
|
|
527
|
+
environmentId: candidate.id,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
ctx.config.logger.warn("environment deleteAfter failed", {
|
|
533
|
+
environmentId: candidate.id,
|
|
534
|
+
message: error instanceof Error ? error.message : String(error),
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/** The pod Environment `id` under the row lock, or a 404 or 400. */
|
|
540
|
+
async function podEnvironment(t, id) {
|
|
541
|
+
const environment = (await liveEnvironment(t, id, { lock: true })) ?? notFound();
|
|
542
|
+
if (!environment.pod)
|
|
543
|
+
return fail(400, `Environment ${id} is of kind ${environment.kind}: only a pod Environment stops and resets`);
|
|
544
|
+
return environment;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* `POST /v1/environments/{id}/stop`: suspends a pod Environment. Its volume is kept, and the next
|
|
548
|
+
* turn of a session that names it starts it again. Refused during a turn.
|
|
549
|
+
*/
|
|
550
|
+
export async function stopEnvironment(ctx, id, scope) {
|
|
551
|
+
reachable(environmentGrantsOf(scope), id);
|
|
552
|
+
return ctx.store.tx(async (t) => {
|
|
553
|
+
const environment = await podEnvironment(t, id);
|
|
554
|
+
await refuseBusy(t, id);
|
|
555
|
+
const pod = environment.pod;
|
|
556
|
+
const now = new Date().toISOString();
|
|
557
|
+
if (pod.desired === "running" && pod.observed !== "lost") {
|
|
558
|
+
const epoch = pod.hostEpoch + 1;
|
|
559
|
+
await t.updateEnvironmentPod(id, { desired: "suspended", rev: pod.rev + 1, hostEpoch: epoch }, now);
|
|
560
|
+
await t.environmentEvent(id, "environment.suspended", { reason: "stop" });
|
|
561
|
+
await reconcileAfter(t, id);
|
|
562
|
+
await revokeHost(t, id, epoch);
|
|
563
|
+
}
|
|
564
|
+
await t.updateEnvironment(id, { lastUsedAt: now }, now);
|
|
565
|
+
return viewOf(ctx, t, (await t.environment(id)) ?? environment, subjectOf(scope));
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* `POST /v1/environments/{id}/reset`: a new pod on a new, empty volume (the next volume
|
|
570
|
+
* generation), with a new join token and the recipe applied; the old Sandbox and volume are
|
|
571
|
+
* deleted. Leaves `lost` and `failed`. Refused during a turn, and while an earlier reset's
|
|
572
|
+
* Sandbox is still being deleted.
|
|
573
|
+
*/
|
|
574
|
+
export async function resetEnvironment(ctx, id, scope) {
|
|
575
|
+
reachable(environmentGrantsOf(scope), id);
|
|
576
|
+
// A missing Environment is a 404, and one of another kind a 400, with or without sandbox pods.
|
|
577
|
+
await ctx.store.tx((t) => podEnvironment(t, id));
|
|
578
|
+
await requirePods(ctx.pods, "Resetting a pod Environment");
|
|
579
|
+
return ctx.store.tx(async (t) => {
|
|
580
|
+
const environment = await podEnvironment(t, id);
|
|
581
|
+
await refuseBusy(t, id);
|
|
582
|
+
refuseDiskCopy(environment);
|
|
583
|
+
const pod = environment.pod;
|
|
584
|
+
if (pod.retiring !== undefined)
|
|
585
|
+
fail(409, `Environment ${id} is still deleting its previous volume. Try again shortly.`, {
|
|
586
|
+
code: "environment_busy",
|
|
587
|
+
details: { retryAfterSeconds: 5 },
|
|
588
|
+
}, { "retry-after": "5" });
|
|
589
|
+
await newVolume(ctx, t, environment);
|
|
590
|
+
return viewOf(ctx, t, (await t.environment(id)) ?? environment, subjectOf(scope));
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Gives pod Environment `environment` (locked) a new pod on a new, empty volume, the next volume
|
|
595
|
+
* generation, with its recipe applied: the old Sandbox and volume are retired. With `seed`, the
|
|
596
|
+
* new volume starts from that disk version before any turn (R2d step 3): a restore, or the
|
|
597
|
+
* recovery of a lost volume.
|
|
598
|
+
*/
|
|
599
|
+
export async function newVolume(ctx, t, environment, options = {}) {
|
|
600
|
+
const id = environment.id;
|
|
601
|
+
const pod = environment.pod;
|
|
602
|
+
const now = new Date().toISOString();
|
|
603
|
+
const volumeGen = pod.volumeGen + 1;
|
|
604
|
+
const epoch = pod.hostEpoch + 1;
|
|
605
|
+
await t.updateEnvironment(id, {
|
|
606
|
+
applied: environment.recipe,
|
|
607
|
+
appliedVersion: environment.version,
|
|
608
|
+
lastUsedAt: now,
|
|
609
|
+
...(!options.seed
|
|
610
|
+
? { diskSeed: null, diskOp: null }
|
|
611
|
+
: {
|
|
612
|
+
diskSeed: options.seed,
|
|
613
|
+
diskOp: {
|
|
614
|
+
kind: "seed",
|
|
615
|
+
version: options.seed.version,
|
|
616
|
+
startedAt: now,
|
|
617
|
+
until: new Date(Date.now() + SEED_LEASE_MS).toISOString(),
|
|
618
|
+
},
|
|
619
|
+
}),
|
|
620
|
+
}, now);
|
|
621
|
+
await t.updateEnvironmentPod(id, {
|
|
622
|
+
k8sName: podName(ctx.config.tenantId, id, volumeGen),
|
|
623
|
+
volumeGen,
|
|
624
|
+
retiring: pod.k8sName,
|
|
625
|
+
desired: "running",
|
|
626
|
+
observed: "creating",
|
|
627
|
+
rev: pod.rev + 1,
|
|
628
|
+
hostEpoch: epoch,
|
|
629
|
+
joinTokenHash: null,
|
|
630
|
+
podUid: null,
|
|
631
|
+
volumeId: null,
|
|
632
|
+
startedAt: now,
|
|
633
|
+
lastActiveAt: now,
|
|
634
|
+
reason: null,
|
|
635
|
+
}, now);
|
|
636
|
+
await t.environmentEvent(id, "environment.reset", {
|
|
637
|
+
volumeGeneration: volumeGen,
|
|
638
|
+
...(options.reason ? { reason: options.reason } : {}),
|
|
639
|
+
...(options.seed ? { from: { version: options.seed.version } } : {}),
|
|
640
|
+
});
|
|
641
|
+
await reconcileAfter(t, id);
|
|
642
|
+
await revokeHost(t, id, epoch);
|
|
643
|
+
return volumeGen;
|
|
644
|
+
}
|
|
645
|
+
/** How long a pending seed holds the disk before another process may take it over. */
|
|
646
|
+
export const SEED_LEASE_MS = 10 * 60_000;
|
|
647
|
+
/** `GET /v1/environments/{id}/events`: the Environment's lifecycle stream. */
|
|
648
|
+
export async function environmentEventsOf(ctx, id, scope, fromSeq) {
|
|
649
|
+
reachable(environmentGrantsOf(scope), id);
|
|
650
|
+
return ctx.store.tx(async (t) => ({
|
|
651
|
+
events: await t.environmentEvents(id, fromSeq === undefined ? {} : { fromSeq }),
|
|
652
|
+
}));
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* The owner guard (Q7): refuses a session of `ownerUserId` on an Environment guarded for
|
|
656
|
+
* another owner. A caller acting for a person (`opaque`) gets the 404 of a missing one.
|
|
657
|
+
*/
|
|
658
|
+
function checkOwner(environment, ownerUserId, opaque) {
|
|
659
|
+
if (environment.ownerUserId === undefined || environment.ownerUserId === ownerUserId)
|
|
660
|
+
return;
|
|
661
|
+
if (opaque)
|
|
662
|
+
notFound();
|
|
663
|
+
fail(403, `Environment ${environment.id} is for another owner's sessions (its ownerUserId guard).`, { code: "environment_owner_mismatch" });
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Opens a new session on Environment `id` (`PutSessionRequest.environment`): it must exist, be
|
|
667
|
+
* reachable with `grants` and pass its owner guard. Runs in the session's transaction, after
|
|
668
|
+
* its session row lock; holds the Environment's row lock.
|
|
669
|
+
*/
|
|
670
|
+
export async function openEnvironment(t, id, options) {
|
|
671
|
+
reachable(options.grants, id);
|
|
672
|
+
const environment = (await liveEnvironment(t, id, { lock: true })) ?? notFound();
|
|
673
|
+
checkOwner(environment, options.ownerUserId, options.opaque);
|
|
674
|
+
return environment;
|
|
675
|
+
}
|
|
676
|
+
/** Records the attachment on the session's log and on the Environment's stream. */
|
|
677
|
+
export async function recordAttachment(t, sessionId, environmentId) {
|
|
678
|
+
await t.event(sessionId, null, "environment.attached", { environmentId });
|
|
679
|
+
await t.environmentEvent(environmentId, "environment.attached", { sessionId });
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Checks a turn start on a session that names an Environment, in the command's transaction
|
|
683
|
+
* after the session row lock: the Environment still exists, a token caller's grants reach it,
|
|
684
|
+
* its owner guard admits the session's owner, and, with a sandbox, no other session's turn
|
|
685
|
+
* holds it. Holds the Environment row's lock until the transaction ends, so two turn starts on
|
|
686
|
+
* one Environment are decided one after the other.
|
|
687
|
+
*
|
|
688
|
+
* A pod Environment must also be usable: not lost (`environment_lost`), on a Runtime with
|
|
689
|
+
* sandbox pods (`environment_unavailable`). A stopped or failed pod is asked to run again, with
|
|
690
|
+
* its recipe applied, after the commit.
|
|
691
|
+
*/
|
|
692
|
+
export async function checkEnvironmentTurn(t, session, scope, ctx) {
|
|
693
|
+
const id = session.environmentId;
|
|
694
|
+
if (id === undefined)
|
|
695
|
+
return;
|
|
696
|
+
const grants = environmentGrantsOf(scope);
|
|
697
|
+
if (grants !== undefined && !environmentGranted(grants, id))
|
|
698
|
+
fail(403, `This token does not reach Environment ${id}.`, { code: "environment_not_granted" });
|
|
699
|
+
const environment = await liveEnvironment(t, id, { lock: true });
|
|
700
|
+
if (!environment)
|
|
701
|
+
return fail(409, `Environment ${id} was deleted. Create it again to continue this session.`, {
|
|
702
|
+
code: "environment_unavailable",
|
|
703
|
+
});
|
|
704
|
+
if (environment.ownerUserId !== undefined && environment.ownerUserId !== session.ownerUserId)
|
|
705
|
+
fail(403, `Environment ${id} is for another owner's sessions (its ownerUserId guard).`, {
|
|
706
|
+
code: "environment_owner_mismatch",
|
|
707
|
+
});
|
|
708
|
+
// The harness the turn would run on (R4.1, Q9): a pod about to wake runs its recipe's. A
|
|
709
|
+
// session never moves to another harness: its state is that harness's.
|
|
710
|
+
const waking = environment.pod !== undefined && (environment.pod.desired !== "running" || environment.pod.observed === "failed");
|
|
711
|
+
const runs = waking ? environment.recipe.harness : environment.applied.harness;
|
|
712
|
+
if (!isWorkflowManifest(session.manifest) && runs !== harnessOf(session))
|
|
713
|
+
fail(409, `Environment ${id} now runs the ${runs} harness, and this session runs ${harnessOf(session)}: open a new session to run on it.`, {
|
|
714
|
+
code: "harness_mismatch",
|
|
715
|
+
details: { harness: harnessOf(session), environmentHarness: runs },
|
|
716
|
+
});
|
|
717
|
+
if (environment.kind === "none")
|
|
718
|
+
return;
|
|
719
|
+
// A snapshot or restore copies the disk between turns (R2d step 3); a seed's turn waits.
|
|
720
|
+
refuseDiskCopy(environment);
|
|
721
|
+
// A flow tree's sessions nest their turns in its root's: one holder.
|
|
722
|
+
const tree = session.flowRootId ?? session.id;
|
|
723
|
+
const holder = (await t.sessionsOnEnvironment(id)).find((other) => other.id !== session.id && other.activeTurnId !== null && (other.flowRootId ?? other.id) !== tree);
|
|
724
|
+
if (holder)
|
|
725
|
+
fail(409, `Environment ${id} is running another session's turn. Turns are serial per Environment.`, {
|
|
726
|
+
code: "environment_busy",
|
|
727
|
+
details: { retryAfterSeconds: 5 },
|
|
728
|
+
}, { "retry-after": "5" });
|
|
729
|
+
const pod = environment.pod;
|
|
730
|
+
if (!pod)
|
|
731
|
+
return;
|
|
732
|
+
if (!ctx?.pods)
|
|
733
|
+
fail(409, `Environment ${id} is a pod Environment, and this Runtime has no sandbox pods: run \`nylorun sandbox enable --context <name>\`.`, {
|
|
734
|
+
code: "environment_unavailable",
|
|
735
|
+
});
|
|
736
|
+
if (pod.observed === "lost")
|
|
737
|
+
fail(409, `Environment ${id} was lost (${pod.reason ?? "its volume is gone"}). Reset it to continue.`, {
|
|
738
|
+
code: "environment_lost",
|
|
739
|
+
});
|
|
740
|
+
const now = new Date().toISOString();
|
|
741
|
+
if (pod.desired !== "running" || pod.observed === "failed") {
|
|
742
|
+
await wantRunning(t, environment, now);
|
|
743
|
+
await reconcileAfter(t, id);
|
|
744
|
+
}
|
|
745
|
+
else
|
|
746
|
+
await t.updateEnvironmentPod(id, { lastActiveAt: now }, now);
|
|
747
|
+
}
|
|
748
|
+
/** A sessions reset detaches every session from its Environment: each one's stream says so. */
|
|
749
|
+
export async function detachAllSessions(t) {
|
|
750
|
+
for (const environment of await t.listEnvironments()) {
|
|
751
|
+
const sessions = await t.sessionsOnEnvironment(environment.id);
|
|
752
|
+
if (sessions.length === 0)
|
|
753
|
+
continue;
|
|
754
|
+
await t.environment(environment.id, { lock: true });
|
|
755
|
+
for (const session of sessions)
|
|
756
|
+
await t.environmentEvent(environment.id, "environment.detached", {
|
|
757
|
+
sessionId: session.id,
|
|
758
|
+
reason: "reset",
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Marks the end of a turn's segment on Environment `id`: it was used now (`deleteAfter` counts
|
|
764
|
+
* from here), and for a running pod idle counts from now and the idle timer is armed (D34).
|
|
765
|
+
* Best effort.
|
|
766
|
+
*/
|
|
767
|
+
export async function touchEnvironment(ctx, id) {
|
|
768
|
+
try {
|
|
769
|
+
await ctx.store.tx(async (t) => {
|
|
770
|
+
const environment = await liveEnvironment(t, id, { lock: true });
|
|
771
|
+
if (!environment)
|
|
772
|
+
return;
|
|
773
|
+
const now = new Date();
|
|
774
|
+
const iso = now.toISOString();
|
|
775
|
+
await t.updateEnvironment(id, { lastUsedAt: iso }, iso);
|
|
776
|
+
if (!environment.pod || environment.pod.desired !== "running")
|
|
777
|
+
return;
|
|
778
|
+
await t.updateEnvironmentPod(id, { lastActiveAt: iso }, iso);
|
|
779
|
+
const idleMs = podLifecycleConfig(await readSandboxConfig(t), environment.recipe).idleMs;
|
|
780
|
+
await t.signalSandbox(id, { kind: "arm", timer: "idle", at: now.getTime() + idleMs });
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
catch (error) {
|
|
784
|
+
ctx.config.logger.warn("environment use not recorded", {
|
|
785
|
+
environmentId: id,
|
|
786
|
+
message: error instanceof Error ? error.message : String(error),
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Re-arms the idle timer of every running pod after the Tenant's settings change, so a shorter
|
|
792
|
+
* `limits.idle` applies to pods already idle (D36: the settings are read whenever a timer is
|
|
793
|
+
* set). Best effort.
|
|
794
|
+
*/
|
|
795
|
+
export async function rearmPodTimers(ctx) {
|
|
796
|
+
if (!ctx.pods)
|
|
797
|
+
return;
|
|
798
|
+
try {
|
|
799
|
+
const { rows, config } = await ctx.store.tx(async (t) => ({
|
|
800
|
+
rows: await t.listEnvironments(),
|
|
801
|
+
config: await readSandboxConfig(t),
|
|
802
|
+
}));
|
|
803
|
+
for (const row of rows)
|
|
804
|
+
if (row.pod?.desired === "running")
|
|
805
|
+
await ctx.sandboxSignal(row.id, {
|
|
806
|
+
kind: "arm",
|
|
807
|
+
timer: "idle",
|
|
808
|
+
at: Date.parse(row.pod.lastActiveAt ?? row.createdAt) + podLifecycleConfig(config, row.recipe).idleMs,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
ctx.config.logger.warn("pod idle timers not re-armed", {
|
|
813
|
+
message: error instanceof Error ? error.message : String(error),
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
/** Session lookups for the sandbox route handlers (`core/sandbox-routes.ts`). */
|
|
818
|
+
export function sandboxRouteDeps(ctx) {
|
|
819
|
+
return {
|
|
820
|
+
sandbox: ctx.sandbox,
|
|
821
|
+
session: (id) => ctx.store.tx((t) => sessionOf(t, id)),
|
|
822
|
+
};
|
|
823
|
+
}
|