@danypops/papyrus 0.42.1 → 0.43.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 +4 -4
- package/src/client.ts +40 -17
- package/src/vehicle/artifact-trash-vehicle.ts +2 -2
- package/src/vehicle/artifact-vehicle-shared.ts +22 -5
- package/src/vehicle/docs-vehicle.ts +3 -3
- package/src/vehicle/notes-vehicle.ts +3 -3
- package/src/vehicle/playbooks-vehicle.ts +2 -1
- package/src/vehicle/rules-vehicle.ts +3 -3
- package/src/vehicle/tasks-vehicle.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.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
|
"keywords": ["pi-package"],
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
},
|
|
35
35
|
"files": ["src", "README.md"],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@danypops/vehicle-core": "^0.
|
|
38
|
-
"@danypops/vehicle-client": "^0.
|
|
39
|
-
"@danypops/vehicle-server": "^0.
|
|
37
|
+
"@danypops/vehicle-core": "^0.10.0",
|
|
38
|
+
"@danypops/vehicle-client": "^0.5.0",
|
|
39
|
+
"@danypops/vehicle-server": "^0.11.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/client.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { spawn as spawnProcess } from "node:child_process";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import {
|
|
3
|
+
import { connectWithVersionCheck, spawnDetachedDaemon } from "@danypops/vehicle-client/daemon-client";
|
|
4
|
+
import { readPackageVersion } from "@danypops/vehicle-client/version";
|
|
4
5
|
import { DAEMON_CLIENT_TIMEOUT_MS, DAEMON_DIR_ENV, DAEMON_PROBE_TIMEOUT_MS } from "./constants.ts";
|
|
5
6
|
import { type DaemonHandle, daemonStateDir, readDaemonHandle } from "./daemon-state.ts";
|
|
6
7
|
import type { OperationName, SchemaState } from "./service.ts";
|
|
7
8
|
|
|
9
|
+
/** Compared against the running daemon's /health-reported version by connectWithVersionCheck below -- a long-lived daemon holds whatever code was loaded at its own start. */
|
|
10
|
+
const PAPYRUS_VERSION = readPackageVersion(new URL("../package.json", import.meta.url), "Papyrus");
|
|
11
|
+
|
|
8
12
|
export type FetchAdapter = (request: Request) => Promise<Response>;
|
|
9
13
|
|
|
10
14
|
export class PapyrusClient {
|
|
@@ -64,6 +68,16 @@ function papyrusCliPath(): string {
|
|
|
64
68
|
return fileURLToPath(new URL("cli.ts", import.meta.url));
|
|
65
69
|
}
|
|
66
70
|
|
|
71
|
+
/** connectWithVersionCheck's killStaleProcess callback, factored out for a direct unit test -- a real spawned daemon can't be made to report a mismatched version without a second build. */
|
|
72
|
+
export function killStalePapyrusDaemon(handle: Pick<DaemonHandle, "pid">): void {
|
|
73
|
+
if (handle.pid <= 0) return; // daemon-state.ts's inert "unknown pid" sentinel -- never a real process.
|
|
74
|
+
try {
|
|
75
|
+
process.kill(handle.pid, "SIGTERM");
|
|
76
|
+
} catch {
|
|
77
|
+
// Caller's handle-file poll is the real guarantee, not this call succeeding.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
67
81
|
export interface ConnectPapyrusClientOptions {
|
|
68
82
|
/**
|
|
69
83
|
* Environment passed to an auto-spawned daemon child. Defaults to the current
|
|
@@ -74,6 +88,8 @@ export interface ConnectPapyrusClientOptions {
|
|
|
74
88
|
* silently diverge from wherever the child actually starts writing its handle.
|
|
75
89
|
*/
|
|
76
90
|
env?: Record<string, string | undefined>;
|
|
91
|
+
/** Overrides the version connectWithVersionCheck compares against. Defaults to PAPYRUS_VERSION; test-only -- lets a test force a mismatch against a real daemon without a second build. */
|
|
92
|
+
expectedVersion?: string;
|
|
77
93
|
}
|
|
78
94
|
|
|
79
95
|
/**
|
|
@@ -91,23 +107,30 @@ export async function connectPapyrusClient(
|
|
|
91
107
|
dir: string = daemonStateDir(),
|
|
92
108
|
options: ConnectPapyrusClientOptions = {},
|
|
93
109
|
): Promise<PapyrusClient> {
|
|
94
|
-
return
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
110
|
+
return connectWithVersionCheck(
|
|
111
|
+
{
|
|
112
|
+
readHandle: () => readDaemonHandle(dir) ?? null,
|
|
113
|
+
buildClient: probedPapyrusClient,
|
|
114
|
+
autoStart: true,
|
|
115
|
+
spawn: () => {
|
|
116
|
+
spawnDetachedDaemon({
|
|
117
|
+
binPath: papyrusCliPath(),
|
|
118
|
+
args: ["serve"],
|
|
119
|
+
env: { ...(options.env ?? process.env), [DAEMON_DIR_ENV]: dir },
|
|
120
|
+
spawn: (command, args, spawnOptions) => {
|
|
121
|
+
const child = spawnProcess(command, args, spawnOptions);
|
|
122
|
+
child.unref();
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
fallbackMessage: "Papyrus daemon failed to start automatically; run `papyrus service install` or `papyrus serve` manually.",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
expectedVersion: options.expectedVersion ?? PAPYRUS_VERSION,
|
|
130
|
+
readVersion: async (client) => (await client.health()).version,
|
|
131
|
+
killStaleProcess: killStalePapyrusDaemon,
|
|
108
132
|
},
|
|
109
|
-
|
|
110
|
-
});
|
|
133
|
+
);
|
|
111
134
|
}
|
|
112
135
|
|
|
113
136
|
export interface PushChannelTarget {
|
|
@@ -8,7 +8,7 @@ import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
|
8
8
|
import { removeArtifactSubtree } from "../artifact-subtree.ts";
|
|
9
9
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
10
10
|
import type { ArtifactTrashStore } from "../ports/artifact-trash-store.ts";
|
|
11
|
-
import { looseObjectSchema, numberProp, passthroughOutput, stringProp } from "./artifact-vehicle-shared.ts";
|
|
11
|
+
import { looseObjectSchema, numberProp, passthroughOutput, stringProp, validationError } from "./artifact-vehicle-shared.ts";
|
|
12
12
|
|
|
13
13
|
const OWNER = "artifact";
|
|
14
14
|
const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
@@ -26,7 +26,7 @@ function eventContext(input: Record<string, unknown>): { actor?: string; source?
|
|
|
26
26
|
|
|
27
27
|
function requireId(input: Record<string, unknown>): string {
|
|
28
28
|
const id = input.id;
|
|
29
|
-
if (typeof id !== "string" || id.length === 0) throw
|
|
29
|
+
if (typeof id !== "string" || id.length === 0) throw validationError("id is required");
|
|
30
30
|
return id;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* VehicleRegistry projection (notes-vehicle.ts, rules-vehicle.ts, docs-vehicle.ts,
|
|
4
4
|
* artifact-trash-vehicle.ts).
|
|
5
5
|
*/
|
|
6
|
-
import { defineVehicleSchema, type VehicleContentBlock, type VehicleSchemaCodec } from "@danypops/vehicle-core";
|
|
6
|
+
import { defineVehicleSchema, VehicleError, type VehicleContentBlock, type VehicleSchemaCodec } from "@danypops/vehicle-core";
|
|
7
7
|
import type { Artifact } from "../domain/artifact.ts";
|
|
8
8
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
9
9
|
import type { TaskExecutionPlan } from "../task-execution.ts";
|
|
@@ -47,6 +47,19 @@ export const passthroughOutput: VehicleSchemaCodec<unknown> = defineVehicleSchem
|
|
|
47
47
|
export const stringProp = { type: "string" } as const;
|
|
48
48
|
export const numberProp = { type: "number" } as const;
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* A plain `throw new Error(...)` inside any resolve()/execute() step here is caught by
|
|
52
|
+
* vehicle-registry.ts's generic dispatch and re-wrapped as VehicleError("handler-failed",
|
|
53
|
+
* `${key} handler failed`, {category: "internal"}) -- built to catch a genuine crash, but
|
|
54
|
+
* it can't distinguish that from an ordinary, expected validation/lookup failure, so it
|
|
55
|
+
* discards the original message and category either way. Every guard clause and name
|
|
56
|
+
* resolution below must throw a VehicleError directly so it passes through that dispatch
|
|
57
|
+
* unchanged (vehicle-registry.ts only rewraps errors that are NOT already a VehicleError).
|
|
58
|
+
*/
|
|
59
|
+
export function validationError(message: string): VehicleError {
|
|
60
|
+
return new VehicleError("validation-failed", message, { category: "validation" });
|
|
61
|
+
}
|
|
62
|
+
|
|
50
63
|
/** A known LLM tool-calling quirk: a nested-object field arrives JSON-stringified rather than as a real object. Mutates input[key] in place when it's a string, leaves it untouched otherwise. */
|
|
51
64
|
export function normalizeJsonEncodedField(input: Record<string, unknown>, key: string): void {
|
|
52
65
|
const value = input[key];
|
|
@@ -54,7 +67,7 @@ export function normalizeJsonEncodedField(input: Record<string, unknown>, key: s
|
|
|
54
67
|
try {
|
|
55
68
|
input[key] = JSON.parse(value);
|
|
56
69
|
} catch {
|
|
57
|
-
throw
|
|
70
|
+
throw validationError(`${key} must be valid JSON`);
|
|
58
71
|
}
|
|
59
72
|
}
|
|
60
73
|
|
|
@@ -62,10 +75,14 @@ export function normalizeJsonEncodedField(input: Record<string, unknown>, key: s
|
|
|
62
75
|
export function matchArtifactByName(candidates: readonly Artifact[], name: string): string {
|
|
63
76
|
const needle = name.trim().toLowerCase();
|
|
64
77
|
const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
|
|
65
|
-
if (matches.length === 0)
|
|
78
|
+
if (matches.length === 0) {
|
|
79
|
+
throw new VehicleError("artifact-not-found", `no artifact named "${name}" found in this scope`, { category: "not_found" });
|
|
80
|
+
}
|
|
66
81
|
if (matches.length > 1) {
|
|
67
|
-
throw new
|
|
82
|
+
throw new VehicleError(
|
|
83
|
+
"artifact-name-ambiguous",
|
|
68
84
|
`${matches.length} artifacts are named "${name}": ${matches.map((a) => `${a.title} (${a.id})`).join(", ")} -- use id to disambiguate`,
|
|
85
|
+
{ category: "conflict" },
|
|
69
86
|
);
|
|
70
87
|
}
|
|
71
88
|
return matches[0]!.id;
|
|
@@ -85,7 +102,7 @@ export function resolveArtifactIdWidened(
|
|
|
85
102
|
try {
|
|
86
103
|
return matchArtifactByName(fetchCandidates(), name);
|
|
87
104
|
} catch (error) {
|
|
88
|
-
if (!(error instanceof
|
|
105
|
+
if (!(error instanceof VehicleError) || error.code !== "artifact-not-found" || !fetchWidened) throw error;
|
|
89
106
|
return matchArtifactByName(fetchWidened(), name);
|
|
90
107
|
}
|
|
91
108
|
}
|
|
@@ -10,7 +10,7 @@ import { listDocuments } from "../domain-services.ts";
|
|
|
10
10
|
import { docsOperations } from "../modules/docs.ts";
|
|
11
11
|
import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
|
|
12
12
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
13
|
-
import { looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp } from "./artifact-vehicle-shared.ts";
|
|
13
|
+
import { looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp, validationError } from "./artifact-vehicle-shared.ts";
|
|
14
14
|
|
|
15
15
|
const OWNER = "docs";
|
|
16
16
|
const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
@@ -23,7 +23,7 @@ function resolveDocId(
|
|
|
23
23
|
name: unknown,
|
|
24
24
|
): string {
|
|
25
25
|
if (typeof id === "string" && id.length > 0) return id;
|
|
26
|
-
if (typeof name !== "string" || name.length === 0) throw
|
|
26
|
+
if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
|
|
27
27
|
return resolveArtifactIdWidened(
|
|
28
28
|
name,
|
|
29
29
|
() => listDocuments(artifacts, scopes, { text: name, projectRoot }),
|
|
@@ -34,7 +34,7 @@ function resolveDocId(
|
|
|
34
34
|
/** Cross-kind resolution for a link target -- can be a doc, task, rule, or playbook. Unscoped, matching the exact behavior of the artifact.query-backed resolution it replaces. */
|
|
35
35
|
function resolveTargetId(artifacts: ArtifactStore, id: unknown, name: unknown): string {
|
|
36
36
|
if (typeof id === "string" && id.length > 0) return id;
|
|
37
|
-
if (typeof name !== "string" || name.length === 0) throw
|
|
37
|
+
if (typeof name !== "string" || name.length === 0) throw validationError("target_id or target_name is required");
|
|
38
38
|
return resolveArtifactIdWidened(name, () => artifacts.query({ text: name }));
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -12,7 +12,7 @@ import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
|
12
12
|
import { notesOperations } from "../modules/notes.ts";
|
|
13
13
|
import { NOTE_DISPOSITIONS, type Notes } from "../note-service.ts";
|
|
14
14
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
15
|
-
import { looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp } from "./artifact-vehicle-shared.ts";
|
|
15
|
+
import { looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp, validationError } from "./artifact-vehicle-shared.ts";
|
|
16
16
|
|
|
17
17
|
const OWNER = "notes";
|
|
18
18
|
|
|
@@ -21,14 +21,14 @@ const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes:
|
|
|
21
21
|
/** Resolves a note's id from either an explicit id or its title within projectRoot. */
|
|
22
22
|
function resolveNoteId(notes: Notes, projectRoot: string, id: unknown, name: unknown): string {
|
|
23
23
|
if (typeof id === "string" && id.length > 0) return id;
|
|
24
|
-
if (typeof name !== "string" || name.length === 0) throw
|
|
24
|
+
if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
|
|
25
25
|
return resolveArtifactIdWidened(name, () => notes.list({ projectRoot, text: name }));
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/** Cross-kind equivalent for a promotion target -- a target can be a task, doc, rule, or playbook, not just a note. Unscoped by project, matching the exact behavior of the artifact.query-backed resolution it replaces. */
|
|
29
29
|
function resolveArtifactId(artifacts: ArtifactStore, id: unknown, name: unknown): string {
|
|
30
30
|
if (typeof id === "string" && id.length > 0) return id;
|
|
31
|
-
if (typeof name !== "string" || name.length === 0) throw
|
|
31
|
+
if (typeof name !== "string" || name.length === 0) throw validationError("target_id or target_name is required");
|
|
32
32
|
return resolveArtifactIdWidened(name, () => artifacts.query({ text: name }));
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
passthroughOutput,
|
|
38
38
|
resolveArtifactIdWidened,
|
|
39
39
|
stringProp,
|
|
40
|
+
validationError,
|
|
40
41
|
} from "./artifact-vehicle-shared.ts";
|
|
41
42
|
|
|
42
43
|
const OWNER = "playbooks";
|
|
@@ -54,7 +55,7 @@ export interface PlaybooksVehicleDeps {
|
|
|
54
55
|
/** Unscoped resolution -- a Playbook is commonly cross-project (e.g. a lab-deploy playbook), matching the hand-rolled tool's own resolutionRequest choice. */
|
|
55
56
|
function resolvePlaybookId(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: unknown, name: unknown): string {
|
|
56
57
|
if (typeof id === "string" && id.length > 0) return id;
|
|
57
|
-
if (typeof name !== "string" || name.length === 0) throw
|
|
58
|
+
if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
|
|
58
59
|
return resolveArtifactIdWidened(name, () => listPlaybooks(artifacts, scopes, { text: name }));
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -10,7 +10,7 @@ import { listRules } from "../domain-services.ts";
|
|
|
10
10
|
import { rulesOperations } from "../modules/rules.ts";
|
|
11
11
|
import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
|
|
12
12
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
13
|
-
import { looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp } from "./artifact-vehicle-shared.ts";
|
|
13
|
+
import { looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp, validationError } from "./artifact-vehicle-shared.ts";
|
|
14
14
|
|
|
15
15
|
const OWNER = "rules";
|
|
16
16
|
const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
@@ -24,7 +24,7 @@ function resolveRuleId(
|
|
|
24
24
|
name: unknown,
|
|
25
25
|
): string {
|
|
26
26
|
if (typeof id === "string" && id.length > 0) return id;
|
|
27
|
-
if (typeof name !== "string" || name.length === 0) throw
|
|
27
|
+
if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
|
|
28
28
|
return resolveArtifactIdWidened(
|
|
29
29
|
name,
|
|
30
30
|
() => listRules(artifacts, scopes, { text: name, projectRoot }),
|
|
@@ -151,7 +151,7 @@ export function registerRulesVehicleOperations(registry: VehicleRegistry, artifa
|
|
|
151
151
|
[],
|
|
152
152
|
(input) => {
|
|
153
153
|
const taskId = resolveTaskId(artifacts, input.project_root as string | undefined, input.task_id, input.task_name);
|
|
154
|
-
if (!taskId) throw
|
|
154
|
+
if (!taskId) throw validationError("task_id or task_name is required");
|
|
155
155
|
return {
|
|
156
156
|
...input,
|
|
157
157
|
id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name),
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
passthroughOutput,
|
|
35
35
|
resolveArtifactIdWidened,
|
|
36
36
|
stringProp,
|
|
37
|
+
validationError,
|
|
37
38
|
} from "./artifact-vehicle-shared.ts";
|
|
38
39
|
|
|
39
40
|
const OWNER = "tasks";
|
|
@@ -70,8 +71,8 @@ function resolveTaskId(
|
|
|
70
71
|
name: unknown,
|
|
71
72
|
): string {
|
|
72
73
|
if (typeof id === "string" && id.length > 0) return id;
|
|
73
|
-
if (typeof name !== "string" || name.length === 0) throw
|
|
74
|
-
if (!filter.projectRoot) throw
|
|
74
|
+
if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
|
|
75
|
+
if (!filter.projectRoot) throw validationError("project_root is required when resolving a task by name");
|
|
75
76
|
return resolveArtifactIdWidened(
|
|
76
77
|
name,
|
|
77
78
|
() => tasks.list({ ...filter, text: name }),
|