@danypops/papyrus 0.58.2 → 0.59.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 -4
- package/src/cli/batch-command.ts +54 -0
- package/src/cli.ts +7 -0
- package/src/constants.ts +4 -0
- package/src/handlers/batch.ts +134 -0
- package/src/handlers/registry.ts +2 -0
- package/src/service.ts +27 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.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
|
"main": "./src/index.ts",
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
},
|
|
58
58
|
"files": ["src", "README.md"],
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@danypops/vehicle-client": "^0.
|
|
61
|
-
"@danypops/vehicle-core": "^0.
|
|
62
|
-
"@danypops/vehicle-server": "^0.
|
|
60
|
+
"@danypops/vehicle-client": "^0.10.1",
|
|
61
|
+
"@danypops/vehicle-core": "^0.17.1",
|
|
62
|
+
"@danypops/vehicle-server": "^0.24.5",
|
|
63
63
|
"@stricli/core": "^1.3.0"
|
|
64
64
|
}
|
|
65
65
|
}
|
|
@@ -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
|
+
}
|
package/src/handlers/registry.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|