@danypops/papyrus 0.44.3 → 0.44.5

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.44.3",
3
+ "version": "0.44.5",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -34,8 +34,8 @@
34
34
  },
35
35
  "files": ["src", "README.md"],
36
36
  "dependencies": {
37
- "@danypops/vehicle-core": "^0.10.0",
38
- "@danypops/vehicle-client": "^0.6.0",
39
- "@danypops/vehicle-server": "^0.13.0"
37
+ "@danypops/vehicle-client": "^0.6.1",
38
+ "@danypops/vehicle-core": "^0.12.3",
39
+ "@danypops/vehicle-server": "^0.18.1"
40
40
  }
41
41
  }
package/src/cli.ts CHANGED
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env bun
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { copyFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
4
- import { homedir } from "node:os";
5
- import { join } from "node:path";
6
4
  import { fileURLToPath } from "node:url";
7
5
  import { createNodeServiceInstallDeps, generateSystemdUnit, installUserService, type ServiceSpec } from "@danypops/vehicle-server/service";
8
6
  import { connectPapyrusClient, type PapyrusClient } from "./client.ts";
9
7
  import { DAEMON_UNIT_NAME, dbPath, TASK_EXECUTION_MAX_NODES } from "./constants.ts";
10
8
  import { serveMain } from "./daemon.ts";
9
+ import { daemonStateDir, vehicleHandlePath } from "./daemon-state.ts";
11
10
  import { openDb } from "./db.ts";
12
11
  import type { GateResult } from "./domain/gate.ts";
13
12
  import { applyIdMigration, type IdMigrationPlan, mirrorDatabase, planIdMigration, verifyIdMigration } from "./id-migration.ts";
14
13
  import type { TaskExecutionPlan } from "./task-execution.ts";
15
14
  import type { TaskBlockage, TaskCompletion } from "./task-service.ts";
15
+ import { VERSION } from "./version.ts";
16
16
 
17
17
  export interface SystemdUnitOptions {
18
18
  bunBin: string;
@@ -26,20 +26,23 @@ export interface SystemdUnitOptions {
26
26
  *
27
27
  * Papyrus's own daemon.ts does not (yet) use vehicle-server's startDaemon/runDaemonProcess -- it's
28
28
  * a bespoke Bun.serve() with its own state-file layout (daemon-state.ts) and maintenance-timer
29
- * scheduling, predating that shared substrate. Only unit *generation* is migrated here; unitPath()
30
- * stays a manual XDG_CONFIG_HOME construction rather than pulling in resolveDaemonPaths() for a
31
- * database/token/handle layout Papyrus doesn't actually use. DAEMON_KIT_LAUNCH_PROVENANCE=service
32
- * is emitted (generateSystemdUnit always adds it) but is currently inert -- Papyrus's daemon never
33
- * reads it, since it has no idle-shutdown concept of its own. Migrating daemon.ts's own substrate
34
- * is tracked separately.
29
+ * scheduling, predating that shared substrate. handlePath points at the real {host,port,pid} file
30
+ * daemon.ts writes on startup (writeVehicleHandle), so Armada's readiness probe works once Papyrus
31
+ * is service-installed. DAEMON_KIT_LAUNCH_PROVENANCE=service is emitted (generateSystemdUnit always
32
+ * adds it) but is currently inert -- Papyrus's daemon never reads it, since it has no idle-shutdown
33
+ * concept of its own. Migrating daemon.ts's own substrate onto vehicle-server's shared daemon
34
+ * runtime entirely is tracked separately.
35
35
  */
36
- function papyrusServiceSpec(options: SystemdUnitOptions): ServiceSpec {
36
+ export function papyrusServiceSpec(options: SystemdUnitOptions): ServiceSpec {
37
37
  return {
38
38
  name: "papyrus",
39
39
  displayName: "Papyrus graph artifact service",
40
+ version: VERSION,
40
41
  binPath: options.bunBin,
41
42
  args: [options.cliPath, "serve"],
42
- descriptorPath: unitPath(),
43
+ // The real {host,port,pid} file daemon.ts writes on startup (writeVehicleHandle) --
44
+ // Armada's readiness probe polls exactly this path once Papyrus is service-installed.
45
+ handlePath: vehicleHandlePath(daemonStateDir()),
43
46
  // Restart=always/RestartSec=2 already unconditional in the prior hand-rolled unit --
44
47
  // preserved exactly, not a new opt-in.
45
48
  restartOnFailure: true,
@@ -51,11 +54,6 @@ export function renderSystemdUnit(options: SystemdUnitOptions): string {
51
54
  return generateSystemdUnit(papyrusServiceSpec(options));
52
55
  }
53
56
 
54
- function unitPath(): string {
55
- const configHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
56
- return join(configHome, "systemd", "user", DAEMON_UNIT_NAME);
57
- }
58
-
59
57
  function systemctl(...args: string[]): void {
60
58
  execFileSync("systemctl", ["--user", ...args], { stdio: "inherit" });
61
59
  }
package/src/constants.ts CHANGED
@@ -2,6 +2,8 @@
2
2
  export const DAEMON_HOST = "127.0.0.1";
3
3
  export const DAEMON_PORT_FILE = "port";
4
4
  export const DAEMON_TOKEN_FILE = "token";
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
+ export const DAEMON_HANDLE_FILE = "vehicle-handle.json";
5
7
  export const DAEMON_CLIENT_TIMEOUT_MS = 15_000;
6
8
  export const DAEMON_PROBE_TIMEOUT_MS = 800;
7
9
  export const DAEMON_UNIT_NAME = "papyrus.service";
@@ -2,7 +2,8 @@ import { randomBytes } from "node:crypto";
2
2
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { DAEMON_DIR_ENV, DAEMON_HOST, DAEMON_PORT_FILE, DAEMON_TOKEN_FILE } from "./constants.ts";
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
7
 
7
8
  export interface DaemonHandle {
8
9
  baseUrl: string;
@@ -42,6 +43,20 @@ export function clearDaemonPort(dir: string): void {
42
43
  rmSync(join(dir, DAEMON_PORT_FILE), { force: true });
43
44
  }
44
45
 
46
+ /** Where Armada's readiness probe looks once Papyrus is service-installed -- see cli.ts's papyrusServiceSpec. */
47
+ export function vehicleHandlePath(dir: string): string {
48
+ return join(dir, DAEMON_HANDLE_FILE);
49
+ }
50
+
51
+ /** 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
+ export function writeVehicleHandle(dir: string, port: number, pid: number = process.pid): void {
53
+ writeDaemonHandle(vehicleHandlePath(dir), { host: LOOPBACK_HOST, port, pid });
54
+ }
55
+
56
+ export function clearVehicleHandle(dir: string): void {
57
+ removeDaemonHandle(vehicleHandlePath(dir));
58
+ }
59
+
45
60
  export function readDaemonHandle(dir: string): DaemonHandle | undefined {
46
61
  try {
47
62
  const token = readFileSync(join(dir, DAEMON_TOKEN_FILE), "utf8").trim();
package/src/daemon.ts CHANGED
@@ -2,7 +2,14 @@ import { join } from "node:path";
2
2
  import { acquireDaemonLock, releaseDaemonLock } from "@danypops/vehicle-server/paths";
3
3
  import { PushChannel } from "@danypops/vehicle-server/push-channel";
4
4
  import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, dbPath, WAL_CHECKPOINT_INTERVAL_MS } from "./constants.ts";
5
- import { clearDaemonPort, daemonStateDir, loadOrCreateToken, writeDaemonPort } from "./daemon-state.ts";
5
+ import {
6
+ clearDaemonPort,
7
+ clearVehicleHandle,
8
+ daemonStateDir,
9
+ loadOrCreateToken,
10
+ writeDaemonPort,
11
+ writeVehicleHandle,
12
+ } from "./daemon-state.ts";
6
13
  import { logEvent, vehicleLogger } from "./log.ts";
7
14
  import { createApp, createPapyrusService } from "./service.ts";
8
15
 
@@ -66,6 +73,7 @@ export function serveMain(): void {
66
73
  throw new Error("Papyrus daemon failed to bind a listener");
67
74
  }
68
75
  writeDaemonPort(stateDir, server.port);
76
+ writeVehicleHandle(stateDir, server.port);
69
77
  const checkpointTimer = setInterval(() => {
70
78
  try {
71
79
  service.checkpoint();
@@ -110,6 +118,7 @@ export function serveMain(): void {
110
118
  clearInterval(reapFocusTimer);
111
119
  clearInterval(purgeTrashTimer);
112
120
  clearDaemonPort(stateDir);
121
+ clearVehicleHandle(stateDir);
113
122
  releaseDaemonLock(lockPath);
114
123
  service.close();
115
124
  // .finally() re-throws rather than handling a rejection -- catching it first turns a bare
@@ -329,11 +329,17 @@ export class Tasks {
329
329
  throw new Error(`task graph limit must be between 1 and ${TASK_EXECUTION_MAX_NODES + 1}`);
330
330
  }
331
331
  const tasks = this.list({ ...filter, limit: requestedLimit });
332
+ return this.buildGraph(tasks, scope, filter.sessionId);
333
+ }
334
+
335
+ /** Shared by graph() and dependencyCheckGraph() -- the latter builds a graph from an
336
+ * explicitly assembled task list (a union of two projects) rather than one filter's own scope. */
337
+ private buildGraph(tasks: Artifact[], scope: TaskViewSelection, sessionId?: string): TaskGraph {
332
338
  if (tasks.length > TASK_EXECUTION_MAX_NODES) {
333
- throw new Error(`task execution graph exceeds ${TASK_EXECUTION_MAX_NODES} nodes`);
339
+ throw new TaskExecutionBoundExceededError(`task execution graph exceeds ${TASK_EXECUTION_MAX_NODES} nodes`);
334
340
  }
335
341
  const byId = new Map(tasks.map((task) => [task.id, task]));
336
- const focus = this.focusStore.get(filter.sessionId);
342
+ const focus = this.focusStore.get(sessionId);
337
343
  const focusedId = focus?.taskId;
338
344
  const focusStatus = focus?.status;
339
345
  const nodes = new Map(
@@ -355,7 +361,7 @@ export class Tasks {
355
361
  limit: TASK_EXECUTION_MAX_EDGES + 1,
356
362
  });
357
363
  if (relationships.length > TASK_EXECUTION_MAX_EDGES) {
358
- throw new Error(`task execution graph exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
364
+ throw new TaskExecutionBoundExceededError(`task execution graph exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
359
365
  }
360
366
  for (const edge of relationships) {
361
367
  if (!byId.has(edge.from) || !byId.has(edge.to)) continue;
@@ -656,6 +662,18 @@ export class Tasks {
656
662
  if (sourceProject !== undefined && sourceProject === targetProject) {
657
663
  return this.graph({ projectRoot: sourceProject, scope: "project" });
658
664
  }
665
+ if (sourceProject !== undefined && targetProject !== undefined) {
666
+ // A genuine cross-project pair: the union of both endpoints' own project scopes
667
+ // covers every prerequisite/successor edge relevant to THIS pair's own cycle check,
668
+ // without pulling in every unrelated project's tasks -- those can push the daemon-wide
669
+ // total over TASK_EXECUTION_MAX_NODES for reasons that have nothing to do with these
670
+ // two tasks. A cycle threading through a third, uninvolved project is not covered by
671
+ // this union; falls back to the fully unscoped graph only when a project is unknown.
672
+ const limit = TASK_EXECUTION_MAX_NODES + 1;
673
+ const merged = new Map(this.list({ projectRoot: sourceProject, scope: "project", limit }).map((task) => [task.id, task]));
674
+ for (const task of this.list({ projectRoot: targetProject, scope: "project", limit })) merged.set(task.id, task);
675
+ return this.buildGraph([...merged.values()], { mode: "all", label: taskScopeLabel("all") });
676
+ }
659
677
  return this.graph();
660
678
  }
661
679