@vibesdotdev/infra-deploy 1.0.0-rc.6 → 1.0.0-rc.8

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 (62) hide show
  1. package/dist/cli/dns/infra-dns-list.cli-command.impl.d.ts +1 -1
  2. package/dist/cli/dns/infra-dns-list.cli-command.impl.js +1 -1
  3. package/dist/cli/npm/mirror-registry-artifacts.d.ts +3 -2
  4. package/dist/cli/npm/mirror-registry-artifacts.d.ts.map +1 -1
  5. package/dist/cli/npm/mirror-registry-artifacts.js +6 -4
  6. package/dist/cli/npm/mirror-registry-artifacts.js.map +1 -1
  7. package/dist/cli/npm/put-attested-packument.d.ts +15 -0
  8. package/dist/cli/npm/put-attested-packument.d.ts.map +1 -1
  9. package/dist/cli/npm/put-attested-packument.js +31 -5
  10. package/dist/cli/npm/put-attested-packument.js.map +1 -1
  11. package/dist/cli/run/build-closure.d.ts +34 -0
  12. package/dist/cli/run/build-closure.d.ts.map +1 -0
  13. package/dist/cli/run/build-closure.js +122 -0
  14. package/dist/cli/run/build-closure.js.map +1 -0
  15. package/dist/cli/run/build-only.d.ts +45 -0
  16. package/dist/cli/run/build-only.d.ts.map +1 -0
  17. package/dist/cli/run/build-only.js +35 -0
  18. package/dist/cli/run/build-only.js.map +1 -0
  19. package/dist/cli/run/deploy-smoke.d.ts +6 -0
  20. package/dist/cli/run/deploy-smoke.d.ts.map +1 -1
  21. package/dist/cli/run/deploy-smoke.js +43 -3
  22. package/dist/cli/run/deploy-smoke.js.map +1 -1
  23. package/dist/cli/run/deployment-budget-gate.d.ts +41 -0
  24. package/dist/cli/run/deployment-budget-gate.d.ts.map +1 -0
  25. package/dist/cli/run/deployment-budget-gate.js +85 -0
  26. package/dist/cli/run/deployment-budget-gate.js.map +1 -0
  27. package/dist/cli/run/deployment-provider.d.ts +24 -0
  28. package/dist/cli/run/deployment-provider.d.ts.map +1 -1
  29. package/dist/cli/run/deployment-provider.js +57 -17
  30. package/dist/cli/run/deployment-provider.js.map +1 -1
  31. package/dist/cli/run/deployment-run-ledger.d.ts +2 -0
  32. package/dist/cli/run/deployment-run-ledger.d.ts.map +1 -1
  33. package/dist/cli/run/deployment-run-ledger.js +1 -0
  34. package/dist/cli/run/deployment-run-ledger.js.map +1 -1
  35. package/dist/cli/run/edge-hostname-reconcile.d.ts +52 -0
  36. package/dist/cli/run/edge-hostname-reconcile.d.ts.map +1 -0
  37. package/dist/cli/run/edge-hostname-reconcile.js +105 -0
  38. package/dist/cli/run/edge-hostname-reconcile.js.map +1 -0
  39. package/dist/cli/run/immutable-deployment-release.program.d.ts +9 -0
  40. package/dist/cli/run/immutable-deployment-release.program.d.ts.map +1 -1
  41. package/dist/cli/run/immutable-deployment-release.program.js +2 -1
  42. package/dist/cli/run/immutable-deployment-release.program.js.map +1 -1
  43. package/dist/cli/run/infra-deploy.run.cli-command.descriptor.d.ts +6 -0
  44. package/dist/cli/run/infra-deploy.run.cli-command.descriptor.d.ts.map +1 -1
  45. package/dist/cli/run/infra-deploy.run.cli-command.descriptor.js +8 -0
  46. package/dist/cli/run/infra-deploy.run.cli-command.descriptor.js.map +1 -1
  47. package/dist/cli/run/infra-deploy.run.cli-command.impl.d.ts.map +1 -1
  48. package/dist/cli/run/infra-deploy.run.cli-command.impl.js +118 -7
  49. package/dist/cli/run/infra-deploy.run.cli-command.impl.js.map +1 -1
  50. package/dist/cli/shared/build-workspace-artifacts.d.ts +44 -7
  51. package/dist/cli/shared/build-workspace-artifacts.d.ts.map +1 -1
  52. package/dist/cli/shared/build-workspace-artifacts.js +313 -202
  53. package/dist/cli/shared/build-workspace-artifacts.js.map +1 -1
  54. package/dist/cli/shared/workspace-artifact-store.d.ts +5 -0
  55. package/dist/cli/shared/workspace-artifact-store.d.ts.map +1 -1
  56. package/dist/cli/shared/workspace-artifact-store.js +22 -3
  57. package/dist/cli/shared/workspace-artifact-store.js.map +1 -1
  58. package/dist/storage/deployment-runs.schema.d.ts +2 -0
  59. package/dist/storage/deployment-runs.schema.d.ts.map +1 -1
  60. package/dist/storage/deployment-runs.schema.js +2 -0
  61. package/dist/storage/deployment-runs.schema.js.map +1 -1
  62. package/package.json +21 -21
@@ -1,6 +1,6 @@
1
1
  import { satisfies, validRange } from 'semver';
2
2
  import * as z from 'zod/v4';
3
- import { CapabilityError, settle } from '@vibesdotdev/kernel';
3
+ import { CapabilityError, settle, settleNow } from '@vibesdotdev/kernel';
4
4
  import { JsonValueSchema } from '@vibesdotdev/kernel/schemas/json';
5
5
  import { planDag } from '@vibesdotdev/shared/schemas';
6
6
  import { resolveHash } from '@vibesdotdev/crypto';
@@ -274,7 +274,7 @@ async function readAppManifest(host, path) {
274
274
  }
275
275
  /** The errno facts a raw host absence carries. */
276
276
  const ErrnoCodeSchema = z.object({ code: z.string().optional() }).loose();
277
- function isMissingPathError(error) {
277
+ export function isMissingPathError(error) {
278
278
  if (error instanceof CapabilityError && error.code === 'NOT_FOUND')
279
279
  return true;
280
280
  const facts = ErrnoCodeSchema.safeParse(error);
@@ -502,7 +502,7 @@ async function collectTsconfigInputs(host, packageRoot, artifactsDir, configPath
502
502
  * every `tsconfig*.json` (and their `extends`/`include` targets), tool config
503
503
  * files (`vite.config.*`, `svelte.config.*`, ...), `scripts/`, every file the
504
504
  * selected build script names, and every non-test, non-hidden file under
505
- * `src/`. Workspace dependencies are folded in by {@link effectiveSourceHash}.
505
+ * `src/`. Workspace dependencies enter through the stamp's traces ({@link currentTraces}).
506
506
  */
507
507
  async function packageBuildInputs(host, pkg) {
508
508
  const packageRoot = pkg.dir;
@@ -548,155 +548,108 @@ async function computePackageSourceHash(host, packageRoot) {
548
548
  }
549
549
  return hash.digestHex();
550
550
  }
551
- /** One memo for the build inputs and hashes read in a plan or independent postflight. */
552
- const effectiveHashMemos = new WeakMap();
553
- function dependencyHashMemo(ownHashes) {
554
- let memo = effectiveHashMemos.get(ownHashes);
555
- if (!memo) {
556
- memo = { effective: new Map(), edges: new Map() };
557
- effectiveHashMemos.set(ownHashes, memo);
558
- }
559
- return memo;
560
- }
561
- function buildDependencyNames(host, pkg, packages, memo) {
562
- let pending = memo.edges.get(pkg.name);
551
+ function buildDependencyNames(host, pkg, packages, context) {
552
+ let pending = context.edges.get(pkg.name);
563
553
  if (!pending) {
564
554
  pending = workspaceDependencyEdges(host, pkg, packages)
565
555
  .then(({ inputs }) => inputs.filter((name) => packages.has(name)));
566
- memo.edges.set(pkg.name, pending);
556
+ context.edges.set(pkg.name, pending);
567
557
  }
568
558
  return pending;
569
559
  }
570
- /**
571
- * A package's own source hash folded with its workspace dependencies'
572
- * effective hashes, computed once per package in linear time.
573
- *
574
- * Packages that import each other (a tolerated mutual-import cycle) form a
575
- * strongly connected component; every member's hash covers the whole
576
- * component and everything it depends on, so the result does not depend on
577
- * which package the walk started from. A package outside any cycle hashes
578
- * exactly as it always has: its own hash, then `\0name\0effective` for each
579
- * declared dependency in order.
580
- */
581
- async function effectiveSourceHash(host, pkg, packages, ownHashes) {
582
- const scope = dependencyHashMemo(ownHashes);
583
- const memo = scope.effective;
584
- const known = memo.get(pkg.name);
585
- if (known !== undefined)
586
- return known;
587
- // Hash the planner's build inputs. Imported optional peers participate in
588
- // freshness without becoming required members of the build closure.
589
- const graph = new Map();
590
- const pending = [pkg.name];
591
- const queued = new Set(pending);
560
+ function createTraceContext() {
561
+ return { ownHashes: new Map(), edges: new Map(), outputs: new Map(), verdicts: new Map() };
562
+ }
563
+ /** `name`'s artifacts directory was replaced: its output and every verdict that read it are stale. */
564
+ function forgetArtifacts(context, name) {
565
+ context.outputs.delete(name);
566
+ context.verdicts.clear();
567
+ }
568
+ async function ownSourceHash(host, pkg, context) {
569
+ let own = context.ownHashes.get(pkg.name);
570
+ if (own === undefined) {
571
+ own = await computePackageSourceHash(host, pkg.dir);
572
+ context.ownHashes.set(pkg.name, own);
573
+ }
574
+ return own;
575
+ }
576
+ /** Every workspace package `pkg` reaches through build-input edges, itself excluded, sorted. */
577
+ async function buildInputClosure(host, pkg, packages, context) {
578
+ if (!packages)
579
+ return [];
580
+ const reached = new Set();
581
+ const pending = [pkg];
592
582
  for (let cursor = 0; cursor < pending.length; cursor += 1) {
593
- const name = pending[cursor];
594
- if (memo.has(name))
595
- continue;
596
- const dependencies = await buildDependencyNames(host, packages.get(name), packages, scope);
597
- graph.set(name, dependencies);
598
- for (const dependency of dependencies) {
599
- if (memo.has(dependency) || queued.has(dependency))
583
+ for (const name of await buildDependencyNames(host, pending[cursor], packages, context)) {
584
+ if (reached.has(name))
600
585
  continue;
601
- queued.add(dependency);
602
- pending.push(dependency);
603
- }
604
- }
605
- // Another concurrent writer may have completed this component during I/O.
606
- const completed = memo.get(pkg.name);
607
- if (completed !== undefined)
608
- return completed;
609
- const edges = (name) => graph.get(name) ?? [];
610
- // Tarjan's SCC over the packages reachable from `pkg` that are not yet
611
- // memoized; components come out dependencies-first.
612
- const index = new Map();
613
- const low = new Map();
614
- const onStack = new Set();
615
- const stack = [];
616
- const components = [];
617
- let counter = 0;
618
- const connect = (name) => {
619
- index.set(name, counter);
620
- low.set(name, counter);
621
- counter += 1;
622
- stack.push(name);
623
- onStack.add(name);
624
- for (const dep of edges(name)) {
625
- if (memo.has(dep))
586
+ reached.add(name);
587
+ pending.push(packages.get(name));
588
+ }
589
+ }
590
+ reached.delete(pkg.name);
591
+ return [...reached].sort();
592
+ }
593
+ /** The hash of every file under `artifactsDirectory` except the stamp and incremental build state. */
594
+ async function computeOutputHash(host, artifactsDirectory) {
595
+ if (!(await host.files.exists(artifactsDirectory)))
596
+ return 'absent';
597
+ const files = [];
598
+ const visit = async (directory) => {
599
+ for (const entry of await host.files.list(directory)) {
600
+ const absolute = host.paths.join(directory, entry.name);
601
+ if (entry.kind === 'directory') {
602
+ await visit(absolute);
626
603
  continue;
627
- if (!index.has(dep)) {
628
- connect(dep);
629
- low.set(name, Math.min(low.get(name), low.get(dep)));
630
- }
631
- else if (onStack.has(dep)) {
632
- low.set(name, Math.min(low.get(name), index.get(dep)));
633
604
  }
605
+ const path = toPosixPath(host, host.paths.relative(artifactsDirectory, absolute));
606
+ if (path === BUILD_STAMP_FILENAME || path.endsWith('.tsbuildinfo'))
607
+ continue;
608
+ files.push({ path, absolute });
634
609
  }
635
- if (low.get(name) === index.get(name)) {
636
- const component = [];
637
- let member;
638
- do {
639
- member = stack.pop();
640
- onStack.delete(member);
641
- component.push(member);
642
- } while (member !== name);
643
- components.push(component);
644
- }
645
- };
646
- connect(pkg.name);
647
- const ownOf = async (name) => {
648
- let own = ownHashes.get(name);
649
- if (own === undefined) {
650
- own = await computePackageSourceHash(host, packages.get(name).dir);
651
- ownHashes.set(name, own);
652
- }
653
- return own;
654
610
  };
655
- for (const component of components) {
656
- const members = new Set(component);
657
- const selfLoop = component.length === 1 && edges(component[0]).includes(component[0]);
658
- if (component.length === 1 && !selfLoop) {
659
- const name = component[0];
660
- const hash = host.sha256().update(await ownOf(name));
661
- for (const dep of edges(name))
662
- hash.update(`\0${dep}\0${memo.get(dep)}`);
663
- memo.set(name, hash.digestHex());
664
- continue;
665
- }
666
- // One hash for the whole component: every member's own hash and every
667
- // dependency the component reaches outside itself, in a fixed order.
668
- const componentHash = host.sha256().update('scc');
669
- for (const name of [...component].sort())
670
- componentHash.update(`\0${name}\0${await ownOf(name)}`);
671
- const external = new Set();
672
- for (const name of component)
673
- for (const dep of edges(name))
674
- if (!members.has(dep))
675
- external.add(dep);
676
- for (const dep of [...external].sort())
677
- componentHash.update(`\0dep\0${dep}\0${memo.get(dep)}`);
678
- const shared = componentHash.digestHex();
679
- // Publish the whole component without yielding: concurrent walks must
680
- // never observe only part of a cycle as memoized.
681
- for (const name of component) {
682
- memo.set(name, host.sha256().update(ownHashes.get(name)).update(`\0scc\0${shared}`).digestHex());
683
- }
684
- }
685
- return memo.get(pkg.name);
686
- }
687
- /** What a stamp records about a package's workspace dependencies: name -> effective source hash. */
688
- const RecordedDependencyHashesSchema = z.record(z.string(), z.string());
689
- async function workspaceDependencyHashes(host, pkg, packages, ownHashes) {
690
- const recorded = {};
611
+ await visit(artifactsDirectory);
612
+ if (files.length === 0)
613
+ return 'absent';
614
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
615
+ const hash = host.sha256().update('outputs');
616
+ for (const file of files) {
617
+ const fileHash = host.sha256().update(await readBytes(host.files, file.absolute)).digestHex();
618
+ hash.update(`\0${file.path}\0${fileHash}`);
619
+ }
620
+ return hash.digestHex();
621
+ }
622
+ function outputHashOf(host, pkg, context) {
623
+ let pending = context.outputs.get(pkg.name);
624
+ if (!pending) {
625
+ pending = computeOutputHash(host, host.paths.join(pkg.dir, resolveArtifactsDir(pkg)));
626
+ context.outputs.set(pkg.name, pending);
627
+ }
628
+ return pending;
629
+ }
630
+ /**
631
+ * What a dependent's build consumed from `pkg`: the bytes it emitted, or for a
632
+ * package that builds nothing, its own sources.
633
+ */
634
+ async function traceOf(host, pkg, context) {
635
+ return hasBuildArtifacts(pkg)
636
+ ? `output:${await outputHashOf(host, pkg, context)}`
637
+ : `source:${await ownSourceHash(host, pkg, context)}`;
638
+ }
639
+ /** The current trace of every package in `pkg`'s build-input closure. */
640
+ async function currentTraces(host, pkg, packages, context) {
641
+ const traces = {};
691
642
  if (!packages)
692
- return recorded;
693
- for (const name of await buildDependencyNames(host, pkg, packages, dependencyHashMemo(ownHashes))) {
694
- const dependency = packages.get(name);
695
- if (!dependency)
696
- continue;
697
- recorded[name] = await effectiveSourceHash(host, dependency, packages, ownHashes);
643
+ return traces;
644
+ for (const name of await buildInputClosure(host, pkg, packages, context)) {
645
+ traces[name] = await traceOf(host, packages.get(name), context);
698
646
  }
699
- return recorded;
647
+ return traces;
648
+ }
649
+ /** The first closure member whose recorded trace differs from `current`, if any. */
650
+ function changedTrace(recorded, current) {
651
+ const names = new Set([...Object.keys(recorded), ...Object.keys(current)]);
652
+ return [...names].sort().find((name) => recorded[name] !== current[name]);
700
653
  }
701
654
  /** Every file the build emitted under the artifacts directory (the stamp itself excluded). */
702
655
  async function listEmittedFiles(host, artifactsDirectory) {
@@ -744,18 +697,22 @@ async function removeBuildStamp(host, pkg) {
744
697
  await removeIfPresent(host.files, host.paths.join(pkg.dir, resolveArtifactsDir(pkg), BUILD_STAMP_FILENAME));
745
698
  }
746
699
  /**
747
- * Records a successful build: the source content hash it was built from and the
748
- * exact files it emitted. Callers write it only after the build exited 0.
700
+ * Records a successful build: the source content hash it was built from, the
701
+ * traces of its build-input closure as they stand now, and the exact files it
702
+ * emitted. Callers write it only after the build exited 0 and, inside a
703
+ * cycle, only once the whole component settled.
749
704
  */
750
705
  export async function writeBuildOkStamp(packageDir,
751
706
  /** The runtime whose logger reports a build input the hash could only read as bytes. */
752
- runtime, packages, dependencySourceHashes = new Map()) {
707
+ runtime, packages, context = createTraceContext()) {
753
708
  const host = await resolveArtifactHost(runtime);
754
709
  const pkg = await readPackageManifest(host, packageDir);
755
710
  const artifactsDirectory = host.paths.join(pkg.dir, resolveArtifactsDir(pkg));
711
+ forgetArtifacts(context, pkg.name);
756
712
  const stamp = {
757
- sourceHash: await computePackageSourceHash(host, pkg.dir),
758
- dependencies: await workspaceDependencyHashes(host, pkg, packages, dependencySourceHashes),
713
+ sourceHash: await ownSourceHash(host, pkg, context),
714
+ traces: await currentTraces(host, pkg, packages, context),
715
+ outputHash: await outputHashOf(host, pkg, context),
759
716
  toolVersions: declaredToolVersions(pkg.packageJson),
760
717
  files: await listEmittedFiles(host, artifactsDirectory)
761
718
  };
@@ -769,7 +726,8 @@ runtime, packages, dependencySourceHashes = new Map()) {
769
726
  /** The admitted shape of a build stamp, parsed once at the boundary. */
770
727
  const BuildStampSchema = z.object({
771
728
  sourceHash: z.string(),
772
- dependencies: z.record(z.string(), z.string()),
729
+ traces: z.record(z.string(), z.string()),
730
+ outputHash: z.string(),
773
731
  toolVersions: z.record(z.string(), z.string()),
774
732
  files: z.array(z.object({ path: z.string(), size: z.number() }).loose())
775
733
  }).loose();
@@ -777,11 +735,12 @@ function parseBuildStamp(text) {
777
735
  const parsed = BuildStampSchema.safeParse(JSON.parse(text));
778
736
  if (parsed.success) {
779
737
  // The schema admits extra keys from older and newer writers; the stamp
780
- // is the four typed fields, constructed explicitly instead of cast.
781
- const { sourceHash, dependencies, toolVersions, files } = parsed.data;
738
+ // is the five typed fields, constructed explicitly instead of cast.
739
+ const { sourceHash, traces, outputHash, toolVersions, files } = parsed.data;
782
740
  return {
783
741
  sourceHash,
784
- dependencies,
742
+ traces,
743
+ outputHash,
785
744
  toolVersions,
786
745
  files: files.map((file) => ({ path: file.path, size: file.size }))
787
746
  };
@@ -793,13 +752,47 @@ function parseBuildStamp(text) {
793
752
  /**
794
753
  * The single freshness decision every caller shares: `assertDeployWorkspacePreflight`
795
754
  * uses it to refuse a deploy over stale inputs, and the build orchestration
796
- * below uses the same predicate to skip a closure member that never went
797
- * stale. Fresh means a build-success stamp exists, its source hash equals the
798
- * hash of the package's current build inputs, and every file it lists still
799
- * exists at the recorded size. There is no timestamp heuristic: a package with
800
- * no stamp (never built, failed build, interrupted build) is not fresh.
755
+ * below uses the same predicate to skip or restore a closure member. Fresh
756
+ * means the package's own build is valid ({@link checkOwnBuild}) and so is
757
+ * every package in its build-input closure: a trace only vouches for a
758
+ * dependency whose own build still matches its sources. There is no
759
+ * timestamp heuristic: a package with no stamp (never built, failed build,
760
+ * interrupted build) is not fresh.
801
761
  */
802
- async function checkWorkspacePackageFreshness(host, pkg, packages, ownHashes = new Map()) {
762
+ async function checkWorkspacePackageFreshness(host, pkg, packages, context = createTraceContext()) {
763
+ const own = await checkOwnBuild(host, pkg, packages, context);
764
+ if (!own.fresh || !packages)
765
+ return own;
766
+ for (const name of await buildInputClosure(host, pkg, packages, context)) {
767
+ const dependency = packages.get(name);
768
+ if (!hasBuildArtifacts(dependency))
769
+ continue;
770
+ const verdict = await checkOwnBuild(host, dependency, packages, context);
771
+ if (!verdict.fresh) {
772
+ return {
773
+ ...own,
774
+ fresh: false,
775
+ reason: `workspace dependency ${name} changed since the last successful build (${verdict.reason})`
776
+ };
777
+ }
778
+ }
779
+ return own;
780
+ }
781
+ /**
782
+ * One package's own build, judged alone: a build-success stamp exists, its
783
+ * source hash equals the hash of the package's current build inputs, every
784
+ * closure trace it recorded equals that package's trace now, and the emitted
785
+ * files are still the recorded bytes.
786
+ */
787
+ function checkOwnBuild(host, pkg, packages, context) {
788
+ let verdict = context.verdicts.get(pkg.name);
789
+ if (!verdict) {
790
+ verdict = judgeOwnBuild(host, pkg, packages, context);
791
+ context.verdicts.set(pkg.name, verdict);
792
+ }
793
+ return verdict;
794
+ }
795
+ async function judgeOwnBuild(host, pkg, packages, context) {
803
796
  const resolvedPackageRoot = await realpathChecked(host.files, pkg.dir);
804
797
  const artifactsDir = resolveArtifactsDir(pkg);
805
798
  const artifactsDirectory = host.paths.join(resolvedPackageRoot, artifactsDir);
@@ -825,16 +818,12 @@ async function checkWorkspacePackageFreshness(host, pkg, packages, ownHashes = n
825
818
  return result(`${artifactsDir}/${BUILD_STAMP_FILENAME} is not a valid build stamp`);
826
819
  if (stamp.files.length === 0)
827
820
  return result(`${artifactsDir}/${BUILD_STAMP_FILENAME} lists no emitted files`);
828
- if (stamp.sourceHash !== (await computePackageSourceHash(host, resolvedPackageRoot))) {
821
+ if (stamp.sourceHash !== (await ownSourceHash(host, pkg, context))) {
829
822
  return result(`package sources changed since the last successful build`);
830
823
  }
831
- const currentDependencies = await workspaceDependencyHashes(host, pkg, packages, ownHashes);
832
- const dependencyNames = new Set([...Object.keys(stamp.dependencies), ...Object.keys(currentDependencies)]);
833
- for (const name of [...dependencyNames].sort()) {
834
- if (stamp.dependencies[name] !== currentDependencies[name]) {
835
- return result(`workspace dependency ${name} changed since the last successful build`);
836
- }
837
- }
824
+ const changed = changedTrace(stamp.traces, await currentTraces(host, pkg, packages, context));
825
+ if (changed !== undefined)
826
+ return result(`workspace dependency ${changed} changed since the last successful build`);
838
827
  for (const file of stamp.files) {
839
828
  const path = host.paths.join(artifactsDirectory, file.path);
840
829
  if (!isPathWithin(host, artifactsDirectory, path)) {
@@ -854,6 +843,9 @@ async function checkWorkspacePackageFreshness(host, pkg, packages, ownHashes = n
854
843
  return result(`emitted file changed size (${file.size} -> ${size}): ${artifactsDir}/${file.path}`);
855
844
  }
856
845
  }
846
+ if (stamp.outputHash !== (await outputHashOf(host, pkg, context))) {
847
+ return result(`${artifactsDir} no longer holds the bytes its build emitted`);
848
+ }
857
849
  const buildInfoReason = await typeScriptBuildInfoRefusal(host, host.paths.join(artifactsDirectory, TYPESCRIPT_BUILD_INFO_FILENAME));
858
850
  if (buildInfoReason)
859
851
  return result(buildInfoReason);
@@ -888,7 +880,7 @@ async function typeScriptBuildInfoRefusal(host, tsBuildInfoPath) {
888
880
  }
889
881
  return undefined;
890
882
  }
891
- async function buildPackage(host, workspaceRoot, pkg, execution, packages, dependencySourceHashes, onLine) {
883
+ async function buildPackage(host, workspaceRoot, pkg, execution, onLine) {
892
884
  const scriptName = resolveBuildScriptName(pkg);
893
885
  onLine?.('stdout', `[workspace-artifacts] building ${pkg.name}`);
894
886
  const diagnostics = createWorkspaceDiagnosticTail();
@@ -920,7 +912,6 @@ async function buildPackage(host, workspaceRoot, pkg, execution, packages, depen
920
912
  throw new Error(`Workspace artifact build failed for ${pkg.name} (exit=${result.code ?? 'unknown'})\n` +
921
913
  `Compiler diagnostics (redacted tail):\n${diagnostics.render()}`);
922
914
  }
923
- await writeBuildOkStamp(pkg.dir, host.runtime, packages, dependencySourceHashes);
924
915
  }
925
916
  /** A failed build leaves neither its stamp nor its incremental state, so the next run rebuilds from scratch. */
926
917
  async function invalidateFailedBuild(host, pkg) {
@@ -933,11 +924,10 @@ async function invalidateFailedBuild(host, pkg) {
933
924
  * Independent branches of the dependency graph (declared or source-discovered
934
925
  * edges, the same edge set the planner used) run concurrently; a mutual-import
935
926
  * cycle — the class `orderCycleMembersByWorkspaceDependencies` already
936
- * resolves into one contiguous, order-sensitive run — still builds its
937
- * members serially, in the plan's existing order, so a tolerated cycle's
938
- * behaviour is unchanged. `run` decides per package whether to actually spawn
939
- * a build (it applies the freshness skip); this function only owns the safe
940
- * scheduling.
927
+ * resolves into one contiguous run — is handed to `run` whole, in the plan's
928
+ * existing order, once every component it depends on has settled. `run`
929
+ * decides which members actually spawn a build; this function only owns the
930
+ * safe scheduling.
941
931
  */
942
932
  async function runWorkspaceBuildPlan(host, plan, packages, concurrency, run) {
943
933
  if (plan.length === 0)
@@ -977,8 +967,7 @@ async function runWorkspaceBuildPlan(host, plan, packages, concurrency, run) {
977
967
  // combined rejection and completion always advances the scheduler.
978
968
  const runComponent = async (index) => {
979
969
  const outcome = await settle(async () => {
980
- for (const pkg of components[index].members)
981
- await run(pkg);
970
+ await run(components[index].members);
982
971
  });
983
972
  if (!outcome.ok)
984
973
  fail(outcome.cause);
@@ -1060,37 +1049,164 @@ function groupWorkspaceBuildPlanIntoComponents(plan, dependenciesOf) {
1060
1049
  dependsOn: dependsOnByComponent.get(component) ?? new Set()
1061
1050
  }));
1062
1051
  }
1063
- /** Independent workspace package builds run this many at a time (see `runWorkspaceBuildPlan`). */
1052
+ /** Independent workspace package builds run this many at a time when the host reports no resources. */
1064
1053
  const DEFAULT_WORKSPACE_BUILD_CONCURRENCY = 4;
1054
+ /** One package build (tsc over the dependency graph's declarations) holds about this much memory. */
1055
+ const WORKSPACE_BUILD_MEMORY_BYTES = 4 * 1024 ** 3;
1056
+ /**
1057
+ * Size the build to the host it runs on: half its cores, no more builds than its memory
1058
+ * holds, never below the default.
1059
+ */
1060
+ function workspaceBuildConcurrency(host) {
1061
+ const resources = host.runtime.environment?.systemResources?.();
1062
+ if (!resources)
1063
+ return DEFAULT_WORKSPACE_BUILD_CONCURRENCY;
1064
+ const byCpu = Math.floor(resources.cpuCount / 2);
1065
+ const byMemory = Math.floor(resources.totalMemoryBytes / WORKSPACE_BUILD_MEMORY_BYTES);
1066
+ return Math.max(DEFAULT_WORKSPACE_BUILD_CONCURRENCY, Math.min(byCpu, byMemory));
1067
+ }
1065
1068
  async function buildWorkspacePlanWithFreshnessSkip(host, workspaceRoot, plan, packages, execution, onLine, artifactStore) {
1066
- // Source hashes do not change while a plan builds (builds emit only into artifact directories).
1067
- const ownHashes = new Map();
1069
+ const context = createTraceContext();
1068
1070
  const store = artifactStore ?? resolveWorkspaceArtifactStore(host.runtime, host.files, host.paths);
1069
1071
  const tools = await installedToolVersions(host, workspaceRoot);
1070
- await runWorkspaceBuildPlan(host, plan, packages, DEFAULT_WORKSPACE_BUILD_CONCURRENCY, async (pkg) => {
1071
- const freshness = await checkWorkspacePackageFreshness(host, pkg, packages, ownHashes);
1072
- // The graph decides what changed: a package whose effective hash (its inputs and every
1073
- // workspace dependency's) was built before is restored, and only then trusted by the
1074
- // same freshness predicate a local build passes.
1075
- const key = await artifactStoreKey(host, pkg, packages, ownHashes, tools);
1076
- const artifactsDirectory = host.paths.join(pkg.dir, resolveArtifactsDir(pkg));
1077
- if (freshness.fresh) {
1078
- onLine?.('stdout', `[workspace-artifacts] skip ${pkg.name}: ${freshness.artifactsDir} is already fresh`);
1079
- // A fresh local build is the build for this hash; another checkout restores it.
1080
- await store.save(pkg.name, key, artifactsDirectory);
1081
- return;
1072
+ await runWorkspaceBuildPlan(host, plan, packages, workspaceBuildConcurrency(host), (members) => settleWorkspaceComponent(host, workspaceRoot, members, packages, execution, context, store, tools, onLine));
1073
+ }
1074
+ /**
1075
+ * Settles one component (a lone package, or every member of an import cycle)
1076
+ * once everything it depends on has settled.
1077
+ *
1078
+ * Each member reuses a build of its current sources (the one on disk, else a
1079
+ * stored one) whose recorded traces agree with the settled packages below and
1080
+ * with the builds already chosen for earlier members; a member with none
1081
+ * builds. Builds read workspace dependencies from source, so a member built in
1082
+ * this run is right whatever its siblings' artifacts hold; a reused member is
1083
+ * right only if its traces still agree once the component's artifacts are in
1084
+ * place, so any that disagree rebuild, until none do. Each pass turns at least
1085
+ * one reuse into a build, so this ends. Stamps for the members built here are
1086
+ * written last, recording the settled traces.
1087
+ */
1088
+ async function settleWorkspaceComponent(host, workspaceRoot, members, packages, execution, context, store, tools, onLine) {
1089
+ const memberNames = new Set(members.map((pkg) => pkg.name));
1090
+ const ownKeys = new Map();
1091
+ const options = new Map();
1092
+ for (const pkg of members) {
1093
+ const own = await ownSourceHash(host, pkg, context);
1094
+ const ownKey = host.sha256().update(`workspace-artifacts\0v2\0${own}\0${tools}`).digestHex();
1095
+ ownKeys.set(pkg.name, ownKey);
1096
+ const current = await currentTraces(host, pkg, packages, context);
1097
+ // Outside the component everything has settled, so a reusable build agrees with it exactly.
1098
+ const agreesBelow = (stamp) => {
1099
+ if (stamp.sourceHash !== own)
1100
+ return false;
1101
+ const names = new Set([...Object.keys(stamp.traces), ...Object.keys(current)]);
1102
+ return [...names].every((name) => memberNames.has(name) || stamp.traces[name] === current[name]);
1103
+ };
1104
+ const reusable = [];
1105
+ const onDisk = await readStampOnDisk(host, pkg);
1106
+ if (onDisk && onDisk.outputHash === (await outputHashOf(host, pkg, context)) && agreesBelow(onDisk)) {
1107
+ reusable.push({ stamp: onDisk });
1108
+ }
1109
+ for (const entry of await store.entries(pkg.name, ownKey, BUILD_STAMP_FILENAME)) {
1110
+ const stamp = parseStoredStamp(entry.text);
1111
+ if (stamp && agreesBelow(stamp))
1112
+ reusable.push({ stamp, key: entry.key });
1113
+ }
1114
+ options.set(pkg.name, reusable);
1115
+ }
1116
+ // Inside the component, drop a build that traced a sibling output no reusable
1117
+ // build of that sibling has. A sibling with none builds; its output is checked
1118
+ // after it builds.
1119
+ const satisfiable = (build) => Object.entries(build.stamp.traces).every(([name, trace]) => {
1120
+ const sibling = memberNames.has(name) ? options.get(name) : [];
1121
+ return sibling.length === 0 || sibling.some((other) => trace === `output:${other.stamp.outputHash}`);
1122
+ });
1123
+ for (let pruned = true; pruned;) {
1124
+ pruned = false;
1125
+ for (const pkg of members) {
1126
+ const reusable = options.get(pkg.name);
1127
+ const kept = reusable.filter(satisfiable);
1128
+ if (kept.length === reusable.length)
1129
+ continue;
1130
+ options.set(pkg.name, kept);
1131
+ pruned = true;
1132
+ }
1133
+ }
1134
+ // Choose in plan order, each agreeing both ways with the builds chosen before it.
1135
+ const chosen = new Map();
1136
+ for (const pkg of members) {
1137
+ const pick = options.get(pkg.name).find((build) => [...chosen].every(([name, other]) => (build.stamp.traces[name] === undefined || build.stamp.traces[name] === `output:${other.stamp.outputHash}`) &&
1138
+ (other.stamp.traces[pkg.name] === undefined || other.stamp.traces[pkg.name] === `output:${build.stamp.outputHash}`)));
1139
+ if (pick)
1140
+ chosen.set(pkg.name, pick);
1141
+ }
1142
+ const built = new Set();
1143
+ const build = async (pkg) => {
1144
+ await buildPackage(host, workspaceRoot, pkg, execution, onLine);
1145
+ forgetArtifacts(context, pkg.name);
1146
+ built.add(pkg.name);
1147
+ };
1148
+ for (const pkg of members) {
1149
+ const reuse = chosen.get(pkg.name);
1150
+ if (!reuse)
1151
+ continue;
1152
+ if (reuse.key === undefined) {
1153
+ onLine?.('stdout', `[workspace-artifacts] skip ${pkg.name}: ${resolveArtifactsDir(pkg)} is already fresh`);
1154
+ continue;
1082
1155
  }
1083
- if (await store.restore(pkg.name, key, artifactsDirectory)) {
1084
- const restored = await checkWorkspacePackageFreshness(host, pkg, packages, ownHashes);
1085
- if (restored.fresh) {
1086
- onLine?.('stdout', `[workspace-artifacts] restore ${pkg.name}: built before at this source hash`);
1087
- return;
1088
- }
1089
- onLine?.('stdout', `[workspace-artifacts] rebuild ${pkg.name}: the stored build is not fresh (${restored.reason})`);
1156
+ if (await store.restore(pkg.name, reuse.key, host.paths.join(pkg.dir, resolveArtifactsDir(pkg)))) {
1157
+ forgetArtifacts(context, pkg.name);
1158
+ onLine?.('stdout', `[workspace-artifacts] restore ${pkg.name}: built before from these sources and inputs`);
1090
1159
  }
1091
- await buildPackage(host, workspaceRoot, pkg, execution, packages, ownHashes, onLine);
1092
- await store.save(pkg.name, key, artifactsDirectory);
1093
- });
1160
+ else {
1161
+ chosen.delete(pkg.name);
1162
+ }
1163
+ }
1164
+ for (const pkg of members)
1165
+ if (!chosen.has(pkg.name))
1166
+ await build(pkg);
1167
+ for (;;) {
1168
+ const disagreeing = [];
1169
+ for (const pkg of members) {
1170
+ if (built.has(pkg.name))
1171
+ continue;
1172
+ const verdict = await checkOwnBuild(host, pkg, packages, context);
1173
+ if (verdict.fresh)
1174
+ continue;
1175
+ onLine?.('stdout', `[workspace-artifacts] rebuild ${pkg.name}: ${verdict.reason}`);
1176
+ disagreeing.push(pkg);
1177
+ }
1178
+ if (disagreeing.length === 0)
1179
+ break;
1180
+ for (const pkg of disagreeing)
1181
+ await build(pkg);
1182
+ }
1183
+ for (const pkg of members) {
1184
+ const artifactsDirectory = host.paths.join(pkg.dir, resolveArtifactsDir(pkg));
1185
+ const stamp = built.has(pkg.name)
1186
+ ? await writeBuildOkStamp(pkg.dir, host.runtime, packages, context)
1187
+ : chosen.get(pkg.name).stamp;
1188
+ // A stored entry is the build for its key; a fresh build or a reused local one is stored for other checkouts.
1189
+ if (!built.has(pkg.name) && chosen.get(pkg.name).key !== undefined)
1190
+ continue;
1191
+ await store.save(pkg.name, `${ownKeys.get(pkg.name)}/${tracesKey(host, stamp.traces)}`, artifactsDirectory);
1192
+ }
1193
+ }
1194
+ function tracesKey(host, traces) {
1195
+ const hash = host.sha256().update('traces');
1196
+ for (const name of Object.keys(traces).sort())
1197
+ hash.update(`\0${name}\0${traces[name]}`);
1198
+ return hash.digestHex();
1199
+ }
1200
+ async function readStampOnDisk(host, pkg) {
1201
+ const path = host.paths.join(pkg.dir, resolveArtifactsDir(pkg), BUILD_STAMP_FILENAME);
1202
+ if (!(await host.files.exists(path)))
1203
+ return undefined;
1204
+ return parseStoredStamp(await readText(host.files, path));
1205
+ }
1206
+ /** A stamp read from disk or the store; one that does not parse is no candidate. */
1207
+ function parseStoredStamp(text) {
1208
+ const parsed = settleNow(() => parseBuildStamp(text));
1209
+ return parsed.ok ? parsed.value : undefined;
1094
1210
  }
1095
1211
  /**
1096
1212
  * The resolved versions of the toolchain a build runs (from the workspace install),
@@ -1104,11 +1220,6 @@ async function installedToolVersions(host, workspaceRoot) {
1104
1220
  }
1105
1221
  return JSON.stringify(versions);
1106
1222
  }
1107
- /** A package build's store identity: its effective source hash and the installed toolchain. */
1108
- async function artifactStoreKey(host, pkg, packages, ownHashes, tools) {
1109
- const effective = await effectiveSourceHash(host, pkg, packages, ownHashes);
1110
- return host.sha256().update(`workspace-artifacts\0v1\0${effective}\0${tools}`).digestHex();
1111
- }
1112
1223
  async function discoverSvelteKitApps(host, workspaceRoot) {
1113
1224
  const appsRoot = host.paths.join(workspaceRoot, 'apps');
1114
1225
  if (!(await host.files.exists(appsRoot)))
@@ -1743,13 +1854,13 @@ export async function assertDeployWorkspacePreflight({ workspaceRoot, runtime, a
1743
1854
  if (artifactPolicy === 'planned-build') {
1744
1855
  return { warnings };
1745
1856
  }
1746
- const ownHashes = new Map();
1857
+ const context = createTraceContext();
1747
1858
  for (const pkg of packageClosure.filter(hasBuildArtifacts)) {
1748
1859
  // Single source of truth: the same predicate that decides whether the
1749
1860
  // build orchestration below may skip this package's build (see
1750
1861
  // `checkWorkspacePackageFreshness`) decides whether this postflight
1751
1862
  // assertion accepts it as a valid deploy input.
1752
- const freshness = await checkWorkspacePackageFreshness(host, pkg, workspacePackages, ownHashes);
1863
+ const freshness = await checkWorkspacePackageFreshness(host, pkg, workspacePackages, context);
1753
1864
  if (freshness.fresh)
1754
1865
  continue;
1755
1866
  if (freshness.artifactsMissing) {