@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +1 -1
  3. package/dist/api/a2a/card.d.ts +4 -3
  4. package/dist/api/a2a/card.js +3 -3
  5. package/dist/api/a2a/routes.js +18 -7
  6. package/dist/api/ag-ui/routes.js +4 -7
  7. package/dist/api/components.d.ts +5854 -2718
  8. package/dist/api/components.js +39 -30
  9. package/dist/api/http/app.js +9 -4
  10. package/dist/api/http/body.d.ts +2 -0
  11. package/dist/api/http/body.js +20 -0
  12. package/dist/api/http/define.d.ts +4 -0
  13. package/dist/api/http/define.js +20 -9
  14. package/dist/api/http/routes/agent-manifests.d.ts +10 -0
  15. package/dist/api/http/routes/agent-manifests.js +135 -0
  16. package/dist/api/http/routes/agents.d.ts +11 -0
  17. package/dist/api/http/routes/agents.js +418 -0
  18. package/dist/api/http/routes/artifacts.js +2 -2
  19. package/dist/api/http/routes/coverage.d.ts +4 -4
  20. package/dist/api/http/routes/coverage.js +12 -6
  21. package/dist/api/http/routes/files.js +1 -1
  22. package/dist/api/http/routes/mcp.js +2 -2
  23. package/dist/api/http/routes/me.d.ts +1 -1
  24. package/dist/api/http/routes/me.js +1 -2
  25. package/dist/api/http/routes/memories.d.ts +9 -0
  26. package/dist/api/http/routes/memories.js +152 -0
  27. package/dist/api/http/routes/reads.d.ts +1 -137
  28. package/dist/api/http/routes/reads.js +31 -12
  29. package/dist/api/http/routes/sessions.js +21 -50
  30. package/dist/api/http/routes/tenant.js +13 -10
  31. package/dist/api/http/routes/triggers.d.ts +10 -0
  32. package/dist/api/http/routes/triggers.js +183 -0
  33. package/dist/api/openapi.js +69 -47
  34. package/dist/artifacts/disk.d.ts +10 -10
  35. package/dist/artifacts/disk.js +27 -27
  36. package/dist/artifacts/quota.d.ts +1 -1
  37. package/dist/artifacts/quota.js +2 -2
  38. package/dist/artifacts/service.js +2 -2
  39. package/dist/core/flow-host.d.ts +0 -1
  40. package/dist/gates/egress-credentials.js +1 -1
  41. package/dist/gates/egress.d.ts +5 -5
  42. package/dist/gates/egress.js +6 -6
  43. package/dist/gates/http-tool.js +2 -2
  44. package/dist/gates/meter.js +22 -5
  45. package/dist/gates/policy.d.ts +8 -3
  46. package/dist/gates/policy.js +7 -2
  47. package/dist/gates/tenant-vaults.d.ts +1 -1
  48. package/dist/harness/pod.d.ts +2 -0
  49. package/dist/harness/setup.js +17 -6
  50. package/dist/harness-api/record.js +11 -0
  51. package/dist/harness-api/server.js +4 -0
  52. package/dist/harness-api/start.d.ts +11 -7
  53. package/dist/harness-api/start.js +49 -5
  54. package/dist/harness-api/workspace.js +2 -2
  55. package/dist/host/config-for.d.ts +2 -0
  56. package/dist/host/config-for.js +1 -0
  57. package/dist/host/http.d.ts +2 -2
  58. package/dist/host/http.js +5 -2
  59. package/dist/host/main.js +1 -0
  60. package/dist/keys/keys.d.ts +15 -4
  61. package/dist/keys/keys.js +16 -0
  62. package/dist/keys/webhooks.d.ts +25 -0
  63. package/dist/keys/webhooks.js +62 -0
  64. package/dist/management-openapi.json +313 -133
  65. package/dist/mcp/connect.d.ts +2 -2
  66. package/dist/mcp/connect.js +3 -3
  67. package/dist/mcp/preview.js +7 -7
  68. package/dist/memory/service.d.ts +97 -0
  69. package/dist/memory/service.js +335 -0
  70. package/dist/memory/tool.d.ts +36 -0
  71. package/dist/memory/tool.js +84 -0
  72. package/dist/model/pi-model.js +7 -0
  73. package/dist/openapi.json +13120 -9034
  74. package/dist/reads/types.d.ts +3 -3
  75. package/dist/record/agent.d.ts +27 -0
  76. package/dist/record/agent.js +46 -0
  77. package/dist/record/index.d.ts +1 -1
  78. package/dist/record/index.js +1 -1
  79. package/dist/sandbox/join.js +5 -5
  80. package/dist/sandbox/placement.d.ts +1 -1
  81. package/dist/sandbox/placement.js +3 -3
  82. package/dist/sandbox/pods/lifecycle.d.ts +6 -6
  83. package/dist/sandbox/pods/lifecycle.js +6 -6
  84. package/dist/sandbox/pods/reconcile.js +10 -2
  85. package/dist/sandbox/resolve.d.ts +3 -3
  86. package/dist/sandbox/resolve.js +7 -7
  87. package/dist/sandbox/setup.d.ts +3 -1
  88. package/dist/sandbox/setup.js +14 -4
  89. package/dist/store/postgres/drizzle/0032_agents.sql +54 -0
  90. package/dist/store/postgres/drizzle/0033_agent_versions.sql +7 -0
  91. package/dist/store/postgres/drizzle/0034_protocol_17_session_queue.sql +13 -0
  92. package/dist/store/postgres/drizzle/0035_t4_triggers.sql +41 -0
  93. package/dist/store/postgres/drizzle/0036_t5_memories.sql +30 -0
  94. package/dist/store/postgres/drizzle/0037_model_usage_cost_split.sql +4 -0
  95. package/dist/store/postgres/drizzle/meta/_journal.json +42 -0
  96. package/dist/store/postgres/reads.js +44 -87
  97. package/dist/store/postgres/record-writer.d.ts +4 -4
  98. package/dist/store/postgres/record-writer.js +9 -9
  99. package/dist/store/postgres/schema.d.ts +1408 -123
  100. package/dist/store/postgres/schema.js +194 -38
  101. package/dist/store/postgres/store.js +329 -79
  102. package/dist/store/postgres/tenant.js +1 -2
  103. package/dist/store/types.d.ts +155 -50
  104. package/dist/store/types.js +0 -1
  105. package/dist/tenant/advance.js +20 -8
  106. package/dist/tenant/agent-manifests.d.ts +72 -0
  107. package/dist/tenant/agent-manifests.js +359 -0
  108. package/dist/tenant/agent-versions.d.ts +126 -0
  109. package/dist/tenant/agent-versions.js +389 -0
  110. package/dist/tenant/agents.d.ts +230 -0
  111. package/dist/tenant/{environments.js → agents.js} +464 -320
  112. package/dist/tenant/auth.d.ts +1 -1
  113. package/dist/tenant/auth.js +3 -3
  114. package/dist/tenant/commands.d.ts +63 -2
  115. package/dist/tenant/commands.js +240 -83
  116. package/dist/tenant/context.d.ts +46 -19
  117. package/dist/tenant/context.js +6 -2
  118. package/dist/tenant/effects.js +7 -16
  119. package/dist/tenant/identity-file.d.ts +6 -6
  120. package/dist/tenant/identity-file.js +12 -10
  121. package/dist/tenant/issuers.js +3 -3
  122. package/dist/tenant/limits.d.ts +0 -3
  123. package/dist/tenant/limits.js +0 -3
  124. package/dist/tenant/memories.d.ts +38 -0
  125. package/dist/tenant/memories.js +119 -0
  126. package/dist/tenant/reset.js +2 -2
  127. package/dist/tenant/run-grants.d.ts +6 -0
  128. package/dist/tenant/run-grants.js +4 -0
  129. package/dist/tenant/runtime.d.ts +1 -1
  130. package/dist/tenant/runtime.js +8 -0
  131. package/dist/tenant/sessions.d.ts +28 -45
  132. package/dist/tenant/sessions.js +85 -208
  133. package/dist/tenant/status.js +6 -7
  134. package/dist/tenant/sweep.js +12 -3
  135. package/dist/tenant/transcript.d.ts +16 -16
  136. package/dist/tenant/triggers.d.ts +143 -0
  137. package/dist/tenant/triggers.js +715 -0
  138. package/dist/tenant/turn-timing.d.ts +11 -1
  139. package/dist/tenant/turn-timing.js +3 -1
  140. package/dist/tenant/types.d.ts +5 -0
  141. package/dist/tenant/worker.d.ts +2 -0
  142. package/dist/tenant/worker.js +5 -0
  143. package/dist/traces/map.d.ts +1 -1
  144. package/dist/traces/map.js +7 -1
  145. package/dist/tuning.d.ts +7 -0
  146. package/dist/tuning.js +19 -0
  147. package/dist/vault/service.d.ts +13 -8
  148. package/dist/vault/service.js +61 -17
  149. package/dist/version.d.ts +1 -1
  150. package/dist/version.js +1 -1
  151. package/package.json +13 -12
  152. package/dist/api/http/routes/environment-manifests.d.ts +0 -9
  153. package/dist/api/http/routes/environment-manifests.js +0 -118
  154. package/dist/api/http/routes/environments.d.ts +0 -11
  155. package/dist/api/http/routes/environments.js +0 -332
  156. package/dist/record/environment.d.ts +0 -27
  157. package/dist/record/environment.js +0 -46
  158. package/dist/tenant/environment-manifests.d.ts +0 -57
  159. package/dist/tenant/environment-manifests.js +0 -208
  160. package/dist/tenant/environments.d.ts +0 -172
@@ -1,49 +1,53 @@
1
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.
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
- * - **No scope.** The Runtime decides nothing about who uses an Environment: the app maps its
7
- * people and agents to ids. It stores the id, kind, recipe, labels and the optional owner
8
- * guard; the session body names the Environment (`Session.environmentId`), and deleting a
9
- * session (a sessions reset) only detaches it.
10
- * - **One sandbox, one vault.** Kind `virtual` (a just-bash workspace in the harness container,
11
- * keyed by the Environment id and created on the first tool call), `pod` (an agent-sandbox pod
12
- * on the Tenant's cluster, Host feature `sandbox-pods`, created at once) or `none` (no compute).
13
- * Each has one vault (`vault/service.ts`), created and deleted with it in one transaction.
14
- * - **Made once, never changed** (protocol 14, D63). An Environment is made from one version of
15
- * an environment manifest (`manifest: "name"` or `"name@n"`, `environment-manifests.ts`) or
16
- * from an inline manifest (the fields of the `PUT`, its `recipe`), and keeps what it runs for
17
- * life: a `PUT` on an existing id may change its labels and owner guard, a field that matches
18
- * what it runs is accepted (so get-or-create stays one call), and anything else is
19
- * `409 environment_immutable`, naming the fork that makes a new Environment instead.
20
- * `version` and `appliedVersion` stay 1 for an Environment made since then.
21
- * - **Secret slots** (D64): when what it runs declares `secrets`, a secret write sends only the
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 Environment, and so does an application key
25
- * acting for a subject (`Nylorun-Subject`), which needs `environments:write` to change one. A
26
- * token caller reaches only the ids its grants match, and changes them only with
27
- * `environments:write`; any other id is the 404 of a missing one.
28
- * - **The owner guard.** When `ownerUserId` is set, only that owner's sessions may name the
29
- * Environment, checked when a session opens and at every turn start (`403
30
- * environment_owner_mismatch`; the opaque 404 to a caller acting for a person).
31
- * - **Parallel turns (D66).** Sessions on one Environment run their turns at the same time and
32
- * share its `/workspace`; one turn per session stays. Which sessions work on one disk, and when,
33
- * is the app's call. Disk work (snapshot, restore, stop, reset) waits until no turn runs
34
- * (`409 environment_busy`), and a turn waits while a seed copies a disk.
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
- * (`environment_lost`) until a reset. `lifecycle.deleteAfter` deletes an Environment unused
38
- * for that long (the Tenant sweep, `sweepUnusedEnvironments`). Deleting a pod Environment keeps
39
- * its row (`desired: deleted`, invisible here) until its Sandbox is gone; its vault goes at once.
40
- * - **Lifecycle events** go to the Environment's own stream in the record (`Tx.environmentEvent`,
41
- * `record/environment.ts`); the session's log records `environment.attached` too.
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 { environmentGranted, parseEnvironmentManifestRef, PutEnvironmentSecretRequestSchema, PutEnvironmentSecretValueRequestSchema, } from "@nylorun/core/contracts";
45
- import { hashManifest } from "@nylorun/core/compatibility";
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 "./environment-manifests.js";
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 Environment grants that limit `scope`, or undefined when it reaches every Environment. */
63
- export function environmentGrantsOf(scope) {
64
- return scope.kind === "token" ? (scope.environments ?? []) : undefined;
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, "Environment not found");
75
- /** The Environment, when `grants` reach it; otherwise the 404 of a missing one. */
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 && !environmentGranted(grants, id))
84
+ if (grants !== undefined && !agentGranted(grants, id))
78
85
  notFound();
79
86
  }
80
- /** Refuses (the 404 of a missing one) Environment `id` when the caller's grants miss it. */
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(environmentGrantsOf(scope), id);
89
+ reachable(agentGrantsOf(scope), id);
83
90
  }
84
- /** A pod Environment being deleted is no longer an Environment, here. */
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 Environment, unless it is gone or being deleted. */
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 slots = await slotsOf(t, environment);
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
- manifest: environment.recipe,
112
- ...(environment.manifestName === undefined ? {} : { manifestName: environment.manifestName }),
113
- ...(environment.manifestVersion === undefined ? {} : { manifestVersion: environment.manifestVersion }),
114
- ...(environment.manifestHash === undefined ? {} : { manifestHash: environment.manifestHash }),
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
- ...(environment.forkedFrom === undefined ? {} : { forkedFrom: environment.forkedFrom }),
117
- ...(slots === undefined ? {} : { slots }),
118
- version: environment.version,
119
- appliedVersion: environment.appliedVersion,
120
- ownerUserId: environment.ownerUserId ?? null,
121
- state: pod ? podState(pod) : environment.kind === "none" ? "ready" : (record?.state ?? "ready"),
122
- ...(pod
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
- ...(diskHeld(environment)
205
+ operation: diskHeld(environment)
141
206
  ? {
142
- operation: {
143
- kind: environment.diskOp.kind,
144
- ...(environment.diskOp.version === undefined ? {} : { version: environment.diskOp.version }),
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
- sessions: sessions
152
- .filter((session) => owner === undefined || session.ownerUserId === owner)
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
- /** Each secret slot `environment` declares, and whether its vault holds a value for it. */
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.recipe.secrets;
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 held = new Set(vault ? (await t.credentialsForVault(vault.id)).map((row) => row.name) : []);
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]) => ({ name, type: slot.type, required: slot.required ?? false, set: held.has(name) }));
168
- }
169
- /** The inline fields of a `PUT`: what it would run, besides labels, owner and `from`. */
170
- const INLINE_FIELDS = [
171
- "kind",
172
- "image",
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 ownerUserId only to that person.");
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
- if (environment.appliedVersion !== environment.version)
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/environments/{id}`: creates the Environment and its vault (within the Tenant's limit
284
- * on their number) from an environment manifest's version or from the inline fields, or finds
285
- * the one with this id, so "get or create" is one call. An existing one never changes what it
286
- * runs (D63): its labels and owner guard may change, fields that match are accepted, and any
287
- * other difference is `409 environment_immutable`. Every `PUT` counts as the Environment's use.
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 putEnvironment(ctx, id, body, scope, options = {}) {
332
+ export async function putAgent(ctx, id, body, scope, options = {}) {
290
333
  const fork = options.fork;
291
- reachable(environmentGrantsOf(scope), id);
292
- const ref = body.manifest === undefined ? undefined : parseEnvironmentManifestRef(body.manifest);
293
- if (body.manifest !== undefined) {
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
- // `limits` and the manifest's shape are checked against the Tenant's only when an Environment
317
- // is made: a get-or-create on one made before the Tenant lowered a limit still answers, and
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(`Environment ${id} is being deleted. Try again once it is gone.`, "environment_busy");
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, `Environment ${id} exists: a fork makes a new Environment, so name another id.`);
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, `Environment ${id} exists: from applies only when a PUT creates one.`);
339
- if (ref !== undefined) {
340
- // A bare name matches the manifest it was made from, whatever its latest version is now.
341
- if (ref.name !== existing.manifestName)
342
- immutable(["manifest"], existing);
343
- if (ref.version !== undefined && ref.version !== existing.manifestVersion)
344
- immutable(["manifest"], existing);
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.ownerUserId !== undefined && (body.ownerUserId ?? undefined) !== existing.ownerUserId)
363
- patch.ownerUserId = body.ownerUserId;
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.ownerUserId);
372
- const manifest = ref === undefined
373
- ? undefined
374
- : ((await t.environmentManifest(ref.name, ref.version)) ??
375
- fail(404, ref.version === undefined
376
- ? `No environment manifest is named ${ref.name}: save it first (PUT /v1/environment-manifests/${ref.name}).`
377
- : `Environment manifest ${ref.name} has no version ${ref.version}.`, { code: "manifest_not_found" }));
378
- const kind = manifest ? (manifest.body.kind ?? "virtual") : (body.kind ?? "virtual");
379
- if (manifest && kind === "pod")
380
- await requirePods(ctx.pods, `Environment manifest ${manifest.name} is of kind pod, and a pod Environment`);
381
- const recipe = resolve(kind, manifest ? manifestRecipeRequest(manifest.body) : recipeRequest(body), true);
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.ownerUserId ? { ownerUserId: body.ownerUserId } : {}),
406
+ ...(body.owner ? { ownerUserId: body.owner } : {}),
395
407
  lastUsedAt: now,
396
408
  createdAt: now,
397
409
  updatedAt: now,
398
- ...(manifest ? { manifestName: manifest.name, manifestVersion: manifest.version } : {}),
399
- manifestHash: recipeHash(recipe),
400
- ...(fork ? { forkedFrom: { environment: fork.source, disk: seed?.version ?? null } } : {}),
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} 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
- const madeFrom = manifest ? { manifest: { name: manifest.name, version: manifest.version } } : {};
443
- await t.environmentEvent(id, "environment.created", {
462
+ await t.agentEvent(id, "agent.created", {
444
463
  kind,
445
464
  labels: row.labels,
446
- ...madeFrom,
447
- ...(seed ? { from: { environment: body.from.environment, version: seed.version } } : {}),
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.environmentEvent(fork.source, "environment.forked", {
470
+ await t.agentEvent(fork.source, "agent.forked", {
452
471
  to: id,
453
472
  disk: seed?.version ?? null,
454
- ...madeFrom,
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 getEnvironment(ctx, id, scope);
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) => liveEnvironment(t, id));
488
- const slots = environment?.recipe.secrets;
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 PutEnvironmentSecretRequestSchema.parse(raw);
558
+ return PutAgentSecretRequestSchema.parse(raw);
559
+ const named = `${environment.manifestName}@${environment.targetVersion ?? environment.manifestVersion}`;
491
560
  const slot = slots[name] ??
492
- fail(409, `What Environment ${id} runs declares no secret ${name}${environment.manifestName ? ` (manifest ${environment.manifestName}@${environment.manifestVersion})` : ""}: add the slot to the manifest, then make or fork an Environment from the new version.`, { code: "secret_not_in_manifest", details: { declared: Object.keys(slots).sort() } });
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 = PutEnvironmentSecretRequestSchema.parse(raw);
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 what Environment ${id} runs: send only its value ({ "value": "…" }).`, {
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 } = PutEnvironmentSecretValueRequestSchema.parse(raw);
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 PutEnvironmentSecretRequestSchema.parse({ ...withId, ...full, headers });
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 PutEnvironmentSecretRequestSchema.parse({ ...withId, ...full, value });
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 Environment with a disk: kind none has none.");
526
- reachable(environmentGrantsOf(scope), from.environment);
527
- const source = (await liveEnvironment(t, from.environment)) ?? notFound();
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/environments/{id}/fork` (Q15): how an Environment gets a new manifest version. A new
535
- * Environment `body.id` is made from the manifest version (default the source's manifest at its
536
- * latest), its disk started from a version of the source's (`latest` takes a snapshot first,
537
- * refused during a turn), and its secrets copied inside the Runtime. A fork onto other setup
538
- * runs that setup on the copied disk before its first turn (Q17). The source keeps what it runs,
539
- * its disk and its sessions. With `dryRun`, the plan only.
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 forkEnvironment(ctx, sourceId, body, scope) {
542
- const grants = environmentGrantsOf(scope);
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 Environment: give it another id.");
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 ? undefined : parseEnvironmentManifestRef(body.manifest);
553
- if (body.manifest !== undefined && ref === undefined)
554
- fail(400, "manifest is an environment manifest's name, or name@version such as analyst-box@3.");
555
- const name = ref?.name ??
556
- source.manifestName ??
557
- fail(400, `Environment ${sourceId} was made from inline fields, not a manifest: name the manifest to fork it onto (manifest).`);
558
- const manifest = (await t.environmentManifest(name, ref?.version)) ??
559
- fail(404, ref?.version === undefined
560
- ? `No environment manifest is named ${name}.`
561
- : `Environment manifest ${name} has no version ${ref.version}.`, { code: "manifest_not_found" });
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(manifest.body.limits, await readTenantLimits(t)),
567
- ...manifestShapeIssues(manifest.body, await readRuntimeLimits(t)),
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
- recipeHash(recipe) !== source.manifestHash &&
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.recipe, recipe),
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 = { source: sourceId, setup: planned.setup, ...(sealed ? { sealed } : {}) };
621
- const environment = await putEnvironment(ctx, body.id, {
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
- manifest: `${planned.manifest.name}@${planned.manifest.version}`,
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.ownerUserId === undefined ? {} : { ownerUserId: body.ownerUserId }),
626
- ...(disk === null ? {} : { from: { environment: sourceId, version: disk } }),
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
- environment,
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(environment.manifest.secrets ?? {})
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/environments/{id}`. */
647
- export async function getEnvironment(ctx, id, scope) {
648
- reachable(environmentGrantsOf(scope), id);
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
- /** `GET /v1/environments?label=k=v`: every Environment with all the labels, that the caller reaches. */
655
- export async function listEnvironments(ctx, labels, scope) {
656
- const grants = environmentGrantsOf(scope);
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
- for (const environment of found)
661
- if (live(environment) && (grants === undefined || environmentGranted(grants, environment.id)))
662
- views.push(await viewOf(ctx, t, environment, subjectOf(scope)));
663
- return { environments: views };
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, `Environment ${id} is a pod Environment, and this Runtime has no sandbox pods.`, { code: "environment_unavailable" });
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, `Environment ${id} was lost (${pod.reason ?? "its volume is gone"}). Reset or restore it.`, {
680
- code: "environment_lost",
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(`Environment ${environment.id}'s disk is being copied (${environment.diskOp.kind}). Try again shortly.`, "environment_busy");
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(`Environment ${id} has a turn running. Cancel it, or wait for it to end.`, "environment_busy");
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.environmentEvent(id, "environment.deleted", { reason });
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/environments/{id}`: deletes the Environment, its vault and its workspace. Refused
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 (`environment_unavailable`) unless an Environment with this id exists again.
834
+ * refused (`agent_unavailable`) unless an Environment with this id exists again.
735
835
  */
736
- export async function deleteEnvironment(ctx, id, scope) {
737
- reachable(environmentGrantsOf(scope), id);
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 sweepUnusedEnvironments(ctx, now = Date.now()) {
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, `Environment ${id} is of kind ${environment.kind}: only a pod Environment stops and resets`);
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/environments/{id}/stop`: suspends a pod Environment. Its volume is kept, and the next
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 stopEnvironment(ctx, id, scope) {
805
- reachable(environmentGrantsOf(scope), id);
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.environmentEvent(id, "environment.suspended", { reason: "stop" });
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/environments/{id}/reset`: a new pod on a new, empty volume (the next volume
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 resetEnvironment(ctx, id, scope) {
829
- reachable(environmentGrantsOf(scope), id);
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 Environment");
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(`Environment ${id} is still deleting its previous volume. Try again shortly.`, "environment_busy");
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.environmentEvent(id, "environment.reset", {
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/environments/{id}/events`: the Environment's lifecycle stream. */
897
- export async function environmentEventsOf(ctx, id, scope, query = {}) {
898
- reachable(environmentGrantsOf(scope), id);
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.environmentEvents(id, { ...query, limit: (await readRuntimeLimits(t)).environmentEventItems }),
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, `Environment ${environment.id} is for another owner's sessions (its ownerUserId guard).`, { code: "environment_owner_mismatch" });
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 openEnvironment(t, id, options) {
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, environmentId) {
928
- await t.event(sessionId, null, "environment.attached", { environmentId });
929
- await t.environmentEvent(environmentId, "environment.attached", { sessionId });
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 (`environment_lost`), on a Runtime with
939
- * sandbox pods (`environment_unavailable`). A stopped or failed pod is asked to run again, with
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 checkEnvironmentTurn(t, session, scope, ctx) {
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 = environmentGrantsOf(scope);
947
- if (grants !== undefined && !environmentGranted(grants, id))
948
- fail(403, `This token does not reach Environment ${id}.`, { code: "environment_not_granted" });
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, `Environment ${id} was deleted. Create it again to continue this session.`, {
952
- code: "environment_unavailable",
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, `Environment ${id} is for another owner's sessions (its ownerUserId guard).`, {
956
- code: "environment_owner_mismatch",
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, `Environment ${id} runs the ${runs} harness, and this session runs ${harnessOf(session)}: open a new session to run on it.`, {
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), environmentHarness: runs },
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, `Environment ${id} is a pod Environment, and this Runtime has no sandbox pods: run \`nylorun local sandbox enable --context <name>\`.`, {
979
- code: "environment_unavailable",
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, `Environment ${id} was lost (${pod.reason ?? "its volume is gone"}). Reset it to continue.`, {
983
- code: "environment_lost",
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.environmentEvent(environment.id, "environment.detached", {
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 touchEnvironment(ctx, id) {
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 = podLifecycleConfig(await readSandboxConfig(t), environment.recipe).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) + podLifecycleConfig(config, row.recipe).idleMs,
1197
+ at: Date.parse(row.pod.lastActiveAt ?? row.createdAt) + idleMsOf(config, row),
1054
1198
  });
1055
1199
  }
1056
1200
  catch (error) {