@danypops/papyrus 0.58.1 → 0.59.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.58.1",
3
+ "version": "0.59.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
  "main": "./src/index.ts",
@@ -0,0 +1,54 @@
1
+ import type { CommandContext } from "@stricli/core";
2
+ import { buildApplication, buildCommand } from "@stricli/core";
3
+ import type { PapyrusClient } from "../client.ts";
4
+ import { runStricliToString } from "./stricli-run.ts";
5
+
6
+ type BatchClient = Pick<PapyrusClient, "call">;
7
+ type BatchItemResult = { ok: true; result: unknown } | { ok: false; error: string };
8
+ type BatchResult = { results: BatchItemResult[] };
9
+
10
+ interface BatchContext extends CommandContext {
11
+ readonly client: BatchClient;
12
+ readonly json: boolean;
13
+ }
14
+
15
+ /** No further shape assertion here -- the service validates each item's own {op, input} shape;
16
+ * this only needs a real JSON array to hand off. */
17
+ function parseItems(value: string): unknown[] {
18
+ const parsed = JSON.parse(value) as unknown;
19
+ if (!Array.isArray(parsed)) throw new Error("--items-json must be a JSON array");
20
+ return parsed;
21
+ }
22
+
23
+ const runCommand = buildCommand({
24
+ func: async function (this: BatchContext, flags: { itemsJson: unknown[] }) {
25
+ const result = await this.client.call<Record<string, unknown>, BatchResult>("batch.execute", { items: flags.itemsJson });
26
+ if (this.json) {
27
+ this.process.stdout.write(JSON.stringify(result));
28
+ return;
29
+ }
30
+ const lines = result.results.map((entry, index) =>
31
+ entry.ok ? `[${index}] ok: ${JSON.stringify(entry.result)}` : `[${index}] failed: ${entry.error}`,
32
+ );
33
+ this.process.stdout.write(lines.join("\n"));
34
+ },
35
+ parameters: {
36
+ flags: {
37
+ itemsJson: {
38
+ brief: 'JSON array of {"op": "<operation>", "input": {...}} entries -- each fans out exactly like a direct call to that operation',
39
+ kind: "parsed",
40
+ parse: parseItems,
41
+ placeholder: "json",
42
+ },
43
+ },
44
+ },
45
+ docs: { brief: "Fan out N independent operations in one call, so N artifact mutations don't need N separate round-trips" },
46
+ });
47
+
48
+ const app = buildApplication(runCommand, { name: "batch", scanner: { caseStyle: "allow-kebab-for-camel" } });
49
+
50
+ export async function runBatchCli(args: string[], client: BatchClient): Promise<string> {
51
+ const json = args.includes("--json");
52
+ const positional = args.filter((arg) => arg !== "--json");
53
+ return runStricliToString(app, positional, { client, json });
54
+ }
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@ import { copyFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFi
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { createNodeServiceInstallDeps, generateSystemdUnit, installUserService, type ServiceSpec } from "@danypops/vehicle-server/service";
6
6
  import { runArtifactCli } from "./cli/artifact-command.ts";
7
+ import { runBatchCli } from "./cli/batch-command.ts";
7
8
  import { runDaemonCli } from "./cli/daemon-command.ts";
8
9
  import { runDiscussCli } from "./cli/discuss-command.ts";
9
10
  import { runDocsCli } from "./cli/docs-command.ts";
@@ -353,6 +354,7 @@ export function runIdMigrationCli(args: string[]): string {
353
354
 
354
355
  export {
355
356
  runArtifactCli,
357
+ runBatchCli,
356
358
  runDiscussCli,
357
359
  runDocsCli,
358
360
  runGatesCli,
@@ -419,6 +421,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
419
421
  console.log(await runMigrationCli(args.slice(1), client));
420
422
  return;
421
423
  }
424
+ if (command === "batch") {
425
+ const client = await connectPapyrusClient();
426
+ console.log(await runBatchCli(args.slice(1), client));
427
+ return;
428
+ }
422
429
  if (command === "daemon") {
423
430
  const client = await connectPapyrusClient();
424
431
  console.log(await runDaemonCli(args.slice(1), client));
package/src/constants.ts CHANGED
@@ -325,6 +325,10 @@ export const TOOL_COLLAPSED_ROW_LIMIT = 5;
325
325
  export const TOOL_DETAILS_MAX_ITEMS = 100;
326
326
  export const TOOL_DETAILS_MAX_EDGES = 200;
327
327
 
328
+ /** batch.execute's own bound on items.length -- a caller should get a clear rejection above
329
+ * this, not an unbounded server-side fan-out loop. */
330
+ export const BATCH_MAX_ITEMS = 100;
331
+
328
332
  /** Reconciliation instruction appended whenever Papyrus has open work. */
329
333
  export const TASK_RECONCILIATION_INSTRUCTION = [
330
334
  "Reconcile before concluding or moving on:",
@@ -1,9 +1,4 @@
1
- /**
2
- * Cross-domain artifact name/id resolution and workflow-run narrative building, split out of
3
- * handlers/shared.ts as part of a SOLID-audit-driven decomposition (see Doc "Modularity playbook:
4
- * building-block-shaped TypeScript modules for papyrus/pi-papyrus" and the "handlers/shared.ts
5
- * split" child of "Epic: Modularize papyrus/pi-papyrus god-files into building-block modules").
6
- */
1
+ /** Cross-domain artifact name/id resolution and workflow-run narrative building. */
7
2
  import { type VehicleContentBlock, VehicleError } from "@danypops/vehicle-core";
8
3
  import type { Artifact } from "../artifact/artifact.ts";
9
4
  import type { ArtifactStore } from "../artifact/artifact-store.ts";
@@ -0,0 +1,134 @@
1
+ /**
2
+ * batch.execute -- a generic fan-out over N independent operations in one Vehicle call, so a
3
+ * caller doing N artifact mutations (e.g. renaming 45 tasks/docs) doesn't need N separate tool
4
+ * round-trips. Reuses each item's existing single-operation validation/execute path unchanged:
5
+ * a thin fan-out over registry.invoke() -- this same VehicleRegistry, self-referenced, so every
6
+ * item goes through the exact same schema/permission/effect enforcement a direct call would --
7
+ * not a parallel reimplementation of any domain's own update logic.
8
+ *
9
+ * Partial failure, not all-or-nothing: every item is attempted regardless of an earlier item's
10
+ * own failure, and the response reports one {ok, result} or {ok, error} entry per item, in the
11
+ * SAME order as the request -- an artifact-not-found on item 12 of 45 never rolls back or skips
12
+ * items 1-11 or 13-45.
13
+ *
14
+ * Deliberately scoped to fan out N INDEPENDENT calls, nothing more. A caller whose own items are
15
+ * NOT independent (e.g. item 2 needs item 1's just-created id) needs a genuinely different
16
+ * feature (transactional/dependent-batch chaining) -- out of scope here.
17
+ */
18
+ import { bindVehicleOperation, defineVehicleOperation, isVehicleError, type VehicleInvocationOptions } from "@danypops/vehicle-core";
19
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
20
+ import { BATCH_MAX_ITEMS } from "../constants.ts";
21
+ import { looseObjectSchema, passthroughOutput, stringProp, validationError } from "./shared.ts";
22
+
23
+ const OWNER = "batch";
24
+ const LIMITS = { defaultTimeoutMs: 30_000, maxTimeoutMs: 60_000, maxRequestBytes: 262_144, maxResponseBytes: 1_048_576 };
25
+
26
+ export interface BatchItem {
27
+ readonly op: string;
28
+ readonly version?: number;
29
+ readonly input: Record<string, unknown>;
30
+ }
31
+
32
+ export type BatchItemResult = { readonly ok: true; readonly result: unknown } | { readonly ok: false; readonly error: string };
33
+
34
+ /** Shared validation for both this file's own Vehicle-native registration and service.ts's
35
+ * moduleRegistry/composition-root-facing "batch.execute" entry -- one bounds/shape check, not
36
+ * two independently-drifting copies. */
37
+ export function parseBatchItems(input: Record<string, unknown>): BatchItem[] {
38
+ const raw = input.items;
39
+ if (!Array.isArray(raw) || raw.length === 0) throw validationError("items must be a non-empty array");
40
+ if (raw.length > BATCH_MAX_ITEMS) throw validationError(`items cannot contain more than ${BATCH_MAX_ITEMS} entries`);
41
+ return raw.map((entry, index) => {
42
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
43
+ throw validationError(`items[${index}] must be an object`);
44
+ }
45
+ const record = entry as Record<string, unknown>;
46
+ if (typeof record.op !== "string" || record.op.length === 0) throw validationError(`items[${index}].op is required`);
47
+ if (record.version !== undefined && typeof record.version !== "number") {
48
+ throw validationError(`items[${index}].version must be a number`);
49
+ }
50
+ if (record.input !== undefined && (typeof record.input !== "object" || record.input === null || Array.isArray(record.input))) {
51
+ throw validationError(`items[${index}].input must be an object`);
52
+ }
53
+ return {
54
+ op: record.op,
55
+ version: record.version as number | undefined,
56
+ input: (record.input as Record<string, unknown> | undefined) ?? {},
57
+ };
58
+ });
59
+ }
60
+
61
+ /** Turns any per-item failure (a real VehicleError, or an unexpected throw) into the same plain
62
+ * message string every {ok:false, error} entry reports -- a caller reading the aggregate result
63
+ * needs one consistent shape regardless of which layer the failure came from. */
64
+ export function batchItemErrorMessage(error: unknown): string {
65
+ if (isVehicleError(error)) return error.message;
66
+ return error instanceof Error ? error.message : String(error);
67
+ }
68
+
69
+ /**
70
+ * Registers batch.execute as a real VehicleOperation -- the path an actual Pi tool call
71
+ * reaches. Each item is dispatched via registry.invoke() (this same registry, self-referenced),
72
+ * propagating the OUTER batch call's own granted permissions/principal/correlation identity to
73
+ * every item unchanged -- a caller with a narrower permission set than some item requires gets
74
+ * that item reported as a normal {ok:false} permission-denied failure, never a silent escalation
75
+ * granted by batch.execute's own (deliberately empty) permission requirement.
76
+ */
77
+ export function registerBatchVehicleOperation(registry: VehicleRegistry): void {
78
+ const operation = defineVehicleOperation({
79
+ name: "batch.execute",
80
+ version: 1,
81
+ description:
82
+ 'Fans out N independent operations (each an existing {op, input} pair, e.g. {"op":"tasks.update","input":{"id":"...","title":"..."}}) in one call, so N artifact mutations don\'t need N separate tool round-trips. Every item is attempted regardless of another item\'s own failure; the response is {results: [{ok, result} | {ok, error}, ...]}, one entry per item, in request order. Not transactional and not for dependent items (e.g. one item needing another\'s just-created id) -- only for independent operations that would otherwise be called one at a time.',
83
+ input: looseObjectSchema(
84
+ {
85
+ items: {
86
+ type: "array",
87
+ minItems: 1,
88
+ maxItems: BATCH_MAX_ITEMS,
89
+ description: "Each entry names an existing operation and its own input, exactly as a direct call would receive it.",
90
+ items: {
91
+ type: "object",
92
+ properties: {
93
+ op: stringProp,
94
+ version: { type: "number" },
95
+ input: { type: "object" },
96
+ },
97
+ required: ["op"],
98
+ additionalProperties: false,
99
+ },
100
+ },
101
+ },
102
+ ["items"],
103
+ ),
104
+ output: passthroughOutput,
105
+ // Deliberately empty: batch.execute itself gates nothing -- every fanned-out item is
106
+ // re-checked against the caller's own real granted permissions via registry.invoke() below.
107
+ permissions: [],
108
+ effect: "local-write",
109
+ idempotency: { mode: "unsafe" },
110
+ limits: LIMITS,
111
+ });
112
+ registry.register(
113
+ OWNER,
114
+ bindVehicleOperation(operation, () => async (context) => {
115
+ const items = parseBatchItems(context.input as Record<string, unknown>);
116
+ const propagated: VehicleInvocationOptions = {
117
+ permissions: context.permissions,
118
+ principal: context.principal,
119
+ correlationId: context.correlationId,
120
+ signal: context.signal,
121
+ };
122
+ const results: BatchItemResult[] = [];
123
+ for (const item of items) {
124
+ try {
125
+ const result = await registry.invoke(item.op, item.version ?? 1, item.input, propagated);
126
+ results.push({ ok: true, result });
127
+ } catch (error) {
128
+ results.push({ ok: false, error: batchItemErrorMessage(error) });
129
+ }
130
+ }
131
+ return { results };
132
+ }),
133
+ );
134
+ }
@@ -1,10 +1,7 @@
1
1
  /**
2
- * Generic, domain-agnostic operation-input schema DSL, split out of handlers/shared.ts as part of
3
- * a SOLID-audit-driven decomposition (see Doc "Modularity playbook: building-block-shaped
4
- * TypeScript modules for papyrus/pi-papyrus" and the "handlers/shared.ts split" child of "Epic:
5
- * Modularize papyrus/pi-papyrus god-files into building-block modules"). Nothing here references
6
- * a specific Papyrus domain (tasks/docs/rules/...) -- a real candidate to eventually become its
7
- * own building block other Vehicle-backed daemons could reuse directly.
2
+ * Generic, domain-agnostic operation-input schema DSL. Nothing here references a specific
3
+ * Papyrus domain (tasks/docs/rules/...) -- a real candidate to eventually become its own building
4
+ * block other Vehicle-backed daemons could reuse directly.
8
5
  */
9
6
  import {
10
7
  defineVehicleSchema,
@@ -1,10 +1,4 @@
1
- /**
2
- * Vehicle-operation-definer DSL (createOperationDefiner) and the paired add/remove mutation shape
3
- * built on top of it (definePairedMutation), split out of handlers/shared.ts as part of a
4
- * SOLID-audit-driven decomposition (see Doc "Modularity playbook: building-block-shaped
5
- * TypeScript modules for papyrus/pi-papyrus" and the "handlers/shared.ts split" child of "Epic:
6
- * Modularize papyrus/pi-papyrus god-files into building-block modules").
7
- */
1
+ /** Vehicle-operation-definer DSL (createOperationDefiner) and the paired add/remove mutation shape built on top of it (definePairedMutation). */
8
2
  import { bindVehicleOperation, defineVehicleOperation, type VehicleLimits, type VehicleOperationContext } from "@danypops/vehicle-core";
9
3
  import type { VehicleRegistry } from "@danypops/vehicle-server";
10
4
  import { looseObjectSchema, passthroughOutput } from "./operation-schema.ts";
@@ -20,6 +20,7 @@ import type { TaskEventStore } from "../task/event/task-event-store.ts";
20
20
  import type { TaskScopeStore } from "../task/scope/task-scope-store.ts";
21
21
  import type { Tasks } from "../task/task-service.ts";
22
22
  import { registerArtifactTrashOperations } from "./artifact-trash.ts";
23
+ import { registerBatchVehicleOperation } from "./batch.ts";
23
24
  import { registerDiscussVehicleOperations } from "./discuss.ts";
24
25
  import { registerDocsVehicleOperations } from "./docs.ts";
25
26
  import { registerNotesVehicleOperations } from "./notes.ts";
@@ -72,5 +73,6 @@ export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleR
72
73
  registerScopeGroupsVehicleOperations(registry, deps.scopeGroups, deps.projectRegistry, deps.scopes);
73
74
  registerDiscussVehicleOperations(registry, deps.discussions, deps.artifacts);
74
75
  registerArtifactTrashOperations(registry, deps.artifacts);
76
+ registerBatchVehicleOperation(registry);
75
77
  return registry;
76
78
  }
@@ -1,20 +1,11 @@
1
1
  /**
2
2
  * Barrel re-exporting the shared schema helpers and name->id resolution used by every per-domain
3
- * VehicleRegistry projection (notes-vehicle.ts, rules-vehicle.ts, docs-vehicle.ts,
4
- * artifact-trash-vehicle.ts, ...). Kept as a real file (not a `shared/index.ts` subdirectory) so
5
- * every existing `from "./shared.ts"` import across the 8 handler files that depend on it keeps
6
- * resolving unchanged -- this codebase's imports always carry an explicit `.ts` extension, which
7
- * does not implicitly resolve a bare specifier to a directory's own index file the way Node's
8
- * CJS `require()` does.
9
- *
10
- * The real implementation now lives in four focused sibling modules instead of one 435-line file
11
- * mixing four unrelated concerns -- see Doc "Modularity playbook: building-block-shaped
12
- * TypeScript modules for papyrus/pi-papyrus" and the "handlers/shared.ts split" child of "Epic:
13
- * Modularize papyrus/pi-papyrus god-files into building-block modules":
14
- * - operation-schema.ts: generic, domain-agnostic operation-input schema DSL
15
- * - paired-mutation.ts: the Vehicle-operation-definer DSL and the paired add/remove shape built on it
16
- * - task-classifiers.ts: business-rule error classifiers specific to this package's own domain errors
17
- * - artifact-helpers.ts: cross-domain artifact name/id resolution and workflow-run narrative building
3
+ * VehicleRegistry projection. Kept as a real file (not a `shared/index.ts` subdirectory) so every
4
+ * existing `from "./shared.ts"` import keeps resolving unchanged -- this codebase's imports
5
+ * always carry an explicit `.ts` extension, which does not implicitly resolve a bare specifier to
6
+ * a directory's own index file the way Node's CJS `require()` does. Real implementation lives in
7
+ * operation-schema.ts (schema DSL), paired-mutation.ts (operation-definer DSL), task-classifiers.ts
8
+ * (domain error classifiers), artifact-helpers.ts (name/id resolution, workflow-run narrative).
18
9
  */
19
10
  export {
20
11
  buildWorkflowRunContent,
package/src/service.ts CHANGED
@@ -19,6 +19,7 @@ import { SQLiteDiscussionRoundStore } from "./discussion/sqlite-discussion-round
19
19
  import type { GateRunner } from "./gate/gate-runner.ts";
20
20
  import { SQLiteGateRunner } from "./gate/sqlite-gate-runner.ts";
21
21
  import { SQLiteGraphProjectionStore } from "./graph-projection/sqlite-graph-projection-store.ts";
22
+ import { batchItemErrorMessage, parseBatchItems } from "./handlers/batch.ts";
22
23
  import { createPapyrusVehicleRegistry } from "./handlers/registry.ts";
23
24
  import { logEvent } from "./log/log.ts";
24
25
  import { Logs } from "./log/log-service.ts";
@@ -68,6 +69,7 @@ import { VERSION } from "./version.ts";
68
69
  */
69
70
  const COMPOSITION_ROOT_OPERATION_NAMES = [
70
71
  "system.migrate",
72
+ "batch.execute",
71
73
  "artifact.create",
72
74
  "artifact.query",
73
75
  "artifact.show",
@@ -260,8 +262,31 @@ function handlers(
260
262
  rootTaskId: optionalString(input, "root_task_id"),
261
263
  sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
262
264
  });
263
- return {
265
+ const table: Record<OperationName, OperationHandler> = {
264
266
  "system.migrate": () => migrate(),
267
+ // A thin fan-out over this SAME table (self-referenced via closure -- `table` is fully
268
+ // assigned by the time this handler is ever actually called, well after this object
269
+ // literal finishes constructing), covering both module-forwarded and composition-root
270
+ // operations alike. Deliberately bypasses execute()'s own migration-required check per
271
+ // item -- that check already ran once for the outer "batch.execute" call itself, and
272
+ // migration state cannot change mid-request. This path has no permission model of its
273
+ // own (matching every other operation reached through this same table via /api/v1/ops or
274
+ // in-process execute()) -- see handlers/batch.ts's registerBatchVehicleOperation for the
275
+ // permission-propagating counterpart real Pi tool calls actually reach.
276
+ "batch.execute": async (input) => {
277
+ const items = parseBatchItems(input);
278
+ const results: Array<{ ok: true; result: unknown } | { ok: false; error: string }> = [];
279
+ for (const item of items) {
280
+ try {
281
+ const itemHandler = table[item.op as OperationName];
282
+ if (!itemHandler) throw new UnknownOperationError(`unknown operation "${item.op}"`);
283
+ results.push({ ok: true, result: await itemHandler(item.input) });
284
+ } catch (error) {
285
+ results.push({ ok: false, error: batchItemErrorMessage(error) });
286
+ }
287
+ }
288
+ return { results };
289
+ },
265
290
  "artifact.create": (input) => {
266
291
  const normalized = normalizeCreateInput(input);
267
292
  authority.requireArtifactAllowed(
@@ -495,6 +520,7 @@ function handlers(
495
520
  "discuss.rounds": forwardToModule("discuss.rounds"),
496
521
  "discuss.list": forwardToModule("discuss.list"),
497
522
  };
523
+ return table;
498
524
  }
499
525
 
500
526
  export function createPapyrusService(path: string): PapyrusService {