@danypops/papyrus 0.37.0 → 0.38.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 +4 -2
- package/src/artifact-subtree.ts +54 -0
- package/src/cli.ts +9 -1
- package/src/client.ts +19 -0
- package/src/constants.ts +2 -0
- package/src/index.ts +1 -1
- package/src/service.ts +20 -1
- package/src/vehicle/notes-vehicle.ts +210 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.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"],
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
},
|
|
35
35
|
"files": ["src", "README.md"],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@danypops/daemon-kit": "^0.10.0"
|
|
37
|
+
"@danypops/daemon-kit": "^0.10.0",
|
|
38
|
+
"@danypops/vehicle-core": "^0.1.0",
|
|
39
|
+
"@danypops/vehicle-server": "^0.1.0"
|
|
38
40
|
}
|
|
39
41
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bulk trash of a `contains` subtree, any artifact kind -- a whole materialized Task run
|
|
3
|
+
* (root container + steps + nested playbook children) or a Playbook's own nested-playbook
|
|
4
|
+
* tree can be moved to trash in one call instead of enumerating every id by hand. Mirrors
|
|
5
|
+
* Tasks.cancelSubtree's traversal shape but performs trash(), not a lifecycle transition,
|
|
6
|
+
* so it applies to any kind that participates in `contains` (task, playbook), not just Task.
|
|
7
|
+
*/
|
|
8
|
+
import { ARTIFACT_REMOVE_SUBTREE_MAX_NODES } from "./constants.ts";
|
|
9
|
+
import type { ArtifactEventContext } from "./domain/artifact-event.ts";
|
|
10
|
+
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
11
|
+
import type { ArtifactTrashStore } from "./ports/artifact-trash-store.ts";
|
|
12
|
+
|
|
13
|
+
export interface RemoveSubtreeResult {
|
|
14
|
+
removed: string[];
|
|
15
|
+
/** Already trashed -- a real no-op, not an error, matching trash()/restore()'s own idempotence elsewhere. */
|
|
16
|
+
skipped: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Trashes `id` and every artifact reachable by following `contains` edges outward from it,
|
|
21
|
+
* transitively. An already-trashed node is skipped rather than re-trashed (trash() itself is
|
|
22
|
+
* idempotent, but skipping keeps the result's `removed` list meaningful -- only nodes newly
|
|
23
|
+
* moved to trash by this call). A node that is the live Task Focus in some scope still throws
|
|
24
|
+
* (the same guard trash() always enforces for a single artifact) rather than being silently
|
|
25
|
+
* skipped -- an active Focus is a real conflict to surface, not routine already-done state.
|
|
26
|
+
*/
|
|
27
|
+
export function removeArtifactSubtree(
|
|
28
|
+
store: ArtifactStore & ArtifactTrashStore,
|
|
29
|
+
id: string,
|
|
30
|
+
options: { reason?: string; context?: ArtifactEventContext } = {},
|
|
31
|
+
): RemoveSubtreeResult {
|
|
32
|
+
if (!store.get(id)) throw new Error(`artifact "${id}" not found`);
|
|
33
|
+
const visited = new Set<string>();
|
|
34
|
+
const queue = [id];
|
|
35
|
+
const removed: string[] = [];
|
|
36
|
+
const skipped: string[] = [];
|
|
37
|
+
while (queue.length > 0) {
|
|
38
|
+
const current = queue.shift()!;
|
|
39
|
+
if (visited.has(current)) continue;
|
|
40
|
+
visited.add(current);
|
|
41
|
+
if (visited.size > ARTIFACT_REMOVE_SUBTREE_MAX_NODES) throw new Error(`remove_subtree exceeds ${ARTIFACT_REMOVE_SUBTREE_MAX_NODES} artifacts`);
|
|
42
|
+
const childIds = store.relationships({ artifactIds: [current] })
|
|
43
|
+
.filter((edge) => edge.from === current && edge.relation === "contains")
|
|
44
|
+
.map((edge) => edge.to);
|
|
45
|
+
queue.push(...childIds);
|
|
46
|
+
if (store.trashStatus(current)) {
|
|
47
|
+
skipped.push(current);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
store.trash(current, { reason: options.reason, context: options.context });
|
|
51
|
+
removed.push(current);
|
|
52
|
+
}
|
|
53
|
+
return { removed, skipped };
|
|
54
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -83,6 +83,7 @@ const USAGE = `Usage:
|
|
|
83
83
|
papyrus artifact query [--kind <kind>] [--status <status>] [--text <query>] [--limit <count>] [--json]
|
|
84
84
|
papyrus artifact show <id> [--depth <n>] [--max-nodes <n>] [--json]
|
|
85
85
|
papyrus artifact remove <id> [--reason <text>] [--json]
|
|
86
|
+
papyrus artifact remove-subtree <id> [--reason <text>] [--json]
|
|
86
87
|
papyrus artifact restore <id> [--json]
|
|
87
88
|
papyrus artifact trash-status <id> [--json]
|
|
88
89
|
papyrus artifact trash-list [--json]
|
|
@@ -1023,6 +1024,13 @@ export async function runArtifactCli(args: string[], client: TaskCliClient, proj
|
|
|
1023
1024
|
human = `Trashed ${record.artifactId}: eligible for purge at ${record.purgeAfter}`;
|
|
1024
1025
|
break;
|
|
1025
1026
|
}
|
|
1027
|
+
case "remove-subtree": {
|
|
1028
|
+
if (!id) throw new Error("artifact remove-subtree requires exactly one artifact id");
|
|
1029
|
+
const outcome = await client.call<Record<string, unknown>, { removed: string[]; skipped: string[] }>("artifact.remove_subtree", { id, reason });
|
|
1030
|
+
result = outcome;
|
|
1031
|
+
human = `Trashed ${outcome.removed.length} artifact(s)${outcome.skipped.length > 0 ? `, skipped ${outcome.skipped.length} already-trashed` : ""}.`;
|
|
1032
|
+
break;
|
|
1033
|
+
}
|
|
1026
1034
|
case "restore": {
|
|
1027
1035
|
if (!id) throw new Error("artifact restore requires exactly one artifact id");
|
|
1028
1036
|
const outcome = await client.call<Record<string, unknown>, { restored: boolean }>("artifact.restore", { id });
|
|
@@ -1045,7 +1053,7 @@ export async function runArtifactCli(args: string[], client: TaskCliClient, proj
|
|
|
1045
1053
|
break;
|
|
1046
1054
|
}
|
|
1047
1055
|
default:
|
|
1048
|
-
throw new Error("artifact action must be create, query, show, remove, restore, trash-status, or trash-list");
|
|
1056
|
+
throw new Error("artifact action must be create, query, show, remove, remove-subtree, restore, trash-status, or trash-list");
|
|
1049
1057
|
}
|
|
1050
1058
|
return json ? JSON.stringify(result) : human;
|
|
1051
1059
|
}
|
package/src/client.ts
CHANGED
|
@@ -78,3 +78,22 @@ export function resolvePushChannelTarget(dir: string = daemonStateDir()): PushCh
|
|
|
78
78
|
if (!handle) return undefined;
|
|
79
79
|
return { url: `${handle.baseUrl.replace(/^http/, "ws")}/push`, token: handle.token };
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
export interface VehicleClientTarget {
|
|
83
|
+
/** Base URL for a domain migrated onto VehicleRegistry (see src/vehicle/*.ts) -- @danypops/vehicle-client's RemoteVehicleClient mounts its own /vehicle/manifest, /vehicle/invoke, /vehicle/cancel routes under this. */
|
|
84
|
+
baseUrl: string;
|
|
85
|
+
token: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Narrow surface for a Vehicle-projected domain consumer -- same daemon, same
|
|
90
|
+
* handle file, same Bearer token every other Papyrus RPC call already uses (see
|
|
91
|
+
* service.ts's createApp, which mounts the Vehicle HTTP app at /vehicle/* on
|
|
92
|
+
* this same port). Returns undefined rather than throwing when the daemon has
|
|
93
|
+
* never started, matching resolvePushChannelTarget's own tolerance.
|
|
94
|
+
*/
|
|
95
|
+
export function resolveVehicleClientTarget(dir: string = daemonStateDir()): VehicleClientTarget | undefined {
|
|
96
|
+
const handle = readDaemonHandle(dir);
|
|
97
|
+
if (!handle) return undefined;
|
|
98
|
+
return { baseUrl: handle.baseUrl, token: handle.token };
|
|
99
|
+
}
|
package/src/constants.ts
CHANGED
|
@@ -123,6 +123,8 @@ export const PLAYBOOK_INVOCATION_MAX_CREATED_TASKS = 200;
|
|
|
123
123
|
|
|
124
124
|
/** Tasks.cancelSubtree walks `contains` edges transitively (a whole materialized playbook run can be torn down in one call instead of enumerating every task id by hand) -- bounded the same way PLAYBOOK_INVOCATION_MAX_CREATED_TASKS bounds the forward direction. */
|
|
125
125
|
export const TASK_CANCEL_SUBTREE_MAX_NODES = 500;
|
|
126
|
+
/** artifact.remove_subtree walks `contains` transitively across any artifact kind (a task tree, or a playbook's own nested-playbook children) -- same bound rationale as TASK_CANCEL_SUBTREE_MAX_NODES, kept separate since the two traversals serve different operations. */
|
|
127
|
+
export const ARTIFACT_REMOVE_SUBTREE_MAX_NODES = 500;
|
|
126
128
|
|
|
127
129
|
/**
|
|
128
130
|
* At the core, a workflow Skill creates Tasks and begins a pipeline -- an Ansible playbook or
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ export type { TaskViewSelection } from "./domain/task-scope.ts";
|
|
|
19
19
|
export type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
20
20
|
export type { GraphRenderer } from "./ports/graph-renderer.ts";
|
|
21
21
|
|
|
22
|
-
export { connectPapyrusClient, resolvePushChannelTarget, type PapyrusClient, type PushChannelTarget } from "./client.ts";
|
|
22
|
+
export { connectPapyrusClient, resolvePushChannelTarget, resolveVehicleClientTarget, type PapyrusClient, type PushChannelTarget, type VehicleClientTarget } from "./client.ts";
|
|
23
23
|
export type { DiscussionAndRounds } from "./discussion-service.ts";
|
|
24
24
|
export { NOTE_DISPOSITIONS } from "./note-service.ts";
|
|
25
25
|
export type { OperationName, SchemaState } from "./service.ts";
|
package/src/service.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SERVICE_MAX_BODY_BYTES, SQLITE_SCHEMA_VERSION } from "./constants.ts";
|
|
2
2
|
import { VERSION } from "./version.ts";
|
|
3
3
|
import { migrateDb, openDb, schemaVersion } from "./db.ts";
|
|
4
|
+
import { removeArtifactSubtree } from "./artifact-subtree.ts";
|
|
4
5
|
import { SQLiteArtifactStore } from "./adapters/sqlite-artifact-store.ts";
|
|
5
6
|
import { SQLiteGateRunner } from "./adapters/sqlite-gate-runner.ts";
|
|
6
7
|
import { SQLiteArtifactScopeStore } from "./adapters/sqlite-artifact-scope-store.ts";
|
|
@@ -27,6 +28,9 @@ import {
|
|
|
27
28
|
listInjectableRules,
|
|
28
29
|
} from "./domain-services.ts";
|
|
29
30
|
import { Notes, NOTE_SUBTYPE } from "./note-service.ts";
|
|
31
|
+
import { createNotesVehicleRegistry } from "./vehicle/notes-vehicle.ts";
|
|
32
|
+
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
33
|
+
import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
|
|
30
34
|
import { Logs } from "./log-service.ts";
|
|
31
35
|
import { SQLiteLogStore } from "./adapters/sqlite-log-store.ts";
|
|
32
36
|
import { SessionIdentity, InvalidSessionSecretError } from "./session-identity-service.ts";
|
|
@@ -57,7 +61,7 @@ import { SQLiteDiscussionRoundStore } from "./adapters/sqlite-discussion-round-s
|
|
|
57
61
|
*/
|
|
58
62
|
const COMPOSITION_ROOT_OPERATION_NAMES = [
|
|
59
63
|
"system.migrate", "artifact.create", "artifact.query", "artifact.show",
|
|
60
|
-
"artifact.remove", "artifact.restore", "artifact.trash_status", "artifact.trash_list",
|
|
64
|
+
"artifact.remove", "artifact.remove_subtree", "artifact.restore", "artifact.trash_status", "artifact.trash_list",
|
|
61
65
|
"graph.link", "graph.unlink", "graph.tree", "graph.status", "graph.history", "gates.run",
|
|
62
66
|
"rules.injectable", "skills.instantiate",
|
|
63
67
|
] as const;
|
|
@@ -203,6 +207,13 @@ export interface PapyrusService {
|
|
|
203
207
|
operationNames(): OperationName[];
|
|
204
208
|
schemaState(): SchemaState;
|
|
205
209
|
execute(operation: string, input?: OperationInput): Promise<unknown>;
|
|
210
|
+
/**
|
|
211
|
+
* Notes projected as a real VehicleRegistry (see ./vehicle/notes-vehicle.ts) --
|
|
212
|
+
* one honest VehicleOperation per real action, replacing the Pi extension's old
|
|
213
|
+
* `notes(action=X)` mega-tool. The first domain migrated this way; not every
|
|
214
|
+
* domain has one yet.
|
|
215
|
+
*/
|
|
216
|
+
readonly notesVehicle: VehicleRegistry;
|
|
206
217
|
checkpoint(): void;
|
|
207
218
|
optimize(): void;
|
|
208
219
|
/** Time-based Task Focus reclamation (see Tasks.reapStaleFocus); returns how many rows were removed, for daemon logging. */
|
|
@@ -280,6 +291,7 @@ function handlers(
|
|
|
280
291
|
maxNodes: optionalNumber(input, "max_nodes") ?? optionalNumber(input, "maxNodes"),
|
|
281
292
|
}),
|
|
282
293
|
"artifact.remove": (input) => artifacts.trash(string(input, "id"), { reason: optionalString(input, "reason"), context: eventContext(input) }),
|
|
294
|
+
"artifact.remove_subtree": (input) => removeArtifactSubtree(artifacts, string(input, "id"), { reason: optionalString(input, "reason"), context: eventContext(input) }),
|
|
283
295
|
"artifact.restore": (input) => artifacts.restore(string(input, "id"), eventContext(input)),
|
|
284
296
|
"artifact.trash_status": (input) => artifacts.trashStatus(string(input, "id")),
|
|
285
297
|
"artifact.trash_list": () => artifacts.listTrash(),
|
|
@@ -467,6 +479,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
467
479
|
const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases);
|
|
468
480
|
const noteEvents = new SQLiteNoteEventStore(db);
|
|
469
481
|
const notes = new Notes(artifacts, noteEvents);
|
|
482
|
+
const notesVehicle = createNotesVehicleRegistry(notes, artifacts);
|
|
470
483
|
const projections = new SQLiteGraphProjectionStore(db);
|
|
471
484
|
const artifactScopes = new SQLiteArtifactScopeStore(db);
|
|
472
485
|
const logs = new Logs(new SQLiteLogStore(db));
|
|
@@ -492,6 +505,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
492
505
|
return {
|
|
493
506
|
operationNames: () => [...EXPECTED_OPERATION_NAMES],
|
|
494
507
|
schemaState: state,
|
|
508
|
+
notesVehicle,
|
|
495
509
|
async execute(operation, input = {}) {
|
|
496
510
|
const handler = registry[operation as OperationName];
|
|
497
511
|
if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
|
|
@@ -551,12 +565,17 @@ export function createApp(deps: {
|
|
|
551
565
|
*/
|
|
552
566
|
onOperationExecuted?: (operation: string, input: OperationInput) => void;
|
|
553
567
|
}): { fetch(request: Request): Promise<Response> } {
|
|
568
|
+
// Same Bearer token as the rest of this API -- a Vehicle-projected domain (see
|
|
569
|
+
// ./vehicle/notes-vehicle.ts) rides the same daemon, same auth, same port; it is
|
|
570
|
+
// not a second service to stand up or authenticate against separately.
|
|
571
|
+
const vehicleApp = createVehicleHttpApp({ registry: deps.service.notesVehicle, token: deps.token });
|
|
554
572
|
return {
|
|
555
573
|
async fetch(request: Request): Promise<Response> {
|
|
556
574
|
if (request.headers.get("authorization") !== `Bearer ${deps.token}`) {
|
|
557
575
|
return json({ error: "missing or invalid bearer token" }, { status: 401 });
|
|
558
576
|
}
|
|
559
577
|
const url = new URL(request.url);
|
|
578
|
+
if (url.pathname.startsWith("/vehicle/")) return vehicleApp.fetch(request);
|
|
560
579
|
if (request.method === "GET" && url.pathname === "/health") {
|
|
561
580
|
return json({ ok: true, version: VERSION, schema: deps.service.schemaState() });
|
|
562
581
|
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notes projected as a real VehicleRegistry: one VehicleOperation per real
|
|
3
|
+
* action (capture/list/show/history/consume/promote/archive), each with its
|
|
4
|
+
* own honest effect and narrow schema -- replacing pi-papyrus's hand-rolled
|
|
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).
|
|
8
|
+
*
|
|
9
|
+
* Wraps modules/notes.ts's existing operation definitions rather than
|
|
10
|
+
* reimplementing their input parsing -- this is a projection/contract layer
|
|
11
|
+
* on top of the existing domain logic, not a second copy of it. Adds the
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
import { defineVehicleOperation, defineVehicleSchema, bindVehicleOperation, type VehicleSchemaCodec } from "@danypops/vehicle-core";
|
|
19
|
+
import { VehicleRegistry } from "@danypops/vehicle-server";
|
|
20
|
+
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
21
|
+
import { Notes, NOTE_DISPOSITIONS } from "../note-service.ts";
|
|
22
|
+
import type { Artifact } from "../domain/artifact.ts";
|
|
23
|
+
import { notesOperations } from "../modules/notes.ts";
|
|
24
|
+
|
|
25
|
+
const OWNER = "notes";
|
|
26
|
+
|
|
27
|
+
const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
28
|
+
|
|
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
|
+
/** Resolves a note's id from either an explicit id or its title within projectRoot. */
|
|
74
|
+
function resolveNoteId(notes: Notes, projectRoot: string, id: unknown, name: unknown): string {
|
|
75
|
+
if (typeof id === "string" && id.length > 0) return id;
|
|
76
|
+
if (typeof name !== "string" || name.length === 0) throw new Error("id or name is required");
|
|
77
|
+
return matchArtifactByName(notes.list({ projectRoot, text: name }), name);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 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
|
+
function resolveArtifactId(artifacts: ArtifactStore, id: unknown, name: unknown): string {
|
|
82
|
+
if (typeof id === "string" && id.length > 0) return id;
|
|
83
|
+
if (typeof name !== "string" || name.length === 0) throw new Error("target_id or target_name is required");
|
|
84
|
+
return matchArtifactByName(artifacts.query({ text: name }), name);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Builds a VehicleRegistry exposing every notes.* action as its own honest
|
|
89
|
+
* operation. `artifacts` is only needed for promote's cross-kind
|
|
90
|
+
* target_name resolution -- every other operation only ever touches notes
|
|
91
|
+
* themselves via `notes`.
|
|
92
|
+
*/
|
|
93
|
+
export function createNotesVehicleRegistry(notes: Notes, artifacts: ArtifactStore): VehicleRegistry {
|
|
94
|
+
const registry = new VehicleRegistry({ name: "papyrus-notes", version: "1.0.0", description: "Papyrus's deferred human-intent inbox." });
|
|
95
|
+
const moduleOperations = new Map(notesOperations(notes).map((op) => [op.name, op]));
|
|
96
|
+
const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
|
|
97
|
+
|
|
98
|
+
const define = (
|
|
99
|
+
action: string,
|
|
100
|
+
description: string,
|
|
101
|
+
effect: "read" | "local-write",
|
|
102
|
+
properties: Record<string, { type: string; enum?: readonly string[] }>,
|
|
103
|
+
required: readonly string[],
|
|
104
|
+
resolve: (input: Record<string, unknown>) => Record<string, unknown>,
|
|
105
|
+
): void => {
|
|
106
|
+
const operation = defineVehicleOperation({
|
|
107
|
+
name: `notes.${action}`,
|
|
108
|
+
version: 1,
|
|
109
|
+
description,
|
|
110
|
+
input: looseObjectSchema(properties, required),
|
|
111
|
+
output: passthroughOutput,
|
|
112
|
+
permissions: ["notes:read", "notes:write"],
|
|
113
|
+
effect,
|
|
114
|
+
idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
|
|
115
|
+
limits: LIMITS,
|
|
116
|
+
});
|
|
117
|
+
registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => call(`notes.${action}`, resolve(context.input))));
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const stringProp = { type: "string" } as const;
|
|
121
|
+
const numberProp = { type: "number" } as const;
|
|
122
|
+
|
|
123
|
+
define(
|
|
124
|
+
"capture",
|
|
125
|
+
"Stores a deferred request without creating work. Returns the created note.",
|
|
126
|
+
"local-write",
|
|
127
|
+
{ body: stringProp, title: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
128
|
+
["body", "project_root"],
|
|
129
|
+
(input) => input,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
define(
|
|
133
|
+
"list",
|
|
134
|
+
"Lists open (draft/active) notes, or a specific status, in a project.",
|
|
135
|
+
"read",
|
|
136
|
+
{ project_root: stringProp, status: { type: "string", enum: ["draft", "active", "archived"] }, text: stringProp, limit: numberProp },
|
|
137
|
+
["project_root"],
|
|
138
|
+
(input) => input,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
define(
|
|
142
|
+
"show",
|
|
143
|
+
"Shows one note by id or title.",
|
|
144
|
+
"read",
|
|
145
|
+
{ id: stringProp, name: stringProp, project_root: stringProp },
|
|
146
|
+
["project_root"],
|
|
147
|
+
(input) => ({ ...input, id: resolveNoteId(notes, input.project_root as string, input.id, input.name) }),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
define(
|
|
151
|
+
"history",
|
|
152
|
+
"This note's own real append-only event log (captured/consumed/promoted/archived).",
|
|
153
|
+
"read",
|
|
154
|
+
{ id: stringProp, name: stringProp, project_root: stringProp, limit: numberProp, cursor: numberProp, direction: { type: "string", enum: ["asc", "desc"] } },
|
|
155
|
+
["project_root"],
|
|
156
|
+
(input) => ({ ...input, id: resolveNoteId(notes, input.project_root as string, input.id, input.name) }),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
define(
|
|
160
|
+
"consume",
|
|
161
|
+
"Marks a note as considered.",
|
|
162
|
+
"local-write",
|
|
163
|
+
{ id: stringProp, name: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp, reason: stringProp },
|
|
164
|
+
["project_root"],
|
|
165
|
+
(input) => ({ ...input, id: resolveNoteId(notes, input.project_root as string, input.id, input.name) }),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
define(
|
|
169
|
+
"promote",
|
|
170
|
+
"Links a note to the Task, Doc, Rule, or Skill it was promoted into, then archives it.",
|
|
171
|
+
"local-write",
|
|
172
|
+
{
|
|
173
|
+
id: stringProp,
|
|
174
|
+
name: stringProp,
|
|
175
|
+
target_id: stringProp,
|
|
176
|
+
target_name: stringProp,
|
|
177
|
+
project_root: stringProp,
|
|
178
|
+
actor: stringProp,
|
|
179
|
+
source: stringProp,
|
|
180
|
+
session_id: stringProp,
|
|
181
|
+
reason: stringProp,
|
|
182
|
+
},
|
|
183
|
+
["project_root"],
|
|
184
|
+
(input) => ({
|
|
185
|
+
...input,
|
|
186
|
+
id: resolveNoteId(notes, input.project_root as string, input.id, input.name),
|
|
187
|
+
target_id: resolveArtifactId(artifacts, input.target_id, input.target_name),
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
define(
|
|
192
|
+
"archive",
|
|
193
|
+
"Archives a note with an explicit disposition.",
|
|
194
|
+
"local-write",
|
|
195
|
+
{
|
|
196
|
+
id: stringProp,
|
|
197
|
+
name: stringProp,
|
|
198
|
+
project_root: stringProp,
|
|
199
|
+
disposition: { type: "string", enum: [...NOTE_DISPOSITIONS] },
|
|
200
|
+
actor: stringProp,
|
|
201
|
+
source: stringProp,
|
|
202
|
+
session_id: stringProp,
|
|
203
|
+
reason: stringProp,
|
|
204
|
+
},
|
|
205
|
+
["project_root", "disposition"],
|
|
206
|
+
(input) => ({ ...input, id: resolveNoteId(notes, input.project_root as string, input.id, input.name) }),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
return registry;
|
|
210
|
+
}
|