@hosty-sdk/app 0.34.1 → 0.35.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,35 @@
1
+ import type { ProviderServerOptions } from "./providers-server.js";
2
+ import type { ToolActivity, ToolActivityState } from "./ui-types.js";
3
+ export type ToolActivityValue = {
4
+ state: ToolActivityState;
5
+ count?: number;
6
+ label?: string;
7
+ ttlSeconds?: number;
8
+ };
9
+ export type ToolActivityReporterOptions = ProviderServerOptions & {
10
+ installationId?: string;
11
+ onError?: (error: Error) => void;
12
+ /** Acknowledges the current value; rejection keeps it queued for retry. */
13
+ onReported?: (userId: string, panelId: string, value: ToolActivityValue) => void | Promise<void>;
14
+ };
15
+ /** Server-only, current-state reporting. No transcript data or browser frame is required. */
16
+ export declare class ToolActivityReporter {
17
+ private readonly options;
18
+ private readonly appId;
19
+ private readonly installation;
20
+ private readonly core;
21
+ private readonly service;
22
+ private readonly entries;
23
+ private readonly controller;
24
+ private readonly timer;
25
+ private pending?;
26
+ private disposed;
27
+ constructor(options?: ToolActivityReporterOptions);
28
+ set(userId: string, panelId: string, value: ToolActivityValue): void;
29
+ /** Serializes changes, retries failures and renews presence. After Core restarts every retained
30
+ * state is re-reported within 20 seconds, without rebinding to another installation. */
31
+ flush(): Promise<void>;
32
+ private sendDue;
33
+ dispose(): Promise<void>;
34
+ }
35
+ export type { ToolActivity };
@@ -0,0 +1,93 @@
1
+ /** Server-only, current-state reporting. No transcript data or browser frame is required. */
2
+ export class ToolActivityReporter {
3
+ options;
4
+ appId;
5
+ installation;
6
+ core;
7
+ service;
8
+ entries = new Map();
9
+ controller = new AbortController();
10
+ timer;
11
+ pending;
12
+ disposed = false;
13
+ constructor(options = {}) {
14
+ this.options = options;
15
+ this.appId = options.appId ?? process.env.HOSTY_APP_ID ?? "";
16
+ this.installation = options.installationId ?? process.env.HOSTY_APP_INSTALLATION_ID ?? "";
17
+ this.core = new URL(options.coreOrigin ?? process.env.HOSTY_CORE_ORIGIN ?? "http://127.0.0.1:7070");
18
+ this.service = options.serviceToken ?? process.env.HOSTY_APP_SERVICE_TOKEN ?? "";
19
+ if (!this.appId || !this.installation || !this.service)
20
+ throw new Error("Tool activity requires a Core-managed installation and app service identity.");
21
+ this.timer = setInterval(() => { void this.flush(); }, 250);
22
+ this.timer.unref?.();
23
+ }
24
+ set(userId, panelId, value) {
25
+ if (this.disposed)
26
+ return;
27
+ if (!userId || !panelId || !["idle", "working", "needs-you", "failed"].includes(value.state)
28
+ || value.count !== undefined && (!Number.isInteger(value.count) || value.count < 0 || value.count > 9999)
29
+ || value.label !== undefined && (value.label.length > 120 || /[\x00-\x1f\x7f]/.test(value.label))
30
+ || value.ttlSeconds !== undefined && (!Number.isInteger(value.ttlSeconds) || value.ttlSeconds < 1 || value.ttlSeconds > 300))
31
+ throw new Error("Invalid tool activity.");
32
+ const key = JSON.stringify([userId, panelId]), previous = this.entries.get(key);
33
+ if (!previous && this.entries.size >= 10_000)
34
+ throw new Error("Tool activity reporter capacity reached.");
35
+ if (previous && JSON.stringify(previous.value) === JSON.stringify(value))
36
+ return;
37
+ this.entries.set(key, { userId, panelId, value: { ...value }, due: 0 });
38
+ void this.flush();
39
+ }
40
+ /** Serializes changes, retries failures and renews presence. After Core restarts every retained
41
+ * state is re-reported within 20 seconds, without rebinding to another installation. */
42
+ flush() {
43
+ if (this.pending)
44
+ return this.pending;
45
+ if (this.disposed)
46
+ return Promise.resolve();
47
+ this.pending = this.sendDue().finally(() => { this.pending = undefined; });
48
+ return this.pending;
49
+ }
50
+ async sendDue() {
51
+ for (const [key, entry] of this.entries) {
52
+ if (this.disposed || entry.due > Date.now())
53
+ continue;
54
+ try {
55
+ const response = await fetch(new URL(`/api/internal/apps/${encodeURIComponent(this.appId)}/ui/activity`, this.core), {
56
+ method: "POST", redirect: "error", cache: "no-store",
57
+ signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(10_000)]),
58
+ headers: { authorization: `Bearer ${this.service}`, "content-type": "application/json" },
59
+ body: JSON.stringify({ userId: entry.userId, installationId: this.installation, panelId: entry.panelId, ...entry.value }),
60
+ });
61
+ await response.body?.cancel();
62
+ if (!response.ok)
63
+ throw new Error(`Tool activity report failed (${response.status}).`);
64
+ // A newer value may have arrived during this request; never acknowledge or delete it.
65
+ if (this.entries.get(key) !== entry)
66
+ continue;
67
+ await this.options.onReported?.(entry.userId, entry.panelId, { ...entry.value });
68
+ if (this.entries.get(key) !== entry)
69
+ continue;
70
+ if (entry.value.state === "idle")
71
+ this.entries.delete(key);
72
+ else
73
+ entry.due = Date.now() + Math.min(20_000, (entry.value.ttlSeconds ?? 60) * 1000 / 3);
74
+ }
75
+ catch (error) {
76
+ if (this.disposed)
77
+ return;
78
+ entry.due = Date.now() + 5000;
79
+ try {
80
+ this.options.onError?.(error instanceof Error ? error : new Error("Tool activity unavailable."));
81
+ }
82
+ catch { /* Observers cannot stop reporting. */ }
83
+ }
84
+ }
85
+ }
86
+ async dispose() {
87
+ this.disposed = true;
88
+ clearInterval(this.timer);
89
+ this.controller.abort();
90
+ await this.pending;
91
+ this.entries.clear();
92
+ }
93
+ }
package/dist/ui-host.d.ts CHANGED
@@ -112,7 +112,8 @@ export declare class UiHost implements UiClient {
112
112
  signal?: AbortSignal;
113
113
  }) => Promise<PresentationResult>;
114
114
  private status;
115
- selectPanel: (ref: PanelRef, workspaceId?: string) => Promise<PresentationResult>;
115
+ selectPanel: (ref: PanelRef, workspaceId?: string, signal?: AbortSignal) => Promise<PresentationResult>;
116
+ private selectPanelWithSignal;
116
117
  setExpanded: (expanded: boolean, workspaceId?: string) => void;
117
118
  setWidth: (width: number, workspaceId?: string) => void;
118
119
  setAttention: (panel: PanelRef | string, attention: number) => void;
package/dist/ui-host.js CHANGED
@@ -449,8 +449,10 @@ export class UiHost {
449
449
  return operation;
450
450
  };
451
451
  openSurface = (target, options) => waitForPresentation(this.open(target, options, "local"), options.requestId, options.signal);
452
- open(target, options, source, frame, sourceWorkspace) {
452
+ open(target, options, source, frame, sourceWorkspace, selectionSignal) {
453
453
  const id = options.requestId;
454
+ if (selectionSignal?.aborted)
455
+ return Promise.resolve({ status: "blocked", requestId: id, code: "presentation_cancelled" });
454
456
  if (!this.active || !this.activityReady || this.disposed)
455
457
  return Promise.resolve({ status: "blocked", requestId: id, code: "activity_required" });
456
458
  if (!validId(id) || !validTarget(target) || options.placement !== "side" || options.reuse !== undefined && !["exact", "new"].includes(options.reuse))
@@ -503,6 +505,8 @@ export class UiHost {
503
505
  try {
504
506
  const own = await this.bootstrap(generation);
505
507
  const check = () => {
508
+ if (selectionSignal?.aborted)
509
+ throw new UiError("presentation_cancelled", "This selection was cancelled.");
506
510
  this.checkActivity(generation);
507
511
  this.requireTools(frame);
508
512
  if (!own.canHost)
@@ -763,7 +767,21 @@ export class UiHost {
763
767
  return failure(id, error);
764
768
  }
765
769
  }
766
- selectPanel = async (ref, workspaceId = DEFAULT_WORKSPACE) => {
770
+ // Host-owned selections may cancel before presentation without superseding another selection.
771
+ // PresentationOptions.signal still cancels only a protocol caller's wait.
772
+ selectPanel = async (ref, workspaceId = DEFAULT_WORKSPACE, signal) => {
773
+ const cancel = () => this.invalidateRendererWork();
774
+ signal?.addEventListener("abort", cancel);
775
+ try {
776
+ return await this.selectPanelWithSignal(ref, workspaceId, signal);
777
+ }
778
+ finally {
779
+ signal?.removeEventListener("abort", cancel);
780
+ }
781
+ };
782
+ selectPanelWithSignal = async (ref, workspaceId, signal) => {
783
+ if (signal?.aborted)
784
+ return { status: "blocked", requestId: createPresentationRequestId(), code: "presentation_cancelled" };
767
785
  const opened = this.snapshot.tools.filter(t => t.workspaceId === workspaceId && t.key === panelKey(ref)).sort((a, b) => b.focusedAt - a.focusedAt)[0];
768
786
  if (opened?.resolved.surface) {
769
787
  const requestId = createPresentationRequestId();
@@ -778,6 +796,8 @@ export class UiHost {
778
796
  const resolved = await uiRequest(this.path, "panels/resolve", opened.target);
779
797
  this.checkActivity(generation);
780
798
  this.requireTools();
799
+ if (signal?.aborted)
800
+ throw new UiError("presentation_cancelled", "This selection was cancelled.");
781
801
  if (selection !== state.generation)
782
802
  throw new UiError("presentation_superseded", "A newer action replaced this opening.");
783
803
  if (!resolved.available || !resolved.surface)
@@ -802,7 +822,8 @@ export class UiHost {
802
822
  else if (opened)
803
823
  this.closePresentation(opened.presentationId);
804
824
  const requestId = createPresentationRequestId();
805
- return waitForPresentation(this.open({ panel: ref }, { placement: "side", requestId }, "local", undefined, workspaceId), requestId);
825
+ const opening = this.open({ panel: ref }, { placement: "side", requestId }, "local", undefined, workspaceId, signal);
826
+ return signal ? opening : waitForPresentation(opening, requestId);
806
827
  };
807
828
  setExpanded = (expanded, workspaceId = DEFAULT_WORKSPACE) => {
808
829
  if (!expanded)
@@ -1,6 +1,6 @@
1
1
  import type { SandboxIdentityContext } from "./sandbox-identity.js";
2
2
  import type { ProviderServerOptions } from "@hosty-sdk/app/providers/server";
3
- import { type PanelTarget, type UiBootstrap, type UiPanel, type UiResolvedPanel, type UiNavigationCatalog, type UiNavigationTarget, type UiResolvedNavigation } from "@hosty-sdk/app/ui/types";
3
+ import { type ToolActivity, type PanelTarget, type UiBootstrap, type UiPanel, type UiResolvedPanel, type UiNavigationCatalog, type UiNavigationTarget, type UiResolvedNavigation } from "@hosty-sdk/app/ui/types";
4
4
  /** Server transport for generic UI metadata. Credentials never appear in returned metadata. */
5
5
  export declare class UiServerClient {
6
6
  private readonly appId;
@@ -12,6 +12,7 @@ export declare class UiServerClient {
12
12
  parentOrigin?: string;
13
13
  }, token: string, signal?: AbortSignal): Promise<UiBootstrap>;
14
14
  listPanels(token: string, signal?: AbortSignal): Promise<UiPanel[]>;
15
+ activity(token: string, signal?: AbortSignal): Promise<ToolActivity[]>;
15
16
  navigation(token: string, signal?: AbortSignal): Promise<UiNavigationCatalog>;
16
17
  resolveNavigation(target: UiNavigationTarget, token: string, signal?: AbortSignal): Promise<UiResolvedNavigation>;
17
18
  resolvePanel(target: PanelTarget, token: string, signal?: AbortSignal): Promise<UiResolvedPanel>;
package/dist/ui-server.js CHANGED
@@ -35,6 +35,9 @@ export class UiServerClient {
35
35
  async listPanels(token, signal) {
36
36
  return (await this.request("panels", token, undefined, signal)).panels;
37
37
  }
38
+ async activity(token, signal) {
39
+ return (await this.request("activity", token, undefined, signal)).activities;
40
+ }
38
41
  navigation(token, signal) {
39
42
  return this.request("navigation", token, undefined, signal);
40
43
  }
@@ -70,6 +73,11 @@ export async function createHostyUiResponse(request, context, options = {}) {
70
73
  else if (request.method === "GET" && route === "panels") {
71
74
  result = { panels: await client.listPanels(context.token, request.signal) };
72
75
  }
76
+ else if (request.method === "GET" && route === "activity") {
77
+ if (url.search)
78
+ throw new UiError("ui_request_invalid", "Unexpected activity parameters.", 400);
79
+ result = { activities: await client.activity(context.token, request.signal) };
80
+ }
73
81
  else if (request.method === "GET" && route === "navigation") {
74
82
  if (url.search)
75
83
  throw new UiError("ui_request_invalid", "Unexpected navigation parameters.", 400);
@@ -192,3 +192,13 @@ export type UiRouterAdapter = {
192
192
  subscribe(listener: () => void): () => void;
193
193
  navigate(route: string): void | Promise<void>;
194
194
  };
195
+ /** Activity is owned by Core, independently of mounted tool frames. */
196
+ export type ToolActivityState = "idle" | "working" | "needs-you" | "failed";
197
+ export type ToolActivity = {
198
+ panel: PanelRef;
199
+ state: ToolActivityState | "no-connection";
200
+ count: number | null;
201
+ label: string | null;
202
+ reportedAt: string | null;
203
+ expiresAt: string | null;
204
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hosty-sdk/app",
3
- "version": "0.34.1",
3
+ "version": "0.35.1",
4
4
  "description": "Hosty app auth: session state machine, recovery bridge, Core revalidation, and the embedder responder.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -128,6 +128,10 @@
128
128
  "./sandbox/react": {
129
129
  "types": "./dist/sandbox-react.d.ts",
130
130
  "default": "./dist/sandbox-react.js"
131
+ },
132
+ "./tool-activity/server": {
133
+ "types": "./dist/tool-activity-server.d.ts",
134
+ "default": "./dist/tool-activity-server.js"
131
135
  }
132
136
  },
133
137
  "scripts": {