@danypops/papyrus 0.48.0 → 0.49.1

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.1",
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/ops.ts CHANGED
@@ -599,22 +599,34 @@ function readBoundedGateFile(path: string): string {
599
599
  return readFileSync(path, "utf-8") as string;
600
600
  }
601
601
 
602
- /** Shared by the sync and async process-gate runners so "test" is never a second, independently
603
- * maintained copy of "command"'s own command-template/timeout selection. */
602
+ /**
603
+ * Shared by the sync and async process-gate runners so "test" is never a second, independently
604
+ * maintained copy of "command"'s own command-template selection.
605
+ *
606
+ * "test" runs `gate.target` verbatim, exactly like "command" -- the only real difference is a
607
+ * more generous default timeout (GATE_TEST_TIMEOUT_MS vs GATE_COMMAND_TIMEOUT_MS), since a test
608
+ * suite routinely runs longer than an arbitrary command. It previously wrapped target in
609
+ * `npx vitest run ${target} --reporter=dot`, silently wrong for every real consumer in this
610
+ * ecosystem (all Bun-native, none use vitest): a target that was itself a full command (e.g.
611
+ * `bun test path/to.test.ts`, exactly what every existing gate/checklist example here has always
612
+ * shown) got parsed by vitest as three separate positional args, triggering vitest's own broad
613
+ * discovery across the whole repo instead of running the intended command at all -- a real
614
+ * incident (task ab1463e2) that produced an unrelated multi-suite vitest failure cascade instead
615
+ * of the actual target ever running.
616
+ */
604
617
  function processGateCommand(gate: Gate): { command: string; timeout: number } {
605
- if (gate.type === "test")
606
- return { command: `npx vitest run ${gate.target} --reporter=dot`, timeout: gate.timeoutMs ?? GATE_TEST_TIMEOUT_MS };
618
+ if (gate.type === "test") return { command: gate.target, timeout: gate.timeoutMs ?? GATE_TEST_TIMEOUT_MS };
607
619
  return { command: gate.target, timeout: gate.timeoutMs ?? GATE_COMMAND_TIMEOUT_MS };
608
620
  }
609
621
 
610
622
  /**
611
623
  * spawnSync + manual stdout/stderr concatenation, not execSync: execSync's return value is stdout
612
- * only. Many real commands (bun test's own per-test lines and its pass/fail summary among them, and
613
- * vitest's own "test" gate output) write their actual output to stderr, so an execSync-based match
614
- * against gate.expect saw only the first line of a banner and never the result -- every such gate
615
- * failed regardless of whether the command actually passed. This one function now serves both
616
- * "command" and "test" gates; previously "test" was a second, separately-maintained execSync path
617
- * that never checked gate.expect at all.
624
+ * only. Many real commands (bun test's own per-test lines and its pass/fail summary among them)
625
+ * write their actual output to stderr, so an execSync-based match against gate.expect saw only the
626
+ * first line of a banner and never the result -- every such gate failed regardless of whether the
627
+ * command actually passed. This one function now serves both "command" and "test" gates;
628
+ * previously "test" was a second, separately-maintained execSync path that never checked
629
+ * gate.expect at all.
618
630
  */
619
631
  function runProcessGateSync(gate: Gate, cwd?: string): GateResult {
620
632
  const { spawnSync } = require_("node:child_process");
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
  }