@ateam-ai-mobile/core 0.1.0 → 0.1.1

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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Tracks the user's ACS widget tiles for the home carousel. Single source of
3
+ * truth for /api/acs/surface state. Optimistic ops with rollback on failure
4
+ * — backend contract treats 404 / already:true as success (per spec), so the
5
+ * store mirrors that policy: a delete that the server says was already done
6
+ * stays removed locally.
7
+ *
8
+ * Cross-device freshness: the WS subscription (./ws) forwards `acs.artifacts`
9
+ * events into the applyServer* methods; hosts should also refresh() on app
10
+ * foreground ("WS is best-effort; the GET is the source of truth").
11
+ */
12
+
13
+ import { create } from 'zustand';
14
+ import {
15
+ listSurface,
16
+ deleteArtifact,
17
+ patchArtifact,
18
+ setOrder,
19
+ recordEvent,
20
+ type AcsArtifact,
21
+ type AcsDeleteReason,
22
+ } from './api';
23
+
24
+ interface WidgetsState {
25
+ /** Tiles to render in the home carousel. Order matches what /surface
26
+ * returned; sort by pinOrder when present. */
27
+ tiles: AcsArtifact[];
28
+ /** True after the first /surface fetch completes (success OR empty). */
29
+ hydrated: boolean;
30
+ /** True while a /surface request is in flight. */
31
+ loading: boolean;
32
+ /** Last error string from /surface; null on success or pre-fetch. */
33
+ error: string | null;
34
+
35
+ /** First load — idempotent; safe to call from multiple effects. */
36
+ hydrate: () => Promise<void>;
37
+ /** Force re-fetch; call on app foreground and after destructive ops. */
38
+ refresh: () => Promise<void>;
39
+ /** Reset to empty (sign-out, env switch). */
40
+ clear: () => void;
41
+
42
+ /**
43
+ * Optimistic remove. Returns { ok: false, error } only on real network /
44
+ * 5xx failures — 404 and already:true rollback to ok:true per the backend
45
+ * contract (no existence leak; the row is gone either way).
46
+ */
47
+ remove: (artifactId: string, reason?: AcsDeleteReason) => Promise<{ ok: boolean; error?: string }>;
48
+
49
+ /** Optimistic hide (visibility → 'hidden'). Tile disappears from carousel. */
50
+ hide: (artifactId: string) => Promise<{ ok: boolean; error?: string }>;
51
+
52
+ /** Optimistic unhide (visibility → 'pinned'). Tile reappears in carousel. */
53
+ unhide: (artifactId: string) => Promise<{ ok: boolean; error?: string }>;
54
+
55
+ /** Optimistic rename. Trims input and rejects empty / overlong names locally. */
56
+ rename: (artifactId: string, name: string) => Promise<{ ok: boolean; error?: string }>;
57
+
58
+ /**
59
+ * Persist a new pin order to the server. `pinnedIds` is the visible order
60
+ * of currently-pinned tiles. Optimistic — applies locally first, rolls
61
+ * back on server rejection.
62
+ */
63
+ reorder: (pinnedIds: string[]) => Promise<{ ok: boolean; error?: string }>;
64
+
65
+ // ─── Server-driven mutations (no API call) ─────────────────────────────
66
+ // The WS subscription (./ws) forwards `acs.artifacts` events into these
67
+ // methods. They ONLY mutate local state — they DO NOT call the API (the
68
+ // server is the source of the event, calling back would loop).
69
+
70
+ /** WS 'updated' event — replace the tile by id (hide/unhide/rename land here). */
71
+ applyServerUpdate: (artifact: AcsArtifact) => void;
72
+ /** WS 'retired' event — remove the tile by id. */
73
+ applyServerRetire: (artifactId: string) => void;
74
+ /** WS 'order' event — re-rank tiles to match the pinned ID array. */
75
+ applyServerOrder: (pinnedIds: string[]) => void;
76
+
77
+ /** Fire-and-forget usage event. Caller doesn't await. */
78
+ track: (artifactId: string, event: 'clicked' | 'dismissed' | 'acted' | 'ignored') => void;
79
+ }
80
+
81
+ export const useWidgetsStore = create<WidgetsState>((set, get) => ({
82
+ tiles: [],
83
+ hydrated: false,
84
+ loading: false,
85
+ error: null,
86
+
87
+ hydrate: async () => {
88
+ if (get().hydrated || get().loading) return;
89
+ await get().refresh();
90
+ },
91
+
92
+ refresh: async () => {
93
+ if (get().loading) return;
94
+ set({ loading: true, error: null });
95
+ try {
96
+ const tiles = await listSurface('home');
97
+ set({ tiles, loading: false, hydrated: true });
98
+ } catch (e: any) {
99
+ set({ loading: false, hydrated: true, error: e?.message || 'Failed to load widgets' });
100
+ }
101
+ },
102
+
103
+ clear: () => set({ tiles: [], hydrated: false, loading: false, error: null }),
104
+
105
+ remove: async (artifactId, reason = 'user_long_press_delete') => {
106
+ const before = get().tiles;
107
+ // Optimistic: snap removed immediately so the user sees the tile vanish.
108
+ set({ tiles: before.filter(t => t.id !== artifactId) });
109
+
110
+ const result = await deleteArtifact(artifactId, reason);
111
+ if (result.ok) {
112
+ // Includes 200, 200 already:true, and 404 — all "gone server-side too".
113
+ return { ok: true };
114
+ }
115
+
116
+ // Real failure — rollback.
117
+ set({ tiles: before });
118
+ return { ok: false, error: result.error || `HTTP ${result.status}` };
119
+ },
120
+
121
+ hide: async (artifactId) => {
122
+ const before = get().tiles;
123
+ set({
124
+ tiles: before.map(t =>
125
+ t.id === artifactId ? { ...t, visibility: 'hidden' as const } : t,
126
+ ),
127
+ });
128
+ const r = await patchArtifact(artifactId, { visibility: 'hidden' }, 'user_long_press_hide');
129
+ if (r.ok) {
130
+ // If the server returned the updated artifact, sync our copy to it
131
+ // (covers race conditions and server-side normalisation).
132
+ if (r.artifact) {
133
+ set({
134
+ tiles: get().tiles.map(t => (t.id === artifactId ? { ...t, ...r.artifact! } : t)),
135
+ });
136
+ }
137
+ return { ok: true };
138
+ }
139
+ set({ tiles: before });
140
+ return { ok: false, error: r.error || `HTTP ${r.status}` };
141
+ },
142
+
143
+ unhide: async (artifactId) => {
144
+ const before = get().tiles;
145
+ set({
146
+ tiles: before.map(t =>
147
+ t.id === artifactId ? { ...t, visibility: 'pinned' as const } : t,
148
+ ),
149
+ });
150
+ const r = await patchArtifact(artifactId, { visibility: 'pinned' }, 'user_long_press_unhide');
151
+ if (r.ok) {
152
+ if (r.artifact) {
153
+ set({
154
+ tiles: get().tiles.map(t => (t.id === artifactId ? { ...t, ...r.artifact! } : t)),
155
+ });
156
+ }
157
+ return { ok: true };
158
+ }
159
+ set({ tiles: before });
160
+ return { ok: false, error: r.error || `HTTP ${r.status}` };
161
+ },
162
+
163
+ rename: async (artifactId, name) => {
164
+ // Local validation mirrors backend's 60-char cap from the spec. Keep
165
+ // this strict so we don't hit the API with garbage that will 400 anyway.
166
+ const trimmed = name.trim();
167
+ if (!trimmed) return { ok: false, error: 'Name cannot be empty' };
168
+ if (trimmed.length > 60) return { ok: false, error: 'Name must be 60 characters or less' };
169
+
170
+ const before = get().tiles;
171
+ set({
172
+ tiles: before.map(t => (t.id === artifactId ? { ...t, name: trimmed } : t)),
173
+ });
174
+ const r = await patchArtifact(artifactId, { name: trimmed }, 'user_long_press_rename');
175
+ if (r.ok) {
176
+ if (r.artifact) {
177
+ set({
178
+ tiles: get().tiles.map(t => (t.id === artifactId ? { ...t, ...r.artifact! } : t)),
179
+ });
180
+ }
181
+ return { ok: true };
182
+ }
183
+ set({ tiles: before });
184
+ return { ok: false, error: r.error || `HTTP ${r.status}` };
185
+ },
186
+
187
+ reorder: async (pinnedIds) => {
188
+ const before = get().tiles;
189
+ // Optimistic: assign pinOrder by position in pinnedIds. Hidden tiles
190
+ // are not touched. If the server rejects, we roll back to `before`.
191
+ const idToOrder = new Map(pinnedIds.map((id, i) => [id, i] as const));
192
+ set({
193
+ tiles: before.map(t => {
194
+ const o = idToOrder.get(t.id);
195
+ return o == null ? t : { ...t, pinOrder: o };
196
+ }),
197
+ });
198
+ const r = await setOrder(pinnedIds);
199
+ if (r.ok) return { ok: true };
200
+ set({ tiles: before });
201
+ return { ok: false, error: r.error || `HTTP ${r.status}` };
202
+ },
203
+
204
+ applyServerUpdate: (artifact) => {
205
+ set({
206
+ tiles: get().tiles.map(t => (t.id === artifact.id ? { ...t, ...artifact } : t)),
207
+ });
208
+ },
209
+
210
+ applyServerRetire: (artifactId) => {
211
+ set({ tiles: get().tiles.filter(t => t.id !== artifactId) });
212
+ },
213
+
214
+ applyServerOrder: (pinnedIds) => {
215
+ const idToOrder = new Map(pinnedIds.map((id, i) => [id, i] as const));
216
+ set({
217
+ tiles: get().tiles.map(t => {
218
+ const o = idToOrder.get(t.id);
219
+ return o == null ? t : { ...t, pinOrder: o };
220
+ }),
221
+ });
222
+ },
223
+
224
+ track: (artifactId, event) => {
225
+ // Fire-and-forget. Surface tagged so backend analytics knows where the
226
+ // click happened (home carousel vs library, etc.).
227
+ recordEvent(artifactId, event, 'home');
228
+ },
229
+ }));
package/src/acs/ws.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Long-lived WebSocket subscription to the `acs.artifacts` and
3
+ * `acs.home-actions` topics. Protocol (backend websocket.js):
4
+ *
5
+ * client → server : { type: "subscribe", topic: "acs.artifacts" }
6
+ * server → client : { event: "subscribed", data: { topic: "acs.artifacts" } }
7
+ * server → client : { topic: "acs.artifacts", event: "updated", artifact: {...} }
8
+ * server → client : { topic: "acs.artifacts", event: "retired", artifactId: "..." }
9
+ * server → client : { topic: "acs.artifacts", event: "order", pinned: [...] }
10
+ *
11
+ * Events flow into the stores' applyServer* methods so we don't loop (those
12
+ * methods mutate local state only — no API calls).
13
+ *
14
+ * Lifecycle:
15
+ * connectAcsWs(token, tenant) starts a single connection + subscribe.
16
+ * On disconnect, exponential-backoff reconnect (capped). Call
17
+ * notifyAcsWsForeground() from the host's AppState listener to
18
+ * force-reconnect when the socket dropped while backgrounded (this module
19
+ * is host-agnostic — no react-native import — so the host owns AppState).
20
+ * On signout, disconnectAcsWs() closes the socket and stops reconnects.
21
+ *
22
+ * Backend's spec note: "WS is best-effort; the GET is the source of truth"
23
+ * — so widgetsStore.refresh() on foreground stays the safety net even when
24
+ * the WS is healthy. This file is the realtime cherry on top.
25
+ */
26
+
27
+ import { getCoreWsUrl } from '../config';
28
+ import { useWidgetsStore } from './widgetsStore';
29
+ import { useHomeActionsStore } from './homeActionsStore';
30
+
31
+ let _ws: WebSocket | null = null;
32
+ let _wantOpen = false;
33
+ let _retryAttempt = 0;
34
+ let _retryTimer: ReturnType<typeof setTimeout> | null = null;
35
+ let _token: string | null = null;
36
+ let _tenant: string | null = null;
37
+
38
+ const TOPIC = 'acs.artifacts';
39
+ const TOPIC_HOME = 'acs.home-actions';
40
+ const MAX_BACKOFF_MS = 30_000;
41
+
42
+ function backoffMs(attempt: number): number {
43
+ // 0.5s, 1s, 2s, 4s, ... capped at 30s. Tiny jitter to avoid thundering herd.
44
+ const base = Math.min(MAX_BACKOFF_MS, 500 * Math.pow(2, attempt));
45
+ const jitter = Math.random() * 250;
46
+ return base + jitter;
47
+ }
48
+
49
+ function send(obj: any) {
50
+ if (_ws && _ws.readyState === 1) {
51
+ try { _ws.send(JSON.stringify(obj)); } catch (e) { console.warn('[acsWs] send failed', e); }
52
+ }
53
+ }
54
+
55
+ function handleEvent(msg: any) {
56
+ // The subscribe confirmation comes as { event: "subscribed", data: {...} }
57
+ // (no `topic` field). Real events come as { topic, event, ...payload }.
58
+ if (msg?.event === 'subscribed') {
59
+ console.log('[acsWs] subscribed to', msg?.data?.topic);
60
+ return;
61
+ }
62
+
63
+ // Home-actions feed events route to its own store.
64
+ if (msg?.topic === TOPIC_HOME) {
65
+ const home = useHomeActionsStore.getState();
66
+ switch (msg.event) {
67
+ case 'updated':
68
+ if (msg.action && typeof msg.action.actionId === 'string') home.applyServerUpsert(msg.action);
69
+ break;
70
+ case 'removed':
71
+ if (typeof msg.actionId === 'string') home.applyServerRemove(msg.actionId);
72
+ break;
73
+ case 'order':
74
+ if (Array.isArray(msg.order)) home.applyServerOrder(msg.order.filter((id: any) => typeof id === 'string'));
75
+ break;
76
+ case 'refreshed':
77
+ home.refresh();
78
+ break;
79
+ default:
80
+ break;
81
+ }
82
+ return;
83
+ }
84
+
85
+ if (msg?.topic !== TOPIC) return;
86
+
87
+ const store = useWidgetsStore.getState();
88
+ switch (msg.event) {
89
+ case 'updated':
90
+ if (msg.artifact && typeof msg.artifact.id === 'string') {
91
+ store.applyServerUpdate(msg.artifact);
92
+ }
93
+ break;
94
+ case 'retired':
95
+ if (typeof msg.artifactId === 'string') {
96
+ store.applyServerRetire(msg.artifactId);
97
+ }
98
+ break;
99
+ case 'order':
100
+ if (Array.isArray(msg.pinned)) {
101
+ store.applyServerOrder(msg.pinned.filter((id: any) => typeof id === 'string'));
102
+ }
103
+ break;
104
+ case 'created':
105
+ // Backend spec lists `created` as TODO. Until it ships, host foreground
106
+ // refresh catches new widgets. When it lands the WS will also fire
107
+ // `updated` for the new tile, which is already handled. For now, treat
108
+ // any inbound created the same as updated.
109
+ if (msg.artifact && typeof msg.artifact.id === 'string') {
110
+ store.applyServerUpdate(msg.artifact);
111
+ }
112
+ break;
113
+ default:
114
+ // Unknown event — ignore so we stay forward-compatible with new types.
115
+ break;
116
+ }
117
+ }
118
+
119
+ function openSocket() {
120
+ if (_ws) return;
121
+ if (!_token || !_tenant) return;
122
+
123
+ const url = `${getCoreWsUrl()}/ws?token=${encodeURIComponent(_token)}&tenant=${encodeURIComponent(_tenant)}`;
124
+ console.log(`[acsWs] opening`);
125
+ const ws = new WebSocket(url);
126
+ _ws = ws;
127
+
128
+ ws.onopen = () => {
129
+ _retryAttempt = 0;
130
+ send({ type: 'subscribe', topic: TOPIC });
131
+ send({ type: 'subscribe', topic: TOPIC_HOME });
132
+ };
133
+ ws.onmessage = (e) => {
134
+ try {
135
+ const msg = JSON.parse(typeof e.data === 'string' ? e.data : '');
136
+ handleEvent(msg);
137
+ } catch (err) {
138
+ // Non-JSON or malformed frame — ignore, log for debugging.
139
+ console.warn('[acsWs] parse failed', err);
140
+ }
141
+ };
142
+ ws.onerror = (e: any) => {
143
+ console.warn('[acsWs] error', e?.message || e);
144
+ };
145
+ ws.onclose = () => {
146
+ _ws = null;
147
+ if (!_wantOpen) return;
148
+ const delay = backoffMs(_retryAttempt++);
149
+ console.log(`[acsWs] closed; reconnecting in ${Math.round(delay)}ms (attempt ${_retryAttempt})`);
150
+ if (_retryTimer) clearTimeout(_retryTimer);
151
+ _retryTimer = setTimeout(openSocket, delay);
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Call from the host's AppState listener when the app returns to the
157
+ * foreground — if the socket dropped while backgrounded, reconnect
158
+ * immediately rather than waiting out the backoff timer.
159
+ */
160
+ export function notifyAcsWsForeground() {
161
+ if (_wantOpen && !_ws) {
162
+ if (_retryTimer) { clearTimeout(_retryTimer); _retryTimer = null; }
163
+ _retryAttempt = 0;
164
+ openSocket();
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Start the ACS WS subscription. Idempotent — calling twice with the same
170
+ * token/tenant is a no-op. Call once the host says the user is signed in
171
+ * (JWT available).
172
+ */
173
+ export function connectAcsWs(token: string, tenant: string) {
174
+ if (_ws && _wantOpen && _token === token && _tenant === tenant) return;
175
+ // Token or tenant changed → tear down + reopen.
176
+ if (_ws) {
177
+ try { _ws.close(); } catch {}
178
+ _ws = null;
179
+ }
180
+ _token = token;
181
+ _tenant = tenant;
182
+ _wantOpen = true;
183
+ openSocket();
184
+ }
185
+
186
+ /**
187
+ * Stop the ACS WS subscription. Call on signout / env switch.
188
+ */
189
+ export function disconnectAcsWs() {
190
+ _wantOpen = false;
191
+ _token = null;
192
+ _tenant = null;
193
+ if (_retryTimer) { clearTimeout(_retryTimer); _retryTimer = null; }
194
+ if (_ws) {
195
+ try { _ws.close(); } catch {}
196
+ _ws = null;
197
+ }
198
+ }
@@ -0,0 +1,22 @@
1
+ import { apiFetch } from './client';
2
+
3
+ export interface TenantMembership {
4
+ id: string;
5
+ name: string;
6
+ role: string | null;
7
+ }
8
+
9
+ /**
10
+ * List the tenants the CURRENT user belongs to (GET /api/tenants/list).
11
+ *
12
+ * For consumer JWTs the backend resolves memberships by login identity
13
+ * (google/apple oauth or embed id) across active tenants; builder JWTs get
14
+ * their user-doc memberships. Returns [] when signed out or unknown.
15
+ *
16
+ * Note: switching tenant still requires a NEW JWT for that tenant (the
17
+ * token is tenant-scoped) — the host owns that re-auth flow.
18
+ */
19
+ export async function listMyTenants(): Promise<TenantMembership[]> {
20
+ const data = await apiFetch<{ ok: boolean; tenants?: TenantMembership[] }>('/api/tenants/list');
21
+ return data.ok && Array.isArray(data.tenants) ? data.tenants : [];
22
+ }
package/src/config.ts CHANGED
@@ -41,17 +41,28 @@ export interface AdasUser {
41
41
  actorId: string;
42
42
  name?: string;
43
43
  email?: string;
44
+ /** Roles from the host's identity (e.g. JWT claims) — forwarded to plugins
45
+ * as PluginContext.user.roles. */
46
+ roles?: string[];
44
47
  }
45
48
 
49
+ /** A URL that is either static or resolved on each read. Hosts with a
50
+ * runtime env switcher (dev/prod) pass a getter so the SDK always sees the
51
+ * currently-selected backend without re-calling initAdas(). */
52
+ export type UrlSource = string | (() => string);
53
+
46
54
  export interface AdasConfig {
47
- /** Core API base URL, e.g. https://app.ateam-ai.com */
48
- coreUrl: string;
55
+ /** Core API base URL, e.g. https://app.ateam-ai.com. Pass a getter to
56
+ * support a runtime env switch without re-initializing. */
57
+ coreUrl: UrlSource;
49
58
  /** Voice service base URL (optional — only voice features need it). */
50
- voiceUrl?: string;
59
+ voiceUrl?: UrlSource;
51
60
  tokenProvider: TokenProvider;
52
- /** Tenant slug — informational/telemetry only. The backend derives the
53
- * tenant from the JWT; the SDK never sends a tenant header. */
54
- tenant?: string;
61
+ /** Tenant slug — used for plugin URL resolution and voice, never sent as a
62
+ * header (the backend derives the tenant from the JWT). Pass a getter when
63
+ * the host resolves the tenant after startup (storage hydration, runtime
64
+ * tenant switch). */
65
+ tenant?: string | (() => string);
55
66
  /** Current end user as known to the host. actorId is used as the chat
56
67
  * reply context; without it, replies route to 'unknown'. */
57
68
  getUser?: () => AdasUser | null;
@@ -81,8 +92,12 @@ export function getConfig(): AdasConfig {
81
92
  return _config;
82
93
  }
83
94
 
95
+ function resolveUrl(src: UrlSource): string {
96
+ return (typeof src === 'function' ? src() : src).replace(/\/$/, '');
97
+ }
98
+
84
99
  export function getCoreApiUrl(): string {
85
- return getConfig().coreUrl.replace(/\/$/, '');
100
+ return resolveUrl(getConfig().coreUrl);
86
101
  }
87
102
 
88
103
  export function getCoreWsUrl(): string {
@@ -92,11 +107,12 @@ export function getCoreWsUrl(): string {
92
107
  export function getVoiceApiUrl(): string {
93
108
  const url = getConfig().voiceUrl;
94
109
  if (!url) throw new Error('[adas] voiceUrl not configured');
95
- return url.replace(/\/$/, '');
110
+ return resolveUrl(url);
96
111
  }
97
112
 
98
113
  export function getTenant(): string {
99
- return getConfig().tenant || '';
114
+ const t = getConfig().tenant;
115
+ return (typeof t === 'function' ? t() : t) || '';
100
116
  }
101
117
 
102
118
  export function getUser(): AdasUser | null {
package/src/index.ts CHANGED
@@ -41,6 +41,10 @@ export {
41
41
  unwrapMcpResult,
42
42
  } from './api/mcp';
43
43
 
44
+ // Tenant memberships
45
+ export { listMyTenants } from './api/tenants';
46
+ export type { TenantMembership } from './api/tenants';
47
+
44
48
  // Chat
45
49
  export {
46
50
  useChatStore,
@@ -70,3 +74,36 @@ export type { BufferedCommand } from './utils/emitter';
70
74
 
71
75
  // Theme tokens
72
76
  export { colors, ada, spacing, fontSize, borderRadius } from './theme/tokens';
77
+
78
+ // ACS (home widgets + top-actions feed)
79
+ export {
80
+ listSurface,
81
+ listMadeForYou,
82
+ deleteArtifact,
83
+ patchArtifact,
84
+ setOrder,
85
+ recordEvent,
86
+ listHomeActions,
87
+ patchHomeAction,
88
+ setHomeActionOrder,
89
+ } from './acs/api';
90
+ export type {
91
+ AcsArtifact,
92
+ AcsSurface,
93
+ AcsEvent,
94
+ AcsDeleteReason,
95
+ AcsPatchReason,
96
+ DeleteResult,
97
+ PatchResult,
98
+ HomeAction,
99
+ HomeActionLeaf,
100
+ HomeActionReason,
101
+ } from './acs/api';
102
+ export { useWidgetsStore } from './acs/widgetsStore';
103
+ export { useHomeActionsStore } from './acs/homeActionsStore';
104
+ export { connectAcsWs, disconnectAcsWs, notifyAcsWsForeground } from './acs/ws';
105
+ export {
106
+ setAcsReachabilityReporter,
107
+ setAcsCacheStorage,
108
+ } from './acs/providers';
109
+ export type { AcsReachabilityReporter, AcsKvStorage } from './acs/providers';