@dazl/api-client 1.0.43 → 1.0.45

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.
@@ -0,0 +1,333 @@
1
+ import { z } from "zod";
2
+
3
+ /** The only schema version there has ever been. Interactive fields are ordinary optional fields. */
4
+ export const WORKFLOW_DOCUMENT_SCHEMA_VERSION = "1";
5
+
6
+ /** Directory the editor creates workflow files in. Discovery is by extension, not by directory. */
7
+ export const WORKFLOWS_DIR = "workflows";
8
+ export const WORKFLOW_FILE_EXTENSION = ".flow.json";
9
+
10
+ /** How a run can start. A closed set: nothing else starts a run, and there is no schedule trigger. */
11
+ export const TRIGGER_SOURCES = ["workspace-prompt", "chat-prompt", "chat-agent-tool"] as const;
12
+ export type TriggerSource = (typeof TRIGGER_SOURCES)[number];
13
+
14
+ /**
15
+ * Whether a path stays inside the project root it is resolved against: not an absolute POSIX, drive
16
+ * or UNC path, not a URL, no `..` segment, no NUL byte. Both `/` and `\` count as separators, so the
17
+ * guarantee holds for a consumer on Windows too. A backslash is otherwise allowed, because git
18
+ * C-quotes a non-ASCII name (`"\303\251.txt"`) and observed paths carry that verbatim. `.` and empty
19
+ * segments cannot escape, so a `./` prefix or a doubled slash is accepted rather than rejected.
20
+ */
21
+ function staysInProjectRoot(path: string): boolean {
22
+ return (
23
+ !/^[/\\]/.test(path) &&
24
+ !/^[a-z]:/i.test(path) &&
25
+ !/^[a-z][a-z\d+.-]*:\/\//i.test(path) &&
26
+ !path.split(/[/\\]/).includes("..") &&
27
+ !path.includes("\0")
28
+ );
29
+ }
30
+
31
+ const relativePathSchema = z.string().refine(staysInProjectRoot, {
32
+ message: "Must be a project-relative path: not absolute, not a URL, and without '..' segments",
33
+ });
34
+
35
+ /** A non-empty path relative to the project root that cannot leave it. */
36
+ export const projectRelativePathSchema = relativePathSchema.min(1);
37
+
38
+ /**
39
+ * What every node produces. The same shape for every node in every workflow — it is not authored and
40
+ * is not part of the document. A step that needs to hand on anything richer than prose writes a file
41
+ * and names it here.
42
+ */
43
+ export const workflowNodeOutputSchema = z.object({
44
+ text: z.string(),
45
+ /** Project-relative paths. Never absolute, never a URL, never escaping the root. */
46
+ files: z.array(projectRelativePathSchema).default([]),
47
+ /**
48
+ * What the executor observed this step change, one entry per repository whose head moved.
49
+ *
50
+ * Written by the executor after the agent's own output, never by the agent: `text` and `files`
51
+ * are what a step says it did, and this is what it was seen to do. Optional because an output
52
+ * stored before this existed must still parse, and because a step that moved no head records
53
+ * nothing at all. Absent therefore means "nothing was observed", which covers both a step that
54
+ * changed nothing and a step nobody was watching - the two are deliberately not distinguished
55
+ * here. What is kept distinct is absent versus an empty array, which no producer ever writes.
56
+ */
57
+ changes: z
58
+ .array(
59
+ z.object({
60
+ /** Directory under the project root; '' when the project root is itself the repo. */
61
+ dirName: relativePathSchema,
62
+ from: z.string(),
63
+ to: z.string(),
64
+ commits: z.number().int().nonnegative(),
65
+ /** Project-relative, as `getChangedFilesBetween` reports them. */
66
+ files: z.array(projectRelativePathSchema),
67
+ }),
68
+ )
69
+ .optional(),
70
+ });
71
+ export type WorkflowNodeOutput = z.infer<typeof workflowNodeOutputSchema>;
72
+
73
+ const nodePositionSchema = z.object({ x: z.number(), y: z.number() });
74
+
75
+ const nodeSizeSchema = z.object({ width: z.number(), height: z.number() });
76
+
77
+ const baseNodeSchema = z.object({
78
+ id: z.string().min(1),
79
+ title: z.string(),
80
+ description: z.string().optional(),
81
+ position: nodePositionSchema,
82
+ /** Set once a user drags a node, so auto-layout stops moving it. */
83
+ manualPosition: z.boolean().optional(),
84
+ size: nodeSizeSchema.optional(),
85
+ /** The one node that follows this one. Absent means the run ends here. */
86
+ nextStep: z.string().optional(),
87
+ skippable: z.boolean().default(false),
88
+ });
89
+
90
+ /** One reviewer on a review node: an agent, optionally speaking for a person. */
91
+ export const workflowReviewerSchema = z.object({
92
+ /**
93
+ * An agent document's `meta.name`. Deliberately not `.min(1)`, for the same reason as the `work`
94
+ * node's `agent` field below: a reviewer can be added before its agent is picked, and rejecting
95
+ * `""` here would make the whole document non-conforming over the step the user just touched.
96
+ * `validateWorkflowChain` warns about an empty reviewer agent; sync time is where it becomes an
97
+ * error.
98
+ */
99
+ agent: z.string(),
100
+ prompt: z.string().optional(),
101
+ /** Email of the person this reviewer represents, matching how rule owners are named. */
102
+ onBehalfOf: z.string().optional(),
103
+ /** Omission means personal. Only an explicit designation makes a conversation workspace-visible. */
104
+ conversationRole: z.enum(["personal", "workspace"]).optional(),
105
+ });
106
+ export type WorkflowReviewer = z.infer<typeof workflowReviewerSchema>;
107
+
108
+ export const workflowNodeSchema = z.discriminatedUnion("kind", [
109
+ baseNodeSchema.extend({
110
+ kind: z.literal("trigger"),
111
+ source: z.enum(TRIGGER_SOURCES),
112
+ }),
113
+ baseNodeSchema.extend({
114
+ kind: z.literal("work"),
115
+ interaction: z.enum(["automatic", "interactive"]).optional(),
116
+ /**
117
+ * An agent document's `meta.name`. Resolved at sync time, warned about while authoring -
118
+ * deliberately not `.min(1)`. A work step exists before an agent is chosen for it, and a
119
+ * `.min(1)` here would make the whole document non-conforming the instant such a step is
120
+ * created, collapsing the panel around the step the user just added. `""` is a safe sentinel
121
+ * for "not picked yet" because every other agent reference in this file is `.min(1)`, so no
122
+ * real agent can be named the empty string. `validateWorkflowChain` warns about it instead.
123
+ */
124
+ agent: z.string(),
125
+ /** Extra instruction for this step, appended to the agent's own system prompt. */
126
+ prompt: z.string().optional(),
127
+ }),
128
+ baseNodeSchema.extend({
129
+ kind: z.literal("review"),
130
+ interaction: z.enum(["automatic", "interactive"]).optional(),
131
+ reviewers: z.array(workflowReviewerSchema).default([]),
132
+ }),
133
+ ]);
134
+ export type WorkflowNode = z.infer<typeof workflowNodeSchema>;
135
+
136
+ /**
137
+ * Kinds that can only ever *begin* a chain: nothing may name one as its `nextStep`.
138
+ *
139
+ * A named list rather than a `=== "trigger"` written out wherever the question comes up, because it
140
+ * is asked in three places that must agree - `validateWorkflowChain` below, the editor's chain
141
+ * mutations, and the editor's add menus - and two of those are in another repository reaching this
142
+ * file through the SDK. It is a list rather than a single value because several triggers in one
143
+ * workflow are coming, each starting its own chain, and because a second head-only kind would then
144
+ * be one entry here rather than a fourth copy of the same comparison.
145
+ */
146
+ export const CHAIN_HEAD_KINDS: readonly WorkflowNode["kind"][] = ["trigger"];
147
+
148
+ /** Whether a node of this kind can only start a chain. `undefined` - an unknown node - is not one. */
149
+ export function isChainHeadKind(kind: string | undefined): boolean {
150
+ return kind !== undefined && (CHAIN_HEAD_KINDS as readonly string[]).includes(kind);
151
+ }
152
+
153
+ export const workflowDocumentSchema = z.object({
154
+ schemaVersion: z.literal(WORKFLOW_DOCUMENT_SCHEMA_VERSION).optional(),
155
+ meta: z.object({
156
+ /**
157
+ * Deliberately not `.min(1)`. A user clearing the field to retype it would otherwise invalidate
158
+ * the document mid-keystroke and collapse the form around them — the exact defect step 1a shipped
159
+ * and had to fix. An empty name is recoverable; a missing one is still an error.
160
+ */
161
+ name: z.string(),
162
+ description: z.string().optional(),
163
+ }),
164
+ nodes: z.array(workflowNodeSchema).default([]),
165
+ });
166
+
167
+ export type WorkflowDocument = z.infer<typeof workflowDocumentSchema>;
168
+ export type WorkflowDocumentInput = z.input<typeof workflowDocumentSchema>;
169
+
170
+ /** Inspects the whole document. Execution must first restrict this to its selected reachable chain. */
171
+ export function isInteractiveWorkflowDocument(document: WorkflowDocument): boolean {
172
+ return document.nodes.some((node) => node.kind !== "trigger" && node.interaction === "interactive");
173
+ }
174
+
175
+ export interface WorkflowDocumentError {
176
+ path: string;
177
+ message: string;
178
+ }
179
+
180
+ export type WorkflowDocumentValidation =
181
+ { valid: true; document: WorkflowDocument } | { valid: false; errors: WorkflowDocumentError[] };
182
+
183
+ export function validateWorkflowDocument(value: unknown): WorkflowDocumentValidation {
184
+ const result = workflowDocumentSchema.safeParse(value);
185
+ if (result.success) {
186
+ return { valid: true, document: result.data };
187
+ }
188
+ return {
189
+ valid: false,
190
+ errors: result.error.issues.map((issue) => ({
191
+ path: issue.path.length > 0 ? issue.path.join(".") : "document",
192
+ message: issue.message,
193
+ })),
194
+ };
195
+ }
196
+
197
+ export interface WorkflowChainWarning {
198
+ nodeId: string;
199
+ message: string;
200
+ }
201
+
202
+ /**
203
+ * Chain integrity, reported separately from schema validity and deliberately as warnings.
204
+ *
205
+ * A half-built workflow is a normal state to be in while authoring — a node whose `nextStep` is not
206
+ * written yet must not make the file invalid. Step 2's sync is where these become failures.
207
+ */
208
+ export function validateWorkflowChain(nodes: readonly WorkflowNode[]): WorkflowChainWarning[] {
209
+ const warnings: WorkflowChainWarning[] = [];
210
+
211
+ // Not a chain-shape problem, but the same "authoring problem, not a schema violation" category:
212
+ // `agent` accepts "" so that adding a step or a reviewer never invalidates the document, and this
213
+ // is where that loosened value gets flagged instead.
214
+ for (const node of nodes) {
215
+ if (node.kind === "work" && node.agent === "") {
216
+ warnings.push({ nodeId: node.id, message: "This step has no agent assigned yet." });
217
+ }
218
+ if (node.kind === "review") {
219
+ if (
220
+ node.interaction === "interactive" &&
221
+ node.reviewers.filter((r) => r.conversationRole === "workspace").length !== 1
222
+ ) {
223
+ warnings.push({
224
+ nodeId: node.id,
225
+ message: "An interactive review must designate exactly one workspace reviewer.",
226
+ });
227
+ }
228
+ for (const reviewer of node.reviewers) {
229
+ if (reviewer.agent === "") {
230
+ warnings.push({ nodeId: node.id, message: "This step has a reviewer with no agent assigned yet." });
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ const byId = new Map(nodes.map((node) => [node.id, node]));
237
+ const incoming = new Map<string, number>();
238
+ const seen = new Set<string>();
239
+
240
+ for (const node of nodes) {
241
+ if (node.nextStep === undefined) {
242
+ continue;
243
+ }
244
+ if (node.nextStep === node.id) {
245
+ warnings.push({ nodeId: node.id, message: "This step points at itself." });
246
+ seen.add(node.id);
247
+ continue;
248
+ }
249
+ const target = byId.get(node.nextStep);
250
+ if (target === undefined) {
251
+ warnings.push({ nodeId: node.id, message: `Next step "${node.nextStep}" is not a step in this workflow.` });
252
+ continue;
253
+ }
254
+ // A trigger is a chain head and nothing may point at one. Reported against the step holding the
255
+ // link rather than against the trigger, because the link is the thing that is wrong and the step
256
+ // holding it is the only one that can be repaired - the trigger itself is fine as it is.
257
+ if (isChainHeadKind(target.kind)) {
258
+ warnings.push({
259
+ nodeId: node.id,
260
+ message: "This step points at a trigger, and a trigger can only start a chain.",
261
+ });
262
+ }
263
+ incoming.set(node.nextStep, (incoming.get(node.nextStep) ?? 0) + 1);
264
+ }
265
+
266
+ for (const [nodeId, count] of incoming) {
267
+ if (count > 1) {
268
+ warnings.push({ nodeId, message: "More than one step leads here, and the flow is a single chain." });
269
+ }
270
+ }
271
+
272
+ // A cycle is only reachable once every hop resolves, so this runs after the checks above.
273
+ for (const node of nodes) {
274
+ if (seen.has(node.id)) {
275
+ continue;
276
+ }
277
+ const path = new Set<string>();
278
+ let current: WorkflowNode | undefined = node;
279
+ while (current && !seen.has(current.id)) {
280
+ if (path.has(current.id)) {
281
+ warnings.push({ nodeId: current.id, message: "These steps form a cycle, so the run would never end." });
282
+ break;
283
+ }
284
+ path.add(current.id);
285
+ current = current.nextStep === undefined ? undefined : byId.get(current.nextStep);
286
+ }
287
+ for (const id of path) {
288
+ seen.add(id);
289
+ }
290
+ }
291
+
292
+ return warnings;
293
+ }
294
+
295
+ export function createDefaultWorkflowDocument(name: string): WorkflowDocumentInput {
296
+ return {
297
+ schemaVersion: WORKFLOW_DOCUMENT_SCHEMA_VERSION,
298
+ meta: { name },
299
+ nodes: [
300
+ {
301
+ // A uuid, like every other node id. `id` is what becomes `WorkflowNodeRun.nodeKey` when a
302
+ // run is projected, and the editor's own `createNode` uses `crypto.randomUUID()` - so the
303
+ // literal "trigger" here meant every workflow ever created shipped with exactly one node
304
+ // whose key was not a uuid, and two workflows whose first steps collided on it.
305
+ id: crypto.randomUUID(),
306
+ kind: "trigger",
307
+ title: "Start",
308
+ source: "workspace-prompt",
309
+ position: { x: 0, y: 0 },
310
+ },
311
+ ],
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Filename for a workflow, derived from its display name.
317
+ *
318
+ * Throws rather than returning a bare extension for a name with no ASCII alphanumerics, matching
319
+ * `CanvasManager.createCanvasFileName`. Step 1a's agent equivalent shipped without this guard and a
320
+ * non-Latin name silently wrote a dotfile.
321
+ */
322
+ export function toSafeWorkflowFileName(name: string): string {
323
+ const slug = name
324
+ .replace(/[^a-zA-Z0-9]/g, "-")
325
+ .replace(/-+/g, "-")
326
+ .replace(/^-+|-+$/g, "")
327
+ .slice(0, 60)
328
+ .toLowerCase();
329
+ if (!slug) {
330
+ throw new Error("Workflow name must contain at least one letter or number");
331
+ }
332
+ return `${slug}${WORKFLOW_FILE_EXTENSION}`;
333
+ }