@chllming/wave-orchestration 0.6.1 → 0.6.3

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 (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +79 -30
  3. package/docs/README.md +15 -3
  4. package/docs/concepts/context7-vs-skills.md +24 -0
  5. package/docs/concepts/runtime-agnostic-orchestration.md +17 -2
  6. package/docs/concepts/what-is-a-wave.md +28 -0
  7. package/docs/evals/README.md +2 -0
  8. package/docs/guides/terminal-surfaces.md +2 -0
  9. package/docs/plans/current-state.md +2 -1
  10. package/docs/plans/wave-orchestrator.md +22 -3
  11. package/docs/reference/runtime-config/README.md +4 -4
  12. package/docs/reference/runtime-config/claude.md +6 -1
  13. package/docs/reference/runtime-config/codex.md +2 -2
  14. package/docs/reference/runtime-config/opencode.md +1 -1
  15. package/docs/research/agent-context-sources.md +2 -0
  16. package/docs/research/coordination-failure-review.md +37 -13
  17. package/package.json +1 -1
  18. package/releases/manifest.json +33 -0
  19. package/scripts/wave-autonomous.mjs +2 -4
  20. package/scripts/wave-orchestrator/adhoc.mjs +32 -11
  21. package/scripts/wave-orchestrator/agent-state.mjs +10 -3
  22. package/scripts/wave-orchestrator/autonomous.mjs +20 -6
  23. package/scripts/wave-orchestrator/config.mjs +19 -0
  24. package/scripts/wave-orchestrator/dashboard-renderer.mjs +150 -20
  25. package/scripts/wave-orchestrator/dashboard-state.mjs +8 -0
  26. package/scripts/wave-orchestrator/executors.mjs +67 -4
  27. package/scripts/wave-orchestrator/install.mjs +198 -25
  28. package/scripts/wave-orchestrator/launcher-runtime.mjs +1 -0
  29. package/scripts/wave-orchestrator/launcher.mjs +249 -10
  30. package/scripts/wave-orchestrator/package-update-notice.mjs +230 -0
  31. package/scripts/wave-orchestrator/package-version.mjs +32 -0
  32. package/scripts/wave-orchestrator/terminals.mjs +25 -0
  33. package/scripts/wave-orchestrator/wave-files.mjs +31 -0
  34. package/scripts/wave.mjs +12 -2
@@ -81,6 +81,7 @@ import {
81
81
  writeTextAtomic,
82
82
  } from "./shared.mjs";
83
83
  import {
84
+ createCurrentWaveDashboardTerminalEntry,
84
85
  appendTerminalEntries,
85
86
  createGlobalDashboardTerminalEntry,
86
87
  createTemporaryTerminalEntries,
@@ -97,6 +98,7 @@ import {
97
98
  commandForExecutor,
98
99
  isExecutorCommandAvailable,
99
100
  } from "./executors.mjs";
101
+ import { maybeAnnouncePackageUpdate } from "./package-update-notice.mjs";
100
102
  import {
101
103
  agentRequiresProofCentricValidation,
102
104
  buildRunStateEvidence,
@@ -1449,11 +1451,94 @@ export function readWaveImplementationGate(wave, agentRuns) {
1449
1451
  };
1450
1452
  }
1451
1453
 
1454
+ function analyzePromotedComponentOwners(componentId, agentRuns, summariesByAgentId) {
1455
+ const ownerRuns = (agentRuns || []).filter((runInfo) =>
1456
+ runInfo.agent.components?.includes(componentId),
1457
+ );
1458
+ const ownerAgentIds = ownerRuns.map((runInfo) => runInfo.agent.agentId);
1459
+ const satisfiedAgentIds = [];
1460
+ const waitingOnAgentIds = [];
1461
+ const failedOwnContractAgentIds = [];
1462
+ for (const runInfo of ownerRuns) {
1463
+ const summary = summariesByAgentId?.[runInfo.agent.agentId] || null;
1464
+ const implementationValidation = validateImplementationSummary(runInfo.agent, summary);
1465
+ const componentMarkers = new Map(
1466
+ Array.isArray(summary?.components)
1467
+ ? summary.components.map((component) => [component.componentId, component])
1468
+ : [],
1469
+ );
1470
+ const marker = componentMarkers.get(componentId);
1471
+ const expectedLevel = runInfo.agent.componentTargets?.[componentId] || null;
1472
+ const componentSatisfied =
1473
+ marker &&
1474
+ marker.state === "met" &&
1475
+ (!expectedLevel || marker.level === expectedLevel);
1476
+ if (implementationValidation.ok && componentSatisfied) {
1477
+ satisfiedAgentIds.push(runInfo.agent.agentId);
1478
+ continue;
1479
+ }
1480
+ waitingOnAgentIds.push(runInfo.agent.agentId);
1481
+ if (!implementationValidation.ok) {
1482
+ failedOwnContractAgentIds.push(runInfo.agent.agentId);
1483
+ }
1484
+ }
1485
+ return {
1486
+ componentId,
1487
+ ownerRuns,
1488
+ ownerAgentIds,
1489
+ satisfiedAgentIds,
1490
+ waitingOnAgentIds,
1491
+ failedOwnContractAgentIds,
1492
+ };
1493
+ }
1494
+
1495
+ function buildSharedComponentSiblingPendingFailure(componentState) {
1496
+ if (
1497
+ !componentState ||
1498
+ componentState.satisfiedAgentIds.length === 0 ||
1499
+ componentState.waitingOnAgentIds.length === 0
1500
+ ) {
1501
+ return null;
1502
+ }
1503
+ const landedSummary =
1504
+ componentState.satisfiedAgentIds.length === 1
1505
+ ? `${componentState.satisfiedAgentIds[0]} desired-state slice landed`
1506
+ : `${componentState.satisfiedAgentIds.join(", ")} desired-state slices landed`;
1507
+ const ownerRun =
1508
+ componentState.ownerRuns.find((runInfo) =>
1509
+ componentState.waitingOnAgentIds.includes(runInfo.agent.agentId),
1510
+ ) ||
1511
+ componentState.ownerRuns[0] ||
1512
+ null;
1513
+ return {
1514
+ ok: false,
1515
+ agentId: componentState.waitingOnAgentIds[0] || ownerRun?.agent?.agentId || null,
1516
+ componentId: componentState.componentId || null,
1517
+ statusCode: "shared-component-sibling-pending",
1518
+ detail: `${landedSummary}; shared component closure still depends on ${componentState.waitingOnAgentIds.join("/")}.`,
1519
+ logPath: ownerRun ? path.relative(REPO_ROOT, ownerRun.logPath) : null,
1520
+ ownerAgentIds: componentState.ownerAgentIds,
1521
+ satisfiedAgentIds: componentState.satisfiedAgentIds,
1522
+ waitingOnAgentIds: componentState.waitingOnAgentIds,
1523
+ failedOwnContractAgentIds: componentState.failedOwnContractAgentIds,
1524
+ };
1525
+ }
1526
+
1452
1527
  export function readWaveComponentGate(wave, agentRuns, options = {}) {
1453
1528
  const summariesByAgentId = Object.fromEntries(
1454
1529
  agentRuns.map((runInfo) => [runInfo.agent.agentId, readRunExecutionSummary(runInfo, wave)]),
1455
1530
  );
1456
1531
  const validation = validateWaveComponentPromotions(wave, summariesByAgentId, options);
1532
+ const sharedPending = (wave.componentPromotions || [])
1533
+ .map((promotion) =>
1534
+ buildSharedComponentSiblingPendingFailure(
1535
+ analyzePromotedComponentOwners(promotion.componentId, agentRuns, summariesByAgentId),
1536
+ ),
1537
+ )
1538
+ .find(Boolean);
1539
+ if (sharedPending) {
1540
+ return sharedPending;
1541
+ }
1457
1542
  if (validation.ok) {
1458
1543
  return {
1459
1544
  ok: true,
@@ -1464,8 +1549,12 @@ export function readWaveComponentGate(wave, agentRuns, options = {}) {
1464
1549
  logPath: null,
1465
1550
  };
1466
1551
  }
1467
- const ownerRun =
1468
- agentRuns.find((runInfo) => runInfo.agent.components?.includes(validation.componentId)) ?? null;
1552
+ const componentState = analyzePromotedComponentOwners(
1553
+ validation.componentId,
1554
+ agentRuns,
1555
+ summariesByAgentId,
1556
+ );
1557
+ const ownerRun = componentState.ownerRuns[0] ?? null;
1469
1558
  return {
1470
1559
  ok: false,
1471
1560
  agentId: ownerRun?.agent?.agentId || null,
@@ -1473,6 +1562,10 @@ export function readWaveComponentGate(wave, agentRuns, options = {}) {
1473
1562
  statusCode: validation.statusCode,
1474
1563
  detail: validation.detail,
1475
1564
  logPath: ownerRun ? path.relative(REPO_ROOT, ownerRun.logPath) : null,
1565
+ ownerAgentIds: componentState.ownerAgentIds,
1566
+ satisfiedAgentIds: componentState.satisfiedAgentIds,
1567
+ waitingOnAgentIds: componentState.waitingOnAgentIds,
1568
+ failedOwnContractAgentIds: componentState.failedOwnContractAgentIds,
1476
1569
  };
1477
1570
  }
1478
1571
 
@@ -1799,6 +1892,38 @@ function removeOrphanWaveDashboards(lanePaths, activeSessionNames) {
1799
1892
  return removedDashboardPaths;
1800
1893
  }
1801
1894
 
1895
+ function pruneDryRunExecutorPreviewDirs(lanePaths, waves) {
1896
+ if (!fs.existsSync(lanePaths.executorOverlaysDir)) {
1897
+ return [];
1898
+ }
1899
+ const expectedSlugsByWave = new Map(
1900
+ (waves || []).map((wave) => [wave.wave, new Set((wave.agents || []).map((agent) => agent.slug))]),
1901
+ );
1902
+ const removedPaths = [];
1903
+ for (const entry of fs.readdirSync(lanePaths.executorOverlaysDir, { withFileTypes: true })) {
1904
+ if (!entry.isDirectory() || !/^wave-\d+$/.test(entry.name)) {
1905
+ continue;
1906
+ }
1907
+ const waveNumber = Number.parseInt(entry.name.slice("wave-".length), 10);
1908
+ const waveDir = path.join(lanePaths.executorOverlaysDir, entry.name);
1909
+ const expectedSlugs = expectedSlugsByWave.get(waveNumber);
1910
+ if (!expectedSlugs) {
1911
+ fs.rmSync(waveDir, { recursive: true, force: true });
1912
+ removedPaths.push(path.relative(REPO_ROOT, waveDir));
1913
+ continue;
1914
+ }
1915
+ for (const child of fs.readdirSync(waveDir, { withFileTypes: true })) {
1916
+ if (!child.isDirectory() || expectedSlugs.has(child.name)) {
1917
+ continue;
1918
+ }
1919
+ const childPath = path.join(waveDir, child.name);
1920
+ fs.rmSync(childPath, { recursive: true, force: true });
1921
+ removedPaths.push(path.relative(REPO_ROOT, childPath));
1922
+ }
1923
+ }
1924
+ return removedPaths.toSorted();
1925
+ }
1926
+
1802
1927
  export function reconcileStaleLauncherArtifacts(lanePaths, options = {}) {
1803
1928
  const outcome = {
1804
1929
  removedLock: false,
@@ -2162,9 +2287,63 @@ function relaunchReasonBuckets(runs, failures, derivedState) {
2162
2287
  closureGate: (failures || []).some(
2163
2288
  (failure) => failure.agentId && selectedAgentIds.has(failure.agentId),
2164
2289
  ),
2290
+ sharedComponentSiblingWait: (failures || []).some(
2291
+ (failure) =>
2292
+ failure.statusCode === "shared-component-sibling-pending" &&
2293
+ (failure.waitingOnAgentIds || []).some((agentId) => selectedAgentIds.has(agentId)),
2294
+ ),
2165
2295
  };
2166
2296
  }
2167
2297
 
2298
+ function applySharedComponentWaitStateToDashboard(componentGate, dashboardState) {
2299
+ const waitingSummary = (componentGate?.waitingOnAgentIds || []).join("/");
2300
+ if (!waitingSummary) {
2301
+ return;
2302
+ }
2303
+ for (const agentId of componentGate?.satisfiedAgentIds || []) {
2304
+ setWaveDashboardAgent(dashboardState, agentId, {
2305
+ state: "completed",
2306
+ detail: `Desired-state slice landed; waiting on ${waitingSummary} for shared component closure`,
2307
+ });
2308
+ }
2309
+ }
2310
+
2311
+ function reconcileFailuresAgainstSharedComponentState(wave, agentRuns, failures) {
2312
+ if (!Array.isArray(failures) || failures.length === 0) {
2313
+ return failures;
2314
+ }
2315
+ const summariesByAgentId = Object.fromEntries(
2316
+ (agentRuns || []).map((runInfo) => [runInfo.agent.agentId, readRunExecutionSummary(runInfo, wave)]),
2317
+ );
2318
+ const failureAgentIds = new Set(failures.map((failure) => failure.agentId).filter(Boolean));
2319
+ const consumedSatisfiedAgentIds = new Set();
2320
+ const synthesizedFailures = [];
2321
+ for (const promotion of wave?.componentPromotions || []) {
2322
+ const componentState = analyzePromotedComponentOwners(
2323
+ promotion.componentId,
2324
+ agentRuns,
2325
+ summariesByAgentId,
2326
+ );
2327
+ if (
2328
+ componentState.satisfiedAgentIds.length === 0 ||
2329
+ componentState.waitingOnAgentIds.length === 0 ||
2330
+ !componentState.satisfiedAgentIds.some((agentId) => failureAgentIds.has(agentId))
2331
+ ) {
2332
+ continue;
2333
+ }
2334
+ for (const agentId of componentState.satisfiedAgentIds) {
2335
+ if (failureAgentIds.has(agentId)) {
2336
+ consumedSatisfiedAgentIds.add(agentId);
2337
+ }
2338
+ }
2339
+ synthesizedFailures.push(buildSharedComponentSiblingPendingFailure(componentState));
2340
+ }
2341
+ return [
2342
+ ...synthesizedFailures.filter(Boolean),
2343
+ ...failures.filter((failure) => !consumedSatisfiedAgentIds.has(failure.agentId)),
2344
+ ];
2345
+ }
2346
+
2168
2347
  export function hasReusableSuccessStatus(agent, statusPath, options = {}) {
2169
2348
  const statusRecord = readStatusRecordIfPresent(statusPath);
2170
2349
  const basicReuseOk = Boolean(
@@ -2272,7 +2451,11 @@ function buildFallbackExecutorState(executorState, executorId, attempt, reason)
2272
2451
  }
2273
2452
 
2274
2453
  function applyRetryFallbacks(agentRuns, failures, lanePaths, attemptNumber, waveDefinition = null) {
2275
- const failedAgentIds = new Set(failures.map((failure) => failure.agentId));
2454
+ const failedAgentIds = new Set(
2455
+ failures
2456
+ .filter((failure) => failure.statusCode !== "shared-component-sibling-pending")
2457
+ .map((failure) => failure.agentId),
2458
+ );
2276
2459
  let changed = false;
2277
2460
  const outcomes = new Map();
2278
2461
  for (const run of agentRuns) {
@@ -2733,6 +2916,20 @@ export function resolveRelaunchRuns(agentRuns, failures, derivedState, lanePaths
2733
2916
  barrier: null,
2734
2917
  };
2735
2918
  }
2919
+ const sharedComponentWaitingAgentIds = new Set(
2920
+ (failures || [])
2921
+ .filter((failure) => failure.statusCode === "shared-component-sibling-pending")
2922
+ .flatMap((failure) => failure.waitingOnAgentIds || [])
2923
+ .filter((agentId) => runsByAgentId.has(agentId)),
2924
+ );
2925
+ if (sharedComponentWaitingAgentIds.size > 0) {
2926
+ return {
2927
+ runs: Array.from(sharedComponentWaitingAgentIds)
2928
+ .map((agentId) => runsByAgentId.get(agentId))
2929
+ .filter(Boolean),
2930
+ barrier: null,
2931
+ };
2932
+ }
2736
2933
  const failedAgentIds = new Set(failures.map((failure) => failure.agentId));
2737
2934
  return {
2738
2935
  runs: agentRuns.filter((run) => failedAgentIds.has(run.agent.agentId)),
@@ -2775,10 +2972,15 @@ export async function runLauncherCli(argv) {
2775
2972
  return;
2776
2973
  }
2777
2974
  const { lanePaths, options } = parsed;
2975
+ if (!options.reconcileStatus) {
2976
+ await maybeAnnouncePackageUpdate();
2977
+ }
2778
2978
  let lockHeld = false;
2779
2979
  let globalDashboard = null;
2780
2980
  let globalDashboardTerminalEntry = null;
2781
2981
  let globalDashboardTerminalAppended = false;
2982
+ let currentWaveDashboardTerminalEntry = null;
2983
+ let currentWaveDashboardTerminalAppended = false;
2782
2984
  let selectedWavesForCoordination = [];
2783
2985
 
2784
2986
  const appendCoordination = ({
@@ -2976,6 +3178,7 @@ export async function runLauncherCli(argv) {
2976
3178
  });
2977
3179
 
2978
3180
  if (options.dryRun) {
3181
+ pruneDryRunExecutorPreviewDirs(lanePaths, allWaves);
2979
3182
  for (const wave of filteredWaves) {
2980
3183
  const derivedState = writeWaveDerivedState({
2981
3184
  lanePaths,
@@ -3072,9 +3275,14 @@ export async function runLauncherCli(argv) {
3072
3275
  lanePaths,
3073
3276
  globalDashboard.runId || "global",
3074
3277
  );
3278
+ currentWaveDashboardTerminalEntry = createCurrentWaveDashboardTerminalEntry(lanePaths);
3075
3279
  if (terminalRegistryEnabled) {
3076
- appendTerminalEntries(lanePaths.terminalsPath, [globalDashboardTerminalEntry]);
3280
+ appendTerminalEntries(lanePaths.terminalsPath, [
3281
+ globalDashboardTerminalEntry,
3282
+ currentWaveDashboardTerminalEntry,
3283
+ ]);
3077
3284
  globalDashboardTerminalAppended = true;
3285
+ currentWaveDashboardTerminalAppended = true;
3078
3286
  }
3079
3287
  launchWaveDashboardSession(lanePaths, {
3080
3288
  sessionName: globalDashboardTerminalEntry.sessionName,
@@ -3152,7 +3360,7 @@ export async function runLauncherCli(argv) {
3152
3360
  wave.wave,
3153
3361
  wave.agents,
3154
3362
  runTag,
3155
- options.dashboard,
3363
+ false,
3156
3364
  );
3157
3365
  if (terminalRegistryEnabled) {
3158
3366
  appendTerminalEntries(lanePaths.terminalsPath, terminalEntries);
@@ -3265,12 +3473,9 @@ export async function runLauncherCli(argv) {
3265
3473
  }
3266
3474
  flushDashboards();
3267
3475
 
3268
- const dashboardEntry = terminalEntries.find(
3269
- (entry) => entry.terminalName === `${lanePaths.dashboardTerminalNamePrefix}${wave.wave}`,
3270
- );
3271
- if (options.dashboard && dashboardEntry) {
3476
+ if (options.dashboard && currentWaveDashboardTerminalEntry) {
3272
3477
  launchWaveDashboardSession(lanePaths, {
3273
- sessionName: dashboardEntry.sessionName,
3478
+ sessionName: currentWaveDashboardTerminalEntry.sessionName,
3274
3479
  dashboardPath,
3275
3480
  messageBoardPath,
3276
3481
  });
@@ -3442,6 +3647,12 @@ export async function runLauncherCli(argv) {
3442
3647
 
3443
3648
  materializeAgentExecutionSummaries(wave, agentRuns);
3444
3649
  refreshDerivedState(attempt);
3650
+ failures = reconcileFailuresAgainstSharedComponentState(wave, agentRuns, failures);
3651
+ for (const failure of failures) {
3652
+ if (failure.statusCode === "shared-component-sibling-pending") {
3653
+ applySharedComponentWaitStateToDashboard(failure, dashboardState);
3654
+ }
3655
+ }
3445
3656
 
3446
3657
  if (failures.length > 0) {
3447
3658
  for (const failure of failures) {
@@ -3483,12 +3694,20 @@ export async function runLauncherCli(argv) {
3483
3694
  laneProfile: lanePaths.laneProfile,
3484
3695
  });
3485
3696
  if (!componentGate.ok) {
3697
+ if (componentGate.statusCode === "shared-component-sibling-pending") {
3698
+ applySharedComponentWaitStateToDashboard(componentGate, dashboardState);
3699
+ }
3486
3700
  failures = [
3487
3701
  {
3488
3702
  agentId: componentGate.agentId,
3489
3703
  statusCode: componentGate.statusCode,
3490
3704
  logPath:
3491
3705
  componentGate.logPath || path.relative(REPO_ROOT, messageBoardPath),
3706
+ detail: componentGate.detail,
3707
+ ownerAgentIds: componentGate.ownerAgentIds || [],
3708
+ satisfiedAgentIds: componentGate.satisfiedAgentIds || [],
3709
+ waitingOnAgentIds: componentGate.waitingOnAgentIds || [],
3710
+ failedOwnContractAgentIds: componentGate.failedOwnContractAgentIds || [],
3492
3711
  },
3493
3712
  ];
3494
3713
  recordCombinedEvent({
@@ -4045,6 +4264,9 @@ export async function runLauncherCli(argv) {
4045
4264
  if (globalDashboardTerminalEntry) {
4046
4265
  excludeSessionNames.add(globalDashboardTerminalEntry.sessionName);
4047
4266
  }
4267
+ if (currentWaveDashboardTerminalEntry) {
4268
+ excludeSessionNames.add(currentWaveDashboardTerminalEntry.sessionName);
4269
+ }
4048
4270
  cleanupLaneTmuxSessions(lanePaths, { excludeSessionNames });
4049
4271
  }
4050
4272
  if (globalWave && globalWave.status === "running") {
@@ -4079,6 +4301,13 @@ export async function runLauncherCli(argv) {
4079
4301
  if (globalDashboardTerminalAppended && globalDashboardTerminalEntry && !options.keepTerminals) {
4080
4302
  removeTerminalEntries(lanePaths.terminalsPath, [globalDashboardTerminalEntry]);
4081
4303
  }
4304
+ if (
4305
+ currentWaveDashboardTerminalAppended &&
4306
+ currentWaveDashboardTerminalEntry &&
4307
+ !options.keepTerminals
4308
+ ) {
4309
+ removeTerminalEntries(lanePaths.terminalsPath, [currentWaveDashboardTerminalEntry]);
4310
+ }
4082
4311
  if (options.cleanupSessions && globalDashboardTerminalEntry) {
4083
4312
  try {
4084
4313
  killTmuxSessionIfExists(lanePaths.tmuxSocketName, globalDashboardTerminalEntry.sessionName);
@@ -4086,6 +4315,16 @@ export async function runLauncherCli(argv) {
4086
4315
  // no-op
4087
4316
  }
4088
4317
  }
4318
+ if (options.cleanupSessions && currentWaveDashboardTerminalEntry) {
4319
+ try {
4320
+ killTmuxSessionIfExists(
4321
+ lanePaths.tmuxSocketName,
4322
+ currentWaveDashboardTerminalEntry.sessionName,
4323
+ );
4324
+ } catch {
4325
+ // no-op
4326
+ }
4327
+ }
4089
4328
  if (lockHeld) {
4090
4329
  releaseLauncherLock(lanePaths.launcherLockPath);
4091
4330
  }
@@ -0,0 +1,230 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { ensureDirectory, readJsonOrNull, REPO_ROOT, writeJsonAtomic } from "./shared.mjs";
4
+ import {
5
+ compareVersions,
6
+ readInstalledPackageMetadata,
7
+ WAVE_PACKAGE_NAME,
8
+ } from "./package-version.mjs";
9
+
10
+ export const PACKAGE_UPDATE_CHECK_SCHEMA_VERSION = 1;
11
+ export const PACKAGE_UPDATE_CHECK_PATH = path.join(REPO_ROOT, ".wave", "package-update-check.json");
12
+ export const PACKAGE_UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
13
+ export const PACKAGE_UPDATE_CHECK_TIMEOUT_MS = 2000;
14
+ export const WAVE_SKIP_UPDATE_CHECK_ENV = "WAVE_SKIP_UPDATE_CHECK";
15
+ export const WAVE_SUPPRESS_UPDATE_NOTICE_ENV = "WAVE_SUPPRESS_UPDATE_NOTICE";
16
+ export const NPM_REGISTRY_LATEST_URL = "https://registry.npmjs.org";
17
+
18
+ function isTruthyEnvValue(value) {
19
+ const normalized = String(value ?? "")
20
+ .trim()
21
+ .toLowerCase();
22
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
23
+ }
24
+
25
+ function parsePackageManagerId(value) {
26
+ const normalized = String(value || "")
27
+ .trim()
28
+ .toLowerCase();
29
+ if (normalized.startsWith("pnpm@")) {
30
+ return "pnpm";
31
+ }
32
+ if (normalized.startsWith("npm@")) {
33
+ return "npm";
34
+ }
35
+ if (normalized.startsWith("yarn@")) {
36
+ return "yarn";
37
+ }
38
+ if (normalized.startsWith("bun@")) {
39
+ return "bun";
40
+ }
41
+ return null;
42
+ }
43
+
44
+ function runtimeSelfUpdateCommand(workspaceRoot = REPO_ROOT) {
45
+ const workspacePackage = readJsonOrNull(path.join(workspaceRoot, "package.json"));
46
+ const packageManagerId = parsePackageManagerId(workspacePackage?.packageManager);
47
+ if (packageManagerId === "pnpm") {
48
+ return "pnpm exec wave self-update";
49
+ }
50
+ if (packageManagerId === "npm") {
51
+ return "npm exec -- wave self-update";
52
+ }
53
+ if (packageManagerId === "yarn") {
54
+ return "yarn exec wave self-update";
55
+ }
56
+ if (packageManagerId === "bun") {
57
+ return "bun x wave self-update";
58
+ }
59
+ if (fs.existsSync(path.join(workspaceRoot, "pnpm-lock.yaml"))) {
60
+ return "pnpm exec wave self-update";
61
+ }
62
+ if (fs.existsSync(path.join(workspaceRoot, "yarn.lock"))) {
63
+ return "yarn exec wave self-update";
64
+ }
65
+ if (fs.existsSync(path.join(workspaceRoot, "bun.lock")) || fs.existsSync(path.join(workspaceRoot, "bun.lockb"))) {
66
+ return "bun x wave self-update";
67
+ }
68
+ return "npm exec -- wave self-update";
69
+ }
70
+
71
+ function buildPackageLatestUrl(packageName) {
72
+ return `${NPM_REGISTRY_LATEST_URL}/${encodeURIComponent(String(packageName || WAVE_PACKAGE_NAME)).replace("%40", "@")}/latest`;
73
+ }
74
+
75
+ function readUpdateCheckCache(cachePath = PACKAGE_UPDATE_CHECK_PATH) {
76
+ const payload = readJsonOrNull(cachePath);
77
+ return payload && typeof payload === "object" ? payload : null;
78
+ }
79
+
80
+ function writeUpdateCheckCache(cachePath, payload) {
81
+ ensureDirectory(path.dirname(cachePath));
82
+ writeJsonAtomic(cachePath, payload);
83
+ }
84
+
85
+ function buildNoticeLines(packageName, currentVersion, latestVersion, workspaceRoot = REPO_ROOT) {
86
+ return [
87
+ `[wave:update] newer ${packageName} available: installed ${currentVersion}, latest ${latestVersion}`,
88
+ `[wave:update] update now with: ${runtimeSelfUpdateCommand(workspaceRoot)}`,
89
+ ];
90
+ }
91
+
92
+ function emitNotice(packageName, currentVersion, latestVersion, emit = console.error, workspaceRoot = REPO_ROOT) {
93
+ for (const line of buildNoticeLines(packageName, currentVersion, latestVersion, workspaceRoot)) {
94
+ emit(line);
95
+ }
96
+ }
97
+
98
+ export async function fetchLatestPackageVersion(
99
+ packageName = WAVE_PACKAGE_NAME,
100
+ {
101
+ fetchImpl = globalThis.fetch,
102
+ timeoutMs = PACKAGE_UPDATE_CHECK_TIMEOUT_MS,
103
+ } = {},
104
+ ) {
105
+ if (typeof fetchImpl !== "function") {
106
+ throw new Error("Package update check is unavailable in this Node runtime.");
107
+ }
108
+ const abortController = new AbortController();
109
+ const timer = setTimeout(() => abortController.abort(), timeoutMs);
110
+ try {
111
+ const response = await fetchImpl(buildPackageLatestUrl(packageName), {
112
+ signal: abortController.signal,
113
+ headers: {
114
+ Accept: "application/json",
115
+ },
116
+ });
117
+ if (!response?.ok) {
118
+ throw new Error(`Upstream package check failed with status ${response?.status || "unknown"}.`);
119
+ }
120
+ const payload = await response.json();
121
+ const latestVersion = String(payload?.version || "").trim();
122
+ if (!latestVersion) {
123
+ throw new Error("Upstream package check returned no version.");
124
+ }
125
+ return latestVersion;
126
+ } finally {
127
+ clearTimeout(timer);
128
+ }
129
+ }
130
+
131
+ export async function maybeAnnouncePackageUpdate(options = {}) {
132
+ const env = options.env || process.env;
133
+ if (
134
+ isTruthyEnvValue(env[WAVE_SKIP_UPDATE_CHECK_ENV]) ||
135
+ isTruthyEnvValue(env[WAVE_SUPPRESS_UPDATE_NOTICE_ENV])
136
+ ) {
137
+ return {
138
+ skipped: true,
139
+ reason: "disabled",
140
+ updateAvailable: false,
141
+ latestVersion: null,
142
+ currentVersion: null,
143
+ };
144
+ }
145
+
146
+ const metadata = options.packageMetadata || readInstalledPackageMetadata();
147
+ const packageName = String(metadata.name || WAVE_PACKAGE_NAME);
148
+ const currentVersion = String(metadata.version || "").trim();
149
+ const cachePath = options.cachePath || PACKAGE_UPDATE_CHECK_PATH;
150
+ const workspaceRoot = options.workspaceRoot || REPO_ROOT;
151
+ const cacheTtlMs = options.cacheTtlMs ?? PACKAGE_UPDATE_CHECK_TTL_MS;
152
+ const nowMs = options.nowMs ?? Date.now();
153
+ const emit = options.emit || console.error;
154
+ const cache = readUpdateCheckCache(cachePath);
155
+ const cachedCheckedAtMs = Date.parse(String(cache?.checkedAt || ""));
156
+ const cacheMatchesCurrentVersion = cache?.currentVersion === currentVersion;
157
+ const cachedUpdateAvailable =
158
+ cacheMatchesCurrentVersion &&
159
+ typeof cache?.latestVersion === "string" &&
160
+ compareVersions(cache.latestVersion, currentVersion) > 0;
161
+ const cacheFresh =
162
+ cacheMatchesCurrentVersion &&
163
+ Number.isFinite(cachedCheckedAtMs) &&
164
+ nowMs - cachedCheckedAtMs <= cacheTtlMs;
165
+ let emitted = false;
166
+
167
+ if (cachedUpdateAvailable) {
168
+ emitNotice(packageName, currentVersion, cache.latestVersion, emit, workspaceRoot);
169
+ emitted = true;
170
+ }
171
+
172
+ if (cacheFresh) {
173
+ return {
174
+ skipped: false,
175
+ source: "cache",
176
+ updateAvailable: cachedUpdateAvailable,
177
+ latestVersion: cache?.latestVersion || currentVersion,
178
+ currentVersion,
179
+ };
180
+ }
181
+
182
+ try {
183
+ const latestVersion = await fetchLatestPackageVersion(packageName, options);
184
+ const updateAvailable = compareVersions(latestVersion, currentVersion) > 0;
185
+ writeUpdateCheckCache(cachePath, {
186
+ schemaVersion: PACKAGE_UPDATE_CHECK_SCHEMA_VERSION,
187
+ packageName,
188
+ checkedAt: new Date(nowMs).toISOString(),
189
+ currentVersion,
190
+ latestVersion,
191
+ updateAvailable,
192
+ lastErrorAt: null,
193
+ lastErrorMessage: null,
194
+ });
195
+ if (updateAvailable && !emitted) {
196
+ emitNotice(packageName, currentVersion, latestVersion, emit, workspaceRoot);
197
+ }
198
+ return {
199
+ skipped: false,
200
+ source: "network",
201
+ updateAvailable,
202
+ latestVersion,
203
+ currentVersion,
204
+ };
205
+ } catch (error) {
206
+ writeUpdateCheckCache(cachePath, {
207
+ schemaVersion: PACKAGE_UPDATE_CHECK_SCHEMA_VERSION,
208
+ packageName,
209
+ checkedAt: new Date(nowMs).toISOString(),
210
+ currentVersion,
211
+ latestVersion:
212
+ cacheMatchesCurrentVersion && typeof cache?.latestVersion === "string"
213
+ ? cache.latestVersion
214
+ : currentVersion,
215
+ updateAvailable: cachedUpdateAvailable,
216
+ lastErrorAt: new Date(nowMs).toISOString(),
217
+ lastErrorMessage: error instanceof Error ? error.message : String(error),
218
+ });
219
+ return {
220
+ skipped: false,
221
+ source: "error",
222
+ updateAvailable: cachedUpdateAvailable,
223
+ latestVersion:
224
+ cacheMatchesCurrentVersion && typeof cache?.latestVersion === "string"
225
+ ? cache.latestVersion
226
+ : currentVersion,
227
+ currentVersion,
228
+ };
229
+ }
230
+ }
@@ -0,0 +1,32 @@
1
+ import path from "node:path";
2
+ import { PACKAGE_ROOT, readJsonOrNull } from "./shared.mjs";
3
+
4
+ export const WAVE_PACKAGE_NAME = "@chllming/wave-orchestration";
5
+ export const PACKAGE_METADATA_PATH = path.join(PACKAGE_ROOT, "package.json");
6
+
7
+ export function readInstalledPackageMetadata(metadataPath = PACKAGE_METADATA_PATH) {
8
+ const payload = readJsonOrNull(metadataPath);
9
+ if (!payload?.name || !payload?.version) {
10
+ throw new Error(`Invalid package metadata: ${metadataPath}`);
11
+ }
12
+ return payload;
13
+ }
14
+
15
+ function normalizeVersionParts(version) {
16
+ return String(version || "")
17
+ .split(".")
18
+ .map((part) => Number.parseInt(part.replace(/[^0-9].*$/, ""), 10) || 0);
19
+ }
20
+
21
+ export function compareVersions(a, b) {
22
+ const left = normalizeVersionParts(a);
23
+ const right = normalizeVersionParts(b);
24
+ const length = Math.max(left.length, right.length);
25
+ for (let index = 0; index < length; index += 1) {
26
+ const diff = (left[index] || 0) - (right[index] || 0);
27
+ if (diff !== 0) {
28
+ return diff;
29
+ }
30
+ }
31
+ return 0;
32
+ }