@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
|
@@ -9,13 +9,29 @@ import { AGENT_API_NAME, API_BASE } from "./constants";
|
|
|
9
9
|
|
|
10
10
|
configure({ agentApiName: AGENT_API_NAME, apiBase: API_BASE });
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Headers the app's own requests carried besides the cookies. Add only what
|
|
14
|
+
* you OBSERVED: one build needed `x-requested-with: XMLHttpRequest`; most need
|
|
15
|
+
* none. Both `apiFetch` and `apiFetchBinary` send them.
|
|
16
|
+
*/
|
|
17
|
+
const EXTRA_HEADERS: Record<string, string> = {};
|
|
18
|
+
|
|
12
19
|
export async function apiFetch<T = unknown>(
|
|
13
20
|
path: string,
|
|
14
21
|
init: RequestInit = {},
|
|
15
22
|
timeoutMs = 15_000,
|
|
16
23
|
): Promise<ApiResult<T>> {
|
|
24
|
+
// Empty API_BASE is a failure on purpose: a guessed host cannot look like
|
|
25
|
+
// success. `error` is drawn on the card and read by the agent - it says what
|
|
26
|
+
// is true; the builder's hint goes in `detail`, which only the console sees.
|
|
17
27
|
if (!API_BASE) {
|
|
18
|
-
return {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
status: 0,
|
|
31
|
+
reason: "config",
|
|
32
|
+
error: "This is not set up yet.",
|
|
33
|
+
detail: "API_BASE is not set - capture the app's XHR first.",
|
|
34
|
+
};
|
|
19
35
|
}
|
|
20
36
|
|
|
21
37
|
const controller = new AbortController();
|
|
@@ -25,9 +41,7 @@ export async function apiFetch<T = unknown>(
|
|
|
25
41
|
...init,
|
|
26
42
|
credentials: "include",
|
|
27
43
|
signal: controller.signal,
|
|
28
|
-
|
|
29
|
-
// `x-requested-with: XMLHttpRequest`; most do not.
|
|
30
|
-
headers: { accept: "application/json", "Content-Type": "application/json", ...(init.headers ?? {}) },
|
|
44
|
+
headers: { accept: "application/json", "Content-Type": "application/json", ...EXTRA_HEADERS, ...(init.headers ?? {}) },
|
|
31
45
|
});
|
|
32
46
|
const text = await res.text();
|
|
33
47
|
let parsed: unknown = text;
|
|
@@ -45,8 +59,44 @@ export async function apiFetch<T = unknown>(
|
|
|
45
59
|
}
|
|
46
60
|
}
|
|
47
61
|
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
/** The same sign-in as `apiFetch`, for a file: the answer comes back as bytes. */
|
|
63
|
+
export async function apiFetchBinary(
|
|
64
|
+
path: string,
|
|
65
|
+
init: RequestInit = {},
|
|
66
|
+
timeoutMs = 15_000,
|
|
67
|
+
): Promise<ApiResult<Uint8Array>> {
|
|
68
|
+
if (!API_BASE) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
status: 0,
|
|
72
|
+
reason: "config",
|
|
73
|
+
error: "This is not set up yet.",
|
|
74
|
+
detail: "API_BASE is not set - capture the app's XHR first.",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
82
|
+
...init,
|
|
83
|
+
credentials: "include",
|
|
84
|
+
signal: controller.signal,
|
|
85
|
+
headers: { ...EXTRA_HEADERS, ...(init.headers ?? {}) },
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) return httpFailure(res, "That file could not be returned.");
|
|
88
|
+
return { ok: true, status: res.status, data: new Uint8Array(await res.arrayBuffer()) };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return networkFailure(error, "Download timed out.", "Could not reach the server.");
|
|
91
|
+
} finally {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Everything else comes from the harness, by name. Give a helper that calls
|
|
97
|
+
// the app's API its own version above, as apiFetch and apiFetchBinary have:
|
|
98
|
+
// the harness's returns a config failure here, because this file sets no
|
|
99
|
+
// `authSource`. For the same reason getAuthToken returns null (see the README).
|
|
50
100
|
export {
|
|
51
101
|
getAgent,
|
|
52
102
|
armAllInstances,
|
|
@@ -60,5 +110,8 @@ export {
|
|
|
60
110
|
renderError,
|
|
61
111
|
renderFatal,
|
|
62
112
|
renderFailure,
|
|
113
|
+
httpFailure,
|
|
114
|
+
networkFailure,
|
|
115
|
+
getAuthToken,
|
|
63
116
|
} from "@foldspace_npm/harness/runtime";
|
|
64
117
|
export type { ApiResult, ApiFailure, FailureReason, ViewHost } from "@foldspace_npm/harness/runtime";
|
|
@@ -4,9 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
**Proven by 1 production build.**
|
|
6
6
|
|
|
7
|
-
A missing `meetingId` or empty notes fails and
|
|
7
|
+
A missing `meetingId` or empty notes fails and says, in plain words, which one is missing. A missing meeting comes back as `not_found`. The saved result is the note id and the meeting id. A 2xx from the app with no note id comes back as `success: false`, `status: "unconfirmed"`, `reason: "server"`: it is not known whether the note was saved.
|
|
8
8
|
|
|
9
|
-
In Agent Studio: an action with key `update_meeting_notes` and string parameters
|
|
9
|
+
In Agent Studio: an action with key `update_meeting_notes` and three string parameters, described as:
|
|
10
|
+
|
|
11
|
+
- `meetingId`: "The meeting's id. Never taken from the user's words: get it from `<meetings action key>`."
|
|
12
|
+
- `notes`: "The text to save on the meeting."
|
|
13
|
+
- `author` (optional): "The name to put on the note, only when the user gives one - never the agent's own name; leave it out otherwise."
|
|
14
|
+
|
|
15
|
+
Replace `<meetings action key>` with the key of this agent's action that returns meeting ids; if the agent has none, build that action first - `find-by-name` has the shape. Instructions to set: "No emoji."
|
|
10
16
|
|
|
11
17
|
## Adapt it
|
|
12
18
|
|
|
@@ -18,5 +24,5 @@ In Agent Studio: an action with key `update_meeting_notes` and string parameters
|
|
|
18
24
|
## What that build learned the hard way
|
|
19
25
|
|
|
20
26
|
- **Return the id the server created.** "Saved" without an id is not something the next action can use.
|
|
21
|
-
- **
|
|
22
|
-
- **This is a live write.**
|
|
27
|
+
- **Say which one is missing.** An empty note and a missing meeting are different failures.
|
|
28
|
+
- **This is a live write.** Running it - with `foldspace ask` or any other way - saves a real note in the signed-in account. Run it yourself only in an account the human named as a test environment; anywhere else, let the human type the question. Publishing it, and every later edit to it, needs the human's yes.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// A write, with no card. The result is what was saved: the note id and the
|
|
2
|
-
// meeting id. A missing
|
|
2
|
+
// meeting id. A missing meeting id or note text is said in plain words. A
|
|
3
|
+
// missing meeting is not_found.
|
|
3
4
|
|
|
4
5
|
import { saveMeetingNote } from "../api/meetings";
|
|
5
6
|
|
|
@@ -10,10 +11,10 @@ export const update_meeting_notes = {
|
|
|
10
11
|
const author = typeof params?.author === "string" ? params.author.trim() : "";
|
|
11
12
|
|
|
12
13
|
if (!meetingId) {
|
|
13
|
-
return { success: false, error: "
|
|
14
|
+
return { success: false, error: "No meeting was given.", reason: "config" };
|
|
14
15
|
}
|
|
15
16
|
if (!notes) {
|
|
16
|
-
return { success: false, error: "
|
|
17
|
+
return { success: false, error: "No note text was given.", reason: "config" };
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
const res = await saveMeetingNote(meetingId, notes, author || undefined);
|
|
@@ -22,9 +23,19 @@ export const update_meeting_notes = {
|
|
|
22
23
|
return { success: false, error: res.error, reason: res.reason };
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
// A 2xx without an id is not a failure the app reported: the note may
|
|
27
|
+
// well be saved. Say exactly that, so it is neither retried as lost nor
|
|
28
|
+
// reported as saved. The body may be anything - `null`, or a numeric id.
|
|
29
|
+
const rawId = res.data?.note?.id as unknown;
|
|
30
|
+
const noteId = typeof rawId === "number" ? String(rawId) : typeof rawId === "string" ? rawId.trim() : "";
|
|
26
31
|
if (!noteId) {
|
|
27
|
-
return {
|
|
32
|
+
return {
|
|
33
|
+
success: false,
|
|
34
|
+
status: "unconfirmed",
|
|
35
|
+
error: "The app answered without a note id, so it is not known whether the note was saved.",
|
|
36
|
+
reason: "server",
|
|
37
|
+
data: { meetingId },
|
|
38
|
+
};
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
return {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Upload contacts — L4
|
|
2
2
|
|
|
3
|
-
**The user says** "upload these contacts". **The user sees** the import card: a drop zone, a review table, then the result. Nothing is written until they choose Import.
|
|
3
|
+
**The user says** "upload these contacts". **The user sees** the import card: a drop zone, a review table, then the result. Nothing is written until they choose Import.
|
|
4
4
|
|
|
5
5
|
**Proven by 1 production build.**
|
|
6
6
|
|
|
7
|
-
`
|
|
7
|
+
An input card: `render` uses `callback`, so the agent waits until the card calls it. Cancel, an empty file, a failed import and a finished import each call `callback` once - a finished import as soon as it returns, with `false` so Done and View Contacts still work; those two buttons do not call back. Each callback states what happened; a finished import carries created, skipped, and a reason on each skipped row.
|
|
8
8
|
|
|
9
|
-
In Agent Studio: an action with key `upload_contacts` and no parameters. The file comes from the card, not from the model.
|
|
9
|
+
In Agent Studio: an action with key `upload_contacts` and no parameters. The file comes from the card, not from the model. Instructions to set: "No emoji. Quote totals and counts exactly as returned; never work them out."
|
|
10
10
|
|
|
11
11
|
## Adapt it
|
|
12
12
|
|
|
@@ -14,12 +14,12 @@ In Agent Studio: an action with key `upload_contacts` and no parameters. The fil
|
|
|
14
14
|
|---|---|
|
|
15
15
|
| `agent/api/contacts.ts` | Mock import path. Find a real API that accepts a list of contacts and returns created and skipped rows |
|
|
16
16
|
| `agent/contacts.ts` | The columns the file actually has |
|
|
17
|
-
| `agent/views/uploader.ts` | The card. `brand.ts`
|
|
17
|
+
| `agent/views/uploader.ts` | The card. `brand.ts` here is a placeholder: keep the `agent/views/brand.ts` that `npx foldspace observe styles --write` wrote - it has every key the recipe cards read. If the project has none yet, run that command first; do not copy this one |
|
|
18
18
|
|
|
19
19
|
## What that build learned the hard way
|
|
20
20
|
|
|
21
21
|
- **Preview before the write.** Choosing a file is not a request to create records. The write runs when they choose Import.
|
|
22
22
|
- **Invalid rows stay out of the request.** They are reported as skipped, with the reason, and the valid rows are what get posted.
|
|
23
|
-
- **Every path calls `callback`.** Cancel and an empty file included.
|
|
23
|
+
- **Every path calls `callback`.** Cancel and an empty file included. A path that never calls it leaves the agent waiting until the user presses stop.
|
|
24
24
|
- **A failed import is not "Created 0".** Draw the failure and return its `reason`.
|
|
25
25
|
- **"View Contacts" only works where the app listens.** The build's single-page app routed on a message the card posts; other apps do not. ⚠️ Keep the button only if you wire that message, or open the list with a navigation route.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// An input card: the user picks a file, reviews the rows, and imports.
|
|
2
|
+
// render uses callback, so the agent waits until it is called - exactly once
|
|
3
|
+
// on every path: Cancel, an empty file, a failed import, or as soon as the
|
|
4
|
+
// import returns (that one with `false`, so Done and View Contacts still
|
|
5
|
+
// work). The write happens only after Import.
|
|
4
6
|
|
|
5
7
|
import type { ImportOutcome } from "../contacts";
|
|
6
8
|
import { renderFailure, type ApiFailure, type FailureReason } from "../utils";
|
|
@@ -27,8 +29,6 @@ export const upload_contacts = {
|
|
|
27
29
|
return { success: true, message: "A contact upload is on screen.", data: { cancelled: false } };
|
|
28
30
|
},
|
|
29
31
|
|
|
30
|
-
awaitUserInput: true,
|
|
31
|
-
|
|
32
32
|
render: (
|
|
33
33
|
result: UploadResult | undefined,
|
|
34
34
|
host: HTMLElement,
|
|
@@ -63,8 +63,8 @@ export const upload_contacts = {
|
|
|
63
63
|
true,
|
|
64
64
|
);
|
|
65
65
|
},
|
|
66
|
-
|
|
67
|
-
callback(done(outcome),
|
|
66
|
+
onImported: (outcome) => {
|
|
67
|
+
callback(done(outcome), false);
|
|
68
68
|
},
|
|
69
69
|
onFailed: (failure: ApiFailure) => {
|
|
70
70
|
callback(
|
|
@@ -1,25 +1,35 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// card to history as an image drawn with html2canvas, which cannot read
|
|
5
|
-
// oklch()/lab()/color-mix(). Take `accent` from the agent's own primaryColor
|
|
6
|
-
// (get_agent_settings); re-sample font and radius from the page if you want
|
|
7
|
-
// them to match; swap for a dark set only when the panel itself is dark.
|
|
1
|
+
// Placeholder with the keys of the agent/views/brand.ts that
|
|
2
|
+
// `npx foldspace observe styles --write` writes. When you copy this recipe,
|
|
3
|
+
// keep the project's own brand.ts; if it has none yet, run that command.
|
|
8
4
|
|
|
9
5
|
export const brand = {
|
|
10
6
|
surface: "#FFFFFF",
|
|
11
|
-
|
|
7
|
+
surface2: "#F5F6FA",
|
|
12
8
|
text: "#374151",
|
|
13
|
-
textStrong: "#111827",
|
|
14
9
|
muted: "#6B7280",
|
|
15
|
-
|
|
10
|
+
border: "#E5E7EB",
|
|
16
11
|
accent: "#3248F2",
|
|
12
|
+
accentText: "#FFFFFF",
|
|
13
|
+
font: 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
|
14
|
+
radius: "8px",
|
|
15
|
+
size: { base: "14px", small: "12px" },
|
|
16
|
+
textStrong: "#111827",
|
|
17
|
+
faint: "#9CA3AF",
|
|
18
|
+
row: "#F9FAFB",
|
|
19
|
+
tableHead: "#F3F4F6",
|
|
17
20
|
accentDeep: "#2538D6",
|
|
18
|
-
|
|
21
|
+
accentSoft: "#EEF0FE",
|
|
22
|
+
headerStart: "#F0F1FE",
|
|
23
|
+
good: "#047857",
|
|
19
24
|
warn: "#B45309",
|
|
25
|
+
warnInk: "#FEF3C7",
|
|
20
26
|
bad: "#B91C1C",
|
|
21
|
-
|
|
27
|
+
primary: "#3248F2",
|
|
28
|
+
tint: "#F3F4F6",
|
|
22
29
|
disabled: "#9CA3AF",
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
money: "#047857",
|
|
31
|
+
ok: "#047857",
|
|
32
|
+
deals: "#3248F2",
|
|
33
|
+
contacts: "#2538D6",
|
|
34
|
+
cases: "#B45309",
|
|
25
35
|
} as const;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The import card from the production build. textContent only — never innerHTML
|
|
2
|
-
// with file data. Styles go in `header`. The write runs only after Import.
|
|
3
|
-
//
|
|
2
|
+
// with file data. Styles go in `header`. The write runs only after Import. The
|
|
3
|
+
// result goes back as soon as the import returns; Done and View Contacts do not
|
|
4
|
+
// call back.
|
|
4
5
|
|
|
5
6
|
import { importContacts } from "../api/contacts";
|
|
6
7
|
import { contactName, outcomeFrom, parseContactsCsv, toDraft, type ImportOutcome, type ParsedContact } from "../contacts";
|
|
@@ -67,12 +68,12 @@ const UPLOAD_PATH = "M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M
|
|
|
67
68
|
const FILE_PATH = "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z";
|
|
68
69
|
const REVIEW_PATH = "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4";
|
|
69
70
|
const DONE_PATH = "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z";
|
|
70
|
-
const UPLOAD_COPY = "Upload a CSV file
|
|
71
|
+
const UPLOAD_COPY = "Upload a CSV file of contacts. It needs first name, last name and email columns; phone, title and account (company) are optional.";
|
|
71
72
|
|
|
72
73
|
export type UploadHandlers = {
|
|
73
74
|
onCancel: () => void;
|
|
74
75
|
onEmpty: () => void;
|
|
75
|
-
|
|
76
|
+
onImported: (outcome: ImportOutcome) => void;
|
|
76
77
|
onFailed: (failure: ApiFailure) => void;
|
|
77
78
|
};
|
|
78
79
|
|
|
@@ -229,7 +230,14 @@ export function mountContactUpload(host: HTMLElement, header: HTMLElement, handl
|
|
|
229
230
|
|
|
230
231
|
const cancel = button("fs-up-btn fs-up-secondary", "Cancel");
|
|
231
232
|
cancel.addEventListener("click", () => {
|
|
232
|
-
report(
|
|
233
|
+
report(
|
|
234
|
+
() => {
|
|
235
|
+
const closed = el("div", "fs-up");
|
|
236
|
+
closed.append(el("div", "fs-up-sub", "Upload cancelled. No contacts were imported."));
|
|
237
|
+
mount(closed);
|
|
238
|
+
},
|
|
239
|
+
() => handlers.onCancel(),
|
|
240
|
+
);
|
|
233
241
|
});
|
|
234
242
|
|
|
235
243
|
card.append(zone, actions(cancel));
|
|
@@ -291,7 +299,7 @@ export function mountContactUpload(host: HTMLElement, header: HTMLElement, handl
|
|
|
291
299
|
parsed = [];
|
|
292
300
|
showUpload();
|
|
293
301
|
});
|
|
294
|
-
const confirm = button("fs-up-btn fs-up-primary", `Import ${ready.length} Contacts`);
|
|
302
|
+
const confirm = button("fs-up-btn fs-up-primary", `Import ${ready.length} ${ready.length === 1 ? "Contact" : "Contacts"}`);
|
|
295
303
|
confirm.disabled = ready.length === 0;
|
|
296
304
|
confirm.addEventListener("click", async () => {
|
|
297
305
|
if (settled || busy || ready.length === 0) return;
|
|
@@ -300,12 +308,16 @@ export function mountContactUpload(host: HTMLElement, header: HTMLElement, handl
|
|
|
300
308
|
const res = await importContacts(ready.map(toDraft));
|
|
301
309
|
if (!res.ok) {
|
|
302
310
|
report(
|
|
303
|
-
() => renderFailure(host, res, { notFound: "
|
|
311
|
+
() => renderFailure(host, res, { notFound: "Contacts cannot be imported here." }),
|
|
304
312
|
() => handlers.onFailed(res),
|
|
305
313
|
);
|
|
306
314
|
return;
|
|
307
315
|
}
|
|
308
|
-
|
|
316
|
+
const outcome = outcomeFrom(parsed, res.data);
|
|
317
|
+
report(
|
|
318
|
+
() => showResults(outcome),
|
|
319
|
+
() => handlers.onImported(outcome),
|
|
320
|
+
);
|
|
309
321
|
});
|
|
310
322
|
card.append(actions(back, confirm));
|
|
311
323
|
mount(card);
|
|
@@ -318,7 +330,7 @@ export function mountContactUpload(host: HTMLElement, header: HTMLElement, handl
|
|
|
318
330
|
const head = el("div", "fs-up-head");
|
|
319
331
|
head.append(row, el("div", "fs-up-sub", "Please wait while we import your contacts. This may take a moment for large files."));
|
|
320
332
|
const wait = el("div", "fs-up-wait");
|
|
321
|
-
wait.append(el("div", "fs-up-spin is-lg"), el("div", "fs-up-wait-text", `Processing ${count} contacts...`));
|
|
333
|
+
wait.append(el("div", "fs-up-spin is-lg"), el("div", "fs-up-wait-text", `Processing ${count} ${count === 1 ? "contact" : "contacts"}...`));
|
|
322
334
|
card.append(head, wait);
|
|
323
335
|
mount(card);
|
|
324
336
|
};
|
|
@@ -377,16 +389,15 @@ export function mountContactUpload(host: HTMLElement, header: HTMLElement, handl
|
|
|
377
389
|
card.append(section);
|
|
378
390
|
}
|
|
379
391
|
|
|
380
|
-
|
|
392
|
+
// The agent already has the result. These buttons only act on the card.
|
|
381
393
|
const view = button("fs-up-btn fs-up-secondary", "View Contacts");
|
|
382
|
-
view.addEventListener("click", () =>
|
|
383
|
-
// callback first: a navigation that unloads the page must not lose it.
|
|
384
|
-
finish();
|
|
385
|
-
go("/__observe_me/contacts");
|
|
386
|
-
});
|
|
394
|
+
view.addEventListener("click", () => go("/__observe_me/contacts"));
|
|
387
395
|
const done = button("fs-up-btn fs-up-primary", "Done");
|
|
388
|
-
|
|
389
|
-
|
|
396
|
+
const buttons = actions(view, done);
|
|
397
|
+
done.addEventListener("click", () => {
|
|
398
|
+
buttons.textContent = "";
|
|
399
|
+
});
|
|
400
|
+
card.append(buttons);
|
|
390
401
|
mount(card);
|
|
391
402
|
};
|
|
392
403
|
|
|
@@ -30,8 +30,10 @@ import { identifyUser } from "../identify";
|
|
|
30
30
|
identifyUser();
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
Built-in user attributes need no setup: `id`, `email`, `name`, `role`,
|
|
34
|
+
`image`, `phone`, `timezone`, `signUpDate`. A custom one (a plan name, say)
|
|
35
|
+
lands only if it was created first in **Settings → Attribute Settings**; send
|
|
36
|
+
it under its API Name. <https://docs.foldspace.ai/user-guides/settings/attributes/>
|
|
35
37
|
|
|
36
38
|
## What those builds learned the hard way
|
|
37
39
|
|
|
@@ -49,7 +51,9 @@ first in **Settings → Attribute Settings**; send them under their API Name.
|
|
|
49
51
|
- **A failed identify must not break the agent.** Every failure is silent to the
|
|
50
52
|
user, and releases the flag so a later load can try again.
|
|
51
53
|
- **Hide the agent until identify resolves, with a timeout** — and on a page
|
|
52
|
-
with no signed-in user (the login page), do not show it at all.
|
|
54
|
+
with no signed-in user (the login page), do not show it at all. This recipe
|
|
55
|
+
does not do it; to add it, call `getAgent()?.hide()` /
|
|
56
|
+
`getAgent()?.show()` (<https://docs.foldspace.ai/reference/visibility-api/>).
|
|
53
57
|
- **This is unsigned.** Anyone can call `foldspace.identify` from a console.
|
|
54
58
|
Signing has to come from the customer's backend
|
|
55
59
|
(<https://docs.foldspace.ai/security/overview/>); until then treat the
|
package/src/ask-core.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Pure helpers behind `foldspace ask`: deciding when the
|
|
1
|
+
// Pure helpers behind `foldspace ask`: deciding when the agent has finished
|
|
2
2
|
// replying, reading what attach logged about the handlers, and deciding whether
|
|
3
3
|
// to ask again. No I/O here, so every judgement call is unit-tested.
|
|
4
4
|
|
|
@@ -109,12 +109,24 @@ export function summarizeHandlerLog(logText) {
|
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// execute/render come from the log the attach daemon writes. With no daemon
|
|
113
|
+
// running - the page runs published code, see `foldspace published` - there is
|
|
114
|
+
// no log to read, so they are reported as not tracked rather than as absent.
|
|
115
|
+
export const NOT_TRACKED = "not tracked here (published code)";
|
|
116
|
+
|
|
117
|
+
export function untrackedHandler() {
|
|
118
|
+
return { execute: NOT_TRACKED, render: NOT_TRACKED, actions: [], lines: [] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Without the log only a card counts: an action that answers with data and
|
|
122
|
+
// draws no card reads as not fired, and --retry asks again.
|
|
112
123
|
export function actionFired(handler, hasCard) {
|
|
113
|
-
|
|
124
|
+
const ran = (status) => status !== "absent" && status !== NOT_TRACKED;
|
|
125
|
+
return Boolean(hasCard) || ran(handler?.execute) || ran(handler?.render);
|
|
114
126
|
}
|
|
115
127
|
|
|
116
128
|
/**
|
|
117
|
-
* Re-ask only for publish lag: the
|
|
129
|
+
* Re-ask only for publish lag: the agent answered from knowledge because
|
|
118
130
|
* the platform has not picked the new action up yet. A handler that ran and
|
|
119
131
|
* FAILED is a bug to read, not something another ask will fix.
|
|
120
132
|
*/
|
|
@@ -131,7 +143,7 @@ export function numberFlag(value, fallback) {
|
|
|
131
143
|
: fallback;
|
|
132
144
|
}
|
|
133
145
|
|
|
134
|
-
// ----
|
|
146
|
+
// ---- Describe what a handler returned without printing it ----
|
|
135
147
|
|
|
136
148
|
function typeOf(value) {
|
|
137
149
|
if (value === null) return "null";
|
package/src/attach-daemon.mjs
CHANGED
|
@@ -76,11 +76,16 @@ export function rejectionAdvice(code) {
|
|
|
76
76
|
return "The page has no Foldspace code. Run: npx foldspace attach --bootstrap --daemon";
|
|
77
77
|
}
|
|
78
78
|
if (code === "REGISTRATION_MISMATCH") {
|
|
79
|
-
return "The page
|
|
79
|
+
return "The page's Foldspace agent is not the one this project is set up for (another agent or product, or one that could not be confirmed). Run: npx foldspace attach --replace --daemon";
|
|
80
80
|
}
|
|
81
81
|
return null;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** Where to read why registration did not match: attach prints it under the lifecycle line. */
|
|
85
|
+
export function mismatchNext(logPath) {
|
|
86
|
+
return `Read the lines under "[lifecycle] inspect_registration:registration_mismatch" in ${logPath}: they name what differs and the next step.`;
|
|
87
|
+
}
|
|
88
|
+
|
|
84
89
|
export function readDaemonState(root) {
|
|
85
90
|
const { stateFile } = daemonPaths(root);
|
|
86
91
|
try {
|
|
@@ -162,7 +167,7 @@ function daemonReport(state, extra = {}) {
|
|
|
162
167
|
}
|
|
163
168
|
}
|
|
164
169
|
const registration = parseRegistrationLifecycle(logText);
|
|
165
|
-
|
|
170
|
+
const report = {
|
|
166
171
|
daemon: true,
|
|
167
172
|
pid: state?.pid ?? null,
|
|
168
173
|
logPath: state?.logPath ?? null,
|
|
@@ -171,6 +176,8 @@ function daemonReport(state, extra = {}) {
|
|
|
171
176
|
: {}),
|
|
172
177
|
...extra,
|
|
173
178
|
};
|
|
179
|
+
if (report.registration === "registration_mismatch") report.next = mismatchNext(report.logPath);
|
|
180
|
+
return report;
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
export async function stopAttachDaemon({
|
package/src/attach-helpers.mjs
CHANGED
|
@@ -15,6 +15,19 @@ export function attachModeFromArgs(argv) {
|
|
|
15
15
|
return ATTACH_MODES.SWAP;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The flags to start attach again with, so a restart keeps what this one ran
|
|
20
|
+
* with: its mode, test mode (a restart without --test-mode turns it off) and
|
|
21
|
+
* --no-badge. Leading space; empty for a plain swap attach.
|
|
22
|
+
*/
|
|
23
|
+
export function attachAgainFlags({ mode, testMode = false, noBadge = false } = {}) {
|
|
24
|
+
return (
|
|
25
|
+
(mode === ATTACH_MODES.REPLACE ? " --replace" : mode === ATTACH_MODES.BOOTSTRAP ? " --bootstrap" : "") +
|
|
26
|
+
(testMode === true ? " --test-mode" : "") +
|
|
27
|
+
(noBadge === true ? " --no-badge" : "")
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
function present(value) {
|
|
19
32
|
if (value == null) return null;
|
|
20
33
|
const text = String(value);
|
|
@@ -172,6 +185,98 @@ export function actionRequestMatchesTarget(request, target) {
|
|
|
172
185
|
);
|
|
173
186
|
}
|
|
174
187
|
|
|
188
|
+
/**
|
|
189
|
+
* What the log says under `inspect_registration:registration_mismatch`, so
|
|
190
|
+
* the model reads the difference instead of hunting for it: whether the page's
|
|
191
|
+
* agent, product or panel is not this project's, which action names differ,
|
|
192
|
+
* the diagnostic error, and one next step. `details` is the lifecycle result's
|
|
193
|
+
* details; the log prints only `lines` and `next`, never the fields.
|
|
194
|
+
*/
|
|
195
|
+
export function registrationMismatchReport(details = {}) {
|
|
196
|
+
const list = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === "string") : []);
|
|
197
|
+
const missing = list(details.missingActionNames);
|
|
198
|
+
const unexpected = list(details.unexpectedActionNames);
|
|
199
|
+
const agents = list(details.detectedAgentIds);
|
|
200
|
+
const products = list(details.detectedProductIds);
|
|
201
|
+
const expectedAgent = details.expectedAgentId;
|
|
202
|
+
const expectedProduct = details.expectedProductId;
|
|
203
|
+
const lines = [];
|
|
204
|
+
const noAgent = agents.length === 0;
|
|
205
|
+
const otherAgent = Boolean(expectedAgent) && !noAgent && !agents.includes(expectedAgent);
|
|
206
|
+
const moreAgents = Boolean(expectedAgent) && agents.length > 1 && agents.includes(expectedAgent);
|
|
207
|
+
const otherProduct = Boolean(expectedProduct) && products.length > 0 && !products.includes(expectedProduct);
|
|
208
|
+
const unknownProduct = Boolean(expectedProduct) && !noAgent && products.length === 0;
|
|
209
|
+
// Only --replace requires this project's panel to be the one visible.
|
|
210
|
+
const widget = details.mode === ATTACH_MODES.REPLACE && details.widget ? details.widget : null;
|
|
211
|
+
const visibleCount = widget ? Number(widget.visibleCount) || 0 : 0;
|
|
212
|
+
|
|
213
|
+
if (noAgent) lines.push(`Agent: no Foldspace agent started on the page; this project is set up for ${expectedAgent}.`);
|
|
214
|
+
if (otherAgent) lines.push(`Agent: this project is set up for ${expectedAgent}; the page has ${agents.join(", ")}.`);
|
|
215
|
+
if (moreAgents) lines.push(`Agent: the page has ${agents.join(", ")} - more than this project's ${expectedAgent}.`);
|
|
216
|
+
if (otherProduct) lines.push(`Product: this project is set up for ${expectedProduct}; the page has ${products.join(", ")}.`);
|
|
217
|
+
if (unknownProduct) {
|
|
218
|
+
lines.push(`Product: the page's agent does not say which product it is; this project is set up for ${expectedProduct}.`);
|
|
219
|
+
}
|
|
220
|
+
if (widget && visibleCount === 0) {
|
|
221
|
+
lines.push("Panel: no agent panel is visible on the page.");
|
|
222
|
+
} else if (widget && visibleCount > 1) {
|
|
223
|
+
lines.push(`Panel: ${visibleCount} agent panels are visible; --replace needs this project's to be the only one.`);
|
|
224
|
+
} else if (widget && widget.ownerMatches !== true) {
|
|
225
|
+
lines.push("Panel: the visible panel is not this project's agent panel.");
|
|
226
|
+
}
|
|
227
|
+
// The action checks run only once the agent, product and panel match.
|
|
228
|
+
if (!lines.length) {
|
|
229
|
+
if (details.actionObserverInstalled === false || details.actionCaptureCount === 0) {
|
|
230
|
+
lines.push("Actions: the page's agent never took this build's actions.");
|
|
231
|
+
}
|
|
232
|
+
if (details.actionNameLimitExceeded === true) {
|
|
233
|
+
lines.push("Actions: this build has more than 200 action names; attach checks at most 200, so it cannot confirm them.");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (missing.length) {
|
|
237
|
+
lines.push(`Missing - in this build, not registered on the page's agent: ${missing.join(", ")}`);
|
|
238
|
+
}
|
|
239
|
+
if (unexpected.length) {
|
|
240
|
+
lines.push(`Unexpected - registered on the page's agent, not in this build: ${unexpected.join(", ")}`);
|
|
241
|
+
}
|
|
242
|
+
if (details.diagnosticError) lines.push(`Diagnostic error: ${details.diagnosticError}`);
|
|
243
|
+
|
|
244
|
+
const sameMode = attachAgainFlags(details);
|
|
245
|
+
// Switching to --replace keeps the rest of what this attach ran with.
|
|
246
|
+
const asReplace = attachAgainFlags({ ...details, mode: ATTACH_MODES.REPLACE });
|
|
247
|
+
const once =
|
|
248
|
+
`run \`npx foldspace attach --stop\`, then \`npx foldspace attach${sameMode} --daemon\` once; if the same lines come back, ` +
|
|
249
|
+
"tell the human in one sentence that the test window could not load the latest changes to their agent.";
|
|
250
|
+
let next;
|
|
251
|
+
if (noAgent && details.mode !== ATTACH_MODES.SWAP) {
|
|
252
|
+
// bootstrap and replace start the agent from foldspace.dev.json.
|
|
253
|
+
next =
|
|
254
|
+
"check `productId`, `agentApiName` (the Agent Key) and `region` in foldspace.dev.json against the agent in " +
|
|
255
|
+
"Agent Studio, and correct any that differ with the human's yes. Then run `npx foldspace attach --stop`, " +
|
|
256
|
+
`then \`npx foldspace attach${sameMode} --daemon\` once; if the same lines come back, tell the human in one ` +
|
|
257
|
+
"sentence that their agent could not be started in the test window.";
|
|
258
|
+
} else if ((otherAgent || moreAgents || otherProduct || unknownProduct) && details.mode !== ATTACH_MODES.REPLACE) {
|
|
259
|
+
next = `run \`npx foldspace attach --stop\`, then \`npx foldspace attach${asReplace} --daemon\`: the page's Foldspace agent is not this project's.`;
|
|
260
|
+
} else if (missing.length || unexpected.length) {
|
|
261
|
+
next =
|
|
262
|
+
"compare these names with agent/actions/index.ts. If the code changed since the last build: `npm run build`, " +
|
|
263
|
+
`\`npx foldspace attach --stop\`, then \`npx foldspace attach${sameMode} --daemon\`. If it did not, ${once}`;
|
|
264
|
+
} else {
|
|
265
|
+
if (!lines.length) {
|
|
266
|
+
lines.push(
|
|
267
|
+
`Details: ${JSON.stringify({
|
|
268
|
+
mode: details.mode ?? null,
|
|
269
|
+
actionObserverInstalled: details.actionObserverInstalled ?? null,
|
|
270
|
+
actionCaptureCount: details.actionCaptureCount ?? null,
|
|
271
|
+
agentFound: details.agentFound ?? null,
|
|
272
|
+
})}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
next = once;
|
|
276
|
+
}
|
|
277
|
+
return { lines, next };
|
|
278
|
+
}
|
|
279
|
+
|
|
175
280
|
export function registrationVerified(observation, registration) {
|
|
176
281
|
return Boolean(
|
|
177
282
|
observation?.installed === true &&
|