@danypops/papyrus 0.58.2 → 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.2",
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:",
@@ -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
+ }
@@ -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
  }
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 {