@geminixiang/mikan 1.3.0 → 1.4.0

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 (106) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +7 -8
  3. package/dist/adapters/commands/login.js +1 -1
  4. package/dist/adapters/commands/login.js.map +1 -1
  5. package/dist/adapters/commands/sandbox.js +2 -2
  6. package/dist/adapters/commands/sandbox.js.map +1 -1
  7. package/dist/cli/boot.js +1 -1
  8. package/dist/cli/boot.js.map +1 -1
  9. package/dist/cli/migrate.d.ts.map +1 -1
  10. package/dist/cli/migrate.js +3 -2
  11. package/dist/cli/migrate.js.map +1 -1
  12. package/dist/cli/onboard.d.ts.map +1 -1
  13. package/dist/cli/onboard.js +15 -13
  14. package/dist/cli/onboard.js.map +1 -1
  15. package/dist/cli/pm2-ecosystem.d.ts +1 -2
  16. package/dist/cli/pm2-ecosystem.d.ts.map +1 -1
  17. package/dist/cli/pm2-ecosystem.js +2 -3
  18. package/dist/cli/pm2-ecosystem.js.map +1 -1
  19. package/dist/env-manifest.d.ts.map +1 -1
  20. package/dist/env-manifest.js +11 -0
  21. package/dist/env-manifest.js.map +1 -1
  22. package/dist/harness/execution-resolver.d.ts +3 -2
  23. package/dist/harness/execution-resolver.d.ts.map +1 -1
  24. package/dist/harness/execution-resolver.js +12 -10
  25. package/dist/harness/execution-resolver.js.map +1 -1
  26. package/dist/harness/prompt.d.ts.map +1 -1
  27. package/dist/harness/prompt.js +3 -2
  28. package/dist/harness/prompt.js.map +1 -1
  29. package/dist/harness/runner.d.ts.map +1 -1
  30. package/dist/harness/runner.js +37 -62
  31. package/dist/harness/runner.js.map +1 -1
  32. package/dist/harness/session.js +1 -1
  33. package/dist/harness/session.js.map +1 -1
  34. package/dist/harness/tools/attach.d.ts +3 -2
  35. package/dist/harness/tools/attach.d.ts.map +1 -1
  36. package/dist/harness/tools/attach.js +9 -2
  37. package/dist/harness/tools/attach.js.map +1 -1
  38. package/dist/harness/tools/index.d.ts +3 -2
  39. package/dist/harness/tools/index.d.ts.map +1 -1
  40. package/dist/harness/tools/index.js +6 -4
  41. package/dist/harness/tools/index.js.map +1 -1
  42. package/dist/harness/tools/jev-browser.d.ts +2 -2
  43. package/dist/harness/tools/jev-browser.d.ts.map +1 -1
  44. package/dist/harness/tools/jev-browser.js +41 -33
  45. package/dist/harness/tools/jev-browser.js.map +1 -1
  46. package/dist/harness/tools/sandbox.js +2 -2
  47. package/dist/harness/tools/sandbox.js.map +1 -1
  48. package/dist/harness/tools/types.d.ts +4 -0
  49. package/dist/harness/tools/types.d.ts.map +1 -1
  50. package/dist/harness/tools/types.js.map +1 -1
  51. package/dist/harness/tools/web-fetch.d.ts +6 -0
  52. package/dist/harness/tools/web-fetch.d.ts.map +1 -0
  53. package/dist/harness/tools/web-fetch.js +136 -0
  54. package/dist/harness/tools/web-fetch.js.map +1 -0
  55. package/dist/harness/types.d.ts +4 -3
  56. package/dist/harness/types.d.ts.map +1 -1
  57. package/dist/harness/types.js.map +1 -1
  58. package/dist/main.js +10 -24
  59. package/dist/main.js.map +1 -1
  60. package/dist/migrations/index.js +2 -2
  61. package/dist/migrations/index.js.map +1 -1
  62. package/dist/package-version.d.ts +2 -0
  63. package/dist/package-version.d.ts.map +1 -0
  64. package/dist/package-version.js +19 -0
  65. package/dist/package-version.js.map +1 -0
  66. package/dist/sandbox/container.d.ts +3 -17
  67. package/dist/sandbox/container.d.ts.map +1 -1
  68. package/dist/sandbox/container.js +108 -129
  69. package/dist/sandbox/container.js.map +1 -1
  70. package/dist/sandbox/engine.d.ts +4 -0
  71. package/dist/sandbox/engine.d.ts.map +1 -0
  72. package/dist/sandbox/engine.js +30 -0
  73. package/dist/sandbox/engine.js.map +1 -0
  74. package/dist/sandbox/host.d.ts +1 -10
  75. package/dist/sandbox/host.d.ts.map +1 -1
  76. package/dist/sandbox/host.js +2 -79
  77. package/dist/sandbox/host.js.map +1 -1
  78. package/dist/sandbox/provisioner.d.ts +16 -1
  79. package/dist/sandbox/provisioner.d.ts.map +1 -1
  80. package/dist/sandbox/provisioner.js +173 -111
  81. package/dist/sandbox/provisioner.js.map +1 -1
  82. package/dist/sandbox/published-image.d.ts +3 -0
  83. package/dist/sandbox/published-image.d.ts.map +1 -0
  84. package/dist/sandbox/published-image.js +9 -0
  85. package/dist/sandbox/published-image.js.map +1 -0
  86. package/dist/sandbox/registry.d.ts +5 -3
  87. package/dist/sandbox/registry.d.ts.map +1 -1
  88. package/dist/sandbox/registry.js +36 -27
  89. package/dist/sandbox/registry.js.map +1 -1
  90. package/dist/sandbox/types.d.ts +14 -22
  91. package/dist/sandbox/types.d.ts.map +1 -1
  92. package/dist/sandbox/types.js.map +1 -1
  93. package/dist/sandbox/utils.d.ts +0 -15
  94. package/dist/sandbox/utils.d.ts.map +1 -1
  95. package/dist/sandbox/utils.js +0 -72
  96. package/dist/sandbox/utils.js.map +1 -1
  97. package/dist/types.d.ts +2 -0
  98. package/dist/types.d.ts.map +1 -1
  99. package/dist/types.js.map +1 -1
  100. package/dist/vault/index.js +1 -1
  101. package/dist/vault/index.js.map +1 -1
  102. package/package.json +3 -1
  103. package/dist/harness/execution-env.d.ts +0 -4
  104. package/dist/harness/execution-env.d.ts.map +0 -1
  105. package/dist/harness/execution-env.js +0 -322
  106. package/dist/harness/execution-env.js.map +0 -1
@@ -4,29 +4,19 @@ import { readFileSync, statSync } from "node:fs";
4
4
  import { isAbsolute, relative } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import * as log from "../log.js";
7
+ import { isPublishedSandboxImage } from "./published-image.js";
7
8
  import { reportUserFacingError } from "../observability/index.js";
8
9
  const execFileAsync = promisify(execFile);
9
- function isDockerNotFoundError(err) {
10
- if (!err || typeof err !== "object")
11
- return false;
12
- const stderr = err.stderr;
13
- const message = err.message;
14
- const haystack = `${typeof stderr === "string" ? stderr : ""}\n${typeof message === "string" ? message : ""}`.toLowerCase();
15
- return (haystack.includes("no such network") ||
16
- haystack.includes("no such container") ||
17
- haystack.includes("no such object") ||
18
- haystack.includes("network not found") ||
19
- /network [^\n]+ not found/.test(haystack) ||
20
- /error: no such [^\n]+/.test(haystack));
21
- }
22
- import { errorMessage } from "../unknown-values.js";
23
- import { sweepOrphanedCommands } from "./container.js";
10
+ import { errorMessage, isRecord } from "../unknown-values.js";
11
+ import { containerEngine } from "./engine.js";
24
12
  export class DockerContainerManager {
25
13
  static { this.MANAGED_LABEL = "mikan.managed=true"; }
26
14
  static { this.IMAGE_MODE_LABEL = "mikan.sandbox=image"; }
27
15
  static { this.VAULT_ID_LABEL_KEY = "mikan.vault-id"; }
28
16
  static { this.CONVERSATION_ID_LABEL_KEY = "mikan.conversation-id"; }
29
17
  static { this.MOUNT_SIGNATURE_LABEL_KEY = "mikan.mount-signature"; }
18
+ static { this.NETWORK_LABEL_KEY = "mikan.network"; }
19
+ static { this.IMAGE_ID_LABEL_KEY = "mikan.image-id"; }
30
20
  constructor(image, options = {}) {
31
21
  this.image = image;
32
22
  this.state = new Map();
@@ -37,6 +27,14 @@ export class DockerContainerManager {
37
27
  this.limits = options.limits;
38
28
  this.boostLimits = options.boostLimits;
39
29
  this.execFileImpl = options.execFileImpl ?? execFileAsync;
30
+ this.configuredEngine = options.engine;
31
+ this.timeZone = options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
32
+ }
33
+ get engine() {
34
+ return this.configuredEngine ?? containerEngine();
35
+ }
36
+ run(args) {
37
+ return this.execFileImpl(this.engine, args);
40
38
  }
41
39
  static containerName(containerKey) {
42
40
  return `mikan-sandbox-${containerKey}`;
@@ -75,11 +73,14 @@ export class DockerContainerManager {
75
73
  : await this.runtimeDrift(containerKey, containerName, mounts, status);
76
74
  if (drift) {
77
75
  log.logInfo(`Container ${containerName} is out of date (${drift}); replacing container`);
76
+ const leftBehind = await this.imageLeftBehind(containerName);
78
77
  await this.replaceContainer(containerKey, containerName, mounts, options);
79
78
  log.logInfo(`Container ${containerName} replaced from image ${this.image}`);
79
+ if (leftBehind)
80
+ await this.removeImageIfUnused(leftBehind);
80
81
  }
81
82
  else if (status === "stopped") {
82
- await this.execFileImpl("docker", ["start", containerName]);
83
+ await this.run(["start", containerName]);
83
84
  log.logInfo(`Container ${containerName} started`);
84
85
  }
85
86
  else if (status === "missing") {
@@ -126,13 +127,18 @@ export class DockerContainerManager {
126
127
  getBoostLimits() {
127
128
  return this.boostLimits;
128
129
  }
130
+ markUsed(containerKey) {
131
+ const state = this.state.get(containerKey);
132
+ if (state)
133
+ state.lastUsed = Date.now();
134
+ }
129
135
  stop(containerKey) {
130
136
  return this.serialize(containerKey, () => this.stopInner(containerKey));
131
137
  }
132
138
  async stopInner(containerKey) {
133
139
  const containerName = this.getContainerName(containerKey);
134
140
  try {
135
- await this.execFileImpl("docker", ["stop", containerName]);
141
+ await this.run(["stop", containerName]);
136
142
  this.setState(containerKey, "stopped", containerName);
137
143
  this.boostedKeys.delete(containerKey);
138
144
  this.overrideLimits.delete(containerKey);
@@ -153,7 +159,7 @@ export class DockerContainerManager {
153
159
  throw new Error(`Failed to remove container ${containerName}`);
154
160
  }
155
161
  try {
156
- await this.execFileImpl("docker", ["network", "rm", networkName]);
162
+ await this.run(["network", "rm", networkName]);
157
163
  log.logInfo(`Network ${networkName} removed`);
158
164
  }
159
165
  catch (err) {
@@ -192,9 +198,6 @@ export class DockerContainerManager {
192
198
  const lastUsed = details.startedAtMs ?? Date.now();
193
199
  this.state.set(containerKey, { status, lastUsed, containerName });
194
200
  }
195
- await Promise.all(Array.from(this.state.values())
196
- .filter((state) => state.status === "running")
197
- .map((state) => sweepOrphanedCommands(state.containerName, this.execFileImpl)));
198
201
  const running = Array.from(this.state.values()).filter((s) => s.status === "running").length;
199
202
  const stopped = this.state.size - running;
200
203
  log.logInfo(`Reconciled ${this.state.size} managed containers (running=${running}, stopped=${stopped})`);
@@ -229,7 +232,9 @@ export class DockerContainerManager {
229
232
  if (mounts.length > 0) {
230
233
  labels.push("--label", `${DockerContainerManager.MOUNT_SIGNATURE_LABEL_KEY}=${this.mountSignature(mounts)}`);
231
234
  }
232
- await this.execFileImpl("docker", [
235
+ labels.push("--label", `${DockerContainerManager.NETWORK_LABEL_KEY}=${networkName}`);
236
+ labels.push("--label", `${DockerContainerManager.IMAGE_ID_LABEL_KEY}=${await this.pulledImageId()}`);
237
+ await this.run([
233
238
  "run",
234
239
  "-d",
235
240
  "--name",
@@ -242,6 +247,8 @@ export class DockerContainerManager {
242
247
  "no-new-privileges",
243
248
  "--pids-limit",
244
249
  "1024",
250
+ "--env",
251
+ `TZ=${this.timeZone}`,
245
252
  ...labels,
246
253
  ...this.resourceLimitArgs(this.effectiveLimits(containerKey)),
247
254
  ...this.mountArgs(mounts),
@@ -261,7 +268,7 @@ export class DockerContainerManager {
261
268
  resourceLimitArgs(limits) {
262
269
  const args = [];
263
270
  if (limits?.cpus)
264
- args.push("--cpus", limits.cpus);
271
+ args.push(...cpuQuotaArgs(limits.cpus));
265
272
  if (limits?.memory) {
266
273
  args.push("--memory", limits.memory);
267
274
  args.push("--memory-swap", limits.memory);
@@ -274,7 +281,7 @@ export class DockerContainerManager {
274
281
  return;
275
282
  const args = ["update", ...limitArgs, containerName];
276
283
  try {
277
- await this.execFileImpl("docker", args);
284
+ await this.run(args);
278
285
  }
279
286
  catch (err) {
280
287
  log.logWarning(`Failed to apply resource limits to container ${containerName}`, errorMessage(err));
@@ -284,7 +291,7 @@ export class DockerContainerManager {
284
291
  operation: "apply_resource_limits",
285
292
  severity: "warning",
286
293
  context: {
287
- sandboxType: "image",
294
+ sandboxType: "oci",
288
295
  containerKey,
289
296
  containerName,
290
297
  limitArgCount: limitArgs.length,
@@ -323,14 +330,8 @@ export class DockerContainerManager {
323
330
  if (mounts.length === 0)
324
331
  return false;
325
332
  const expected = this.mountSignature(mounts);
326
- const { stdout } = await this.execFileImpl("docker", [
327
- "inspect",
328
- "-f",
329
- `{{index .Config.Labels "${DockerContainerManager.MOUNT_SIGNATURE_LABEL_KEY}"}}`,
330
- containerName,
331
- ]);
332
- const actual = this.normalizeDockerValue(stdout.trim());
333
- return actual !== expected;
333
+ return ((await this.label(containerName, DockerContainerManager.MOUNT_SIGNATURE_LABEL_KEY)) !==
334
+ expected);
334
335
  }
335
336
  mountSignature(mounts) {
336
337
  const payload = mounts
@@ -354,50 +355,95 @@ export class DockerContainerManager {
354
355
  return "missing";
355
356
  }
356
357
  }
357
- async inspectBindMounts(containerName) {
358
- const { stdout } = await this.execFileImpl("docker", [
358
+ async label(containerName, key) {
359
+ const { stdout } = await this.run([
359
360
  "inspect",
360
361
  "-f",
361
- "{{json .HostConfig.Binds}}",
362
+ `{{index .Config.Labels "${key}"}}`,
362
363
  containerName,
363
364
  ]);
364
- const payload = stdout.trim();
365
- const parsed = JSON.parse(payload.length > 0 ? payload : "null");
366
- if (parsed === null) {
367
- return [];
368
- }
369
- if (!Array.isArray(parsed) || parsed.some((bind) => typeof bind !== "string")) {
370
- throw new Error(`Unexpected docker bind mount payload for container "${containerName}"`);
371
- }
372
- return [...parsed].toSorted();
365
+ return this.normalizeDockerValue(stdout.trim());
366
+ }
367
+ async inspectBindMounts(containerName) {
368
+ const { stdout } = await this.run(["inspect", "-f", "{{json .Mounts}}", containerName]);
369
+ return parseBindMounts(stdout, containerName)
370
+ .map((mount) => `${mount.source}:${mount.target}${mount.readOnly ? ":ro" : ""}`)
371
+ .toSorted();
373
372
  }
374
373
  async hasImageDrift(containerName) {
375
374
  const desired = await this.localImageId();
376
375
  if (!desired)
377
376
  return false;
378
- const { stdout } = await this.execFileImpl("docker", [
377
+ return (await this.label(containerName, DockerContainerManager.IMAGE_ID_LABEL_KEY)) !== desired;
378
+ }
379
+ async localImageId() {
380
+ try {
381
+ const { stdout } = await this.run(["image", "inspect", "-f", "{{.Id}}", this.image]);
382
+ return this.normalizeDockerValue(stdout.trim());
383
+ }
384
+ catch {
385
+ return undefined;
386
+ }
387
+ }
388
+ prepareImage() {
389
+ this.imagePull ??= this.pullImage();
390
+ return this.imagePull;
391
+ }
392
+ async pullImage() {
393
+ try {
394
+ await this.run(["pull", this.image]);
395
+ log.logInfo(`Sandbox image ${this.image} is current`);
396
+ }
397
+ catch (err) {
398
+ log.logWarning(`Could not pull sandbox image ${this.image}; offices keep the local image if there is one`, errorMessage(err));
399
+ }
400
+ }
401
+ async imageLeftBehind(containerName) {
402
+ const { stdout } = await this.run([
379
403
  "inspect",
380
404
  "-f",
381
- "{{.Image}}",
405
+ `{{.Config.Image}}\t{{index .Config.Labels "${DockerContainerManager.IMAGE_ID_LABEL_KEY}"}}`,
382
406
  containerName,
383
407
  ]);
384
- return stdout.trim() !== desired;
408
+ const [reference = "", label] = stdout.trim().split("\t");
409
+ const imageId = this.normalizeDockerValue(label);
410
+ if (imageId && imageId === (await this.localImageId()))
411
+ return undefined;
412
+ if (reference !== this.image && isPublishedSandboxImage(reference))
413
+ return reference;
414
+ if (imageId && (await this.isUnnamedImage(imageId)))
415
+ return imageId;
416
+ return undefined;
385
417
  }
386
- async localImageId() {
418
+ async isUnnamedImage(imageId) {
387
419
  try {
388
- const { stdout } = await this.execFileImpl("docker", [
389
- "image",
390
- "inspect",
391
- "-f",
392
- "{{.Id}}",
393
- this.image,
394
- ]);
395
- return this.normalizeDockerValue(stdout.trim());
420
+ const { stdout } = await this.run(["image", "inspect", "-f", "{{len .RepoTags}}", imageId]);
421
+ return stdout.trim() === "0";
396
422
  }
397
423
  catch {
398
- return undefined;
424
+ return false;
399
425
  }
400
426
  }
427
+ async removeImageIfUnused(image) {
428
+ try {
429
+ await this.run(["rmi", image]);
430
+ log.logInfo(`Removed sandbox image ${image}, which no container uses`);
431
+ }
432
+ catch {
433
+ return;
434
+ }
435
+ }
436
+ async pulledImageId() {
437
+ await this.imagePull;
438
+ const local = await this.localImageId();
439
+ if (local)
440
+ return local;
441
+ await this.run(["pull", this.image]);
442
+ const pulled = await this.localImageId();
443
+ if (!pulled)
444
+ throw new Error(`Image ${this.image} has no ID after pulling it`);
445
+ return pulled;
446
+ }
401
447
  async replaceContainer(containerKey, containerName, mounts, options) {
402
448
  const removed = await this.forceRemoveContainer(containerName, `Container ${containerName} removed for replacement`, `Failed to remove container ${containerName} for replacement`);
403
449
  if (!removed)
@@ -406,25 +452,21 @@ export class DockerContainerManager {
406
452
  }
407
453
  async hasNetworkModeDrift(containerKey, containerName) {
408
454
  const expected = DockerContainerManager.networkName(containerKey);
409
- const { stdout } = await this.execFileImpl("docker", [
410
- "inspect",
411
- "-f",
412
- "{{.HostConfig.NetworkMode}}",
413
- containerName,
414
- ]);
415
- return stdout.trim() !== expected;
455
+ return (await this.label(containerName, DockerContainerManager.NETWORK_LABEL_KEY)) !== expected;
416
456
  }
417
457
  async ensureNetwork(containerKey) {
418
458
  const networkName = DockerContainerManager.networkName(containerKey);
419
- try {
420
- await this.execFileImpl("docker", ["network", "inspect", networkName]);
459
+ const { stdout } = await this.run([
460
+ "network",
461
+ "ls",
462
+ "--filter",
463
+ `name=^${networkName}$`,
464
+ "--format",
465
+ "{{.Name}}",
466
+ ]);
467
+ if (this.parseNameLines(stdout).includes(networkName))
421
468
  return networkName;
422
- }
423
- catch (err) {
424
- if (!isDockerNotFoundError(err))
425
- throw err;
426
- }
427
- await this.execFileImpl("docker", [
469
+ await this.run([
428
470
  "network",
429
471
  "create",
430
472
  "--driver",
@@ -439,25 +481,26 @@ export class DockerContainerManager {
439
481
  ]);
440
482
  return networkName;
441
483
  }
484
+ async containerExists(containerName) {
485
+ const { stdout } = await this.run([
486
+ "ps",
487
+ "-a",
488
+ "--filter",
489
+ `name=^${containerName}$`,
490
+ "--format",
491
+ "{{.Names}}",
492
+ ]);
493
+ return this.parseNameLines(stdout).includes(containerName);
494
+ }
442
495
  async inspectStatus(containerName) {
443
- try {
444
- const { stdout } = await this.execFileImpl("docker", [
445
- "inspect",
446
- "-f",
447
- "{{.State.Running}}",
448
- containerName,
449
- ]);
450
- return stdout.trim() === "true" ? "running" : "stopped";
451
- }
452
- catch (err) {
453
- if (isDockerNotFoundError(err))
454
- return "missing";
455
- throw err;
456
- }
496
+ if (!(await this.containerExists(containerName)))
497
+ return "missing";
498
+ const { stdout } = await this.run(["inspect", "-f", "{{.State.Running}}", containerName]);
499
+ return stdout.trim() === "true" ? "running" : "stopped";
457
500
  }
458
501
  async listContainerNamesByLabel() {
459
502
  try {
460
- const { stdout } = await this.execFileImpl("docker", [
503
+ const { stdout } = await this.run([
461
504
  "ps",
462
505
  "-a",
463
506
  "--filter",
@@ -482,16 +525,17 @@ export class DockerContainerManager {
482
525
  }
483
526
  async inspectContainerDetails(containerName) {
484
527
  try {
485
- const { stdout } = await this.execFileImpl("docker", [
528
+ const { stdout } = await this.run([
486
529
  "inspect",
487
530
  "-f",
488
- `{{.State.Running}}\t{{.State.StartedAt}}\t{{json .HostConfig.Binds}}`,
531
+ `{{.State.Running}}\t{{.State.StartedAt}}\t{{json .Mounts}}`,
489
532
  containerName,
490
533
  ]);
491
- const [runningRaw, startedAtRaw, bindsRaw] = stdout.trim().split("\t");
534
+ const [runningRaw, startedAtRaw, mountsRaw] = stdout.trim().split("\t");
492
535
  const running = runningRaw === "true";
493
- const startedAtMs = this.parseDockerTimestamp(startedAtRaw);
494
- return { running, startedAtMs, bindSources: parseBindSources(bindsRaw) };
536
+ const startedAtMs = parseEngineTimestamp(this.normalizeDockerValue(startedAtRaw));
537
+ const bindSources = parseBindMounts(mountsRaw ?? "", containerName).map((mount) => mount.source);
538
+ return { running, startedAtMs, bindSources };
495
539
  }
496
540
  catch (err) {
497
541
  log.logWarning(`Failed to inspect container ${containerName} during reconcile`, errorMessage(err));
@@ -504,13 +548,6 @@ export class DockerContainerManager {
504
548
  const trimmed = value.trim();
505
549
  return trimmed.length > 0 ? trimmed : undefined;
506
550
  }
507
- parseDockerTimestamp(value) {
508
- const normalized = this.normalizeDockerValue(value);
509
- if (!normalized || normalized.startsWith("0001-"))
510
- return undefined;
511
- const parsed = Date.parse(normalized);
512
- return Number.isNaN(parsed) ? undefined : parsed;
513
- }
514
551
  containerKeyFromContainerName(containerName) {
515
552
  const prefix = DockerContainerManager.containerName("");
516
553
  if (!containerName.startsWith(prefix))
@@ -520,26 +557,51 @@ export class DockerContainerManager {
520
557
  }
521
558
  async forceRemoveContainer(containerName, successLog, failureLog) {
522
559
  try {
523
- await this.execFileImpl("docker", ["rm", "-f", containerName]);
560
+ if (await this.containerExists(containerName)) {
561
+ await this.run(["kill", containerName]).catch(() => undefined);
562
+ await this.run(["rm", "-f", containerName]);
563
+ }
524
564
  log.logInfo(successLog);
525
565
  return true;
526
566
  }
527
567
  catch (err) {
528
- const message = errorMessage(err);
529
- if (/no such container/i.test(message))
530
- return true;
531
- log.logWarning(failureLog, message);
568
+ log.logWarning(failureLog, errorMessage(err));
532
569
  return false;
533
570
  }
534
571
  }
535
572
  }
536
- function parseBindSources(raw) {
537
- const parsed = JSON.parse(raw && raw.length > 0 ? raw : "null");
538
- if (!Array.isArray(parsed))
573
+ const CPU_PERIOD_MICROSECONDS = 100_000;
574
+ function cpuQuotaArgs(cpus) {
575
+ const quota = Math.round(Number(cpus) * CPU_PERIOD_MICROSECONDS);
576
+ if (!Number.isFinite(quota) || quota <= 0)
577
+ throw new Error(`Invalid CPU limit: ${cpus}`);
578
+ return ["--cpu-period", String(CPU_PERIOD_MICROSECONDS), "--cpu-quota", String(quota)];
579
+ }
580
+ function parseBindMounts(raw, containerName) {
581
+ const payload = raw.trim();
582
+ const parsed = JSON.parse(payload.length > 0 ? payload : "null");
583
+ if (parsed === null)
539
584
  return [];
540
- return parsed
541
- .filter((bind) => typeof bind === "string")
542
- .map((bind) => bind.split(":", 1)[0] ?? bind);
585
+ if (!Array.isArray(parsed)) {
586
+ throw new Error(`Unexpected mount payload for container "${containerName}"`);
587
+ }
588
+ return parsed.flatMap((entry) => {
589
+ if (!isRecord(entry) || entry.Type !== "bind")
590
+ return [];
591
+ const { Source: source, Destination: target, RW: writable } = entry;
592
+ if (typeof source !== "string" || typeof target !== "string")
593
+ return [];
594
+ return [{ source, target, readOnly: writable === false }];
595
+ });
596
+ }
597
+ const ENGINE_LOCAL_TIMESTAMP = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?) ([+-]\d{2})(\d{2})/;
598
+ function parseEngineTimestamp(value) {
599
+ if (!value || value.startsWith("0001-"))
600
+ return undefined;
601
+ const local = ENGINE_LOCAL_TIMESTAMP.exec(value);
602
+ const iso = local ? `${local[1]}T${local[2]}${local[3]}:${local[4]}` : value;
603
+ const parsed = Date.parse(iso);
604
+ return Number.isNaN(parsed) ? undefined : parsed;
543
605
  }
544
606
  function mountsWithin(sources, root) {
545
607
  return sources.some((source) => {