@fungi.computer/trellis 0.2.1 → 0.2.3

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@fungi.computer/trellis",
3
3
  "description": "Trellis: build and run workflows of App methods, an App for Oyster",
4
- "version": "0.2.1",
4
+ "version": "0.2.3",
5
5
  "author": "Levi <levi@technomancy.dev>",
6
6
  "type": "module",
7
7
  "exports": {
@@ -16,18 +16,18 @@
16
16
  "dependencies": {
17
17
  "@fungi.computer/puffball": "0.2.0",
18
18
  "@cfworker/json-schema": "4.1.1",
19
- "@fungi.computer/agar": "0.3.0",
20
- "@fungi.computer/caps": "0.2.0",
19
+ "@fungi.computer/agar": "0.3.1",
20
+ "@fungi.computer/caps": "0.3.0",
21
21
  "@fungi.computer/stipe": "0.1.0",
22
22
  "@xyflow/react": "12.12.0",
23
23
  "react": "18.3.1",
24
24
  "react-dom": "18.3.1",
25
- "@fungi.computer/braid": "0.2.1",
25
+ "@fungi.computer/braid": "0.2.2",
26
26
  "effect": "4.0.2",
27
27
  "@fungi.computer/sigils": "0.2.0",
28
28
  "lucide-react": "0.460.0",
29
29
  "zod": "4.4.3",
30
- "@fungi.computer/workflows-wire": "0.2.1",
30
+ "@fungi.computer/workflows-wire": "0.2.2",
31
31
  "@fungi.computer/cairn": "0.1.0",
32
32
  "@fungi.computer/watchdog": "0.1.0",
33
33
  "@fungi.computer/maosaico": "0.1.0"
@@ -158,6 +158,9 @@
158
158
  "dist/shared",
159
159
  "backend",
160
160
  "shared",
161
+ "src/runs",
162
+ "src/history/relative-time.ts",
163
+ "templates/bonsai-build-on-merge.json",
161
164
  "README.md",
162
165
  "LICENSE"
163
166
  ],
@@ -0,0 +1,17 @@
1
+ const relativeTime = new Intl.RelativeTimeFormat("en", {
2
+ style: "narrow",
3
+ numeric: "auto",
4
+ });
5
+
6
+ /** Version labels use elapsed time; persisted commit timestamps stay unchanged. */
7
+ export function versionRelativeTime(createdAt: string, now = Date.now()) {
8
+ const seconds = (new Date(createdAt).getTime() - now) / 1000;
9
+ const elapsed = Math.abs(seconds);
10
+ if (elapsed >= 86400)
11
+ return relativeTime.format(Math.trunc(seconds / 86400), "day");
12
+ if (elapsed >= 3600)
13
+ return relativeTime.format(Math.trunc(seconds / 3600), "hour");
14
+ if (elapsed >= 60)
15
+ return relativeTime.format(Math.trunc(seconds / 60), "minute");
16
+ return relativeTime.format(Math.trunc(seconds), "second");
17
+ }
@@ -0,0 +1,545 @@
1
+ import { useEffect, useId, useRef, useState } from "react";
2
+ import * as S from "effect/Schema";
3
+ import * as z from "zod";
4
+ import { encodePointer } from "@cfworker/json-schema";
5
+ import { field, type FieldRenderProps } from "@fungi.computer/caps/form";
6
+ import { Field, FieldSet } from "@fungi.computer/caps/components/field";
7
+ import { Textarea } from "@fungi.computer/caps/components/textarea";
8
+ import { Input } from "@fungi.computer/caps/components/input";
9
+ import { Select } from "@fungi.computer/caps/components/select";
10
+ import { Button } from "@fungi.computer/caps/components/button";
11
+ import {
12
+ admitRunInput,
13
+ accepts,
14
+ type Json,
15
+ type PortSchema,
16
+ } from "@fungi.computer/braid";
17
+
18
+ const nameOf = (name: string) => {
19
+ const words = name
20
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
21
+ .replace(/[_-]+/g, " ");
22
+ return words.charAt(0).toUpperCase() + words.slice(1);
23
+ };
24
+ const singular = (name: string) =>
25
+ name.endsWith("ies")
26
+ ? `${name.slice(0, -3)}y`
27
+ : name.endsWith("s")
28
+ ? name.slice(0, -1)
29
+ : name;
30
+
31
+ /** Initial form values are presentation only; the run owner re-admits submitted input. */
32
+ function initial(schema: PortSchema): Json {
33
+ if (schema.const !== undefined) return schema.const;
34
+ if (schema.default !== undefined) return schema.default;
35
+ if (schema.type === "object")
36
+ return Object.fromEntries(
37
+ Object.entries(schema.properties ?? {}).flatMap(([key, child]) =>
38
+ child.default !== undefined || schema.required?.includes(key)
39
+ ? [[key, initial(child)]]
40
+ : [],
41
+ ),
42
+ );
43
+ if (schema.type === "array")
44
+ return (schema.minItems ?? 0) > 0 ? [initial(schema.items ?? {})] : [];
45
+ if (schema.type === "boolean") return false;
46
+ if (schema.type === "null") return null;
47
+ if (schema.type === "number" || schema.type === "integer") return null;
48
+ return "";
49
+ }
50
+
51
+ /** A property whose shape is not expressible as fields keeps one JSON control. */
52
+ function supportsFields(schema: PortSchema): boolean {
53
+ if (schema.type === "object" && schema.properties !== undefined) return true;
54
+ if (schema.oneOf || schema.anyOf) return false;
55
+ if (schema.enum) return true;
56
+ switch (schema.type) {
57
+ case "string":
58
+ case "number":
59
+ case "integer":
60
+ case "boolean":
61
+ return true;
62
+ case "array":
63
+ return schema.items !== undefined && supportsFields(schema.items);
64
+ default:
65
+ return false;
66
+ }
67
+ }
68
+
69
+ function InputValue({
70
+ label,
71
+ schema,
72
+ value,
73
+ onChange,
74
+ disabled,
75
+ root = false,
76
+ onValidity,
77
+ }: {
78
+ label: string;
79
+ schema: PortSchema;
80
+ value: Json | undefined;
81
+ onChange: (value: Json) => void;
82
+ disabled: boolean;
83
+ root?: boolean;
84
+ onValidity: (id: string, valid: boolean) => void;
85
+ }) {
86
+ const name = nameOf(label);
87
+ if (
88
+ !supportsFields(schema) &&
89
+ !(root && schema.type === "object" && !schema.oneOf && !schema.anyOf)
90
+ )
91
+ return (
92
+ <JsonInput
93
+ label={name}
94
+ schema={schema}
95
+ value={value}
96
+ disabled={disabled}
97
+ onChange={onChange}
98
+ onValidity={onValidity}
99
+ />
100
+ );
101
+ if (
102
+ schema.type === "object" &&
103
+ (schema.properties !== undefined || (!schema.oneOf && !schema.anyOf))
104
+ ) {
105
+ const object = S.is(S.JsonObject)(value) ? value : {};
106
+ const fields = Object.entries(schema.properties ?? {}).map(
107
+ ([key, child]) => (
108
+ <InputValue
109
+ key={key}
110
+ label={key}
111
+ schema={child}
112
+ value={object[key]}
113
+ disabled={disabled}
114
+ onValidity={onValidity}
115
+ onChange={(next) => {
116
+ onChange(
117
+ Object.fromEntries([...Object.entries(object), [key, next]]),
118
+ );
119
+ }}
120
+ />
121
+ ),
122
+ );
123
+ return root ? (
124
+ <div className="grid gap-sm">{fields}</div>
125
+ ) : (
126
+ <FieldSet
127
+ legend={name}
128
+ description={schema.description}
129
+ disabled={disabled}
130
+ >
131
+ {fields}
132
+ </FieldSet>
133
+ );
134
+ }
135
+ if (schema.type === "array") {
136
+ const items = S.is(S.Array(S.Json))(value) ? value : [];
137
+ const itemName = singular(name);
138
+ return (
139
+ <FieldSet
140
+ legend={name}
141
+ description={schema.description}
142
+ disabled={disabled}
143
+ >
144
+ {items.map((item, index) => {
145
+ const input = (
146
+ <InputValue
147
+ label={`${itemName} ${String(index + 1)}`}
148
+ schema={schema.items ?? { type: "string" }}
149
+ value={item}
150
+ disabled={disabled}
151
+ onValidity={onValidity}
152
+ onChange={(next) => {
153
+ onChange(items.map((old, i) => (i === index ? next : old)));
154
+ }}
155
+ />
156
+ );
157
+ const remove = (
158
+ <Button
159
+ type="button"
160
+ variant="ghost-destructive"
161
+ size="xs"
162
+ disabled={items.length <= (schema.minItems ?? 0)}
163
+ onClick={() => {
164
+ onChange(items.filter((_, i) => i !== index));
165
+ }}
166
+ >
167
+ Remove
168
+ </Button>
169
+ );
170
+ return (
171
+ <div key={index} className="flex items-end gap-xs">
172
+ <div className="min-w-0 flex-1">{input}</div>
173
+ {remove}
174
+ </div>
175
+ );
176
+ })}
177
+ <div>
178
+ <Button
179
+ type="button"
180
+ variant="outline"
181
+ size="sm"
182
+ disabled={items.length >= Math.min(schema.maxItems ?? 50, 50)}
183
+ onClick={() => {
184
+ onChange([...items, initial(schema.items ?? { type: "string" })]);
185
+ }}
186
+ >
187
+ Add {itemName.toLowerCase()}
188
+ </Button>
189
+ </div>
190
+ </FieldSet>
191
+ );
192
+ }
193
+ if (schema.enum || schema.type === "boolean") {
194
+ const choices = schema.enum ?? [true, false];
195
+ return (
196
+ <Field label={name} description={schema.description}>
197
+ <Select
198
+ aria-label={name}
199
+ disabled={disabled}
200
+ value={value === undefined ? "" : JSON.stringify(value)}
201
+ onChange={(event) => {
202
+ const selected = choices.find(
203
+ (choice) => JSON.stringify(choice) === event.currentTarget.value,
204
+ );
205
+ if (selected !== undefined) onChange(selected);
206
+ }}
207
+ >
208
+ <option value="">Choose…</option>
209
+ {choices.map((choice) => (
210
+ <option key={JSON.stringify(choice)} value={JSON.stringify(choice)}>
211
+ {S.is(S.String)(choice) ? choice : JSON.stringify(choice)}
212
+ </option>
213
+ ))}
214
+ </Select>
215
+ </Field>
216
+ );
217
+ }
218
+ const numeric = schema.type === "number" || schema.type === "integer";
219
+ return (
220
+ <Field label={name} description={schema.description}>
221
+ <Input
222
+ aria-label={name}
223
+ disabled={disabled || schema.const !== undefined}
224
+ type={numeric ? "number" : "text"}
225
+ min={schema.minimum}
226
+ max={schema.maximum}
227
+ step={schema.type === "integer" ? 1 : "any"}
228
+ minLength={schema.minLength}
229
+ maxLength={schema.maxLength}
230
+ value={S.is(S.Union([S.String, S.Number]))(value) ? value : ""}
231
+ placeholder={
232
+ schema.format === "fungi:file" ? "Computer file path" : undefined
233
+ }
234
+ onChange={(event) => {
235
+ onChange(
236
+ numeric
237
+ ? Number.isFinite(event.currentTarget.valueAsNumber)
238
+ ? event.currentTarget.valueAsNumber
239
+ : null
240
+ : event.currentTarget.value,
241
+ );
242
+ }}
243
+ />
244
+ </Field>
245
+ );
246
+ }
247
+
248
+ /** Explain declared expectations; never expose the validator's schema machinery. */
249
+ function inputExpectation(schema: PortSchema, value: Json | undefined) {
250
+ if (value === undefined) return "is required.";
251
+ const expected = (item: Json) =>
252
+ S.is(S.String)(item) ? item : JSON.stringify(item);
253
+ if (schema.enum && !accepts({ enum: schema.enum }, value))
254
+ return `must be one of ${schema.enum.map(expected).join(", ")}.`;
255
+ if (schema.const !== undefined && !accepts({ const: schema.const }, value))
256
+ return `must be ${expected(schema.const)}.`;
257
+ if (schema.type && !accepts({ type: schema.type }, value)) {
258
+ const names = {
259
+ string: "text",
260
+ number: "a number",
261
+ integer: "a whole number",
262
+ boolean: "yes or no",
263
+ array: "a list",
264
+ object: "a group of fields",
265
+ null: "empty",
266
+ };
267
+ return `must be ${names[schema.type]}.`;
268
+ }
269
+ if (
270
+ schema.minLength !== undefined &&
271
+ !accepts({ minLength: schema.minLength }, value)
272
+ )
273
+ return `must contain at least ${String(schema.minLength)} ${schema.minLength === 1 ? "character" : "characters"}.`;
274
+ if (
275
+ schema.maxLength !== undefined &&
276
+ !accepts({ maxLength: schema.maxLength }, value)
277
+ )
278
+ return `must contain no more than ${String(schema.maxLength)} ${schema.maxLength === 1 ? "character" : "characters"}.`;
279
+ if (
280
+ schema.minItems !== undefined &&
281
+ !accepts({ minItems: schema.minItems }, value)
282
+ )
283
+ return `must contain at least ${String(schema.minItems)} ${schema.minItems === 1 ? "item" : "items"}.`;
284
+ if (
285
+ schema.maxItems !== undefined &&
286
+ !accepts({ maxItems: schema.maxItems }, value)
287
+ )
288
+ return `must contain no more than ${String(schema.maxItems)} ${schema.maxItems === 1 ? "item" : "items"}.`;
289
+ if (
290
+ schema.minimum !== undefined &&
291
+ !accepts({ minimum: schema.minimum }, value)
292
+ )
293
+ return `must be at least ${String(schema.minimum)}.`;
294
+ if (
295
+ schema.maximum !== undefined &&
296
+ !accepts({ maximum: schema.maximum }, value)
297
+ )
298
+ return `must be no more than ${String(schema.maximum)}.`;
299
+ if (
300
+ schema.exclusiveMinimum !== undefined &&
301
+ !accepts({ exclusiveMinimum: schema.exclusiveMinimum }, value)
302
+ )
303
+ return `must be greater than ${String(schema.exclusiveMinimum)}.`;
304
+ if (
305
+ schema.exclusiveMaximum !== undefined &&
306
+ !accepts({ exclusiveMaximum: schema.exclusiveMaximum }, value)
307
+ )
308
+ return `must be less than ${String(schema.exclusiveMaximum)}.`;
309
+ return "needs a valid value that matches this field's requirements.";
310
+ }
311
+
312
+ /** Index with the validator's own pointer encoder, then keep actionable leaf issues. */
313
+ function inputMessages(
314
+ projection: PortSchema,
315
+ raw: Json,
316
+ issues: readonly { path: string }[],
317
+ ) {
318
+ const fields = new Map<
319
+ string,
320
+ {
321
+ schema: PortSchema;
322
+ value: Json | undefined;
323
+ label: string;
324
+ parents: readonly string[];
325
+ }
326
+ >();
327
+ const visit = (
328
+ schema: PortSchema,
329
+ value: Json | undefined,
330
+ path: string,
331
+ words: readonly string[],
332
+ parents: readonly string[],
333
+ ) => {
334
+ const first = words.at(0);
335
+ const rest = words.slice(1);
336
+ const label =
337
+ first === undefined
338
+ ? "Inputs"
339
+ : nameOf(first) +
340
+ (rest.length
341
+ ? `: ${rest.map((word) => nameOf(word).toLowerCase()).join(" · ")}`
342
+ : "");
343
+ fields.set(path, { schema, value, label, parents });
344
+ for (const [key, child] of Object.entries(schema.properties ?? {}))
345
+ visit(
346
+ child,
347
+ S.is(S.JsonObject)(value) ? value[key] : undefined,
348
+ `${path}/${encodePointer(key)}`,
349
+ [...words, key],
350
+ [...parents, path],
351
+ );
352
+ if (schema.items && S.is(S.Array(S.Json))(value))
353
+ value.forEach((item, index) => {
354
+ visit(
355
+ schema.items ?? {},
356
+ item,
357
+ `${path}/${String(index)}`,
358
+ [
359
+ ...words,
360
+ `${singular(words.at(-1) ?? "Item")} ${String(index + 1)}`,
361
+ ],
362
+ [...parents, path],
363
+ );
364
+ });
365
+ };
366
+ visit(projection, raw, "#", [], []);
367
+ const affected = new Set<string>();
368
+ for (const issue of issues) {
369
+ const path = fields.has(issue.path) ? issue.path : "#";
370
+ const field = fields.get(path);
371
+ const missing = (field?.schema.required ?? []).filter(
372
+ (key) =>
373
+ S.is(S.JsonObject)(field?.value) && !Object.hasOwn(field.value, key),
374
+ );
375
+ if (missing.length)
376
+ for (const key of missing) affected.add(`${path}/${encodePointer(key)}`);
377
+ else affected.add(path);
378
+ }
379
+ return [...affected]
380
+ .filter(
381
+ (path) =>
382
+ ![...affected].some(
383
+ (other) =>
384
+ other !== path && fields.get(other)?.parents.includes(path),
385
+ ),
386
+ )
387
+ .map((path) => {
388
+ const field = fields.get(path);
389
+ return field
390
+ ? `${field.label} ${inputExpectation(field.schema, field.value)}`
391
+ : "Check the workflow's required input fields.";
392
+ });
393
+ }
394
+
395
+ /** Caps owns form timing/errors; Braid owns projection and instance validation. */
396
+ export function workflowInputField(projection: PortSchema) {
397
+ const properties = Object.values(projection.properties ?? {});
398
+ const group = properties.length === 1 ? properties.at(0) : undefined;
399
+ const singleGroup =
400
+ group !== undefined &&
401
+ ((group.type === "object" && group.properties !== undefined) ||
402
+ (group.type === "array" &&
403
+ group.items !== undefined &&
404
+ supportsFields(group.items)));
405
+ const definition = { input: S.decodeUnknownSync(S.JsonObject)(projection) };
406
+ const schema = z
407
+ .string()
408
+ .transform((text, ctx) => {
409
+ const parsed = S.decodeUnknownOption(S.fromJsonString(S.Json))(text);
410
+ if (parsed._tag === "None") {
411
+ ctx.addIssue({
412
+ code: "custom",
413
+ message:
414
+ "Check the workflow inputs against their declared requirements.",
415
+ });
416
+ return z.NEVER;
417
+ }
418
+ const admitted = admitRunInput(definition, parsed.value);
419
+ if (admitted.kind === "invalid") {
420
+ for (const message of inputMessages(
421
+ projection,
422
+ parsed.value,
423
+ admitted.issues,
424
+ ))
425
+ ctx.addIssue({
426
+ code: "custom",
427
+ message,
428
+ });
429
+ return z.NEVER;
430
+ }
431
+ return z.record(z.string(), z.json()).parse(admitted.input);
432
+ })
433
+ .meta(
434
+ field<string>({
435
+ label: singleGroup ? "" : "Inputs",
436
+ render: (control) => (
437
+ <InputFields schema={projection} control={control} />
438
+ ),
439
+ }),
440
+ );
441
+ return { schema, defaultValue: JSON.stringify(initial(projection)) };
442
+ }
443
+
444
+ /** Incomplete field JSON stays local and blocks submit; it cannot submit an older accepted value. */
445
+ function JsonInput({
446
+ label,
447
+ schema,
448
+ value,
449
+ disabled,
450
+ onChange,
451
+ onValidity,
452
+ }: {
453
+ label: string;
454
+ schema: PortSchema;
455
+ value: Json | undefined;
456
+ disabled: boolean;
457
+ onChange: (value: Json) => void;
458
+ onValidity: (id: string, valid: boolean) => void;
459
+ }) {
460
+ const id = useId();
461
+ const [text, setText] = useState(() =>
462
+ JSON.stringify(value ?? null, null, 2),
463
+ );
464
+ const [error, setError] = useState(false);
465
+ const latest = useRef(onValidity);
466
+ latest.current = onValidity;
467
+ useEffect(
468
+ () => () => {
469
+ latest.current(id, true);
470
+ },
471
+ [id],
472
+ );
473
+ return (
474
+ <Field
475
+ label={label}
476
+ description={schema.description ?? "Enter a JSON value for this field."}
477
+ error={error ? "Enter valid JSON before running." : undefined}
478
+ >
479
+ <Textarea
480
+ aria-label={label}
481
+ disabled={disabled}
482
+ rows={3}
483
+ value={text}
484
+ onChange={(event) => {
485
+ const next = event.currentTarget.value;
486
+ setText(next);
487
+ const parsed = S.decodeUnknownOption(S.fromJsonString(S.Json))(next);
488
+ const valid = parsed._tag === "Some";
489
+ setError(!valid);
490
+ onValidity(id, valid);
491
+ if (parsed._tag === "Some") onChange(parsed.value);
492
+ }}
493
+ />
494
+ </Field>
495
+ );
496
+ }
497
+
498
+ function InputFields({
499
+ schema,
500
+ control,
501
+ }: {
502
+ schema: PortSchema;
503
+ control: FieldRenderProps<string>;
504
+ }) {
505
+ const [value, setValue] = useState(() =>
506
+ S.decodeUnknownSync(S.fromJsonString(S.Json))(control.value),
507
+ );
508
+ const current = useRef(value);
509
+ const invalid = useRef(new Set<string>());
510
+ const emit = () => {
511
+ control.onChange(
512
+ invalid.current.size ? "" : JSON.stringify(current.current),
513
+ );
514
+ };
515
+ return (
516
+ <div
517
+ id={control.id}
518
+ role="group"
519
+ aria-label={control.label || "Inputs"}
520
+ aria-invalid={control["aria-invalid"]}
521
+ aria-describedby={control["aria-describedby"]}
522
+ onBlur={() => {
523
+ control.onBlur();
524
+ }}
525
+ >
526
+ <InputValue
527
+ label="Inputs"
528
+ root
529
+ schema={schema}
530
+ value={value}
531
+ disabled={control.disabled}
532
+ onValidity={(id, valid) => {
533
+ if (valid) invalid.current.delete(id);
534
+ else invalid.current.add(id);
535
+ emit();
536
+ }}
537
+ onChange={(next) => {
538
+ current.current = next;
539
+ setValue(next);
540
+ emit();
541
+ }}
542
+ />
543
+ </div>
544
+ );
545
+ }
@@ -0,0 +1,41 @@
1
+ import {
2
+ Status,
3
+ type StatusTone,
4
+ } from "@fungi.computer/caps/components/status";
5
+ import type { NodeState } from "./run-state.js";
6
+ const tones = {
7
+ queued: "neutral",
8
+ running: "info",
9
+ waiting: "primary",
10
+ complete: "success",
11
+ failed: "error",
12
+ skipped: "neutral",
13
+ cancelled: "neutral",
14
+ } satisfies Record<NodeState, StatusTone>;
15
+ /**
16
+ * One bead per step state, the same Caps bead the tool timeline wears. `label`
17
+ * words the state for a person; `bare` keeps only the bead and its name.
18
+ */
19
+ export function NodeStatus({
20
+ state,
21
+ label = state,
22
+ bare = false,
23
+ }: {
24
+ state: NodeState;
25
+ label?: string;
26
+ bare?: boolean;
27
+ }) {
28
+ return (
29
+ <Status
30
+ role="img"
31
+ aria-label={label}
32
+ title={bare ? label : undefined}
33
+ {...(bare ? {} : { label })}
34
+ data-state={state}
35
+ glyph="dot"
36
+ motion={state === "running" ? "bounce" : "none"}
37
+ size="compact"
38
+ tone={tones[state]}
39
+ />
40
+ );
41
+ }