@fungi.computer/trellis 0.2.2 → 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 +4 -1
- package/src/history/relative-time.ts +17 -0
- package/src/runs/input-form.tsx +545 -0
- package/src/runs/node-status.tsx +41 -0
- package/src/runs/output-preview.tsx +181 -0
- package/src/runs/public.ts +5 -0
- package/src/runs/review-dock.tsx +147 -0
- package/src/runs/run-state.ts +187 -0
- package/src/runs/run-timeline.css +46 -0
- package/src/runs/run-timeline.tsx +156 -0
- package/src/runs/run-workflow.tsx +199 -0
- package/src/runs/runs.tsx +544 -0
- package/src/runs/timeline-items.ts +196 -0
- package/src/runs/use-run-summaries.ts +84 -0
- package/src/runs/use-run.ts +39 -0
- package/templates/bonsai-build-on-merge.json +134 -0
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.
|
|
4
|
+
"version": "0.2.3",
|
|
5
5
|
"author": "Levi <levi@technomancy.dev>",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
|
@@ -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
|
+
}
|