@danypops/pi-packed 0.21.1 → 0.21.2

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.21.1",
3
+ "version": "0.21.2",
4
4
  "description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,7 +3,7 @@ import { readInstalledPackagesAcrossScopes } from "../packages/installed.ts";
3
3
  import { resolveDaemonServiceSpec } from "../daemon/daemon-service.ts";
4
4
 
5
5
  export interface ServiceUnitDiagnostic {
6
- code: "SERVICE_EXEC_PATH_MISSING" | "SERVICE_STATUS_UNAVAILABLE";
6
+ code: "SERVICE_EXEC_PATH_MISSING" | "SERVICE_NOT_RUNNING" | "SERVICE_STATUS_UNAVAILABLE";
7
7
  severity: "error" | "warning";
8
8
  package: string;
9
9
  unitName: string;
@@ -21,7 +21,7 @@ export interface ServiceDoctorDeps extends ServiceInstallDeps {
21
21
  }
22
22
 
23
23
  interface ArmadaStatus {
24
- vehicles?: Array<{ name?: string; executable?: string }>;
24
+ vehicles?: Array<{ name?: string; executable?: string; nativeStatus?: string; ready?: boolean }>;
25
25
  }
26
26
 
27
27
  export function checkServiceUnitPaths(
@@ -79,14 +79,24 @@ export function checkServiceUnitPaths(
79
79
  if (!vehicle) continue;
80
80
  checked++;
81
81
  const executable = vehicle.executable ?? managedPackage.spec.binPath;
82
- if (deps.fileExists(executable)) continue;
83
- diagnostics.push({
84
- code: "SERVICE_EXEC_PATH_MISSING",
85
- severity: "error",
86
- package: managedPackage.packageName,
87
- unitName: managedPackage.spec.name,
88
- message: `${managedPackage.spec.name}'s Armada declaration references a path that no longer exists: ${executable}`,
89
- });
82
+ if (!deps.fileExists(executable)) {
83
+ diagnostics.push({
84
+ code: "SERVICE_EXEC_PATH_MISSING",
85
+ severity: "error",
86
+ package: managedPackage.packageName,
87
+ unitName: managedPackage.spec.name,
88
+ message: `${managedPackage.spec.name}'s Armada declaration references a path that no longer exists: ${executable}`,
89
+ });
90
+ }
91
+ if (vehicle.nativeStatus !== undefined && vehicle.nativeStatus !== "running") {
92
+ diagnostics.push({
93
+ code: "SERVICE_NOT_RUNNING",
94
+ severity: "error",
95
+ package: managedPackage.packageName,
96
+ unitName: managedPackage.spec.name,
97
+ message: `${managedPackage.spec.name}'s Armada service is ${vehicle.nativeStatus}; start it through Armada before connecting`,
98
+ });
99
+ }
90
100
  }
91
101
  return { ok: diagnostics.length === 0, diagnostics, checked };
92
102
  }
@@ -226,6 +226,9 @@ export interface ReconcileAllResult {
226
226
 
227
227
  /** Matches readPackageDeclarations' own bound -- a reconcile-all sweep never processes an unbounded package list. */
228
228
  const MAX_RECONCILE_PACKAGES = 500;
229
+ const PACKED_PACKAGE_NAME = "@danypops/pi-packed";
230
+ const PACKED_VEHICLE_NAME = "pi-packed";
231
+ const SELF_REGISTRATION_REASON = "Packed cannot replace its own Armada service from inside the running daemon";
229
232
 
230
233
  /**
231
234
  * Sweeps every installed Packed package (global scope, plus a project's own
@@ -253,6 +256,12 @@ export async function reconcileAllDaemonServices(
253
256
  const seen = new Set<string>();
254
257
  let skipped = 0;
255
258
  for (const pkg of packages) {
259
+ // Re-registering the daemon from inside its own process makes Armada stop
260
+ // that process before registration can start the replacement.
261
+ if (pkg.name === PACKED_PACKAGE_NAME) {
262
+ skipped++;
263
+ continue;
264
+ }
256
265
  const resolved = await installer.install(piHome, `npm:${pkg.name}`);
257
266
  if (!resolved.ok) {
258
267
  if (resolved.notADaemon) {
@@ -305,6 +314,9 @@ export class RealDaemonServiceInstaller implements DaemonServiceInstaller {
305
314
  ): Promise<{ ok: true; result: ServiceInstallResult; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }> {
306
315
  const resolved = resolveDaemonServiceSpec(piHome, source);
307
316
  if (!resolved.ok) return resolved;
317
+ if (resolved.spec.name === PACKED_VEHICLE_NAME) {
318
+ return { ok: true, result: { installed: false, reason: SELF_REGISTRATION_REASON }, spec: resolved.spec };
319
+ }
308
320
  const result = await registerVehicleService(resolved.spec, this.registrar);
309
321
  return { ok: true, result, spec: resolved.spec };
310
322
  }
@@ -315,6 +327,9 @@ export class RealDaemonServiceInstaller implements DaemonServiceInstaller {
315
327
  ): Promise<{ ok: true; result: ServiceInstallResult; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }> {
316
328
  const resolved = resolveDaemonServiceSpec(piHome, source);
317
329
  if (!resolved.ok) return resolved;
330
+ if (resolved.spec.name === PACKED_VEHICLE_NAME) {
331
+ return { ok: true, result: { installed: false, reason: SELF_REGISTRATION_REASON }, spec: resolved.spec };
332
+ }
318
333
  const result = await unregisterVehicleService(resolved.spec.name, this.registrar);
319
334
  return { ok: true, result, spec: resolved.spec };
320
335
  }
@@ -325,6 +340,9 @@ export class RealDaemonServiceInstaller implements DaemonServiceInstaller {
325
340
  ): Promise<{ ok: true; restarted: boolean; reason?: string; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }> {
326
341
  const resolved = resolveDaemonServiceSpec(piHome, source);
327
342
  if (!resolved.ok) return resolved;
343
+ if (resolved.spec.name === PACKED_VEHICLE_NAME) {
344
+ return { ok: true, restarted: false, reason: SELF_REGISTRATION_REASON, spec: resolved.spec };
345
+ }
328
346
  if (!(await isVehicleServiceRegistered(resolved.spec.name, this.registrar))) {
329
347
  return { ok: true, restarted: false, reason: `no persistent service is registered for ${resolved.spec.name}`, spec: resolved.spec };
330
348
  }
@@ -102,6 +102,9 @@ export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOpt
102
102
 
103
103
  if (options.maintenanceTasks === undefined) {
104
104
  for (const task of maintenanceTasks) {
105
+ // Armada reconciles the whole desired fleet. Running this before Packed
106
+ // publishes its readiness handle makes Armada replace Packed mid-startup.
107
+ if (task.name === "vehicle-reconcile") continue;
105
108
  void Promise.resolve(task.run()).catch((error) =>
106
109
  logger.error(`maintenance task failed: ${task.name}`, { error: error instanceof Error ? error.message : String(error) }),
107
110
  );
@@ -120,13 +123,28 @@ export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOpt
120
123
  };
121
124
  }
122
125
 
123
- export function startPackedDaemon(options: StartPackedDaemonOptions = {}): Promise<RunningDaemon> {
124
- return startDaemon(daemonOptions(options));
126
+ function runReadyVehicleReconcile(maintenanceTasks: MaintenanceTask[] | undefined): void {
127
+ const task = maintenanceTasks?.find((candidate) => candidate.name === "vehicle-reconcile");
128
+ if (!task) return;
129
+ void Promise.resolve(task.run()).catch((error) =>
130
+ logger.error(`maintenance task failed: ${task.name}`, { error: error instanceof Error ? error.message : String(error) }),
131
+ );
132
+ }
133
+
134
+ export async function startPackedDaemon(options: StartPackedDaemonOptions = {}): Promise<RunningDaemon> {
135
+ const configured = daemonOptions(options);
136
+ const running = await startDaemon(configured);
137
+ runReadyVehicleReconcile(configured.maintenanceTasks);
138
+ return running;
125
139
  }
126
140
 
127
141
  export function serveMain(): void {
142
+ const configured = daemonOptions({});
128
143
  runDaemonProcess({
129
- ...daemonOptions({}),
130
- onListen: ({ host, port }) => logger.info("listening", { host, port }),
144
+ ...configured,
145
+ onListen: ({ host, port }) => {
146
+ logger.info("listening", { host, port });
147
+ runReadyVehicleReconcile(configured.maintenanceTasks);
148
+ },
131
149
  });
132
150
  }
@@ -1,9 +1,9 @@
1
1
  import { afterEach, describe, expect, it } from "bun:test";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import type { ServiceInstallResult, ServiceSpec } from "@danypops/vehicle-server/service";
6
- import { daemonOptions } from "../src/daemon/daemon.ts";
6
+ import { daemonOptions, startPackedDaemon } from "../src/daemon/daemon.ts";
7
7
  import type { Installer, Registry } from "../src/packages/package.ts";
8
8
  import { RECONCILE_INTERVAL_DEFAULT_MS } from "../src/shared/constants.ts";
9
9
  import { resolvePackedPaths } from "../src/shared/paths.ts";
@@ -111,17 +111,30 @@ describe("startPackedDaemon's own maintenance-task wiring (self-heals Vehicle dr
111
111
  expect(installerSpy.gotSources).toEqual(["npm:@danypops/probe", "npm:@danypops/other"]);
112
112
  });
113
113
 
114
- it("runs vehicle-reconcile once automatically at startup, not just on its own interval -- the actual self-heal", async () => {
115
- const piHome = fakePiHome(["npm:@danypops/probe"]);
114
+ it("does not reconcile Armada before the daemon has published its readiness handle", async () => {
115
+ const piHome = fakePiHome(["npm:@danypops/pi-packed", "npm:@danypops/probe"]);
116
116
  const paths = fakePaths();
117
117
  const installerSpy = new RecordingDaemonServiceInstaller();
118
118
  mkdirSync(join(piHome, "npm", "node_modules"), { recursive: true });
119
119
  daemonOptions({ paths, reg: registry, inst: installer, piHome, daemonServiceInstaller: installerSpy });
120
120
 
121
- // The startup fire-and-forget is intentionally not awaited by daemonOptions itself
122
- // (matching every other default maintenance task) -- give its microtasks a turn.
121
+ // Give pre-listen maintenance enough time to expose an accidental Armada call.
123
122
  await new Promise((resolveTick) => setTimeout(resolveTick, 10));
124
123
 
125
- expect(installerSpy.gotSources).toEqual(["npm:@danypops/probe"]);
124
+ expect(installerSpy.gotSources).toEqual([]);
125
+ });
126
+
127
+ it("reconciles Armada only after the real daemon handle is ready", async () => {
128
+ const piHome = fakePiHome(["npm:@danypops/pi-packed", "npm:@danypops/probe"]);
129
+ const paths = fakePaths();
130
+ const installerSpy = new RecordingDaemonServiceInstaller();
131
+ const running = await startPackedDaemon({ paths, reg: registry, inst: installer, piHome, daemonServiceInstaller: installerSpy });
132
+ try {
133
+ await new Promise((resolveTick) => setTimeout(resolveTick, 10));
134
+ expect(existsSync(paths.handle)).toBe(true);
135
+ expect(installerSpy.gotSources).toEqual(["npm:@danypops/probe"]);
136
+ } finally {
137
+ await running.stop();
138
+ }
126
139
  });
127
140
  });
@@ -229,6 +229,18 @@ describe("RealDaemonServiceInstaller -- Armada registration through the in-proce
229
229
  expect(registrar.registered.has("web-spider-daemon")).toBe(true);
230
230
  });
231
231
 
232
+ it("install() never asks Armada to replace Packed from inside Packed's own process", async () => {
233
+ const piHome = fakePiHome();
234
+ writePackage(piHome, "@danypops/pi-packed", { binPath: "service/src/cli/cli.ts", args: ["serve"] });
235
+ const registrar = new FakeVehicleRegistrar();
236
+ const installer = new RealDaemonServiceInstaller(registrar);
237
+
238
+ const outcome = await installer.install(piHome, "npm:@danypops/pi-packed");
239
+
240
+ expect(outcome).toMatchObject({ ok: true, result: { installed: false }, spec: { name: "pi-packed" } });
241
+ expect(registrar.registered.size).toBe(0);
242
+ });
243
+
232
244
  it("install() reports a non-daemon package without ever touching the registrar", async () => {
233
245
  const piHome = fakePiHome();
234
246
  writePackage(piHome, "some-pkg", undefined);
@@ -64,6 +64,29 @@ describe("checkServiceUnitPaths", () => {
64
64
  expect(report).toEqual({ ok: true, diagnostics: [], checked: 1 });
65
65
  });
66
66
 
67
+ it("reports an Armada-managed Vehicle that is declared but not running", () => {
68
+ const home = piHome(["npm:fakedaemon"]);
69
+ const dir = installDaemonPackage(home, "fakedaemon");
70
+ const report = checkServiceUnitPaths(
71
+ home,
72
+ undefined,
73
+ fakeDeps({ vehicles: [{ name: "fakedaemon", executable: join(dir, "cli.ts"), nativeStatus: "stopped", ready: false }], diagnostics: [] }),
74
+ );
75
+ expect(report).toEqual({
76
+ ok: false,
77
+ checked: 1,
78
+ diagnostics: [
79
+ {
80
+ code: "SERVICE_NOT_RUNNING",
81
+ severity: "error",
82
+ package: "fakedaemon",
83
+ unitName: "fakedaemon",
84
+ message: expect.stringContaining("stopped"),
85
+ },
86
+ ],
87
+ });
88
+ });
89
+
67
90
  it("reports a missing Armada-managed executable", () => {
68
91
  const home = piHome(["npm:fakedaemon"]);
69
92
  const dir = installDaemonPackage(home, "fakedaemon");