@nylorun/runtime 0.27.0-beta → 0.29.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 +67 -0
- package/README.md +1 -1
- package/dist/api/a2a/card.d.ts +4 -3
- package/dist/api/a2a/card.js +3 -3
- package/dist/api/a2a/routes.js +18 -7
- package/dist/api/ag-ui/routes.js +4 -7
- package/dist/api/components.d.ts +5854 -2718
- package/dist/api/components.js +39 -30
- package/dist/api/http/app.js +9 -4
- package/dist/api/http/body.d.ts +2 -0
- package/dist/api/http/body.js +20 -0
- package/dist/api/http/define.d.ts +4 -0
- package/dist/api/http/define.js +20 -9
- package/dist/api/http/routes/agent-manifests.d.ts +10 -0
- package/dist/api/http/routes/agent-manifests.js +135 -0
- package/dist/api/http/routes/agents.d.ts +11 -0
- package/dist/api/http/routes/agents.js +418 -0
- package/dist/api/http/routes/artifacts.js +2 -2
- package/dist/api/http/routes/coverage.d.ts +4 -4
- package/dist/api/http/routes/coverage.js +12 -6
- package/dist/api/http/routes/files.js +1 -1
- package/dist/api/http/routes/mcp.js +2 -2
- package/dist/api/http/routes/me.d.ts +1 -1
- package/dist/api/http/routes/me.js +1 -2
- package/dist/api/http/routes/memories.d.ts +9 -0
- package/dist/api/http/routes/memories.js +152 -0
- package/dist/api/http/routes/reads.d.ts +1 -137
- package/dist/api/http/routes/reads.js +31 -12
- package/dist/api/http/routes/sessions.js +21 -50
- package/dist/api/http/routes/tenant.js +13 -10
- package/dist/api/http/routes/triggers.d.ts +10 -0
- package/dist/api/http/routes/triggers.js +183 -0
- package/dist/api/openapi.js +69 -47
- package/dist/artifacts/disk.d.ts +10 -10
- package/dist/artifacts/disk.js +27 -27
- package/dist/artifacts/quota.d.ts +1 -1
- package/dist/artifacts/quota.js +2 -2
- package/dist/artifacts/service.js +2 -2
- package/dist/core/flow-host.d.ts +0 -1
- package/dist/gates/egress-credentials.js +1 -1
- package/dist/gates/egress.d.ts +5 -5
- package/dist/gates/egress.js +6 -6
- package/dist/gates/http-tool.js +2 -2
- package/dist/gates/meter.js +22 -5
- package/dist/gates/policy.d.ts +8 -3
- package/dist/gates/policy.js +7 -2
- package/dist/gates/tenant-vaults.d.ts +1 -1
- package/dist/harness/pod.d.ts +2 -0
- package/dist/harness/setup.js +17 -6
- package/dist/harness-api/record.js +11 -0
- package/dist/harness-api/server.js +4 -0
- package/dist/harness-api/start.d.ts +11 -7
- package/dist/harness-api/start.js +49 -5
- package/dist/harness-api/workspace.js +2 -2
- package/dist/host/config-for.d.ts +2 -0
- package/dist/host/config-for.js +1 -0
- package/dist/host/http.d.ts +2 -2
- package/dist/host/http.js +5 -2
- package/dist/host/main.js +1 -0
- package/dist/keys/keys.d.ts +15 -4
- package/dist/keys/keys.js +16 -0
- package/dist/keys/webhooks.d.ts +25 -0
- package/dist/keys/webhooks.js +62 -0
- package/dist/management-openapi.json +313 -133
- package/dist/mcp/connect.d.ts +2 -2
- package/dist/mcp/connect.js +3 -3
- package/dist/mcp/preview.js +7 -7
- package/dist/memory/service.d.ts +97 -0
- package/dist/memory/service.js +335 -0
- package/dist/memory/tool.d.ts +36 -0
- package/dist/memory/tool.js +84 -0
- package/dist/model/pi-model.js +7 -0
- package/dist/openapi.json +13120 -9034
- package/dist/reads/types.d.ts +3 -3
- package/dist/record/agent.d.ts +27 -0
- package/dist/record/agent.js +46 -0
- package/dist/record/index.d.ts +1 -1
- package/dist/record/index.js +1 -1
- package/dist/sandbox/join.js +5 -5
- package/dist/sandbox/placement.d.ts +1 -1
- package/dist/sandbox/placement.js +3 -3
- package/dist/sandbox/pods/lifecycle.d.ts +6 -6
- package/dist/sandbox/pods/lifecycle.js +6 -6
- package/dist/sandbox/pods/reconcile.js +10 -2
- package/dist/sandbox/resolve.d.ts +3 -3
- package/dist/sandbox/resolve.js +7 -7
- package/dist/sandbox/setup.d.ts +3 -1
- package/dist/sandbox/setup.js +14 -4
- package/dist/store/postgres/drizzle/0032_agents.sql +54 -0
- package/dist/store/postgres/drizzle/0033_agent_versions.sql +7 -0
- package/dist/store/postgres/drizzle/0034_protocol_17_session_queue.sql +13 -0
- package/dist/store/postgres/drizzle/0035_t4_triggers.sql +41 -0
- package/dist/store/postgres/drizzle/0036_t5_memories.sql +30 -0
- package/dist/store/postgres/drizzle/0037_model_usage_cost_split.sql +4 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +42 -0
- package/dist/store/postgres/reads.js +44 -87
- package/dist/store/postgres/record-writer.d.ts +4 -4
- package/dist/store/postgres/record-writer.js +9 -9
- package/dist/store/postgres/schema.d.ts +1408 -123
- package/dist/store/postgres/schema.js +194 -38
- package/dist/store/postgres/store.js +329 -79
- package/dist/store/postgres/tenant.js +1 -2
- package/dist/store/types.d.ts +155 -50
- package/dist/store/types.js +0 -1
- package/dist/tenant/advance.js +20 -8
- package/dist/tenant/agent-manifests.d.ts +72 -0
- package/dist/tenant/agent-manifests.js +359 -0
- package/dist/tenant/agent-versions.d.ts +126 -0
- package/dist/tenant/agent-versions.js +389 -0
- package/dist/tenant/agents.d.ts +230 -0
- package/dist/tenant/{environments.js → agents.js} +464 -320
- package/dist/tenant/auth.d.ts +1 -1
- package/dist/tenant/auth.js +3 -3
- package/dist/tenant/commands.d.ts +63 -2
- package/dist/tenant/commands.js +240 -83
- package/dist/tenant/context.d.ts +46 -19
- package/dist/tenant/context.js +6 -2
- package/dist/tenant/effects.js +7 -16
- package/dist/tenant/identity-file.d.ts +6 -6
- package/dist/tenant/identity-file.js +12 -10
- package/dist/tenant/issuers.js +3 -3
- package/dist/tenant/limits.d.ts +0 -3
- package/dist/tenant/limits.js +0 -3
- package/dist/tenant/memories.d.ts +38 -0
- package/dist/tenant/memories.js +119 -0
- package/dist/tenant/reset.js +2 -2
- package/dist/tenant/run-grants.d.ts +6 -0
- package/dist/tenant/run-grants.js +4 -0
- package/dist/tenant/runtime.d.ts +1 -1
- package/dist/tenant/runtime.js +8 -0
- package/dist/tenant/sessions.d.ts +28 -45
- package/dist/tenant/sessions.js +85 -208
- package/dist/tenant/status.js +6 -7
- package/dist/tenant/sweep.js +12 -3
- package/dist/tenant/transcript.d.ts +16 -16
- package/dist/tenant/triggers.d.ts +143 -0
- package/dist/tenant/triggers.js +715 -0
- package/dist/tenant/turn-timing.d.ts +11 -1
- package/dist/tenant/turn-timing.js +3 -1
- package/dist/tenant/types.d.ts +5 -0
- package/dist/tenant/worker.d.ts +2 -0
- package/dist/tenant/worker.js +5 -0
- package/dist/traces/map.d.ts +1 -1
- package/dist/traces/map.js +7 -1
- package/dist/tuning.d.ts +7 -0
- package/dist/tuning.js +19 -0
- package/dist/vault/service.d.ts +13 -8
- package/dist/vault/service.js +61 -17
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +13 -12
- package/dist/api/http/routes/environment-manifests.d.ts +0 -9
- package/dist/api/http/routes/environment-manifests.js +0 -118
- package/dist/api/http/routes/environments.d.ts +0 -11
- package/dist/api/http/routes/environments.js +0 -332
- package/dist/record/environment.d.ts +0 -27
- package/dist/record/environment.js +0 -46
- package/dist/tenant/environment-manifests.d.ts +0 -57
- package/dist/tenant/environment-manifests.js +0 -208
- package/dist/tenant/environments.d.ts +0 -172
|
@@ -1,49 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Agents (protocol 16; Environments from protocol 11, D55, D56): `PUT`, `GET` and `DELETE
|
|
3
|
+
* /v1/agents/{id}`, the list, stop, reset, fork and the lifecycle stream; sessions on one, and the
|
|
4
|
+
* checks every turn start makes on it.
|
|
5
5
|
*
|
|
6
|
-
* - **
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* - **
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
6
|
+
* - **The instance of a manifest.** An Agent is made from one version of a saved agent manifest
|
|
7
|
+
* (`manifest: "name"`, tracking, or `"name@n"`, pinned; `agent-manifests.ts`) and runs one
|
|
8
|
+
* version of it at a time (`resolvedVersion`). A `PUT` on an existing id may change its labels
|
|
9
|
+
* and owner guard, and moves it to the version its `manifest` names (track T3,
|
|
10
|
+
* `agent-versions.ts`; `?dryRun=true` answers the plan); a save moves every Agent that tracks
|
|
11
|
+
* the manifest. Its manifest's name, kind, harness and storage are fixed for its life: a fork
|
|
12
|
+
* makes a new Agent to change them. Its kind is its manifest's `runtime.kind` (`none` without a
|
|
13
|
+
* runtime section).
|
|
14
|
+
* - **No scope, no main session** (D70, D71). The Runtime decides nothing about who uses an
|
|
15
|
+
* Agent or which sessions it has: the app opens sessions on it with ids it picks, and deleting
|
|
16
|
+
* a session (a sessions reset) only detaches it.
|
|
17
|
+
* - **Its environment: one sandbox, one vault.** Kind `virtual` (a just-bash workspace in the
|
|
18
|
+
* harness container, keyed by the Agent id and created on the first tool call), `pod` (an
|
|
19
|
+
* agent-sandbox pod on the Tenant's cluster, Host feature `sandbox-pods`, created at once) or
|
|
20
|
+
* `none` (no compute). Each has one vault (`vault/service.ts`), created and deleted with it in
|
|
21
|
+
* one transaction. The store calls this part the Agent's environment (`Environment`, keyed by
|
|
22
|
+
* the Agent's id).
|
|
23
|
+
* - **State on read.** `state` is derived when it is read, the first rule that holds: `paused`
|
|
24
|
+
* (desired `off`, track T6), `failing` (its compute failed or was lost, or a session's last
|
|
25
|
+
* turn failed), `input_required` (a session waits on a person), `working` (a turn runs),
|
|
26
|
+
* `sleeping`; `attention` names the sessions behind each that holds (`stateOf`).
|
|
27
|
+
* - **Secret slots** (D64): when its manifest declares `secrets`, a secret write sends only the
|
|
22
28
|
* value, the binding comes from the slot, and a name it does not declare is
|
|
23
29
|
* `409 secret_not_in_manifest` (`secretRequestFor`).
|
|
24
|
-
* - **Access.** An application key reaches every
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* - **The owner guard.** When `
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* - **Parallel turns (D66).** Sessions on one
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* (`409 environment_busy`), and a turn waits while a seed copies a disk.
|
|
30
|
+
* - **Access.** An application key reaches every Agent, and so does an application key acting
|
|
31
|
+
* for a subject (`Nylorun-Subject`), which needs `agents:write` to change one. A token caller
|
|
32
|
+
* reaches only the ids its Agent grants match, and changes them only with `agents:write`; any
|
|
33
|
+
* other id is the 404 of a missing one.
|
|
34
|
+
* - **The owner guard.** When `owner` is set, only that owner's sessions may run on the Agent,
|
|
35
|
+
* checked when a session opens and at every turn start (`403 agent_owner_mismatch`; the opaque
|
|
36
|
+
* 404 to a caller acting for a person).
|
|
37
|
+
* - **Parallel turns (D66).** Sessions on one Agent run their turns at the same time and share
|
|
38
|
+
* its `/workspace`; one turn per session stays. Disk work (snapshot, restore, stop, reset)
|
|
39
|
+
* waits until no turn runs (`409 agent_busy`), and a turn waits while a seed copies a disk.
|
|
35
40
|
* - **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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* - **Lifecycle events** go to the
|
|
41
|
-
* `record/
|
|
41
|
+
* `POST .../reset` gives it a new pod on a new volume. A lost pod refuses turns (`agent_lost`)
|
|
42
|
+
* until a reset. `lifecycle.deleteAfter` deletes an Agent unused for that long (the Tenant
|
|
43
|
+
* sweep, `sweepUnusedAgents`). Deleting a pod Agent keeps its row (`desired: deleted`, invisible
|
|
44
|
+
* here) until its Sandbox is gone; its vault goes at once.
|
|
45
|
+
* - **Lifecycle events** go to the Agent's own stream in the record (`Tx.agentEvent`,
|
|
46
|
+
* `record/agent.ts`); the session's log records `agent.attached` too.
|
|
42
47
|
*/
|
|
43
48
|
import { randomUUID } from "node:crypto";
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
46
|
-
import { isWorkflowManifest, parseSandboxDuration } from "@nylorun/core/define";
|
|
49
|
+
import { agentGranted, parseAgentManifestRef, PutAgentSecretRequestSchema, PutAgentSecretValueRequestSchema, } from "@nylorun/core/contracts";
|
|
50
|
+
import { isWorkflowManifest, parseSandboxDuration, runtimeOf } from "@nylorun/core/define";
|
|
47
51
|
import { canonical } from "../store/canonical.js";
|
|
48
52
|
import { resolveRecipe } from "../sandbox/resolve.js";
|
|
49
53
|
import { sandboxWorkspaceKey } from "../sandbox/records.js";
|
|
@@ -53,15 +57,18 @@ import { podLifecycleConfig } from "../sandbox/pods/spec.js";
|
|
|
53
57
|
import { SETUP_FAILED } from "../sandbox/pods/lifecycle.js";
|
|
54
58
|
import { requirePods } from "../sandbox/placement.js";
|
|
55
59
|
import { deleteDiskArtifact, forkSeedOf, runPendingSeed, snapshotEnvironment } from "../artifacts/disk.js";
|
|
56
|
-
import { manifestChanges, manifestRecipeRequest } from "./
|
|
60
|
+
import { manifestChanges, manifestRecipeRequest } from "./agent-manifests.js";
|
|
61
|
+
import { idleMsOf, moveAgent, planMove, resumeMove, switchTo, waitingFor } from "./agent-versions.js";
|
|
62
|
+
import { slotHolds } from "../vault/service.js";
|
|
57
63
|
import { labelIssues, limitIssues, limitsInForce, manifestShapeIssues, readTenantLimits, secretRequestIssues, } from "./limits.js";
|
|
58
64
|
import { readRuntimeLimits } from "./runtime-limits.js";
|
|
65
|
+
import { armAgentTriggers, createAgentTriggers } from "./triggers.js";
|
|
59
66
|
import { harnessOf, sessionOf } from "./context.js";
|
|
60
67
|
import { fail, failRetryLater } from "./http.js";
|
|
61
68
|
import { hostTuning } from "../tuning.js";
|
|
62
|
-
/** The
|
|
63
|
-
export function
|
|
64
|
-
return scope.kind === "token"
|
|
69
|
+
/** The Agent grants that limit `scope`, or undefined when it reaches every Agent. */
|
|
70
|
+
export function agentGrantsOf(scope) {
|
|
71
|
+
return scope.kind === "token" && scope.agents !== "*" ? [...scope.agents] : undefined;
|
|
65
72
|
}
|
|
66
73
|
/** The subject a request acts for, when it acts for one. */
|
|
67
74
|
function subjectOf(scope) {
|
|
@@ -71,21 +78,21 @@ function subjectOf(scope) {
|
|
|
71
78
|
export function actorOf(scope) {
|
|
72
79
|
return subjectOf(scope) ?? "application";
|
|
73
80
|
}
|
|
74
|
-
const notFound = () => fail(404, "
|
|
75
|
-
/** The
|
|
81
|
+
const notFound = () => fail(404, "Agent not found");
|
|
82
|
+
/** The Agent, when `grants` reach it; otherwise the 404 of a missing one. */
|
|
76
83
|
function reachable(grants, id) {
|
|
77
|
-
if (grants !== undefined && !
|
|
84
|
+
if (grants !== undefined && !agentGranted(grants, id))
|
|
78
85
|
notFound();
|
|
79
86
|
}
|
|
80
|
-
/** Refuses (the 404 of a missing one)
|
|
87
|
+
/** Refuses (the 404 of a missing one) Agent `id` when the caller's grants miss it. */
|
|
81
88
|
export function assertReaches(scope, id) {
|
|
82
|
-
reachable(
|
|
89
|
+
reachable(agentGrantsOf(scope), id);
|
|
83
90
|
}
|
|
84
|
-
/** A pod
|
|
91
|
+
/** A pod Agent being deleted is no longer an Agent, here. */
|
|
85
92
|
function live(row) {
|
|
86
93
|
return row?.pod?.desired === "deleted" ? undefined : row;
|
|
87
94
|
}
|
|
88
|
-
/** An
|
|
95
|
+
/** An Agent's row, unless it is gone or being deleted. */
|
|
89
96
|
export async function liveEnvironment(t, id, options = {}) {
|
|
90
97
|
return live(await t.environment(id, options));
|
|
91
98
|
}
|
|
@@ -97,113 +104,147 @@ function podState(pod) {
|
|
|
97
104
|
return "creating";
|
|
98
105
|
return "stopped";
|
|
99
106
|
}
|
|
107
|
+
/** The order the states rank in: the first that holds is the Agent's. */
|
|
108
|
+
const STATE_RANK = ["paused", "failing", "input_required", "working", "sleeping"];
|
|
109
|
+
/**
|
|
110
|
+
* An Agent's state (protocol 16), from its desired state, its compute and its sessions, and
|
|
111
|
+
* every state that holds with the session behind it (`attention`), highest first. A session waits
|
|
112
|
+
* on a person while it is paused (an approval or an interaction) or uncertain, and the Agent while
|
|
113
|
+
* a move waits for its required secrets (track T3); a session's last turn
|
|
114
|
+
* failed while it is `failed`; a turn runs while it is `running`, `runnable` or `waiting`.
|
|
115
|
+
*/
|
|
116
|
+
export function stateOf(agent, sessions) {
|
|
117
|
+
const attention = [];
|
|
118
|
+
if (agent.desiredState === "off")
|
|
119
|
+
attention.push({ state: "paused", reason: "desired off" });
|
|
120
|
+
// A move that waits for a person to set its required secrets (track T3).
|
|
121
|
+
if (agent.targetVersion !== undefined && agent.appliedVersion === agent.version)
|
|
122
|
+
attention.push({ state: "input_required", reason: `secret_required: version ${agent.targetVersion} waits for its required secrets` });
|
|
123
|
+
const pod = agent.pod;
|
|
124
|
+
if (pod && (pod.observed === "failed" || pod.observed === "lost") && pod.desired !== "deleted")
|
|
125
|
+
attention.push({ state: "failing", reason: `compute ${pod.observed}${pod.reason ? `: ${pod.reason}` : ""}` });
|
|
126
|
+
// A start on a new version failed and went back (track T3): until a start on a new one succeeds.
|
|
127
|
+
if (agent.failedVersion !== undefined)
|
|
128
|
+
attention.push({ state: "failing", reason: `start_failed: version ${agent.failedVersion} did not start` });
|
|
129
|
+
for (const session of sessions) {
|
|
130
|
+
const status = String(session.status);
|
|
131
|
+
const state = status === "failed"
|
|
132
|
+
? "failing"
|
|
133
|
+
: status === "paused" || status === "uncertain"
|
|
134
|
+
? "input_required"
|
|
135
|
+
: status === "running" || status === "runnable" || status === "waiting"
|
|
136
|
+
? "working"
|
|
137
|
+
: undefined;
|
|
138
|
+
if (state)
|
|
139
|
+
attention.push({ state, sessionId: session.id, reason: status });
|
|
140
|
+
}
|
|
141
|
+
attention.sort((a, b) => STATE_RANK.indexOf(a.state) - STATE_RANK.indexOf(b.state));
|
|
142
|
+
return { state: attention[0]?.state ?? "sleeping", attention };
|
|
143
|
+
}
|
|
144
|
+
/** How an Agent names its manifest: `name` when it tracks it, `name@n` when pinned. */
|
|
145
|
+
export function manifestRefOf(agent) {
|
|
146
|
+
return agent.manifestRef === "tracking" ? agent.manifestName : `${agent.manifestName}@${agent.manifestVersion}`;
|
|
147
|
+
}
|
|
100
148
|
async function viewOf(ctx, t, environment, owner) {
|
|
101
149
|
const sessions = await t.sessionsOnEnvironment(environment.id);
|
|
102
150
|
const record = environment.kind === "virtual"
|
|
103
151
|
? await t.get("sandboxes", sandboxWorkspaceKey(ctx.config.tenantId, environment.id))
|
|
104
152
|
: undefined;
|
|
105
153
|
const pod = environment.pod;
|
|
106
|
-
const
|
|
154
|
+
const visible = sessions.filter((session) => owner === undefined || session.ownerUserId === owner);
|
|
155
|
+
const { state, attention } = stateOf(environment, visible);
|
|
107
156
|
return {
|
|
108
157
|
id: environment.id,
|
|
158
|
+
manifest: manifestRefOf(environment),
|
|
159
|
+
resolvedVersion: environment.manifestVersion,
|
|
160
|
+
manifestHash: environment.manifestHash,
|
|
161
|
+
tracking: environment.manifestRef,
|
|
162
|
+
targetVersion: environment.targetVersion ?? null,
|
|
163
|
+
waitingFor: waitingFor(environment),
|
|
164
|
+
failedVersion: environment.failedVersion ?? null,
|
|
165
|
+
startCheck: environment.startCheck
|
|
166
|
+
? { from: environment.startCheck.from.manifestVersion, startedAt: environment.startCheck.startedAt }
|
|
167
|
+
: null,
|
|
109
168
|
kind: environment.kind,
|
|
169
|
+
harness: environment.applied.harness,
|
|
170
|
+
owner: environment.ownerUserId ?? null,
|
|
110
171
|
labels: environment.labels,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
172
|
+
state,
|
|
173
|
+
attention,
|
|
174
|
+
desired: environment.desiredState,
|
|
175
|
+
nextWakeAt: await t.nextWakeAt(environment.id),
|
|
176
|
+
compute: {
|
|
177
|
+
state: pod ? podState(pod) : environment.kind === "none" ? "ready" : (record?.state ?? "ready"),
|
|
178
|
+
...(pod
|
|
179
|
+
? {
|
|
180
|
+
pod: {
|
|
181
|
+
desired: pod.desired,
|
|
182
|
+
observed: pod.observed,
|
|
183
|
+
volumeGeneration: pod.volumeGen,
|
|
184
|
+
hostEpoch: pod.hostEpoch,
|
|
185
|
+
...(pod.reason ? { reason: pod.reason } : {}),
|
|
186
|
+
...(pod.imageDigest ? { imageDigest: pod.imageDigest } : {}),
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
: {}),
|
|
190
|
+
},
|
|
191
|
+
runtime: environment.applied,
|
|
115
192
|
limits: limitsInForce(await readTenantLimits(t), environment.applied.limits),
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
? {
|
|
124
|
-
pod: {
|
|
125
|
-
desired: pod.desired,
|
|
126
|
-
observed: pod.observed,
|
|
127
|
-
volumeGeneration: pod.volumeGen,
|
|
128
|
-
hostEpoch: pod.hostEpoch,
|
|
129
|
-
...(pod.reason ? { reason: pod.reason } : {}),
|
|
130
|
-
...(pod.imageDigest ? { imageDigest: pod.imageDigest } : {}),
|
|
131
|
-
},
|
|
132
|
-
}
|
|
133
|
-
: {}),
|
|
134
|
-
lastUsedAt: environment.lastUsedAt,
|
|
193
|
+
sessions: visible.map((session) => ({
|
|
194
|
+
id: session.id,
|
|
195
|
+
ownerUserId: session.ownerUserId,
|
|
196
|
+
status: String(session.status),
|
|
197
|
+
activeTurnId: session.activeTurnId,
|
|
198
|
+
})),
|
|
199
|
+
slots: (await slotsOf(t, environment)) ?? [],
|
|
135
200
|
...(environment.kind === "none"
|
|
136
201
|
? {}
|
|
137
202
|
: {
|
|
138
203
|
disk: {
|
|
139
204
|
latestVersion: (await t.diskArtifact(environment.id))?.latestVersion || null,
|
|
140
|
-
|
|
205
|
+
operation: diskHeld(environment)
|
|
141
206
|
? {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
startedAt: environment.diskOp.startedAt,
|
|
146
|
-
},
|
|
207
|
+
kind: environment.diskOp.kind,
|
|
208
|
+
...(environment.diskOp.version === undefined ? {} : { version: environment.diskOp.version }),
|
|
209
|
+
startedAt: environment.diskOp.startedAt,
|
|
147
210
|
}
|
|
148
|
-
:
|
|
211
|
+
: null,
|
|
149
212
|
},
|
|
150
213
|
}),
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
.map((session) => ({ id: session.id, activeTurnId: session.activeTurnId })),
|
|
214
|
+
forkedFrom: environment.forkedFrom ?? null,
|
|
215
|
+
lastUsedAt: environment.lastUsedAt,
|
|
154
216
|
createdAt: environment.createdAt,
|
|
155
217
|
updatedAt: environment.updatedAt,
|
|
156
218
|
};
|
|
157
219
|
}
|
|
158
|
-
/**
|
|
220
|
+
/**
|
|
221
|
+
* The secret slots a secret write fills (D64): the version `environment` moves to while it is
|
|
222
|
+
* outdated (track T3), so its new slots can be set before it applies, else the one it runs.
|
|
223
|
+
*/
|
|
224
|
+
async function slotsAhead(t, environment) {
|
|
225
|
+
if (environment.targetVersion === undefined)
|
|
226
|
+
return environment.applied.secrets;
|
|
227
|
+
// Waiting for its pod's start, `recipe` is the target's runtime.
|
|
228
|
+
if (environment.appliedVersion !== environment.version)
|
|
229
|
+
return environment.recipe.secrets;
|
|
230
|
+
const target = await t.agentManifest(environment.manifestName, environment.targetVersion);
|
|
231
|
+
return target ? runtimeOf(target.body).secrets : environment.applied.secrets;
|
|
232
|
+
}
|
|
233
|
+
/** Each secret slot `environment` declares (`slotsAhead`), and whether its vault holds a value that fills it. */
|
|
159
234
|
async function slotsOf(t, environment) {
|
|
160
|
-
const declared = environment
|
|
235
|
+
const declared = await slotsAhead(t, environment);
|
|
161
236
|
if (declared === undefined)
|
|
162
237
|
return undefined;
|
|
163
238
|
const vault = await t.vaultOfEnvironment(environment.id);
|
|
164
|
-
const
|
|
239
|
+
const rows = vault ? await t.credentialsForVault(vault.id) : [];
|
|
165
240
|
return Object.entries(declared)
|
|
166
241
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
167
|
-
.map(([name, slot]) => ({
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
"resources",
|
|
174
|
-
"storage",
|
|
175
|
-
"network",
|
|
176
|
-
"harness",
|
|
177
|
-
"lifecycle",
|
|
178
|
-
"setup",
|
|
179
|
-
"files",
|
|
180
|
-
"limits",
|
|
181
|
-
"secrets",
|
|
182
|
-
];
|
|
183
|
-
/** The hash of what an Environment runs, recorded when it is made. */
|
|
184
|
-
function recipeHash(recipe) {
|
|
185
|
-
return hashManifest(recipe);
|
|
186
|
-
}
|
|
187
|
-
/** What a `PUT` asks of the recipe: its fields over `existing`'s (fixed `storage` kept). */
|
|
188
|
-
function recipeRequest(body, existing) {
|
|
189
|
-
const recipe = existing?.recipe;
|
|
190
|
-
const pick = (key, sent) => sent !== undefined ? { [key]: sent } : recipe?.[key] !== undefined ? { [key]: recipe[key] } : {};
|
|
191
|
-
return {
|
|
192
|
-
...pick("image", body.image),
|
|
193
|
-
...pick("resources", body.resources),
|
|
194
|
-
...(recipe?.storage !== undefined
|
|
195
|
-
? { storage: recipe.storage }
|
|
196
|
-
: body.storage !== undefined
|
|
197
|
-
? { storage: body.storage }
|
|
198
|
-
: {}),
|
|
199
|
-
...pick("network", body.network),
|
|
200
|
-
...pick("harness", body.harness),
|
|
201
|
-
...pick("lifecycle", body.lifecycle),
|
|
202
|
-
...pick("setup", body.setup),
|
|
203
|
-
...pick("files", body.files),
|
|
204
|
-
...pick("limits", body.limits),
|
|
205
|
-
...pick("secrets", body.secrets),
|
|
206
|
-
};
|
|
242
|
+
.map(([name, slot]) => ({
|
|
243
|
+
name,
|
|
244
|
+
type: slot.type,
|
|
245
|
+
required: slot.required ?? false,
|
|
246
|
+
set: rows.some((row) => row.name === name && slotHolds(slot, row)),
|
|
247
|
+
}));
|
|
207
248
|
}
|
|
208
249
|
/**
|
|
209
250
|
* Why input files cannot be used: each that is not a version of a Tenant-wide artifact (a
|
|
@@ -241,7 +282,7 @@ function checkGuard(scope, asked, current) {
|
|
|
241
282
|
if (subject === undefined || asked === undefined)
|
|
242
283
|
return;
|
|
243
284
|
if (asked === null ? (current !== undefined && current !== subject) : asked !== subject)
|
|
244
|
-
fail(403, "A request acting for a person may set
|
|
285
|
+
fail(403, "A request acting for a person may set owner only to that person.");
|
|
245
286
|
}
|
|
246
287
|
/** Asks for a pod's reconcile once the transaction commits (the sandbox signal outbox). */
|
|
247
288
|
function reconcileAfter(t, id) {
|
|
@@ -268,7 +309,8 @@ async function wantRunning(t, environment, now) {
|
|
|
268
309
|
await t.updateEnvironmentPod(environment.id, { observed: "creating", startedAt: now, lastActiveAt: now, reason: null }, now);
|
|
269
310
|
return;
|
|
270
311
|
}
|
|
271
|
-
|
|
312
|
+
// A move whose runtime waits for this start switches now (track T3).
|
|
313
|
+
if (!(await switchTo(t, environment, now)) && environment.appliedVersion !== environment.version)
|
|
272
314
|
await t.updateEnvironment(environment.id, { applied: environment.recipe, appliedVersion: environment.version }, now);
|
|
273
315
|
await t.updateEnvironmentPod(environment.id, {
|
|
274
316
|
desired: "running",
|
|
@@ -280,87 +322,48 @@ async function wantRunning(t, environment, now) {
|
|
|
280
322
|
}, now);
|
|
281
323
|
}
|
|
282
324
|
/**
|
|
283
|
-
* `PUT /v1/
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
325
|
+
* `PUT /v1/agents/{id}`: creates the Agent and its vault (within the Tenant's limit on their
|
|
326
|
+
* number) from a saved agent manifest's version, or finds the one with this id, so "get or
|
|
327
|
+
* create" is one call. On an existing one, its labels and owner guard may change, and `manifest`
|
|
328
|
+
* moves it to the version it names (track T3, `moveAgent`): `name` tracks the latest (unless that
|
|
329
|
+
* failed to start), `name@n` pins `n`. Another manifest name is `409 agent_fixed_field`. Every
|
|
330
|
+
* `PUT` counts as the Agent's use.
|
|
288
331
|
*/
|
|
289
|
-
export async function
|
|
332
|
+
export async function putAgent(ctx, id, body, scope, options = {}) {
|
|
290
333
|
const fork = options.fork;
|
|
291
|
-
reachable(
|
|
292
|
-
const ref =
|
|
293
|
-
|
|
294
|
-
if (ref === undefined)
|
|
295
|
-
fail(400, "manifest is an environment manifest's name, or name@version such as analyst-box@3.");
|
|
296
|
-
const inline = INLINE_FIELDS.filter((field) => body[field] !== undefined);
|
|
297
|
-
if (inline.length > 0)
|
|
298
|
-
fail(400, `An Environment made from manifest ${body.manifest} runs what the manifest says: send no ${inline.join(", ")} with it.`);
|
|
299
|
-
}
|
|
300
|
-
if (body.kind === "pod")
|
|
301
|
-
await requirePods(ctx.pods, "A pod Environment");
|
|
302
|
-
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.`, {
|
|
303
|
-
code: "environment_immutable",
|
|
304
|
-
details: {
|
|
305
|
-
fields: [...fields],
|
|
306
|
-
...(existing.manifestName === undefined
|
|
307
|
-
? {}
|
|
308
|
-
: { manifest: `${existing.manifestName}@${existing.manifestVersion}` }),
|
|
309
|
-
},
|
|
310
|
-
});
|
|
334
|
+
reachable(agentGrantsOf(scope), id);
|
|
335
|
+
const ref = parseAgentManifestRef(body.manifest) ??
|
|
336
|
+
fail(400, "manifest is an agent manifest's name, or name@version such as triage@3.");
|
|
311
337
|
const view = await ctx.store.tx(async (t) => {
|
|
312
338
|
const now = new Date().toISOString();
|
|
313
339
|
const config = effectiveSandboxConfig(await readSandboxConfig(t));
|
|
314
340
|
const tenantLimits = await readTenantLimits(t);
|
|
315
341
|
const runtimeLimits = await readRuntimeLimits(t);
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
// the lower limit applies. Labels are checked whenever they are set.
|
|
342
|
+
// Labels are checked whenever they are set; what it runs only when it is made, so a
|
|
343
|
+
// get-or-create on one made before the Tenant lowered a limit still answers.
|
|
319
344
|
const labels = labelIssues(body.labels, runtimeLimits);
|
|
320
345
|
if (labels.length > 0)
|
|
321
346
|
fail(400, labels.join(" "));
|
|
322
|
-
const resolve = (kind, request, creating) => {
|
|
323
|
-
const resolved = resolveRecipe(kind, request, config);
|
|
324
|
-
const errors = [
|
|
325
|
-
...(resolved.kind === "error" ? resolved.errors : []),
|
|
326
|
-
...(creating ? [...limitIssues(request.limits, tenantLimits), ...manifestShapeIssues(request, runtimeLimits)] : []),
|
|
327
|
-
];
|
|
328
|
-
return resolved.kind === "error" || errors.length > 0 ? fail(400, errors.join(" ")) : resolved.recipe;
|
|
329
|
-
};
|
|
330
347
|
const deleting = (row) => {
|
|
331
348
|
if (row && !live(row))
|
|
332
|
-
failRetryLater(`
|
|
349
|
+
failRetryLater(`Agent ${id} is being deleted. Try again once it is gone.`, "agent_busy");
|
|
333
350
|
};
|
|
334
351
|
const found = async (existing) => {
|
|
335
352
|
if (fork !== undefined)
|
|
336
|
-
fail(409, `
|
|
353
|
+
fail(409, `Agent ${id} exists: a fork makes a new Agent, so name another id.`);
|
|
337
354
|
if (body.from !== undefined)
|
|
338
|
-
fail(409, `
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
}
|
|
346
|
-
else if (INLINE_FIELDS.some((field) => body[field] !== undefined)) {
|
|
347
|
-
if (body.kind !== undefined && body.kind !== existing.kind)
|
|
348
|
-
immutable(["kind"], existing);
|
|
349
|
-
if (body.storage !== undefined && body.storage !== existing.recipe.storage)
|
|
350
|
-
immutable(["storage"], existing);
|
|
351
|
-
const recipe = resolve(existing.kind, recipeRequest(body, existing), false);
|
|
352
|
-
const differing = Object.keys({ ...recipe, ...existing.recipe })
|
|
353
|
-
.filter((field) => canonical(recipe[field]) !== canonical(existing.recipe[field]))
|
|
354
|
-
.sort();
|
|
355
|
-
if (differing.length > 0)
|
|
356
|
-
immutable(differing, existing);
|
|
357
|
-
}
|
|
358
|
-
checkGuard(scope, body.ownerUserId, existing.ownerUserId);
|
|
355
|
+
fail(409, `Agent ${id} exists: from applies only when a PUT creates one.`);
|
|
356
|
+
checkGuard(scope, body.owner, existing.ownerUserId);
|
|
357
|
+
const move = await moveOf(t, existing, ref);
|
|
358
|
+
// A tracking Agent named as it is made (get-or-create) stays where it can move to.
|
|
359
|
+
const stays = move.plan.applies === "refused" && ref.version === undefined && existing.manifestRef === "tracking";
|
|
360
|
+
if (!stays)
|
|
361
|
+
await moveAgent(t, existing, move, now);
|
|
359
362
|
const patch = { lastUsedAt: now };
|
|
360
363
|
if (body.labels !== undefined && canonical(body.labels) !== canonical(existing.labels))
|
|
361
364
|
patch.labels = body.labels;
|
|
362
|
-
if (body.
|
|
363
|
-
patch.ownerUserId = body.
|
|
365
|
+
if (body.owner !== undefined && (body.owner ?? undefined) !== existing.ownerUserId)
|
|
366
|
+
patch.ownerUserId = body.owner;
|
|
364
367
|
await t.updateEnvironment(id, patch, now);
|
|
365
368
|
return viewOf(ctx, t, (await t.environment(id)) ?? existing, subjectOf(scope));
|
|
366
369
|
};
|
|
@@ -368,21 +371,30 @@ export async function putEnvironment(ctx, id, body, scope, options = {}) {
|
|
|
368
371
|
deleting(existing);
|
|
369
372
|
if (existing)
|
|
370
373
|
return found(existing);
|
|
371
|
-
checkGuard(scope, body.
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const kind =
|
|
379
|
-
if (
|
|
380
|
-
await requirePods(ctx.pods, `
|
|
381
|
-
const
|
|
374
|
+
checkGuard(scope, body.owner);
|
|
375
|
+
const version = fork?.version ?? ref.version;
|
|
376
|
+
const manifest = (await t.agentManifest(ref.name, version)) ??
|
|
377
|
+
fail(404, version === undefined
|
|
378
|
+
? `No agent manifest is named ${ref.name}: save it first (PUT /v1/agent-manifests/${ref.name}).`
|
|
379
|
+
: `Agent manifest ${ref.name} has no version ${version}.`, { code: "manifest_not_found" });
|
|
380
|
+
const runtime = runtimeOf(manifest.body);
|
|
381
|
+
const kind = runtime.kind;
|
|
382
|
+
if (kind === "pod")
|
|
383
|
+
await requirePods(ctx.pods, `Agent manifest ${manifest.name} is of kind pod, and a pod Agent`);
|
|
384
|
+
const resolved = resolveRecipe(kind, manifestRecipeRequest(manifest.body), config);
|
|
385
|
+
const errors = [
|
|
386
|
+
...(resolved.kind === "error" ? resolved.errors : []),
|
|
387
|
+
...limitIssues(runtime.limits, tenantLimits),
|
|
388
|
+
...manifestShapeIssues(runtime, runtimeLimits),
|
|
389
|
+
];
|
|
390
|
+
if (resolved.kind === "error" || errors.length > 0)
|
|
391
|
+
return fail(400, errors.join(" "));
|
|
392
|
+
const recipe = resolved.recipe;
|
|
382
393
|
await checkFiles(t, recipe.files);
|
|
383
394
|
const from = body.from === undefined ? undefined : await forkSource(t, scope, kind, body.from);
|
|
384
395
|
// A fork onto other setup runs it on the written disk (Q17).
|
|
385
396
|
const seed = from && fork?.setup ? { ...from, setup: true } : from;
|
|
397
|
+
const tracking = ref.version === undefined ? "tracking" : "pinned";
|
|
386
398
|
const row = {
|
|
387
399
|
id,
|
|
388
400
|
kind,
|
|
@@ -391,13 +403,16 @@ export async function putEnvironment(ctx, id, body, scope, options = {}) {
|
|
|
391
403
|
applied: recipe,
|
|
392
404
|
version: 1,
|
|
393
405
|
appliedVersion: 1,
|
|
394
|
-
...(body.
|
|
406
|
+
...(body.owner ? { ownerUserId: body.owner } : {}),
|
|
395
407
|
lastUsedAt: now,
|
|
396
408
|
createdAt: now,
|
|
397
409
|
updatedAt: now,
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
410
|
+
manifestName: manifest.name,
|
|
411
|
+
manifestVersion: manifest.version,
|
|
412
|
+
manifestRef: tracking,
|
|
413
|
+
manifestHash: manifest.hash,
|
|
414
|
+
desiredState: "on",
|
|
415
|
+
...(fork ? { forkedFrom: { agent: fork.source, disk: seed?.version ?? null } } : {}),
|
|
401
416
|
...(seed
|
|
402
417
|
? {
|
|
403
418
|
diskSeed: seed,
|
|
@@ -426,7 +441,7 @@ export async function putEnvironment(ctx, id, body, scope, options = {}) {
|
|
|
426
441
|
};
|
|
427
442
|
const created = await t.createEnvironment(row, config.limits.environments);
|
|
428
443
|
if (created === "limit")
|
|
429
|
-
fail(409, `This Tenant allows at most ${config.limits.environments}
|
|
444
|
+
fail(409, `This Tenant allows at most ${config.limits.environments} Agents.`, {
|
|
430
445
|
code: "limit_exceeded",
|
|
431
446
|
details: { limit: "environments", max: config.limits.environments },
|
|
432
447
|
});
|
|
@@ -436,22 +451,26 @@ export async function putEnvironment(ctx, id, body, scope, options = {}) {
|
|
|
436
451
|
deleting(again);
|
|
437
452
|
return found(again);
|
|
438
453
|
}
|
|
454
|
+
// Its triggers start active (D74); armed after the commit.
|
|
455
|
+
await createAgentTriggers(t, row);
|
|
456
|
+
// A fork starts with the source's memories as they are now, with no history (T5).
|
|
457
|
+
if (fork)
|
|
458
|
+
await t.copyMemories(fork.source, id, now);
|
|
439
459
|
const vaultId = await ctx.vault.createEnvironmentVault(t, id, actorOf(scope), fork?.sealed?.vaultId);
|
|
440
460
|
if (fork?.sealed)
|
|
441
461
|
fork.copied = await ctx.vault.insertEnvironmentSecretCopies(t, vaultId, fork.source, actorOf(scope), fork.sealed.copies);
|
|
442
|
-
|
|
443
|
-
await t.environmentEvent(id, "environment.created", {
|
|
462
|
+
await t.agentEvent(id, "agent.created", {
|
|
444
463
|
kind,
|
|
445
464
|
labels: row.labels,
|
|
446
|
-
|
|
447
|
-
...(seed ? { from: {
|
|
465
|
+
manifest: { name: manifest.name, version: manifest.version, tracking },
|
|
466
|
+
...(seed ? { from: { agent: body.from.agent, version: seed.version } } : {}),
|
|
448
467
|
...(row.forkedFrom ? { forkedFrom: row.forkedFrom } : {}),
|
|
449
468
|
});
|
|
450
469
|
if (fork)
|
|
451
|
-
await t.
|
|
470
|
+
await t.agentEvent(fork.source, "agent.forked", {
|
|
452
471
|
to: id,
|
|
453
472
|
disk: seed?.version ?? null,
|
|
454
|
-
|
|
473
|
+
manifest: { name: manifest.name, version: manifest.version },
|
|
455
474
|
secrets: fork.copied?.copied.length ?? 0,
|
|
456
475
|
});
|
|
457
476
|
if (row.pod) {
|
|
@@ -462,13 +481,60 @@ export async function putEnvironment(ctx, id, body, scope, options = {}) {
|
|
|
462
481
|
}
|
|
463
482
|
return viewOf(ctx, t, row, subjectOf(scope));
|
|
464
483
|
});
|
|
484
|
+
await armAgentTriggers(ctx, id);
|
|
465
485
|
// A virtual fork's workspace is written at once (a pod's once its engine connects).
|
|
466
486
|
if (body.from !== undefined && view.kind === "virtual") {
|
|
467
487
|
await runPendingSeed(ctx, id);
|
|
468
|
-
return
|
|
488
|
+
return getAgent(ctx, id, scope);
|
|
469
489
|
}
|
|
470
490
|
return view;
|
|
471
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* The move a `PUT` naming `ref` asks of existing Agent `agent` (track T3): to version `n` for
|
|
494
|
+
* `name@n` (pinned), else to the latest version (tracking), unless that one failed to start, when
|
|
495
|
+
* it stays on what it runs. Another manifest name is `409 agent_fixed_field`.
|
|
496
|
+
*/
|
|
497
|
+
async function moveOf(t, agent, ref) {
|
|
498
|
+
if (ref.name !== agent.manifestName)
|
|
499
|
+
fail(409, `Agent ${agent.id} runs agent manifest ${agent.manifestName}, which is fixed for its life: fork it into a new Agent made from ${ref.name} with POST /v1/agents/${encodeURIComponent(agent.id)}/fork.`, { code: "agent_fixed_field", details: { fields: [{ field: "manifest", from: agent.manifestName, to: ref.name }] } });
|
|
500
|
+
const missing = () => fail(404, `Agent manifest ${ref.name} has no version ${ref.version}.`, { code: "manifest_not_found" });
|
|
501
|
+
let target = (await t.agentManifest(ref.name, ref.version)) ?? missing();
|
|
502
|
+
if (ref.version === undefined && target.version === agent.failedVersion)
|
|
503
|
+
target = (await t.agentManifest(ref.name, agent.manifestVersion)) ?? target;
|
|
504
|
+
return planMove(t, agent, target, ref.version === undefined ? "tracking" : "pinned");
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* `PUT /v1/agents/{id}?dryRun=true` (track T3): what moving existing Agent `id` to the version
|
|
508
|
+
* `body.manifest` names would do (`AgentMovePlan`), with nothing changed.
|
|
509
|
+
*/
|
|
510
|
+
export async function planAgentPut(ctx, id, body, scope) {
|
|
511
|
+
reachable(agentGrantsOf(scope), id);
|
|
512
|
+
const ref = parseAgentManifestRef(body.manifest) ??
|
|
513
|
+
fail(400, "manifest is an agent manifest's name, or name@version such as triage@3.");
|
|
514
|
+
return ctx.store.tx(async (t) => {
|
|
515
|
+
const agent = (await liveEnvironment(t, id)) ?? fail(404, "Agent not found: a dry run plans the move of an existing Agent.");
|
|
516
|
+
return { dryRun: true, plan: (await moveOf(t, agent, ref)).plan };
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* After a secret write on Agent `id`: a move that waited for its required secrets goes on once
|
|
521
|
+
* they are set (track T3). Best effort: the write stands either way.
|
|
522
|
+
*/
|
|
523
|
+
export async function secretWritten(ctx, id) {
|
|
524
|
+
try {
|
|
525
|
+
await ctx.store.tx(async (t) => {
|
|
526
|
+
const agent = await liveEnvironment(t, id, { lock: true });
|
|
527
|
+
if (agent)
|
|
528
|
+
await resumeMove(t, agent, new Date().toISOString());
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
ctx.config.logger.warn("agent move not resumed after a secret write", {
|
|
533
|
+
environmentId: id,
|
|
534
|
+
message: error instanceof Error ? error.message : String(error),
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
472
538
|
/**
|
|
473
539
|
* The secret write `raw` asks of Environment `id` (D64). When what it runs declares secret
|
|
474
540
|
* slots, the name must be one (`409 secret_not_in_manifest`), and the body is `{ value }` (or
|
|
@@ -484,32 +550,35 @@ export async function secretRequestFor(ctx, id, name, raw) {
|
|
|
484
550
|
return request;
|
|
485
551
|
}
|
|
486
552
|
async function parseSecretRequest(ctx, id, name, raw) {
|
|
487
|
-
const environment = await ctx.store.tx((t) =>
|
|
488
|
-
|
|
553
|
+
const { environment, slots } = await ctx.store.tx(async (t) => {
|
|
554
|
+
const environment = await liveEnvironment(t, id);
|
|
555
|
+
return { environment, slots: environment && (await slotsAhead(t, environment)) };
|
|
556
|
+
});
|
|
489
557
|
if (!environment || slots === undefined)
|
|
490
|
-
return
|
|
558
|
+
return PutAgentSecretRequestSchema.parse(raw);
|
|
559
|
+
const named = `${environment.manifestName}@${environment.targetVersion ?? environment.manifestVersion}`;
|
|
491
560
|
const slot = slots[name] ??
|
|
492
|
-
fail(409, `
|
|
561
|
+
fail(409, `Agent ${id}'s manifest (${named}) declares no secret ${name}: add the slot to the manifest's runtime section, then move the Agent to the new version.`, { code: "secret_not_in_manifest", details: { declared: Object.keys(slots).sort() } });
|
|
493
562
|
const full = slotRequest(slot);
|
|
494
563
|
if (raw !== null && typeof raw === "object" && "type" in raw) {
|
|
495
|
-
const body =
|
|
564
|
+
const body = PutAgentSecretRequestSchema.parse(raw);
|
|
496
565
|
const { requestId: _, value: _v, headers: _h, ...binding } = body;
|
|
497
566
|
if (canonical(binding) !== canonical(full))
|
|
498
|
-
fail(409, `Secret ${name}'s type and binding come from
|
|
567
|
+
fail(409, `Secret ${name}'s type and binding come from Agent ${id}'s manifest: send only its value ({ "value": "…" }).`, {
|
|
499
568
|
code: "secret_not_in_manifest",
|
|
500
569
|
});
|
|
501
570
|
return body;
|
|
502
571
|
}
|
|
503
|
-
const { requestId, value, headers } =
|
|
572
|
+
const { requestId, value, headers } = PutAgentSecretValueRequestSchema.parse(raw);
|
|
504
573
|
const withId = requestId === undefined ? {} : { requestId };
|
|
505
574
|
if (slot.type === "headers") {
|
|
506
575
|
if (headers === undefined)
|
|
507
576
|
fail(400, `Secret ${name} is a headers credential: send { "headers": { … } }.`);
|
|
508
|
-
return
|
|
577
|
+
return PutAgentSecretRequestSchema.parse({ ...withId, ...full, headers });
|
|
509
578
|
}
|
|
510
579
|
if (value === undefined)
|
|
511
580
|
fail(400, `Secret ${name} takes a value: send { "value": "…" }.`);
|
|
512
|
-
return
|
|
581
|
+
return PutAgentSecretRequestSchema.parse({ ...withId, ...full, value });
|
|
513
582
|
}
|
|
514
583
|
/** A slot's type and binding, as a secret write's fields (without the value). */
|
|
515
584
|
function slotRequest(slot) {
|
|
@@ -522,49 +591,49 @@ function slotRequest(slot) {
|
|
|
522
591
|
*/
|
|
523
592
|
async function forkSource(t, scope, kind, from) {
|
|
524
593
|
if (kind === "none")
|
|
525
|
-
fail(400, "from is for an
|
|
526
|
-
reachable(
|
|
527
|
-
const source = (await liveEnvironment(t, from.
|
|
594
|
+
fail(400, "from is for an Agent with a disk: kind none has none.");
|
|
595
|
+
reachable(agentGrantsOf(scope), from.agent);
|
|
596
|
+
const source = (await liveEnvironment(t, from.agent)) ?? notFound();
|
|
528
597
|
const subject = subjectOf(scope);
|
|
529
598
|
if (subject !== undefined && source.ownerUserId !== undefined && source.ownerUserId !== subject)
|
|
530
599
|
notFound();
|
|
531
|
-
return forkSeedOf(t, from);
|
|
600
|
+
return forkSeedOf(t, { environment: from.agent, version: from.version });
|
|
532
601
|
}
|
|
533
602
|
/**
|
|
534
|
-
* `POST /v1/
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
* its disk and its sessions. With
|
|
603
|
+
* `POST /v1/agents/{id}/fork` (Q15): copies an Agent into a new one, which is how an Agent changes
|
|
604
|
+
* what is fixed for its life (its manifest, kind, harness or storage). A new Agent `body.id` is made from the manifest
|
|
605
|
+
* (default the source's manifest name, tracking, at its latest version), its disk started from a
|
|
606
|
+
* version of the source's (`latest` takes a snapshot first, refused during a turn), and its
|
|
607
|
+
* secrets copied inside the Runtime. A fork onto other setup runs that setup on the copied disk
|
|
608
|
+
* before its first turn (Q17). The source keeps what it runs, its disk and its sessions. With
|
|
609
|
+
* `dryRun`, the plan only.
|
|
540
610
|
*/
|
|
541
|
-
export async function
|
|
542
|
-
const grants =
|
|
611
|
+
export async function forkAgent(ctx, sourceId, body, scope) {
|
|
612
|
+
const grants = agentGrantsOf(scope);
|
|
543
613
|
reachable(grants, sourceId);
|
|
544
614
|
reachable(grants, body.id);
|
|
545
615
|
if (body.id === sourceId)
|
|
546
|
-
fail(400, "A fork is a new
|
|
616
|
+
fail(400, "A fork is a new Agent: give it another id.");
|
|
547
617
|
const { plan: planned, slots } = await ctx.store.tx(async (t) => {
|
|
548
618
|
const source = (await liveEnvironment(t, sourceId)) ?? notFound();
|
|
549
619
|
const subject = subjectOf(scope);
|
|
550
620
|
if (subject !== undefined && source.ownerUserId !== undefined && source.ownerUserId !== subject)
|
|
551
621
|
notFound();
|
|
552
|
-
const ref = body.manifest === undefined
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
fail(
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
const kind = manifest.body.kind ?? "virtual";
|
|
622
|
+
const ref = body.manifest === undefined
|
|
623
|
+
? { name: source.manifestName }
|
|
624
|
+
: (parseAgentManifestRef(body.manifest) ??
|
|
625
|
+
fail(400, "manifest is an agent manifest's name, or name@version such as triage@3."));
|
|
626
|
+
const manifest = (await t.agentManifest(ref.name, ref.version)) ??
|
|
627
|
+
fail(404, ref.version === undefined
|
|
628
|
+
? `No agent manifest is named ${ref.name}.`
|
|
629
|
+
: `Agent manifest ${ref.name} has no version ${ref.version}.`, { code: "manifest_not_found" });
|
|
630
|
+
const runtime = runtimeOf(manifest.body);
|
|
631
|
+
const kind = runtime.kind;
|
|
563
632
|
const resolved = resolveRecipe(kind, manifestRecipeRequest(manifest.body), effectiveSandboxConfig(await readSandboxConfig(t)));
|
|
564
633
|
const errors = [
|
|
565
634
|
...(resolved.kind === "error" ? resolved.errors : []),
|
|
566
|
-
...limitIssues(
|
|
567
|
-
...manifestShapeIssues(
|
|
635
|
+
...limitIssues(runtime.limits, await readTenantLimits(t)),
|
|
636
|
+
...manifestShapeIssues(runtime, await readRuntimeLimits(t)),
|
|
568
637
|
...labelIssues(body.labels, await readRuntimeLimits(t)),
|
|
569
638
|
];
|
|
570
639
|
if (resolved.kind === "error" || errors.length > 0)
|
|
@@ -589,7 +658,7 @@ export async function forkEnvironment(ctx, sourceId, body, scope) {
|
|
|
589
658
|
disk,
|
|
590
659
|
setup: kind === "pod" &&
|
|
591
660
|
disk !== null &&
|
|
592
|
-
|
|
661
|
+
canonical(recipe) !== canonical(source.applied) &&
|
|
593
662
|
Boolean(recipe.setup?.length || recipe.files?.length),
|
|
594
663
|
secrets: {
|
|
595
664
|
copied,
|
|
@@ -599,8 +668,9 @@ export async function forkEnvironment(ctx, sourceId, body, scope) {
|
|
|
599
668
|
.map(([item]) => item)
|
|
600
669
|
.sort(),
|
|
601
670
|
},
|
|
602
|
-
changes: manifestChanges(source.
|
|
671
|
+
changes: manifestChanges(source.applied, recipe),
|
|
603
672
|
sessions: (await t.sessionsOnEnvironment(sourceId)).length,
|
|
673
|
+
memories: await t.countMemories(sourceId),
|
|
604
674
|
};
|
|
605
675
|
const slots = recipe.secrets === undefined
|
|
606
676
|
? null
|
|
@@ -617,25 +687,31 @@ export async function forkEnvironment(ctx, sourceId, body, scope) {
|
|
|
617
687
|
const sealed = (body.secrets ?? "copy") === "copy"
|
|
618
688
|
? { vaultId, copies: await ctx.keys.sealEnvironmentSecretCopies(sourceId, vaultId, slots) }
|
|
619
689
|
: undefined;
|
|
620
|
-
const fork = {
|
|
621
|
-
|
|
690
|
+
const fork = {
|
|
691
|
+
source: sourceId,
|
|
692
|
+
version: planned.manifest.version,
|
|
693
|
+
setup: planned.setup,
|
|
694
|
+
...(sealed ? { sealed } : {}),
|
|
695
|
+
};
|
|
696
|
+
const agent = await putAgent(ctx, body.id, {
|
|
622
697
|
...(body.requestId === undefined ? {} : { requestId: body.requestId }),
|
|
623
|
-
|
|
698
|
+
// Tracking unless the request pinned a version; either way the plan's version is made.
|
|
699
|
+
manifest: body.manifest ?? planned.manifest.name,
|
|
624
700
|
...(body.labels === undefined ? {} : { labels: body.labels }),
|
|
625
|
-
...(body.
|
|
626
|
-
...(disk === null ? {} : { from: {
|
|
701
|
+
...(body.owner === undefined ? {} : { owner: body.owner }),
|
|
702
|
+
...(disk === null ? {} : { from: { agent: sourceId, version: disk } }),
|
|
627
703
|
}, scope, { fork });
|
|
628
704
|
const copied = fork.copied ?? { copied: [], dropped: planned.secrets.copied };
|
|
629
705
|
const dropped = [...new Set([...planned.secrets.dropped, ...copied.dropped])].sort();
|
|
630
706
|
return {
|
|
631
|
-
|
|
707
|
+
agent,
|
|
632
708
|
plan: {
|
|
633
709
|
...planned,
|
|
634
710
|
disk,
|
|
635
711
|
secrets: {
|
|
636
712
|
copied: copied.copied,
|
|
637
713
|
dropped,
|
|
638
|
-
missing: Object.entries(
|
|
714
|
+
missing: Object.entries(agent.runtime.secrets ?? {})
|
|
639
715
|
.filter(([item, slot]) => slot.required === true && !copied.copied.includes(item))
|
|
640
716
|
.map(([item]) => item)
|
|
641
717
|
.sort(),
|
|
@@ -643,24 +719,44 @@ export async function forkEnvironment(ctx, sourceId, body, scope) {
|
|
|
643
719
|
},
|
|
644
720
|
};
|
|
645
721
|
}
|
|
646
|
-
/** `GET /v1/
|
|
647
|
-
export async function
|
|
648
|
-
reachable(
|
|
722
|
+
/** `GET /v1/agents/{id}`. */
|
|
723
|
+
export async function getAgent(ctx, id, scope) {
|
|
724
|
+
reachable(agentGrantsOf(scope), id);
|
|
649
725
|
return ctx.store.tx(async (t) => {
|
|
650
726
|
const environment = (await liveEnvironment(t, id)) ?? notFound();
|
|
651
727
|
return viewOf(ctx, t, environment, subjectOf(scope));
|
|
652
728
|
});
|
|
653
729
|
}
|
|
654
|
-
/**
|
|
655
|
-
|
|
656
|
-
|
|
730
|
+
/**
|
|
731
|
+
* `GET /v1/agents?label=k=v&state=s&manifest=m`: every Agent with all the labels, in the state
|
|
732
|
+
* and made from the manifest, that the caller reaches, by id. With `page`, at most `limit` after
|
|
733
|
+
* the Agent id `after` (the cursor), and the id to continue from.
|
|
734
|
+
*/
|
|
735
|
+
export async function listAgents(ctx, filters, scope, page) {
|
|
736
|
+
const grants = agentGrantsOf(scope);
|
|
657
737
|
return ctx.store.tx(async (t) => {
|
|
658
|
-
const found = await t.listEnvironments({ labels });
|
|
738
|
+
const found = await t.listEnvironments({ labels: filters.labels });
|
|
659
739
|
const views = [];
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
740
|
+
let next = null;
|
|
741
|
+
for (const environment of found) {
|
|
742
|
+
if (!live(environment) || (grants !== undefined && !agentGranted(grants, environment.id)))
|
|
743
|
+
continue;
|
|
744
|
+
if (filters.manifest !== undefined && environment.manifestName !== filters.manifest)
|
|
745
|
+
continue;
|
|
746
|
+
if (filters.outdated !== undefined && (environment.targetVersion !== undefined) !== filters.outdated)
|
|
747
|
+
continue;
|
|
748
|
+
if (page?.after !== undefined && environment.id <= page.after)
|
|
749
|
+
continue;
|
|
750
|
+
const view = await viewOf(ctx, t, environment, subjectOf(scope));
|
|
751
|
+
if (filters.state !== undefined && view.state !== filters.state)
|
|
752
|
+
continue;
|
|
753
|
+
if (page !== undefined && views.length === page.limit) {
|
|
754
|
+
next = views.at(-1).id;
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
views.push(view);
|
|
758
|
+
}
|
|
759
|
+
return { agents: views, next };
|
|
664
760
|
});
|
|
665
761
|
}
|
|
666
762
|
/**
|
|
@@ -669,15 +765,15 @@ export async function listEnvironments(ctx, labels, scope) {
|
|
|
669
765
|
*/
|
|
670
766
|
export async function wakePod(ctx, id) {
|
|
671
767
|
if (!ctx.pods)
|
|
672
|
-
fail(409, `
|
|
768
|
+
fail(409, `Agent ${id} runs on a pod, and this Runtime has no sandbox pods.`, { code: "agent_unavailable" });
|
|
673
769
|
await ctx.store.tx(async (t) => {
|
|
674
770
|
const environment = await liveEnvironment(t, id, { lock: true });
|
|
675
771
|
const pod = environment?.pod;
|
|
676
772
|
if (!environment || !pod)
|
|
677
773
|
return;
|
|
678
774
|
if (pod.observed === "lost")
|
|
679
|
-
fail(409, `
|
|
680
|
-
code: "
|
|
775
|
+
fail(409, `Agent ${id}'s pod was lost (${pod.reason ?? "its volume is gone"}). Reset or restore it.`, {
|
|
776
|
+
code: "agent_lost",
|
|
681
777
|
});
|
|
682
778
|
const now = new Date().toISOString();
|
|
683
779
|
if (pod.desired !== "running" || pod.observed === "failed") {
|
|
@@ -695,13 +791,13 @@ export function diskHeld(environment, now = Date.now()) {
|
|
|
695
791
|
/** Refused while a snapshot or restore copies the disk (a pending seed does not count). */
|
|
696
792
|
export function refuseDiskCopy(environment) {
|
|
697
793
|
if (diskHeld(environment) && environment.diskOp.kind !== "seed")
|
|
698
|
-
failRetryLater(`
|
|
794
|
+
failRetryLater(`Agent ${environment.id}'s disk is being copied (${environment.diskOp.kind}). Try again shortly.`, "agent_busy");
|
|
699
795
|
}
|
|
700
796
|
/** Refused while a session that names Environment `id` has a turn running. */
|
|
701
797
|
async function refuseBusy(t, id) {
|
|
702
798
|
const busy = (await t.sessionsOnEnvironment(id)).some((session) => session.activeTurnId !== null);
|
|
703
799
|
if (busy)
|
|
704
|
-
failRetryLater(`
|
|
800
|
+
failRetryLater(`Agent ${id} has a turn running. Cancel it, or wait for it to end.`, "agent_busy");
|
|
705
801
|
}
|
|
706
802
|
/**
|
|
707
803
|
* Deletes `environment` (locked, live) in `t`: its vault and credentials at once, its workspace
|
|
@@ -709,7 +805,11 @@ async function refuseBusy(t, id) {
|
|
|
709
805
|
*/
|
|
710
806
|
async function deleteIn(ctx, t, environment, actor, reason) {
|
|
711
807
|
const id = environment.id;
|
|
712
|
-
await t.
|
|
808
|
+
await t.agentEvent(id, "agent.deleted", { reason });
|
|
809
|
+
// Its triggers and their runs go with it; a timer still armed finds no row.
|
|
810
|
+
await t.deleteAgentTriggers(id);
|
|
811
|
+
// So do its memories and their versions (T5).
|
|
812
|
+
await t.deleteAgentMemories(id);
|
|
713
813
|
await ctx.vault.deleteEnvironmentVault(t, id, actor);
|
|
714
814
|
// Its disk versions go with it; their manifests' bytes are the caller's, after the commit.
|
|
715
815
|
const keys = await deleteDiskArtifact(ctx, t, id);
|
|
@@ -729,12 +829,12 @@ async function deleteKeys(ctx, keys) {
|
|
|
729
829
|
await ctx.blobs.delete(key).catch(() => undefined);
|
|
730
830
|
}
|
|
731
831
|
/**
|
|
732
|
-
* `DELETE /v1/
|
|
832
|
+
* `DELETE /v1/agents/{id}`: deletes the Environment, its vault and its workspace. Refused
|
|
733
833
|
* while a session that names it has an active turn. Sessions keep its id: their next turn is
|
|
734
|
-
* refused (`
|
|
834
|
+
* refused (`agent_unavailable`) unless an Environment with this id exists again.
|
|
735
835
|
*/
|
|
736
|
-
export async function
|
|
737
|
-
reachable(
|
|
836
|
+
export async function deleteAgent(ctx, id, scope) {
|
|
837
|
+
reachable(agentGrantsOf(scope), id);
|
|
738
838
|
const deleted = await ctx.store.tx(async (t) => {
|
|
739
839
|
const environment = await liveEnvironment(t, id, { lock: true });
|
|
740
840
|
if (!environment)
|
|
@@ -755,7 +855,7 @@ export async function deleteEnvironment(ctx, id, scope) {
|
|
|
755
855
|
* change, secret write, stop or reset since. One with a turn running waits for the next sweep.
|
|
756
856
|
* Run by the Tenant sweep.
|
|
757
857
|
*/
|
|
758
|
-
export async function
|
|
858
|
+
export async function sweepUnusedAgents(ctx, now = Date.now()) {
|
|
759
859
|
const due = (await ctx.store.tx((t) => t.listEnvironments())).filter((environment) => {
|
|
760
860
|
const after = environment.recipe.lifecycle.deleteAfter;
|
|
761
861
|
const ms = after === undefined ? undefined : parseSandboxDuration(after);
|
|
@@ -794,15 +894,15 @@ export async function sweepUnusedEnvironments(ctx, now = Date.now()) {
|
|
|
794
894
|
async function podEnvironment(t, id) {
|
|
795
895
|
const environment = (await liveEnvironment(t, id, { lock: true })) ?? notFound();
|
|
796
896
|
if (!environment.pod)
|
|
797
|
-
return fail(400, `
|
|
897
|
+
return fail(400, `Agent ${id} is of kind ${environment.kind}: only a pod Agent stops and resets`);
|
|
798
898
|
return environment;
|
|
799
899
|
}
|
|
800
900
|
/**
|
|
801
|
-
* `POST /v1/
|
|
901
|
+
* `POST /v1/agents/{id}/stop`: suspends a pod Environment. Its volume is kept, and the next
|
|
802
902
|
* turn of a session that names it starts it again. Refused during a turn.
|
|
803
903
|
*/
|
|
804
|
-
export async function
|
|
805
|
-
reachable(
|
|
904
|
+
export async function stopAgent(ctx, id, scope) {
|
|
905
|
+
reachable(agentGrantsOf(scope), id);
|
|
806
906
|
return ctx.store.tx(async (t) => {
|
|
807
907
|
const environment = await podEnvironment(t, id);
|
|
808
908
|
await refuseBusy(t, id);
|
|
@@ -811,7 +911,7 @@ export async function stopEnvironment(ctx, id, scope) {
|
|
|
811
911
|
if (pod.desired === "running" && pod.observed !== "lost") {
|
|
812
912
|
const epoch = pod.hostEpoch + 1;
|
|
813
913
|
await t.updateEnvironmentPod(id, { desired: "suspended", rev: pod.rev + 1, hostEpoch: epoch }, now);
|
|
814
|
-
await t.
|
|
914
|
+
await t.agentEvent(id, "agent.suspended", { reason: "stop" });
|
|
815
915
|
await reconcileAfter(t, id);
|
|
816
916
|
await revokeHost(t, id, epoch);
|
|
817
917
|
}
|
|
@@ -820,23 +920,65 @@ export async function stopEnvironment(ctx, id, scope) {
|
|
|
820
920
|
});
|
|
821
921
|
}
|
|
822
922
|
/**
|
|
823
|
-
* `POST /v1/
|
|
923
|
+
* `POST /v1/agents/{id}/restart` (track T3): applies a pending version now, refused during a
|
|
924
|
+
* turn. With `retry`, it first moves the Agent to the version that failed to start again. A
|
|
925
|
+
* running pod stops, so the next turn starts it on the version; a stopped or failed one is asked
|
|
926
|
+
* to run at once. Kinds `virtual` and `none` apply a version at once anyway.
|
|
927
|
+
*/
|
|
928
|
+
export async function restartAgent(ctx, id, options, scope) {
|
|
929
|
+
reachable(agentGrantsOf(scope), id);
|
|
930
|
+
return ctx.store.tx(async (t) => {
|
|
931
|
+
let agent = (await liveEnvironment(t, id, { lock: true })) ?? notFound();
|
|
932
|
+
await refuseBusy(t, id);
|
|
933
|
+
refuseDiskCopy(agent);
|
|
934
|
+
const now = new Date().toISOString();
|
|
935
|
+
if (options.retry && agent.failedVersion !== undefined) {
|
|
936
|
+
const failed = (await t.agentManifest(agent.manifestName, agent.failedVersion)) ??
|
|
937
|
+
fail(404, `Agent manifest ${agent.manifestName} has no version ${agent.failedVersion}.`, { code: "manifest_not_found" });
|
|
938
|
+
await moveAgent(t, agent, await planMove(t, agent, failed, agent.manifestRef), now);
|
|
939
|
+
agent = (await t.environment(id, { lock: true }));
|
|
940
|
+
}
|
|
941
|
+
const pod = agent.pod;
|
|
942
|
+
if (pod && waitingFor(agent) === "start") {
|
|
943
|
+
if (!ctx.pods)
|
|
944
|
+
fail(409, `Agent ${id} runs on a pod, and this Runtime has no sandbox pods.`, { code: "agent_unavailable" });
|
|
945
|
+
if (pod.observed === "lost")
|
|
946
|
+
fail(409, `Agent ${id}'s pod was lost (${pod.reason ?? "its volume is gone"}). Reset it to continue.`, { code: "agent_lost" });
|
|
947
|
+
if (pod.desired === "running" && pod.observed !== "failed") {
|
|
948
|
+
// Stopped now; the next turn starts it on the version.
|
|
949
|
+
const epoch = pod.hostEpoch + 1;
|
|
950
|
+
await t.updateEnvironmentPod(id, { desired: "suspended", rev: pod.rev + 1, hostEpoch: epoch }, now);
|
|
951
|
+
await t.agentEvent(id, "agent.suspended", { reason: "restart" });
|
|
952
|
+
await reconcileAfter(t, id);
|
|
953
|
+
await revokeHost(t, id, epoch);
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
await wantRunning(t, agent, now);
|
|
957
|
+
await reconcileAfter(t, id);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
await t.updateEnvironment(id, { lastUsedAt: now }, now);
|
|
961
|
+
return viewOf(ctx, t, (await t.environment(id)) ?? agent, subjectOf(scope));
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* `POST /v1/agents/{id}/reset`: a new pod on a new, empty volume (the next volume
|
|
824
966
|
* generation), with a new join token and the recipe applied; the old Sandbox and volume are
|
|
825
967
|
* deleted. Leaves `lost` and `failed`. Refused during a turn, and while an earlier reset's
|
|
826
968
|
* Sandbox is still being deleted.
|
|
827
969
|
*/
|
|
828
|
-
export async function
|
|
829
|
-
reachable(
|
|
970
|
+
export async function resetAgent(ctx, id, scope) {
|
|
971
|
+
reachable(agentGrantsOf(scope), id);
|
|
830
972
|
// A missing Environment is a 404, and one of another kind a 400, with or without sandbox pods.
|
|
831
973
|
await ctx.store.tx((t) => podEnvironment(t, id));
|
|
832
|
-
await requirePods(ctx.pods, "Resetting a pod
|
|
974
|
+
await requirePods(ctx.pods, "Resetting a pod Agent");
|
|
833
975
|
return ctx.store.tx(async (t) => {
|
|
834
976
|
const environment = await podEnvironment(t, id);
|
|
835
977
|
await refuseBusy(t, id);
|
|
836
978
|
refuseDiskCopy(environment);
|
|
837
979
|
const pod = environment.pod;
|
|
838
980
|
if (pod.retiring !== undefined)
|
|
839
|
-
failRetryLater(`
|
|
981
|
+
failRetryLater(`Agent ${id} is still deleting its previous volume. Try again shortly.`, "agent_busy");
|
|
840
982
|
await newVolume(ctx, t, environment);
|
|
841
983
|
return viewOf(ctx, t, (await t.environment(id)) ?? environment, subjectOf(scope));
|
|
842
984
|
});
|
|
@@ -853,6 +995,8 @@ export async function newVolume(ctx, t, environment, options = {}) {
|
|
|
853
995
|
const now = new Date().toISOString();
|
|
854
996
|
const volumeGen = pod.volumeGen + 1;
|
|
855
997
|
const epoch = pod.hostEpoch + 1;
|
|
998
|
+
// A move whose runtime waits for a start switches now (track T3).
|
|
999
|
+
await switchTo(t, environment, now);
|
|
856
1000
|
await t.updateEnvironment(id, {
|
|
857
1001
|
applied: environment.recipe,
|
|
858
1002
|
appliedVersion: environment.version,
|
|
@@ -884,7 +1028,7 @@ export async function newVolume(ctx, t, environment, options = {}) {
|
|
|
884
1028
|
lastActiveAt: now,
|
|
885
1029
|
reason: null,
|
|
886
1030
|
}, now);
|
|
887
|
-
await t.
|
|
1031
|
+
await t.agentEvent(id, "agent.reset", {
|
|
888
1032
|
volumeGeneration: volumeGen,
|
|
889
1033
|
...(options.reason ? { reason: options.reason } : {}),
|
|
890
1034
|
...(options.seed ? { from: { version: options.seed.version } } : {}),
|
|
@@ -893,12 +1037,12 @@ export async function newVolume(ctx, t, environment, options = {}) {
|
|
|
893
1037
|
await revokeHost(t, id, epoch);
|
|
894
1038
|
return volumeGen;
|
|
895
1039
|
}
|
|
896
|
-
/** `GET /v1/
|
|
897
|
-
export async function
|
|
898
|
-
reachable(
|
|
1040
|
+
/** `GET /v1/agents/{id}/events`: the Environment's lifecycle stream. */
|
|
1041
|
+
export async function agentEventsOf(ctx, id, scope, query = {}) {
|
|
1042
|
+
reachable(agentGrantsOf(scope), id);
|
|
899
1043
|
// One read returns at most the Tenant's Runtime limit `environmentEventItems`; `from` reads on.
|
|
900
1044
|
return ctx.store.tx(async (t) => ({
|
|
901
|
-
events: await t.
|
|
1045
|
+
events: await t.agentEvents(id, { ...query, limit: (await readRuntimeLimits(t)).environmentEventItems }),
|
|
902
1046
|
}));
|
|
903
1047
|
}
|
|
904
1048
|
/**
|
|
@@ -910,23 +1054,23 @@ function checkOwner(environment, ownerUserId, opaque) {
|
|
|
910
1054
|
return;
|
|
911
1055
|
if (opaque)
|
|
912
1056
|
notFound();
|
|
913
|
-
fail(403, `
|
|
1057
|
+
fail(403, `Agent ${environment.id} is for another owner's sessions (its owner guard).`, { code: "agent_owner_mismatch" });
|
|
914
1058
|
}
|
|
915
1059
|
/**
|
|
916
1060
|
* Opens a new session on Environment `id` (`PutSessionRequest.environment`): it must exist, be
|
|
917
1061
|
* reachable with `grants` and pass its owner guard. Runs in the session's transaction, after
|
|
918
1062
|
* its session row lock; holds the Environment's row lock.
|
|
919
1063
|
*/
|
|
920
|
-
export async function
|
|
1064
|
+
export async function openAgent(t, id, options) {
|
|
921
1065
|
reachable(options.grants, id);
|
|
922
1066
|
const environment = (await liveEnvironment(t, id, { lock: true })) ?? notFound();
|
|
923
1067
|
checkOwner(environment, options.ownerUserId, options.opaque);
|
|
924
1068
|
return environment;
|
|
925
1069
|
}
|
|
926
1070
|
/** Records the attachment on the session's log and on the Environment's stream. */
|
|
927
|
-
export async function recordAttachment(t, sessionId,
|
|
928
|
-
await t.event(sessionId, null, "
|
|
929
|
-
await t.
|
|
1071
|
+
export async function recordAttachment(t, sessionId, agentId) {
|
|
1072
|
+
await t.event(sessionId, null, "agent.attached", { agentId });
|
|
1073
|
+
await t.agentEvent(agentId, "agent.attached", { sessionId });
|
|
930
1074
|
}
|
|
931
1075
|
/**
|
|
932
1076
|
* Checks a turn start on a session that names an Environment, in the command's transaction
|
|
@@ -935,25 +1079,25 @@ export async function recordAttachment(t, sessionId, environmentId) {
|
|
|
935
1079
|
* do not stop it (D66). Holds the Environment row's lock until the transaction ends, so a turn
|
|
936
1080
|
* start and disk work on one Environment are decided one after the other.
|
|
937
1081
|
*
|
|
938
|
-
* A pod Environment must also be usable: not lost (`
|
|
939
|
-
* sandbox pods (`
|
|
1082
|
+
* A pod Environment must also be usable: not lost (`agent_lost`), on a Runtime with
|
|
1083
|
+
* sandbox pods (`agent_unavailable`). A stopped or failed pod is asked to run again, with
|
|
940
1084
|
* its recipe applied, after the commit.
|
|
941
1085
|
*/
|
|
942
|
-
export async function
|
|
1086
|
+
export async function checkAgentTurn(t, session, scope, ctx) {
|
|
943
1087
|
const id = session.environmentId;
|
|
944
1088
|
if (id === undefined)
|
|
945
1089
|
return;
|
|
946
|
-
const grants =
|
|
947
|
-
if (grants !== undefined && !
|
|
948
|
-
fail(403, `This token does not reach
|
|
1090
|
+
const grants = agentGrantsOf(scope);
|
|
1091
|
+
if (grants !== undefined && !agentGranted(grants, id))
|
|
1092
|
+
fail(403, `This token does not reach Agent ${id}.`, { code: "agent_not_granted" });
|
|
949
1093
|
const environment = await liveEnvironment(t, id, { lock: true });
|
|
950
1094
|
if (!environment)
|
|
951
|
-
return fail(409, `
|
|
952
|
-
code: "
|
|
1095
|
+
return fail(409, `Agent ${id} was deleted. Create it again to continue this session.`, {
|
|
1096
|
+
code: "agent_unavailable",
|
|
953
1097
|
});
|
|
954
1098
|
if (environment.ownerUserId !== undefined && environment.ownerUserId !== session.ownerUserId)
|
|
955
|
-
fail(403, `
|
|
956
|
-
code: "
|
|
1099
|
+
fail(403, `Agent ${id} is for another owner's sessions (its owner guard).`, {
|
|
1100
|
+
code: "agent_owner_mismatch",
|
|
957
1101
|
});
|
|
958
1102
|
// The harness the turn would run on (R4.1, Q9): a pod about to wake runs its recipe's. A
|
|
959
1103
|
// session never moves to another harness: its state is that harness's. An Environment's
|
|
@@ -963,9 +1107,9 @@ export async function checkEnvironmentTurn(t, session, scope, ctx) {
|
|
|
963
1107
|
const waking = environment.pod !== undefined && (environment.pod.desired !== "running" || environment.pod.observed === "failed");
|
|
964
1108
|
const runs = waking ? environment.recipe.harness : environment.applied.harness;
|
|
965
1109
|
if (!isWorkflowManifest(session.manifest) && runs !== harnessOf(session))
|
|
966
|
-
fail(409, `
|
|
1110
|
+
fail(409, `Agent ${id} runs the ${runs} harness, and this session runs ${harnessOf(session)}: open a new session to run on it.`, {
|
|
967
1111
|
code: "harness_mismatch",
|
|
968
|
-
details: { harness: harnessOf(session),
|
|
1112
|
+
details: { harness: harnessOf(session), agentHarness: runs },
|
|
969
1113
|
});
|
|
970
1114
|
if (environment.kind === "none")
|
|
971
1115
|
return;
|
|
@@ -975,12 +1119,12 @@ export async function checkEnvironmentTurn(t, session, scope, ctx) {
|
|
|
975
1119
|
if (!pod)
|
|
976
1120
|
return;
|
|
977
1121
|
if (!ctx?.pods)
|
|
978
|
-
fail(409, `
|
|
979
|
-
code: "
|
|
1122
|
+
fail(409, `Agent ${id} runs on a pod, and this Runtime has no sandbox pods: run \`nylorun local sandbox enable --context <name>\`.`, {
|
|
1123
|
+
code: "agent_unavailable",
|
|
980
1124
|
});
|
|
981
1125
|
if (pod.observed === "lost")
|
|
982
|
-
fail(409, `
|
|
983
|
-
code: "
|
|
1126
|
+
fail(409, `Agent ${id}'s pod was lost (${pod.reason ?? "its volume is gone"}). Reset it to continue.`, {
|
|
1127
|
+
code: "agent_lost",
|
|
984
1128
|
});
|
|
985
1129
|
const now = new Date().toISOString();
|
|
986
1130
|
if (pod.desired !== "running" || pod.observed === "failed") {
|
|
@@ -998,7 +1142,7 @@ export async function detachAllSessions(t) {
|
|
|
998
1142
|
continue;
|
|
999
1143
|
await t.environment(environment.id, { lock: true });
|
|
1000
1144
|
for (const session of sessions)
|
|
1001
|
-
await t.
|
|
1145
|
+
await t.agentEvent(environment.id, "agent.detached", {
|
|
1002
1146
|
sessionId: session.id,
|
|
1003
1147
|
reason: "reset",
|
|
1004
1148
|
});
|
|
@@ -1009,7 +1153,7 @@ export async function detachAllSessions(t) {
|
|
|
1009
1153
|
* from here), and for a running pod idle counts from now and the idle timer is armed (D34).
|
|
1010
1154
|
* Best effort.
|
|
1011
1155
|
*/
|
|
1012
|
-
export async function
|
|
1156
|
+
export async function touchAgent(ctx, id) {
|
|
1013
1157
|
try {
|
|
1014
1158
|
await ctx.store.tx(async (t) => {
|
|
1015
1159
|
const environment = await liveEnvironment(t, id, { lock: true });
|
|
@@ -1021,7 +1165,7 @@ export async function touchEnvironment(ctx, id) {
|
|
|
1021
1165
|
if (!environment.pod || environment.pod.desired !== "running")
|
|
1022
1166
|
return;
|
|
1023
1167
|
await t.updateEnvironmentPod(id, { lastActiveAt: iso }, iso);
|
|
1024
|
-
const idleMs =
|
|
1168
|
+
const idleMs = idleMsOf(await readSandboxConfig(t), environment);
|
|
1025
1169
|
await t.signalSandbox(id, { kind: "arm", timer: "idle", at: now.getTime() + idleMs });
|
|
1026
1170
|
});
|
|
1027
1171
|
}
|
|
@@ -1050,7 +1194,7 @@ export async function rearmPodTimers(ctx) {
|
|
|
1050
1194
|
await ctx.sandboxSignal(row.id, {
|
|
1051
1195
|
kind: "arm",
|
|
1052
1196
|
timer: "idle",
|
|
1053
|
-
at: Date.parse(row.pod.lastActiveAt ?? row.createdAt) +
|
|
1197
|
+
at: Date.parse(row.pod.lastActiveAt ?? row.createdAt) + idleMsOf(config, row),
|
|
1054
1198
|
});
|
|
1055
1199
|
}
|
|
1056
1200
|
catch (error) {
|