@foldspace_npm/harness 0.1.11 → 0.1.13
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 +287 -178
- package/README.md +1 -1
- package/package.json +2 -1
- package/recipes/INDEX.md +15 -0
- package/recipes/README.md +46 -0
- package/recipes/find-by-name/README.md +47 -0
- package/recipes/find-by-name/agent/actions/find_project_id.ts +101 -0
- package/recipes/find-by-name/agent/api/projects.ts +36 -0
- package/recipes/find-by-name/agent/projects.ts +40 -0
- package/recipes/find-by-name/fixtures/projects.all.json +29 -0
- package/recipes/find-by-name/fixtures/projects.empty-account.json +4 -0
- package/recipes/find-by-name/fixtures/projects.none.json +4 -0
- package/recipes/find-by-name/recipe.json +10 -0
- package/recipes/pick-from-a-list/README.md +44 -0
- package/recipes/pick-from-a-list/agent/actions/choose_project.ts +161 -0
- package/recipes/pick-from-a-list/agent/api/projects.ts +36 -0
- package/recipes/pick-from-a-list/agent/projects.ts +40 -0
- package/recipes/pick-from-a-list/agent/views/brand.ts +14 -0
- package/recipes/pick-from-a-list/agent/views/picker.ts +119 -0
- package/recipes/pick-from-a-list/fixtures/projects.all.json +29 -0
- package/recipes/pick-from-a-list/fixtures/projects.empty-account.json +4 -0
- package/recipes/pick-from-a-list/recipe.json +10 -0
- package/recipes/swap-the-login-method/README.md +40 -0
- package/recipes/swap-the-login-method/agent/utils.ts +73 -0
- package/recipes/swap-the-login-method/fixtures/anything.ok.json +8 -0
- package/recipes/swap-the-login-method/recipe.json +12 -0
- package/recipes/swap-the-login-method/variants/utils.cookies.ts +64 -0
- package/recipes/who-is-the-user/README.md +56 -0
- package/recipes/who-is-the-user/agent/identify.ts +89 -0
- package/recipes/who-is-the-user/fixtures/profile.ok.json +6 -0
- package/recipes/who-is-the-user/recipe.json +9 -0
- package/src/runtime/config.ts +1 -1
- package/src/runtime/http.ts +104 -53
- package/src/runtime/index.ts +4 -1
- package/src/runtime/match.ts +1 -1
- package/src/runtime/render.ts +42 -1
- package/templates/agent-starter/agent/actions/_example.ts +9 -2
- package/templates/agent-starter/agent/utils.ts +2 -0
package/src/runtime/http.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// HTTP against the customer's own API, from inside their signed-in page.
|
|
2
2
|
//
|
|
3
|
-
// Lifted from
|
|
3
|
+
// Lifted from a production build. Errors are RETURNED, never
|
|
4
4
|
// thrown, and never carry the token or a raw API error body back to the agent.
|
|
5
5
|
//
|
|
6
6
|
// Two of three early builds used Authorization: Bearer from localStorage, not
|
|
7
7
|
// cookies. Reach for credentials: "include" only when you have observed the
|
|
8
|
-
// app doing that.
|
|
8
|
+
// app doing that. A custom-header scheme belongs in a tenant override of
|
|
9
9
|
// apiFetch, not here.
|
|
10
10
|
|
|
11
11
|
import { getConfig } from "./config";
|
|
@@ -103,9 +103,20 @@ export interface ApiSuccess<T> {
|
|
|
103
103
|
* offline, connection refused). Timeouts set `unreachable: false` and
|
|
104
104
|
* `error: "Request timed out."`. Empty `API_BASE` / `AUTH_SOURCE` also
|
|
105
105
|
* return `{ ok: false }` with status 0 — they do not throw.
|
|
106
|
+
*
|
|
107
|
+
* Branch on `reason`, not on `status`: a search that finds nothing, an expired
|
|
108
|
+
* session and a throttled call are three different widget states, and
|
|
109
|
+
* `renderFailure` picks the right one for you.
|
|
106
110
|
*/
|
|
107
111
|
export interface ApiFailure {
|
|
108
112
|
ok: false;
|
|
113
|
+
/**
|
|
114
|
+
* Why it failed. Always set by the harness; optional only so a tenant's own
|
|
115
|
+
* `apiFetch` override keeps compiling.
|
|
116
|
+
*/
|
|
117
|
+
reason?: FailureReason;
|
|
118
|
+
/** From `Retry-After` on a 429, when the API sent one. */
|
|
119
|
+
retryAfterMs?: number;
|
|
109
120
|
/** HTTP status, or `0` when there was no response / config was missing. */
|
|
110
121
|
status: number;
|
|
111
122
|
/** Safe, user-facing sentence. Pass this to `renderError`. */
|
|
@@ -119,6 +130,28 @@ export interface ApiFailure {
|
|
|
119
130
|
unreachable?: boolean;
|
|
120
131
|
}
|
|
121
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Why a call failed, in terms a widget can act on.
|
|
135
|
+
*
|
|
136
|
+
* - `signed_out` — no token on the page, or the API answered 401. The session
|
|
137
|
+
* observed at build time is not the session at run time; expect this.
|
|
138
|
+
* - `forbidden` — 403. Show the product's own limit; never reach for another token.
|
|
139
|
+
* - `not_found` — 404. **Not an error**: render it as an empty state.
|
|
140
|
+
* - `rate_limited` — 429. `retryAfterMs` is set when the API said how long.
|
|
141
|
+
* - `timeout` / `unreachable` — no usable response.
|
|
142
|
+
* - `config` — `API_BASE` / `AUTH_SOURCE` / public origin not set yet.
|
|
143
|
+
* - `server` — any other non-2xx.
|
|
144
|
+
*/
|
|
145
|
+
export type FailureReason =
|
|
146
|
+
| "signed_out"
|
|
147
|
+
| "forbidden"
|
|
148
|
+
| "not_found"
|
|
149
|
+
| "rate_limited"
|
|
150
|
+
| "timeout"
|
|
151
|
+
| "unreachable"
|
|
152
|
+
| "config"
|
|
153
|
+
| "server";
|
|
154
|
+
|
|
122
155
|
/** Discriminated result of a customer-API call. Branch on `ok`. */
|
|
123
156
|
export type ApiResult<T> = ApiSuccess<T> | ApiFailure;
|
|
124
157
|
|
|
@@ -128,6 +161,7 @@ function missingConfigResult(): ApiResult<never> | null {
|
|
|
128
161
|
return {
|
|
129
162
|
ok: false,
|
|
130
163
|
status: 0,
|
|
164
|
+
reason: "config",
|
|
131
165
|
error: "API_BASE is not set — capture the app's XHR first.",
|
|
132
166
|
};
|
|
133
167
|
}
|
|
@@ -135,6 +169,7 @@ function missingConfigResult(): ApiResult<never> | null {
|
|
|
135
169
|
return {
|
|
136
170
|
ok: false,
|
|
137
171
|
status: 0,
|
|
172
|
+
reason: "config",
|
|
138
173
|
error: "AUTH_SOURCE is not set — capture the app's session first.",
|
|
139
174
|
};
|
|
140
175
|
}
|
|
@@ -151,6 +186,62 @@ function failureDetail(parsed: unknown): string | undefined {
|
|
|
151
186
|
return undefined;
|
|
152
187
|
}
|
|
153
188
|
|
|
189
|
+
function retryAfterMs(res: Response): number | undefined {
|
|
190
|
+
const raw = res.headers?.get?.("Retry-After");
|
|
191
|
+
if (!raw) return undefined;
|
|
192
|
+
const seconds = Number(raw);
|
|
193
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
194
|
+
const at = Date.parse(raw);
|
|
195
|
+
return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now());
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* One mapping from HTTP status to a failure a widget can act on.
|
|
200
|
+
*
|
|
201
|
+
* Exported for a tenant's own `apiFetch` — an app that authenticates with a
|
|
202
|
+
* custom header or cookies replaces the transport, and should not also have to
|
|
203
|
+
* reinvent what a 401, a 404 or a 429 means. See recipes/swap-the-login-method.
|
|
204
|
+
*/
|
|
205
|
+
export function httpFailure(res: Response, fallback: string, detail?: string): ApiFailure {
|
|
206
|
+
const base = { ok: false as const, status: res.status, detail };
|
|
207
|
+
switch (res.status) {
|
|
208
|
+
case 401:
|
|
209
|
+
return { ...base, reason: "signed_out", error: "Your session expired. Sign in again." };
|
|
210
|
+
case 403:
|
|
211
|
+
return { ...base, reason: "forbidden", error: "You do not have access to that." };
|
|
212
|
+
case 404:
|
|
213
|
+
return { ...base, reason: "not_found", error: "That could not be found." };
|
|
214
|
+
case 429:
|
|
215
|
+
return {
|
|
216
|
+
...base,
|
|
217
|
+
reason: "rate_limited",
|
|
218
|
+
retryAfterMs: retryAfterMs(res),
|
|
219
|
+
error: "Too many requests. Try again in a moment.",
|
|
220
|
+
};
|
|
221
|
+
default:
|
|
222
|
+
return { ...base, reason: "server", error: fallback };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The no-response half of {@link httpFailure}: a timeout, or a host that never answered. */
|
|
227
|
+
export function networkFailure(error: unknown, timedOut: string, unreachable: string): ApiFailure {
|
|
228
|
+
const aborted = error instanceof Error && error.name === "AbortError";
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
status: 0,
|
|
232
|
+
reason: aborted ? "timeout" : "unreachable",
|
|
233
|
+
unreachable: !aborted,
|
|
234
|
+
error: aborted ? timedOut : unreachable,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const SIGNED_OUT: ApiFailure = {
|
|
239
|
+
ok: false,
|
|
240
|
+
status: 401,
|
|
241
|
+
reason: "signed_out",
|
|
242
|
+
error: "Not signed in.",
|
|
243
|
+
};
|
|
244
|
+
|
|
154
245
|
/**
|
|
155
246
|
* Authenticated JSON request against `apiBase` with `Authorization: Bearer`.
|
|
156
247
|
*
|
|
@@ -174,7 +265,7 @@ export async function apiFetch<T = unknown>(
|
|
|
174
265
|
if (missing) return missing;
|
|
175
266
|
|
|
176
267
|
const token = getAuthToken();
|
|
177
|
-
if (!token) return
|
|
268
|
+
if (!token) return SIGNED_OUT;
|
|
178
269
|
|
|
179
270
|
const controller = new AbortController();
|
|
180
271
|
const timer = setTimeout(
|
|
@@ -202,26 +293,12 @@ export async function apiFetch<T = unknown>(
|
|
|
202
293
|
}
|
|
203
294
|
|
|
204
295
|
if (!res.ok) {
|
|
205
|
-
return
|
|
206
|
-
ok: false,
|
|
207
|
-
status: res.status,
|
|
208
|
-
error:
|
|
209
|
-
res.status === 401
|
|
210
|
-
? "Your session expired. Sign in again."
|
|
211
|
-
: "That request could not be completed.",
|
|
212
|
-
detail: failureDetail(parsed),
|
|
213
|
-
};
|
|
296
|
+
return httpFailure(res, "That request could not be completed.", failureDetail(parsed));
|
|
214
297
|
}
|
|
215
298
|
|
|
216
299
|
return { ok: true, status: res.status, data: parsed as T };
|
|
217
300
|
} catch (error) {
|
|
218
|
-
|
|
219
|
-
return {
|
|
220
|
-
ok: false,
|
|
221
|
-
status: 0,
|
|
222
|
-
unreachable: !aborted,
|
|
223
|
-
error: aborted ? "Request timed out." : "Could not reach the server.",
|
|
224
|
-
};
|
|
301
|
+
return networkFailure(error, "Request timed out.", "Could not reach the server.");
|
|
225
302
|
} finally {
|
|
226
303
|
clearTimeout(timer);
|
|
227
304
|
}
|
|
@@ -275,7 +352,7 @@ export async function apiFetchBinary(
|
|
|
275
352
|
if (missing) return missing;
|
|
276
353
|
|
|
277
354
|
const token = getAuthToken();
|
|
278
|
-
if (!token) return
|
|
355
|
+
if (!token) return SIGNED_OUT;
|
|
279
356
|
|
|
280
357
|
const controller = new AbortController();
|
|
281
358
|
const timer = setTimeout(
|
|
@@ -289,29 +366,14 @@ export async function apiFetchBinary(
|
|
|
289
366
|
signal: controller.signal,
|
|
290
367
|
headers: { Authorization: `Bearer ${token}`, ...(init.headers ?? {}) },
|
|
291
368
|
});
|
|
292
|
-
if (!res.ok)
|
|
293
|
-
return {
|
|
294
|
-
ok: false,
|
|
295
|
-
status: res.status,
|
|
296
|
-
error:
|
|
297
|
-
res.status === 401
|
|
298
|
-
? "Your session expired. Sign in again."
|
|
299
|
-
: "That file could not be returned.",
|
|
300
|
-
};
|
|
301
|
-
}
|
|
369
|
+
if (!res.ok) return httpFailure(res, "That file could not be returned.");
|
|
302
370
|
return {
|
|
303
371
|
ok: true,
|
|
304
372
|
status: res.status,
|
|
305
373
|
data: new Uint8Array(await res.arrayBuffer()),
|
|
306
374
|
};
|
|
307
375
|
} catch (error) {
|
|
308
|
-
|
|
309
|
-
return {
|
|
310
|
-
ok: false,
|
|
311
|
-
status: 0,
|
|
312
|
-
unreachable: !aborted,
|
|
313
|
-
error: aborted ? "Download timed out." : "Could not reach the server.",
|
|
314
|
-
};
|
|
376
|
+
return networkFailure(error, "Download timed out.", "Could not reach the server.");
|
|
315
377
|
} finally {
|
|
316
378
|
clearTimeout(timer);
|
|
317
379
|
}
|
|
@@ -321,8 +383,8 @@ export async function apiFetchBinary(
|
|
|
321
383
|
* Unauthenticated JSON GET. No Bearer token.
|
|
322
384
|
*
|
|
323
385
|
* Origin is the second argument, or `RuntimeConfig.publicOrigin`. Path first
|
|
324
|
-
* (
|
|
325
|
-
* not the signed-in product API.
|
|
386
|
+
* (the calling convention of the build it came from). Use for marketing /
|
|
387
|
+
* public APIs, not the signed-in product API.
|
|
326
388
|
*
|
|
327
389
|
* @param path - Path under the public origin
|
|
328
390
|
* @param origin - Override `publicOrigin` for this call
|
|
@@ -338,6 +400,7 @@ export async function publicFetch<T = unknown>(
|
|
|
338
400
|
return {
|
|
339
401
|
ok: false,
|
|
340
402
|
status: 0,
|
|
403
|
+
reason: "config",
|
|
341
404
|
error: "No public origin is configured for this app.",
|
|
342
405
|
};
|
|
343
406
|
}
|
|
@@ -351,22 +414,10 @@ export async function publicFetch<T = unknown>(
|
|
|
351
414
|
credentials: "include",
|
|
352
415
|
signal: controller.signal,
|
|
353
416
|
});
|
|
354
|
-
if (!res.ok)
|
|
355
|
-
return {
|
|
356
|
-
ok: false,
|
|
357
|
-
status: res.status,
|
|
358
|
-
error: "That request could not be completed.",
|
|
359
|
-
};
|
|
360
|
-
}
|
|
417
|
+
if (!res.ok) return httpFailure(res, "That request could not be completed.");
|
|
361
418
|
return { ok: true, status: res.status, data: (await res.json()) as T };
|
|
362
419
|
} catch (error) {
|
|
363
|
-
|
|
364
|
-
return {
|
|
365
|
-
ok: false,
|
|
366
|
-
status: 0,
|
|
367
|
-
unreachable: !aborted,
|
|
368
|
-
error: aborted ? "The request timed out." : "Could not reach that host.",
|
|
369
|
-
};
|
|
420
|
+
return networkFailure(error, "The request timed out.", "Could not reach that host.");
|
|
370
421
|
} finally {
|
|
371
422
|
clearTimeout(timer);
|
|
372
423
|
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -21,8 +21,10 @@ export {
|
|
|
21
21
|
apiFetchBinary,
|
|
22
22
|
publicFetch,
|
|
23
23
|
mapWithConcurrency,
|
|
24
|
+
httpFailure,
|
|
25
|
+
networkFailure,
|
|
24
26
|
} from "./http";
|
|
25
|
-
export type { ApiResult, ApiSuccess, ApiFailure } from "./http";
|
|
27
|
+
export type { ApiResult, ApiSuccess, ApiFailure, FailureReason } from "./http";
|
|
26
28
|
|
|
27
29
|
export { rankBy } from "./match";
|
|
28
30
|
export type { FuzzyMatch, RankByOptions } from "./match";
|
|
@@ -32,5 +34,6 @@ export {
|
|
|
32
34
|
renderEmpty,
|
|
33
35
|
renderError,
|
|
34
36
|
renderFatal,
|
|
37
|
+
renderFailure,
|
|
35
38
|
} from "./render";
|
|
36
39
|
export type { ViewHost } from "./render";
|
package/src/runtime/match.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Substring hits first (they meant that name), then the Foldspace SDK's
|
|
5
5
|
* `searchInList` (Fuse) for typos. Do not use this for domain ranking
|
|
6
|
-
* (invoices, coverage,
|
|
6
|
+
* (invoices, coverage, industry classification codes).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { getAgent } from "./agent";
|
package/src/runtime/render.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* use `innerHTML` with API data.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { ApiFailure } from "./http";
|
|
9
|
+
|
|
8
10
|
/**
|
|
9
11
|
* The `host` argument Foldspace passes to `render`, or `{ container }` from
|
|
10
12
|
* older tenant helpers. Either works.
|
|
@@ -44,7 +46,7 @@ export function renderLoading(host: ViewHost, message = "Working..."): void {
|
|
|
44
46
|
* panel that does not name the query reads as a failure.
|
|
45
47
|
*
|
|
46
48
|
* @param host - Foldspace `render` host
|
|
47
|
-
* @param message - Include the query, e.g. `No
|
|
49
|
+
* @param message - Include the query, e.g. `No projects matching "Acme"`
|
|
48
50
|
*/
|
|
49
51
|
export function renderEmpty(host: ViewHost, message: string): void {
|
|
50
52
|
replace(host, el("div", "fs-state fs-empty", message));
|
|
@@ -95,3 +97,42 @@ export function renderFatal(
|
|
|
95
97
|
}
|
|
96
98
|
replace(host, wrap);
|
|
97
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Pick the right state for a failed call, so every action handles the boring
|
|
103
|
+
* failures the same way and none of them renders as a blank or a success card.
|
|
104
|
+
*
|
|
105
|
+
* - `not_found` → {@link renderEmpty}. Nothing matching is an answer, not an error.
|
|
106
|
+
* - `signed_out` → {@link renderFatal}, with your same-tab sign-in link if given.
|
|
107
|
+
* - `forbidden` / `config` / `unreachable` → {@link renderFatal}. Retrying cannot help.
|
|
108
|
+
* - `rate_limited` / `timeout` / `server` → {@link renderError}, with retry.
|
|
109
|
+
*
|
|
110
|
+
* @param host - Foldspace `render` host
|
|
111
|
+
* @param failure - The `{ ok: false }` half of an `ApiResult`
|
|
112
|
+
* @param options - `notFound`: name what was looked for. `signIn`: same-tab link.
|
|
113
|
+
*/
|
|
114
|
+
export function renderFailure(
|
|
115
|
+
host: ViewHost,
|
|
116
|
+
failure: ApiFailure,
|
|
117
|
+
options: {
|
|
118
|
+
onRetry?: () => void;
|
|
119
|
+
signIn?: { label: string; href: string };
|
|
120
|
+
notFound?: string;
|
|
121
|
+
} = {},
|
|
122
|
+
): void {
|
|
123
|
+
switch (failure.reason) {
|
|
124
|
+
case "not_found":
|
|
125
|
+
renderEmpty(host, options.notFound ?? failure.error);
|
|
126
|
+
return;
|
|
127
|
+
case "signed_out":
|
|
128
|
+
renderFatal(host, failure.error, options.signIn);
|
|
129
|
+
return;
|
|
130
|
+
case "forbidden":
|
|
131
|
+
case "config":
|
|
132
|
+
case "unreachable":
|
|
133
|
+
renderFatal(host, failure.error);
|
|
134
|
+
return;
|
|
135
|
+
default:
|
|
136
|
+
renderError(host, failure.error, options.onRetry);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Copy this file to <action_key>.ts and register it in index.ts.
|
|
2
2
|
// Do not register _example — it is not a real Agent Studio action.
|
|
3
3
|
//
|
|
4
|
+
// This is an empty skeleton. For a finished experience — handler, card with
|
|
5
|
+
// every state, fixtures — read node_modules/@foldspace_npm/harness/recipes/INDEX.md.
|
|
6
|
+
//
|
|
4
7
|
// Import helpers from ../utils, not from @foldspace_npm/harness/runtime.
|
|
5
8
|
// The path below is a placeholder until you capture a real HTTP 200.
|
|
6
9
|
|
|
@@ -14,7 +17,9 @@ export const example_action = {
|
|
|
14
17
|
|
|
15
18
|
const result = await apiFetch<{ items?: Item[] }>("/__observe_me");
|
|
16
19
|
if (!result.ok) {
|
|
17
|
-
|
|
20
|
+
// Pass `reason` through so `render` can call renderFailure — an expired
|
|
21
|
+
// session, a 404 and a throttled call are three different widget states.
|
|
22
|
+
return { ok: false, error: result.error, reason: result.reason };
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
const items = result.data.items ?? [];
|
|
@@ -23,6 +28,8 @@ export const example_action = {
|
|
|
23
28
|
idOf: (item) => item.id,
|
|
24
29
|
});
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
// Zero matches is an answer, not an error. Return the query so the empty
|
|
32
|
+
// state can name what was searched for.
|
|
33
|
+
return { ok: true, query, matches };
|
|
27
34
|
},
|
|
28
35
|
};
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
*
|
|
28
28
|
* Widget chrome (unstyled; replace in `agent/views/` when brand matters):
|
|
29
29
|
* - `renderLoading` / `renderEmpty` / `renderError` / `renderFatal`
|
|
30
|
+
* - `renderFailure` — picks one of those from `ApiFailure.reason`, so an expired
|
|
31
|
+
* session, a 404 and a throttled call each draw the right state.
|
|
30
32
|
*
|
|
31
33
|
* Config (already called above; you rarely need these in an action):
|
|
32
34
|
* - `configure` / `getConfig`
|