@danypops/papyrus 0.48.0 → 0.49.0
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 +2 -2
- package/src/cli/daemon-command.ts +48 -0
- package/src/cli.ts +8 -2
- package/src/client.ts +6 -0
- package/src/constants.ts +1 -0
- package/src/daemon/daemon-state.ts +13 -1
- package/src/daemon/daemon.ts +25 -6
- package/src/service.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@danypops/vehicle-client": "^0.6.1",
|
|
37
37
|
"@danypops/vehicle-core": "^0.12.3",
|
|
38
|
-
"@danypops/vehicle-server": "^0.
|
|
38
|
+
"@danypops/vehicle-server": "^0.19.0",
|
|
39
39
|
"@stricli/core": "^1.3.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { DaemonDiagnosis } from "@danypops/vehicle-server/daemon-lifecycle";
|
|
2
|
+
import type { CommandContext } from "@stricli/core";
|
|
3
|
+
import { buildApplication, buildCommand, buildRouteMap } from "@stricli/core";
|
|
4
|
+
import { runStricliToString } from "./stricli-run.ts";
|
|
5
|
+
|
|
6
|
+
export interface DaemonDiagnoseClient {
|
|
7
|
+
diagnose(): Promise<DaemonDiagnosis>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface DaemonContext extends CommandContext {
|
|
11
|
+
readonly client: DaemonDiagnoseClient;
|
|
12
|
+
readonly json: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function renderHistoryLine(event: DaemonDiagnosis["history"][number]): string {
|
|
16
|
+
const reason = event.reason ? ` (${event.reason})` : "";
|
|
17
|
+
return `${event.at} ${event.type}${reason} pid=${event.pid} instance=${event.instanceId}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const diagnoseCommand = buildCommand({
|
|
21
|
+
func: async function (this: DaemonContext) {
|
|
22
|
+
const diagnosis = await this.client.diagnose();
|
|
23
|
+
if (this.json) {
|
|
24
|
+
this.process.stdout.write(JSON.stringify(diagnosis));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const lines = [
|
|
28
|
+
`instance ${diagnosis.instanceId} (pid ${diagnosis.pid}, ${diagnosis.provenance}), started ${diagnosis.startedAt}`,
|
|
29
|
+
"",
|
|
30
|
+
"recent history:",
|
|
31
|
+
...(diagnosis.history.length === 0 ? [" (none)"] : diagnosis.history.map((event) => ` ${renderHistoryLine(event)}`)),
|
|
32
|
+
];
|
|
33
|
+
this.process.stdout.write(lines.join("\n"));
|
|
34
|
+
},
|
|
35
|
+
parameters: { flags: {} },
|
|
36
|
+
docs: { brief: "Show this daemon's identity and recent start/stop/already_running history" },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const app = buildApplication(buildRouteMap({ routes: { diagnose: diagnoseCommand }, docs: { brief: "Daemon operations" } }), {
|
|
40
|
+
name: "daemon",
|
|
41
|
+
scanner: { caseStyle: "allow-kebab-for-camel" },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export async function runDaemonCli(args: string[], client: DaemonDiagnoseClient): Promise<string> {
|
|
45
|
+
const json = args.includes("--json");
|
|
46
|
+
const positional = args.filter((arg) => arg !== "--json");
|
|
47
|
+
return runStricliToString(app, positional, { client, json });
|
|
48
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { copyFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFi
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createNodeServiceInstallDeps, generateSystemdUnit, installUserService, type ServiceSpec } from "@danypops/vehicle-server/service";
|
|
6
6
|
import { runArtifactCli } from "./cli/artifact-command.ts";
|
|
7
|
+
import { runDaemonCli } from "./cli/daemon-command.ts";
|
|
7
8
|
import { runDiscussCli } from "./cli/discuss-command.ts";
|
|
8
9
|
import { runDocsCli } from "./cli/docs-command.ts";
|
|
9
10
|
import { runGatesCli } from "./cli/gates-command.ts";
|
|
@@ -206,7 +207,7 @@ function usage(): never {
|
|
|
206
207
|
process.exit(2);
|
|
207
208
|
}
|
|
208
209
|
|
|
209
|
-
export { runMigrationCli };
|
|
210
|
+
export { runDaemonCli, runMigrationCli };
|
|
210
211
|
|
|
211
212
|
function readIdMap(sidecarPath: string): IdMigrationPlan {
|
|
212
213
|
const raw = JSON.parse(readFileSync(sidecarPath, "utf8")) as { idMap: Record<string, string> };
|
|
@@ -366,7 +367,7 @@ export {
|
|
|
366
367
|
export async function main(args: string[] = process.argv.slice(2)): Promise<void> {
|
|
367
368
|
const [command, action] = args;
|
|
368
369
|
if (command === "serve") {
|
|
369
|
-
serveMain();
|
|
370
|
+
await serveMain();
|
|
370
371
|
return;
|
|
371
372
|
}
|
|
372
373
|
if (command === "tasks") {
|
|
@@ -404,6 +405,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
|
|
|
404
405
|
console.log(await runMigrationCli(args.slice(1), client));
|
|
405
406
|
return;
|
|
406
407
|
}
|
|
408
|
+
if (command === "daemon") {
|
|
409
|
+
const client = await connectPapyrusClient();
|
|
410
|
+
console.log(await runDaemonCli(args.slice(1), client));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
407
413
|
if (command === "migrate-ids") {
|
|
408
414
|
console.log(runIdMigrationCli(args.slice(1)));
|
|
409
415
|
return;
|
package/src/client.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
spawnDetachedDaemon,
|
|
8
8
|
} from "@danypops/vehicle-client/daemon-client";
|
|
9
9
|
import { createLiveVersionExpectation } from "@danypops/vehicle-client/version";
|
|
10
|
+
import type { DaemonDiagnosis } from "@danypops/vehicle-server/daemon-lifecycle";
|
|
10
11
|
import { DAEMON_CLIENT_TIMEOUT_MS, DAEMON_DIR_ENV, DAEMON_PROBE_TIMEOUT_MS } from "./constants.ts";
|
|
11
12
|
import { type DaemonHandle, daemonStateDir, readDaemonHandle } from "./daemon/daemon-state.ts";
|
|
12
13
|
import type { OperationName, SchemaState } from "./service.ts";
|
|
@@ -50,6 +51,11 @@ export class PapyrusClient {
|
|
|
50
51
|
return this.request("/health");
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/** Backed by GET /daemon/diagnose -- see service.ts's createApp and vehicle-server's daemon-lifecycle.ts. */
|
|
55
|
+
diagnose(): Promise<DaemonDiagnosis> {
|
|
56
|
+
return this.request("/daemon/diagnose");
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
async operations(): Promise<OperationName[]> {
|
|
54
60
|
const body = await this.request<{ operations: OperationName[] }>("/api/v1/ops");
|
|
55
61
|
return body.operations;
|
package/src/constants.ts
CHANGED
|
@@ -4,6 +4,7 @@ export const DAEMON_PORT_FILE = "port";
|
|
|
4
4
|
export const DAEMON_TOKEN_FILE = "token";
|
|
5
5
|
/** vehicle-server's own {host,port,pid} handle format -- read by Armada's readiness probe once Papyrus is service-installed, see cli.ts's papyrusServiceSpec. */
|
|
6
6
|
export const DAEMON_HANDLE_FILE = "vehicle-handle.json";
|
|
7
|
+
export const DAEMON_LIFECYCLE_FILE = "lifecycle.json";
|
|
7
8
|
export const DAEMON_CLIENT_TIMEOUT_MS = 15_000;
|
|
8
9
|
export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
9
10
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
@@ -3,7 +3,14 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { LOOPBACK_HOST, removeDaemonHandle, writeDaemonHandle } from "@danypops/vehicle-server/paths";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
DAEMON_DIR_ENV,
|
|
8
|
+
DAEMON_HANDLE_FILE,
|
|
9
|
+
DAEMON_HOST,
|
|
10
|
+
DAEMON_LIFECYCLE_FILE,
|
|
11
|
+
DAEMON_PORT_FILE,
|
|
12
|
+
DAEMON_TOKEN_FILE,
|
|
13
|
+
} from "../constants.ts";
|
|
7
14
|
|
|
8
15
|
export interface DaemonHandle {
|
|
9
16
|
baseUrl: string;
|
|
@@ -48,6 +55,11 @@ export function vehicleHandlePath(dir: string): string {
|
|
|
48
55
|
return join(dir, DAEMON_HANDLE_FILE);
|
|
49
56
|
}
|
|
50
57
|
|
|
58
|
+
/** Where the structured daemon lifecycle event log (@danypops/vehicle-server's daemon-lifecycle.ts) persists start/stop/already_running history across restarts -- see daemon.ts's diagnose wiring. */
|
|
59
|
+
export function lifecyclePath(dir: string): string {
|
|
60
|
+
return join(dir, DAEMON_LIFECYCLE_FILE);
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
/** vehicle-server's own {host,port,pid} handle format, distinct from this file's port/token pair -- Armada's readiness probe (createHandleReadinessProbe) reads exactly this shape. */
|
|
52
64
|
export function writeVehicleHandle(dir: string, port: number, pid: number = process.pid): void {
|
|
53
65
|
writeDaemonHandle(vehicleHandlePath(dir), { host: LOOPBACK_HOST, port, pid });
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { join } from "node:path";
|
|
3
|
+
import { createNodeAtomicJsonFsAdapter } from "@danypops/vehicle-server/atomic-json";
|
|
4
|
+
import { readLaunchProvenance } from "@danypops/vehicle-server/daemon";
|
|
5
|
+
import { diagnoseDaemon, openDaemonLifecycleLog } from "@danypops/vehicle-server/daemon-lifecycle";
|
|
2
6
|
import { acquireDaemonLock, releaseDaemonLock } from "@danypops/vehicle-server/paths";
|
|
3
7
|
import { PushChannel } from "@danypops/vehicle-server/push-channel";
|
|
4
8
|
import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, dbPath, WAL_CHECKPOINT_INTERVAL_MS } from "../constants.ts";
|
|
@@ -8,6 +12,7 @@ import {
|
|
|
8
12
|
clearDaemonPort,
|
|
9
13
|
clearVehicleHandle,
|
|
10
14
|
daemonStateDir,
|
|
15
|
+
lifecyclePath,
|
|
11
16
|
loadOrCreateToken,
|
|
12
17
|
writeDaemonPort,
|
|
13
18
|
writeVehicleHandle,
|
|
@@ -35,14 +40,26 @@ const TASK_READ_ONLY_OPERATIONS = new Set([
|
|
|
35
40
|
]);
|
|
36
41
|
|
|
37
42
|
/** Start the supervised, long-running Papyrus service. */
|
|
38
|
-
export function serveMain(): void {
|
|
43
|
+
export async function serveMain(): Promise<void> {
|
|
39
44
|
const stateDir = daemonStateDir();
|
|
40
45
|
const lockPath = join(stateDir, "daemon.lock");
|
|
46
|
+
const instanceId = randomUUID();
|
|
47
|
+
const provenance = readLaunchProvenance();
|
|
48
|
+
const lifecycleLog = openDaemonLifecycleLog({ path: lifecyclePath(stateDir), fs: createNodeAtomicJsonFsAdapter() });
|
|
49
|
+
const recordLifecycle = async (type: "started" | "already_running" | "stopped", reason?: string): Promise<void> => {
|
|
50
|
+
try {
|
|
51
|
+
await lifecycleLog.record({ instanceId, pid: process.pid, type, provenance, reason });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
logEvent("error", "lifecycle_log_record_failed", { message: error instanceof Error ? error.message : String(error) });
|
|
54
|
+
}
|
|
55
|
+
};
|
|
41
56
|
const lock = acquireDaemonLock(lockPath);
|
|
42
57
|
if (!lock.acquired) {
|
|
43
58
|
logEvent("info", "already_running", { holderPid: lock.holderPid });
|
|
59
|
+
await recordLifecycle("already_running", lock.holderPid === null ? undefined : `holder pid ${lock.holderPid}`);
|
|
44
60
|
return;
|
|
45
61
|
}
|
|
62
|
+
const startedAt = new Date().toISOString();
|
|
46
63
|
const token = loadOrCreateToken(stateDir);
|
|
47
64
|
const service = createPapyrusService(dbPath());
|
|
48
65
|
const pushChannel = new PushChannel({ token });
|
|
@@ -55,6 +72,7 @@ export function serveMain(): void {
|
|
|
55
72
|
}
|
|
56
73
|
},
|
|
57
74
|
logger: vehicleLogger(),
|
|
75
|
+
diagnose: () => diagnoseDaemon({ lifecycleLog, current: { instanceId, pid: process.pid, startedAt, provenance } }),
|
|
58
76
|
});
|
|
59
77
|
const server = Bun.serve({
|
|
60
78
|
hostname: DAEMON_HOST,
|
|
@@ -110,7 +128,7 @@ export function serveMain(): void {
|
|
|
110
128
|
}
|
|
111
129
|
}, DB_OPTIMIZE_INTERVAL_MS);
|
|
112
130
|
let stopping = false;
|
|
113
|
-
const shutdown = () => {
|
|
131
|
+
const shutdown = (signal: string) => {
|
|
114
132
|
if (stopping) return;
|
|
115
133
|
stopping = true;
|
|
116
134
|
clearInterval(checkpointTimer);
|
|
@@ -123,12 +141,13 @@ export function serveMain(): void {
|
|
|
123
141
|
service.close();
|
|
124
142
|
// .finally() re-throws rather than handling a rejection -- catching it first turns a bare
|
|
125
143
|
// unhandled-rejection warning into a real, queryable shutdown-failure log line.
|
|
126
|
-
void
|
|
127
|
-
.stop(true)
|
|
144
|
+
void recordLifecycle("stopped", signal)
|
|
145
|
+
.then(() => server.stop(true))
|
|
128
146
|
.catch((error) => logEvent("error", "server_stop_failed", { message: error instanceof Error ? error.message : String(error) }))
|
|
129
147
|
.finally(() => process.exit(0));
|
|
130
148
|
};
|
|
131
|
-
process.on("SIGINT", shutdown);
|
|
132
|
-
process.on("SIGTERM", shutdown);
|
|
149
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
150
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
133
151
|
logEvent("info", "listening", { host: DAEMON_HOST, port: server.port });
|
|
152
|
+
await recordLifecycle("started");
|
|
134
153
|
}
|
package/src/service.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { VehicleError } from "@danypops/vehicle-core";
|
|
2
2
|
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
3
|
+
import type { DaemonDiagnosis } from "@danypops/vehicle-server/daemon-lifecycle";
|
|
3
4
|
import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
|
|
4
5
|
import type { Logger } from "@danypops/vehicle-server/logging";
|
|
5
6
|
import type { CreateArtifactInput } from "./artifact/artifact.ts";
|
|
@@ -577,6 +578,14 @@ export function createApp(deps: {
|
|
|
577
578
|
onOperationExecuted?: (operation: string, input: OperationInput) => void;
|
|
578
579
|
/** Defaults to a no-op (createVehicleHttpApp's own default) -- daemon.ts wires vehicleLogger() so a failed invocation is actually logged, not silently discarded. */
|
|
579
580
|
logger?: Logger;
|
|
581
|
+
/**
|
|
582
|
+
* Backs GET /daemon/diagnose -- "who am I, and what happened recently" (see
|
|
583
|
+
* @danypops/vehicle-server's daemon-lifecycle.ts), without a caller reading Papyrus's own
|
|
584
|
+
* SQLite database or state files directly. Omitted (e.g. in most tests, which don't run a
|
|
585
|
+
* real supervised daemon process) means the route 404s, matching how /health always exists
|
|
586
|
+
* but this diagnostic identity does not until a real serveMain() supplies it.
|
|
587
|
+
*/
|
|
588
|
+
diagnose?: () => Promise<DaemonDiagnosis>;
|
|
580
589
|
}): { fetch(request: Request): Promise<Response> } {
|
|
581
590
|
// Same Bearer token, daemon, and port as the rest of this API -- see ./handlers/registry.ts.
|
|
582
591
|
const vehicleApp = createVehicleHttpApp({ registry: deps.service.vehicle, token: deps.token, logger: deps.logger });
|
|
@@ -606,6 +615,10 @@ export function createApp(deps: {
|
|
|
606
615
|
if (request.method === "GET" && url.pathname === "/health") {
|
|
607
616
|
return json({ ok: true, version: VERSION, schema: deps.service.schemaState() });
|
|
608
617
|
}
|
|
618
|
+
if (request.method === "GET" && url.pathname === "/daemon/diagnose") {
|
|
619
|
+
if (!deps.diagnose) return json({ error: "daemon diagnose is unavailable on this instance" }, { status: 404 });
|
|
620
|
+
return json(await deps.diagnose());
|
|
621
|
+
}
|
|
609
622
|
if (request.method === "GET" && url.pathname === "/api/v1/ops") {
|
|
610
623
|
return json({ operations: deps.service.operationNames() });
|
|
611
624
|
}
|