@danypops/papyrus 0.54.2 → 0.54.4
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 +12 -1
- package/src/daemon/daemon.ts +2 -2
- package/src/domain/gate-execution.ts +205 -0
- package/src/handlers/artifact-helpers.ts +110 -0
- package/src/handlers/operation-schema.ts +161 -0
- package/src/handlers/paired-mutation.ts +102 -0
- package/src/handlers/shared.ts +47 -433
- package/src/handlers/task-classifiers.ts +88 -0
- package/src/log/log.ts +31 -20
- package/src/ops.ts +1 -198
- package/src/service.ts +1 -1
- package/src/stores/sqlite-gate-runner.ts +1 -1
- package/src/stores/task-mutation-request-store.ts +16 -1
- package/src/task/task-edges.ts +106 -0
- package/src/task/task-focus-coordinator.ts +189 -0
- package/src/task/task-lifecycle-errors.ts +18 -0
- package/src/task/task-project-scope.ts +107 -0
- package/src/task/task-service.ts +64 -251
package/src/handlers/shared.ts
CHANGED
|
@@ -1,435 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Barrel re-exporting the shared schema helpers and name->id resolution used by every per-domain
|
|
3
3
|
* VehicleRegistry projection (notes-vehicle.ts, rules-vehicle.ts, docs-vehicle.ts,
|
|
4
|
-
* artifact-trash-vehicle.ts).
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (type === "object") return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
|
-
if (type === "array") return Array.isArray(value);
|
|
52
|
-
if (type === "string") return typeof value === "string";
|
|
53
|
-
if (type === "number") return typeof value === "number" && Number.isFinite(value);
|
|
54
|
-
if (type === "integer") return typeof value === "number" && Number.isInteger(value);
|
|
55
|
-
if (type === "boolean") return typeof value === "boolean";
|
|
56
|
-
return true;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function validateSchemaValue(value: unknown, schema: OperationSchemaNode, path: readonly (string | number)[]): VehicleSchemaIssue[] {
|
|
60
|
-
const label = path.length === 0 ? "input" : String(path.at(-1));
|
|
61
|
-
const declaredTypes = typeof schema.type === "string" ? [schema.type] : (schema.type ?? []);
|
|
62
|
-
const type = declaredTypes.find((candidate) => matchesSchemaType(value, candidate));
|
|
63
|
-
if (declaredTypes.length > 0 && type === undefined) {
|
|
64
|
-
const accepted = declaredTypes.map((candidate) =>
|
|
65
|
-
candidate === "integer" ? "an integer" : `${candidate === "object" ? "an" : "a"} ${candidate}`,
|
|
66
|
-
);
|
|
67
|
-
return schemaIssue(path, `${label} must be ${accepted.join(" or ")}`);
|
|
68
|
-
}
|
|
69
|
-
if (type === "object") {
|
|
70
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return schemaIssue(path, `${label} must be an object`);
|
|
71
|
-
const record = value as Record<string, unknown>;
|
|
72
|
-
for (const key of schema.required ?? []) {
|
|
73
|
-
if (!(key in record)) {
|
|
74
|
-
const acceptedShape = schema.description ? `; ${schema.description}` : "";
|
|
75
|
-
return schemaIssue([...path, key], `${key} is required${acceptedShape}`);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
for (const [key, child] of Object.entries(schema.properties ?? {})) {
|
|
79
|
-
if (!(key in record)) continue;
|
|
80
|
-
const issues = validateSchemaValue(record[key], child, [...path, key]);
|
|
81
|
-
if (issues.length > 0) return issues;
|
|
82
|
-
}
|
|
83
|
-
for (const key of Object.keys(record)) {
|
|
84
|
-
if (key in (schema.properties ?? {})) continue;
|
|
85
|
-
const patternMatch = Object.entries(schema.patternProperties ?? {}).find(([pattern]) => new RegExp(pattern).test(key));
|
|
86
|
-
if (patternMatch) {
|
|
87
|
-
const issues = validateSchemaValue(record[key], patternMatch[1], [...path, key]);
|
|
88
|
-
if (issues.length > 0) return issues;
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
if (schema.additionalProperties === false) return schemaIssue([...path, key], `${key} is not allowed`);
|
|
92
|
-
if (typeof schema.additionalProperties === "object") {
|
|
93
|
-
const issues = validateSchemaValue(record[key], schema.additionalProperties, [...path, key]);
|
|
94
|
-
if (issues.length > 0) return issues;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
} else if (type === "array") {
|
|
98
|
-
const entries = value as unknown[];
|
|
99
|
-
if (schema.minItems !== undefined && entries.length < schema.minItems) {
|
|
100
|
-
return schemaIssue(path, `${label} must contain at least ${schema.minItems} item(s)`);
|
|
101
|
-
}
|
|
102
|
-
if (schema.maxItems !== undefined && entries.length > schema.maxItems) {
|
|
103
|
-
return schemaIssue(path, `${label} cannot contain more than ${schema.maxItems} item(s)`);
|
|
104
|
-
}
|
|
105
|
-
if (schema.items) {
|
|
106
|
-
for (const [index, entry] of entries.entries()) {
|
|
107
|
-
const issues = validateSchemaValue(entry, schema.items, [...path, index]);
|
|
108
|
-
if (issues.length > 0) return issues;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
} else if (type === "string") {
|
|
112
|
-
const text = value as string;
|
|
113
|
-
if (schema.minLength !== undefined && text.length < schema.minLength) {
|
|
114
|
-
return schemaIssue(path, `${label} must contain at least ${schema.minLength} character(s)`);
|
|
115
|
-
}
|
|
116
|
-
if (schema.maxLength !== undefined && text.length > schema.maxLength) {
|
|
117
|
-
return schemaIssue(path, `${label} cannot exceed ${schema.maxLength} character(s)`);
|
|
118
|
-
}
|
|
119
|
-
} else if (type === "number" || type === "integer") {
|
|
120
|
-
const number = value as number;
|
|
121
|
-
if (schema.minimum !== undefined && number < schema.minimum) {
|
|
122
|
-
return schemaIssue(path, `${label} must be at least ${schema.minimum}`);
|
|
123
|
-
}
|
|
124
|
-
if (schema.maximum !== undefined && number > schema.maximum) {
|
|
125
|
-
return schemaIssue(path, `${label} cannot exceed ${schema.maximum}`);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
if (schema.enum && !schema.enum.includes(value)) {
|
|
129
|
-
return schemaIssue(path, `${label} must be one of ${schema.enum.join(", ")}`);
|
|
130
|
-
}
|
|
131
|
-
return [];
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** VehicleRegistry executes this codec before resolving or dispatching an operation. Keep the
|
|
135
|
-
* recursive runtime checks aligned with the same JSON Schema clients and tools_man receive. */
|
|
136
|
-
export function looseObjectSchema(
|
|
137
|
-
properties: Readonly<Record<string, OperationSchemaNode>>,
|
|
138
|
-
required: readonly string[] = [],
|
|
139
|
-
): VehicleSchemaCodec<Record<string, unknown>> {
|
|
140
|
-
const schema = { type: "object", properties, required: [...required], additionalProperties: false } as const;
|
|
141
|
-
return defineVehicleSchema<Record<string, unknown>>({
|
|
142
|
-
jsonSchema: schema as unknown as JsonSchema,
|
|
143
|
-
safeParse(value) {
|
|
144
|
-
const issues = validateSchemaValue(value, schema, []);
|
|
145
|
-
return issues.length > 0 ? { success: false, issues } : { success: true, value: value as Record<string, unknown> };
|
|
146
|
-
},
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export const passthroughOutput: VehicleSchemaCodec<unknown> = defineVehicleSchema<unknown>({
|
|
151
|
-
jsonSchema: { type: "object" },
|
|
152
|
-
safeParse: (value) => ({ success: true, value }),
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
export const stringProp = { type: "string" } as const;
|
|
156
|
-
export const numberProp = { type: "number" } as const;
|
|
157
|
-
export const booleanProp = { type: "boolean" } as const;
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* A plain `throw new Error(...)` inside any resolve()/execute() step here is caught by
|
|
161
|
-
* vehicle-registry.ts's generic dispatch and re-wrapped as VehicleError("handler-failed",
|
|
162
|
-
* `${key} handler failed`, {category: "internal"}) -- built to catch a genuine crash, but
|
|
163
|
-
* it can't distinguish that from an ordinary, expected validation/lookup failure, so it
|
|
164
|
-
* discards the original message and category either way. Every guard clause and name
|
|
165
|
-
* resolution below must throw a VehicleError directly so it passes through that dispatch
|
|
166
|
-
* unchanged (vehicle-registry.ts only rewraps errors that are NOT already a VehicleError).
|
|
167
|
-
*/
|
|
168
|
-
export function validationError(message: string): VehicleError {
|
|
169
|
-
return new VehicleError("validation-failed", message, { category: "validation" });
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* tasks.focus/pause/unpause/clear_focus and playbooks.invoke all re-run
|
|
174
|
-
* sessionIdentity.assertAuthorized(session_id, session_secret) directly, bypassing the guarded
|
|
175
|
-
* tasks.focus operation (see modules/tasks.ts's guardFocusMutation and modules/playbooks.ts's own
|
|
176
|
-
* doc comment) -- a real, registered session's own auth failure is an ordinary, expected outcome,
|
|
177
|
-
* not an unexpected crash, so it must surface as its own classified VehicleError. Real incident:
|
|
178
|
-
* this used to arrive only inside .cause of an opaque "... handler failed", invisible to a caller
|
|
179
|
-
* that doesn't already know to dig for it. Anything else propagates unchanged, so vehicle-registry's
|
|
180
|
-
* own secure-by-default handler-failed opacity still applies to a genuine unexpected crash.
|
|
181
|
-
*/
|
|
182
|
-
export function classifySessionAuthorization<T>(run: () => T): T {
|
|
183
|
-
try {
|
|
184
|
-
return run();
|
|
185
|
-
} catch (error) {
|
|
186
|
-
if (error instanceof InvalidSessionSecretError) {
|
|
187
|
-
throw new VehicleError("invalid-session-secret", error.message, { category: "authorization" });
|
|
188
|
-
}
|
|
189
|
-
throw error;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* A task execution graph (or a workflow/playbook run materializing one) that exceeds its own
|
|
195
|
-
* node/edge/degree bound is an ordinary, expected capacity failure, not an unexpected crash --
|
|
196
|
-
* must surface as its own classified VehicleError instead of vehicle-registry's generic
|
|
197
|
-
* handler-failed. Shared by tasks-vehicle.ts (create/depend/contain/graph/plan/complete) and
|
|
198
|
-
* playbooks-vehicle.ts (invoke, which materializes Tasks through the same shared engine).
|
|
199
|
-
*/
|
|
200
|
-
export function classifyTaskExecutionBounds<T>(run: () => T): T {
|
|
201
|
-
try {
|
|
202
|
-
return run();
|
|
203
|
-
} catch (error) {
|
|
204
|
-
if (error instanceof TaskExecutionBoundExceededError) {
|
|
205
|
-
throw new VehicleError("task-execution-bound-exceeded", error.message, { category: "capacity" });
|
|
206
|
-
}
|
|
207
|
-
throw error;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export function classifyTaskCreateIdempotency<T>(run: () => T): T {
|
|
212
|
-
try {
|
|
213
|
-
return run();
|
|
214
|
-
} catch (error) {
|
|
215
|
-
if (error instanceof TaskCreateIdempotencyConflictError) {
|
|
216
|
-
throw new VehicleError("idempotency-key-conflict", error.message, { category: "conflict" });
|
|
217
|
-
}
|
|
218
|
-
throw error;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** A self-dependency or dependency-cycle rejection (tasks.depend/undepend/create) is an ordinary, expected validation failure, not an unexpected crash. */
|
|
223
|
-
export function classifyTaskDependencyCycles<T>(run: () => T): T {
|
|
224
|
-
try {
|
|
225
|
-
return run();
|
|
226
|
-
} catch (error) {
|
|
227
|
-
if (error instanceof TaskDependencyCycleError) {
|
|
228
|
-
throw new VehicleError("task-dependency-cycle", error.message, { category: "validation" });
|
|
229
|
-
}
|
|
230
|
-
throw error;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** A Playbook's own composition tree (contains/depends_on nesting) is invalid -- a cycle, excessive depth/size, or conflicting argument types -- an ordinary, expected authoring mistake caught at playbooks.invoke/preview compile time, not an unexpected crash. */
|
|
235
|
-
export function classifyPlaybookComposition<T>(run: () => T): T {
|
|
236
|
-
try {
|
|
237
|
-
return run();
|
|
238
|
-
} catch (error) {
|
|
239
|
-
if (error instanceof PlaybookCompositionError) {
|
|
240
|
-
throw new VehicleError("playbook-composition-invalid", error.message, { category: "validation" });
|
|
241
|
-
}
|
|
242
|
-
throw error;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/** A known LLM tool-calling quirk: a nested-object field arrives JSON-stringified rather than as a real object. Mutates input[key] in place when it's a string, leaves it untouched otherwise. */
|
|
247
|
-
export function normalizeJsonEncodedField(input: Record<string, unknown>, key: string): void {
|
|
248
|
-
const value = input[key];
|
|
249
|
-
if (typeof value !== "string") return;
|
|
250
|
-
try {
|
|
251
|
-
input[key] = JSON.parse(value);
|
|
252
|
-
} catch {
|
|
253
|
-
throw validationError(`${key} must be valid JSON`);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** Exact match semantics as the Pi-extension helper this replaces (domain-tools.ts's matchArtifactByName) -- case-insensitive exact title match, refuses to guess between ambiguous matches. */
|
|
258
|
-
export function matchArtifactByName(candidates: readonly Artifact[], name: string): string {
|
|
259
|
-
const needle = name.trim().toLowerCase();
|
|
260
|
-
const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
|
|
261
|
-
if (matches.length === 0) {
|
|
262
|
-
throw new VehicleError("artifact-not-found", `no artifact named "${name}" found in this scope`, { category: "not_found" });
|
|
263
|
-
}
|
|
264
|
-
if (matches.length > 1) {
|
|
265
|
-
throw new VehicleError(
|
|
266
|
-
"artifact-name-ambiguous",
|
|
267
|
-
`${matches.length} artifacts are named "${name}": ${matches.map((a) => `${a.title} (${a.alias})`).join(", ")} -- use id or alias to disambiguate`,
|
|
268
|
-
{ category: "conflict" },
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
return matches[0]!.id;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Resolves a name to an id. Checks `artifacts.getByAlias` first -- a real, indexed,
|
|
276
|
-
* globally-unique match, unlike title -- before falling back to today's scoped
|
|
277
|
-
* title-based matching, retrying against `fetchWidened` (an unscoped/cross-project
|
|
278
|
-
* search) only when `fetchCandidates` finds nothing. Owns the match-or-widen control
|
|
279
|
-
* flow only -- the caller supplies its own scoped/widened list calls, since scoping
|
|
280
|
-
* differs per domain. Omit `fetchWidened` when there is no wider scope to retry.
|
|
281
|
-
*/
|
|
282
|
-
export function resolveArtifactIdWidened(
|
|
283
|
-
artifacts: ArtifactStore,
|
|
284
|
-
name: string,
|
|
285
|
-
fetchCandidates: () => readonly Artifact[],
|
|
286
|
-
fetchWidened?: () => readonly Artifact[],
|
|
287
|
-
): string {
|
|
288
|
-
const byAlias = artifacts.getByAlias(name.trim());
|
|
289
|
-
if (byAlias) return byAlias.id;
|
|
290
|
-
try {
|
|
291
|
-
return matchArtifactByName(fetchCandidates(), name);
|
|
292
|
-
} catch (error) {
|
|
293
|
-
if (!(error instanceof VehicleError) || error.code !== "artifact-not-found" || !fetchWidened) throw error;
|
|
294
|
-
return matchArtifactByName(fetchWidened(), name);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/** Synchronous equivalent of pi-papyrus's own artifactLabelsById -- server-side, a direct ArtifactStore.get() replaces the extra RPC round-trip that helper needed client-side. Always suffixes the alias -- a short, meaningful, globally-unique reference, unlike the raw UUID it replaces. */
|
|
299
|
-
export function labelsById(artifacts: ArtifactStore, ids: readonly string[]): Map<string, string> {
|
|
300
|
-
const uniqueIds = [...new Set(ids)];
|
|
301
|
-
const resolved = uniqueIds.map((id) => artifacts.get(id)).filter((artifact): artifact is Artifact => artifact !== null);
|
|
302
|
-
return new Map(resolved.map((artifact) => [artifact.id, `${artifact.title} (${artifact.alias})`]));
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
export interface WorkflowRunNarrativeInput {
|
|
306
|
-
runId: string;
|
|
307
|
-
created: { docs: readonly string[]; rules: readonly string[]; tasks: readonly string[] };
|
|
308
|
-
rootTaskIds: readonly string[];
|
|
309
|
-
execution: TaskExecutionPlan;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Builds the model-facing `content` text for a workflow run result (ready roots, context docs,
|
|
314
|
-
* scoped rules, an execution tree) directly, so the model reads a summary instead of the raw
|
|
315
|
-
* execution DAG -- the same shape pi-papyrus's own hand-rolled playbooks tool built
|
|
316
|
-
* client-side, now built once here where the run result is actually produced.
|
|
317
|
-
*/
|
|
318
|
-
export function buildWorkflowRunContent(
|
|
319
|
-
artifacts: ArtifactStore,
|
|
320
|
-
headline: string,
|
|
321
|
-
input: WorkflowRunNarrativeInput,
|
|
322
|
-
extraLines: readonly string[] = [],
|
|
323
|
-
): VehicleContentBlock {
|
|
324
|
-
const nodeById = new Map(input.execution.nodes.map((node) => [node.id, node]));
|
|
325
|
-
const rootLabels = input.rootTaskIds.map((id) => nodeById.get(id)?.title ?? "unknown task");
|
|
326
|
-
const createdLabels = labelsById(artifacts, [...input.created.docs, ...input.created.rules]);
|
|
327
|
-
const titleCounts = new Map<string, number>();
|
|
328
|
-
for (const node of input.execution.nodes) titleCounts.set(node.title, (titleCounts.get(node.title) ?? 0) + 1);
|
|
329
|
-
const executionLines = input.execution.nodes
|
|
330
|
-
.map((node) =>
|
|
331
|
-
(titleCounts.get(node.title) ?? 0) > 1 ? ` [${node.state}] ${node.title} (${node.id})` : ` [${node.state}] ${node.title}`,
|
|
332
|
-
)
|
|
333
|
-
.join("\n");
|
|
334
|
-
const text = [
|
|
335
|
-
headline,
|
|
336
|
-
...extraLines,
|
|
337
|
-
`Ready roots: ${rootLabels.join(", ") || "none"}.`,
|
|
338
|
-
`Context docs: ${input.created.docs.map((id) => createdLabels.get(id) ?? "unknown document").join(", ") || "none"}.`,
|
|
339
|
-
`Scoped rules: ${input.created.rules.map((id) => createdLabels.get(id) ?? "unknown rule").join(", ") || "none"}.`,
|
|
340
|
-
...(executionLines ? ["Execution:", executionLines] : []),
|
|
341
|
-
].join("\n");
|
|
342
|
-
return { type: "text", text };
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
export type OperationSchemaProperties = Record<
|
|
346
|
-
string,
|
|
347
|
-
{ type: string | readonly string[]; enum?: readonly string[]; description?: string; [key: string]: unknown }
|
|
348
|
-
>;
|
|
349
|
-
|
|
350
|
-
export type DefineOperation = (
|
|
351
|
-
action: string,
|
|
352
|
-
description: string,
|
|
353
|
-
effect: "read" | "local-write",
|
|
354
|
-
properties: OperationSchemaProperties,
|
|
355
|
-
required: readonly string[],
|
|
356
|
-
resolve: (input: Record<string, unknown>) => Record<string, unknown>,
|
|
357
|
-
execute?: (input: Record<string, unknown>, context: VehicleOperationContext<Record<string, unknown>>) => unknown,
|
|
358
|
-
/**
|
|
359
|
-
* Overrides this one operation's own Vehicle transport limits, distinct from every other
|
|
360
|
-
* operation this same createOperationDefiner call produces. For an operation that shells out
|
|
361
|
-
* to and waits on a real external command (e.g. tasks.run_gates/tasks.complete) rather than an
|
|
362
|
-
* instant CRUD read/write -- see handlers/tasks.ts's GATE_OPERATION_LIMITS for the motivating
|
|
363
|
-
* case. Omit to keep the definer's own default limits, unchanged for every other action.
|
|
364
|
-
*/
|
|
365
|
-
limits?: VehicleLimits,
|
|
366
|
-
) => void;
|
|
367
|
-
|
|
368
|
-
const STANDARD_OPERATION_LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Every *-vehicle.ts handler wires up the identical defineVehicleOperation +
|
|
372
|
-
* bindVehicleOperation + registry.register triple per action, differing only in
|
|
373
|
-
* owner/domain-prefix/permissions and (for tasks/playbooks) a real execute() override
|
|
374
|
-
* in place of the default "call the wrapped module operation" behavior. One factory,
|
|
375
|
-
* called once per domain, replaces that repetition.
|
|
376
|
-
*/
|
|
377
|
-
export function createOperationDefiner(
|
|
378
|
-
registry: VehicleRegistry,
|
|
379
|
-
owner: string,
|
|
380
|
-
domain: string,
|
|
381
|
-
permissions: readonly [string, string],
|
|
382
|
-
defaultCall: (name: string, input: Record<string, unknown>) => unknown,
|
|
383
|
-
): DefineOperation {
|
|
384
|
-
return (action, description, effect, properties, required, resolve, execute, limits) => {
|
|
385
|
-
const operation = defineVehicleOperation({
|
|
386
|
-
name: `${domain}.${action}`,
|
|
387
|
-
version: 1,
|
|
388
|
-
description,
|
|
389
|
-
input: looseObjectSchema(properties, required),
|
|
390
|
-
output: passthroughOutput,
|
|
391
|
-
permissions: [...permissions],
|
|
392
|
-
effect,
|
|
393
|
-
idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
|
|
394
|
-
limits: limits ?? STANDARD_OPERATION_LIMITS,
|
|
395
|
-
});
|
|
396
|
-
registry.register(
|
|
397
|
-
owner,
|
|
398
|
-
bindVehicleOperation(
|
|
399
|
-
operation,
|
|
400
|
-
() => async (context) =>
|
|
401
|
-
(execute ?? ((input: Record<string, unknown>) => defaultCall(`${domain}.${action}`, input)))(resolve(context.input), context),
|
|
402
|
-
),
|
|
403
|
-
);
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
export interface PairedMutationFieldSpec {
|
|
408
|
-
idProp: string;
|
|
409
|
-
nameProp: string;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* depend/undepend and contain/uncontain (tasks-vehicle.ts, playbooks-vehicle.ts) share
|
|
414
|
-
* one shape: two id-or-name fields resolved the same way for both the add and the
|
|
415
|
-
* remove action, differing only in action name/description. One call replaces two
|
|
416
|
-
* near-identical define() invocations.
|
|
417
|
-
*/
|
|
418
|
-
export function definePairedMutation(
|
|
419
|
-
define: DefineOperation,
|
|
420
|
-
first: PairedMutationFieldSpec,
|
|
421
|
-
second: PairedMutationFieldSpec,
|
|
422
|
-
properties: OperationSchemaProperties,
|
|
423
|
-
required: readonly string[],
|
|
424
|
-
resolveId: (input: Record<string, unknown>, idProp: string, nameProp: string) => string,
|
|
425
|
-
add: { action: string; description: string },
|
|
426
|
-
remove: { action: string; description: string },
|
|
427
|
-
): void {
|
|
428
|
-
const resolve = (input: Record<string, unknown>): Record<string, unknown> => ({
|
|
429
|
-
...input,
|
|
430
|
-
[first.idProp]: resolveId(input, first.idProp, first.nameProp),
|
|
431
|
-
[second.idProp]: resolveId(input, second.idProp, second.nameProp),
|
|
432
|
-
});
|
|
433
|
-
define(add.action, add.description, "local-write", properties, required, resolve);
|
|
434
|
-
define(remove.action, remove.description, "local-write", properties, required, resolve);
|
|
435
|
-
}
|
|
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
|
|
18
|
+
*/
|
|
19
|
+
export {
|
|
20
|
+
buildWorkflowRunContent,
|
|
21
|
+
labelsById,
|
|
22
|
+
matchArtifactByName,
|
|
23
|
+
normalizeJsonEncodedField,
|
|
24
|
+
resolveArtifactIdWidened,
|
|
25
|
+
type WorkflowRunNarrativeInput,
|
|
26
|
+
} from "./artifact-helpers.ts";
|
|
27
|
+
export {
|
|
28
|
+
booleanProp,
|
|
29
|
+
looseObjectSchema,
|
|
30
|
+
numberProp,
|
|
31
|
+
type OperationSchemaNode,
|
|
32
|
+
passthroughOutput,
|
|
33
|
+
stringProp,
|
|
34
|
+
validationError,
|
|
35
|
+
} from "./operation-schema.ts";
|
|
36
|
+
export {
|
|
37
|
+
createOperationDefiner,
|
|
38
|
+
type DefineOperation,
|
|
39
|
+
definePairedMutation,
|
|
40
|
+
type OperationSchemaProperties,
|
|
41
|
+
type PairedMutationFieldSpec,
|
|
42
|
+
} from "./paired-mutation.ts";
|
|
43
|
+
export {
|
|
44
|
+
classifyPlaybookComposition,
|
|
45
|
+
classifySessionAuthorization,
|
|
46
|
+
classifyTaskCreateIdempotency,
|
|
47
|
+
classifyTaskDependencyCycles,
|
|
48
|
+
classifyTaskExecutionBounds,
|
|
49
|
+
} from "./task-classifiers.ts";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Business-rule error classifiers -- turn an ordinary, expected domain rejection into its own
|
|
3
|
+
* classified VehicleError instead of vehicle-registry's generic opaque "handler-failed", split out
|
|
4
|
+
* of handlers/shared.ts as part of a SOLID-audit-driven decomposition (see Doc "Modularity
|
|
5
|
+
* playbook: building-block-shaped TypeScript modules for papyrus/pi-papyrus" and the
|
|
6
|
+
* "handlers/shared.ts split" child of "Epic: Modularize papyrus/pi-papyrus god-files into
|
|
7
|
+
* building-block modules"). Unlike operation-schema.ts, every function here is specific to this
|
|
8
|
+
* package's own domain error types.
|
|
9
|
+
*/
|
|
10
|
+
import { VehicleError } from "@danypops/vehicle-core";
|
|
11
|
+
import { PlaybookCompositionError } from "../playbook/playbook-definition.ts";
|
|
12
|
+
import { InvalidSessionSecretError } from "../session-identity/session-identity-service.ts";
|
|
13
|
+
import { TaskCreateIdempotencyConflictError } from "../stores/task-create-request-store.ts";
|
|
14
|
+
import { TaskDependencyCycleError, TaskExecutionBoundExceededError } from "../task/task-execution.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* tasks.focus/pause/unpause/clear_focus and playbooks.invoke all re-run
|
|
18
|
+
* sessionIdentity.assertAuthorized(session_id, session_secret) directly, bypassing the guarded
|
|
19
|
+
* tasks.focus operation (see modules/tasks.ts's guardFocusMutation and modules/playbooks.ts's own
|
|
20
|
+
* doc comment) -- a real, registered session's own auth failure is an ordinary, expected outcome,
|
|
21
|
+
* not an unexpected crash, so it must surface as its own classified VehicleError. Real incident:
|
|
22
|
+
* this used to arrive only inside .cause of an opaque "... handler failed", invisible to a caller
|
|
23
|
+
* that doesn't already know to dig for it. Anything else propagates unchanged, so vehicle-registry's
|
|
24
|
+
* own secure-by-default handler-failed opacity still applies to a genuine unexpected crash.
|
|
25
|
+
*/
|
|
26
|
+
export function classifySessionAuthorization<T>(run: () => T): T {
|
|
27
|
+
try {
|
|
28
|
+
return run();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error instanceof InvalidSessionSecretError) {
|
|
31
|
+
throw new VehicleError("invalid-session-secret", error.message, { category: "authorization" });
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A task execution graph (or a workflow/playbook run materializing one) that exceeds its own
|
|
39
|
+
* node/edge/degree bound is an ordinary, expected capacity failure, not an unexpected crash --
|
|
40
|
+
* must surface as its own classified VehicleError instead of vehicle-registry's generic
|
|
41
|
+
* handler-failed. Shared by tasks-vehicle.ts (create/depend/contain/graph/plan/complete) and
|
|
42
|
+
* playbooks-vehicle.ts (invoke, which materializes Tasks through the same shared engine).
|
|
43
|
+
*/
|
|
44
|
+
export function classifyTaskExecutionBounds<T>(run: () => T): T {
|
|
45
|
+
try {
|
|
46
|
+
return run();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error instanceof TaskExecutionBoundExceededError) {
|
|
49
|
+
throw new VehicleError("task-execution-bound-exceeded", error.message, { category: "capacity" });
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function classifyTaskCreateIdempotency<T>(run: () => T): T {
|
|
56
|
+
try {
|
|
57
|
+
return run();
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof TaskCreateIdempotencyConflictError) {
|
|
60
|
+
throw new VehicleError("idempotency-key-conflict", error.message, { category: "conflict" });
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A self-dependency or dependency-cycle rejection (tasks.depend/undepend/create) is an ordinary, expected validation failure, not an unexpected crash. */
|
|
67
|
+
export function classifyTaskDependencyCycles<T>(run: () => T): T {
|
|
68
|
+
try {
|
|
69
|
+
return run();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error instanceof TaskDependencyCycleError) {
|
|
72
|
+
throw new VehicleError("task-dependency-cycle", error.message, { category: "validation" });
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A Playbook's own composition tree (contains/depends_on nesting) is invalid -- a cycle, excessive depth/size, or conflicting argument types -- an ordinary, expected authoring mistake caught at playbooks.invoke/preview compile time, not an unexpected crash. */
|
|
79
|
+
export function classifyPlaybookComposition<T>(run: () => T): T {
|
|
80
|
+
try {
|
|
81
|
+
return run();
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error instanceof PlaybookCompositionError) {
|
|
84
|
+
throw new VehicleError("playbook-composition-invalid", error.message, { category: "validation" });
|
|
85
|
+
}
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/log/log.ts
CHANGED
|
@@ -1,25 +1,36 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Structured daemon logging, now backed by `@danypops/vehicle-server/logging` (pino) instead of a
|
|
3
|
+
* hand-rolled `console.error(JSON.stringify(...))` -- gains real level filtering (including a real
|
|
4
|
+
* debug level this daemon previously had no way to emit at all) and a destination injectable for
|
|
5
|
+
* tests, matching jittor/src/log.ts's already-migrated shape exactly. One deliberate, disclosed
|
|
6
|
+
* shape change from the old bespoke format: the event name is now pino's `msg` field rather than a
|
|
7
|
+
* separate `event` field, matching daemon-kit's shared convention across every migrated daemon.
|
|
8
|
+
* `component`/`level`/`timestamp` and credential-safety (callers still must pass only bounded,
|
|
9
|
+
* non-sensitive fields; @danypops/vehicle-server/logging's own default redact list also catches any
|
|
10
|
+
* credential-shaped field that slips in regardless) are unchanged.
|
|
11
|
+
*/
|
|
12
|
+
import { createLogger, type Logger, type LogLevel as VehicleLogLevel } from "@danypops/vehicle-server/logging";
|
|
2
13
|
|
|
3
|
-
export type LogLevel = "info" | "warn" | "error"
|
|
4
|
-
|
|
5
|
-
/** Credential-safe structured daemon event. Callers must pass bounded, non-sensitive fields. */
|
|
6
|
-
export function logEvent(level: LogLevel, event: string, fields: Record<string, unknown> = {}): void {
|
|
7
|
-
console.error(JSON.stringify({ timestamp: new Date().toISOString(), level, component: "papyrus-daemon", event, ...fields }));
|
|
8
|
-
}
|
|
14
|
+
export type LogLevel = Extract<VehicleLogLevel, "info" | "warn" | "error">;
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* Pinned to `console.error` -- rather than `createLogger`'s own default of a raw fd 2 write via
|
|
18
|
+
* `pino.destination(2)`, which bypasses `console.error` entirely -- so existing tooling/tests that
|
|
19
|
+
* intercept `console.error` keep working unchanged (matches jittor/src/log.ts's own reasoning).
|
|
20
|
+
* Also satisfies @danypops/vehicle-server's own `Logger` port directly wherever one is needed (e.g.
|
|
21
|
+
* `createVehicleHttpApp`'s failure logging in daemon.ts), replacing the old `vehicleLogger()`
|
|
22
|
+
* adapter now that this logger already natively implements the full debug/info/warn/error surface.
|
|
17
23
|
*/
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
24
|
+
export const logger: Logger = createLogger("papyrus-daemon", {
|
|
25
|
+
destination: {
|
|
26
|
+
write: (chunk: string) => {
|
|
27
|
+
console.error(chunk.replace(/\n$/, ""));
|
|
28
|
+
return true;
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** Credential-safe structured daemon event. Callers must pass bounded, non-sensitive fields. */
|
|
34
|
+
export function logEvent(level: LogLevel, event: string, fields: Record<string, unknown> = {}): void {
|
|
35
|
+
logger[level](event, fields);
|
|
25
36
|
}
|