@danypops/papyrus 0.21.0 → 0.21.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.21.0",
3
+ "version": "0.21.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
  "keywords": ["pi-package"],
@@ -6,8 +6,8 @@ import { runGates, runGatesAsync } from "../ops.ts";
6
6
  export class SQLiteGateRunner implements GateRunner {
7
7
  constructor(private readonly db: Db) {}
8
8
 
9
- run(artifactId: string): GateResult[] {
10
- return runGates(this.db, artifactId);
9
+ run(artifactId: string, options?: GateRunOptions): GateResult[] {
10
+ return runGates(this.db, artifactId, options);
11
11
  }
12
12
 
13
13
  runAsync(artifactId: string, options?: GateRunOptions): Promise<GateResult[]> {
@@ -7,6 +7,18 @@ export interface Gate {
7
7
  export interface GateRunOptions {
8
8
  /** Absolute Unix epoch deadline for the full gate sequence. */
9
9
  deadlineMs?: number;
10
+ /**
11
+ * Working directory for "command"/"test" gates. Without this, a command gate inherits the
12
+ * Papyrus daemon's own process cwd (its systemd unit's launch directory, e.g. the user's home
13
+ * directory) rather than the task's project -- a real incident: a `bun test` command gate ran
14
+ * against the daemon's home directory instead of the task's project, recursively discovering
15
+ * and attempting to run every test file under every project on the machine, which exhausted
16
+ * memory and crashed the `bun` process outright (SIGILL/SIGABRT), well past the configured
17
+ * gate timeout because the timeout only ever terminated the immediate shell, not the process
18
+ * group it spawned (see executeGateCommand). Task-scoped completion must always pass the
19
+ * task's project_root here.
20
+ */
21
+ cwd?: string;
10
22
  }
11
23
 
12
24
  export interface GateResult {
package/src/ops.ts CHANGED
@@ -3,7 +3,6 @@
3
3
  * Enforces the schema protocol (kinds, statuses, relations) via FK + app validation.
4
4
  */
5
5
  import { createRequire } from "node:module";
6
- import { exec } from "node:child_process";
7
6
  import type { Db } from "./db.ts";
8
7
  import { inTransaction } from "./db.ts";
9
8
  import { ARTIFACT_TRASH_RETENTION_MS, DEFAULT_STATUS_BY_KIND } from "./constants.ts";
@@ -508,10 +507,11 @@ function readBoundedGateFile(path: string): string {
508
507
  return readFileSync(path, "utf-8") as string;
509
508
  }
510
509
 
511
- export function runGates(db: Db, artifactId: string): GateResult[] {
510
+ export function runGates(db: Db, artifactId: string, options: GateRunOptions = {}): GateResult[] {
512
511
  const art = getArtifact(db, artifactId);
513
512
  if (!art) throw new Error("artifact not found");
514
513
  const gates = (art.extra["gates"] as Gate[]) ?? [];
514
+ const cwd = options.cwd;
515
515
  return gates.map((gate) => {
516
516
  switch (gate.type) {
517
517
  case "file-exists": {
@@ -531,7 +531,7 @@ export function runGates(db: Db, artifactId: string): GateResult[] {
531
531
  case "command": {
532
532
  const { execSync } = require_("node:child_process");
533
533
  try {
534
- const output = execSync(gate.target, { encoding: "utf-8", timeout: GATE_COMMAND_TIMEOUT_MS, stdio: ["pipe", "pipe", "pipe"] }).trim();
534
+ const output = execSync(gate.target, { encoding: "utf-8", timeout: GATE_COMMAND_TIMEOUT_MS, stdio: ["pipe", "pipe", "pipe"], ...(cwd ? { cwd } : {}) }).trim();
535
535
  const passed = gate.expect ? output.includes(gate.expect) : true;
536
536
  return { gate, passed, output: output.slice(0, GATE_OUTPUT_LIMIT) };
537
537
  } catch (e) {
@@ -541,7 +541,7 @@ export function runGates(db: Db, artifactId: string): GateResult[] {
541
541
  case "test": {
542
542
  const { execSync } = require_("node:child_process");
543
543
  try {
544
- execSync(`npx vitest run ${gate.target} --reporter=dot`, { encoding: "utf-8", timeout: GATE_TEST_TIMEOUT_MS, stdio: ["pipe", "pipe", "pipe"] });
544
+ execSync(`npx vitest run ${gate.target} --reporter=dot`, { encoding: "utf-8", timeout: GATE_TEST_TIMEOUT_MS, stdio: ["pipe", "pipe", "pipe"], ...(cwd ? { cwd } : {}) });
545
545
  return { gate, passed: true, output: "tests passed" };
546
546
  } catch (e) {
547
547
  return { gate, passed: false, output: e instanceof Error ? e.message.slice(0, GATE_OUTPUT_LIMIT) : "tests failed" };
@@ -553,15 +553,64 @@ export function runGates(db: Db, artifactId: string): GateResult[] {
553
553
  });
554
554
  }
555
555
 
556
- function executeGateCommand(command: string, timeout: number): Promise<{ passed: boolean; output: string }> {
556
+ /**
557
+ * Runs one gate command with two invariants a prior implementation lacked (a real incident; see
558
+ * GateRunOptions.cwd's doc comment):
559
+ * 1. `cwd` is always explicit, never inherited from the daemon's own process cwd.
560
+ * 2. The whole process group is killed on timeout, not just the immediate shell. `exec()`'s own
561
+ * `timeout` option only signals the process it directly spawned (the shell running
562
+ * `command`); a shell's own child (e.g. `bun` under `sh -c "bun test"`) is not in general
563
+ * killed by that signal and can be reparented and keep running -- and consuming memory --
564
+ * indefinitely after Papyrus considers the gate "timed out". Spawning detached (its own
565
+ * process group) and killing the negated pid on our own timer reaches the whole tree.
566
+ */
567
+ function executeGateCommand(command: string, timeout: number, cwd?: string): Promise<{ passed: boolean; output: string }> {
568
+ // `spawn(..., { shell: true, detached: true })` instead of the `exec()` convenience wrapper:
569
+ // `detached` (needed to make the shell the leader of its own process group, so the negated pid
570
+ // below reaches every descendant, not just the shell) is not part of Node's `exec()`/
571
+ // `ExecOptions` type at all -- `spawn`'s options support it directly and correctly.
572
+ const { spawn } = require_("node:child_process") as typeof import("node:child_process");
557
573
  return new Promise((resolve) => {
558
- exec(command, { encoding: "utf8", timeout, maxBuffer: GATE_MAX_BUFFER_BYTES }, (error, stdout, stderr) => {
559
- const output = `${stdout}${stderr}`.trim().slice(0, GATE_OUTPUT_LIMIT);
560
- resolve({
561
- passed: error === null,
562
- output: output || (error ? error.message.slice(0, GATE_OUTPUT_LIMIT) : "ok"),
563
- });
574
+ let settled = false;
575
+ let buffered = "";
576
+ let truncated = false;
577
+ const child = spawn(command, { shell: true, detached: true, ...(cwd ? { cwd } : {}) });
578
+
579
+ const append = (chunk: Buffer): void => {
580
+ if (truncated) return;
581
+ buffered += chunk.toString("utf8");
582
+ if (buffered.length > GATE_MAX_BUFFER_BYTES) {
583
+ buffered = buffered.slice(0, GATE_MAX_BUFFER_BYTES);
584
+ truncated = true;
585
+ }
586
+ };
587
+ child.stdout?.on("data", append);
588
+ child.stderr?.on("data", append);
589
+
590
+ const finish = (result: { passed: boolean; output: string }): void => {
591
+ if (settled) return;
592
+ settled = true;
593
+ clearTimeout(timer);
594
+ resolve(result);
595
+ };
596
+
597
+ child.on("error", (error) => finish({ passed: false, output: error.message.slice(0, GATE_OUTPUT_LIMIT) }));
598
+ child.on("close", (code) => {
599
+ const output = buffered.trim().slice(0, GATE_OUTPUT_LIMIT);
600
+ finish({ passed: code === 0, output: output || (code === 0 ? "ok" : `command exited with code ${code}`) });
564
601
  });
602
+
603
+ const timer = setTimeout(() => {
604
+ if (settled) return;
605
+ if (child.pid !== undefined) {
606
+ try {
607
+ process.kill(-child.pid, "SIGKILL");
608
+ } catch {
609
+ child.kill("SIGKILL");
610
+ }
611
+ }
612
+ finish({ passed: false, output: `gate command timed out after ${timeout}ms` });
613
+ }, timeout);
565
614
  });
566
615
  }
567
616
 
@@ -599,7 +648,7 @@ export async function runGatesAsync(db: Db, artifactId: string, options: GateRun
599
648
  const command = gate.type === "test" ? `npx vitest run ${gate.target} --reporter=dot` : gate.target;
600
649
  const configuredTimeout = gate.type === "test" ? GATE_TEST_TIMEOUT_MS : GATE_COMMAND_TIMEOUT_MS;
601
650
  const timeout = remainingMs === undefined ? configuredTimeout : Math.max(1, Math.min(configuredTimeout, remainingMs));
602
- const executed = await executeGateCommand(command, timeout);
651
+ const executed = await executeGateCommand(command, timeout, options.cwd);
603
652
  results.push({
604
653
  gate,
605
654
  passed: executed.passed && (gate.expect ? executed.output.includes(gate.expect) : true),
@@ -1,6 +1,6 @@
1
1
  import type { GateResult, GateRunOptions } from "../domain/gate.ts";
2
2
 
3
3
  export interface GateRunner {
4
- run(artifactId: string): GateResult[];
4
+ run(artifactId: string, options?: GateRunOptions): GateResult[];
5
5
  runAsync(artifactId: string, options?: GateRunOptions): Promise<GateResult[]>;
6
6
  }
@@ -424,7 +424,7 @@ export class Tasks {
424
424
  const attemptId = crypto.randomUUID();
425
425
  this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
426
426
  const checklist = this.reviewChecklist(task);
427
- const results = this.gates.run(id);
427
+ const results = this.gates.run(id, { cwd: this.scopes.get(id)?.projectRoot });
428
428
  return this.resolveCompletion(id, attemptId, results, checklist, context, options);
429
429
  }
430
430
 
@@ -434,14 +434,17 @@ export class Tasks {
434
434
  const attemptId = crypto.randomUUID();
435
435
  this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
436
436
  const checklist = this.reviewChecklist(task);
437
- const results = await this.gates.runAsync(id, { deadlineMs: options.gateDeadlineMs });
437
+ // project_root, never the daemon's own inherited process cwd -- see GateRunOptions.cwd's doc
438
+ // comment for the real incident this fixes (a command gate once tested the daemon's entire
439
+ // home directory instead of the task's project and crashed the bun process outright).
440
+ const results = await this.gates.runAsync(id, { deadlineMs: options.gateDeadlineMs, cwd: this.scopes.get(id)?.projectRoot });
438
441
  this.requireReview(id);
439
442
  return this.resolveCompletion(id, attemptId, results, checklist, context, options);
440
443
  }
441
444
 
442
445
  async runGates(id: string, context: TaskEventContext = {}): Promise<GateResult[]> {
443
446
  this.require(id);
444
- const results = await this.gates.runAsync(id);
447
+ const results = await this.gates.runAsync(id, { cwd: this.scopes.get(id)?.projectRoot });
445
448
  this.events.atomic(() => this.appendEvent({ taskId: id, type: "gates_evaluated", evidence: { gates: results, result: results.every((gate) => gate.passed) ? "passed" : "failed" } }, context));
446
449
  return results;
447
450
  }