@fungi.computer/agar 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +766 -2
- package/dist/agent-message-meta.d.ts +74 -0
- package/dist/agent-message-meta.js +63 -0
- package/dist/agents.d.ts +170 -0
- package/dist/agents.js +218 -0
- package/dist/app-build-contract.d.ts +43 -0
- package/dist/app-build-contract.js +24 -0
- package/dist/apps.d.ts +66 -0
- package/dist/apps.js +47 -0
- package/dist/backend-declaration.d.ts +6 -0
- package/dist/backend-declaration.js +65 -0
- package/dist/backend-definition.d.ts +88 -0
- package/dist/backend-definition.js +158 -0
- package/dist/backend-refusal.d.ts +10 -0
- package/dist/backend-refusal.js +10 -0
- package/dist/backend.d.ts +30 -0
- package/dist/backend.js +28 -0
- package/dist/capabilities.d.ts +30 -0
- package/dist/capabilities.js +50 -0
- package/dist/channels.d.ts +142 -0
- package/dist/channels.js +97 -0
- package/dist/commands.d.ts +33 -0
- package/dist/commands.js +46 -0
- package/dist/computer-contracts.d.ts +73 -0
- package/dist/computer-contracts.js +57 -0
- package/dist/computer.d.ts +294 -0
- package/dist/computer.js +319 -0
- package/dist/connect.d.ts +11 -0
- package/dist/connect.js +211 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +106 -0
- package/dist/failure.d.ts +2 -0
- package/dist/failure.js +9 -0
- package/dist/files.d.ts +25 -0
- package/dist/files.js +84 -0
- package/dist/game-servers.d.ts +530 -0
- package/dist/game-servers.js +244 -0
- package/dist/gills.d.ts +43 -0
- package/dist/gills.js +174 -0
- package/dist/git-commit.d.ts +143 -0
- package/dist/git-commit.js +251 -0
- package/dist/git-rpc.d.ts +310 -0
- package/dist/git-rpc.js +166 -0
- package/dist/git-steps.d.ts +579 -0
- package/dist/git-steps.js +804 -0
- package/dist/git-wire.d.ts +33 -0
- package/dist/git-wire.js +240 -0
- package/dist/git.d.ts +322 -0
- package/dist/git.js +98 -0
- package/dist/host.d.ts +97 -0
- package/dist/host.js +732 -0
- package/dist/inbox.d.ts +99 -0
- package/dist/inbox.js +184 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +15 -0
- package/dist/inference.d.ts +17 -0
- package/dist/inference.js +115 -0
- package/dist/internal/app-error.d.ts +8 -0
- package/dist/internal/app-error.js +36 -0
- package/dist/internal/app-session.d.ts +8 -0
- package/dist/internal/app-session.js +551 -0
- package/dist/internal/back-inputs.d.ts +22 -0
- package/dist/internal/back-inputs.js +93 -0
- package/dist/internal/browser-bridge.d.ts +30 -0
- package/dist/internal/browser-bridge.js +203 -0
- package/dist/internal/capabilities.d.ts +13 -0
- package/dist/internal/capabilities.js +138 -0
- package/dist/internal/commands.d.ts +9 -0
- package/dist/internal/commands.js +257 -0
- package/dist/internal/disconnect-host.d.ts +7 -0
- package/dist/internal/disconnect-host.js +19 -0
- package/dist/internal/git-edit-tree.d.ts +6 -0
- package/dist/internal/git-edit-tree.js +46 -0
- package/dist/internal/memory-fs.d.ts +39 -0
- package/dist/internal/memory-fs.js +154 -0
- package/dist/internal/protocol.d.ts +116 -0
- package/dist/internal/protocol.js +6 -0
- package/dist/internal/query-feed.d.ts +23 -0
- package/dist/internal/query-feed.js +111 -0
- package/dist/internal/result.d.ts +22 -0
- package/dist/internal/result.js +61 -0
- package/dist/internal/rpc-contract.d.ts +24 -0
- package/dist/internal/rpc-contract.js +78 -0
- package/dist/internal/schemas.d.ts +307 -0
- package/dist/internal/schemas.js +524 -0
- package/dist/internal/store.d.ts +8 -0
- package/dist/internal/store.js +30 -0
- package/dist/internal/terminal-gate.d.ts +17 -0
- package/dist/internal/terminal-gate.js +94 -0
- package/dist/lifecycle.d.ts +44 -0
- package/dist/lifecycle.js +1 -0
- package/dist/marketplace.d.ts +159 -0
- package/dist/marketplace.js +102 -0
- package/dist/media-json.d.ts +181 -0
- package/dist/media-json.js +204 -0
- package/dist/media-library.d.ts +11 -0
- package/dist/media-library.js +15 -0
- package/dist/media-request-id.d.ts +7 -0
- package/dist/media-request-id.js +16 -0
- package/dist/media.d.ts +354 -0
- package/dist/media.js +654 -0
- package/dist/method-refusal.d.ts +64 -0
- package/dist/method-refusal.js +48 -0
- package/dist/pending.d.ts +10 -0
- package/dist/pending.js +10 -0
- package/dist/query.d.ts +76 -0
- package/dist/query.js +195 -0
- package/dist/react-mount.d.ts +23 -0
- package/dist/react-mount.js +108 -0
- package/dist/react.d.ts +41 -0
- package/dist/react.js +111 -0
- package/dist/read-copy.d.ts +103 -0
- package/dist/read-copy.js +195 -0
- package/dist/roles/files.d.ts +295 -0
- package/dist/roles/files.js +184 -0
- package/dist/root.d.ts +21 -0
- package/dist/root.js +1 -0
- package/dist/rpc.d.ts +1 -0
- package/dist/rpc.js +1 -0
- package/dist/status.d.ts +17 -0
- package/dist/status.js +50 -0
- package/dist/storage-wire.d.ts +167 -0
- package/dist/storage-wire.js +115 -0
- package/dist/storage.d.ts +72 -0
- package/dist/storage.js +143 -0
- package/dist/store.d.ts +11 -0
- package/dist/store.js +1 -0
- package/dist/test-kit/git.d.ts +31 -0
- package/dist/test-kit/git.js +174 -0
- package/dist/ui.d.ts +169 -0
- package/dist/ui.js +10 -0
- package/dist/window.d.ts +41 -0
- package/dist/window.js +15 -0
- package/dist/world.d.ts +16 -0
- package/dist/world.js +17 -0
- package/docs/api/README.md +28 -0
- package/docs/api/app-build-contract.md +113 -0
- package/docs/api/backend-declaration.md +62 -0
- package/docs/api/backend-definition.md +215 -0
- package/docs/api/backend.md +269 -0
- package/docs/api/errors.md +169 -0
- package/docs/api/files.md +91 -0
- package/docs/api/gills.md +164 -0
- package/docs/api/git-commit.md +279 -0
- package/docs/api/git-rpc.md +243 -0
- package/docs/api/git-steps.md +1010 -0
- package/docs/api/git-wire.md +197 -0
- package/docs/api/git.md +579 -0
- package/docs/api/index.md +2880 -0
- package/docs/api/media-json.md +507 -0
- package/docs/api/media-request-id.md +32 -0
- package/docs/api/modules.md +32 -0
- package/docs/api/query.md +253 -0
- package/docs/api/react.md +216 -0
- package/docs/api/read-copy.md +204 -0
- package/docs/api/rpc.md +128 -0
- package/docs/api/storage.md +620 -0
- package/package.json +197 -3
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import type { WhistleJsonValue } from "@fungi.computer/whistle";
|
|
3
|
+
/**
|
|
4
|
+
* The closed vocabulary an App method may refuse with. Each code names one
|
|
5
|
+
* caller-visible outcome; the host owns how it is presented (see
|
|
6
|
+
* {@link appMethodRefusalStatus}).
|
|
7
|
+
*/
|
|
8
|
+
export declare const appMethodRefusalCodes: readonly ["invalid", "forbidden", "not_found", "conflict", "unavailable"];
|
|
9
|
+
/** One of {@link appMethodRefusalCodes}. */
|
|
10
|
+
export type AppMethodRefusalCode = (typeof appMethodRefusalCodes)[number];
|
|
11
|
+
/** HTTP status for each refusal code, shared by every host and App route. */
|
|
12
|
+
export declare const appMethodRefusalStatus: {
|
|
13
|
+
readonly invalid: 400;
|
|
14
|
+
readonly forbidden: 403;
|
|
15
|
+
readonly not_found: 404;
|
|
16
|
+
readonly conflict: 409;
|
|
17
|
+
readonly unavailable: 503;
|
|
18
|
+
};
|
|
19
|
+
/** Bound on the App-authored refusal message that reaches callers. */
|
|
20
|
+
export declare const maxAppMethodRefusalMessage = 500;
|
|
21
|
+
/**
|
|
22
|
+
* An App method's declared domain refusal. A handler throws it to decline a
|
|
23
|
+
* call; the generated method boundary returns it as a typed
|
|
24
|
+
* {@link AppMethodReply} instead of an execution failure. Its message is
|
|
25
|
+
* caller-facing App copy, so it must not carry private data.
|
|
26
|
+
*/
|
|
27
|
+
export declare class AppMethodRefusal extends Error {
|
|
28
|
+
readonly code: AppMethodRefusalCode;
|
|
29
|
+
constructor(code: AppMethodRefusalCode, message: string);
|
|
30
|
+
}
|
|
31
|
+
/** A refused call as it crosses the generated method boundary. */
|
|
32
|
+
export declare const appMethodRefusedReplySchema: z.ZodObject<{
|
|
33
|
+
kind: z.ZodLiteral<"refused">;
|
|
34
|
+
code: z.ZodEnum<{
|
|
35
|
+
unavailable: "unavailable";
|
|
36
|
+
invalid: "invalid";
|
|
37
|
+
conflict: "conflict";
|
|
38
|
+
forbidden: "forbidden";
|
|
39
|
+
not_found: "not_found";
|
|
40
|
+
}>;
|
|
41
|
+
message: z.ZodString;
|
|
42
|
+
}, z.core.$strict>;
|
|
43
|
+
/** A decoded {@link appMethodRefusedReplySchema} value. */
|
|
44
|
+
export type AppMethodRefusedReply = z.output<typeof appMethodRefusedReplySchema>;
|
|
45
|
+
/** Every generated App method RPC answers with exactly one of these. */
|
|
46
|
+
export declare const appMethodReplySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
47
|
+
kind: z.ZodLiteral<"ok">;
|
|
48
|
+
value: z.ZodJSONSchema;
|
|
49
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
50
|
+
kind: z.ZodLiteral<"refused">;
|
|
51
|
+
code: z.ZodEnum<{
|
|
52
|
+
unavailable: "unavailable";
|
|
53
|
+
invalid: "invalid";
|
|
54
|
+
conflict: "conflict";
|
|
55
|
+
forbidden: "forbidden";
|
|
56
|
+
not_found: "not_found";
|
|
57
|
+
}>;
|
|
58
|
+
message: z.ZodString;
|
|
59
|
+
}, z.core.$strict>], "kind">;
|
|
60
|
+
/** A decoded {@link appMethodReplySchema} value; the result is read-only JSON. */
|
|
61
|
+
export type AppMethodReply = Readonly<{
|
|
62
|
+
kind: "ok";
|
|
63
|
+
value: WhistleJsonValue;
|
|
64
|
+
}> | AppMethodRefusedReply;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* The closed vocabulary an App method may refuse with. Each code names one
|
|
4
|
+
* caller-visible outcome; the host owns how it is presented (see
|
|
5
|
+
* {@link appMethodRefusalStatus}).
|
|
6
|
+
*/
|
|
7
|
+
export const appMethodRefusalCodes = [
|
|
8
|
+
"invalid",
|
|
9
|
+
"forbidden",
|
|
10
|
+
"not_found",
|
|
11
|
+
"conflict",
|
|
12
|
+
"unavailable",
|
|
13
|
+
];
|
|
14
|
+
/** HTTP status for each refusal code, shared by every host and App route. */
|
|
15
|
+
export const appMethodRefusalStatus = {
|
|
16
|
+
invalid: 400,
|
|
17
|
+
forbidden: 403,
|
|
18
|
+
not_found: 404,
|
|
19
|
+
conflict: 409,
|
|
20
|
+
unavailable: 503,
|
|
21
|
+
};
|
|
22
|
+
/** Bound on the App-authored refusal message that reaches callers. */
|
|
23
|
+
export const maxAppMethodRefusalMessage = 500;
|
|
24
|
+
/**
|
|
25
|
+
* An App method's declared domain refusal. A handler throws it to decline a
|
|
26
|
+
* call; the generated method boundary returns it as a typed
|
|
27
|
+
* {@link AppMethodReply} instead of an execution failure. Its message is
|
|
28
|
+
* caller-facing App copy, so it must not carry private data.
|
|
29
|
+
*/
|
|
30
|
+
export class AppMethodRefusal extends Error {
|
|
31
|
+
code;
|
|
32
|
+
constructor(code, message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "AppMethodRefusal";
|
|
35
|
+
this.code = code;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** A refused call as it crosses the generated method boundary. */
|
|
39
|
+
export const appMethodRefusedReplySchema = z.strictObject({
|
|
40
|
+
kind: z.literal("refused"),
|
|
41
|
+
code: z.enum(appMethodRefusalCodes),
|
|
42
|
+
message: z.string().min(1).max(maxAppMethodRefusalMessage),
|
|
43
|
+
});
|
|
44
|
+
/** Every generated App method RPC answers with exactly one of these. */
|
|
45
|
+
export const appMethodReplySchema = z.discriminatedUnion("kind", [
|
|
46
|
+
z.strictObject({ kind: z.literal("ok"), value: z.json() }),
|
|
47
|
+
appMethodRefusedReplySchema,
|
|
48
|
+
]);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
/** An opted-in App method is still working; poll again after this bounded delay. */
|
|
3
|
+
export declare const pendingSchema: z.ZodObject<{
|
|
4
|
+
kind: z.ZodLiteral<"pending">;
|
|
5
|
+
retryAfterMs: z.ZodNumber;
|
|
6
|
+
}, z.core.$strict>;
|
|
7
|
+
/** The shared polling result, owned by the App contract. */
|
|
8
|
+
export type Pending = Readonly<z.output<typeof pendingSchema>>;
|
|
9
|
+
/** Return Pending with a polling delay between one and sixty seconds. */
|
|
10
|
+
export declare function pending(retryAfterMs: number): Pending;
|
package/dist/pending.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
/** An opted-in App method is still working; poll again after this bounded delay. */
|
|
3
|
+
export const pendingSchema = z.strictObject({
|
|
4
|
+
kind: z.literal("pending"),
|
|
5
|
+
retryAfterMs: z.number().int().min(1000).max(60_000),
|
|
6
|
+
});
|
|
7
|
+
/** Return Pending with a polling delay between one and sixty seconds. */
|
|
8
|
+
export function pending(retryAfterMs) {
|
|
9
|
+
return Object.freeze(pendingSchema.parse({ kind: "pending", retryAfterMs }));
|
|
10
|
+
}
|
package/dist/query.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { QueryClient, type MutationFunction, type QueryKey } from "@tanstack/react-query";
|
|
2
|
+
import type { CapabilityContract } from "./capabilities.js";
|
|
3
|
+
import { AppError } from "./errors.js";
|
|
4
|
+
import type { AppRoot } from "./root.js";
|
|
5
|
+
export { useFeedQuery } from "./internal/query-feed.js";
|
|
6
|
+
export type { FeedSnapshot, FeedWatch } from "./internal/query-feed.js";
|
|
7
|
+
/** The App-owned identity and grants needed by the query integration. */
|
|
8
|
+
export type QueryApp = Pick<AppRoot, "identity" | "context" | "capabilities">;
|
|
9
|
+
/** Cache identity belongs to an App surface and its selected Computer. */
|
|
10
|
+
export declare const queryKeys: {
|
|
11
|
+
/** Build a cache key scoped to this App's surface and its currently
|
|
12
|
+
* selected Computer, followed by the given key parts. */
|
|
13
|
+
of(app: QueryApp | null, ...parts: QueryKey): readonly [string | null, string | null, ...unknown[]];
|
|
14
|
+
};
|
|
15
|
+
/** App defaults and grant lifetime binding, using the host's ordinary cache. */
|
|
16
|
+
export declare function createQueryClient(): QueryClient;
|
|
17
|
+
/** Decode capabilities through their owner; Query owns cancellation and retries. */
|
|
18
|
+
export declare function capabilityQuery<H, T>(app: QueryApp | null, contract: CapabilityContract<H>, queryKey: QueryKey, run: (handle: H, signal: AbortSignal) => Promise<T>): import("@tanstack/react-query").OmitKeyof<import("@tanstack/react-query").UseQueryOptions<T, AppError, T, readonly unknown[]>, "queryFn"> & {
|
|
19
|
+
queryFn?: import("@tanstack/react-query").QueryFunction<T, readonly unknown[], never> | undefined;
|
|
20
|
+
} & import("@tanstack/react-query").QueryKeyWithDataTag<readonly unknown[], T, AppError>;
|
|
21
|
+
/**
|
|
22
|
+
* Optimistic writes for one cache entry, followed by owner reconciliation.
|
|
23
|
+
* Apply must return new data. Concurrent writes to this key run in order;
|
|
24
|
+
* queued writes wait for reconciliation instead of stacking rollback snapshots.
|
|
25
|
+
*/
|
|
26
|
+
export declare function optimisticMutation<TData, TVars, TResult = unknown>(options: {
|
|
27
|
+
queryKey: QueryKey;
|
|
28
|
+
mutationFn: MutationFunction<TResult, TVars>;
|
|
29
|
+
apply: (previous: TData, variables: TVars) => TData;
|
|
30
|
+
}): import("@tanstack/react-query").WithRequired<import("@tanstack/react-query").UseMutationOptions<TResult, AppError, TVars, {
|
|
31
|
+
previous: TData;
|
|
32
|
+
optimistic: TData | undefined;
|
|
33
|
+
} | undefined>, "mutationKey">;
|
|
34
|
+
/** One shared inbox watch; reloads and stream failures retain the last live rows. */
|
|
35
|
+
export declare function useMemberInbox(app: QueryApp | null): {
|
|
36
|
+
items: readonly ({
|
|
37
|
+
readonly agentId: string;
|
|
38
|
+
readonly sessionId: string;
|
|
39
|
+
readonly title: string;
|
|
40
|
+
readonly state: "running" | "done" | "needs_you" | "errored";
|
|
41
|
+
readonly createdAt: number;
|
|
42
|
+
readonly lastActivityAt: number;
|
|
43
|
+
readonly triggerId?: string | undefined;
|
|
44
|
+
readonly settledAt?: number | undefined;
|
|
45
|
+
readonly visitedAt?: number | undefined;
|
|
46
|
+
} | {
|
|
47
|
+
readonly agentId: string;
|
|
48
|
+
readonly sessionId: string;
|
|
49
|
+
readonly state: "unavailable";
|
|
50
|
+
} | {
|
|
51
|
+
readonly kind: "workflow-review";
|
|
52
|
+
readonly key: {
|
|
53
|
+
readonly runId: string;
|
|
54
|
+
readonly nodeId: string;
|
|
55
|
+
readonly iteration: string;
|
|
56
|
+
};
|
|
57
|
+
readonly state: "pending" | "closed" | "answered" | "delivered" | "timed_out";
|
|
58
|
+
readonly workflowId: string | null;
|
|
59
|
+
readonly expiresAt: number;
|
|
60
|
+
readonly installationId: string;
|
|
61
|
+
readonly workflowTitle: string;
|
|
62
|
+
readonly reviewTitle: string;
|
|
63
|
+
readonly reviewers: "runner" | "owners";
|
|
64
|
+
readonly show: readonly import("effect/Schema").Json[];
|
|
65
|
+
readonly openedAt: number;
|
|
66
|
+
readonly choices?: readonly string[] | undefined;
|
|
67
|
+
})[];
|
|
68
|
+
status: "error" | "loading" | "live";
|
|
69
|
+
error: AppError | null;
|
|
70
|
+
/** Restart the inbox watch; use after a change that the live feed
|
|
71
|
+
* would not otherwise observe. */
|
|
72
|
+
refresh: () => void;
|
|
73
|
+
/** Record that the member visited one agent session, so the inbox
|
|
74
|
+
* can clear its unread state for it. */
|
|
75
|
+
visit: (agentId: string, sessionId: string) => Promise<void>;
|
|
76
|
+
};
|
package/dist/query.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { QueryClient, hashKey, mutationOptions, queryOptions, useQueryClient, } from "@tanstack/react-query";
|
|
2
|
+
import { useCallback, useMemo } from "react";
|
|
3
|
+
import { useStore } from "./react.js";
|
|
4
|
+
import { openAppInbox } from "./agents.js";
|
|
5
|
+
import { AppError } from "./errors.js";
|
|
6
|
+
import { watchInbox, useFeedQuery, queryError, } from "./internal/query-feed.js";
|
|
7
|
+
export { useFeedQuery } from "./internal/query-feed.js";
|
|
8
|
+
const scopes = new WeakMap();
|
|
9
|
+
/** Cache identity belongs to an App surface and its selected Computer. */
|
|
10
|
+
export const queryKeys = {
|
|
11
|
+
/** Build a cache key scoped to this App's surface and its currently
|
|
12
|
+
* selected Computer, followed by the given key parts. */
|
|
13
|
+
of(app, ...parts) {
|
|
14
|
+
return [
|
|
15
|
+
app?.identity.surfaceId ?? null,
|
|
16
|
+
app?.context.get().computerId ?? null,
|
|
17
|
+
...parts,
|
|
18
|
+
];
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
function scopeMeta(app) {
|
|
22
|
+
const meta = {};
|
|
23
|
+
if (app)
|
|
24
|
+
scopes.set(meta, app);
|
|
25
|
+
return meta;
|
|
26
|
+
}
|
|
27
|
+
/** App defaults and grant lifetime binding, using the host's ordinary cache. */
|
|
28
|
+
export function createQueryClient() {
|
|
29
|
+
const client = new QueryClient({
|
|
30
|
+
defaultOptions: {
|
|
31
|
+
queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false },
|
|
32
|
+
mutations: { retry: false },
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const bindings = new Map();
|
|
36
|
+
client.getQueryCache().subscribe((event) => {
|
|
37
|
+
const query = event.query;
|
|
38
|
+
if (event.type === "removed") {
|
|
39
|
+
bindings.get(query.queryHash)?.release();
|
|
40
|
+
bindings.delete(query.queryHash);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const app = query.meta && scopes.get(query.meta);
|
|
44
|
+
const previous = bindings.get(query.queryHash);
|
|
45
|
+
if (previous?.app === app)
|
|
46
|
+
return;
|
|
47
|
+
previous?.release();
|
|
48
|
+
bindings.delete(query.queryHash);
|
|
49
|
+
if (!app)
|
|
50
|
+
return;
|
|
51
|
+
const reset = () => {
|
|
52
|
+
// The host publishes new grants before the accompanying Computer context.
|
|
53
|
+
// Clear revoked data and cancel old reads only after that atomic RPC turn.
|
|
54
|
+
queueMicrotask(() => {
|
|
55
|
+
if (client.getQueryCache().get(query.queryHash) !== query ||
|
|
56
|
+
bindings.get(query.queryHash)?.app !== app)
|
|
57
|
+
return;
|
|
58
|
+
void client.resetQueries({
|
|
59
|
+
predicate: (candidate) => candidate === query,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
const release = app.capabilities.subscribe(reset);
|
|
64
|
+
bindings.set(query.queryHash, { app, release });
|
|
65
|
+
// Reconnecting the same logical surface replaces its remote handles too.
|
|
66
|
+
if (previous)
|
|
67
|
+
reset();
|
|
68
|
+
});
|
|
69
|
+
return client;
|
|
70
|
+
}
|
|
71
|
+
/** Decode capabilities through their owner; Query owns cancellation and retries. */
|
|
72
|
+
export function capabilityQuery(app, contract, queryKey, run) {
|
|
73
|
+
const computerId = app?.context.get().computerId;
|
|
74
|
+
return queryOptions({
|
|
75
|
+
queryKey,
|
|
76
|
+
meta: scopeMeta(app),
|
|
77
|
+
enabled: app !== null,
|
|
78
|
+
retry: false,
|
|
79
|
+
queryFn: async ({ signal }) => {
|
|
80
|
+
try {
|
|
81
|
+
if (!app)
|
|
82
|
+
throw new AppError("No App connection", {
|
|
83
|
+
code: "DISCONNECTED",
|
|
84
|
+
retryable: true,
|
|
85
|
+
});
|
|
86
|
+
if (app.context.get().computerId !== computerId)
|
|
87
|
+
throw new AppError("The selected Computer changed", {
|
|
88
|
+
code: "SELECTION_CHANGED",
|
|
89
|
+
retryable: false,
|
|
90
|
+
});
|
|
91
|
+
return await run(app.capabilities.require(contract), signal);
|
|
92
|
+
}
|
|
93
|
+
catch (cause) {
|
|
94
|
+
throw queryError(cause);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Optimistic writes for one cache entry, followed by owner reconciliation.
|
|
101
|
+
* Apply must return new data. Concurrent writes to this key run in order;
|
|
102
|
+
* queued writes wait for reconciliation instead of stacking rollback snapshots.
|
|
103
|
+
*/
|
|
104
|
+
export function optimisticMutation(options) {
|
|
105
|
+
return mutationOptions({
|
|
106
|
+
mutationKey: options.queryKey,
|
|
107
|
+
scope: { id: hashKey(options.queryKey) },
|
|
108
|
+
mutationFn: async (variables, context) => {
|
|
109
|
+
try {
|
|
110
|
+
return await options.mutationFn(variables, context);
|
|
111
|
+
}
|
|
112
|
+
catch (cause) {
|
|
113
|
+
throw queryError(cause);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
onMutate: async (variables, { client }) => {
|
|
117
|
+
if (client.isMutating({ mutationKey: options.queryKey, exact: true }) > 1)
|
|
118
|
+
return undefined;
|
|
119
|
+
await client.cancelQueries({ queryKey: options.queryKey, exact: true });
|
|
120
|
+
const previous = client.getQueryData(options.queryKey);
|
|
121
|
+
if (previous === undefined)
|
|
122
|
+
return undefined;
|
|
123
|
+
client.setQueryData(options.queryKey, options.apply(previous, variables));
|
|
124
|
+
return {
|
|
125
|
+
previous,
|
|
126
|
+
optimistic: client.getQueryData(options.queryKey),
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
onError: (_error, _variables, snapshot, { client }) => {
|
|
130
|
+
if (snapshot &&
|
|
131
|
+
client.getQueryData(options.queryKey) === snapshot.optimistic)
|
|
132
|
+
client.setQueryData(options.queryKey, snapshot.previous);
|
|
133
|
+
},
|
|
134
|
+
onSettled: (_data, _error, _variables, _snapshot, { client }) => client.invalidateQueries({ queryKey: options.queryKey, exact: true }),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/** One shared inbox watch; reloads and stream failures retain the last live rows. */
|
|
138
|
+
export function useMemberInbox(app) {
|
|
139
|
+
useStore(app?.context);
|
|
140
|
+
const client = useQueryClient();
|
|
141
|
+
const computerId = app?.context.get().computerId;
|
|
142
|
+
const queryKey = useMemo(() => queryKeys.of(app, "member-inbox"), [app, computerId]);
|
|
143
|
+
const watch = useCallback((onChange, signal) => {
|
|
144
|
+
if (app?.context.get().computerId !== computerId)
|
|
145
|
+
throw new AppError("The selected Computer changed", {
|
|
146
|
+
code: "SELECTION_CHANGED",
|
|
147
|
+
retryable: false,
|
|
148
|
+
});
|
|
149
|
+
return watchInbox(app, onChange, signal);
|
|
150
|
+
}, [app, computerId]);
|
|
151
|
+
const query = useFeedQuery(queryKey, watch, {
|
|
152
|
+
enabled: app !== null,
|
|
153
|
+
meta: scopeMeta(app),
|
|
154
|
+
});
|
|
155
|
+
// A watch has no completion promise; refreshing restarts it in the cache.
|
|
156
|
+
const refresh = useCallback(() => {
|
|
157
|
+
void client.invalidateQueries({ queryKey, exact: true });
|
|
158
|
+
}, [client, queryKey]);
|
|
159
|
+
const visit = useCallback(async (agentId, sessionId) => {
|
|
160
|
+
if (!app)
|
|
161
|
+
throw new AppError("No App connection", {
|
|
162
|
+
code: "DISCONNECTED",
|
|
163
|
+
retryable: true,
|
|
164
|
+
});
|
|
165
|
+
const transport = await openAppInbox(app.capabilities);
|
|
166
|
+
try {
|
|
167
|
+
const response = await transport.fetch(`${transport.baseUrl}/inbox/visits`, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "content-type": "application/json" },
|
|
170
|
+
body: JSON.stringify({ agentId, sessionId }),
|
|
171
|
+
});
|
|
172
|
+
if (!response.ok && response.status !== 404)
|
|
173
|
+
throw new AppError("Visit was not recorded", {
|
|
174
|
+
code: "HOST_FAILURE",
|
|
175
|
+
retryable: true,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
await transport.close();
|
|
180
|
+
}
|
|
181
|
+
}, [app]);
|
|
182
|
+
return {
|
|
183
|
+
items: query.data?.data ?? [],
|
|
184
|
+
status: query.isError
|
|
185
|
+
? "error"
|
|
186
|
+
: (query.data?.status ?? "loading"),
|
|
187
|
+
error: query.error,
|
|
188
|
+
/** Restart the inbox watch; use after a change that the live feed
|
|
189
|
+
* would not otherwise observe. */
|
|
190
|
+
refresh,
|
|
191
|
+
/** Record that the member visited one agent session, so the inbox
|
|
192
|
+
* can clear its unread state for it. */
|
|
193
|
+
visit,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
import type { AppRoot } from "./root.js";
|
|
4
|
+
import type { Disposer } from "./store.js";
|
|
5
|
+
/** The App connection state passed to `mountApp`'s `render` callback:
|
|
6
|
+
* `live` once connected, or `connecting`/`standalone`/`ended` while there
|
|
7
|
+
* is no usable App. */
|
|
8
|
+
export type AppMountState = Readonly<{
|
|
9
|
+
app: AppRoot;
|
|
10
|
+
status: "live";
|
|
11
|
+
} | {
|
|
12
|
+
app: null;
|
|
13
|
+
status: "connecting" | "standalone" | "ended";
|
|
14
|
+
}>;
|
|
15
|
+
/** Options passed to `mountApp`. */
|
|
16
|
+
export type AppMountOptions = Readonly<{
|
|
17
|
+
/** App name used when reporting a failed host connection. */
|
|
18
|
+
name: string;
|
|
19
|
+
/** The caller owns the cache and its query policy. */
|
|
20
|
+
queryClient?: QueryClient;
|
|
21
|
+
}>;
|
|
22
|
+
/** Own this page's React root, authenticated App connection and host theme. */
|
|
23
|
+
export declare function mountApp(element: HTMLElement, options: AppMountOptions, render: (state: AppMountState) => ReactNode): Disposer;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { applyTheme, getTheme } from "@fungi.computer/stipe/theme";
|
|
2
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
3
|
+
import { createElement, useLayoutEffect } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { connectApp } from "./connect.js";
|
|
6
|
+
import { AppError } from "./errors.js";
|
|
7
|
+
/** Own this page's React root, authenticated App connection and host theme. */
|
|
8
|
+
export function mountApp(element, options, render) {
|
|
9
|
+
element.style.isolation = "isolate";
|
|
10
|
+
const root = createRoot(element);
|
|
11
|
+
const controller = new AbortController();
|
|
12
|
+
const themeRoot = element.ownerDocument.documentElement;
|
|
13
|
+
const previousTheme = getTheme(themeRoot);
|
|
14
|
+
let mounted;
|
|
15
|
+
let releaseTheme;
|
|
16
|
+
let releaseLifecycle;
|
|
17
|
+
let themed = false;
|
|
18
|
+
const publish = (state) => {
|
|
19
|
+
const view = createElement(AppView, { state, render });
|
|
20
|
+
root.render(options.queryClient
|
|
21
|
+
? createElement(QueryClientProvider, { client: options.queryClient }, view)
|
|
22
|
+
: view);
|
|
23
|
+
};
|
|
24
|
+
const releaseConnection = () => {
|
|
25
|
+
releaseLifecycle?.();
|
|
26
|
+
releaseLifecycle = undefined;
|
|
27
|
+
releaseTheme?.();
|
|
28
|
+
releaseTheme = undefined;
|
|
29
|
+
const connection = mounted;
|
|
30
|
+
mounted = undefined;
|
|
31
|
+
connection?.dispose();
|
|
32
|
+
if (themed) {
|
|
33
|
+
if (previousTheme === null)
|
|
34
|
+
themeRoot.removeAttribute("data-theme");
|
|
35
|
+
else
|
|
36
|
+
applyTheme(previousTheme, themeRoot);
|
|
37
|
+
themed = false;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const { referrer, defaultView } = element.ownerDocument;
|
|
41
|
+
performance.mark("fungi.app-open.react-root-render-start");
|
|
42
|
+
if (!defaultView ||
|
|
43
|
+
defaultView.parent === defaultView ||
|
|
44
|
+
!URL.canParse(referrer)) {
|
|
45
|
+
publish({ app: null, status: "standalone" });
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
publish({ app: null, status: "connecting" });
|
|
49
|
+
void connectApp({
|
|
50
|
+
expectedHostOrigin: new URL(referrer).origin,
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
})
|
|
53
|
+
.then((app) => {
|
|
54
|
+
if (controller.signal.aborted) {
|
|
55
|
+
app.dispose();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
mounted = app;
|
|
59
|
+
const onLifecycle = () => {
|
|
60
|
+
const state = app.lifecycle.get();
|
|
61
|
+
if (state === "disposing" ||
|
|
62
|
+
state === "disconnected" ||
|
|
63
|
+
state === "disposed") {
|
|
64
|
+
releaseConnection();
|
|
65
|
+
publish({ app: null, status: "ended" });
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
};
|
|
70
|
+
releaseLifecycle = app.lifecycle.subscribe(onLifecycle);
|
|
71
|
+
if (onLifecycle())
|
|
72
|
+
return;
|
|
73
|
+
themed = true;
|
|
74
|
+
applyTheme(app.theme.get(), themeRoot);
|
|
75
|
+
releaseTheme = app.theme.subscribe((theme) => {
|
|
76
|
+
applyTheme(theme, themeRoot);
|
|
77
|
+
});
|
|
78
|
+
publish({ app, status: "live" });
|
|
79
|
+
})
|
|
80
|
+
.catch((cause) => {
|
|
81
|
+
if (controller.signal.aborted &&
|
|
82
|
+
cause instanceof AppError &&
|
|
83
|
+
cause.code === "CANCELLED")
|
|
84
|
+
return;
|
|
85
|
+
console.error(`${options.name} host connection failed`, cause);
|
|
86
|
+
releaseConnection();
|
|
87
|
+
if (!controller.signal.aborted)
|
|
88
|
+
publish({ app: null, status: "ended" });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return () => {
|
|
92
|
+
if (controller.signal.aborted)
|
|
93
|
+
return;
|
|
94
|
+
controller.abort();
|
|
95
|
+
root.unmount();
|
|
96
|
+
releaseConnection();
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function AppView({ state, render, }) {
|
|
100
|
+
useLayoutEffect(() => {
|
|
101
|
+
performance.mark("fungi.app-open.react-initial-commit");
|
|
102
|
+
}, []);
|
|
103
|
+
useLayoutEffect(() => {
|
|
104
|
+
if (state.app)
|
|
105
|
+
performance.mark("fungi.app-open.react-connected-commit");
|
|
106
|
+
}, [state.app]);
|
|
107
|
+
return render(state);
|
|
108
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type WhistleContribution, type WhistleRuntime } from "@fungi.computer/whistle";
|
|
2
|
+
import type { AppBackHandler, HostNavigation } from "./window.js";
|
|
3
|
+
import type { AppContext } from "./lifecycle.js";
|
|
4
|
+
import type { AppStatusActionHandler, AppStatusItem } from "./ui.js";
|
|
5
|
+
import type { AppRoot } from "./root.js";
|
|
6
|
+
import type { ReadonlyStore } from "./store.js";
|
|
7
|
+
export { mountApp } from "./react-mount.js";
|
|
8
|
+
export type { AppMountOptions, AppMountState } from "./react-mount.js";
|
|
9
|
+
/** Read an SDK snapshot and release its subscription when the store changes. */
|
|
10
|
+
export declare function useStore<T>(store: ReadonlyStore<T> | undefined): T | undefined;
|
|
11
|
+
/** Read the App's live context (focus, visibility, Computer selection, any
|
|
12
|
+
* pending open request), or null while there is no connected App. */
|
|
13
|
+
export declare function useAppContext(app: AppRoot | null | undefined): AppContext | null;
|
|
14
|
+
/** Read the id of the Computer currently selected for this App, or null
|
|
15
|
+
* when no App is connected or no Computer is selected yet. */
|
|
16
|
+
export declare function useComputerId(app: AppRoot | null | undefined): string | null;
|
|
17
|
+
/**
|
|
18
|
+
* Keep this window's status bar items current in the host while mounted, and
|
|
19
|
+
* hear the person use them. Items compare by value; the bar clears on unmount.
|
|
20
|
+
*/
|
|
21
|
+
export declare function useWindowStatus(app: Pick<AppRoot, "ui"> | null | undefined, items: readonly AppStatusItem[], onAction?: AppStatusActionHandler,
|
|
22
|
+
/** Hear a report the host refused, so the App can say so. */
|
|
23
|
+
onReportFailure?: (cause: unknown) => void): void;
|
|
24
|
+
/**
|
|
25
|
+
* Own one catalog's local slash runtime and host registrations. Memoize `build`
|
|
26
|
+
* with useCallback; changing it replaces the catalog and its availability.
|
|
27
|
+
*/
|
|
28
|
+
export declare function useAppCommandCatalog(app: AppRoot | null | undefined, { sourceId, namespace }: Pick<WhistleContribution, "sourceId" | "namespace">, build: () => WhistleContribution["commands"]): WhistleRuntime;
|
|
29
|
+
/** What an App's router can do now, and how it goes back. */
|
|
30
|
+
export type HostNavigationOptions = Readonly<{
|
|
31
|
+
canGoBack: boolean;
|
|
32
|
+
/** The current page's title, shown after the App name in window chrome. */
|
|
33
|
+
title?: string | undefined;
|
|
34
|
+
onBack: AppBackHandler;
|
|
35
|
+
}>;
|
|
36
|
+
/**
|
|
37
|
+
* Publish an App's navigation state to its host and own the host's Back
|
|
38
|
+
* requests while mounted. Pass `app?.window.navigation`; with no connected
|
|
39
|
+
* App it does nothing. Unmounting withdraws Back from the host chrome.
|
|
40
|
+
*/
|
|
41
|
+
export declare function useHostNavigation(navigation: HostNavigation | null | undefined, { canGoBack, title, onBack }: HostNavigationOptions): void;
|