@foldspace_npm/harness 0.1.11 → 0.1.12

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.
Files changed (38) hide show
  1. package/CLAUDE.md +271 -178
  2. package/README.md +1 -1
  3. package/package.json +2 -1
  4. package/recipes/INDEX.md +15 -0
  5. package/recipes/README.md +46 -0
  6. package/recipes/find-by-name/README.md +47 -0
  7. package/recipes/find-by-name/agent/actions/find_project_id.ts +101 -0
  8. package/recipes/find-by-name/agent/api/projects.ts +36 -0
  9. package/recipes/find-by-name/agent/projects.ts +40 -0
  10. package/recipes/find-by-name/fixtures/projects.all.json +29 -0
  11. package/recipes/find-by-name/fixtures/projects.empty-account.json +4 -0
  12. package/recipes/find-by-name/fixtures/projects.none.json +4 -0
  13. package/recipes/find-by-name/recipe.json +10 -0
  14. package/recipes/pick-from-a-list/README.md +44 -0
  15. package/recipes/pick-from-a-list/agent/actions/choose_project.ts +161 -0
  16. package/recipes/pick-from-a-list/agent/api/projects.ts +36 -0
  17. package/recipes/pick-from-a-list/agent/projects.ts +40 -0
  18. package/recipes/pick-from-a-list/agent/views/brand.ts +14 -0
  19. package/recipes/pick-from-a-list/agent/views/picker.ts +119 -0
  20. package/recipes/pick-from-a-list/fixtures/projects.all.json +29 -0
  21. package/recipes/pick-from-a-list/fixtures/projects.empty-account.json +4 -0
  22. package/recipes/pick-from-a-list/recipe.json +10 -0
  23. package/recipes/swap-the-login-method/README.md +40 -0
  24. package/recipes/swap-the-login-method/agent/utils.ts +73 -0
  25. package/recipes/swap-the-login-method/fixtures/anything.ok.json +8 -0
  26. package/recipes/swap-the-login-method/recipe.json +12 -0
  27. package/recipes/swap-the-login-method/variants/utils.cookies.ts +64 -0
  28. package/recipes/who-is-the-user/README.md +56 -0
  29. package/recipes/who-is-the-user/agent/identify.ts +89 -0
  30. package/recipes/who-is-the-user/fixtures/profile.ok.json +6 -0
  31. package/recipes/who-is-the-user/recipe.json +9 -0
  32. package/src/runtime/config.ts +1 -1
  33. package/src/runtime/http.ts +104 -53
  34. package/src/runtime/index.ts +4 -1
  35. package/src/runtime/match.ts +1 -1
  36. package/src/runtime/render.ts +42 -1
  37. package/templates/agent-starter/agent/actions/_example.ts +9 -2
  38. package/templates/agent-starter/agent/utils.ts +2 -0
@@ -1,11 +1,11 @@
1
1
  // HTTP against the customer's own API, from inside their signed-in page.
2
2
  //
3
- // Lifted from the Transparency Catalog build. Errors are RETURNED, never
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. Custom headers (Joist-style) belong in a tenant override of
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 { ok: false, status: 401, error: "Not signed in." };
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
- const aborted = error instanceof Error && error.name === "AbortError";
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 { ok: false, status: 401, error: "Not signed in." };
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
- const aborted = error instanceof Error && error.name === "AbortError";
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
- * (Transparency Catalog calling convention). Use for marketing / public APIs,
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
- const aborted = error instanceof Error && error.name === "AbortError";
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
  }
@@ -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";
@@ -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, MasterFormat).
6
+ * (invoices, coverage, industry classification codes).
7
7
  */
8
8
 
9
9
  import { getAgent } from "./agent";
@@ -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 estimates matching "Webb"`
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
- return { ok: false, error: result.error };
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
- return { ok: true, matches };
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`