@danypops/pi-packed 0.27.3 → 0.27.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.27.3",
3
+ "version": "0.27.5",
4
4
  "description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,7 @@
33
33
  "@danypops/vehicle-client": "^0.10.0",
34
34
  "@danypops/vehicle-client-pi": "^0.40.1",
35
35
  "@danypops/vehicle-core": "^0.15.0",
36
- "@danypops/vehicle-server": "^0.24.1",
36
+ "@danypops/vehicle-server": "^0.24.2",
37
37
  "jiti": "^2.7.0",
38
38
  "malevich-tui-components": "^0.21.1",
39
39
  "publint": "0.3.22",
@@ -455,7 +455,13 @@ export function listManagedPackages(piHome: string, projectRoot?: string): Insta
455
455
  }
456
456
 
457
457
  export interface ReconcileAllResult {
458
- reconciled: Array<{ packageName: string; vehicleName: string; installed: boolean; reason?: string }>;
458
+ /** `versionChanged` is true only when this pass's newly-resolved version genuinely differs
459
+ * from what Armada had registered before this sweep started -- `installed: true` alone means
460
+ * only "the register call itself succeeded", which is true on every healthy pass whether or
461
+ * not anything actually changed underneath (register()/reconcile() is a safe no-op against
462
+ * unchanged desired state). A caller wanting to know whether this pass genuinely restarted
463
+ * something (vs. just re-confirmed a healthy, unchanged fleet) must check this, not `installed`. */
464
+ reconciled: Array<{ packageName: string; vehicleName: string; installed: boolean; versionChanged: boolean; reason?: string }>;
459
465
  skipped: number;
460
466
  failed: Array<{ packageName: string; reason: string }>;
461
467
  /** Every Vehicle unregistered because this sweep no longer discovers it at all -- see pruneStaleVehicles. */
@@ -555,6 +561,13 @@ export async function reconcileAllDaemonServices(
555
561
  const failed: ReconcileAllResult["failed"] = [];
556
562
  const seen = new Set<string>();
557
563
  let skipped = 0;
564
+ // Snapshot BEFORE this sweep's own install() calls mutate Armada's registration -- the only
565
+ // way to tell "this pass's register() call genuinely changed something" from "register()
566
+ // succeeded against an already-current registration", since install()'s own ServiceInstallResult
567
+ // reports only the latter.
568
+ const previousVersions = new Map<string, string>(
569
+ (await installer.listRegisteredVehicles()).map((vehicle) => [String(vehicle.name), vehicle.version]),
570
+ );
558
571
  for (const pkg of packages) {
559
572
  // Re-registering the daemon from inside its own process makes Armada stop
560
573
  // that process before registration can start the replacement.
@@ -577,6 +590,7 @@ export async function reconcileAllDaemonServices(
577
590
  packageName: pkg.name,
578
591
  vehicleName: resolved.spec.name,
579
592
  installed: resolved.result.installed,
593
+ versionChanged: previousVersions.get(resolved.spec.name) !== resolved.spec.version,
580
594
  ...(resolved.result.installed ? {} : { reason: resolved.result.reason }),
581
595
  });
582
596
  }
@@ -127,6 +127,20 @@ export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOpt
127
127
  intervalMs: envMs(ENV.RECONCILE_SECS, RECONCILE_INTERVAL_DEFAULT_MS),
128
128
  run: async () => {
129
129
  const result = await reconcileAllDaemonServices(piHome, undefined, daemonServiceInstaller);
130
+ // A package updated through a route pi-packed never sees at all (e.g. the generic,
131
+ // daemon-unaware `pi update --extension` / pkg_update path -- see
132
+ // pkg-update-never-restarts-vehicle-daemon) leaves this as the ONLY thing that ever
133
+ // notices and restarts the stale daemon. Logging only on failure made every silent
134
+ // success indistinguishable from "this never ran" -- there was no way to confirm a
135
+ // restart this task performed actually happened, short of checking the process's own
136
+ // PID by hand.
137
+ const changed = result.reconciled.filter((entry) => entry.installed && entry.versionChanged);
138
+ if (changed.length > 0 || result.pruned.length > 0) {
139
+ logger.info("vehicle-reconcile applied changes", {
140
+ reconciled: changed.map((entry) => entry.vehicleName),
141
+ pruned: result.pruned.map((entry) => entry.vehicleName),
142
+ });
143
+ }
130
144
  if (result.failed.length > 0) {
131
145
  logger.warn("vehicle-reconcile completed with failures", {
132
146
  reconciled: result.reconciled.length,
@@ -171,28 +185,23 @@ export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOpt
171
185
  };
172
186
  }
173
187
 
174
- function runInitialMaintenance(maintenanceTasks: MaintenanceTask[] | undefined): void {
175
- for (const task of maintenanceTasks ?? []) {
176
- void Promise.resolve(task.run()).catch((error) =>
177
- logger.error(`maintenance task failed: ${task.name}`, { error: error instanceof Error ? error.message : String(error) }),
178
- );
179
- }
180
- }
181
-
188
+ // startDaemon() itself now runs every maintenance task once immediately at startup (in
189
+ // addition to its own interval) -- see @danypops/vehicle-server's own daemon.ts. This used to
190
+ // be a bespoke wrapper here (runInitialMaintenance(), called after startDaemon() returned, or
191
+ // from serveMain()'s onListen) working around a bare setInterval() never firing until its full
192
+ // interval first elapsed; kept here would now double-run every task on every startup. Every
193
+ // OTHER vehicle-server-based daemon (lector, jittor, papyrus, pipes, tickets,
194
+ // web-spider-daemon) never had this workaround at all and now gets the same fix for free from
195
+ // the shared implementation, instead of needing to independently reinvent it.
182
196
  export async function startPackedDaemon(options: StartPackedDaemonOptions = {}): Promise<RunningDaemon> {
183
- const configured = daemonOptions(options);
184
- const running = await startDaemon(configured);
185
- runInitialMaintenance(configured.maintenanceTasks);
186
- return running;
197
+ return startDaemon(daemonOptions(options));
187
198
  }
188
199
 
189
200
  export function serveMain(): void {
190
- const configured = daemonOptions({});
191
201
  runDaemonProcess({
192
- ...configured,
202
+ ...daemonOptions({}),
193
203
  onListen: ({ host, port }) => {
194
204
  logger.info("listening", { host, port });
195
- runInitialMaintenance(configured.maintenanceTasks);
196
205
  },
197
206
  });
198
207
  }
@@ -52,7 +52,17 @@ export const INDEX_OPERATION_TIMEOUT_MS = 10 * 60_000;
52
52
  export const WATCH_INTERVAL_DEFAULT_MS = 30 * 60_000; // updates diff cadence
53
53
  export const CATALOG_INTERVAL_DEFAULT_MS = 6 * 3_600_000; // full mirror TTL
54
54
  export const INDEX_INTERVAL_DEFAULT_MS = 6 * 3_600_000; // static index regeneration TTL, same cadence as the catalog mirror it reads from
55
- export const RECONCILE_INTERVAL_DEFAULT_MS = 30 * 60_000; // Vehicle-service drift sweep cadence -- catches an out-of-band npm install/update a running daemon never picked up
55
+ // Vehicle-service drift sweep cadence -- catches an out-of-band npm install/update a running
56
+ // daemon never picked up (e.g. a plain `pkg_update`/`pi update --extension`, which has no way to
57
+ // notify Armada/pi-packed at all -- see pkg-update-never-restarts-vehicle-daemon). Now that
58
+ // startDaemon() also runs every maintenance task once immediately at startup (see
59
+ // vehicle-server's own daemon.ts), a restart of pi-packed itself no longer waits out this
60
+ // interval at all -- this cadence now only bounds the OTHER case: an out-of-band update that
61
+ // lands while pi-packed's own daemon is already running and stays up. 30 minutes was tuned for
62
+ // a background safety net, not an interactive "I just updated something" workflow; 5 minutes
63
+ // keeps the same self-healing guarantee at a much more reasonable latency for a per-pass cost
64
+ // that's still just a handful of cheap native-service inspections for a fleet this size.
65
+ export const RECONCILE_INTERVAL_DEFAULT_MS = 5 * 60_000;
56
66
  export const IDLE_BUDGET_DEFAULT_MS = 10 * 60_000; // on-demand self-exit
57
67
  export const WATCHDOG_TICK_MS = 15_000;
58
68
 
@@ -120,7 +120,7 @@ class FakeDaemonServiceInstaller {
120
120
  return {
121
121
  ok: true,
122
122
  output: "reconciled 1 Vehicle(s), skipped 0 non-daemon package(s)",
123
- reconciled: [{ packageName: "@danypops/probe", vehicleName: "probe", installed: true }],
123
+ reconciled: [{ packageName: "@danypops/probe", vehicleName: "probe", installed: true, versionChanged: true }],
124
124
  skipped: 0,
125
125
  failed: [],
126
126
  pruned: [],
@@ -604,7 +604,7 @@ describe("CLI", () => {
604
604
  expect(JSON.parse(json.out)).toEqual({
605
605
  ok: true,
606
606
  output: "reconciled 1 Vehicle(s), skipped 0 non-daemon package(s)",
607
- reconciled: [{ packageName: "@danypops/probe", vehicleName: "probe", installed: true }],
607
+ reconciled: [{ packageName: "@danypops/probe", vehicleName: "probe", installed: true, versionChanged: true }],
608
608
  skipped: 0,
609
609
  failed: [],
610
610
  pruned: [],
@@ -226,6 +226,33 @@ describe("startPackedDaemon's own maintenance-task wiring (self-heals Vehicle dr
226
226
  }
227
227
  });
228
228
 
229
+ /**
230
+ * Regression guard for the double-execution risk this file's own daemon.ts (startPackedDaemon/
231
+ * serveMain) used to carry: a bespoke runInitialMaintenance() wrapper called explicitly, on top
232
+ * of what startDaemon() (vehicle-server) itself now already does since it started running every
233
+ * maintenance task once immediately at startup. Removed entirely -- this proves it stayed
234
+ * removed, not just that a task runs at least once (the test above already covers that).
235
+ */
236
+ it("runs each maintenance task exactly once at startup, never twice", async () => {
237
+ const piHome = fakePiHome([]);
238
+ const paths = fakePaths();
239
+ let runs = 0;
240
+ const task: MaintenanceTask = {
241
+ name: "probe-exactly-once",
242
+ intervalMs: RECONCILE_INTERVAL_DEFAULT_MS,
243
+ run: () => {
244
+ runs++;
245
+ },
246
+ };
247
+ const running = await startPackedDaemon({ paths, reg: registry, inst: installer, piHome, maintenanceTasks: [task] });
248
+ try {
249
+ await new Promise((resolve) => setTimeout(resolve, 30));
250
+ expect(runs).toBe(1);
251
+ } finally {
252
+ await running.stop();
253
+ }
254
+ });
255
+
229
256
  it("does not reconcile Armada before the daemon has published its readiness handle", async () => {
230
257
  const piHome = fakePiHome(["npm:@danypops/pi-packed", "npm:@danypops/probe"]);
231
258
  const paths = fakePaths();
@@ -316,6 +343,46 @@ describe("startPackedDaemon's own maintenance-task wiring (self-heals Vehicle dr
316
343
  });
317
344
  });
318
345
 
346
+ /**
347
+ * Real gap this closes: install()'s own ServiceInstallResult reports `installed: true`
348
+ * whenever register()/reconcile() SUCCEEDS -- which is true on every healthy pass whether or
349
+ * not anything actually changed underneath (register() is a safe no-op against unchanged
350
+ * desired state). A caller logging "applied changes" off `installed` alone would say so on
351
+ * literally every tick of a healthy, unchanged fleet -- see pkg-update-never-restarts-
352
+ * vehicle-daemon. versionChanged is the honest signal instead.
353
+ */
354
+ describe("reconcileAllDaemonServices reports versionChanged honestly, not just installed", () => {
355
+ it("is true on a package's first-ever registration, false on an unchanged re-sweep, true again once its on-disk version actually moves", async () => {
356
+ const piHome = fakePiHome(["npm:@danypops/probe"]);
357
+ installMockDaemon(piHome, "@danypops/probe");
358
+ const harness = await createArmadaTestHarness();
359
+ try {
360
+ const installer = new RealDaemonServiceInstaller(harness.registrar);
361
+
362
+ const first = await reconcileAllDaemonServices(piHome, undefined, installer);
363
+ expect(first.reconciled).toEqual([
364
+ { packageName: "@danypops/probe", vehicleName: "probe", installed: true, versionChanged: true },
365
+ ]);
366
+
367
+ const second = await reconcileAllDaemonServices(piHome, undefined, installer);
368
+ expect(second.reconciled).toEqual([
369
+ { packageName: "@danypops/probe", vehicleName: "probe", installed: true, versionChanged: false },
370
+ ]);
371
+
372
+ writeFileSync(
373
+ join(piHome, "npm", "node_modules", "@danypops", "probe", "package.json"),
374
+ JSON.stringify({ name: "@danypops/probe", version: "1.1.0", packed: { daemonService: { binPath: "cli.ts", args: ["serve"] } } }),
375
+ );
376
+ const third = await reconcileAllDaemonServices(piHome, undefined, installer);
377
+ expect(third.reconciled).toEqual([
378
+ { packageName: "@danypops/probe", vehicleName: "probe", installed: true, versionChanged: true },
379
+ ]);
380
+ } finally {
381
+ await harness.dispose();
382
+ }
383
+ });
384
+ });
385
+
319
386
  describe("reconcileAllDaemonServices pruning (the-armada-registrar-jittor-web-spider-daemon-collision)", () => {
320
387
  function stalePiHomeVehicle(piHome: string, name: string): VehicleSpec {
321
388
  return {