@danypops/papyrus 0.38.0 → 0.38.2
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 +3 -2
- package/src/cli.ts +38 -23
- package/src/client.ts +20 -1
- package/src/daemon.ts +1 -1
- package/src/db.ts +5 -4
- package/src/domain/session-identity.ts +6 -4
- package/src/index.ts +1 -1
- package/src/ports/session-identity-store.ts +4 -4
- package/src/service.ts +17 -0
- package/src/session-identity-service.ts +2 -2
- 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.38.
|
|
3
|
+
"version": "0.38.2",
|
|
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,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"files": ["src", "README.md"],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@danypops/
|
|
37
|
+
"@danypops/vehicle-core": "^0.1.1",
|
|
38
|
+
"@danypops/vehicle-server": "^0.3.2"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { copyFileSync, existsSync,
|
|
3
|
+
import { copyFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import {
|
|
5
|
+
import { join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { createNodeServiceInstallDeps, generateSystemdUnit, installUserService, type ServiceSpec } from "@danypops/vehicle-server/service";
|
|
7
8
|
import { connectPapyrusClient, type PapyrusClient } from "./client.ts";
|
|
8
9
|
import { DAEMON_UNIT_NAME, TASK_EXECUTION_MAX_NODES, dbPath } from "./constants.ts";
|
|
9
10
|
import { serveMain } from "./daemon.ts";
|
|
@@ -18,20 +19,36 @@ export interface SystemdUnitOptions {
|
|
|
18
19
|
cliPath: string;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Pure text generator, delegating to vehicle-server's shared generateSystemdUnit -- kept as its
|
|
24
|
+
* own named export with the same options shape since this package's own tests (and any external
|
|
25
|
+
* caller) call it directly.
|
|
26
|
+
*
|
|
27
|
+
* Papyrus's own daemon.ts does not (yet) use vehicle-server's startDaemon/runDaemonProcess -- it's
|
|
28
|
+
* a bespoke Bun.serve() with its own state-file layout (daemon-state.ts) and maintenance-timer
|
|
29
|
+
* scheduling, predating that shared substrate. Only unit *generation* is migrated here; unitPath()
|
|
30
|
+
* stays a manual XDG_CONFIG_HOME construction rather than pulling in resolveDaemonPaths() for a
|
|
31
|
+
* database/token/handle layout Papyrus doesn't actually use. DAEMON_KIT_LAUNCH_PROVENANCE=service
|
|
32
|
+
* is emitted (generateSystemdUnit always adds it) but is currently inert -- Papyrus's daemon never
|
|
33
|
+
* reads it, since it has no idle-shutdown concept of its own. Migrating daemon.ts's own substrate
|
|
34
|
+
* is tracked separately.
|
|
35
|
+
*/
|
|
36
|
+
function papyrusServiceSpec(options: SystemdUnitOptions): ServiceSpec {
|
|
37
|
+
return {
|
|
38
|
+
name: "papyrus",
|
|
39
|
+
displayName: "Papyrus graph artifact service",
|
|
40
|
+
binPath: options.bunBin,
|
|
41
|
+
args: [options.cliPath, "serve"],
|
|
42
|
+
descriptorPath: unitPath(),
|
|
43
|
+
// Restart=always/RestartSec=2 already unconditional in the prior hand-rolled unit --
|
|
44
|
+
// preserved exactly, not a new opt-in.
|
|
45
|
+
restartOnFailure: true,
|
|
46
|
+
restartSec: 2,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
31
49
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
`;
|
|
50
|
+
export function renderSystemdUnit(options: SystemdUnitOptions): string {
|
|
51
|
+
return generateSystemdUnit(papyrusServiceSpec(options));
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
function unitPath(): string {
|
|
@@ -53,14 +70,12 @@ function isDaemonActive(): boolean {
|
|
|
53
70
|
}
|
|
54
71
|
|
|
55
72
|
function installService(): void {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
systemctl("daemon-reload");
|
|
63
|
-
systemctl("enable", DAEMON_UNIT_NAME);
|
|
73
|
+
const spec = papyrusServiceSpec({ bunBin: process.execPath, cliPath: fileURLToPath(import.meta.url) });
|
|
74
|
+
const result = installUserService(spec, createNodeServiceInstallDeps());
|
|
75
|
+
if (!result.installed) throw new Error(`failed to install the Papyrus service: ${result.reason}`);
|
|
76
|
+
// installUserService's Linux path is `enable --now` (starts if not already running) --
|
|
77
|
+
// an explicit restart on top ensures a re-install after an upgrade actually picks up the
|
|
78
|
+
// freshly-generated unit's new ExecStart path, not just re-enables the old one.
|
|
64
79
|
systemctl("restart", DAEMON_UNIT_NAME);
|
|
65
80
|
}
|
|
66
81
|
|
package/src/client.ts
CHANGED
|
@@ -59,7 +59,7 @@ export async function connectPapyrusClient(dir: string = daemonStateDir()): Prom
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
export interface PushChannelTarget {
|
|
62
|
-
/** ws:// URL for the daemon's push-invalidation channel (see push-channel.ts in
|
|
62
|
+
/** ws:// URL for the daemon's push-invalidation channel (see push-channel.ts in vehicle-server). */
|
|
63
63
|
url: string;
|
|
64
64
|
token: string;
|
|
65
65
|
}
|
|
@@ -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/daemon.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PushChannel } from "@danypops/
|
|
1
|
+
import { PushChannel } from "@danypops/vehicle-server/push-channel";
|
|
2
2
|
import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, WAL_CHECKPOINT_INTERVAL_MS, dbPath } from "./constants.ts";
|
|
3
3
|
import { clearDaemonPort, daemonStateDir, loadOrCreateToken, writeDaemonPort } from "./daemon-state.ts";
|
|
4
4
|
import { createApp, createPapyrusService } from "./service.ts";
|
package/src/db.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { createHash } from "node:crypto";
|
|
|
7
7
|
import { createRequire } from "node:module";
|
|
8
8
|
import { mkdirSync } from "node:fs";
|
|
9
9
|
import { join, dirname } from "node:path";
|
|
10
|
-
import { runMigrations, type SqliteMigrationRunner } from "@danypops/
|
|
10
|
+
import { runMigrations, type SqliteMigrationRunner } from "@danypops/vehicle-server/storage";
|
|
11
11
|
import { SQLITE_BUSY_TIMEOUT_MS, SQLITE_SCHEMA_VERSION } from "./constants.ts";
|
|
12
12
|
|
|
13
13
|
const require_ = createRequire(import.meta.url);
|
|
@@ -449,9 +449,10 @@ const LEGACY_MIGRATION_CHAIN_TARGET_VERSION = 13;
|
|
|
449
449
|
|
|
450
450
|
/**
|
|
451
451
|
* A migration beyond LEGACY_MIGRATION_CHAIN_TARGET_VERSION. Runs through @danypops/
|
|
452
|
-
*
|
|
452
|
+
* vehicle-server's generic runMigrations engine (one transaction per migration, its
|
|
453
453
|
* default) via dbMigrationRunner below, instead of a new branch appended to the legacy
|
|
454
|
-
* if-chain -- the exact reuse
|
|
454
|
+
* if-chain -- the exact reuse the storage module was refactored (originally daemon-kit
|
|
455
|
+
* v0.2.1, now absorbed into vehicle-server) to allow,
|
|
455
456
|
* since Papyrus's dual bun:sqlite/node:sqlite Db abstraction could never satisfy that
|
|
456
457
|
* engine's original bun:sqlite-only signature.
|
|
457
458
|
*/
|
|
@@ -658,7 +659,7 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
|
658
659
|
];
|
|
659
660
|
|
|
660
661
|
/**
|
|
661
|
-
* Adapts Papyrus's own Db/inTransaction to
|
|
662
|
+
* Adapts Papyrus's own Db/inTransaction to vehicle-server's storage-agnostic
|
|
662
663
|
* SqliteMigrationRunner port, so its runMigrations engine (written against bun:sqlite's
|
|
663
664
|
* concrete Database) runs unmodified against Papyrus's dual-runtime Db abstraction instead.
|
|
664
665
|
*/
|
|
@@ -17,11 +17,13 @@
|
|
|
17
17
|
*
|
|
18
18
|
* The actual cryptographic primitive (secret generation, hashing, constant-time verify,
|
|
19
19
|
* first-touch registration semantics) is NOT reimplemented here -- it lives in
|
|
20
|
-
* @danypops/
|
|
20
|
+
* @danypops/vehicle-server's session-identity module. That gap (a shared bearer token cannot
|
|
21
21
|
* distinguish callers; a session id needs a real credential once it becomes behavior-
|
|
22
|
-
* affecting) is generic to every
|
|
23
|
-
* own daemon.ts/service.ts
|
|
24
|
-
*
|
|
22
|
+
* affecting) is generic to every Vehicle-shaped daemon, not Papyrus-specific -- Papyrus's
|
|
23
|
+
* own daemon.ts/service.ts is a fully custom Bun.serve() composition root that has never
|
|
24
|
+
* adopted vehicle-server's own startDaemon()/paths conventions (its own daemon-state.ts
|
|
25
|
+
* hand-rolls path/token/port management instead), but this one narrow capability is adopted
|
|
26
|
+
* regardless (vehicle-server's exports map is designed for
|
|
25
27
|
* exactly this: "a consumer only pulls in what it uses"). This file only wires that generic
|
|
26
28
|
* primitive to Papyrus's own SQLite storage (see adapters/sqlite-session-identity-store.ts)
|
|
27
29
|
* and to Task Focus specifically, the one place session_id is behavior-affecting today.
|
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";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { SessionIdentityRecord, SessionIdentityStore as
|
|
1
|
+
import type { SessionIdentityRecord, SessionIdentityStore as VehicleSessionIdentityStore } from "@danypops/vehicle-server/session-identity";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Papyrus's persistence port for @danypops/
|
|
4
|
+
* Papyrus's persistence port for @danypops/vehicle-server's storage-agnostic session-identity
|
|
5
5
|
* primitive -- re-exported under this project's own port naming convention (src/ports/*)
|
|
6
|
-
* rather than importing the
|
|
6
|
+
* rather than importing the vehicle-server interface name directly at every call site.
|
|
7
7
|
*/
|
|
8
|
-
export type SessionIdentityStore =
|
|
8
|
+
export type SessionIdentityStore = VehicleSessionIdentityStore;
|
|
9
9
|
export type { SessionIdentityRecord };
|
package/src/service.ts
CHANGED
|
@@ -28,6 +28,9 @@ import {
|
|
|
28
28
|
listInjectableRules,
|
|
29
29
|
} from "./domain-services.ts";
|
|
30
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";
|
|
31
34
|
import { Logs } from "./log-service.ts";
|
|
32
35
|
import { SQLiteLogStore } from "./adapters/sqlite-log-store.ts";
|
|
33
36
|
import { SessionIdentity, InvalidSessionSecretError } from "./session-identity-service.ts";
|
|
@@ -204,6 +207,13 @@ export interface PapyrusService {
|
|
|
204
207
|
operationNames(): OperationName[];
|
|
205
208
|
schemaState(): SchemaState;
|
|
206
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;
|
|
207
217
|
checkpoint(): void;
|
|
208
218
|
optimize(): void;
|
|
209
219
|
/** Time-based Task Focus reclamation (see Tasks.reapStaleFocus); returns how many rows were removed, for daemon logging. */
|
|
@@ -469,6 +479,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
469
479
|
const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases);
|
|
470
480
|
const noteEvents = new SQLiteNoteEventStore(db);
|
|
471
481
|
const notes = new Notes(artifacts, noteEvents);
|
|
482
|
+
const notesVehicle = createNotesVehicleRegistry(notes, artifacts);
|
|
472
483
|
const projections = new SQLiteGraphProjectionStore(db);
|
|
473
484
|
const artifactScopes = new SQLiteArtifactScopeStore(db);
|
|
474
485
|
const logs = new Logs(new SQLiteLogStore(db));
|
|
@@ -494,6 +505,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
494
505
|
return {
|
|
495
506
|
operationNames: () => [...EXPECTED_OPERATION_NAMES],
|
|
496
507
|
schemaState: state,
|
|
508
|
+
notesVehicle,
|
|
497
509
|
async execute(operation, input = {}) {
|
|
498
510
|
const handler = registry[operation as OperationName];
|
|
499
511
|
if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
|
|
@@ -553,12 +565,17 @@ export function createApp(deps: {
|
|
|
553
565
|
*/
|
|
554
566
|
onOperationExecuted?: (operation: string, input: OperationInput) => void;
|
|
555
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 });
|
|
556
572
|
return {
|
|
557
573
|
async fetch(request: Request): Promise<Response> {
|
|
558
574
|
if (request.headers.get("authorization") !== `Bearer ${deps.token}`) {
|
|
559
575
|
return json({ error: "missing or invalid bearer token" }, { status: 401 });
|
|
560
576
|
}
|
|
561
577
|
const url = new URL(request.url);
|
|
578
|
+
if (url.pathname.startsWith("/vehicle/")) return vehicleApp.fetch(request);
|
|
562
579
|
if (request.method === "GET" && url.pathname === "/health") {
|
|
563
580
|
return json({ ok: true, version: VERSION, schema: deps.service.schemaState() });
|
|
564
581
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isSessionRegistered, registerSessionIdentity, releaseSessionIdentity, verifySessionSecret } from "@danypops/
|
|
1
|
+
import { isSessionRegistered, registerSessionIdentity, releaseSessionIdentity, verifySessionSecret } from "@danypops/vehicle-server/session-identity";
|
|
2
2
|
import { assertValidSessionId } from "./domain/session-identity.ts";
|
|
3
3
|
import type { SessionIdentityStore } from "./ports/session-identity-store.ts";
|
|
4
4
|
|
|
@@ -11,7 +11,7 @@ export interface RegisterSessionIdentityResult {
|
|
|
11
11
|
export class InvalidSessionSecretError extends Error {}
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Thin Papyrus-side wrapper over @danypops/
|
|
14
|
+
* Thin Papyrus-side wrapper over @danypops/vehicle-server's storage-agnostic session-identity
|
|
15
15
|
* primitive: validates input shape, binds it to Papyrus's own SQLite-backed store, and
|
|
16
16
|
* exposes the exact three operations Task Focus enforcement needs (see
|
|
17
17
|
* assertAuthorizedForFocus in src/modules/tasks.ts). See domain/session-identity.ts for the
|
|
@@ -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
|
+
}
|