@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.
- package/dist/tool-activity-server.d.ts +35 -0
- package/dist/tool-activity-server.js +93 -0
- package/dist/ui-host.d.ts +2 -1
- package/dist/ui-host.js +24 -3
- package/dist/ui-server.d.ts +2 -1
- package/dist/ui-server.js +8 -0
- package/dist/ui-types.d.ts +10 -0
- package/package.json +5 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
package/dist/ui-server.d.ts
CHANGED
|
@@ -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);
|
package/dist/ui-types.d.ts
CHANGED
|
@@ -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.
|
|
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": {
|