@capybara-hq/kernel 0.1.0-rc.7 → 0.1.0-rc.9

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 (118) hide show
  1. package/dist/background/finish.js +2 -1
  2. package/dist/background/launch.d.ts +13 -7
  3. package/dist/background/launch.js +15 -8
  4. package/dist/background/poll.job.d.ts +1 -1
  5. package/dist/background/poll.job.js +8 -3
  6. package/dist/background/session.d.ts +2 -2
  7. package/dist/background/session.js +3 -2
  8. package/dist/background/status.d.ts +1 -1
  9. package/dist/background/status.js +1 -1
  10. package/dist/background/stop.js +4 -3
  11. package/dist/background/store.d.ts +6 -1
  12. package/dist/background/store.js +5 -1
  13. package/dist/background/tasks.service.d.ts +1 -1
  14. package/dist/background/tasks.service.js +4 -4
  15. package/dist/catalog/marketplaces.service.d.ts +1 -1
  16. package/dist/config/docs.js +2 -0
  17. package/dist/config/schema.d.ts +7 -0
  18. package/dist/config/schema.js +45 -0
  19. package/dist/conversations/event-log.d.ts +4 -3
  20. package/dist/conversations/event-log.js +4 -3
  21. package/dist/conversations/events.d.ts +9 -0
  22. package/dist/conversations/events.js +14 -0
  23. package/dist/db/kernel-schema.d.ts +26 -1
  24. package/dist/devices/device-auth.controller.d.ts +42 -0
  25. package/dist/devices/device-auth.controller.js +116 -0
  26. package/dist/devices/device-auth.d.ts +119 -0
  27. package/dist/devices/device-auth.js +403 -0
  28. package/dist/devices/device-auth.module.d.ts +7 -0
  29. package/dist/devices/device-auth.module.js +25 -0
  30. package/dist/devices/device-gateway.d.ts +128 -0
  31. package/dist/devices/device-gateway.js +530 -0
  32. package/dist/devices/device-provider.d.ts +89 -0
  33. package/dist/devices/device-provider.js +151 -0
  34. package/dist/devices/device-routes.d.ts +36 -0
  35. package/dist/devices/device-routes.js +90 -0
  36. package/dist/devices/devices.module.d.ts +16 -0
  37. package/dist/devices/devices.module.js +44 -0
  38. package/dist/devices/dto.d.ts +66 -0
  39. package/dist/devices/dto.js +44 -0
  40. package/dist/devices/keys.d.ts +25 -0
  41. package/dist/devices/keys.js +25 -0
  42. package/dist/devices/machines.d.ts +106 -0
  43. package/dist/devices/machines.js +301 -0
  44. package/dist/devices/relay.d.ts +100 -0
  45. package/dist/devices/relay.js +373 -0
  46. package/dist/devices/targets.d.ts +28 -0
  47. package/dist/devices/targets.js +73 -0
  48. package/dist/environments/access.d.ts +11 -6
  49. package/dist/environments/access.js +23 -8
  50. package/dist/environments/conversation-environments.d.ts +20 -6
  51. package/dist/environments/conversation-environments.js +70 -9
  52. package/dist/environments/environments.controller.d.ts +4 -1
  53. package/dist/environments/environments.controller.js +15 -1
  54. package/dist/environments/environments.module.js +3 -1
  55. package/dist/environments/environments.service.d.ts +54 -7
  56. package/dist/environments/environments.service.js +104 -20
  57. package/dist/environments/legacy-cleanup.job.d.ts +1 -1
  58. package/dist/environments/legacy-cleanup.job.js +4 -4
  59. package/dist/errors.d.ts +24 -0
  60. package/dist/errors.js +24 -0
  61. package/dist/evals/recorded-tools.d.ts +9 -0
  62. package/dist/evals/recorded-tools.js +27 -0
  63. package/dist/http/api.module.js +4 -0
  64. package/dist/http/gateway-routes.d.ts +7 -3
  65. package/dist/http/gateway-routes.js +6 -3
  66. package/dist/identity/me.controller.d.ts +1 -3
  67. package/dist/identity/me.controller.js +1 -9
  68. package/dist/identity/me.schemas.d.ts +14 -0
  69. package/dist/identity/me.schemas.js +17 -0
  70. package/dist/infra/config/kernel-config.service.js +2 -0
  71. package/dist/infra/throttler/named-throttlers.d.ts +5 -0
  72. package/dist/infra/throttler/named-throttlers.js +38 -0
  73. package/dist/kernel.module.js +3 -0
  74. package/dist/lifecycle/kernel-lifecycle.js +5 -0
  75. package/dist/platform/exclusions.js +33 -10
  76. package/dist/platform/operation.js +1 -1
  77. package/dist/platform/ops/environments.ops.js +21 -3
  78. package/dist/platform/ops/platform.ops.d.ts +7 -2
  79. package/dist/platform/ops/platform.ops.js +50 -3
  80. package/dist/platform/route-schemas.js +3 -0
  81. package/dist/roles/gateway-role.d.ts +14 -3
  82. package/dist/roles/gateway-role.js +25 -5
  83. package/dist/roles/gateway.module.d.ts +2 -1
  84. package/dist/roles/gateway.module.js +6 -3
  85. package/dist/runtime/context/build.d.ts +2 -0
  86. package/dist/runtime/context/build.js +14 -0
  87. package/dist/runtime/context/compaction.js +1 -0
  88. package/dist/runtime/context/environment-paths.d.ts +40 -0
  89. package/dist/runtime/context/environment-paths.js +48 -0
  90. package/dist/runtime/model-call.js +2 -0
  91. package/dist/sandbox/providers.d.ts +29 -7
  92. package/dist/sandbox/providers.js +43 -8
  93. package/dist/sandbox/sandbox.service.d.ts +70 -18
  94. package/dist/sandbox/sandbox.service.js +176 -59
  95. package/dist/tools/builtins/sandbox/bash.d.ts +2 -2
  96. package/dist/tools/builtins/sandbox/bash.js +39 -27
  97. package/dist/tools/builtins/sandbox/common.d.ts +60 -25
  98. package/dist/tools/builtins/sandbox/common.js +121 -30
  99. package/dist/tools/builtins/sandbox/deliver.js +9 -6
  100. package/dist/tools/builtins/sandbox/edit.js +40 -37
  101. package/dist/tools/builtins/sandbox/glob.js +13 -8
  102. package/dist/tools/builtins/sandbox/grep.js +10 -6
  103. package/dist/tools/builtins/sandbox/index.d.ts +6 -4
  104. package/dist/tools/builtins/sandbox/index.js +6 -4
  105. package/dist/tools/builtins/sandbox/read.js +9 -6
  106. package/dist/tools/builtins/sandbox/task-output.d.ts +7 -3
  107. package/dist/tools/builtins/sandbox/task-output.js +14 -10
  108. package/dist/tools/builtins/sandbox/task-stop.js +3 -3
  109. package/dist/tools/builtins/sandbox/write.js +33 -30
  110. package/dist/tools/builtins/skill-load.d.ts +8 -5
  111. package/dist/tools/builtins/skill-load.js +21 -15
  112. package/dist/tools/builtins/skill-write.js +2 -2
  113. package/dist/tools/pipeline.js +2 -0
  114. package/dist/tools/registry.d.ts +21 -4
  115. package/dist/tools/registry.js +10 -2
  116. package/package.json +6 -3
  117. package/src/migrations/0093_connected_environments.sql +37 -0
  118. package/src/migrations/0094_environment_paths_idx.sql +7 -0
@@ -1,4 +1,4 @@
1
- import { type ExecResult, type SandboxActor, type SandboxHandle, type SandboxLimits, type SandboxProvider } from "@capybara-hq/plugin-sdk";
1
+ import { type ExecResult, type SandboxActor, type SandboxHandle, type SandboxLimits, type SandboxProvider, type SandboxRoots } from "@capybara-hq/plugin-sdk";
2
2
  import type { Redis } from "ioredis";
3
3
  import { type Selectable } from "kysely";
4
4
  import { BackgroundTaskStore } from "../background/store.js";
@@ -21,6 +21,11 @@ export interface SandboxUseInfo {
21
21
  * new instance on the kept files, so earlier processes are gone.
22
22
  */
23
23
  restarted: boolean;
24
+ /**
25
+ * The instance's roots ({@link rootsOf} of its handle): every model-visible path is built from
26
+ * them (environments spec §13.1; a connected machine's real paths).
27
+ */
28
+ roots: SandboxRoots;
24
29
  }
25
30
  /** Who uses the sandbox in {@link SandboxService.withSandbox}. */
26
31
  export interface SandboxUseContext {
@@ -78,8 +83,12 @@ export type IfRunningResult<T> = {
78
83
  export declare const environmentResourceName: (environmentId: string) => string;
79
84
  /** The environment is missing, deleted, or not in the caller's org (404; ids cannot be probed). */
80
85
  export declare const environmentNotFound: () => ApiError;
81
- /** The environment's file system is gone at the provider (409); never replaced silently. */
82
- export declare const environmentFilesLost: () => ApiError;
86
+ /**
87
+ * The environment's file system is gone at the provider (409); never replaced silently. On a
88
+ * connected machine (`device`, details `[{ source: "device" }]`) the directory is the machine
89
+ * owner's: `capybara-device logout`, then `login` into the environment, recreates it (ruling R24).
90
+ */
91
+ export declare const environmentFilesLost: (device?: boolean) => ApiError;
83
92
  /**
84
93
  * The row still records the instance `gone` (same handle and same `created_at`). Handles compare
85
94
  * canonically: a jsonb round trip reorders a multi-key handle's keys (final review m9).
@@ -146,18 +155,25 @@ export declare const sandboxUnavailable: () => ApiError;
146
155
  * row's `created_at` (set by the fenced write after each `create`); the handle alone is the same
147
156
  * after a rebuild on providers whose handle is the instance name (sandbox-docker).
148
157
  *
158
+ * - **Provider per environment** (environments spec §6): each call resolves the provider from
159
+ * the environment's `environments.provider` ({@link providerForEnvironment}): a `device`
160
+ * environment runs on the kernel-internal `@device` provider (never the cloud one), a cloud
161
+ * environment on the loaded `sandbox@2` extension. A `@device` instance is never suspended or
162
+ * destroyed for inactivity; a deleted `@device` environment (or one of a deleted org) is
163
+ * destroyed by the sweep like any other.
164
+ *
149
165
  * Files kept by another provider than the current one make the environment unusable
150
166
  * (`environment.unavailable`) until that provider is back; deleting it still works while that
151
- * provider is loaded ({@link SandboxProviders.lookup}). Without a provider {@link available} is false and
152
- * uses throw `sandbox.unavailable` (503). No in-process state: replicas share it through PG and
153
- * Redis.
167
+ * provider is loaded ({@link SandboxProviders.forName}). Without the environment's provider
168
+ * {@link availableFor} is false and uses throw `sandbox.unavailable` (503). No in-process state:
169
+ * replicas share it through PG and Redis.
154
170
  */
155
171
  export declare class SandboxService {
156
172
  private readonly db;
157
173
  /** The record of the instance each holder last used (`capk:sandbox-seen:…`). */
158
174
  private readonly redis;
159
175
  private readonly leases;
160
- /** The `sandbox@2` provider (read at every call: providers are ready after the extension load). */
176
+ /** The providers (read at every call: providers are ready after the extension load). */
161
177
  private readonly providers;
162
178
  private readonly config;
163
179
  private readonly log;
@@ -166,14 +182,28 @@ export declare class SandboxService {
166
182
  constructor(db: KernelDb,
167
183
  /** The record of the instance each holder last used (`capk:sandbox-seen:…`). */
168
184
  redis: Pick<Redis, "set">, leases: SandboxLeases,
169
- /** The `sandbox@2` provider (read at every call: providers are ready after the extension load). */
170
- providers: Pick<SandboxProviders, "current" | "lookup">, config: Pick<KernelConfigService, "get">, log: Pick<KernelLogger, "info" | "warn">,
185
+ /** The providers (read at every call: providers are ready after the extension load). */
186
+ providers: Pick<SandboxProviders, "cloud" | "forName">, config: Pick<KernelConfigService, "get">, log: Pick<KernelLogger, "info" | "warn">,
171
187
  /** Background tasks lost while their process may live on (killed after a resume). */
172
188
  background: Pick<BackgroundTaskStore, "pendingKills" | "clearKill">);
173
189
  /** Poll interval while waiting for a lease (the leases' own). */
174
190
  private get pollMs();
175
- /** Whether there is a sandbox provider. */
176
- get available(): boolean;
191
+ /**
192
+ * Whether there is a cloud sandbox provider (a loaded `sandbox@2` extension). For gates that have
193
+ * no environment yet (tool listing, skill files); a call on an environment asks
194
+ * {@link availableFor}.
195
+ */
196
+ get cloudAvailable(): boolean;
197
+ /** Whether the environment's provider is there (`@device` registered, or a cloud provider). */
198
+ availableFor(environmentId: string): Promise<boolean>;
199
+ /**
200
+ * The provider the environment works with, read from its row ({@link providerForEnvironment});
201
+ * a missing row (or one of another org, with `orgId`) reads as a cloud environment, whose use
202
+ * then fails as not found.
203
+ */
204
+ private providerOf;
205
+ /** {@link providerOf}, or `sandbox.unavailable` (503) without it. */
206
+ private require;
177
207
  /** Concurrent holders of one environment (`sandbox.maxConcurrentCommands`, read at every use). */
178
208
  private get cap();
179
209
  /**
@@ -233,11 +263,20 @@ export declare class SandboxService {
233
263
  * - When `fn` throws `SandboxGoneError` (the instance is gone) the instance is rebuilt on the
234
264
  * kept files (or the one another holder rebuilt is taken) and `fn` runs once more with
235
265
  * `restarted: true`; a second `gone` is thrown. Other errors are thrown as they are.
236
- * - Lost files throw `environment.files_lost` (409).
266
+ * - Lost files throw `environment.files_lost` (409), also when `fn` throws
267
+ * `FileSystemGoneError` (the files are then recorded lost, ruling R24).
237
268
  * - When `ctx.holderConversationId` last used another instance (another holder rebuilt it
238
269
  * meanwhile), `info.restarted` is true, so it learns its processes are gone (final review m5).
239
270
  */
240
271
  withSandbox<T>(ctx: SandboxUseContext, fn: (h: SandboxHandle, p: HeldProvider, info: SandboxUseInfo, signal: AbortSignal) => Promise<T>, signal: AbortSignal): Promise<T>;
272
+ /**
273
+ * Ruling R24: an operation on the held instance `e` found its file system gone. Records
274
+ * `fs_status = 'lost'` while the row still records that instance on its file system (the
275
+ * instance handle stays, so a reset still destroys it), moving the fence on, and returns
276
+ * `environment.files_lost` to throw. Uses then fail until the files are reset (a connected
277
+ * machine: until a new login, which resets the row). A failed write only logs.
278
+ */
279
+ private filesGone;
241
280
  /**
242
281
  * Records that `holder` uses the instance of `e` (`capk:sandbox-seen:<env>:<holder>` =
243
282
  * `<created_at>:<file system id>`, with a TTL of the inactivity destruction plus a day). When the
@@ -258,7 +297,10 @@ export declare class SandboxService {
258
297
  private readRow;
259
298
  /** The instance row belongs to another org: reported as not found, so ids cannot be probed. */
260
299
  private foreign;
261
- /** Recorded running with the current provider, and not the instance found gone. */
300
+ /**
301
+ * Recorded running with the current provider on files not lost (lost ones go through the
302
+ * exclusive path, which refuses them), and not the instance found gone.
303
+ */
262
304
  private usable;
263
305
  /**
264
306
  * Under a just-taken shared lease: confirms the row is still `accept`ed (else releases and
@@ -295,15 +337,19 @@ export declare class SandboxService {
295
337
  * (the instance only; the file system is kept and the next use builds a new instance on it); an
296
338
  * environment that was deleted, or whose org was deleted, loses its instance and its file system
297
339
  * and its row is deleted (as {@link destroyEnvironment} does). Environments with any holder are
298
- * skipped. Returns how many it destroyed. Called hourly by the `sandbox-destroy-inactive` cron.
340
+ * skipped. Inactivity never destroys a `@device` instance; a gone `@device` environment is
341
+ * destroyed like any other while `@device` is registered. Returns how many it destroyed. Called
342
+ * hourly by the `sandbox-destroy-inactive` cron.
299
343
  */
300
344
  destroyInactive(now: Date): Promise<number>;
301
345
  /**
302
346
  * Destroys the environment's instance, then its file system, then deletes its instance row
303
347
  * (environment deletion, offboarding); nothing to do without a row. Waits for the exclusive lease
304
- * (with intent, so running commands finish and new ones wait) up to 10 minutes. Without a
305
- * provider it only warns (the destruction sweep finishes a deleted environment later). Files kept
306
- * by another provider that is not loaded cannot be destroyed: it throws and the row stays.
348
+ * (with intent, so running commands finish and new ones wait) up to 10 minutes. Without the
349
+ * environment's provider it throws and the row stays, marked `sweep_failed_at` (the job is
350
+ * retried, and the destruction sweep finishes a deleted environment once the provider is back).
351
+ * Files kept by another provider that is not loaded cannot be destroyed: it throws and the row
352
+ * stays.
307
353
  */
308
354
  destroyEnvironment(environmentId: string): Promise<void>;
309
355
  /**
@@ -329,7 +375,6 @@ export declare class SandboxService {
329
375
  * and leaves the flags of this instance's tasks for the next resume.
330
376
  */
331
377
  private killPendingTasks;
332
- private require;
333
378
  /**
334
379
  * A fenced write: only while the lease holds and the row's generation is still the one read
335
380
  * under it (and incremented); otherwise {@link SandboxLockLostError}.
@@ -390,6 +435,13 @@ export declare class SandboxService {
390
435
  private destroyRow;
391
436
  /** Due for suspension: made by this provider, running, and idle or running for too long. */
392
437
  private suspendDue;
438
+ /** The provider of an instance row: an internal one (`@device`) by name, else the cloud one. */
439
+ private rowProvider;
440
+ /**
441
+ * A row the idle sweeps may touch: not on a kernel-internal provider (`@device` instances live on
442
+ * people's machines and are never suspended or destroyed for inactivity).
443
+ */
444
+ private cloudRow;
393
445
  /** The environment was deleted, or its org was: the instance and the files go. */
394
446
  private environmentGone;
395
447
  /** Due for instance destruction: an instance (or a create in progress) unused for too long. */
@@ -8,7 +8,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
8
  return function (target, key) { decorator(target, key, paramIndex); }
9
9
  };
10
10
  import { randomUUID } from "node:crypto";
11
- import { isFileSystemGoneError, isSandboxGoneError, } from "@capybara-hq/plugin-sdk";
11
+ import { isFileSystemGoneError, isSandboxGoneError, rootsOf, } from "@capybara-hq/plugin-sdk";
12
12
  import { Inject, Injectable } from "@nestjs/common";
13
13
  import { sql, } from "kysely";
14
14
  import { BackgroundTaskStore } from "../background/store.js";
@@ -21,7 +21,7 @@ import { KERNEL_LOGGER, REDIS } from "../infra/tokens.js";
21
21
  import { effectiveSandboxEgress, effectiveSandboxLimits, readOrgSettings, } from "../orgs/settings.service.js";
22
22
  import { canonicalJson } from "../tools/canonical-json.js";
23
23
  import { SandboxLeases, SandboxLockLostError, sandboxBusy, } from "./leases.js";
24
- import { SandboxProviders } from "./providers.js";
24
+ import { DEVICE_PROVIDER, providerForEnvironment, SandboxProviders, } from "./providers.js";
25
25
  // Moved to leases.ts (phase C); re-exported for existing importers
26
26
  export { SANDBOX_LOCK_PREFIX, SANDBOX_LOCK_TTL_MS, SandboxLockLostError, sandboxBusy, } from "./leases.js";
27
27
  /** Margin (ms) of a holder record's TTL beyond the longest command: by then the in-sandbox limit has ended its commands. */
@@ -47,8 +47,14 @@ const opSignal = (lease) => AbortSignal.any([lease.signal, AbortSignal.timeout(S
47
47
  export const environmentResourceName = (environmentId) => `cap-env-${environmentId}`;
48
48
  /** The environment is missing, deleted, or not in the caller's org (404; ids cannot be probed). */
49
49
  export const environmentNotFound = () => new ApiError("environment.not_found", "This environment does not exist or was deleted.");
50
- /** The environment's file system is gone at the provider (409); never replaced silently. */
51
- export const environmentFilesLost = () => new ApiError("environment.files_lost", "The environment's files are gone; reset its files from the environments page to start with an empty workspace.");
50
+ /**
51
+ * The environment's file system is gone at the provider (409); never replaced silently. On a
52
+ * connected machine (`device`, details `[{ source: "device" }]`) the directory is the machine
53
+ * owner's: `capybara-device logout`, then `login` into the environment, recreates it (ruling R24).
54
+ */
55
+ export const environmentFilesLost = (device = false) => device
56
+ ? new ApiError("environment.files_lost", "The environment's directory is gone from its connected machine; run capybara-device logout, then capybara-device login into this environment, on that machine to recreate it.", [{ source: "device" }])
57
+ : new ApiError("environment.files_lost", "The environment's files are gone; reset its files from the environments page to start with an empty workspace.");
52
58
  /**
53
59
  * The row still records the instance `gone` (same handle and same `created_at`). Handles compare
54
60
  * canonically: a jsonb round trip reorders a multi-key handle's keys (final review m9).
@@ -185,11 +191,18 @@ async function inPool(items, limit, fn) {
185
191
  * row's `created_at` (set by the fenced write after each `create`); the handle alone is the same
186
192
  * after a rebuild on providers whose handle is the instance name (sandbox-docker).
187
193
  *
194
+ * - **Provider per environment** (environments spec §6): each call resolves the provider from
195
+ * the environment's `environments.provider` ({@link providerForEnvironment}): a `device`
196
+ * environment runs on the kernel-internal `@device` provider (never the cloud one), a cloud
197
+ * environment on the loaded `sandbox@2` extension. A `@device` instance is never suspended or
198
+ * destroyed for inactivity; a deleted `@device` environment (or one of a deleted org) is
199
+ * destroyed by the sweep like any other.
200
+ *
188
201
  * Files kept by another provider than the current one make the environment unusable
189
202
  * (`environment.unavailable`) until that provider is back; deleting it still works while that
190
- * provider is loaded ({@link SandboxProviders.lookup}). Without a provider {@link available} is false and
191
- * uses throw `sandbox.unavailable` (503). No in-process state: replicas share it through PG and
192
- * Redis.
203
+ * provider is loaded ({@link SandboxProviders.forName}). Without the environment's provider
204
+ * {@link availableFor} is false and uses throw `sandbox.unavailable` (503). No in-process state:
205
+ * replicas share it through PG and Redis.
193
206
  */
194
207
  let SandboxService = class SandboxService {
195
208
  db;
@@ -202,7 +215,7 @@ let SandboxService = class SandboxService {
202
215
  constructor(db,
203
216
  /** The record of the instance each holder last used (`capk:sandbox-seen:…`). */
204
217
  redis, leases,
205
- /** The `sandbox@2` provider (read at every call: providers are ready after the extension load). */
218
+ /** The providers (read at every call: providers are ready after the extension load). */
206
219
  providers, config, log,
207
220
  /** Background tasks lost while their process may live on (killed after a resume). */
208
221
  background) {
@@ -218,9 +231,38 @@ let SandboxService = class SandboxService {
218
231
  get pollMs() {
219
232
  return this.leases.pollMs;
220
233
  }
221
- /** Whether there is a sandbox provider. */
222
- get available() {
223
- return this.providers.current() !== undefined;
234
+ /**
235
+ * Whether there is a cloud sandbox provider (a loaded `sandbox@2` extension). For gates that have
236
+ * no environment yet (tool listing, skill files); a call on an environment asks
237
+ * {@link availableFor}.
238
+ */
239
+ get cloudAvailable() {
240
+ return this.providers.cloud() !== undefined;
241
+ }
242
+ /** Whether the environment's provider is there (`@device` registered, or a cloud provider). */
243
+ async availableFor(environmentId) {
244
+ return (await this.providerOf(environmentId)) !== undefined;
245
+ }
246
+ /**
247
+ * The provider the environment works with, read from its row ({@link providerForEnvironment});
248
+ * a missing row (or one of another org, with `orgId`) reads as a cloud environment, whose use
249
+ * then fails as not found.
250
+ */
251
+ async providerOf(environmentId, orgId) {
252
+ const row = await this.db
253
+ .selectFrom("environments")
254
+ .select("provider")
255
+ .where("id", "=", environmentId)
256
+ .$if(orgId !== undefined, (q) => q.where("org_id", "=", orgId))
257
+ .executeTakeFirst();
258
+ return providerForEnvironment(this.providers, row?.provider ?? null);
259
+ }
260
+ /** {@link providerOf}, or `sandbox.unavailable` (503) without it. */
261
+ async require(environmentId, orgId) {
262
+ const p = await this.providerOf(environmentId, orgId);
263
+ if (!p)
264
+ throw sandboxUnavailable();
265
+ return p;
224
266
  }
225
267
  /** Concurrent holders of one environment (`sandbox.maxConcurrentCommands`, read at every use). */
226
268
  get cap() {
@@ -290,7 +332,7 @@ let SandboxService = class SandboxService {
290
332
  * too) are thrown as they are.
291
333
  */
292
334
  async ifRunning(environmentId, instance, fn, signal = new AbortController().signal) {
293
- const p = this.require();
335
+ const p = await this.require(environmentId);
294
336
  const env = environmentId;
295
337
  const deadline = Date.now() + this.config.get().sandbox.lockWaitSeconds * 1000;
296
338
  const token = randomUUID();
@@ -319,7 +361,14 @@ let SandboxService = class SandboxService {
319
361
  try {
320
362
  const impl = tagged(p.impl, lease.token, undefined, () => this.register(env, lease.token));
321
363
  const sig = AbortSignal.any([signal, lease.signal]);
322
- return { kind: "ran", value: await fn(impl, e.handle, sig) };
364
+ try {
365
+ return { kind: "ran", value: await fn(impl, e.handle, sig) };
366
+ }
367
+ catch (err) {
368
+ if (isFileSystemGoneError(err))
369
+ throw await this.filesGone(env, p, e);
370
+ throw err;
371
+ }
323
372
  }
324
373
  finally {
325
374
  await this.leave(env, e);
@@ -353,12 +402,13 @@ let SandboxService = class SandboxService {
353
402
  * - When `fn` throws `SandboxGoneError` (the instance is gone) the instance is rebuilt on the
354
403
  * kept files (or the one another holder rebuilt is taken) and `fn` runs once more with
355
404
  * `restarted: true`; a second `gone` is thrown. Other errors are thrown as they are.
356
- * - Lost files throw `environment.files_lost` (409).
405
+ * - Lost files throw `environment.files_lost` (409), also when `fn` throws
406
+ * `FileSystemGoneError` (the files are then recorded lost, ruling R24).
357
407
  * - When `ctx.holderConversationId` last used another instance (another holder rebuilt it
358
408
  * meanwhile), `info.restarted` is true, so it learns its processes are gone (final review m5).
359
409
  */
360
410
  async withSandbox(ctx, fn, signal) {
361
- const p = this.require();
411
+ const p = await this.require(ctx.environmentId, ctx.orgId);
362
412
  const env = ctx.environmentId;
363
413
  const holder = ctx.holderConversationId;
364
414
  let e = await this.seenBy(env, holder, await this.enter(ctx, p, signal, null));
@@ -367,6 +417,8 @@ let SandboxService = class SandboxService {
367
417
  return await this.runAs(e, ctx, p, fn, signal);
368
418
  }
369
419
  catch (err) {
420
+ if (isFileSystemGoneError(err))
421
+ throw await this.filesGone(env, p, e);
370
422
  if (!isSandboxGoneError(err))
371
423
  throw err;
372
424
  this.log.info("sandbox instance is gone during use; recreating", { environmentId: env });
@@ -374,13 +426,47 @@ let SandboxService = class SandboxService {
374
426
  const prev = e;
375
427
  await this.leave(env, prev);
376
428
  e = await this.seenBy(env, holder, await this.enter(ctx, p, signal, gone));
377
- return await this.runAs({ ...e, info: { fresh: true, restarted: true } }, ctx, p, fn, signal);
429
+ try {
430
+ return await this.runAs({ ...e, info: { fresh: true, restarted: true } }, ctx, p, fn, signal);
431
+ }
432
+ catch (err2) {
433
+ if (isFileSystemGoneError(err2))
434
+ throw await this.filesGone(env, p, e);
435
+ throw err2;
436
+ }
378
437
  }
379
438
  }
380
439
  finally {
381
440
  await this.leave(env, e);
382
441
  }
383
442
  }
443
+ /**
444
+ * Ruling R24: an operation on the held instance `e` found its file system gone. Records
445
+ * `fs_status = 'lost'` while the row still records that instance on its file system (the
446
+ * instance handle stays, so a reset still destroys it), moving the fence on, and returns
447
+ * `environment.files_lost` to throw. Uses then fail until the files are reset (a connected
448
+ * machine: until a new login, which resets the row). A failed write only logs.
449
+ */
450
+ async filesGone(env, p, e) {
451
+ this.log.warn("environment file system is gone at the provider; its files are lost", {
452
+ environmentId: env,
453
+ provider: p.plugin,
454
+ });
455
+ try {
456
+ await this.db
457
+ .updateTable("environment_instances")
458
+ .set((eb) => ({ fs_status: "lost", generation: eb("generation", "+", 1) }))
459
+ .where("environment_id", "=", env)
460
+ .where("fs_status", "=", "ready")
461
+ // `createdAt` is the row's value read back in milliseconds
462
+ .where(sql `date_trunc('milliseconds', created_at)`, "=", new Date(e.createdAt))
463
+ .execute();
464
+ }
465
+ catch (err) {
466
+ this.log.warn("cannot record lost environment files", { environmentId: env, err });
467
+ }
468
+ return environmentFilesLost(p.plugin === DEVICE_PROVIDER);
469
+ }
384
470
  /**
385
471
  * Records that `holder` uses the instance of `e` (`capk:sandbox-seen:<env>:<holder>` =
386
472
  * `<created_at>:<file system id>`, with a TTL of the inactivity destruction plus a day). When the
@@ -415,7 +501,7 @@ let SandboxService = class SandboxService {
415
501
  const sig = AbortSignal.any([signal, e.lease.signal]);
416
502
  const env = ctx.environmentId;
417
503
  const impl = tagged(p.impl, e.lease.token, ctx.actor, () => this.register(env, e.lease.token));
418
- return fn(e.handle, impl, e.info, sig);
504
+ return fn(e.handle, impl, { ...e.info, roots: rootsOf(e.handle) }, sig);
419
505
  }
420
506
  /**
421
507
  * Becomes a holder: loops until it has a shared lease on a running instance, or `sandbox.busy`.
@@ -480,10 +566,14 @@ let SandboxService = class SandboxService {
480
566
  foreign(_ctx) {
481
567
  return environmentNotFound();
482
568
  }
483
- /** Recorded running with the current provider, and not the instance found gone. */
569
+ /**
570
+ * Recorded running with the current provider on files not lost (lost ones go through the
571
+ * exclusive path, which refuses them), and not the instance found gone.
572
+ */
484
573
  usable(row, p, gone) {
485
574
  return (row !== undefined &&
486
575
  row.status === "running" &&
576
+ row.fs_status !== "lost" &&
487
577
  row.provider === p.plugin &&
488
578
  row.handle !== null &&
489
579
  !isInstance(row, gone));
@@ -624,7 +714,8 @@ let SandboxService = class SandboxService {
624
714
  * many it suspended. Called every minute by the `sandbox-suspend-idle` cron.
625
715
  */
626
716
  async suspendIdle(now) {
627
- const p = this.providers.current();
717
+ // cloud only: suspendDue keeps to this provider's rows, so `@device` is never suspended here
718
+ const p = this.providers.cloud();
628
719
  if (!p)
629
720
  return 0;
630
721
  const due = this.suspendDue(now, p.plugin);
@@ -666,31 +757,45 @@ let SandboxService = class SandboxService {
666
757
  * (the instance only; the file system is kept and the next use builds a new instance on it); an
667
758
  * environment that was deleted, or whose org was deleted, loses its instance and its file system
668
759
  * and its row is deleted (as {@link destroyEnvironment} does). Environments with any holder are
669
- * skipped. Returns how many it destroyed. Called hourly by the `sandbox-destroy-inactive` cron.
760
+ * skipped. Inactivity never destroys a `@device` instance; a gone `@device` environment is
761
+ * destroyed like any other while `@device` is registered. Returns how many it destroyed. Called
762
+ * hourly by the `sandbox-destroy-inactive` cron.
670
763
  */
671
764
  async destroyInactive(now) {
672
- const p = this.providers.current();
673
- if (!p)
674
- return 0;
765
+ const p = this.providers.cloud();
766
+ const device = this.providers.forName(DEVICE_PROVIDER) !== undefined;
675
767
  let n = 0;
676
- const gone = (eb) => this.environmentGone(eb);
677
- await inPool(await this.candidates(gone), SWEEP_CONCURRENCY, async ({ environment_id: id }) => {
678
- const done = await this.tryLocked(id, async (lease) => {
679
- const row = await this.db
680
- .selectFrom("environment_instances")
681
- .selectAll()
682
- .where("environment_id", "=", id)
683
- .where(gone)
684
- .executeTakeFirst();
685
- if (!row)
686
- return false;
687
- await this.destroyRow(row, p, lease, "environment");
688
- return true;
768
+ // Gone environments (deleted, or of a deleted org) on every provider that is here, `@device`
769
+ // too (its destroy calls never need the machine online); rows whose provider is missing wait.
770
+ const here = (eb) => eb.or([
771
+ ...(p ? [this.cloudRow(eb)] : []),
772
+ ...(device ? [eb("provider", "=", DEVICE_PROVIDER)] : []),
773
+ ]);
774
+ const gone = (eb) => eb.and([here(eb), this.environmentGone(eb)]);
775
+ if (p || device) {
776
+ await inPool(await this.candidates(gone), SWEEP_CONCURRENCY, async ({ environment_id: id }) => {
777
+ const done = await this.tryLocked(id, async (lease) => {
778
+ const row = await this.db
779
+ .selectFrom("environment_instances")
780
+ .selectAll()
781
+ .where("environment_id", "=", id)
782
+ .where(gone)
783
+ .executeTakeFirst();
784
+ const owner = row && this.rowProvider(row);
785
+ if (!row || !owner)
786
+ return false;
787
+ await this.destroyRow(row, owner, lease, "environment");
788
+ return true;
789
+ });
790
+ if (done)
791
+ n++;
689
792
  });
690
- if (done)
691
- n++;
692
- });
693
- const idle = this.instanceDue(now);
793
+ }
794
+ // inactivity destroys cloud instances only: `@device` instances are never swept
795
+ if (!p)
796
+ return n;
797
+ const due = this.instanceDue(now);
798
+ const idle = (eb) => eb.and([this.cloudRow(eb), due(eb)]);
694
799
  await inPool(await this.candidates(idle), SWEEP_CONCURRENCY, async ({ environment_id: id }) => {
695
800
  const done = await this.tryLocked(id, async (lease) => {
696
801
  const row = await this.db
@@ -712,9 +817,11 @@ let SandboxService = class SandboxService {
712
817
  /**
713
818
  * Destroys the environment's instance, then its file system, then deletes its instance row
714
819
  * (environment deletion, offboarding); nothing to do without a row. Waits for the exclusive lease
715
- * (with intent, so running commands finish and new ones wait) up to 10 minutes. Without a
716
- * provider it only warns (the destruction sweep finishes a deleted environment later). Files kept
717
- * by another provider that is not loaded cannot be destroyed: it throws and the row stays.
820
+ * (with intent, so running commands finish and new ones wait) up to 10 minutes. Without the
821
+ * environment's provider it throws and the row stays, marked `sweep_failed_at` (the job is
822
+ * retried, and the destruction sweep finishes a deleted environment once the provider is back).
823
+ * Files kept by another provider that is not loaded cannot be destroyed: it throws and the row
824
+ * stays.
718
825
  */
719
826
  async destroyEnvironment(environmentId) {
720
827
  const row = await this.db
@@ -724,10 +831,15 @@ let SandboxService = class SandboxService {
724
831
  .executeTakeFirst();
725
832
  if (!row)
726
833
  return;
727
- const p = this.providers.current();
834
+ const p = await this.providerOf(environmentId);
728
835
  if (!p) {
729
- this.log.warn("cannot destroy environment: no sandbox provider", { environmentId });
730
- return;
836
+ await this.db
837
+ .updateTable("environment_instances")
838
+ .set({ sweep_failed_at: sql `now()` })
839
+ .where("environment_id", "=", environmentId)
840
+ .execute();
841
+ throw new Error(`cannot destroy environment ${environmentId}: its sandbox provider is not there; ` +
842
+ "the destruction sweep retries it");
731
843
  }
732
844
  const lease = await this.leases.acquireExclusive(environmentId, AbortSignal.timeout(EXCLUSIVE_WAIT_MS), undefined, { intent: true });
733
845
  try {
@@ -750,7 +862,7 @@ let SandboxService = class SandboxService {
750
862
  * creates a new one. Nothing to do without a row. Throws `sandbox.unavailable` without a provider.
751
863
  */
752
864
  async resetFileSystem(environmentId) {
753
- const p = this.require();
865
+ const p = await this.require(environmentId);
754
866
  const lease = await this.leases.acquireExclusive(environmentId, AbortSignal.timeout(EXCLUSIVE_WAIT_MS), undefined, { intent: true });
755
867
  try {
756
868
  const row = await this.db
@@ -839,12 +951,6 @@ let SandboxService = class SandboxService {
839
951
  }
840
952
  await this.background.clearKill(cleared.map((r) => r.id));
841
953
  }
842
- require() {
843
- const p = this.providers.current();
844
- if (!p)
845
- throw sandboxUnavailable();
846
- return p;
847
- }
848
954
  /**
849
955
  * A fenced write: only while the lease holds and the row's generation is still the one read
850
956
  * under it (and incremented); otherwise {@link SandboxLockLostError}.
@@ -968,7 +1074,7 @@ let SandboxService = class SandboxService {
968
1074
  // kept running (a stalled sweep) then fails its next write.
969
1075
  await this.fenced(held, id, fence, {});
970
1076
  if (row.fs_status === "lost")
971
- throw environmentFilesLost();
1077
+ throw environmentFilesLost(p.plugin === DEVICE_PROVIDER);
972
1078
  if (row.provider !== null &&
973
1079
  row.provider !== p.plugin &&
974
1080
  (row.fs_status !== "none" || row.handle !== null)) {
@@ -1080,7 +1186,7 @@ let SandboxService = class SandboxService {
1080
1186
  provider: p.plugin,
1081
1187
  });
1082
1188
  await this.fenced(held, id, fence, { fs_status: "lost", status: "none" });
1083
- throw environmentFilesLost();
1189
+ throw environmentFilesLost(p.plugin === DEVICE_PROVIDER);
1084
1190
  }
1085
1191
  let createdAt;
1086
1192
  try {
@@ -1115,7 +1221,7 @@ let SandboxService = class SandboxService {
1115
1221
  * usable; an abort is thrown.
1116
1222
  */
1117
1223
  async dropUnrecordedFiles(id, provider, signal) {
1118
- const old = this.providers.lookup(provider);
1224
+ const old = this.providers.forName(provider)?.impl;
1119
1225
  if (!old) {
1120
1226
  this.log.warn("environment file system may have been made by a provider that is not loaded; it may leak", { environmentId: id, provider });
1121
1227
  return;
@@ -1138,7 +1244,7 @@ let SandboxService = class SandboxService {
1138
1244
  * false; otherwise warns and returns true (the record must be kept).
1139
1245
  */
1140
1246
  async retire(id, provider, handle, signal) {
1141
- const old = this.providers.lookup(provider);
1247
+ const old = this.providers.forName(provider)?.impl;
1142
1248
  if (old) {
1143
1249
  try {
1144
1250
  await old.destroy(handle, AbortSignal.any([signal, AbortSignal.timeout(SWEEP_OP_TIMEOUT_MS)]));
@@ -1175,7 +1281,7 @@ let SandboxService = class SandboxService {
1175
1281
  }
1176
1282
  else if (!row.handle && row.status === "creating") {
1177
1283
  // the provider may have built it already: only the name finds it
1178
- const impl = owner === p.plugin ? p.impl : this.providers.lookup(owner);
1284
+ const impl = owner === p.plugin ? p.impl : this.providers.forName(owner)?.impl;
1179
1285
  await impl?.destroyByName(environmentResourceName(id), signal);
1180
1286
  }
1181
1287
  if (row.prev_handle && row.prev_provider && !("prev_handle" in prev)) {
@@ -1194,7 +1300,7 @@ let SandboxService = class SandboxService {
1194
1300
  async destroyFiles(row, p, signal, lenient = false) {
1195
1301
  const id = row.environment_id;
1196
1302
  const owner = row.provider ?? p.plugin;
1197
- const impl = owner === p.plugin ? p.impl : this.providers.lookup(owner);
1303
+ const impl = owner === p.plugin ? p.impl : this.providers.forName(owner)?.impl;
1198
1304
  if (!impl) {
1199
1305
  if (row.fs_handle === null)
1200
1306
  return;
@@ -1256,6 +1362,17 @@ let SandboxService = class SandboxService {
1256
1362
  eb.or([eb("last_used_at", "<", idleBefore), eb("running_since", "<", runningBefore)]),
1257
1363
  ]);
1258
1364
  }
1365
+ /** The provider of an instance row: an internal one (`@device`) by name, else the cloud one. */
1366
+ rowProvider(row) {
1367
+ return providerForEnvironment(this.providers, row.provider);
1368
+ }
1369
+ /**
1370
+ * A row the idle sweeps may touch: not on a kernel-internal provider (`@device` instances live on
1371
+ * people's machines and are never suspended or destroyed for inactivity).
1372
+ */
1373
+ cloudRow(eb) {
1374
+ return eb.or([eb("provider", "is", null), eb("provider", "not like", "@%")]);
1375
+ }
1259
1376
  /** The environment was deleted, or its org was: the instance and the files go. */
1260
1377
  environmentGone(eb) {
1261
1378
  return eb.or([
@@ -9,8 +9,8 @@ export declare const BASH_DESCRIPTION_MAX_CHARS = 120;
9
9
  export declare const BASH_SPILL_CHARS = 30000;
10
10
  /**
11
11
  * 命令结果 → 给模型的文本。`budgetClamped`:运行的时限把超时收紧到了 `seconds`(Ruling M20),超时说明改为
12
- * 指向 `runtime.maxRunSeconds`,而不是让模型加大 timeout。
12
+ * 指向 `runtime.maxRunSeconds`,而不是让模型加大 timeout。`workspace`: the instance's workspace root.
13
13
  */
14
- export declare function formatExec(r: ExecResult, seconds: number, budgetClamped?: boolean): string;
14
+ export declare function formatExec(r: ExecResult, seconds: number, budgetClamped?: boolean, workspace?: string): string;
15
15
  /** 内置工具 `Bash`:在本对话的沙箱里执行命令。 */
16
16
  export declare function sandboxBashTool(deps: SandboxToolDeps): BuiltinTool;