@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.
|
|
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.
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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");
|