@agent-vm/agent-vm 0.0.87 → 0.0.88

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 (49) hide show
  1. package/dist/build/docker-image-builder.d.ts +12 -0
  2. package/dist/build/docker-image-builder.d.ts.map +1 -1
  3. package/dist/build/docker-image-builder.js +57 -2
  4. package/dist/build/docker-image-builder.js.map +1 -1
  5. package/dist/build/gondolin-image-builder.d.ts +8 -1
  6. package/dist/build/gondolin-image-builder.d.ts.map +1 -1
  7. package/dist/build/gondolin-image-builder.js +38 -4
  8. package/dist/build/gondolin-image-builder.js.map +1 -1
  9. package/dist/build/managed-image-dockerfile.d.ts +2 -0
  10. package/dist/build/managed-image-dockerfile.d.ts.map +1 -1
  11. package/dist/build/managed-image-dockerfile.js +84 -15
  12. package/dist/build/managed-image-dockerfile.js.map +1 -1
  13. package/dist/build/prepared-gondolin-image-cache.d.ts +17 -0
  14. package/dist/build/prepared-gondolin-image-cache.d.ts.map +1 -0
  15. package/dist/build/prepared-gondolin-image-cache.js +91 -0
  16. package/dist/build/prepared-gondolin-image-cache.js.map +1 -0
  17. package/dist/cli/build-command.d.ts +7 -1
  18. package/dist/cli/build-command.d.ts.map +1 -1
  19. package/dist/cli/build-command.js +329 -79
  20. package/dist/cli/build-command.js.map +1 -1
  21. package/dist/cli/codex-harness-auth-command.js +1 -1
  22. package/dist/cli/codex-harness-auth-command.js.map +1 -1
  23. package/dist/cli/commands/build-definition.d.ts.map +1 -1
  24. package/dist/cli/commands/build-definition.js +3 -2
  25. package/dist/cli/commands/build-definition.js.map +1 -1
  26. package/dist/cli/init-command.d.ts.map +1 -1
  27. package/dist/cli/init-command.js +33 -29
  28. package/dist/cli/init-command.js.map +1 -1
  29. package/dist/cli/manual-templates.d.ts.map +1 -1
  30. package/dist/cli/manual-templates.js +6 -5
  31. package/dist/cli/manual-templates.js.map +1 -1
  32. package/dist/cli/run-task.d.ts +3 -1
  33. package/dist/cli/run-task.d.ts.map +1 -1
  34. package/dist/cli/run-task.js +54 -11
  35. package/dist/cli/run-task.js.map +1 -1
  36. package/dist/gateway/gateway-image-builder.d.ts.map +1 -1
  37. package/dist/gateway/gateway-image-builder.js +9 -0
  38. package/dist/gateway/gateway-image-builder.js.map +1 -1
  39. package/dist/operations/openclaw-deployment-doctor.d.ts.map +1 -1
  40. package/dist/operations/openclaw-deployment-doctor.js +41 -2
  41. package/dist/operations/openclaw-deployment-doctor.js.map +1 -1
  42. package/dist/shared/run-task.d.ts +9 -0
  43. package/dist/shared/run-task.d.ts.map +1 -1
  44. package/dist/shared/run-task.js.map +1 -1
  45. package/dist/tool-vm/tool-vm-lifecycle.d.ts.map +1 -1
  46. package/dist/tool-vm/tool-vm-lifecycle.js +9 -2
  47. package/dist/tool-vm/tool-vm-lifecycle.js.map +1 -1
  48. package/managed-images.json +5 -4
  49. package/package.json +11 -11
@@ -2,9 +2,10 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { buildImageAssetFileNames, hasBuiltImageAssets, } from '@agent-vm/gondolin-adapter';
4
4
  import { z } from 'zod';
5
- import { buildDockerImage as buildDockerImageDefault } from '../build/docker-image-builder.js';
5
+ import { buildDockerImage as buildDockerImageDefault, resolveDockerRootfsIdentity as resolveDockerRootfsIdentityDefault, } from '../build/docker-image-builder.js';
6
6
  import { buildGondolinImage as buildGondolinImageDefault, computeFingerprintFromConfigPath, } from '../build/gondolin-image-builder.js';
7
7
  import { generateManagedDockerfile as generateManagedDockerfileDefault, resolveManagedImageRelease as resolveManagedImageReleaseDefault, } from '../build/managed-image-dockerfile.js';
8
+ import { writePreparedGondolinImage } from '../build/prepared-gondolin-image-cache.js';
8
9
  import { deleteStaleImageDirectories as deleteStaleImageDirectoriesDefault, findPrunableImageDirectories as findPrunableImageDirectoriesDefault, } from '../build/stale-image-cleaner.js';
9
10
  import { assertGondolinZigCompatibility, resolveGondolinCompatibleZigVersion, resolveHostZigVersion, } from '../build/zig-compatibility.js';
10
11
  import { loadJsonConfigFile } from '../config/json-config-file.js';
@@ -16,6 +17,11 @@ const ociImageTagSchema = z.object({
16
17
  }),
17
18
  });
18
19
  const RETAIN_STALE_IMAGE_GENERATIONS_PER_PROFILE = 2;
20
+ const DOCKER_BUILD_CONCURRENCY = 2;
21
+ const GONDOLIN_BUILD_CONCURRENCY = 2;
22
+ const BUILD_DETAIL_MAX_LENGTH = 180;
23
+ const GONDOLIN_BUILD_SANDBOX_HELPERS_FROM_SOURCE_ENV = 'GONDOLIN_BUILD_SANDBOX_HELPERS_FROM_SOURCE';
24
+ const TASK_OUTPUT_BUFFER_MAX_LENGTH = 4_096;
19
25
  const gatewayRuntimeRecordFileName = 'gateway-runtime.json';
20
26
  const openClawManagedPackageConfigSchema = z
21
27
  .object({
@@ -40,8 +46,42 @@ function imageTargetKey(imageTarget) {
40
46
  function imageTargetDedupeKey(options) {
41
47
  return `${path.resolve(options.buildConfigPath)}${imageTargetKeySeparator}${options.fingerprint}`;
42
48
  }
43
- function imageTargetFingerprintKey(options) {
44
- return path.resolve(options.buildConfigPath);
49
+ function imageTargetFingerprintInputKey(options) {
50
+ return `${path.resolve(options.buildConfigPath)}${imageTargetKeySeparator}${JSON.stringify(options.fingerprintInput ?? null)}`;
51
+ }
52
+ async function runWithConcurrency(items, concurrency, fn) {
53
+ let nextIndex = 0;
54
+ const workerCount = Math.min(concurrency, items.length);
55
+ const workers = Array.from({ length: workerCount }, async () => {
56
+ for (;;) {
57
+ const index = nextIndex;
58
+ nextIndex += 1;
59
+ if (index >= items.length) {
60
+ return;
61
+ }
62
+ const item = items[index];
63
+ if (item === undefined) {
64
+ throw new Error(`Expected build queue item at index ${index}.`);
65
+ }
66
+ // oxlint-disable-next-line no-await-in-loop -- each worker intentionally processes its own queue slot serially while workers run in parallel
67
+ await fn(item);
68
+ }
69
+ });
70
+ await Promise.all(workers);
71
+ }
72
+ function createRunTaskGroupFallback(runTaskStep) {
73
+ return async (tasks, options) => {
74
+ await runWithConcurrency(tasks, options.concurrency, async (task) => {
75
+ await runTaskStep(task.title, task.fn);
76
+ });
77
+ };
78
+ }
79
+ function firstGondolinTargetPlan(targetPlans) {
80
+ const [targetPlan] = targetPlans;
81
+ if (!targetPlan) {
82
+ throw new Error('Expected at least one Gondolin target plan.');
83
+ }
84
+ return targetPlan;
45
85
  }
46
86
  function createEmptyCurrentImageFingerprints() {
47
87
  return {
@@ -96,6 +136,16 @@ async function materializeGondolinImageAlias(options) {
96
136
  }
97
137
  return targetImagePath;
98
138
  }
139
+ async function materializePreparedTargetImage(options) {
140
+ const targetImagePath = path.join(options.targetCacheDirectory, options.fingerprint);
141
+ if (path.resolve(options.sourceImagePath) === path.resolve(targetImagePath)) {
142
+ return targetImagePath;
143
+ }
144
+ if (!(await hasBuiltImageAssets(options.sourceImagePath))) {
145
+ return options.sourceImagePath;
146
+ }
147
+ return await materializeGondolinImageAlias(options);
148
+ }
99
149
  async function findZoneIdsWithGatewayRuntimeRecords(systemConfig) {
100
150
  const zoneIds = [];
101
151
  for (const zone of systemConfig.zones) {
@@ -120,18 +170,29 @@ async function findZoneIdsWithGatewayRuntimeRecords(systemConfig) {
120
170
  }
121
171
  return zoneIds;
122
172
  }
123
- function startElapsedStatusHeartbeat(taskContext, baseStatus) {
124
- taskContext?.setStatus(baseStatus);
125
- if (taskContext?.interactive !== true) {
126
- return () => { };
127
- }
173
+ function startElapsedStatusController(taskContext, initialStatus) {
174
+ let currentStatus = initialStatus;
175
+ const renderStatus = () => {
176
+ if (taskContext?.interactive === true) {
177
+ const elapsedSeconds = Math.max(1, Math.floor((Date.now() - startedAtMs) / 1000));
178
+ taskContext.setStatus(`${currentStatus} · ${elapsedSeconds}s elapsed`);
179
+ return;
180
+ }
181
+ taskContext?.setStatus(currentStatus);
182
+ };
128
183
  const startedAtMs = Date.now();
129
- const heartbeatInterval = setInterval(() => {
130
- const elapsedSeconds = Math.max(1, Math.floor((Date.now() - startedAtMs) / 1000));
131
- taskContext.setStatus(`${baseStatus} · ${elapsedSeconds}s elapsed`);
132
- }, 8_000);
133
- return () => {
134
- clearInterval(heartbeatInterval);
184
+ taskContext?.setStatus(currentStatus);
185
+ const heartbeatInterval = taskContext?.interactive === true ? setInterval(renderStatus, 8_000) : undefined;
186
+ return {
187
+ setBaseStatus: (status) => {
188
+ currentStatus = status;
189
+ renderStatus();
190
+ },
191
+ stop: () => {
192
+ if (heartbeatInterval) {
193
+ clearInterval(heartbeatInterval);
194
+ }
195
+ },
135
196
  };
136
197
  }
137
198
  async function resolveOciImageTagFromConfig(buildConfigPath) {
@@ -150,20 +211,31 @@ async function assertZigBuildPrerequisite(resolveRequiredZigVersion, resolveZigV
150
211
  ...(zigVersion ? { installedVersion: zigVersion } : {}),
151
212
  });
152
213
  }
214
+ function isTruthyEnvironmentFlag(value) {
215
+ const normalizedValue = value?.trim().toLowerCase();
216
+ return (normalizedValue === '1' ||
217
+ normalizedValue === 'true' ||
218
+ normalizedValue === 'yes' ||
219
+ normalizedValue === 'on');
220
+ }
221
+ function shouldAssertZigBuildPrerequisite(env = process.env) {
222
+ return isTruthyEnvironmentFlag(env[GONDOLIN_BUILD_SANDBOX_HELPERS_FROM_SOURCE_ENV]);
223
+ }
153
224
  async function assertUniqueDockerImageTags(imageTargets, resolveOciImageTag) {
154
225
  const profileByTag = new Map();
155
- const tagByProfile = new Map();
226
+ const tagByTargetKey = new Map();
156
227
  for (const imageTarget of imageTargets) {
157
228
  // oxlint-disable-next-line no-await-in-loop -- collision errors are clearer in stable target order
158
229
  const imageTag = await resolveOciImageTag(imageTarget.buildConfigPath);
159
230
  const existingProfile = profileByTag.get(imageTag);
231
+ const key = imageTargetKey(imageTarget);
160
232
  if (existingProfile) {
161
- throw new Error(`Docker image tag '${imageTag}' is used by both image profiles '${existingProfile}' and '${imageTarget.name}'. Give each Docker-backed image profile a unique oci.image tag.`);
233
+ throw new Error(`Docker image tag '${imageTag}' is used by both image profiles '${existingProfile}' and '${key}'. Give each Docker-backed image profile a unique oci.image tag.`);
162
234
  }
163
- profileByTag.set(imageTag, imageTarget.name);
164
- tagByProfile.set(imageTarget.name, imageTag);
235
+ profileByTag.set(imageTag, key);
236
+ tagByTargetKey.set(key, imageTag);
165
237
  }
166
- return tagByProfile;
238
+ return tagByTargetKey;
167
239
  }
168
240
  const defaultRunTask = async (title, fn) => {
169
241
  process.stderr.write(` ${title}...\n`);
@@ -222,46 +294,177 @@ async function resolveRequiredOpenClawPackagesForTarget(systemConfig, imageTarge
222
294
  }
223
295
  return [...requiredPackageNames].toSorted();
224
296
  }
225
- function formatManagedDockerfilePlan(plan) {
226
- const lines = [
227
- `Managed image plan: ${plan.imageTargetFamily}/${plan.imageTargetName}`,
228
- `base image: ${plan.baseImage.reference}`,
229
- `source: ${plan.baseImage.source}`,
230
- `generated Dockerfile: ${plan.dockerfilePath}`,
297
+ function shortenBuildDetail(detail) {
298
+ if (detail.length <= BUILD_DETAIL_MAX_LENGTH) {
299
+ return detail;
300
+ }
301
+ const prefixLength = Math.max(20, Math.floor((BUILD_DETAIL_MAX_LENGTH - 5) * 0.55));
302
+ const suffixLength = BUILD_DETAIL_MAX_LENGTH - prefixLength - 5;
303
+ return `${detail.slice(0, prefixLength)} ... ${detail.slice(-suffixLength)}`;
304
+ }
305
+ function packageNameFromSpec(packageSpec) {
306
+ const versionSeparatorIndex = packageSpec.lastIndexOf('@');
307
+ const unversionedSpec = versionSeparatorIndex > 0 ? packageSpec.slice(0, versionSeparatorIndex) : packageSpec;
308
+ return unversionedSpec.replace(/^@openclaw\//, '').replace(/^@agent-vm\//, '');
309
+ }
310
+ function packageVersionFromSpec(packageSpec) {
311
+ const versionSeparatorIndex = packageSpec.lastIndexOf('@');
312
+ if (versionSeparatorIndex <= 0) {
313
+ return undefined;
314
+ }
315
+ return packageSpec.slice(versionSeparatorIndex + 1);
316
+ }
317
+ function formatAgentVmPackageStatus(packages) {
318
+ if (packages.length === 0) {
319
+ return undefined;
320
+ }
321
+ const versions = [
322
+ ...new Set(packages
323
+ .map((packageEntry) => packageVersionFromSpec(packageEntry.spec))
324
+ .filter((version) => version !== undefined)),
231
325
  ];
232
- if (plan.openClawAgentVmPluginPackage) {
233
- lines.push(`agent-vm plugin: ${plan.openClawAgentVmPluginPackage.spec}`, `source: ${plan.openClawAgentVmPluginPackage.source}`);
326
+ return versions.length === 1 ? `agent-vm ${versions[0]}` : 'agent-vm packages';
327
+ }
328
+ function formatManagedPackagePlanEntry(packageEntry) {
329
+ return `${packageNameFromSpec(packageEntry.spec)}@${packageVersionFromSpec(packageEntry.spec) ?? 'unversioned'}[${packageEntry.source}]`;
330
+ }
331
+ function formatDockerBaseDetail(options) {
332
+ const details = [];
333
+ const plan = options.managedDockerfilePlan;
334
+ if (!plan) {
335
+ return shortenBuildDetail(`dockerfile ${path.basename(options.dockerfilePath)}`);
336
+ }
337
+ details.push(`base ${plan.base}:${plan.baseImage.tag}`);
338
+ if (options.imageTarget.source?.overlay) {
339
+ details.push(`overlay ${path.basename(options.imageTarget.source.overlay)}`);
340
+ }
341
+ const agentVmPackages = [
342
+ plan.openClawAgentVmPluginPackage,
343
+ plan.openClawMcpPortalPluginPackage,
344
+ plan.mcpPortalPackage,
345
+ ].filter((packageEntry) => packageEntry !== undefined);
346
+ const agentVmPackageStatus = formatAgentVmPackageStatus(agentVmPackages);
347
+ if (agentVmPackageStatus) {
348
+ details.push(agentVmPackageStatus);
234
349
  }
235
350
  if (plan.openClawPackages.length > 0) {
236
- lines.push('OpenClaw packages:');
237
- for (const packageEntry of plan.openClawPackages) {
238
- lines.push(` ${packageEntry.spec}`, ` source: ${packageEntry.source}`);
239
- }
351
+ details.push(`packages ${plan.openClawPackages.map((packageEntry) => formatManagedPackagePlanEntry(packageEntry)).join(',')}`);
240
352
  }
241
353
  if (plan.warnings.length > 0) {
242
- lines.push('warnings:');
243
- for (const warning of plan.warnings) {
244
- lines.push(` ${warning.message}`);
354
+ details.push(`warnings ${plan.warnings.length}`);
355
+ }
356
+ return shortenBuildDetail(details.join(' | '));
357
+ }
358
+ function normalizeDockerOutputLine(line) {
359
+ const normalizedLine = line.trim();
360
+ if (normalizedLine.length === 0) {
361
+ return undefined;
362
+ }
363
+ return normalizedLine.replace(/\s+/g, ' ');
364
+ }
365
+ function createDockerTaskOutput(taskContext, baseDetail) {
366
+ if (taskContext?.interactive !== true) {
367
+ return undefined;
368
+ }
369
+ return {
370
+ write: (chunk) => {
371
+ const line = normalizeDockerOutputLine(String(chunk));
372
+ if (!line) {
373
+ return true;
374
+ }
375
+ const detailSuffix = baseDetail ? ` | ${baseDetail}` : '';
376
+ taskContext.setOutput(shortenBuildDetail(`${line}${detailSuffix}`));
377
+ return true;
378
+ },
379
+ };
380
+ }
381
+ const gondolinPhasePatterns = [
382
+ { pattern: /^Extracting OCI rootfs\b/, status: 'extracting OCI rootfs' },
383
+ {
384
+ pattern: /^Creating OCI export container\b/,
385
+ status: 'exporting OCI rootfs',
386
+ },
387
+ {
388
+ pattern: /^Extracting Alpine minirootfs for rootfs\b/,
389
+ status: 'extracting rootfs',
390
+ },
391
+ {
392
+ pattern: /^Extracting Alpine minirootfs for initramfs\b/,
393
+ status: 'extracting initramfs',
394
+ },
395
+ { pattern: /^Installing rootfs packages\b/, status: 'installing rootfs packages' },
396
+ { pattern: /^Installing initramfs packages\b/, status: 'installing initramfs packages' },
397
+ { pattern: /^Bootstrapped busybox shell\b/, status: 'bootstrapping rootfs shell' },
398
+ { pattern: /^Applying post-build copies\b/, status: 'applying post-build copies' },
399
+ { pattern: /^Running post-build command\b/, status: 'running post-build commands' },
400
+ { pattern: /^Syncing kernel modules\b/, status: 'copying kernel modules' },
401
+ { pattern: /^Creating rootfs ext4 image\b/, status: 'creating rootfs image' },
402
+ { pattern: /^Creating initramfs\b/, status: 'creating initramfs' },
403
+ { pattern: /^Rootfs image written\b/, status: 'rootfs image ready' },
404
+ { pattern: /^Fetching kernel\b/, status: 'fetching kernel' },
405
+ { pattern: /^Fetching libkrunfw-compatible kernel\b/, status: 'fetching libkrunfw kernel' },
406
+ { pattern: /^Copying assets to output directory\b/, status: 'copying vm assets' },
407
+ { pattern: /^Generating manifest\b/, status: 'generating vm manifest' },
408
+ { pattern: /^Build complete\b/, status: 'vm asset build complete' },
409
+ ];
410
+ function parseGondolinPhaseStatus(line) {
411
+ const normalizedLine = line.trim();
412
+ for (const phasePattern of gondolinPhasePatterns) {
413
+ if (phasePattern.pattern.test(normalizedLine)) {
414
+ return phasePattern.status;
245
415
  }
246
416
  }
247
- return lines.join('\n');
417
+ return undefined;
418
+ }
419
+ function createGondolinPhaseTaskOutput(taskContext, statusController) {
420
+ if (taskContext?.interactive !== true) {
421
+ return undefined;
422
+ }
423
+ let bufferedOutput = '';
424
+ return {
425
+ write: (chunk) => {
426
+ bufferedOutput += String(chunk);
427
+ let lineBreakIndex = bufferedOutput.indexOf('\n');
428
+ while (lineBreakIndex !== -1) {
429
+ const line = bufferedOutput.slice(0, lineBreakIndex);
430
+ bufferedOutput = bufferedOutput.slice(lineBreakIndex + 1);
431
+ const phaseStatus = parseGondolinPhaseStatus(line);
432
+ if (phaseStatus) {
433
+ statusController.setBaseStatus(phaseStatus);
434
+ }
435
+ lineBreakIndex = bufferedOutput.indexOf('\n');
436
+ }
437
+ if (bufferedOutput.length > TASK_OUTPUT_BUFFER_MAX_LENGTH) {
438
+ bufferedOutput = bufferedOutput.slice(-TASK_OUTPUT_BUFFER_MAX_LENGTH);
439
+ }
440
+ return true;
441
+ },
442
+ };
248
443
  }
249
444
  export async function runBuildCommand(options, dependencies = {}) {
250
445
  const buildDockerImage = dependencies.buildDockerImage ?? buildDockerImageDefault;
446
+ const resolveDockerRootfsIdentity = dependencies.resolveDockerRootfsIdentity ?? resolveDockerRootfsIdentityDefault;
251
447
  const buildGondolinImage = dependencies.buildGondolinImage ?? buildGondolinImageDefault;
252
448
  const computeGondolinFingerprint = dependencies.computeGondolinFingerprint ??
253
- (async (fingerprintOptions) => await computeFingerprintFromConfigPath(fingerprintOptions.buildConfigPath));
449
+ (async (fingerprintOptions) => fingerprintOptions.fingerprintInput === undefined
450
+ ? await computeFingerprintFromConfigPath(fingerprintOptions.buildConfigPath)
451
+ : await computeFingerprintFromConfigPath(fingerprintOptions.buildConfigPath, {
452
+ fingerprintInput: fingerprintOptions.fingerprintInput,
453
+ }));
254
454
  const deleteStaleImageDirectories = dependencies.deleteStaleImageDirectories ?? deleteStaleImageDirectoriesDefault;
255
455
  const findPrunableImageDirectories = dependencies.findPrunableImageDirectories ?? findPrunableImageDirectoriesDefault;
256
456
  const resolveOciImageTag = dependencies.resolveOciImageTag ?? resolveOciImageTagFromConfig;
257
457
  const resolveRequiredZigVersion = dependencies.resolveRequiredZigVersion ?? resolveGondolinCompatibleZigVersion;
258
458
  const resolveZigVersion = dependencies.resolveZigVersion ?? resolveHostZigVersion;
259
459
  const runTaskStep = dependencies.runTask ?? defaultRunTask;
460
+ const runTaskGroup = dependencies.runTaskGroup ?? createRunTaskGroupFallback(runTaskStep);
260
461
  const resolveProjectRoot = dependencies.resolveProjectRootFromDockerfile ?? resolveProjectRootFromDockerfile;
261
462
  const generateManagedDockerfile = dependencies.generateManagedDockerfile ?? generateManagedDockerfileDefault;
262
463
  const resolveManagedImageRelease = dependencies.resolveManagedImageRelease ?? resolveManagedImageReleaseDefault;
263
464
  const syncBundledOpenClawPlugin = dependencies.syncBundledOpenClawPlugin ?? syncBundledOpenClawPluginBundle;
264
- await assertZigBuildPrerequisite(resolveRequiredZigVersion, resolveZigVersion);
465
+ if (shouldAssertZigBuildPrerequisite()) {
466
+ await assertZigBuildPrerequisite(resolveRequiredZigVersion, resolveZigVersion);
467
+ }
265
468
  const gatewayImageTargets = Object.entries(options.systemConfig.imageProfiles.gateways).map(([profileName, profile]) => ({
266
469
  buildConfigPath: profile.buildConfig,
267
470
  cacheDirectory: path.join(options.systemConfig.cacheDir, 'gateway-images', profileName),
@@ -281,15 +484,17 @@ export async function runBuildCommand(options, dependencies = {}) {
281
484
  }));
282
485
  const imageTargets = [...gatewayImageTargets, ...toolVmImageTargets];
283
486
  const dockerImageTargets = imageTargets.filter((imageTarget) => imageTarget.dockerfile !== undefined || imageTarget.source !== undefined);
284
- const dockerImageTagByProfile = await assertUniqueDockerImageTags(dockerImageTargets, resolveOciImageTag);
487
+ const dockerImageTagByTargetKey = await assertUniqueDockerImageTags(dockerImageTargets, resolveOciImageTag);
285
488
  const managedImageRelease = dockerImageTargets.some((imageTarget) => imageTarget.source !== undefined)
286
489
  ? await resolveManagedImageRelease()
287
490
  : undefined;
288
- // oxlint-disable-next-line no-await-in-loop -- image builds are intentionally sequential for stable task output and shared image tags
491
+ const dockerBuildPlans = [];
492
+ const dockerFingerprintInputByTargetKey = new Map();
493
+ // oxlint-disable-next-line no-await-in-loop -- Docker inputs are prepared in stable order before bounded parallel builds start
289
494
  for (const imageTarget of dockerImageTargets) {
290
- const imageTag = dockerImageTagByProfile.get(imageTarget.name);
495
+ const imageTag = dockerImageTagByTargetKey.get(imageTargetKey(imageTarget));
291
496
  if (!imageTag) {
292
- throw new Error(`Missing resolved Docker image tag for image profile '${imageTarget.name}'.`);
497
+ throw new Error(`Missing resolved Docker image tag for image profile '${imageTargetKey(imageTarget)}'.`);
293
498
  }
294
499
  let dockerfilePath = imageTarget.dockerfile;
295
500
  let managedDockerfilePlan;
@@ -327,48 +532,73 @@ export async function runBuildCommand(options, dependencies = {}) {
327
532
  await syncBundledOpenClawPlugin(projectRootDirectory, imageTarget.name);
328
533
  });
329
534
  }
330
- // oxlint-disable-next-line no-await-in-loop -- docker builds intentionally run one at a time to keep task output readable
331
- await runTaskStep(`Docker: ${imageTarget.family}/${imageTarget.name} (${imageTag})`, async (taskContext) => {
332
- if (managedDockerfilePlan) {
333
- taskContext?.setOutput({ message: formatManagedDockerfilePlan(managedDockerfilePlan) });
334
- }
535
+ dockerBuildPlans.push({
536
+ dockerfilePath,
537
+ imageTag,
538
+ imageTarget,
539
+ ...(managedDockerfilePlan ? { managedDockerfilePlan } : {}),
540
+ });
541
+ }
542
+ await runTaskGroup(dockerBuildPlans.map((dockerBuildPlan) => ({
543
+ title: `Docker: ${dockerBuildPlan.imageTarget.family}/${dockerBuildPlan.imageTarget.name} (${dockerBuildPlan.imageTag})`,
544
+ fn: async (taskContext) => {
545
+ const dockerBaseDetail = formatDockerBaseDetail({
546
+ dockerfilePath: dockerBuildPlan.dockerfilePath,
547
+ imageTarget: dockerBuildPlan.imageTarget,
548
+ ...(dockerBuildPlan.managedDockerfilePlan
549
+ ? { managedDockerfilePlan: dockerBuildPlan.managedDockerfilePlan }
550
+ : {}),
551
+ });
552
+ taskContext?.setOutput(dockerBaseDetail);
553
+ const dockerTaskOutput = createDockerTaskOutput(taskContext, dockerBaseDetail);
335
554
  taskContext?.setStatus('docker build');
336
555
  await buildDockerImage({
337
- dockerfilePath,
338
- imageTag,
339
- ...(taskContext?.interactive === true && taskContext.streamPreview
340
- ? { streamPreview: taskContext.streamPreview }
341
- : {}),
556
+ dockerfilePath: dockerBuildPlan.dockerfilePath,
557
+ imageTag: dockerBuildPlan.imageTag,
558
+ ...(taskContext?.interactive === true ? { quiet: true } : {}),
559
+ ...(dockerTaskOutput ? { streamPreview: dockerTaskOutput } : {}),
560
+ });
561
+ taskContext?.setStatus('inspect layers');
562
+ const dockerRootfsIdentity = await resolveDockerRootfsIdentity(dockerBuildPlan.imageTag);
563
+ if (!dockerRootfsIdentity) {
564
+ throw new Error(`Docker image '${dockerBuildPlan.imageTag}' was built but its rootfs identity could not be inspected.`);
565
+ }
566
+ dockerFingerprintInputByTargetKey.set(imageTargetKey(dockerBuildPlan.imageTarget), {
567
+ dockerRootfsIdentity,
568
+ schemaVersion: 1,
342
569
  });
343
570
  taskContext?.setStatus('docker image ready');
344
- });
345
- }
346
- const dockerBackedTargets = new Set(dockerImageTargets.map((imageTarget) => imageTargetKey(imageTarget)));
571
+ },
572
+ })), { concurrency: DOCKER_BUILD_CONCURRENCY });
347
573
  const currentFingerprints = createEmptyCurrentImageFingerprints();
348
574
  const fingerprintByInputKey = new Map();
349
575
  const targetPlans = [];
350
576
  const targetPlansByDedupeKey = new Map();
351
577
  for (const imageTarget of imageTargets) {
352
- const fingerprintInputKey = imageTargetFingerprintKey({
578
+ const key = imageTargetKey(imageTarget);
579
+ const fingerprintInput = dockerFingerprintInputByTargetKey.get(key);
580
+ const fingerprintInputKey = imageTargetFingerprintInputKey({
353
581
  buildConfigPath: imageTarget.buildConfigPath,
582
+ fingerprintInput,
354
583
  });
355
584
  let fingerprint = fingerprintByInputKey.get(fingerprintInputKey);
356
585
  if (fingerprint === undefined) {
357
586
  // oxlint-disable-next-line no-await-in-loop -- fingerprint errors should identify the matching profile path
358
587
  fingerprint = await computeGondolinFingerprint({
359
588
  buildConfigPath: imageTarget.buildConfigPath,
589
+ ...(fingerprintInput === undefined ? {} : { fingerprintInput }),
360
590
  });
361
591
  fingerprintByInputKey.set(fingerprintInputKey, fingerprint);
362
592
  }
363
- const key = imageTargetKey(imageTarget);
364
593
  const dedupeKey = imageTargetDedupeKey({
365
594
  buildConfigPath: imageTarget.buildConfigPath,
366
595
  fingerprint,
367
596
  });
368
- const shouldResetGondolinCache = options.forceRebuild === true || dockerBackedTargets.has(key);
597
+ const shouldResetGondolinCache = options.forceRebuild === true;
369
598
  const targetPlan = {
370
599
  dedupeKey,
371
600
  fingerprint,
601
+ fingerprintInput,
372
602
  imageTarget,
373
603
  key,
374
604
  sharedDedupeKey: false,
@@ -388,46 +618,66 @@ export async function runBuildCommand(options, dependencies = {}) {
388
618
  }
389
619
  }
390
620
  const builtImageByDedupeKey = new Map();
391
- for (const targetPlan of targetPlans) {
392
- // oxlint-disable-next-line no-await-in-loop -- gondolin cache rebuilds are intentionally sequenced per image target
393
- await runTaskStep(`Gondolin: ${targetPlan.imageTarget.family}/${targetPlan.imageTarget.name}`, async (taskContext) => {
394
- const existingBuild = builtImageByDedupeKey.get(targetPlan.dedupeKey);
395
- if (existingBuild) {
396
- await materializeGondolinImageAlias({
397
- fingerprint: existingBuild.result.fingerprint,
398
- fullReset: targetPlan.shouldResetGondolinCache,
399
- sourceImagePath: existingBuild.result.imagePath,
400
- targetCacheDirectory: targetPlan.imageTarget.cacheDirectory,
401
- });
402
- setCurrentImageFingerprint(currentFingerprints, targetPlan.imageTarget, existingBuild.result.fingerprint);
403
- taskContext?.setStatus(`vm assets reused from ${existingBuild.imageTarget.family}/${existingBuild.imageTarget.name}`);
404
- return;
405
- }
406
- const stopHeartbeat = startElapsedStatusHeartbeat(taskContext, targetPlan.shouldResetGondolinCache ? 'building vm assets' : 'checking vm assets');
621
+ const canonicalTargetPlans = [...targetPlansByDedupeKey.values()].map(firstGondolinTargetPlan);
622
+ const builtImageResults = [];
623
+ await runTaskGroup(canonicalTargetPlans.map((targetPlan) => ({
624
+ title: `Gondolin: ${targetPlan.imageTarget.family}/${targetPlan.imageTarget.name}`,
625
+ fn: async (taskContext) => {
626
+ const statusController = startElapsedStatusController(taskContext, targetPlan.shouldResetGondolinCache ? 'building vm assets' : 'checking vm assets');
627
+ const gondolinTaskOutput = createGondolinPhaseTaskOutput(taskContext, statusController);
407
628
  let result;
408
629
  try {
409
630
  result = await buildGondolinImage({
410
631
  buildConfigPath: targetPlan.imageTarget.buildConfigPath,
411
632
  cacheDir: targetPlan.imageTarget.cacheDirectory,
633
+ ...(targetPlan.fingerprintInput === undefined
634
+ ? {}
635
+ : { fingerprintInput: targetPlan.fingerprintInput }),
412
636
  ...(targetPlan.shouldResetGondolinCache ? { fullReset: true } : {}),
413
- ...(taskContext?.interactive === true && taskContext.streamPreview
414
- ? { streamPreview: taskContext.streamPreview }
415
- : {}),
637
+ ...(gondolinTaskOutput ? { streamPreview: gondolinTaskOutput } : {}),
416
638
  });
417
639
  }
418
640
  finally {
419
- stopHeartbeat();
641
+ statusController.stop();
420
642
  }
421
643
  if (targetPlan.sharedDedupeKey && result.fingerprint !== targetPlan.fingerprint) {
422
644
  throw new Error(`Fingerprint mismatch for image profile '${targetPlan.key}': precomputed '${targetPlan.fingerprint}' but build returned '${result.fingerprint}'.`);
423
645
  }
424
- builtImageByDedupeKey.set(targetPlan.dedupeKey, {
646
+ builtImageResults.push({
425
647
  imageTarget: targetPlan.imageTarget,
426
648
  result,
649
+ targetPlan,
427
650
  });
428
- setCurrentImageFingerprint(currentFingerprints, targetPlan.imageTarget, result.fingerprint);
429
651
  taskContext?.setStatus(result.built ? 'vm assets ready' : 'vm assets cache hit');
652
+ },
653
+ })), { concurrency: GONDOLIN_BUILD_CONCURRENCY });
654
+ for (const builtImageResult of builtImageResults) {
655
+ builtImageByDedupeKey.set(builtImageResult.targetPlan.dedupeKey, {
656
+ imageTarget: builtImageResult.imageTarget,
657
+ result: builtImageResult.result,
658
+ });
659
+ }
660
+ for (const targetPlan of targetPlans) {
661
+ const existingBuild = builtImageByDedupeKey.get(targetPlan.dedupeKey);
662
+ if (!existingBuild) {
663
+ throw new Error(`Missing built image result for image profile '${targetPlan.key}'.`);
664
+ }
665
+ // oxlint-disable-next-line no-await-in-loop -- alias materialization is kept deterministic so duplicate-profile errors name the matching profile
666
+ const imagePath = await materializePreparedTargetImage({
667
+ fingerprint: existingBuild.result.fingerprint,
668
+ fullReset: targetPlan.shouldResetGondolinCache,
669
+ sourceImagePath: existingBuild.result.imagePath,
670
+ targetCacheDirectory: targetPlan.imageTarget.cacheDirectory,
671
+ });
672
+ // oxlint-disable-next-line no-await-in-loop -- prepared records are profile-local and must report the matching profile path on failure
673
+ await writePreparedGondolinImage({
674
+ buildConfigPath: targetPlan.imageTarget.buildConfigPath,
675
+ cacheDir: targetPlan.imageTarget.cacheDirectory,
676
+ fingerprint: existingBuild.result.fingerprint,
677
+ fingerprintInput: targetPlan.fingerprintInput,
678
+ imagePath,
430
679
  });
680
+ setCurrentImageFingerprint(currentFingerprints, targetPlan.imageTarget, existingBuild.result.fingerprint);
431
681
  }
432
682
  await runTaskStep('Cache auto-prune', async (taskContext) => {
433
683
  taskContext?.setStatus('checking old image generations');