@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 +1 -1
- package/service/src/adoption/service-doctor.ts +20 -10
- package/service/src/daemon/daemon-service.ts +18 -0
- package/service/src/daemon/daemon.ts +22 -4
- package/service/test/daemon-maintenance.test.ts +20 -7
- package/service/test/daemon-service.test.ts +12 -0
- package/service/test/service-doctor.test.ts +23 -0
package/package.json
CHANGED
|
@@ -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))
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
...
|
|
130
|
-
onListen: ({ 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("
|
|
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
|
-
//
|
|
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([
|
|
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");
|