@nylorun/runtime 0.22.0-beta → 0.24.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/README.md +95 -30
  3. package/dist/adapters/execution/restate.d.ts +15 -3
  4. package/dist/adapters/execution/restate.js +78 -84
  5. package/dist/adapters/replication/pgoutput.d.ts +2 -0
  6. package/dist/adapters/replication/pgoutput.js +6 -3
  7. package/dist/adapters/sandbox/local.d.ts +9 -1
  8. package/dist/adapters/sandbox/local.js +16 -4
  9. package/dist/adapters/sandbox/virtual.js +4 -1
  10. package/dist/adapters/streams/s2.js +1 -13
  11. package/dist/api/a2a/endpoint.js +5 -1
  12. package/dist/api/a2a/routes.d.ts +2 -0
  13. package/dist/api/a2a/routes.js +2 -2
  14. package/dist/api/a2a/tasks.js +2 -1
  15. package/dist/api/ag-ui/endpoint.js +4 -2
  16. package/dist/api/ag-ui/history.js +4 -1
  17. package/dist/api/ag-ui/routes.d.ts +4 -1
  18. package/dist/api/ag-ui/routes.js +11 -10
  19. package/dist/api/ag-ui/translate.js +3 -2
  20. package/dist/api/components.d.ts +4953 -1204
  21. package/dist/api/components.js +25 -16
  22. package/dist/api/gate/keys.js +7 -1
  23. package/dist/api/gate/routes.d.ts +6 -0
  24. package/dist/api/gate/routes.js +39 -3
  25. package/dist/api/http/app.js +4 -4
  26. package/dist/api/http/define.d.ts +6 -0
  27. package/dist/api/http/define.js +13 -1
  28. package/dist/api/http/respond.js +1 -1
  29. package/dist/api/http/routes/artifacts.js +35 -8
  30. package/dist/api/http/routes/coverage.d.ts +9 -0
  31. package/dist/api/http/routes/coverage.js +34 -0
  32. package/dist/api/http/routes/environments.d.ts +11 -0
  33. package/dist/api/http/routes/environments.js +283 -0
  34. package/dist/api/http/routes/mcp.d.ts +1 -1
  35. package/dist/api/http/routes/mcp.js +2 -2
  36. package/dist/api/http/routes/me.js +1 -1
  37. package/dist/api/http/routes/reads.d.ts +45 -9
  38. package/dist/api/http/routes/reads.js +54 -5
  39. package/dist/api/http/routes/sessions.js +11 -6
  40. package/dist/api/http/routes/tenant.js +32 -10
  41. package/dist/api/openapi.js +31 -26
  42. package/dist/artifacts/disk.d.ts +59 -0
  43. package/dist/artifacts/disk.js +563 -0
  44. package/dist/artifacts/export.d.ts +1 -1
  45. package/dist/artifacts/export.js +2 -3
  46. package/dist/artifacts/folders.d.ts +6 -3
  47. package/dist/artifacts/folders.js +17 -12
  48. package/dist/artifacts/service.d.ts +2 -1
  49. package/dist/artifacts/service.js +6 -1
  50. package/dist/artifacts/workspace.d.ts +1 -1
  51. package/dist/artifacts/workspace.js +1 -1
  52. package/dist/core/flow-host.d.ts +3 -22
  53. package/dist/core/flow-host.js +11 -37
  54. package/dist/core/provider.d.ts +3 -2
  55. package/dist/core/provider.js +5 -37
  56. package/dist/core/sandbox-routes.d.ts +2 -21
  57. package/dist/core/sandbox-routes.js +10 -43
  58. package/dist/execution/types.d.ts +11 -9
  59. package/dist/execution/types.js +5 -7
  60. package/dist/gates/contract.d.ts +3 -24
  61. package/dist/gates/contract.js +1 -36
  62. package/dist/gates/egress-credentials.d.ts +9 -0
  63. package/dist/gates/egress-credentials.js +11 -4
  64. package/dist/gates/egress.d.ts +28 -1
  65. package/dist/gates/egress.js +115 -10
  66. package/dist/gates/fixture-model.d.ts +28 -0
  67. package/dist/gates/fixture-model.js +92 -0
  68. package/dist/gates/handler.js +4 -4
  69. package/dist/gates/http-tool.d.ts +6 -2
  70. package/dist/gates/http-tool.js +3 -5
  71. package/dist/gates/in-process.d.ts +5 -0
  72. package/dist/gates/in-process.js +12 -1
  73. package/dist/gates/journal.d.ts +30 -0
  74. package/dist/gates/journal.js +52 -0
  75. package/dist/gates/mcp-handler.d.ts +4 -0
  76. package/dist/gates/mcp-handler.js +5 -1
  77. package/dist/gates/meter.d.ts +10 -2
  78. package/dist/gates/meter.js +20 -5
  79. package/dist/gates/policy.d.ts +49 -0
  80. package/dist/gates/policy.js +134 -0
  81. package/dist/gates/tenant-vaults.d.ts +7 -7
  82. package/dist/gates/tenant-vaults.js +5 -5
  83. package/dist/gates/tool-calls.d.ts +10 -5
  84. package/dist/gates/tool-calls.js +28 -10
  85. package/dist/gates/tool-client.d.ts +8 -0
  86. package/dist/gates/tool-client.js +27 -11
  87. package/dist/gates/tool-contract.d.ts +10 -68
  88. package/dist/gates/tool-contract.js +1 -85
  89. package/dist/gates/tool-gate.d.ts +1 -1
  90. package/dist/harness/calls.d.ts +12 -5
  91. package/dist/harness/calls.js +21 -12
  92. package/dist/harness/executors.js +12 -9
  93. package/dist/harness/main.js +18 -5
  94. package/dist/harness/pod.d.ts +34 -0
  95. package/dist/harness/pod.js +41 -0
  96. package/dist/harness/service.d.ts +0 -1
  97. package/dist/harness/service.js +13 -5
  98. package/dist/harness/setup.d.ts +27 -0
  99. package/dist/harness/setup.js +150 -0
  100. package/dist/harness/ws-client.js +3 -2
  101. package/dist/harness/ws-port.d.ts +1 -5
  102. package/dist/harness/ws-port.js +1 -5
  103. package/dist/harness-api/in-process.js +3 -3
  104. package/dist/harness-api/record.d.ts +15 -1
  105. package/dist/harness-api/record.js +174 -44
  106. package/dist/harness-api/redact.d.ts +3 -0
  107. package/dist/harness-api/redact.js +45 -0
  108. package/dist/harness-api/server.d.ts +8 -0
  109. package/dist/harness-api/server.js +93 -13
  110. package/dist/harness-api/start.d.ts +10 -3
  111. package/dist/harness-api/start.js +40 -10
  112. package/dist/harness-api/tool-journal.d.ts +96 -0
  113. package/dist/harness-api/tool-journal.js +102 -0
  114. package/dist/harness-api/tool-status.d.ts +13 -0
  115. package/dist/harness-api/tool-status.js +26 -0
  116. package/dist/harness-api/workspace.d.ts +6 -3
  117. package/dist/harness-api/workspace.js +27 -11
  118. package/dist/harness-api/ws-server.d.ts +11 -3
  119. package/dist/harness-api/ws-server.js +94 -15
  120. package/dist/host/execution.d.ts +8 -6
  121. package/dist/host/gates.d.ts +2 -1
  122. package/dist/host/gates.js +10 -2
  123. package/dist/host/main.js +64 -21
  124. package/dist/host/operate.js +67 -5
  125. package/dist/host/stack-config.d.ts +70 -0
  126. package/dist/host/stack-config.js +67 -1
  127. package/dist/infra/execution.d.ts +18 -1
  128. package/dist/infra/execution.js +28 -4
  129. package/dist/infra/index.d.ts +1 -1
  130. package/dist/keys/client.js +4 -1
  131. package/dist/keys/keys.d.ts +5 -5
  132. package/dist/keys/keys.js +5 -7
  133. package/dist/management-openapi.json +999 -2211
  134. package/dist/mcp/connect.d.ts +25 -20
  135. package/dist/mcp/connect.js +29 -30
  136. package/dist/mcp/pool.d.ts +2 -2
  137. package/dist/mcp/pool.js +1 -1
  138. package/dist/mcp/preview.d.ts +3 -3
  139. package/dist/mcp/preview.js +10 -20
  140. package/dist/mcp/snapshot.d.ts +5 -11
  141. package/dist/mcp/snapshot.js +6 -7
  142. package/dist/model/pi-model.js +8 -0
  143. package/dist/openapi.json +6955 -2526
  144. package/dist/reads/types.d.ts +16 -3
  145. package/dist/record/append.d.ts +3 -1
  146. package/dist/record/append.js +1 -0
  147. package/dist/record/envelope.d.ts +5 -1
  148. package/dist/record/envelope.js +7 -3
  149. package/dist/record/environment.d.ts +25 -0
  150. package/dist/record/environment.js +44 -0
  151. package/dist/record/index.d.ts +1 -1
  152. package/dist/record/index.js +1 -1
  153. package/dist/retry.d.ts +21 -0
  154. package/dist/retry.js +34 -0
  155. package/dist/sandbox/disk-fs.d.ts +95 -0
  156. package/dist/sandbox/disk-fs.js +206 -0
  157. package/dist/sandbox/join.d.ts +17 -18
  158. package/dist/sandbox/join.js +46 -20
  159. package/dist/sandbox/manager.d.ts +16 -8
  160. package/dist/sandbox/manager.js +33 -17
  161. package/dist/sandbox/placement.d.ts +13 -12
  162. package/dist/sandbox/placement.js +29 -12
  163. package/dist/sandbox/pods/client.d.ts +6 -1
  164. package/dist/sandbox/pods/lifecycle.d.ts +28 -27
  165. package/dist/sandbox/pods/lifecycle.js +14 -26
  166. package/dist/sandbox/pods/reconcile.js +21 -15
  167. package/dist/sandbox/pods/spec.d.ts +15 -13
  168. package/dist/sandbox/pods/spec.js +16 -20
  169. package/dist/sandbox/records.d.ts +2 -2
  170. package/dist/sandbox/records.js +3 -3
  171. package/dist/sandbox/resolve.d.ts +35 -27
  172. package/dist/sandbox/resolve.js +66 -41
  173. package/dist/sandbox/session-sandbox.js +1 -1
  174. package/dist/sandbox/setup.d.ts +61 -0
  175. package/dist/sandbox/setup.js +123 -0
  176. package/dist/sandbox/share.d.ts +11 -21
  177. package/dist/sandbox/share.js +12 -41
  178. package/dist/sandbox/tenant-config.d.ts +12 -14
  179. package/dist/sandbox/tenant-config.js +12 -17
  180. package/dist/sandbox/types.d.ts +6 -0
  181. package/dist/store/postgres/control.js +8 -1
  182. package/dist/store/postgres/drizzle/0018_wake_outbox_retries.sql +3 -0
  183. package/dist/store/postgres/drizzle/0019_sandbox_signal_outbox.sql +18 -0
  184. package/dist/store/postgres/drizzle/0020_environments.sql +63 -0
  185. package/dist/store/postgres/drizzle/0021_disk_versions.sql +7 -0
  186. package/dist/store/postgres/drizzle/0022_model_usage_timing.sql +5 -0
  187. package/dist/store/postgres/drizzle/0023_artifact_pages.sql +1 -0
  188. package/dist/store/postgres/drizzle/meta/_journal.json +42 -0
  189. package/dist/store/postgres/reads.js +117 -17
  190. package/dist/store/postgres/record-writer.d.ts +4 -4
  191. package/dist/store/postgres/record-writer.js +9 -9
  192. package/dist/store/postgres/record.d.ts +7 -0
  193. package/dist/store/postgres/record.js +23 -2
  194. package/dist/store/postgres/schema.d.ts +1213 -779
  195. package/dist/store/postgres/schema.js +136 -76
  196. package/dist/store/postgres/store.js +283 -117
  197. package/dist/store/types.d.ts +206 -88
  198. package/dist/streams/relay/core.js +1 -13
  199. package/dist/tenant/advance.d.ts +7 -6
  200. package/dist/tenant/advance.js +121 -49
  201. package/dist/tenant/artifact-tool.js +3 -4
  202. package/dist/tenant/commands.d.ts +15 -2
  203. package/dist/tenant/commands.js +94 -14
  204. package/dist/tenant/context.d.ts +67 -33
  205. package/dist/tenant/context.js +4 -15
  206. package/dist/tenant/control.d.ts +8 -1
  207. package/dist/tenant/control.js +2 -0
  208. package/dist/tenant/effects.d.ts +1 -1
  209. package/dist/tenant/effects.js +37 -20
  210. package/dist/tenant/environments.d.ts +167 -0
  211. package/dist/tenant/environments.js +823 -0
  212. package/dist/tenant/harness-tokens.d.ts +21 -0
  213. package/dist/tenant/harness-tokens.js +61 -0
  214. package/dist/tenant/host-token.d.ts +1 -1
  215. package/dist/tenant/host-token.js +2 -4
  216. package/dist/tenant/identity-file.d.ts +4 -4
  217. package/dist/tenant/identity-file.js +17 -8
  218. package/dist/tenant/issuers.js +3 -3
  219. package/dist/tenant/principals.js +1 -1
  220. package/dist/tenant/reset.js +3 -3
  221. package/dist/tenant/runtime.d.ts +6 -1
  222. package/dist/tenant/runtime.js +30 -4
  223. package/dist/tenant/scheduler.d.ts +7 -0
  224. package/dist/tenant/scheduler.js +38 -7
  225. package/dist/tenant/session-streams.d.ts +0 -1
  226. package/dist/tenant/session-streams.js +1 -13
  227. package/dist/tenant/session.d.ts +3 -3
  228. package/dist/tenant/session.js +0 -14
  229. package/dist/tenant/sessions.d.ts +13 -13
  230. package/dist/tenant/sessions.js +61 -138
  231. package/dist/tenant/signing-keys.d.ts +5 -1
  232. package/dist/tenant/status.js +3 -3
  233. package/dist/tenant/streams.js +14 -29
  234. package/dist/tenant/sweep.d.ts +39 -12
  235. package/dist/tenant/sweep.js +168 -63
  236. package/dist/tenant/transcript.d.ts +210 -20
  237. package/dist/tenant/transcript.js +62 -11
  238. package/dist/tenant/turn-timing.d.ts +43 -0
  239. package/dist/tenant/turn-timing.js +39 -0
  240. package/dist/tenant/types.d.ts +2 -0
  241. package/dist/traces/ids.d.ts +42 -0
  242. package/dist/traces/ids.js +85 -0
  243. package/dist/traces/key.d.ts +6 -0
  244. package/dist/traces/key.js +37 -0
  245. package/dist/traces/map.d.ts +113 -0
  246. package/dist/traces/map.js +317 -0
  247. package/dist/traces/options.d.ts +7 -0
  248. package/dist/traces/options.js +1 -0
  249. package/dist/traces/otlp.d.ts +42 -0
  250. package/dist/traces/otlp.js +84 -0
  251. package/dist/traces/projector.d.ts +58 -0
  252. package/dist/traces/projector.js +200 -0
  253. package/dist/traces/read.d.ts +23 -0
  254. package/dist/traces/read.js +16 -0
  255. package/dist/vault/coverage.d.ts +26 -0
  256. package/dist/vault/coverage.js +64 -0
  257. package/dist/vault/error.d.ts +11 -1
  258. package/dist/vault/error.js +5 -1
  259. package/dist/vault/service.d.ts +47 -48
  260. package/dist/vault/service.js +321 -382
  261. package/dist/vault/sources.d.ts +5 -16
  262. package/dist/vault/sources.js +6 -8
  263. package/dist/version.d.ts +1 -1
  264. package/dist/version.js +2 -2
  265. package/package.json +14 -13
  266. package/dist/api/http/routes/sandboxes.d.ts +0 -9
  267. package/dist/api/http/routes/sandboxes.js +0 -153
  268. package/dist/api/http/routes/vaults.d.ts +0 -10
  269. package/dist/api/http/routes/vaults.js +0 -139
  270. package/dist/record/sandbox.d.ts +0 -25
  271. package/dist/record/sandbox.js +0 -43
  272. package/dist/tenant/sandboxes.d.ts +0 -122
  273. package/dist/tenant/sandboxes.js +0 -562
@@ -0,0 +1,563 @@
1
+ /**
2
+ * An Environment's disk versions (R2d step 3, Host feature `environment-disk-versions`). A
3
+ * version is a folder artifact version (manifest `nylorun.folder.v2`: files with mode and
4
+ * modification time, links, empty directories) of the one folder artifact the Environment owns
5
+ * (`artifacts.environment_id`), its files content-addressed in the Object store as every folder's
6
+ * are: unchanged files are stored once.
7
+ *
8
+ * - **Snapshot** (`snapshotEnvironment`): between turns, under the disk lease (`disk_op`), core
9
+ * lists the disk through the workspace that holds it (a pod's engine, or the virtual workspace),
10
+ * reads each file in ranges, hashes every byte itself, stores what the Object store lacks and
11
+ * commits the version. An unchanged disk makes no version.
12
+ * - **Seed** (`seedEnvironment`): empties the disk (the engine's own files stay) and writes a
13
+ * version back: directories, files (large ones in appended ranges), then links.
14
+ * - **Restore** puts a version back: on a virtual Environment at once; on a pod as a reset whose
15
+ * new volume is seeded before any turn (`disk_seed`, run once the new pod's engine connects).
16
+ * **Fork** (`from` on a create) and the **recovery of a lost volume** (from the latest version)
17
+ * are seeds too.
18
+ *
19
+ * Nothing here runs inside a transaction but the lease and the commit. No event type is new:
20
+ * a restore or recovery is `environment.reset` with `reason` and `from`.
21
+ */
22
+ import { createHash, randomUUID } from "node:crypto";
23
+ import { newArtifactId } from "@nylorun/core/compatibility";
24
+ import { FOLDER_CONTENT_TYPE, FOLDER_MANIFEST_FORMAT_V2, } from "@nylorun/core/contracts";
25
+ import { sandboxSpecOf } from "../sandbox/resolve.js";
26
+ import { capabilityForSandbox } from "../sandbox/share.js";
27
+ import { effectiveSandboxConfig, readSandboxConfig } from "../sandbox/tenant-config.js";
28
+ import { assertReaches, diskHeld, liveEnvironment, newVolume, SEED_LEASE_MS, wakePod, } from "../tenant/environments.js";
29
+ import { fail } from "../tenant/http.js";
30
+ import { readArtifactLimits } from "./config.js";
31
+ import { collectContent, contentEpoch, contentKey, encodeManifest, readManifest, sha256Hex } from "./folders.js";
32
+ import { contentTypeFor } from "./media-types.js";
33
+ /** The most entries one version holds (R6 Q11). */
34
+ export const DISK_MAX_ENTRIES = 200_000;
35
+ /** The most bytes one version's files hold. */
36
+ export const DISK_MAX_BYTES = 10 * 1024 ** 3;
37
+ /** One range of a file moved at a time, well inside a Harness API frame after base64. */
38
+ const RANGE_BYTES = 8 * 1024 * 1024;
39
+ /** A lease lasts this long unless renewed; a copy renews it every `HEARTBEAT_MS`. */
40
+ const LEASE_MS = 2 * 60_000;
41
+ const HEARTBEAT_MS = 30_000;
42
+ /** How long a snapshot waits for a pod's engine to connect. */
43
+ const ENGINE_WAIT_MS = 60_000;
44
+ /** The name of an Environment's disk artifact. */
45
+ export const DISK_ARTIFACT_NAME = "disk";
46
+ /** What the disk operations of Environment `environment` act through: a ref and its spec. */
47
+ async function diskTarget(ctx, environment) {
48
+ const config = effectiveSandboxConfig(await ctx.store.tx((t) => readSandboxConfig(t)));
49
+ const spec = sandboxSpecOf(environment.applied, config.limits.idle);
50
+ return {
51
+ ref: {
52
+ id: `disk:${environment.id}`,
53
+ activeTurnId: null,
54
+ manifest: { id: "disk", capabilities: [] },
55
+ environmentId: environment.id,
56
+ },
57
+ capability: capabilityForSandbox(spec),
58
+ };
59
+ }
60
+ /** One disk operation through the workspace, or a `409 environment_unavailable`. */
61
+ async function diskOp(ctx, target, op) {
62
+ const answer = await ctx.sandbox.disk(target.ref, target.capability, op, new AbortController().signal);
63
+ if ("code" in answer)
64
+ return fail(409, `The disk could not be reached: ${answer.message}`, { code: "environment_unavailable" });
65
+ return answer;
66
+ }
67
+ /**
68
+ * Takes the disk lease of Environment `id` for `kind`: refused (`409 environment_busy`) while a
69
+ * turn runs or another copy holds it; `400` for kind `none`, which has no disk.
70
+ */
71
+ async function lease(ctx, id, kind, version) {
72
+ return ctx.store.tx(async (t) => {
73
+ const environment = (await liveEnvironment(t, id, { lock: true })) ?? fail(404, "Environment not found");
74
+ if (environment.kind === "none")
75
+ fail(400, `Environment ${id} is of kind none: it has no disk.`);
76
+ const busy = (await t.sessionsOnEnvironment(id)).some((session) => session.activeTurnId);
77
+ if (busy || diskHeld(environment))
78
+ fail(409, `Environment ${id} has a turn running or its disk is being copied. Try again shortly.`, {
79
+ code: "environment_busy",
80
+ details: { retryAfterSeconds: 5 },
81
+ }, { "retry-after": "5" });
82
+ const now = new Date();
83
+ const operation = {
84
+ kind,
85
+ ...(version === undefined ? {} : { version }),
86
+ startedAt: now.toISOString(),
87
+ until: new Date(now.getTime() + LEASE_MS).toISOString(),
88
+ };
89
+ await t.updateEnvironment(id, { diskOp: operation }, now.toISOString());
90
+ return { ...environment, diskOp: operation };
91
+ });
92
+ }
93
+ /** Renews the lease every `HEARTBEAT_MS` until `stop()`, which ends it (and, with `seed`, the seed). */
94
+ function heartbeat(ctx, id, ms = LEASE_MS) {
95
+ const timer = setInterval(() => {
96
+ void ctx.store
97
+ .tx(async (t) => {
98
+ const environment = await t.environment(id, { lock: true });
99
+ if (!environment?.diskOp)
100
+ return;
101
+ const now = new Date();
102
+ await t.updateEnvironment(id, { diskOp: { ...environment.diskOp, until: new Date(now.getTime() + ms).toISOString() } }, now.toISOString());
103
+ })
104
+ .catch(() => undefined);
105
+ }, HEARTBEAT_MS);
106
+ timer.unref();
107
+ return {
108
+ async stop(options = {}) {
109
+ clearInterval(timer);
110
+ await ctx.store.tx(async (t) => {
111
+ const environment = await t.environment(id, { lock: true });
112
+ if (!environment)
113
+ return;
114
+ const clearSeed = options.seed !== undefined &&
115
+ environment.diskSeed?.artifactId === options.seed.artifactId &&
116
+ environment.diskSeed.version === options.seed.version;
117
+ await t.updateEnvironment(id, { diskOp: null, ...(clearSeed ? { diskSeed: null } : {}) }, new Date().toISOString());
118
+ });
119
+ },
120
+ };
121
+ }
122
+ /** Waits until the engine of pod Environment `id` is connected, starting the pod when it is stopped. */
123
+ async function awaitEngine(ctx, id) {
124
+ await wakePod(ctx, id);
125
+ const deadline = Date.now() + ENGINE_WAIT_MS;
126
+ while (!ctx.harness.hosting(id)) {
127
+ if (Date.now() > deadline)
128
+ fail(409, `Environment ${id}'s pod did not start within ${ENGINE_WAIT_MS / 1000} s.`, {
129
+ code: "environment_unavailable",
130
+ details: { retryAfterSeconds: 5 },
131
+ }, { "retry-after": "5" });
132
+ await new Promise((resolve) => setTimeout(resolve, 250));
133
+ }
134
+ }
135
+ /** A file's bytes and their hash, read in ranges. */
136
+ async function readFile(ctx, target, path) {
137
+ const hash = createHash("sha256");
138
+ const chunks = [];
139
+ let offset = 0;
140
+ for (;;) {
141
+ const answer = await diskOp(ctx, target, { read: { path, offset, length: RANGE_BYTES } });
142
+ if (answer.kind === "missing")
143
+ return undefined;
144
+ if (answer.kind !== "read")
145
+ throw new Error(`Unexpected answer reading ${path}`);
146
+ hash.update(answer.bytes);
147
+ chunks.push(answer.bytes);
148
+ offset += answer.bytes.length;
149
+ if (answer.eof || answer.bytes.length === 0)
150
+ break;
151
+ }
152
+ return { bytes: Buffer.concat(chunks), sha256: hash.digest("hex") };
153
+ }
154
+ /**
155
+ * `POST /v1/environments/{id}/snapshot`: a new version of the Environment's disk, between turns.
156
+ * A pod that is stopped is started first. An unchanged disk makes no version.
157
+ */
158
+ export async function snapshotEnvironment(ctx, id, scope) {
159
+ assertReaches(scope, id);
160
+ const started = Date.now();
161
+ const environment = await lease(ctx, id, "snapshot");
162
+ const beat = heartbeat(ctx, id);
163
+ try {
164
+ if (environment.kind === "pod")
165
+ await awaitEngine(ctx, id);
166
+ const target = await diskTarget(ctx, environment);
167
+ const listed = await diskOp(ctx, target, { list: { maxEntries: DISK_MAX_ENTRIES } });
168
+ if (listed.kind !== "listed")
169
+ throw new Error("The disk was not listed");
170
+ if (listed.truncated)
171
+ fail(413, `The disk holds more than ${DISK_MAX_ENTRIES} entries.`, {
172
+ code: "limit_exceeded",
173
+ details: { limit: "files", max: DISK_MAX_ENTRIES },
174
+ });
175
+ const bytes = listed.entries.reduce((sum, entry) => sum + (entry.type === "file" ? entry.size : 0), 0);
176
+ if (bytes > DISK_MAX_BYTES)
177
+ fail(413, `The disk holds ${bytes} bytes; a version holds at most ${DISK_MAX_BYTES}.`, {
178
+ code: "limit_exceeded",
179
+ details: { limit: "bytes", max: DISK_MAX_BYTES },
180
+ });
181
+ const epoch = await ctx.store.tx((t) => contentEpoch(t));
182
+ const entries = [];
183
+ let storedBytes = 0;
184
+ for (const entry of listed.entries) {
185
+ // A directory keeps its mode and a link its target: their times change when written back,
186
+ // so only files keep theirs (and an unchanged disk makes the same manifest).
187
+ if (entry.type === "dir") {
188
+ entries.push({ path: entry.path, mode: entry.mode, size: 0, sha256: "", contentType: "", type: "dir" });
189
+ continue;
190
+ }
191
+ if (entry.type === "link") {
192
+ entries.push({ path: entry.path, size: 0, sha256: "", contentType: "", type: "link", target: entry.target ?? "" });
193
+ continue;
194
+ }
195
+ const base = { path: entry.path, mode: entry.mode, mtimeMs: entry.mtimeMs };
196
+ const read = await readFile(ctx, target, entry.path);
197
+ if (!read)
198
+ continue; // removed while it was copied (a background process)
199
+ const key = contentKey(read.sha256);
200
+ if (!(await ctx.blobs.head(key))) {
201
+ await ctx.blobs.put(key, read.bytes, { contentType: "application/octet-stream" });
202
+ storedBytes += read.bytes.length;
203
+ }
204
+ entries.push({
205
+ ...base,
206
+ size: read.bytes.length,
207
+ sha256: read.sha256,
208
+ contentType: contentTypeFor(entry.path),
209
+ });
210
+ }
211
+ const committed = await commitDiskVersion(ctx, id, entries, epoch);
212
+ ctx.config.logger.info("disk_snapshot", {
213
+ environmentId: id,
214
+ version: committed.version.version,
215
+ files: entries.length,
216
+ bytes,
217
+ storedBytes,
218
+ unchanged: committed.unchanged,
219
+ durationMs: Date.now() - started,
220
+ });
221
+ return {
222
+ environmentId: id,
223
+ artifactId: committed.artifact.id,
224
+ version: committed.version.version,
225
+ format: FOLDER_MANIFEST_FORMAT_V2,
226
+ files: entries.length,
227
+ bytes: entries.reduce((sum, entry) => sum + entry.size, 0),
228
+ storedBytes: committed.unchanged ? 0 : storedBytes,
229
+ durationMs: Date.now() - started,
230
+ unchanged: committed.unchanged,
231
+ createdAt: committed.version.createdAt,
232
+ };
233
+ }
234
+ finally {
235
+ await beat.stop();
236
+ }
237
+ }
238
+ /**
239
+ * Commits a disk version whose files are stored: its manifest to the Object store, then under
240
+ * the Environment's row, the quota and the artifact (that lock order) the version and its
241
+ * content rows, within the Tenant total. No version when the manifest is the latest one's.
242
+ */
243
+ async function commitDiskVersion(ctx, id, entries, epoch) {
244
+ const manifest = encodeManifest(entries, FOLDER_MANIFEST_FORMAT_V2);
245
+ const sha256 = sha256Hex(manifest);
246
+ const files = new Map();
247
+ for (const entry of entries)
248
+ if (entry.type === undefined)
249
+ files.set(entry.sha256, entry.size);
250
+ const size = [...files.values()].reduce((sum, value) => sum + value, 0);
251
+ const prior = await ctx.store.tx((t) => t.diskArtifact(id));
252
+ if (prior && prior.latestVersion > 0) {
253
+ const latest = await ctx.store.tx((t) => t.artifactVersion(prior.id, prior.latestVersion));
254
+ if (latest && latest.sha256 === sha256)
255
+ return { artifact: prior, version: latest, unchanged: true };
256
+ }
257
+ const artifactId = prior?.id ?? newArtifactId();
258
+ const key = `artifacts/${artifactId}/${randomUUID()}`;
259
+ await ctx.blobs.put(key, manifest, { contentType: FOLDER_CONTENT_TYPE });
260
+ const totalBytes = (await ctx.store.tx((t) => readArtifactLimits(t))).totalBytes;
261
+ return ctx.store.tx(async (t) => {
262
+ (await liveEnvironment(t, id, { lock: true })) ?? fail(404, "Environment not found");
263
+ await t.lockArtifactQuota();
264
+ const shas = [...files.keys()];
265
+ const referenced = await t.referencedArtifactContent(shas);
266
+ const fresh = shas.filter((sha) => !referenced.has(sha));
267
+ if ((await contentEpoch(t)) !== epoch)
268
+ for (const sha of fresh)
269
+ if (!(await ctx.blobs.head(contentKey(sha))))
270
+ fail(409, "A file's bytes were removed while the snapshot ran; try again.", { code: "environment_busy" });
271
+ const added = fresh.reduce((sum, sha) => sum + files.get(sha), 0);
272
+ const total = await t.artifactBytes();
273
+ if (total + added > totalBytes)
274
+ fail(413, `The Tenant's artifacts may hold at most ${totalBytes} bytes; this version adds ${added} to ${total}.`, {
275
+ code: "limit_exceeded",
276
+ details: { limit: "tenant_total", max: totalBytes },
277
+ });
278
+ const createdAt = new Date().toISOString();
279
+ const current = await t.diskArtifact(id, { lock: true });
280
+ const version = {
281
+ artifactId,
282
+ version: (current?.latestVersion ?? 0) + 1,
283
+ blobKey: key,
284
+ size,
285
+ sha256,
286
+ contentType: FOLDER_CONTENT_TYPE,
287
+ source: "snapshot",
288
+ createdAt,
289
+ };
290
+ let artifact;
291
+ if (current) {
292
+ await t.insertArtifactVersion(version);
293
+ artifact = { ...current, latestVersion: version.version, updatedAt: createdAt };
294
+ }
295
+ else {
296
+ artifact = {
297
+ id: artifactId,
298
+ kind: "folder",
299
+ name: DISK_ARTIFACT_NAME,
300
+ contentType: FOLDER_CONTENT_TYPE,
301
+ sessionId: null,
302
+ latestVersion: 1,
303
+ labelsJson: null,
304
+ createdAt,
305
+ updatedAt: createdAt,
306
+ environmentId: id,
307
+ };
308
+ await t.insertArtifact(artifact, version);
309
+ }
310
+ await t.insertArtifactContent(artifactId, version.version, shas.map((sha) => ({ sha256: sha, size: files.get(sha) })));
311
+ return { artifact, version, unchanged: false };
312
+ });
313
+ }
314
+ /**
315
+ * Writes disk version `seed` into Environment `id`'s workspace: empties it (the engine's files
316
+ * stay), then directories, files (large ones in ranges) and links. Its caller holds the lease.
317
+ */
318
+ async function writeVersion(ctx, environment, seed) {
319
+ const version = await ctx.store.tx((t) => t.artifactVersion(seed.artifactId, seed.version));
320
+ if (!version)
321
+ throw new Error(`Disk version ${seed.version} of ${seed.artifactId} is gone`);
322
+ const manifest = await readManifest(ctx.blobs, version);
323
+ const target = await diskTarget(ctx, environment);
324
+ await diskOp(ctx, target, { clear: true });
325
+ const dirs = manifest.entries.filter((entry) => entry.type === "dir");
326
+ const links = manifest.entries.filter((entry) => entry.type === "link");
327
+ const files = manifest.entries.filter((entry) => entry.type === undefined);
328
+ if (dirs.length > 0)
329
+ await diskOp(ctx, target, {
330
+ put: dirs.map((entry) => ({ path: entry.path, type: "dir", ...(entry.mode === undefined ? {} : { mode: entry.mode }) })),
331
+ });
332
+ let batch = [];
333
+ let batchBytes = 0;
334
+ const flush = async () => {
335
+ if (batch.length === 0)
336
+ return;
337
+ await diskOp(ctx, target, { put: batch });
338
+ batch = [];
339
+ batchBytes = 0;
340
+ };
341
+ let bytes = 0;
342
+ for (const entry of files) {
343
+ const got = await ctx.blobs.get(contentKey(entry.sha256));
344
+ if (!got)
345
+ throw new Error(`The bytes of ${entry.path} are missing from the Object store`);
346
+ const content = new Uint8Array(await new Response(got.body).arrayBuffer());
347
+ bytes += content.length;
348
+ const meta = {
349
+ ...(entry.mode === undefined ? {} : { mode: entry.mode }),
350
+ ...(entry.mtimeMs === undefined ? {} : { mtimeMs: entry.mtimeMs }),
351
+ };
352
+ if (content.length <= RANGE_BYTES) {
353
+ if (batchBytes + content.length > RANGE_BYTES)
354
+ await flush();
355
+ batch.push({ path: entry.path, type: "file", bytes: content, ...meta });
356
+ batchBytes += content.length;
357
+ continue;
358
+ }
359
+ await flush();
360
+ for (let offset = 0; offset < content.length; offset += RANGE_BYTES) {
361
+ const last = offset + RANGE_BYTES >= content.length;
362
+ await diskOp(ctx, target, {
363
+ put: [
364
+ {
365
+ path: entry.path,
366
+ type: "file",
367
+ bytes: content.subarray(offset, offset + RANGE_BYTES),
368
+ ...(offset > 0 ? { append: true } : {}),
369
+ ...(last ? meta : {}),
370
+ },
371
+ ],
372
+ });
373
+ }
374
+ }
375
+ await flush();
376
+ if (links.length > 0)
377
+ await diskOp(ctx, target, {
378
+ put: links.map((entry) => ({ path: entry.path, type: "link", target: entry.target ?? "" })),
379
+ });
380
+ return { files: manifest.entries.length, bytes };
381
+ }
382
+ /** The version `version` names of Environment `id`'s disk, or the 404 of a missing one. */
383
+ async function versionOf(t, id, version) {
384
+ const artifact = await t.diskArtifact(id);
385
+ const number = version === "latest" ? artifact?.latestVersion : version;
386
+ if (!artifact || !number || !(await t.artifactVersion(artifact.id, number)))
387
+ return fail(404, `Environment ${id} has no disk version ${version}`);
388
+ return { artifactId: artifact.id, version: number };
389
+ }
390
+ /**
391
+ * `POST /v1/environments/{id}/restore`: puts disk version `version` back. A virtual
392
+ * Environment's workspace is rewritten at once; a pod gets a new volume, seeded from the version
393
+ * before any turn (`status: pending` until then).
394
+ */
395
+ export async function restoreEnvironment(ctx, id, scope, version) {
396
+ assertReaches(scope, id);
397
+ const seed = await ctx.store.tx((t) => versionOf(t, id, version));
398
+ const found = await ctx.store.tx((t) => liveEnvironment(t, id));
399
+ if (!found)
400
+ return fail(404, "Environment not found");
401
+ if (found.kind === "pod") {
402
+ const volumeGeneration = await ctx.store.tx(async (t) => {
403
+ const environment = (await liveEnvironment(t, id, { lock: true })) ?? fail(404, "Environment not found");
404
+ const busy = (await t.sessionsOnEnvironment(id)).some((session) => session.activeTurnId);
405
+ if (busy || diskHeld(environment) || environment.pod.retiring !== undefined)
406
+ fail(409, `Environment ${id} has a turn running, its disk is being copied or its last volume is still being deleted.`, {
407
+ code: "environment_busy",
408
+ details: { retryAfterSeconds: 5 },
409
+ }, { "retry-after": "5" });
410
+ return newVolume(ctx, t, environment, { seed, reason: "restore" });
411
+ });
412
+ return { environmentId: id, restored: seed.version, status: "pending", volumeGeneration };
413
+ }
414
+ const environment = await lease(ctx, id, "restore", seed.version);
415
+ const beat = heartbeat(ctx, id);
416
+ try {
417
+ await writeVersion(ctx, environment, seed);
418
+ }
419
+ finally {
420
+ await beat.stop();
421
+ }
422
+ return { environmentId: id, restored: seed.version, status: "done" };
423
+ }
424
+ /** Seeds that run in this process, so the sweep starts no second copy. */
425
+ const running = new Set();
426
+ /**
427
+ * Writes Environment `id`'s pending seed (`disk_seed`) into its disk, once its pod's engine is
428
+ * connected (or at once for a virtual one), then clears the seed and the lease. Started after a
429
+ * pod joins, after a virtual Environment is forked, and by the Tenant sweep for a seed whose
430
+ * lease lapsed. Best effort: a failure leaves the seed for the next try.
431
+ */
432
+ export async function runPendingSeed(ctx, id) {
433
+ const key = `${ctx.config.tenantId}\0${id}`;
434
+ if (running.has(key))
435
+ return;
436
+ running.add(key);
437
+ try {
438
+ const environment = await ctx.store.tx((t) => liveEnvironment(t, id));
439
+ const seed = environment?.diskSeed;
440
+ if (!environment || !seed)
441
+ return;
442
+ if (environment.kind === "pod") {
443
+ const deadline = Date.now() + ENGINE_WAIT_MS;
444
+ while (!ctx.harness.hosting(id)) {
445
+ if (Date.now() > deadline)
446
+ return;
447
+ await new Promise((resolve) => setTimeout(resolve, 250));
448
+ }
449
+ }
450
+ const beat = heartbeat(ctx, id, SEED_LEASE_MS);
451
+ let done = false;
452
+ try {
453
+ const started = Date.now();
454
+ const written = await writeVersion(ctx, environment, seed);
455
+ done = true;
456
+ ctx.config.logger.info("disk_seed", { environmentId: id, version: seed.version, ...written, durationMs: Date.now() - started });
457
+ }
458
+ finally {
459
+ await beat.stop(done ? { seed } : {});
460
+ }
461
+ }
462
+ catch (error) {
463
+ ctx.config.logger.warn("disk_seed_failed", {
464
+ environmentId: id,
465
+ message: error instanceof Error ? error.message : String(error),
466
+ });
467
+ }
468
+ finally {
469
+ running.delete(key);
470
+ }
471
+ }
472
+ /** Starts the pending seeds whose engine is connected and whose lease lapsed (Tenant sweep). */
473
+ export async function sweepPendingSeeds(ctx) {
474
+ const pending = (await ctx.store.tx((t) => t.listEnvironments())).filter((environment) => environment.diskSeed !== undefined && environment.pod?.desired !== "deleted");
475
+ for (const environment of pending)
476
+ if (environment.kind !== "pod" || ctx.harness.hosting(environment.id))
477
+ void runPendingSeed(ctx, environment.id);
478
+ }
479
+ /**
480
+ * After a pod Environment was found `lost`: when its disk has a version, it gets a new volume
481
+ * seeded from the latest one, instead of refusing turns until a reset (R6 Q9: eager).
482
+ */
483
+ export async function recoverLostVolume(ctx, id) {
484
+ const recovered = await ctx.store.tx(async (t) => {
485
+ const environment = await liveEnvironment(t, id, { lock: true });
486
+ if (!environment?.pod || environment.pod.observed !== "lost" || environment.pod.retiring !== undefined)
487
+ return undefined;
488
+ const artifact = await t.diskArtifact(id);
489
+ if (!artifact || artifact.latestVersion < 1)
490
+ return undefined;
491
+ const seed = { artifactId: artifact.id, version: artifact.latestVersion };
492
+ await newVolume(ctx, t, environment, { seed, reason: "lost" });
493
+ return seed;
494
+ });
495
+ if (recovered)
496
+ ctx.config.logger.warn("environment volume lost: recovered from the latest disk version", {
497
+ environmentId: id,
498
+ version: recovered.version,
499
+ });
500
+ return recovered !== undefined;
501
+ }
502
+ /** The disk version a fork starts from (`from` on a create), after the caller's checks. */
503
+ export async function forkSeedOf(t, from) {
504
+ return versionOf(t, from.environment, from.version);
505
+ }
506
+ /** `GET /v1/environments/{id}/versions`: its disk versions, newest first. */
507
+ export async function environmentVersions(ctx, id, scope) {
508
+ assertReaches(scope, id);
509
+ return ctx.store.tx(async (t) => {
510
+ (await liveEnvironment(t, id)) ?? fail(404, "Environment not found");
511
+ const artifact = await t.diskArtifact(id);
512
+ const versions = artifact ? await t.artifactVersions(artifact.id) : [];
513
+ return {
514
+ environmentId: id,
515
+ artifactId: artifact?.id ?? null,
516
+ latest: artifact && artifact.latestVersion > 0 ? artifact.latestVersion : null,
517
+ versions: versions
518
+ .map((row) => ({ version: row.version, bytes: row.size, createdAt: row.createdAt }))
519
+ .reverse(),
520
+ };
521
+ });
522
+ }
523
+ /**
524
+ * `DELETE /v1/environments/{id}/versions/{n}`: deletes one disk version, and the stored files no
525
+ * other version names. Refused while a seed reads it.
526
+ */
527
+ export async function deleteEnvironmentVersion(ctx, id, scope, version) {
528
+ assertReaches(scope, id);
529
+ const blobKey = await ctx.store.tx(async (t) => {
530
+ const environment = (await liveEnvironment(t, id, { lock: true })) ?? fail(404, "Environment not found");
531
+ const artifact = await t.diskArtifact(id, { lock: true });
532
+ if (!artifact)
533
+ return undefined;
534
+ if (environment.diskSeed?.artifactId === artifact.id && environment.diskSeed.version === version)
535
+ fail(409, `A new volume of Environment ${id} is being seeded from version ${version}.`, {
536
+ code: "environment_busy",
537
+ details: { retryAfterSeconds: 5 },
538
+ }, { "retry-after": "5" });
539
+ await t.lockArtifactQuota();
540
+ const content = await t.artifactContentShas({ artifactId: artifact.id });
541
+ const key = await t.deleteArtifactVersion(artifact.id, version);
542
+ if (key !== undefined)
543
+ await collectContent(ctx.blobs, t, content);
544
+ return key;
545
+ });
546
+ if (blobKey !== undefined)
547
+ await ctx.blobs.delete(blobKey).catch(() => undefined);
548
+ return { environmentId: id, version, deleted: blobKey !== undefined };
549
+ }
550
+ /**
551
+ * Deletes Environment `id`'s disk artifact with its versions, in the transaction that deletes the
552
+ * Environment (it holds the row's lock); returns the manifests' keys to delete after commit.
553
+ */
554
+ export async function deleteDiskArtifact(ctx, t, id) {
555
+ const artifact = await t.diskArtifact(id);
556
+ if (!artifact)
557
+ return [];
558
+ await t.lockArtifactQuota();
559
+ const content = await t.artifactContentShas({ artifactId: artifact.id });
560
+ const keys = await t.deleteArtifact(artifact.id);
561
+ await collectContent(ctx.blobs, t, content);
562
+ return keys;
563
+ }
@@ -1,4 +1,4 @@
1
- import { type TenantContext } from "../tenant/context.js";
1
+ import type { TenantContext } from "../tenant/context.js";
2
2
  /** The directory every sandbox's outputs are exported from. */
3
3
  export declare const OUTPUTS_DIR = "/workspace/outputs";
4
4
  /**
@@ -26,7 +26,6 @@ import { SANDBOX_WORKSPACE } from "@nylorun/core/define";
26
26
  import { isWorkflowManifest } from "../core/flow-host.js";
27
27
  import { sandboxWorkspaceOf } from "../sandbox/share.js";
28
28
  import { SandboxFileTooLargeError } from "../sandbox/types.js";
29
- import { sandboxLookup } from "../tenant/context.js";
30
29
  import { readArtifactLimits } from "./config.js";
31
30
  import { collectContent, commitFolderVersion, contentEpoch, contentKey, encodeManifest, FolderCommitRefused, sha256Hex, } from "./folders.js";
32
31
  import { contentTypeFor } from "./media-types.js";
@@ -93,7 +92,7 @@ async function runExport(ctx, sessionId, turnId, signal) {
93
92
  // Only a turn that completed, and only an agent's (a flow agent's sandbox is its agents').
94
93
  if (!s || s.status !== "completed" || s.lastTurnId !== turnId || isWorkflowManifest(s.manifest))
95
94
  return undefined;
96
- const workspace = sandboxWorkspaceOf(s, await sandboxLookup(t, s.id));
95
+ const workspace = sandboxWorkspaceOf(s);
97
96
  const prior = (await t.listArtifacts({ sessionId })).find((row) => row.kind === "folder" && row.name === OUTPUTS_ARTIFACT_NAME);
98
97
  const latest = prior ? await t.artifactVersion(prior.id, prior.latestVersion) : undefined;
99
98
  return {
@@ -101,7 +100,7 @@ async function runExport(ctx, sessionId, turnId, signal) {
101
100
  sessionId,
102
101
  turnId,
103
102
  ownerId: workspace.ownerId,
104
- ...(workspace.sandboxId === undefined ? {} : { sandboxId: workspace.sandboxId }),
103
+ ...(workspace.environmentId === undefined ? {} : { environmentId: workspace.environmentId }),
105
104
  manifest: s.manifest,
106
105
  },
107
106
  limits: await readArtifactLimits(t),
@@ -7,11 +7,14 @@ export declare function contentKey(sha256: string): string;
7
7
  export declare function sha256Hex(bytes: Uint8Array): string;
8
8
  /** Entries in manifest order: by path, comparing UTF-16 code units. */
9
9
  export declare function sortEntries(entries: readonly FolderEntry[]): FolderEntry[];
10
- /** The manifest's bytes, as stored: deterministic for the same entries. */
11
- export declare function encodeManifest(entries: readonly FolderEntry[]): Uint8Array;
10
+ /**
11
+ * The manifest's bytes, as stored: deterministic for the same entries. A v1 manifest keeps each
12
+ * file's path, size, hash and type; a v2 one (a disk version, R2d step 3) every field.
13
+ */
14
+ export declare function encodeManifest(entries: readonly FolderEntry[], format?: FolderManifest["format"]): Uint8Array;
12
15
  /** A folder version's manifest, read from the Object store. */
13
16
  export declare function readManifest(blobs: BlobStore, version: ArtifactVersionRow): Promise<FolderManifest>;
14
- /** The entry at `path`, or the 404 of a missing file. */
17
+ /** The file at `path` (not a v2 link or directory), or the 404 of a missing file. */
15
18
  export declare function entryAt(manifest: FolderManifest, path: string): FolderEntry;
16
19
  /** What changed from `from` (absent: nothing) to `to`. */
17
20
  export declare function diffManifests(artifactId: string, from: {
@@ -34,16 +34,16 @@ export function sha256Hex(bytes) {
34
34
  export function sortEntries(entries) {
35
35
  return [...entries].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
36
36
  }
37
- /** The manifest's bytes, as stored: deterministic for the same entries. */
38
- export function encodeManifest(entries) {
37
+ /**
38
+ * The manifest's bytes, as stored: deterministic for the same entries. A v1 manifest keeps each
39
+ * file's path, size, hash and type; a v2 one (a disk version, R2d step 3) every field.
40
+ */
41
+ export function encodeManifest(entries, format = FOLDER_MANIFEST_FORMAT) {
39
42
  const manifest = {
40
- format: FOLDER_MANIFEST_FORMAT,
41
- entries: sortEntries(entries).map(({ path, size, sha256, contentType }) => ({
42
- path,
43
- size,
44
- sha256,
45
- contentType,
46
- })),
43
+ format,
44
+ entries: sortEntries(entries).map((entry) => format === FOLDER_MANIFEST_FORMAT
45
+ ? { path: entry.path, size: entry.size, sha256: entry.sha256, contentType: entry.contentType }
46
+ : { ...entry }),
47
47
  };
48
48
  return new TextEncoder().encode(JSON.stringify(manifest));
49
49
  }
@@ -57,9 +57,9 @@ export async function readManifest(blobs, version) {
57
57
  throw new Error(`The manifest of ${version.artifactId} v${version.version} is malformed`);
58
58
  return parsed.data;
59
59
  }
60
- /** The entry at `path`, or the 404 of a missing file. */
60
+ /** The file at `path` (not a v2 link or directory), or the 404 of a missing file. */
61
61
  export function entryAt(manifest, path) {
62
- return (manifest.entries.find((entry) => entry.path === path) ??
62
+ return (manifest.entries.find((entry) => entry.path === path && entry.type === undefined) ??
63
63
  fail(404, `No file ${path} in this folder version`));
64
64
  }
65
65
  /** What changed from `from` (absent: nothing) to `to`. */
@@ -72,7 +72,11 @@ export function diffManifests(artifactId, from, to) {
72
72
  const prior = before.get(entry.path);
73
73
  if (!prior)
74
74
  added.push(entry);
75
- else if (prior.sha256 !== entry.sha256 || prior.contentType !== entry.contentType)
75
+ else if (prior.sha256 !== entry.sha256 ||
76
+ prior.contentType !== entry.contentType ||
77
+ prior.type !== entry.type ||
78
+ prior.target !== entry.target ||
79
+ prior.mode !== entry.mode)
76
80
  changed.push({ path: entry.path, from: prior, to: entry });
77
81
  }
78
82
  const removed = (from?.manifest.entries ?? []).filter((entry) => !after.has(entry.path));
@@ -182,6 +186,7 @@ export async function commitFolderVersion(ctx, input) {
182
186
  labelsJson: null,
183
187
  createdAt,
184
188
  updatedAt: createdAt,
189
+ environmentId: null,
185
190
  };
186
191
  await t.insertArtifact(row, version);
187
192
  }