@danypops/pi-packed 0.27.2 → 0.27.4

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.2",
3
+ "version": "0.27.4",
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",
@@ -161,6 +161,39 @@ function resolveDependencyCandidate(depDir: string, depName: string): ResolvedDa
161
161
  return undefined;
162
162
  }
163
163
 
164
+ /** Strips a leading "pi-" from an unscoped package name, e.g. "pi-papyrus" -> "papyrus", "pipes" ->
165
+ * "pipes" unchanged. Mirrors the real, universal naming convention every pi-* extension in this
166
+ * house follows for its own namesake daemon package (pi-lector/lector, pi-jittor/jittor,
167
+ * pi-tickets/tickets, pi-pipes/pipes, pi-web-spider/web-spider, pi-papyrus/papyrus). */
168
+ function namesakeDaemonName(unscopedPackageName: string): string | undefined {
169
+ return unscopedPackageName.startsWith("pi-") ? unscopedPackageName.slice("pi-".length) : undefined;
170
+ }
171
+
172
+ /** Resolves every dependency-name candidate for one dependency, across both its nested and
173
+ * hoisted layout (see resolveDependencyCandidate) -- factored out so detectVehicleDaemonService
174
+ * can collect candidates across EVERY dependency name before picking one, instead of returning
175
+ * on the first name that resolves at all. */
176
+ function resolveDependencyNameCandidate(
177
+ packageDir: string,
178
+ depName: string,
179
+ hoistedNodeModulesDir: string | undefined,
180
+ ): ResolvedDaemonEntrypoint | undefined {
181
+ const candidateDirs = [join(packageDir, "node_modules", depName), ...(hoistedNodeModulesDir ? [join(hoistedNodeModulesDir, depName)] : [])];
182
+ const resolved = candidateDirs
183
+ .map((depDir) => resolveDependencyCandidate(depDir, depName))
184
+ .filter((entry): entry is ResolvedDaemonEntrypoint => entry !== undefined);
185
+ if (resolved.length === 0) return undefined;
186
+ if (resolved.length === 1) return resolved[0];
187
+ // Nested and hoisted both resolved for the same dependency (the real jittor incident:
188
+ // a stale nested copy always won just because it's checked first). Prefer the newer
189
+ // version; a genuine tie keeps the nested result, preserving papyrus's intentional case.
190
+ let best = resolved[0]!;
191
+ for (const candidate of resolved.slice(1)) {
192
+ if (versionAtLeast(candidate.version, best.version) && candidate.version !== best.version) best = candidate;
193
+ }
194
+ return best;
195
+ }
196
+
164
197
  export function detectVehicleDaemonService(
165
198
  packageDir: string,
166
199
  fallbackName: string,
@@ -174,26 +207,24 @@ export function detectVehicleDaemonService(
174
207
  return { binPath: join(packageDir, ownBin), args: ["serve"], name: unscopedName(own.name ?? fallbackName), version: own.version };
175
208
  }
176
209
 
210
+ // A pi-X package can carry OTHER Vehicle-shaped dependencies for unrelated reasons (e.g.
211
+ // pi-papyrus depends on @danypops/jittor only for Context Hub types) -- confirmed live: a
212
+ // naive "first dependency name that resolves" scan returned jittor's spec for pi-papyrus
213
+ // (dependencies happened to list "@danypops/jittor" before "@danypops/papyrus"), silently
214
+ // mis-registering pi-papyrus's own Armada vehicle as another package's daemon entirely. Collect
215
+ // every resolvable candidate across ALL dependency names first, then prefer the one whose own
216
+ // resolved vehicle name matches this package's namesake convention (pi-X -> X) before falling
217
+ // back to the first-found candidate for a package that doesn't follow that convention.
218
+ const byName = unscopedName(own.name ?? fallbackName);
219
+ const wantedNamesake = namesakeDaemonName(byName);
220
+ const candidates: ResolvedDaemonEntrypoint[] = [];
177
221
  for (const depName of Object.keys(own.dependencies ?? {})) {
178
- const candidateDirs = [
179
- join(packageDir, "node_modules", depName),
180
- ...(hoistedNodeModulesDir ? [join(hoistedNodeModulesDir, depName)] : []),
181
- ];
182
- const resolved = candidateDirs
183
- .map((depDir) => resolveDependencyCandidate(depDir, depName))
184
- .filter((entry): entry is ResolvedDaemonEntrypoint => entry !== undefined);
185
- if (resolved.length === 0) continue;
186
- if (resolved.length === 1) return resolved[0];
187
- // Nested and hoisted both resolved for the same dependency (the real jittor incident:
188
- // a stale nested copy always won just because it's checked first). Prefer the newer
189
- // version; a genuine tie keeps the nested result, preserving papyrus's intentional case.
190
- let best = resolved[0]!;
191
- for (const candidate of resolved.slice(1)) {
192
- if (versionAtLeast(candidate.version, best.version) && candidate.version !== best.version) best = candidate;
193
- }
194
- return best;
222
+ const resolved = resolveDependencyNameCandidate(packageDir, depName, hoistedNodeModulesDir);
223
+ if (!resolved) continue;
224
+ if (wantedNamesake !== undefined && resolved.name === wantedNamesake) return resolved;
225
+ candidates.push(resolved);
195
226
  }
196
- return undefined;
227
+ return candidates[0];
197
228
  }
198
229
 
199
230
  /**
@@ -127,6 +127,19 @@ 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
+ if (result.reconciled.some((entry) => entry.installed) || result.pruned.length > 0) {
138
+ logger.info("vehicle-reconcile applied changes", {
139
+ reconciled: result.reconciled.filter((entry) => entry.installed).map((entry) => entry.vehicleName),
140
+ pruned: result.pruned.map((entry) => entry.vehicleName),
141
+ });
142
+ }
130
143
  if (result.failed.length > 0) {
131
144
  logger.warn("vehicle-reconcile completed with failures", {
132
145
  reconciled: result.reconciled.length,
@@ -171,28 +184,23 @@ export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOpt
171
184
  };
172
185
  }
173
186
 
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
-
187
+ // startDaemon() itself now runs every maintenance task once immediately at startup (in
188
+ // addition to its own interval) -- see @danypops/vehicle-server's own daemon.ts. This used to
189
+ // be a bespoke wrapper here (runInitialMaintenance(), called after startDaemon() returned, or
190
+ // from serveMain()'s onListen) working around a bare setInterval() never firing until its full
191
+ // interval first elapsed; kept here would now double-run every task on every startup. Every
192
+ // OTHER vehicle-server-based daemon (lector, jittor, papyrus, pipes, tickets,
193
+ // web-spider-daemon) never had this workaround at all and now gets the same fix for free from
194
+ // the shared implementation, instead of needing to independently reinvent it.
182
195
  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;
196
+ return startDaemon(daemonOptions(options));
187
197
  }
188
198
 
189
199
  export function serveMain(): void {
190
- const configured = daemonOptions({});
191
200
  runDaemonProcess({
192
- ...configured,
201
+ ...daemonOptions({}),
193
202
  onListen: ({ host, port }) => {
194
203
  logger.info("listening", { host, port });
195
- runInitialMaintenance(configured.maintenanceTasks);
196
204
  },
197
205
  });
198
206
  }
@@ -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
 
@@ -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();
@@ -189,6 +189,38 @@ describe("resolveDaemonServiceSpec", () => {
189
189
  expect(result.spec.name).toBe("papyrus");
190
190
  });
191
191
 
192
+ it("prefers the dependency matching its own pi-X -> X namesake convention over an unrelated Vehicle-shaped dependency listed first -- the real live incident: pi-papyrus's own dependency scan resolved to jittor (a Context Hub-types-only dependency) instead of papyrus, because 'jittor' sorted before 'papyrus' in the dependencies object", () => {
193
+ const piHome = fakePiHome();
194
+ const extDir = join(piHome, "npm", "node_modules", "@danypops/pi-papyrus");
195
+ // Real on-disk order: @danypops/jittor genuinely does sort before @danypops/papyrus.
196
+ writeRawPackage(extDir, {
197
+ name: "@danypops/pi-papyrus",
198
+ version: "1.0.0",
199
+ dependencies: { "@danypops/jittor": "^0.18.0", "@danypops/papyrus": "^0.54.0" },
200
+ });
201
+ const jittorDir = join(extDir, "node_modules", "@danypops/jittor");
202
+ writeRawPackage(jittorDir, {
203
+ name: "@danypops/jittor",
204
+ version: "0.18.0",
205
+ bin: { jittor: "src/cli.ts" },
206
+ dependencies: { "@danypops/vehicle-server": "^0.24.0" },
207
+ });
208
+ const papyrusDir = join(extDir, "node_modules", "@danypops/papyrus");
209
+ writeRawPackage(papyrusDir, {
210
+ name: "@danypops/papyrus",
211
+ version: "0.54.0",
212
+ bin: { papyrus: "src/cli.ts" },
213
+ dependencies: { "@danypops/vehicle-server": "^0.24.0" },
214
+ packed: { daemonService: { binPath: "src/cli.ts", args: ["serve"], handleFilename: "vehicle-handle.json" } },
215
+ });
216
+
217
+ const result = resolveDaemonServiceSpec(piHome, "npm:@danypops/pi-papyrus");
218
+ expect(result.ok).toBe(true);
219
+ if (!result.ok) throw new Error("expected ok");
220
+ expect(result.spec.name).toBe("papyrus");
221
+ expect(result.spec.binPath).toBe(join(papyrusDir, "src/cli.ts"));
222
+ });
223
+
192
224
  it("honors a nested dependency's own explicit packed.daemonService manifest, not just bin+dependency convention -- confirmed live: papyrus's real handle file is 'vehicle-handle.json', not the 'daemon.json' convention guess, and papyrus is only ever discovered this way (one level into pi-papyrus's own dependencies), never installed directly by name", () => {
193
225
  const piHome = fakePiHome();
194
226
  const extDir = join(piHome, "npm", "node_modules", "@danypops/pi-papyrus");