@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.48.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.18.1",
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 { DAEMON_DIR_ENV, DAEMON_HANDLE_FILE, DAEMON_HOST, DAEMON_PORT_FILE, DAEMON_TOKEN_FILE } from "../constants.ts";
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 });
@@ -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 server
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
  }