@danypops/papyrus 0.38.3 → 0.39.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 +2 -1
- package/src/client.ts +54 -5
- package/src/daemon-state.ts +12 -4
- package/src/service.ts +11 -12
- package/src/vehicle/artifact-trash-vehicle.ts +95 -0
- package/src/vehicle/artifact-vehicle-shared.ts +69 -0
- package/src/vehicle/docs-vehicle.ts +145 -0
- package/src/vehicle/notes-vehicle.ts +18 -74
- package/src/vehicle/papyrus-vehicle.ts +35 -0
- package/src/vehicle/rules-vehicle.ts +153 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.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"],
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"files": ["src", "README.md"],
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@danypops/vehicle-core": "^0.1.1",
|
|
38
|
+
"@danypops/vehicle-client": "^0.1.1",
|
|
38
39
|
"@danypops/vehicle-server": "^0.3.2"
|
|
39
40
|
}
|
|
40
41
|
}
|
package/src/client.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { spawn as spawnProcess } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { connectWithPolicy, spawnDetachedDaemon } from "@danypops/vehicle-client/daemon-client";
|
|
4
|
+
import { DAEMON_CLIENT_TIMEOUT_MS, DAEMON_DIR_ENV, DAEMON_PROBE_TIMEOUT_MS } from "./constants.ts";
|
|
5
|
+
import { daemonStateDir, readDaemonHandle, type DaemonHandle } from "./daemon-state.ts";
|
|
3
6
|
import type { OperationName, SchemaState } from "./service.ts";
|
|
4
7
|
|
|
5
8
|
export type FetchAdapter = (request: Request) => Promise<Response>;
|
|
@@ -46,9 +49,7 @@ export class PapyrusClient {
|
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
const handle = readDaemonHandle(dir);
|
|
51
|
-
if (!handle) throw new Error("Papyrus daemon is not running; install/start papyrus.service");
|
|
52
|
+
async function probedPapyrusClient(handle: DaemonHandle): Promise<PapyrusClient> {
|
|
52
53
|
const probe = new PapyrusClient(handle.baseUrl, handle.token, (request) => fetch(request), DAEMON_PROBE_TIMEOUT_MS);
|
|
53
54
|
try {
|
|
54
55
|
await probe.health();
|
|
@@ -58,6 +59,54 @@ export async function connectPapyrusClient(dir: string = daemonStateDir()): Prom
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/** packages/papyrus/src/cli.ts, resolved relative to this file's own installed location, not require.resolve('@danypops/papyrus') -- this module IS that package, no cross-package lookup needed. */
|
|
63
|
+
function papyrusCliPath(): string {
|
|
64
|
+
return fileURLToPath(new URL("cli.ts", import.meta.url));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ConnectPapyrusClientOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Environment passed to an auto-spawned daemon child. Defaults to the current
|
|
70
|
+
* process.env. Always carries DAEMON_DIR_ENV=dir so the spawned child computes
|
|
71
|
+
* the exact same state directory this call itself reads/polls -- without this,
|
|
72
|
+
* a caller-supplied `dir` (every real test; production always uses the default
|
|
73
|
+
* daemonStateDir(), which the child would derive identically on its own) would
|
|
74
|
+
* silently diverge from wherever the child actually starts writing its handle.
|
|
75
|
+
*/
|
|
76
|
+
env?: Record<string, string | undefined>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Transparently starts the daemon first if it is not already running -- matches
|
|
81
|
+
* every other daemon-backed ecosystem package that opted into auto-start (see
|
|
82
|
+
* @danypops/vehicle-client's connectWithPolicy doc comment: web-spider opts in,
|
|
83
|
+
* lector/pi-packed fail closed by design). Papyrus previously failed closed with
|
|
84
|
+
* "install/start papyrus.service", requiring a human to separately discover and
|
|
85
|
+
* run `papyrus service install` before the very first tool call could succeed.
|
|
86
|
+
* A handle file that exists but points at a dead/unreachable daemon is a distinct
|
|
87
|
+
* failure (stale, not "never started") and is NOT auto-recovered here -- it still
|
|
88
|
+
* throws its own actionable "restart manually" error, unchanged from before.
|
|
89
|
+
*/
|
|
90
|
+
export async function connectPapyrusClient(dir: string = daemonStateDir(), options: ConnectPapyrusClientOptions = {}): Promise<PapyrusClient> {
|
|
91
|
+
return connectWithPolicy({
|
|
92
|
+
readHandle: () => readDaemonHandle(dir) ?? null,
|
|
93
|
+
buildClient: probedPapyrusClient,
|
|
94
|
+
autoStart: true,
|
|
95
|
+
spawn: () => {
|
|
96
|
+
spawnDetachedDaemon({
|
|
97
|
+
binPath: papyrusCliPath(),
|
|
98
|
+
args: ["serve"],
|
|
99
|
+
env: { ...(options.env ?? process.env), [DAEMON_DIR_ENV]: dir },
|
|
100
|
+
spawn: (command, args, spawnOptions) => {
|
|
101
|
+
const child = spawnProcess(command, args, spawnOptions);
|
|
102
|
+
child.unref();
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
fallbackMessage: "Papyrus daemon failed to start automatically; run `papyrus service install` or `papyrus serve` manually.",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
61
110
|
export interface PushChannelTarget {
|
|
62
111
|
/** ws:// URL for the daemon's push-invalidation channel (see push-channel.ts in vehicle-server). */
|
|
63
112
|
url: string;
|
package/src/daemon-state.ts
CHANGED
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
export interface DaemonHandle {
|
|
13
13
|
baseUrl: string;
|
|
14
14
|
token: string;
|
|
15
|
+
host: string;
|
|
16
|
+
port: number;
|
|
17
|
+
pid: number;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export function daemonStateDir(
|
|
@@ -38,9 +41,9 @@ export function loadOrCreateToken(dir: string): string {
|
|
|
38
41
|
return token;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
export function writeDaemonPort(dir: string, port: number): void {
|
|
44
|
+
export function writeDaemonPort(dir: string, port: number, pid: number = process.pid): void {
|
|
42
45
|
mkdirSync(dir, { recursive: true });
|
|
43
|
-
writeFileSync(join(dir, DAEMON_PORT_FILE), `${port}\n`, { mode: 0o600 });
|
|
46
|
+
writeFileSync(join(dir, DAEMON_PORT_FILE), `${port}\n${pid}\n`, { mode: 0o600 });
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
export function clearDaemonPort(dir: string): void {
|
|
@@ -50,9 +53,14 @@ export function clearDaemonPort(dir: string): void {
|
|
|
50
53
|
export function readDaemonHandle(dir: string): DaemonHandle | undefined {
|
|
51
54
|
try {
|
|
52
55
|
const token = readFileSync(join(dir, DAEMON_TOKEN_FILE), "utf8").trim();
|
|
53
|
-
const
|
|
56
|
+
const lines = readFileSync(join(dir, DAEMON_PORT_FILE), "utf8").trim().split("\n");
|
|
57
|
+
const port = Number(lines[0]);
|
|
58
|
+
// pid is absent for a handle written before this field existed -- 0 is a safe
|
|
59
|
+
// "unknown" sentinel (never a real pid), not a crash. Nothing currently reads
|
|
60
|
+
// pid to make a kill/staleness decision, so a stale 0 is inert, not unsafe.
|
|
61
|
+
const pid = lines[1] ? Number(lines[1]) : 0;
|
|
54
62
|
if (!token || !Number.isInteger(port) || port < 1 || port > 65_535) return undefined;
|
|
55
|
-
return { baseUrl: `http://${DAEMON_HOST}:${port}`, token };
|
|
63
|
+
return { baseUrl: `http://${DAEMON_HOST}:${port}`, token, host: DAEMON_HOST, port, pid };
|
|
56
64
|
} catch {
|
|
57
65
|
return undefined;
|
|
58
66
|
}
|
package/src/service.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
listInjectableRules,
|
|
29
29
|
} from "./domain-services.ts";
|
|
30
30
|
import { Notes, NOTE_SUBTYPE } from "./note-service.ts";
|
|
31
|
-
import {
|
|
31
|
+
import { createPapyrusVehicleRegistry } from "./vehicle/papyrus-vehicle.ts";
|
|
32
32
|
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
33
33
|
import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
|
|
34
34
|
import { Logs } from "./log-service.ts";
|
|
@@ -208,12 +208,13 @@ export interface PapyrusService {
|
|
|
208
208
|
schemaState(): SchemaState;
|
|
209
209
|
execute(operation: string, input?: OperationInput): Promise<unknown>;
|
|
210
210
|
/**
|
|
211
|
-
*
|
|
212
|
-
* one honest VehicleOperation per real
|
|
213
|
-
*
|
|
214
|
-
* domain
|
|
211
|
+
* Every domain migrated onto Vehicle, merged into one registry/one HTTP mount
|
|
212
|
+
* (see ./vehicle/papyrus-vehicle.ts) -- one honest VehicleOperation per real
|
|
213
|
+
* action, replacing the Pi extension's old `<domain>(action=X)` mega-tools.
|
|
214
|
+
* Not every domain is migrated yet -- see papyrus-vehicle.ts's own doc comment
|
|
215
|
+
* for what still isn't and why.
|
|
215
216
|
*/
|
|
216
|
-
readonly
|
|
217
|
+
readonly vehicle: VehicleRegistry;
|
|
217
218
|
checkpoint(): void;
|
|
218
219
|
optimize(): void;
|
|
219
220
|
/** Time-based Task Focus reclamation (see Tasks.reapStaleFocus); returns how many rows were removed, for daemon logging. */
|
|
@@ -479,13 +480,13 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
479
480
|
const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases);
|
|
480
481
|
const noteEvents = new SQLiteNoteEventStore(db);
|
|
481
482
|
const notes = new Notes(artifacts, noteEvents);
|
|
482
|
-
const notesVehicle = createNotesVehicleRegistry(notes, artifacts);
|
|
483
483
|
const projections = new SQLiteGraphProjectionStore(db);
|
|
484
484
|
const artifactScopes = new SQLiteArtifactScopeStore(db);
|
|
485
485
|
const logs = new Logs(new SQLiteLogStore(db));
|
|
486
486
|
const sessionIdentity = new SessionIdentity(new SQLiteSessionIdentityStore(db));
|
|
487
487
|
const discussions = new Discussions(artifacts, new SQLiteDiscussionRoundStore(db));
|
|
488
488
|
const authority = createAuthorityRegistry();
|
|
489
|
+
const vehicle = createPapyrusVehicleRegistry({ artifacts, scopes: artifactScopes, authority, notes });
|
|
489
490
|
const moduleRegistry = new OperationRegistry();
|
|
490
491
|
moduleRegistry.registerAll(notesOperations(notes));
|
|
491
492
|
moduleRegistry.registerAll(logsOperations(logs));
|
|
@@ -505,7 +506,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
505
506
|
return {
|
|
506
507
|
operationNames: () => [...EXPECTED_OPERATION_NAMES],
|
|
507
508
|
schemaState: state,
|
|
508
|
-
|
|
509
|
+
vehicle,
|
|
509
510
|
async execute(operation, input = {}) {
|
|
510
511
|
const handler = registry[operation as OperationName];
|
|
511
512
|
if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
|
|
@@ -565,10 +566,8 @@ export function createApp(deps: {
|
|
|
565
566
|
*/
|
|
566
567
|
onOperationExecuted?: (operation: string, input: OperationInput) => void;
|
|
567
568
|
}): { fetch(request: Request): Promise<Response> } {
|
|
568
|
-
// Same Bearer token as the rest of this API --
|
|
569
|
-
|
|
570
|
-
// not a second service to stand up or authenticate against separately.
|
|
571
|
-
const vehicleApp = createVehicleHttpApp({ registry: deps.service.notesVehicle, token: deps.token });
|
|
569
|
+
// Same Bearer token, daemon, and port as the rest of this API -- see ./vehicle/papyrus-vehicle.ts.
|
|
570
|
+
const vehicleApp = createVehicleHttpApp({ registry: deps.service.vehicle, token: deps.token });
|
|
572
571
|
return {
|
|
573
572
|
async fetch(request: Request): Promise<Response> {
|
|
574
573
|
if (request.headers.get("authorization") !== `Bearer ${deps.token}`) {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* artifact.* -- show/remove/remove_subtree/restore, identical regardless of an
|
|
3
|
+
* artifact's kind. Registered once here, shared by every domain, instead of
|
|
4
|
+
* duplicated as rules.remove/docs.remove/etc.
|
|
5
|
+
*/
|
|
6
|
+
import { defineVehicleOperation, bindVehicleOperation } from "@danypops/vehicle-core";
|
|
7
|
+
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
8
|
+
import { removeArtifactSubtree } from "../artifact-subtree.ts";
|
|
9
|
+
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
10
|
+
import type { ArtifactTrashStore } from "../ports/artifact-trash-store.ts";
|
|
11
|
+
import { looseObjectSchema, numberProp, passthroughOutput, stringProp } from "./artifact-vehicle-shared.ts";
|
|
12
|
+
|
|
13
|
+
const OWNER = "artifact";
|
|
14
|
+
const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
15
|
+
|
|
16
|
+
function eventContext(input: Record<string, unknown>): { actor?: string; source?: string; sessionId?: string } {
|
|
17
|
+
const actor = input["actor"];
|
|
18
|
+
const source = input["source"];
|
|
19
|
+
const sessionId = input["session_id"] ?? input["sessionId"];
|
|
20
|
+
return {
|
|
21
|
+
actor: typeof actor === "string" ? actor : undefined,
|
|
22
|
+
source: typeof source === "string" ? source : undefined,
|
|
23
|
+
sessionId: typeof sessionId === "string" ? sessionId : undefined,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function requireId(input: Record<string, unknown>): string {
|
|
28
|
+
const id = input["id"];
|
|
29
|
+
if (typeof id !== "string" || id.length === 0) throw new Error("id is required");
|
|
30
|
+
return id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function registerArtifactTrashOperations(registry: VehicleRegistry, artifacts: ArtifactStore & ArtifactTrashStore): void {
|
|
34
|
+
const define = (
|
|
35
|
+
action: string,
|
|
36
|
+
description: string,
|
|
37
|
+
effect: "read" | "local-write" | "destructive",
|
|
38
|
+
properties: Record<string, { type: string; enum?: readonly string[] }>,
|
|
39
|
+
required: readonly string[],
|
|
40
|
+
execute: (input: Record<string, unknown>) => unknown,
|
|
41
|
+
): void => {
|
|
42
|
+
const operation = defineVehicleOperation({
|
|
43
|
+
name: `artifact.${action}`,
|
|
44
|
+
version: 1,
|
|
45
|
+
description,
|
|
46
|
+
input: looseObjectSchema(properties, required),
|
|
47
|
+
output: passthroughOutput,
|
|
48
|
+
permissions: ["artifact:read", "artifact:write"],
|
|
49
|
+
effect,
|
|
50
|
+
idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
|
|
51
|
+
limits: LIMITS,
|
|
52
|
+
});
|
|
53
|
+
registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => execute(context.input)));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
define(
|
|
57
|
+
"show",
|
|
58
|
+
"Shows any artifact (doc, task, rule, skill, playbook) by id, regardless of kind.",
|
|
59
|
+
"read",
|
|
60
|
+
{ id: stringProp, tree: { type: "boolean" } as unknown as { type: string }, depth: numberProp, max_nodes: numberProp },
|
|
61
|
+
["id"],
|
|
62
|
+
(input) => artifacts.get(requireId(input), {
|
|
63
|
+
tree: input["tree"] === true,
|
|
64
|
+
depth: typeof input["depth"] === "number" ? input["depth"] : undefined,
|
|
65
|
+
maxNodes: typeof input["max_nodes"] === "number" ? input["max_nodes"] : undefined,
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
define(
|
|
70
|
+
"remove",
|
|
71
|
+
"Moves any artifact to a time-gated trash, excluded from list/query but still directly showable, restorable via artifact.restore until the purge deadline.",
|
|
72
|
+
"local-write",
|
|
73
|
+
{ id: stringProp, reason: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
74
|
+
["id"],
|
|
75
|
+
(input) => artifacts.trash(requireId(input), { reason: typeof input["reason"] === "string" ? input["reason"] : undefined, context: eventContext(input) }),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
define(
|
|
79
|
+
"remove_subtree",
|
|
80
|
+
"Trashes an artifact and its whole `contains` subtree in one call, skipping already-trashed nodes.",
|
|
81
|
+
"local-write",
|
|
82
|
+
{ id: stringProp, reason: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
83
|
+
["id"],
|
|
84
|
+
(input) => removeArtifactSubtree(artifacts, requireId(input), { reason: typeof input["reason"] === "string" ? input["reason"] : undefined, context: eventContext(input) }),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
define(
|
|
88
|
+
"restore",
|
|
89
|
+
"Restores a trashed artifact. Idempotent: restoring one that isn't trashed is a real no-op, not an error.",
|
|
90
|
+
"local-write",
|
|
91
|
+
{ id: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
92
|
+
["id"],
|
|
93
|
+
(input) => artifacts.restore(requireId(input), eventContext(input)),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared schema helpers and name->id resolution for every per-domain
|
|
3
|
+
* VehicleRegistry projection (notes-vehicle.ts, rules-vehicle.ts, docs-vehicle.ts,
|
|
4
|
+
* artifact-trash-vehicle.ts).
|
|
5
|
+
*/
|
|
6
|
+
import { defineVehicleSchema, type VehicleSchemaCodec } from "@danypops/vehicle-core";
|
|
7
|
+
import type { Artifact } from "../domain/artifact.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
|
|
11
|
+
* descriptive metadata surfaced to a client/Pi projection, never itself
|
|
12
|
+
* enforced at runtime -- so a declared `enum` has to be checked here for
|
|
13
|
+
* real, or it's a documentation gesture, not an honest contract.
|
|
14
|
+
*/
|
|
15
|
+
export function looseObjectSchema(properties: Record<string, { type: string; enum?: readonly string[] }>, required: readonly string[] = []): VehicleSchemaCodec<Record<string, unknown>> {
|
|
16
|
+
return defineVehicleSchema<Record<string, unknown>>({
|
|
17
|
+
jsonSchema: { type: "object", properties, required: [...required], additionalProperties: false },
|
|
18
|
+
safeParse(value) {
|
|
19
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
20
|
+
return { success: false, issues: [{ path: [], message: "input must be an object" }] };
|
|
21
|
+
}
|
|
22
|
+
const input = value as Record<string, unknown>;
|
|
23
|
+
for (const key of required) {
|
|
24
|
+
if (!(key in input)) return { success: false, issues: [{ path: [key], message: `${key} is required` }] };
|
|
25
|
+
}
|
|
26
|
+
for (const [key, schema] of Object.entries(properties)) {
|
|
27
|
+
if (!schema.enum || !(key in input)) continue;
|
|
28
|
+
if (!schema.enum.includes(input[key] as string)) {
|
|
29
|
+
return { success: false, issues: [{ path: [key], message: `${key} must be one of ${schema.enum.join(", ")}` }] };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { success: true, value: input };
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const passthroughOutput: VehicleSchemaCodec<unknown> = defineVehicleSchema<unknown>({
|
|
38
|
+
jsonSchema: { type: "object" },
|
|
39
|
+
safeParse: (value) => ({ success: true, value }),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const stringProp = { type: "string" } as const;
|
|
43
|
+
export const numberProp = { type: "number" } as const;
|
|
44
|
+
|
|
45
|
+
/** Exact match semantics as the Pi-extension helper this replaces (domain-tools.ts's matchArtifactByName) -- case-insensitive exact title match, refuses to guess between ambiguous matches. */
|
|
46
|
+
export function matchArtifactByName(candidates: readonly Artifact[], name: string): string {
|
|
47
|
+
const needle = name.trim().toLowerCase();
|
|
48
|
+
const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
|
|
49
|
+
if (matches.length === 0) throw new Error(`no artifact named "${name}" found in this scope`);
|
|
50
|
+
if (matches.length > 1) {
|
|
51
|
+
throw new Error(`${matches.length} artifacts are named "${name}": ${matches.map((a) => `${a.title} (${a.id})`).join(", ")} -- use id to disambiguate`);
|
|
52
|
+
}
|
|
53
|
+
return matches[0]!.id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolves a name to an id, retrying against `fetchWidened` (an unscoped/cross-project
|
|
58
|
+
* search) only when `fetchCandidates` finds nothing. Owns the match-or-widen control
|
|
59
|
+
* flow only -- the caller supplies its own scoped/widened list calls, since scoping
|
|
60
|
+
* differs per domain. Omit `fetchWidened` when there is no wider scope to retry.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveArtifactIdWidened(name: string, fetchCandidates: () => readonly Artifact[], fetchWidened?: () => readonly Artifact[]): string {
|
|
63
|
+
try {
|
|
64
|
+
return matchArtifactByName(fetchCandidates(), name);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (!(error instanceof Error) || !error.message.startsWith("no artifact named") || !fetchWidened) throw error;
|
|
67
|
+
return matchArtifactByName(fetchWidened(), name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docs projected as a real VehicleRegistry: one VehicleOperation per real action.
|
|
3
|
+
* Wraps modules/docs.ts's operation definitions. remove/restore/remove_subtree
|
|
4
|
+
* are not duplicated here -- see ./artifact-trash-vehicle.ts.
|
|
5
|
+
*/
|
|
6
|
+
import { defineVehicleOperation, bindVehicleOperation } from "@danypops/vehicle-core";
|
|
7
|
+
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
8
|
+
import type { AuthorityRegistry } from "../authority-registry.ts";
|
|
9
|
+
import { listDocuments } from "../domain-services.ts";
|
|
10
|
+
import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
|
|
11
|
+
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
12
|
+
import { docsOperations } from "../modules/docs.ts";
|
|
13
|
+
import { looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp } from "./artifact-vehicle-shared.ts";
|
|
14
|
+
|
|
15
|
+
const OWNER = "docs";
|
|
16
|
+
const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
17
|
+
|
|
18
|
+
function resolveDocId(artifacts: ArtifactStore, scopes: ArtifactScopeStore, projectRoot: string | undefined, id: unknown, name: unknown): string {
|
|
19
|
+
if (typeof id === "string" && id.length > 0) return id;
|
|
20
|
+
if (typeof name !== "string" || name.length === 0) throw new Error("id or name is required");
|
|
21
|
+
return resolveArtifactIdWidened(
|
|
22
|
+
name,
|
|
23
|
+
() => listDocuments(artifacts, scopes, { text: name, projectRoot }),
|
|
24
|
+
projectRoot === undefined ? undefined : () => listDocuments(artifacts, scopes, { text: name }),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Cross-kind resolution for a link target -- can be a doc, task, rule, or skill. Unscoped, matching the exact behavior of the artifact.query-backed resolution it replaces. */
|
|
29
|
+
function resolveTargetId(artifacts: ArtifactStore, id: unknown, name: unknown): string {
|
|
30
|
+
if (typeof id === "string" && id.length > 0) return id;
|
|
31
|
+
if (typeof name !== "string" || name.length === 0) throw new Error("target_id or target_name is required");
|
|
32
|
+
return resolveArtifactIdWidened(name, () => artifacts.query({ text: name }));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function registerDocsVehicleOperations(registry: VehicleRegistry, artifacts: ArtifactStore, scopes: ArtifactScopeStore, authority: AuthorityRegistry): void {
|
|
36
|
+
const moduleOperations = new Map(docsOperations(artifacts, scopes, authority).map((op) => [op.name, op]));
|
|
37
|
+
const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
|
|
38
|
+
|
|
39
|
+
const define = (
|
|
40
|
+
action: string,
|
|
41
|
+
description: string,
|
|
42
|
+
effect: "read" | "local-write",
|
|
43
|
+
properties: Record<string, { type: string; enum?: readonly string[] }>,
|
|
44
|
+
required: readonly string[],
|
|
45
|
+
resolve: (input: Record<string, unknown>) => Record<string, unknown>,
|
|
46
|
+
): void => {
|
|
47
|
+
const operation = defineVehicleOperation({
|
|
48
|
+
name: `docs.${action}`,
|
|
49
|
+
version: 1,
|
|
50
|
+
description,
|
|
51
|
+
input: looseObjectSchema(properties, required),
|
|
52
|
+
output: passthroughOutput,
|
|
53
|
+
permissions: ["docs:read", "docs:write"],
|
|
54
|
+
effect,
|
|
55
|
+
idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
|
|
56
|
+
limits: LIMITS,
|
|
57
|
+
});
|
|
58
|
+
registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => call(`docs.${action}`, resolve(context.input))));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
define(
|
|
62
|
+
"create",
|
|
63
|
+
"Creates a Doc -- descriptive knowledge, not actionable work. project_root is optional (omitted = unscoped).",
|
|
64
|
+
"local-write",
|
|
65
|
+
{ title: stringProp, body: stringProp, subtype: stringProp, labels: { type: "array" } as unknown as { type: string }, extra: { type: "object" } as unknown as { type: string }, template_id: stringProp, project_root: stringProp },
|
|
66
|
+
["title"],
|
|
67
|
+
(input) => input,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
define(
|
|
71
|
+
"list",
|
|
72
|
+
"Lists Docs matching an optional status/text filter, scoped to project_root when given.",
|
|
73
|
+
"read",
|
|
74
|
+
{ status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp },
|
|
75
|
+
[],
|
|
76
|
+
(input) => input,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
define(
|
|
80
|
+
"show",
|
|
81
|
+
"Shows one Doc by id or title.",
|
|
82
|
+
"read",
|
|
83
|
+
{ id: stringProp, name: stringProp, project_root: stringProp },
|
|
84
|
+
[],
|
|
85
|
+
(input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
define(
|
|
89
|
+
"activate",
|
|
90
|
+
"Activates a draft Doc.",
|
|
91
|
+
"local-write",
|
|
92
|
+
{ id: stringProp, name: stringProp, project_root: stringProp },
|
|
93
|
+
[],
|
|
94
|
+
(input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
define(
|
|
98
|
+
"archive",
|
|
99
|
+
"Archives an active Doc.",
|
|
100
|
+
"local-write",
|
|
101
|
+
{ id: stringProp, name: stringProp, project_root: stringProp },
|
|
102
|
+
[],
|
|
103
|
+
(input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
define(
|
|
107
|
+
"reopen",
|
|
108
|
+
"Reopens an archived Doc back to active.",
|
|
109
|
+
"local-write",
|
|
110
|
+
{ id: stringProp, name: stringProp, project_root: stringProp },
|
|
111
|
+
[],
|
|
112
|
+
(input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
define(
|
|
116
|
+
"link",
|
|
117
|
+
"Links a Doc to another artifact via a typed relation. Prefer target_name over target_id -- resolved server-side, searching every kind since a link target can be a doc, task, rule, or skill.",
|
|
118
|
+
"local-write",
|
|
119
|
+
{ id: stringProp, name: stringProp, relation: { type: "string", enum: ["references", "documents", "supersedes", "relates_to", "contains", "part_of"] }, target_id: stringProp, target_name: stringProp, project_root: stringProp },
|
|
120
|
+
["relation"],
|
|
121
|
+
(input) => ({
|
|
122
|
+
...input,
|
|
123
|
+
id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name),
|
|
124
|
+
target_id: resolveTargetId(artifacts, input.target_id, input.target_name),
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
define(
|
|
129
|
+
"assign_project",
|
|
130
|
+
"Reassigns a Doc's project_root, or unscopes it when project_root is omitted.",
|
|
131
|
+
"local-write",
|
|
132
|
+
{ id: stringProp, name: stringProp, project_root: stringProp },
|
|
133
|
+
[],
|
|
134
|
+
(input) => ({ ...input, id: resolveDocId(artifacts, scopes, undefined, input.id, input.name) }),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
define(
|
|
138
|
+
"update",
|
|
139
|
+
"Changes a Doc's title/body/labels (at least one required). Refused for a read-only external projection (e.g. web-spider-ingested Docs) -- capture a correction as a new linked Doc instead.",
|
|
140
|
+
"local-write",
|
|
141
|
+
{ id: stringProp, name: stringProp, title: stringProp, body: stringProp, labels: { type: "array" } as unknown as { type: string }, project_root: stringProp },
|
|
142
|
+
[],
|
|
143
|
+
(input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -1,97 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Notes projected as a real VehicleRegistry: one VehicleOperation per real
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* `notes(action=X)` mega-tool (unconstrained `action: Type.String()`, 14
|
|
6
|
-
* fields unioned across all 7 branches, the "God Parameters"/"Kitchen Sink
|
|
7
|
-
* tool" anti-pattern @danypops/vehicle's own README documents).
|
|
2
|
+
* Notes projected as a real VehicleRegistry: one VehicleOperation per real action
|
|
3
|
+
* (capture/list/show/history/consume/promote/archive), each with its own effect
|
|
4
|
+
* and narrow schema, instead of one `action: Type.String()` dispatch tool.
|
|
8
5
|
*
|
|
9
|
-
* Wraps modules/notes.ts's
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* one thing those definitions don't do: resolving a human-readable
|
|
13
|
-
* `name`/`target_name` to the `id`/`target_id` the domain logic actually
|
|
14
|
-
* needs, server-side in the same call. The Pi-extension-side
|
|
15
|
-
* `resolveNameFields` helper it replaces needed a separate round trip per
|
|
16
|
-
* name before the real call; this does it in one.
|
|
6
|
+
* Wraps modules/notes.ts's operation definitions rather than reimplementing their
|
|
7
|
+
* input parsing. Resolves `name`/`target_name` to `id`/`target_id` server-side, in
|
|
8
|
+
* the same call -- avoids a separate round trip per name before the real call.
|
|
17
9
|
*/
|
|
18
|
-
import { defineVehicleOperation,
|
|
19
|
-
import { VehicleRegistry } from "@danypops/vehicle-server";
|
|
10
|
+
import { defineVehicleOperation, bindVehicleOperation } from "@danypops/vehicle-core";
|
|
11
|
+
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
20
12
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
21
13
|
import { Notes, NOTE_DISPOSITIONS } from "../note-service.ts";
|
|
22
|
-
import type { Artifact } from "../domain/artifact.ts";
|
|
23
14
|
import { notesOperations } from "../modules/notes.ts";
|
|
15
|
+
import { looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp } from "./artifact-vehicle-shared.ts";
|
|
24
16
|
|
|
25
17
|
const OWNER = "notes";
|
|
26
18
|
|
|
27
19
|
const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
28
20
|
|
|
29
|
-
/**
|
|
30
|
-
* VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
|
|
31
|
-
* descriptive metadata surfaced to a client/Pi projection, never itself
|
|
32
|
-
* enforced at runtime -- so a declared `enum` has to be checked here for
|
|
33
|
-
* real, or it's a documentation gesture, not an honest contract.
|
|
34
|
-
*/
|
|
35
|
-
function looseObjectSchema(properties: Record<string, { type: string; enum?: readonly string[] }>, required: readonly string[] = []): VehicleSchemaCodec<Record<string, unknown>> {
|
|
36
|
-
return defineVehicleSchema<Record<string, unknown>>({
|
|
37
|
-
jsonSchema: { type: "object", properties, required: [...required], additionalProperties: false },
|
|
38
|
-
safeParse(value) {
|
|
39
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
40
|
-
return { success: false, issues: [{ path: [], message: "input must be an object" }] };
|
|
41
|
-
}
|
|
42
|
-
const input = value as Record<string, unknown>;
|
|
43
|
-
for (const key of required) {
|
|
44
|
-
if (!(key in input)) return { success: false, issues: [{ path: [key], message: `${key} is required` }] };
|
|
45
|
-
}
|
|
46
|
-
for (const [key, schema] of Object.entries(properties)) {
|
|
47
|
-
if (!schema.enum || !(key in input)) continue;
|
|
48
|
-
if (!schema.enum.includes(input[key] as string)) {
|
|
49
|
-
return { success: false, issues: [{ path: [key], message: `${key} must be one of ${schema.enum.join(", ")}` }] };
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return { success: true, value: input };
|
|
53
|
-
},
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const passthroughOutput = defineVehicleSchema<unknown>({
|
|
58
|
-
jsonSchema: { type: "object" },
|
|
59
|
-
safeParse: (value) => ({ success: true, value }),
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
/** Exact match semantics as the Pi-extension helper it replaces (domain-tools.ts's matchArtifactByName) -- case-insensitive exact title match, refuses to guess between ambiguous matches. */
|
|
63
|
-
function matchArtifactByName(candidates: readonly Artifact[], name: string): string {
|
|
64
|
-
const needle = name.trim().toLowerCase();
|
|
65
|
-
const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
|
|
66
|
-
if (matches.length === 0) throw new Error(`no artifact named "${name}" found in this scope`);
|
|
67
|
-
if (matches.length > 1) {
|
|
68
|
-
throw new Error(`${matches.length} artifacts are named "${name}": ${matches.map((a) => `${a.title} (${a.id})`).join(", ")} -- use id to disambiguate`);
|
|
69
|
-
}
|
|
70
|
-
return matches[0]!.id;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
21
|
/** Resolves a note's id from either an explicit id or its title within projectRoot. */
|
|
74
22
|
function resolveNoteId(notes: Notes, projectRoot: string, id: unknown, name: unknown): string {
|
|
75
23
|
if (typeof id === "string" && id.length > 0) return id;
|
|
76
24
|
if (typeof name !== "string" || name.length === 0) throw new Error("id or name is required");
|
|
77
|
-
return
|
|
25
|
+
return resolveArtifactIdWidened(name, () => notes.list({ projectRoot, text: name }));
|
|
78
26
|
}
|
|
79
27
|
|
|
80
28
|
/** Cross-kind equivalent for a promotion target -- a target can be a task, doc, rule, or skill, not just a note. Unscoped by project, matching the exact behavior of the artifact.query-backed resolution it replaces. */
|
|
81
29
|
function resolveArtifactId(artifacts: ArtifactStore, id: unknown, name: unknown): string {
|
|
82
30
|
if (typeof id === "string" && id.length > 0) return id;
|
|
83
31
|
if (typeof name !== "string" || name.length === 0) throw new Error("target_id or target_name is required");
|
|
84
|
-
return
|
|
32
|
+
return resolveArtifactIdWidened(name, () => artifacts.query({ text: name }));
|
|
85
33
|
}
|
|
86
34
|
|
|
87
35
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
36
|
+
* Registers every notes.* action as its own honest VehicleOperation onto an
|
|
37
|
+
* existing registry (see ./papyrus-vehicle.ts for the composition root that
|
|
38
|
+
* merges every domain's operations into one registry/one HTTP mount).
|
|
39
|
+
* `artifacts` is only needed for promote's cross-kind target_name
|
|
40
|
+
* resolution -- every other operation only ever touches notes themselves
|
|
41
|
+
* via `notes`.
|
|
92
42
|
*/
|
|
93
|
-
export function
|
|
94
|
-
const registry = new VehicleRegistry({ name: "papyrus-notes", version: "1.0.0", description: "Papyrus's deferred human-intent inbox." });
|
|
43
|
+
export function registerNotesVehicleOperations(registry: VehicleRegistry, notes: Notes, artifacts: ArtifactStore): void {
|
|
95
44
|
const moduleOperations = new Map(notesOperations(notes).map((op) => [op.name, op]));
|
|
96
45
|
const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
|
|
97
46
|
|
|
@@ -117,9 +66,6 @@ export function createNotesVehicleRegistry(notes: Notes, artifacts: ArtifactStor
|
|
|
117
66
|
registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => call(`notes.${action}`, resolve(context.input))));
|
|
118
67
|
};
|
|
119
68
|
|
|
120
|
-
const stringProp = { type: "string" } as const;
|
|
121
|
-
const numberProp = { type: "number" } as const;
|
|
122
|
-
|
|
123
69
|
define(
|
|
124
70
|
"capture",
|
|
125
71
|
"Stores a deferred request without creating work. Returns the created note.",
|
|
@@ -205,6 +151,4 @@ export function createNotesVehicleRegistry(notes: Notes, artifacts: ArtifactStor
|
|
|
205
151
|
["project_root", "disposition"],
|
|
206
152
|
(input) => ({ ...input, id: resolveNoteId(notes, input.project_root as string, input.id, input.name) }),
|
|
207
153
|
);
|
|
208
|
-
|
|
209
|
-
return registry;
|
|
210
154
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition root for every domain projected onto Vehicle -- one VehicleRegistry,
|
|
3
|
+
* one HTTP mount (see service.ts's createApp). Operation names are already globally
|
|
4
|
+
* unique via their own dotted prefix (notes.*, rules.*, docs.*, artifact.*), so
|
|
5
|
+
* merging costs nothing and avoids a separate registry/mount/client per domain.
|
|
6
|
+
*
|
|
7
|
+
* skills, playbooks, discuss, and tasks still register via pi-papyrus's own
|
|
8
|
+
* pi.registerTool() in domain-tools.ts, not here.
|
|
9
|
+
*/
|
|
10
|
+
import { VehicleRegistry } from "@danypops/vehicle-server";
|
|
11
|
+
import type { AuthorityRegistry } from "../authority-registry.ts";
|
|
12
|
+
import type { Notes } from "../note-service.ts";
|
|
13
|
+
import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
|
|
14
|
+
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
15
|
+
import type { ArtifactTrashStore } from "../ports/artifact-trash-store.ts";
|
|
16
|
+
import { registerArtifactTrashOperations } from "./artifact-trash-vehicle.ts";
|
|
17
|
+
import { registerDocsVehicleOperations } from "./docs-vehicle.ts";
|
|
18
|
+
import { registerNotesVehicleOperations } from "./notes-vehicle.ts";
|
|
19
|
+
import { registerRulesVehicleOperations } from "./rules-vehicle.ts";
|
|
20
|
+
|
|
21
|
+
export interface PapyrusVehicleDeps {
|
|
22
|
+
artifacts: ArtifactStore & ArtifactTrashStore;
|
|
23
|
+
scopes: ArtifactScopeStore;
|
|
24
|
+
authority: AuthorityRegistry;
|
|
25
|
+
notes: Notes;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleRegistry {
|
|
29
|
+
const registry = new VehicleRegistry({ name: "papyrus", version: "1.0.0", description: "Papyrus's graph-artifact domains, one honest operation per real action." });
|
|
30
|
+
registerNotesVehicleOperations(registry, deps.notes, deps.artifacts);
|
|
31
|
+
registerRulesVehicleOperations(registry, deps.artifacts, deps.scopes);
|
|
32
|
+
registerDocsVehicleOperations(registry, deps.artifacts, deps.scopes, deps.authority);
|
|
33
|
+
registerArtifactTrashOperations(registry, deps.artifacts);
|
|
34
|
+
return registry;
|
|
35
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rules projected as a real VehicleRegistry: one VehicleOperation per real action.
|
|
3
|
+
* Wraps modules/rules.ts's operation definitions (rules.injectable stays a
|
|
4
|
+
* composition-root-only concern, absent here too). remove/restore/remove_subtree
|
|
5
|
+
* are not duplicated here -- see ./artifact-trash-vehicle.ts.
|
|
6
|
+
*/
|
|
7
|
+
import { defineVehicleOperation, bindVehicleOperation } from "@danypops/vehicle-core";
|
|
8
|
+
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
9
|
+
import { listRules } from "../domain-services.ts";
|
|
10
|
+
import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
|
|
11
|
+
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
12
|
+
import { rulesOperations } from "../modules/rules.ts";
|
|
13
|
+
import { looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp } from "./artifact-vehicle-shared.ts";
|
|
14
|
+
|
|
15
|
+
const OWNER = "rules";
|
|
16
|
+
const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
17
|
+
|
|
18
|
+
/** Resolves a rule's id from either an explicit id or its title, widened past project_root when unscoped-vs-scoped search finds nothing. */
|
|
19
|
+
function resolveRuleId(artifacts: ArtifactStore, scopes: ArtifactScopeStore, projectRoot: string | undefined, id: unknown, name: unknown): string {
|
|
20
|
+
if (typeof id === "string" && id.length > 0) return id;
|
|
21
|
+
if (typeof name !== "string" || name.length === 0) throw new Error("id or name is required");
|
|
22
|
+
return resolveArtifactIdWidened(
|
|
23
|
+
name,
|
|
24
|
+
() => listRules(artifacts, scopes, { text: name, projectRoot }),
|
|
25
|
+
projectRoot === undefined ? undefined : () => listRules(artifacts, scopes, { text: name }),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolves a task's id from its title for rules.gate. No ambient cwd to default
|
|
31
|
+
* project_root to server-side -- pass project_root explicitly, or this searches
|
|
32
|
+
* unscoped.
|
|
33
|
+
*/
|
|
34
|
+
function resolveTaskId(artifacts: ArtifactStore, projectRoot: string | undefined, id: unknown, name: unknown): string | undefined {
|
|
35
|
+
if (typeof id === "string" && id.length > 0) return id;
|
|
36
|
+
if (typeof name !== "string" || name.length === 0) return undefined;
|
|
37
|
+
return resolveArtifactIdWidened(
|
|
38
|
+
name,
|
|
39
|
+
() => artifacts.query({ kind: "task", text: name }),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function registerRulesVehicleOperations(registry: VehicleRegistry, artifacts: ArtifactStore, scopes: ArtifactScopeStore): void {
|
|
44
|
+
const moduleOperations = new Map(rulesOperations(artifacts, scopes).map((op) => [op.name, op]));
|
|
45
|
+
const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
|
|
46
|
+
|
|
47
|
+
const define = (
|
|
48
|
+
action: string,
|
|
49
|
+
description: string,
|
|
50
|
+
effect: "read" | "local-write",
|
|
51
|
+
properties: Record<string, { type: string; enum?: readonly string[] }>,
|
|
52
|
+
required: readonly string[],
|
|
53
|
+
resolve: (input: Record<string, unknown>) => Record<string, unknown>,
|
|
54
|
+
): void => {
|
|
55
|
+
const operation = defineVehicleOperation({
|
|
56
|
+
name: `rules.${action}`,
|
|
57
|
+
version: 1,
|
|
58
|
+
description,
|
|
59
|
+
input: looseObjectSchema(properties, required),
|
|
60
|
+
output: passthroughOutput,
|
|
61
|
+
permissions: ["rules:read", "rules:write"],
|
|
62
|
+
effect,
|
|
63
|
+
idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
|
|
64
|
+
limits: LIMITS,
|
|
65
|
+
});
|
|
66
|
+
registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => call(`rules.${action}`, resolve(context.input))));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
define(
|
|
70
|
+
"create",
|
|
71
|
+
"Creates a Rule -- a standing constraint injected into the agent system prompt while active. project_root is optional (omitted = unscoped).",
|
|
72
|
+
"local-write",
|
|
73
|
+
{ title: stringProp, body: stringProp, condition: stringProp, rule_action: stringProp, severity: { type: "string", enum: ["block", "warn", "info"] }, labels: { type: "array" } as unknown as { type: string }, extra: { type: "object" } as unknown as { type: string }, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
74
|
+
["title"],
|
|
75
|
+
(input) => input,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
define(
|
|
79
|
+
"list",
|
|
80
|
+
"Lists Rules matching an optional status/text filter, scoped to project_root when given.",
|
|
81
|
+
"read",
|
|
82
|
+
{ status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp },
|
|
83
|
+
[],
|
|
84
|
+
(input) => input,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
define(
|
|
88
|
+
"show",
|
|
89
|
+
"Shows one Rule by id or title.",
|
|
90
|
+
"read",
|
|
91
|
+
{ id: stringProp, name: stringProp, project_root: stringProp },
|
|
92
|
+
[],
|
|
93
|
+
(input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
define(
|
|
97
|
+
"preview",
|
|
98
|
+
"Renders a Rule's own condition/action/body preview text with no side effects.",
|
|
99
|
+
"read",
|
|
100
|
+
{ id: stringProp, name: stringProp, project_root: stringProp },
|
|
101
|
+
[],
|
|
102
|
+
(input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
define(
|
|
106
|
+
"enable",
|
|
107
|
+
"Enables a Rule so it starts injecting into the agent system prompt.",
|
|
108
|
+
"local-write",
|
|
109
|
+
{ id: stringProp, name: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
110
|
+
[],
|
|
111
|
+
(input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
define(
|
|
115
|
+
"disable",
|
|
116
|
+
"Disables a Rule; it stops injecting into the agent system prompt.",
|
|
117
|
+
"local-write",
|
|
118
|
+
{ id: stringProp, name: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
119
|
+
[],
|
|
120
|
+
(input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
define(
|
|
124
|
+
"gate",
|
|
125
|
+
"Attaches a Rule as a gate condition on a Task. Prefer task_name over task_id -- resolved server-side (unscoped if project_root is omitted, since there is no ambient cwd to default to here).",
|
|
126
|
+
"local-write",
|
|
127
|
+
{ id: stringProp, name: stringProp, task_id: stringProp, task_name: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
128
|
+
[],
|
|
129
|
+
(input) => {
|
|
130
|
+
const taskId = resolveTaskId(artifacts, input.project_root as string | undefined, input.task_id, input.task_name);
|
|
131
|
+
if (!taskId) throw new Error("task_id or task_name is required");
|
|
132
|
+
return { ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name), task_id: taskId };
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
define(
|
|
137
|
+
"assign_project",
|
|
138
|
+
"Reassigns a Rule's project_root, or unscopes it when project_root is omitted.",
|
|
139
|
+
"local-write",
|
|
140
|
+
{ id: stringProp, name: stringProp, project_root: stringProp },
|
|
141
|
+
[],
|
|
142
|
+
(input) => ({ ...input, id: resolveRuleId(artifacts, scopes, undefined, input.id, input.name) }),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
define(
|
|
146
|
+
"update",
|
|
147
|
+
"Changes a Rule's title/body/labels (at least one required). Body updates still enforce the same combined condition+action+body context-tax bound as creation.",
|
|
148
|
+
"local-write",
|
|
149
|
+
{ id: stringProp, name: stringProp, title: stringProp, body: stringProp, labels: { type: "array" } as unknown as { type: string }, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
150
|
+
[],
|
|
151
|
+
(input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
152
|
+
);
|
|
153
|
+
}
|