@foldspace_npm/harness 0.1.26 → 0.1.28
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/CLAUDE.md +402 -204
- package/README.md +84 -36
- package/bin/ask.mjs +19 -9
- package/bin/attach.mjs +91 -56
- package/bin/badge.mjs +28 -8
- package/bin/cli.mjs +23 -6
- package/bin/inject.mjs +3 -4
- package/bin/observe.mjs +22 -6
- package/bin/published.mjs +141 -0
- package/package.json +6 -2
- package/recipes/INDEX.md +13 -8
- package/recipes/README.md +30 -7
- package/recipes/account-overview/README.md +6 -6
- package/recipes/account-overview/agent/actions/show_account_overview.ts +11 -16
- package/recipes/account-overview/agent/views/brand.ts +23 -14
- package/recipes/account-overview/agent/views/overview.ts +13 -17
- package/recipes/bottom-bar/README.md +12 -9
- package/recipes/bottom-bar/agent/bottomBar.ts +14 -12
- package/recipes/create-a-record/README.md +97 -0
- package/recipes/create-a-record/agent/actions/create_project.ts +26 -0
- package/recipes/create-a-record/agent/api/projects.ts +62 -0
- package/recipes/create-a-record/agent/projects.ts +145 -0
- package/recipes/create-a-record/agent/views/approval.ts +181 -0
- package/recipes/create-a-record/agent/views/brand.ts +35 -0
- package/recipes/create-a-record/agent/views/fields.ts +252 -0
- package/recipes/create-a-record/agent/views/project-card.ts +253 -0
- package/recipes/create-a-record/agent/views/steps.ts +92 -0
- package/recipes/create-a-record/fixtures/owners.ok.json +9 -0
- package/recipes/create-a-record/fixtures/project.created.json +1 -0
- package/recipes/create-a-record/fixtures/project.rejected.json +1 -0
- package/recipes/create-a-record/recipe.json +13 -0
- package/recipes/create-a-record/variants/actions/create_project.steps.ts +160 -0
- package/recipes/find-by-name/README.md +3 -1
- package/recipes/find-by-name/agent/actions/find_project_id.ts +1 -1
- package/recipes/lookup-then-view/README.md +73 -0
- package/recipes/lookup-then-view/agent/accounts.ts +174 -0
- package/recipes/lookup-then-view/agent/actions/find_account.ts +108 -0
- package/recipes/lookup-then-view/agent/actions/show_account_overview.ts +42 -0
- package/recipes/lookup-then-view/agent/api/accounts.ts +68 -0
- package/recipes/lookup-then-view/agent/views/account.ts +183 -0
- package/recipes/lookup-then-view/agent/views/brand.ts +35 -0
- package/recipes/lookup-then-view/agent/views/format.ts +73 -0
- package/recipes/lookup-then-view/agent/views/pick-one.ts +240 -0
- package/recipes/lookup-then-view/fixtures/accounts.json +319 -0
- package/recipes/lookup-then-view/fixtures/overview.ok.json +122 -0
- package/recipes/lookup-then-view/fixtures/overview.unsigned.json +14 -0
- package/recipes/lookup-then-view/recipe.json +11 -0
- package/recipes/lookup-then-view/studio/find_account.json +10 -0
- package/recipes/lookup-then-view/studio/show_account_overview.json +15 -0
- package/recipes/opportunities-at-risk/README.md +5 -5
- package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +9 -7
- package/recipes/opportunities-at-risk/agent/opportunities.ts +11 -10
- package/recipes/opportunities-at-risk/agent/views/at-risk.ts +1 -1
- package/recipes/opportunities-at-risk/agent/views/brand.ts +26 -15
- package/recipes/pick-from-a-list/README.md +7 -6
- package/recipes/pick-from-a-list/agent/actions/choose_project.ts +27 -12
- package/recipes/pick-from-a-list/agent/views/brand.ts +30 -9
- package/recipes/pick-from-a-list/agent/views/picker.ts +3 -3
- package/recipes/prepare-for-a-meeting/README.md +4 -4
- package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +3 -3
- package/recipes/records-list/README.md +6 -6
- package/recipes/records-list/agent/accounts.ts +3 -1
- package/recipes/records-list/agent/actions/list_accounts.ts +7 -7
- package/recipes/records-list/agent/views/brand.ts +26 -14
- package/recipes/server-rendered-forms/README.md +35 -0
- package/recipes/server-rendered-forms/agent/actions/create_record_via_form.ts +62 -0
- package/recipes/server-rendered-forms/agent/api/pages.ts +57 -0
- package/recipes/server-rendered-forms/agent/forms.ts +192 -0
- package/recipes/server-rendered-forms/fixtures/form.errors.html +9 -0
- package/recipes/server-rendered-forms/fixtures/form.html +17 -0
- package/recipes/server-rendered-forms/fixtures/list.html +9 -0
- package/recipes/server-rendered-forms/fixtures/record.html +4 -0
- package/recipes/server-rendered-forms/recipe.json +10 -0
- package/recipes/several-hosts/README.md +10 -13
- package/recipes/several-hosts/agent/hosts.ts +19 -136
- package/recipes/several-hosts/fixtures/wrong-page.json +6 -0
- package/recipes/several-hosts/recipe.json +3 -3
- package/recipes/swap-the-login-method/README.md +16 -5
- package/recipes/swap-the-login-method/agent/utils.ts +52 -3
- package/recipes/swap-the-login-method/variants/utils.cookies.ts +59 -6
- package/recipes/update-meeting-notes/README.md +10 -4
- package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +16 -5
- package/recipes/upload-contacts/README.md +5 -5
- package/recipes/upload-contacts/agent/actions/upload_contacts.ts +7 -7
- package/recipes/upload-contacts/agent/views/brand.ts +24 -14
- package/recipes/upload-contacts/agent/views/uploader.ts +28 -17
- package/recipes/who-is-the-user/README.md +7 -3
- package/src/ask-core.mjs +16 -4
- package/src/attach-daemon.mjs +9 -2
- package/src/attach-helpers.mjs +105 -0
- package/src/attach-preflight.mjs +34 -11
- package/src/badge-core.mjs +1 -1
- package/src/cdp-client.mjs +15 -19
- package/src/cdp-ownership.mjs +41 -20
- package/src/cli-help.mjs +7 -5
- package/src/cli-registry.mjs +102 -47
- package/src/diagnostics.mjs +1 -1
- package/src/init.mjs +5 -3
- package/src/keep-focus.mjs +17 -13
- package/src/lint/index.mjs +8 -1
- package/src/lint/rules.mjs +240 -43
- package/src/observe-core.mjs +163 -34
- package/src/published-core.mjs +392 -0
- package/src/region.mjs +1 -1
- package/src/runtime/agent.ts +16 -12
- package/src/runtime/http.ts +25 -15
- package/src/runtime/match.ts +1 -1
- package/src/runtime/render.ts +4 -4
- package/src/transports/README.md +2 -2
- package/src/upgrade.mjs +33 -10
- package/templates/agent-starter/CLAUDE.md +8 -5
- package/templates/agent-starter/README.md +32 -33
- package/templates/agent-starter/agent/actions/_example.ts +4 -4
- package/templates/agent-starter/agent/actions/index.ts +2 -2
- package/templates/agent-starter/agent/constants.ts +4 -3
- package/templates/agent-starter/agent/utils.ts +15 -18
- package/templates/agent-starter/claude-settings.json +2 -1
- package/templates/agent-starter/docs/app-profile.md +19 -14
- package/templates/agent-starter/foldspace.dev.json +1 -1
- package/recipes/several-hosts/fixtures/nav-record.json +0 -5
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Editable fields for an approval card. The input type follows the API, not convenience:
|
|
2
|
+
// free text in the API -> a text box; a limited set (often read from the app) -> a dropdown;
|
|
3
|
+
// a single possible value -> drawn as that value, read-only, and still sent;
|
|
4
|
+
// a choice that decides what comes next -> option cards, each with a caption.
|
|
5
|
+
// Every field starts pre-filled with what the user already said (the action's parameters),
|
|
6
|
+
// or the action's default. textContent / value only - never innerHTML.
|
|
7
|
+
|
|
8
|
+
import { brand } from "./brand";
|
|
9
|
+
import { rankBy } from "../utils";
|
|
10
|
+
|
|
11
|
+
export type Option = {
|
|
12
|
+
value: string;
|
|
13
|
+
label: string;
|
|
14
|
+
/** One line under the label, on option cards only: what choosing it means. */
|
|
15
|
+
caption?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type Base = {
|
|
19
|
+
key: string;
|
|
20
|
+
label: string;
|
|
21
|
+
hint?: string;
|
|
22
|
+
/** Hidden (and not sent) unless this returns true for the current values. */
|
|
23
|
+
showWhen?: (values: Values) => boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type Field =
|
|
27
|
+
| (Base & {
|
|
28
|
+
kind: "text";
|
|
29
|
+
value: string;
|
|
30
|
+
multiline?: boolean;
|
|
31
|
+
maxLength?: number;
|
|
32
|
+
required?: boolean;
|
|
33
|
+
/** An error to show beside the field, or null when the value is acceptable. */
|
|
34
|
+
check?: (value: string) => string | null;
|
|
35
|
+
})
|
|
36
|
+
| (Base & { kind: "checkbox"; value: boolean })
|
|
37
|
+
/** A required dropdown with exactly one option is drawn as that value, read-only. */
|
|
38
|
+
| (Base & { kind: "select"; value: string; options: Option[]; required?: boolean; placeholder?: string })
|
|
39
|
+
/** Option cards: a short choice where each option needs a caption to be understood. */
|
|
40
|
+
| (Base & { kind: "choice"; value: string; options: Option[]; required?: boolean });
|
|
41
|
+
|
|
42
|
+
export type Values = Record<string, string | boolean>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The option the user's own words point at, so a dropdown opens on what they asked for.
|
|
46
|
+
* Returns the option's value, or "" when the words match no option or more than one.
|
|
47
|
+
*/
|
|
48
|
+
export function matchOption(wanted: string, options: Option[]): string {
|
|
49
|
+
const norm = (value: string) => value.toLowerCase().replace(/\s+/g, " ").trim();
|
|
50
|
+
const target = norm(wanted);
|
|
51
|
+
if (!target) return "";
|
|
52
|
+
// Exact first, then "contains" - and only a single hit counts: when unsure, the user picks
|
|
53
|
+
// from the list rather than approving a guess they may not notice.
|
|
54
|
+
const exact = options.filter((o) => norm(o.label) === target || norm(o.value) === target);
|
|
55
|
+
if (exact.length) return exact.length === 1 ? exact[0].value : "";
|
|
56
|
+
const partial = options.filter((o) => norm(o.label).includes(target));
|
|
57
|
+
if (partial.length) return partial.length === 1 ? partial[0].value : "";
|
|
58
|
+
// Nothing contains the words: a typo. rankBy's fuzzy pass (the SDK's searchInList) keeps
|
|
59
|
+
// only plausible hits; one of them is a pick, several are a choice for the user.
|
|
60
|
+
const fuzzy = rankBy(options, wanted, { searchKeys: ["label"], idOf: (o) => o.value });
|
|
61
|
+
return fuzzy.length === 1 ? fuzzy[0].value : "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const CSS = `
|
|
65
|
+
.fs-form{display:grid;gap:10px;padding:12px 14px;border-bottom:1px solid ${brand.border}}
|
|
66
|
+
.fs-form-row{display:grid;gap:4px}
|
|
67
|
+
.fs-form-label{font-size:12px;color:${brand.muted};font-weight:500}
|
|
68
|
+
.fs-form-input{font-family:${brand.font};font-size:13px;color:${brand.text};background:#FFFFFF;border:1px solid ${brand.border};
|
|
69
|
+
border-radius:6px;padding:6px 8px;width:100%;box-sizing:border-box}
|
|
70
|
+
.fs-form-input:focus{outline:none;border-color:${brand.accent}}
|
|
71
|
+
textarea.fs-form-input{min-height:64px;resize:vertical}
|
|
72
|
+
.fs-form-check{display:flex;align-items:center;gap:8px;font-size:13px;color:${brand.text}}
|
|
73
|
+
.fs-form-hint{font-size:11px;color:${brand.muted}}
|
|
74
|
+
.fs-form-error{font-size:11px;color:#B91C1C}
|
|
75
|
+
.fs-form-fixed{font-size:13px;color:${brand.text};font-weight:500}
|
|
76
|
+
.fs-form-choices{display:grid;gap:8px}
|
|
77
|
+
.fs-form-choice{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border:1px solid ${brand.border};border-radius:8px;
|
|
78
|
+
background:${brand.surface};cursor:pointer}
|
|
79
|
+
.fs-form-choice.active{border-color:${brand.accent};background:${brand.surface2}}
|
|
80
|
+
.fs-form-choice input{margin:2px 0 0;flex:none}
|
|
81
|
+
.fs-form-choice-text{display:grid;gap:2px}
|
|
82
|
+
.fs-form-choice-label{font-size:13px;font-weight:600;color:${brand.text}}
|
|
83
|
+
.fs-form-choice-caption{font-size:12px;color:${brand.muted};line-height:1.4}
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
function el(tag: string, className: string, text?: string): HTMLElement {
|
|
87
|
+
const node = document.createElement(tag);
|
|
88
|
+
node.className = className;
|
|
89
|
+
if (text !== undefined) node.textContent = text;
|
|
90
|
+
return node;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type Form = {
|
|
94
|
+
node: HTMLElement;
|
|
95
|
+
style: HTMLElement;
|
|
96
|
+
/** Current values of the fields that are shown. */
|
|
97
|
+
values: () => Values;
|
|
98
|
+
/** Keys whose value differs from what the field started with. */
|
|
99
|
+
edited: () => string[];
|
|
100
|
+
/** True when every shown field passes its check. */
|
|
101
|
+
valid: () => boolean;
|
|
102
|
+
onChange: (listener: () => void) => void;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export function buildForm(fields: Field[]): Form {
|
|
106
|
+
const style = el("style", "");
|
|
107
|
+
style.textContent = CSS;
|
|
108
|
+
const node = el("div", "fs-form");
|
|
109
|
+
const listeners: Array<() => void> = [];
|
|
110
|
+
const changed = () => listeners.forEach((listener) => listener());
|
|
111
|
+
const rows = new Map<string, { row: HTMLElement; read: () => string | boolean; error: HTMLElement | null; field: Field }>();
|
|
112
|
+
/** What each field started with: edited() compares against this. */
|
|
113
|
+
const initial = new Map<string, string | boolean>();
|
|
114
|
+
|
|
115
|
+
for (const field of fields) {
|
|
116
|
+
const row = el("div", "fs-form-row");
|
|
117
|
+
let read: () => string | boolean;
|
|
118
|
+
let error: HTMLElement | null = null;
|
|
119
|
+
if (field.kind === "checkbox") {
|
|
120
|
+
const label = el("label", "fs-form-check");
|
|
121
|
+
const box = document.createElement("input") as HTMLInputElement;
|
|
122
|
+
box.type = "checkbox";
|
|
123
|
+
box.checked = field.value;
|
|
124
|
+
box.addEventListener("change", changed);
|
|
125
|
+
label.append(box, document.createTextNode(field.label));
|
|
126
|
+
row.append(label);
|
|
127
|
+
read = () => box.checked;
|
|
128
|
+
} else if (field.kind === "select" && field.required && field.options.length === 1) {
|
|
129
|
+
// Only one possible value: nothing to choose, so it is shown, not asked.
|
|
130
|
+
const only = field.options[0];
|
|
131
|
+
row.append(el("label", "fs-form-label", field.label), el("div", "fs-form-fixed", only.label));
|
|
132
|
+
read = () => only.value;
|
|
133
|
+
} else if (field.kind === "choice") {
|
|
134
|
+
if (field.label) row.append(el("label", "fs-form-label", field.label));
|
|
135
|
+
const group = el("div", "fs-form-choices");
|
|
136
|
+
// A unique group name: other cards in the same conversation have radios too.
|
|
137
|
+
const name = `fs-choice-${field.key}-${Math.random().toString(36).slice(2, 8)}`;
|
|
138
|
+
const cards: Array<{ card: HTMLElement; radio: HTMLInputElement }> = [];
|
|
139
|
+
const paint = () => cards.forEach(({ card, radio }) => (card.className = radio.checked ? "fs-form-choice active" : "fs-form-choice"));
|
|
140
|
+
for (const option of field.options) {
|
|
141
|
+
const card = el("label", "fs-form-choice");
|
|
142
|
+
const radio = document.createElement("input") as HTMLInputElement;
|
|
143
|
+
radio.type = "radio";
|
|
144
|
+
radio.name = name;
|
|
145
|
+
radio.value = option.value;
|
|
146
|
+
radio.checked = option.value === field.value;
|
|
147
|
+
radio.addEventListener("change", () => {
|
|
148
|
+
paint();
|
|
149
|
+
changed();
|
|
150
|
+
});
|
|
151
|
+
const words = el("span", "fs-form-choice-text");
|
|
152
|
+
words.append(el("span", "fs-form-choice-label", option.label));
|
|
153
|
+
if (option.caption) words.append(el("span", "fs-form-choice-caption", option.caption));
|
|
154
|
+
card.append(radio, words);
|
|
155
|
+
group.append(card);
|
|
156
|
+
cards.push({ card, radio });
|
|
157
|
+
}
|
|
158
|
+
paint();
|
|
159
|
+
row.append(group);
|
|
160
|
+
read = () => cards.find(({ radio }) => radio.checked)?.radio.value ?? "";
|
|
161
|
+
} else {
|
|
162
|
+
row.append(el("label", "fs-form-label", field.label));
|
|
163
|
+
let input: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
|
164
|
+
if (field.kind === "select") {
|
|
165
|
+
const select = document.createElement("select") as HTMLSelectElement;
|
|
166
|
+
if (!field.value) {
|
|
167
|
+
const none = document.createElement("option") as HTMLOptionElement;
|
|
168
|
+
none.value = "";
|
|
169
|
+
none.textContent = field.placeholder ?? "Choose one";
|
|
170
|
+
none.disabled = true;
|
|
171
|
+
select.append(none);
|
|
172
|
+
}
|
|
173
|
+
for (const option of field.options) {
|
|
174
|
+
const item = document.createElement("option") as HTMLOptionElement;
|
|
175
|
+
item.value = option.value;
|
|
176
|
+
item.textContent = option.label;
|
|
177
|
+
select.append(item);
|
|
178
|
+
}
|
|
179
|
+
select.value = field.value;
|
|
180
|
+
input = select;
|
|
181
|
+
} else if (field.multiline) {
|
|
182
|
+
input = document.createElement("textarea") as HTMLTextAreaElement;
|
|
183
|
+
} else {
|
|
184
|
+
// Not `instanceof HTMLInputElement`: the card's elements belong to the panel's frame.
|
|
185
|
+
const text = document.createElement("input") as HTMLInputElement;
|
|
186
|
+
text.type = "text";
|
|
187
|
+
input = text;
|
|
188
|
+
}
|
|
189
|
+
if (field.kind === "text") {
|
|
190
|
+
if (field.maxLength) (input as HTMLInputElement).maxLength = field.maxLength;
|
|
191
|
+
input.value = field.value;
|
|
192
|
+
}
|
|
193
|
+
input.className = "fs-form-input";
|
|
194
|
+
input.addEventListener("input", changed);
|
|
195
|
+
input.addEventListener("change", changed);
|
|
196
|
+
row.append(input);
|
|
197
|
+
read = () => input.value;
|
|
198
|
+
error = el("div", "fs-form-error");
|
|
199
|
+
error.style.display = "none";
|
|
200
|
+
row.append(error);
|
|
201
|
+
}
|
|
202
|
+
if (field.hint) row.append(el("div", "fs-form-hint", field.hint));
|
|
203
|
+
node.append(row);
|
|
204
|
+
rows.set(field.key, { row, read, error, field });
|
|
205
|
+
initial.set(field.key, read());
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const all = (): Values => Object.fromEntries(Array.from(rows, ([key, r]) => [key, r.read()]));
|
|
209
|
+
const shown = (key: string, values: Values) => rows.get(key)?.field.showWhen?.(values) ?? true;
|
|
210
|
+
|
|
211
|
+
function problem(field: Field, value: string | boolean): string | null {
|
|
212
|
+
if (field.kind === "checkbox") return null;
|
|
213
|
+
const text = String(value).trim();
|
|
214
|
+
if (field.kind === "choice") return field.required && !text ? "Choose one." : null;
|
|
215
|
+
// The dropdown's own placeholder ("Choose an owner"), so the article is never wrong.
|
|
216
|
+
if (field.required && !text) return field.kind === "select" ? `${field.placeholder ?? "Choose one"}.` : `${field.label} is required.`;
|
|
217
|
+
if (field.kind === "text" && text && field.check) return field.check(text);
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function refresh(): void {
|
|
222
|
+
const values = all();
|
|
223
|
+
for (const [key, r] of rows) {
|
|
224
|
+
r.row.style.display = shown(key, values) ? "" : "none";
|
|
225
|
+
if (r.error) {
|
|
226
|
+
const message = shown(key, values) ? problem(r.field, values[key]) : null;
|
|
227
|
+
r.error.textContent = message ?? "";
|
|
228
|
+
r.error.style.display = message ? "" : "none";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
listeners.push(refresh);
|
|
233
|
+
refresh();
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
node,
|
|
237
|
+
style,
|
|
238
|
+
values: () => {
|
|
239
|
+
const values = all();
|
|
240
|
+
return Object.fromEntries(Object.entries(values).filter(([key]) => shown(key, values)));
|
|
241
|
+
},
|
|
242
|
+
edited: () => {
|
|
243
|
+
const values = all();
|
|
244
|
+
return fields.filter((f) => shown(f.key, values) && values[f.key] !== initial.get(f.key)).map((f) => f.key);
|
|
245
|
+
},
|
|
246
|
+
valid: () => {
|
|
247
|
+
const values = all();
|
|
248
|
+
return fields.every((f) => !shown(f.key, values) || problem(f, values[f.key]) === null);
|
|
249
|
+
},
|
|
250
|
+
onChange: (listener) => listeners.push(listener),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// The review card every way of creating a project ends on: the editable fields,
|
|
2
|
+
// then the write on Create project, then the outcome - and callback, exactly once,
|
|
3
|
+
// on every path. Both versions of the action draw it; they differ only in what they
|
|
4
|
+
// ask first.
|
|
5
|
+
// What goes back says what happened, never what to do next.
|
|
6
|
+
|
|
7
|
+
import { createProject, type ProjectDraft } from "../api/projects";
|
|
8
|
+
import { STATUSES, draftFrom, projectFields, type Proposal } from "../projects";
|
|
9
|
+
import { renderFailure, type ApiFailure, type FailureReason } from "../utils";
|
|
10
|
+
import { mountApproval, mountOutcome } from "./approval";
|
|
11
|
+
import type { Field, Option, Values } from "./fields";
|
|
12
|
+
|
|
13
|
+
export type Callback = (value: unknown, disableOnSubmit?: boolean) => void;
|
|
14
|
+
|
|
15
|
+
export type Answer = {
|
|
16
|
+
success: boolean;
|
|
17
|
+
message: string;
|
|
18
|
+
/** The card's own words for a refusal. Never the runtime's sentence ("Your session has expired."): `reason` says why. */
|
|
19
|
+
error?: string;
|
|
20
|
+
reason?: FailureReason | "rejected" | "render";
|
|
21
|
+
data: {
|
|
22
|
+
/** `unconfirmed`: the app answered without an id, or no answer came back from it (a timeout, a gateway's 504, a call that got no response, or one that threw) - so it is not known whether the project exists. */
|
|
23
|
+
status: "created" | "not_created" | "unconfirmed" | "cancelled";
|
|
24
|
+
/** Left out when it is not known (`unconfirmed`). */
|
|
25
|
+
created?: boolean;
|
|
26
|
+
/** The new project's id, for a navigation route to its page. */
|
|
27
|
+
id?: string;
|
|
28
|
+
/** What was written - the card may have changed what the agent asked for. */
|
|
29
|
+
name?: string;
|
|
30
|
+
key?: string;
|
|
31
|
+
owner?: string;
|
|
32
|
+
project_status?: string;
|
|
33
|
+
private?: boolean;
|
|
34
|
+
kind?: string;
|
|
35
|
+
client?: string;
|
|
36
|
+
/** Fields the user changed on the card before approving. */
|
|
37
|
+
edited?: string[];
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const KICKER = "New project";
|
|
42
|
+
|
|
43
|
+
export function footerOf(proposal: Proposal): string {
|
|
44
|
+
return `Projects · ${proposal.workspace || "Workspace"}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function summary(
|
|
48
|
+
status: "created" | "not_created" | "unconfirmed",
|
|
49
|
+
draft: ProjectDraft,
|
|
50
|
+
owners: Option[],
|
|
51
|
+
edited: string[],
|
|
52
|
+
id?: string,
|
|
53
|
+
): Answer["data"] {
|
|
54
|
+
return {
|
|
55
|
+
status,
|
|
56
|
+
created: status === "unconfirmed" ? undefined : status === "created",
|
|
57
|
+
id,
|
|
58
|
+
name: draft.name,
|
|
59
|
+
key: draft.key,
|
|
60
|
+
owner: owners.find((option) => option.value === draft.ownerId)?.label,
|
|
61
|
+
project_status: STATUSES.find((option) => option.value === draft.status)?.label,
|
|
62
|
+
private: draft.private,
|
|
63
|
+
kind: draft.kind,
|
|
64
|
+
client: draft.clientName ?? undefined,
|
|
65
|
+
edited: edited.length ? edited : undefined,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Draws, then answers - in `finally`, so a card that throws while drawing still
|
|
71
|
+
* answers the agent, which is otherwise left waiting. `fallback` is the plain line
|
|
72
|
+
* shown instead.
|
|
73
|
+
*/
|
|
74
|
+
function answerAfter(host: HTMLElement, callback: Callback, answer: Answer, fallback: string, draw: () => void): void {
|
|
75
|
+
try {
|
|
76
|
+
draw();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.warn("[create_project] render", error);
|
|
79
|
+
host.textContent = fallback;
|
|
80
|
+
} finally {
|
|
81
|
+
callback(answer, true);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The owners could not be loaded: nothing to propose. */
|
|
86
|
+
export function mountLoadFailure(host: HTMLElement, proposal: Proposal | undefined, callback: Callback): void {
|
|
87
|
+
const reason = proposal?.reason ?? "server";
|
|
88
|
+
const error = proposal?.error ?? "The project form could not be loaded.";
|
|
89
|
+
const answer: Answer = {
|
|
90
|
+
success: false,
|
|
91
|
+
message: "The project form could not be loaded.",
|
|
92
|
+
reason,
|
|
93
|
+
data: { status: "not_created", created: false },
|
|
94
|
+
};
|
|
95
|
+
answerAfter(host, callback, answer, error, () => renderFailure(host, { ok: false, status: 0, error, reason }));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function mountCancelled(host: HTMLElement, header: HTMLElement, proposal: Proposal, callback: Callback): void {
|
|
99
|
+
const answer: Answer = {
|
|
100
|
+
success: true,
|
|
101
|
+
message: "The user cancelled. Nothing was created.",
|
|
102
|
+
data: { status: "cancelled", created: false },
|
|
103
|
+
};
|
|
104
|
+
answerAfter(host, callback, answer, "Cancelled. Nothing was created.", () =>
|
|
105
|
+
mountOutcome(host, header, {
|
|
106
|
+
tone: "danger",
|
|
107
|
+
status: "Cancelled",
|
|
108
|
+
kicker: KICKER,
|
|
109
|
+
headline: proposal.name || "Untitled project",
|
|
110
|
+
facts: [],
|
|
111
|
+
note: "Nothing was created.",
|
|
112
|
+
footer: footerOf(proposal),
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Drawing failed. Nothing was written, and the agent is still told. */
|
|
118
|
+
export function drawFailed(host: HTMLElement, callback: Callback, error: unknown): void {
|
|
119
|
+
console.warn("[create_project] render", error);
|
|
120
|
+
host.textContent = "The project card could not be shown. Nothing was created.";
|
|
121
|
+
const answer: Answer = {
|
|
122
|
+
success: false,
|
|
123
|
+
message: "The project card could not be shown. Nothing was created.",
|
|
124
|
+
reason: "render",
|
|
125
|
+
data: { status: "not_created", created: false },
|
|
126
|
+
};
|
|
127
|
+
callback(answer, true);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type CardExtras = {
|
|
131
|
+
/** "Client project" - what was chosen before this card, if anything. */
|
|
132
|
+
kicker?: string;
|
|
133
|
+
/** Fields only this kind of project has, placed after the owner. */
|
|
134
|
+
fields?: Field[];
|
|
135
|
+
/** Read-only lines: values with only one possibility, or a choice already made. */
|
|
136
|
+
facts?: Array<[string, string]>;
|
|
137
|
+
/** The extra fields' values for the create call. */
|
|
138
|
+
draft?: (values: Values) => Partial<ProjectDraft>;
|
|
139
|
+
/** Fields the user already changed before this card (on earlier steps). */
|
|
140
|
+
edited?: string[];
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export function mountProjectCard(
|
|
144
|
+
host: HTMLElement,
|
|
145
|
+
header: HTMLElement,
|
|
146
|
+
proposal: Proposal,
|
|
147
|
+
callback: Callback,
|
|
148
|
+
extras: CardExtras = {},
|
|
149
|
+
): void {
|
|
150
|
+
const owners = proposal.owner_options ?? [];
|
|
151
|
+
const footer = footerOf(proposal);
|
|
152
|
+
const kicker = extras.kicker ?? KICKER;
|
|
153
|
+
|
|
154
|
+
// One of three: an id came back (created), the app answered with an error or the call
|
|
155
|
+
// was never made (failure), or neither - then nobody knows whether the project exists,
|
|
156
|
+
// and `unknown` says why.
|
|
157
|
+
const finish = (draft: ProjectDraft, edited: string[], result: { id?: string; failure?: ApiFailure; unknown?: string }) => {
|
|
158
|
+
const { id, failure } = result;
|
|
159
|
+
// The record may exist now, so whatever happens while drawing, the agent hears it.
|
|
160
|
+
if (id) {
|
|
161
|
+
const data = summary("created", draft, owners, edited, id);
|
|
162
|
+
const answer: Answer = { success: true, message: `Created project ${draft.name}.`, data };
|
|
163
|
+
answerAfter(host, callback, answer, `Project created: ${draft.name}.`, () =>
|
|
164
|
+
mountOutcome(host, header, {
|
|
165
|
+
tone: "success",
|
|
166
|
+
status: "Project created",
|
|
167
|
+
kicker,
|
|
168
|
+
headline: draft.name,
|
|
169
|
+
facts: [
|
|
170
|
+
["Key", draft.key],
|
|
171
|
+
["Owner", data.owner ?? ""],
|
|
172
|
+
["Client", draft.clientName ?? ""],
|
|
173
|
+
["Status", data.project_status ?? ""],
|
|
174
|
+
["Visibility", draft.private ? "Private" : "Everyone in the workspace"],
|
|
175
|
+
],
|
|
176
|
+
footer,
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (!failure) {
|
|
182
|
+
const unknown = result.unknown ?? "The app answered without a project id, so it is not known whether the project was created.";
|
|
183
|
+
// `unknown` says why, for the agent. The card says only what the user needs to know.
|
|
184
|
+
const note = "It is not known whether the project was created. Check the projects list before you create it again.";
|
|
185
|
+
const answer: Answer = { success: false, message: unknown, data: summary("unconfirmed", draft, owners, edited) };
|
|
186
|
+
answerAfter(host, callback, answer, note, () =>
|
|
187
|
+
mountOutcome(host, header, { tone: "warning", status: "Not confirmed", kicker, headline: draft.name, facts: [], note, footer }),
|
|
188
|
+
);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// 400 and 422 are the app refusing these values. Its own words stay in the console:
|
|
192
|
+
// `detail` is never drawn and never returned. Any other failure draws the runtime's
|
|
193
|
+
// sentence on the card and hands the agent only its `reason`.
|
|
194
|
+
const rejected = failure.status === 400 || failure.status === 422;
|
|
195
|
+
if (failure.detail) console.warn("[create_project] create", failure.status, failure.detail);
|
|
196
|
+
const error = rejected ? "The app did not accept these values." : failure.error;
|
|
197
|
+
const answer: Answer = {
|
|
198
|
+
success: false,
|
|
199
|
+
message: "The project was not created.",
|
|
200
|
+
...(rejected ? { error } : {}),
|
|
201
|
+
reason: rejected ? "rejected" : (failure.reason ?? "server"),
|
|
202
|
+
data: summary("not_created", draft, owners, edited),
|
|
203
|
+
};
|
|
204
|
+
answerAfter(host, callback, answer, `Not created. ${error}`, () =>
|
|
205
|
+
mountOutcome(host, header, { tone: "danger", status: "Not created", kicker, headline: draft.name, facts: [], note: error, footer }),
|
|
206
|
+
);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
mountApproval(
|
|
210
|
+
host,
|
|
211
|
+
header,
|
|
212
|
+
{
|
|
213
|
+
tone: "warning",
|
|
214
|
+
status: "", // no pill: the buttons are the ask
|
|
215
|
+
kicker,
|
|
216
|
+
headline: proposal.name || "Untitled project",
|
|
217
|
+
// One possible value, so a fact, not a field.
|
|
218
|
+
facts: [...(extras.facts ?? []), ["Workspace", proposal.workspace || "Workspace"]],
|
|
219
|
+
footer,
|
|
220
|
+
},
|
|
221
|
+
{ approve: "Create project", working: "Creating the project..." },
|
|
222
|
+
projectFields(proposal, extras.fields),
|
|
223
|
+
{
|
|
224
|
+
onApprove: (values, changedHere) => {
|
|
225
|
+
const edited = [...new Set([...(extras.edited ?? []), ...changedHere])];
|
|
226
|
+
const draft: ProjectDraft = { ...draftFrom(values), ...(extras.draft?.(values) ?? {}) };
|
|
227
|
+
void createProject(draft).then(
|
|
228
|
+
(res) => {
|
|
229
|
+
// A timeout gave up waiting, and a 504 is a gateway giving up. Neither is the app
|
|
230
|
+
// saying no: the project may exist.
|
|
231
|
+
if (!res.ok && (res.reason === "timeout" || res.status === 504)) {
|
|
232
|
+
return finish(draft, edited, { unknown: "The app did not answer in time, so it is not known whether the project was created." });
|
|
233
|
+
}
|
|
234
|
+
// `unreachable` is a call that got no answer at all. The connection can fail after
|
|
235
|
+
// the app received the request, so this is not the app saying no either.
|
|
236
|
+
if (!res.ok && res.reason === "unreachable") {
|
|
237
|
+
return finish(draft, edited, { unknown: "No answer came back from the app, so it is not known whether the project was created." });
|
|
238
|
+
}
|
|
239
|
+
if (!res.ok) return finish(draft, edited, { failure: res });
|
|
240
|
+
finish(draft, edited, { id: res.data?.id ? String(res.data.id) : undefined });
|
|
241
|
+
},
|
|
242
|
+
(error) => {
|
|
243
|
+
// The call threw: it may or may not have reached the app.
|
|
244
|
+
console.warn("[create_project] create", error);
|
|
245
|
+
finish(draft, edited, { unknown: "No answer came back from the app, so it is not known whether the project was created." });
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
},
|
|
249
|
+
onCancel: () => mountCancelled(host, header, proposal, callback),
|
|
250
|
+
},
|
|
251
|
+
"name",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// One question per card. Each field gets its own step, pre-filled with what the user
|
|
2
|
+
// already said, so they confirm or change one thing at a time. A step is skipped only
|
|
3
|
+
// when there is nothing to ask: a field with one possible value (kept, and shown on the
|
|
4
|
+
// review), or one that does not apply to the answers so far (its showWhen). Every step
|
|
5
|
+
// has Cancel. Nothing is written here - the caller ends on a review card. A step that
|
|
6
|
+
// throws while it is drawn after Continue goes to onFailed; a throw on the first step
|
|
7
|
+
// reaches the caller's own catch. Either way the caller can still answer the agent.
|
|
8
|
+
|
|
9
|
+
import { mountApproval } from "./approval";
|
|
10
|
+
import type { Field, Values } from "./fields";
|
|
11
|
+
|
|
12
|
+
export type Step = {
|
|
13
|
+
/** The question, as the card's headline: "Who owns it?" */
|
|
14
|
+
question: string;
|
|
15
|
+
field: Field;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** A required dropdown with one option: nothing to choose, so no step. */
|
|
19
|
+
function onlyValue(field: Field): string | null {
|
|
20
|
+
return field.kind === "select" && field.required && field.options.length === 1 ? field.options[0].value : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The field as one step shows it: the latest value, and always shown. */
|
|
24
|
+
function asStep(field: Field, value: string | boolean | undefined): Field {
|
|
25
|
+
if (field.kind === "checkbox") return { ...field, showWhen: undefined, value: value === undefined ? field.value : value === true };
|
|
26
|
+
return { ...field, showWhen: undefined, value: value === undefined ? field.value : String(value) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function runSteps(
|
|
30
|
+
host: HTMLElement,
|
|
31
|
+
header: HTMLElement,
|
|
32
|
+
options: {
|
|
33
|
+
/** "Client project" - the step number is added to it. */
|
|
34
|
+
kicker: string;
|
|
35
|
+
footer: string;
|
|
36
|
+
/** The number the first step shows, when earlier steps came before these. */
|
|
37
|
+
firstNumber?: number;
|
|
38
|
+
steps: Step[];
|
|
39
|
+
/** The last step's button. "Review" by default, for the review card that follows. */
|
|
40
|
+
lastLabel?: string;
|
|
41
|
+
/** Every field's value once the last step is answered, and the keys the user changed. */
|
|
42
|
+
onDone: (values: Values, edited: string[]) => void;
|
|
43
|
+
onCancel: () => void;
|
|
44
|
+
/** A step threw while it was drawn after Continue. Draw the failed state and answer the agent. */
|
|
45
|
+
onFailed: (error: unknown) => void;
|
|
46
|
+
},
|
|
47
|
+
): void {
|
|
48
|
+
const values: Values = {};
|
|
49
|
+
const edited = new Set<string>();
|
|
50
|
+
let number = options.firstNumber ?? 1;
|
|
51
|
+
|
|
52
|
+
const asked = (step: Step) => onlyValue(step.field) === null && (step.field.showWhen?.(values) ?? true);
|
|
53
|
+
|
|
54
|
+
const show = (index: number): void => {
|
|
55
|
+
let at = index;
|
|
56
|
+
// Nothing to ask: keep the one possible value, or leave out a field that does not apply.
|
|
57
|
+
while (at < options.steps.length && !asked(options.steps[at])) {
|
|
58
|
+
const { field } = options.steps[at];
|
|
59
|
+
const only = onlyValue(field);
|
|
60
|
+
if (only !== null) values[field.key] = only;
|
|
61
|
+
at++;
|
|
62
|
+
}
|
|
63
|
+
if (at >= options.steps.length) return options.onDone({ ...values }, [...edited]);
|
|
64
|
+
|
|
65
|
+
const { question, field } = options.steps[at];
|
|
66
|
+
// A later step that depends on this answer may still come, so it counts until it is skipped.
|
|
67
|
+
const last = !options.steps.slice(at + 1).some((step) => onlyValue(step.field) === null && (asked(step) || Boolean(step.field.showWhen)));
|
|
68
|
+
mountApproval(
|
|
69
|
+
host,
|
|
70
|
+
header,
|
|
71
|
+
{ tone: "warning", status: "", kicker: `${options.kicker} · Step ${number}`, headline: question, facts: [], footer: options.footer },
|
|
72
|
+
{ approve: last ? (options.lastLabel ?? "Review") : "Continue", working: "" },
|
|
73
|
+
[asStep(field, values[field.key])],
|
|
74
|
+
{
|
|
75
|
+
onApprove: (answer, changed) => {
|
|
76
|
+
Object.assign(values, answer);
|
|
77
|
+
changed.forEach((key) => edited.add(key));
|
|
78
|
+
number++;
|
|
79
|
+
// Inside a click handler, a throw would reach nobody, and the agent would wait until the user presses stop.
|
|
80
|
+
try {
|
|
81
|
+
show(at + 1);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
options.onFailed(error);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
onCancel: options.onCancel,
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
show(0);
|
|
92
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"workspace": { "id": "ws_1", "name": "Northwind" },
|
|
3
|
+
"items": [
|
|
4
|
+
{ "id": "u_1", "name": "Dana Reyes", "email": "dana@example.com" },
|
|
5
|
+
{ "id": "u_2", "name": "Sam Lee", "email": "sam@example.com" },
|
|
6
|
+
{ "id": "u_3", "name": "Sam Lee", "email": "sam.lee@example.com" },
|
|
7
|
+
{ "id": "u_4", "name": "Pat Kim", "email": "pat@example.com" }
|
|
8
|
+
]
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "id": "prj_481", "name": "Website relaunch", "key": "WEB" }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "message": "Key WEB is already used by project 77 in tenant 44" }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Create a record",
|
|
3
|
+
"level": "L4",
|
|
4
|
+
"family": "write",
|
|
5
|
+
"kind": "action",
|
|
6
|
+
"action": "create_project",
|
|
7
|
+
"entry": "agent/actions/create_project.ts",
|
|
8
|
+
"variants": [
|
|
9
|
+
"variants/actions/create_project.steps.ts"
|
|
10
|
+
],
|
|
11
|
+
"outcome": "An editable card pre-filled from what the user said; the record is created only after they approve",
|
|
12
|
+
"provenBy": 2
|
|
13
|
+
}
|