@trylight/app 0.1.9 → 0.2.0
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/README.md +3 -18
- package/dist/auth/config.d.ts +5 -0
- package/dist/auth/config.js +16 -0
- package/dist/auth/context.d.ts +11 -0
- package/dist/auth/context.js +1 -0
- package/dist/auth/core.d.ts +64 -0
- package/dist/auth/core.js +50 -0
- package/dist/auth/db.d.ts +7 -0
- package/dist/auth/db.js +57 -0
- package/dist/auth/lookup.d.ts +47 -0
- package/dist/auth/lookup.js +120 -0
- package/dist/auth/mode.d.ts +8 -0
- package/dist/auth/mode.js +52 -0
- package/dist/auth/next-path.d.ts +1 -0
- package/dist/auth/next-path.js +10 -0
- package/dist/auth/plaza.d.ts +68 -0
- package/dist/auth/plaza.js +154 -0
- package/dist/auth/problems.d.ts +4 -0
- package/dist/auth/problems.js +36 -0
- package/dist/auth/users.d.ts +8 -0
- package/dist/auth/users.js +25 -0
- package/dist/brand.generated.d.ts +2 -0
- package/dist/brand.generated.js +3 -0
- package/dist/chat/paths.d.ts +10 -0
- package/dist/chat/paths.js +10 -0
- package/dist/chat/server.d.ts +24 -0
- package/dist/chat/server.js +161 -0
- package/dist/client.d.ts +3 -3
- package/dist/client.js +2 -2
- package/dist/db/cli.js +3 -40
- package/dist/db/migrations.d.ts +2 -2
- package/dist/db/migrations.js +2 -2
- package/dist/invoke/contracts.d.ts +28 -0
- package/dist/invoke/contracts.js +1 -0
- package/dist/invoke/error.d.ts +11 -0
- package/dist/invoke/error.js +13 -0
- package/dist/invoke/index.d.ts +73 -0
- package/dist/invoke/index.js +274 -0
- package/dist/invoke/paths.d.ts +2 -0
- package/dist/invoke/paths.js +2 -0
- package/dist/invoke/server.d.ts +45 -0
- package/dist/invoke/server.js +259 -0
- package/dist/platform/index.d.ts +71 -0
- package/dist/platform/index.js +80 -0
- package/dist/platform/pane-bridge.d.ts +1 -0
- package/dist/platform/pane-bridge.js +216 -0
- package/dist/platform/server-fn-door.d.ts +27 -0
- package/dist/platform/server-fn-door.js +32 -0
- package/dist/platform.d.ts +2 -2
- package/dist/platform.js +3 -3
- package/dist/request-error.d.ts +10 -0
- package/dist/request-error.js +29 -0
- package/dist/schema.d.ts +1 -1
- package/dist/server/db-retry.d.ts +7 -0
- package/dist/server/db-retry.js +58 -0
- package/dist/server/entities.js +1 -1
- package/dist/server/health.d.ts +17 -0
- package/dist/server/health.js +42 -0
- package/dist/server/index.d.ts +7 -6
- package/dist/server/index.js +7 -6
- package/dist/server/sentry-tunnel.d.ts +6 -0
- package/dist/server/sentry-tunnel.js +52 -0
- package/dist/server/session.d.ts +1 -1
- package/dist/server/session.js +2 -2
- package/dist/uuid.d.ts +1 -0
- package/dist/uuid.js +7 -0
- package/package.json +12 -10
- package/dist/db/baseline.d.ts +0 -36
- package/dist/db/baseline.js +0 -439
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ContractInput, ContractOutput, Contracts } from "./contracts.js";
|
|
2
|
+
import { InvocationError } from "./error.js";
|
|
3
|
+
export { InvocationError };
|
|
4
|
+
export type { Invocation, InvocationState } from "./contracts.js";
|
|
5
|
+
/** How long one read asks the platform to hold a pending invocation; the platform clamps it to its own limit. */
|
|
6
|
+
export declare const INVOCATION_WAIT_SECONDS = 60;
|
|
7
|
+
/** The pause after a read that failed before asking again. */
|
|
8
|
+
export declare const INVOCATION_RETRY_MS = 1500;
|
|
9
|
+
/** How long a run started with `remember` is picked up again in this browser. */
|
|
10
|
+
export declare const INVOCATION_REMEMBER_MS: number;
|
|
11
|
+
type Result = Record<string, unknown>;
|
|
12
|
+
/**
|
|
13
|
+
* `id` and `input` are null until the platform has the invocation. `input` is the
|
|
14
|
+
* run's input as JSON, so a page that picked a run up can show what it is
|
|
15
|
+
* for. While pending, `pollError` is why the latest read failed, or null: the
|
|
16
|
+
* wait goes on either way, and the app decides when a server that stays down
|
|
17
|
+
* is worth telling the person about.
|
|
18
|
+
*/
|
|
19
|
+
export type InvocationStatus<Output> = {
|
|
20
|
+
readonly state: "idle";
|
|
21
|
+
} | {
|
|
22
|
+
readonly state: "pending";
|
|
23
|
+
readonly id: string | null;
|
|
24
|
+
readonly input: Result | null;
|
|
25
|
+
readonly elapsedMs: number;
|
|
26
|
+
readonly pollError: InvocationError | null;
|
|
27
|
+
} | {
|
|
28
|
+
readonly state: "succeeded";
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly input: Result;
|
|
31
|
+
readonly result: Output;
|
|
32
|
+
readonly elapsedMs: number;
|
|
33
|
+
} | {
|
|
34
|
+
readonly state: "failed";
|
|
35
|
+
readonly id: string | null;
|
|
36
|
+
readonly input: Result | null;
|
|
37
|
+
readonly error: InvocationError;
|
|
38
|
+
readonly elapsedMs: number;
|
|
39
|
+
};
|
|
40
|
+
export type UseInvocationOptions = {
|
|
41
|
+
/**
|
|
42
|
+
* A name for what this press is for (`"fill:revenue"`, `"add-candidates"`).
|
|
43
|
+
* A run started with it is picked up again when the page reloads or the
|
|
44
|
+
* tab is reopened in this browser, finished or not, until `reset()` or
|
|
45
|
+
* INVOCATION_REMEMBER_MS after it started.
|
|
46
|
+
*/
|
|
47
|
+
readonly remember?: string | null;
|
|
48
|
+
};
|
|
49
|
+
export type Invoker<C extends Contracts> = {
|
|
50
|
+
invoke<K extends keyof C & string>(key: K, input: ContractInput<C, K>, signal?: AbortSignal): Promise<ContractOutput<C, K>>;
|
|
51
|
+
useInvocation<K extends keyof C & string>(key: K, options?: UseInvocationOptions): {
|
|
52
|
+
readonly status: InvocationStatus<ContractOutput<C, K>>;
|
|
53
|
+
run(input: ContractInput<C, K>): void;
|
|
54
|
+
/** Follow a run already started, such as one a server function started and the app stored. */
|
|
55
|
+
resume(id: string): void;
|
|
56
|
+
/** Back to idle; a remembered run is let go of. */
|
|
57
|
+
reset(): void;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
export declare function createInvoker<C extends Contracts>(): Invoker<C>;
|
|
61
|
+
/**
|
|
62
|
+
* One button's invocations, outside React so it can be tested: at most one is
|
|
63
|
+
* live, and one that was replaced, reset or disposed of reports nothing more.
|
|
64
|
+
* With `remember`, `restore()` picks up the run this browser last started with it.
|
|
65
|
+
*/
|
|
66
|
+
export declare function createInvocationController<Output = Result>(key: string, onStatus: (status: InvocationStatus<Output>) => void, { remember }?: UseInvocationOptions): {
|
|
67
|
+
run: (input: unknown) => void;
|
|
68
|
+
resume: (id: string) => void;
|
|
69
|
+
/** With `remember`: follow the run this browser noted. */
|
|
70
|
+
restore: () => void;
|
|
71
|
+
reset: () => void;
|
|
72
|
+
dispose: () => void;
|
|
73
|
+
};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// Nothing here gives up on its own: an agent may take a second or an hour, so
|
|
3
|
+
// the hook reports how long it has been and the app decides what that means.
|
|
4
|
+
import { useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { z } from "zod/v4";
|
|
6
|
+
import { errorBody } from "../request-error.js";
|
|
7
|
+
import { failedRun, InvocationError } from "./error.js";
|
|
8
|
+
import { INVOKE_BASE_PATH } from "./paths.js";
|
|
9
|
+
export { InvocationError };
|
|
10
|
+
/** How long one read asks the platform to hold a pending invocation; the platform clamps it to its own limit. */
|
|
11
|
+
export const INVOCATION_WAIT_SECONDS = 60;
|
|
12
|
+
/** The pause after a read that failed before asking again. */
|
|
13
|
+
export const INVOCATION_RETRY_MS = 1500;
|
|
14
|
+
/** How long a run started with `remember` is picked up again in this browser. */
|
|
15
|
+
export const INVOCATION_REMEMBER_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
const ELAPSED_TICK_MS = 1000;
|
|
17
|
+
const REMEMBERED_PREFIX = "mii-invocation:";
|
|
18
|
+
export function createInvoker() {
|
|
19
|
+
return {
|
|
20
|
+
async invoke(key, input, signal = null) {
|
|
21
|
+
const started = await startInvocation(key, input, signal);
|
|
22
|
+
return (await awaitInvocation(started, signal)).result;
|
|
23
|
+
},
|
|
24
|
+
useInvocation(key, { remember = null } = {}) {
|
|
25
|
+
return useInvocationOf(key, remember);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// `Output` is the contract's: the app's server parsed the result with its schema.
|
|
30
|
+
function startInvocation(key, input, signal = null) {
|
|
31
|
+
return call("POST", "/invocations", { key, input }, signal);
|
|
32
|
+
}
|
|
33
|
+
const unwatched = () => undefined;
|
|
34
|
+
/** One read of an invocation; the platform holds a pending one for up to `wait` seconds. */
|
|
35
|
+
function readInvocation(id, options) {
|
|
36
|
+
const query = new URLSearchParams({ wait: String(options.wait) });
|
|
37
|
+
return call("GET", `/invocations/${id}?${query}`, null, options.signal);
|
|
38
|
+
}
|
|
39
|
+
/** Reads until one is answered. Rejects with InvocationError, or the signal's reason. */
|
|
40
|
+
async function readAnswered(id, holdSeconds, signal = null, onPoll = unwatched) {
|
|
41
|
+
for (;;) {
|
|
42
|
+
try {
|
|
43
|
+
const read = await readInvocation(id, { wait: holdSeconds, signal });
|
|
44
|
+
onPoll(read, null);
|
|
45
|
+
return read;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// The platform or the network having a bad minute says nothing about the
|
|
49
|
+
// invocation, which carries on either way.
|
|
50
|
+
if (!(error instanceof InvocationError) || (error.status !== 0 && error.status < 500))
|
|
51
|
+
throw error;
|
|
52
|
+
console.error("mii-invoke: a read failed; trying again", error);
|
|
53
|
+
onPoll(null, error);
|
|
54
|
+
await wait(INVOCATION_RETRY_MS, signal);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Follow an invocation, each read held by the platform while it is pending, to its end. */
|
|
59
|
+
async function awaitInvocation(started, signal = null, onPoll = unwatched) {
|
|
60
|
+
let invocation = started;
|
|
61
|
+
while (invocation.state === "pending") {
|
|
62
|
+
invocation = await readAnswered(invocation.id, INVOCATION_WAIT_SECONDS, signal, onPoll);
|
|
63
|
+
}
|
|
64
|
+
if (invocation.state === "succeeded")
|
|
65
|
+
return invocation;
|
|
66
|
+
throw failedRun(invocation.lastError);
|
|
67
|
+
}
|
|
68
|
+
/** One request to the invoke routes: a POST carries `body` as JSON, a GET nothing. */
|
|
69
|
+
async function call(method, path, body, signal) {
|
|
70
|
+
let response;
|
|
71
|
+
try {
|
|
72
|
+
response = await fetch(`${INVOKE_BASE_PATH}${path}`, {
|
|
73
|
+
method,
|
|
74
|
+
credentials: "same-origin",
|
|
75
|
+
...(body === null ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
|
|
76
|
+
signal
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (signal !== null && signal.aborted)
|
|
81
|
+
throw error;
|
|
82
|
+
throw new InvocationError(0, "unavailable", `Couldn't reach this app's server: ${String(error)}`);
|
|
83
|
+
}
|
|
84
|
+
const text = await response.text();
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
const refusal = errorBody(text, `The request failed (HTTP ${response.status}).`);
|
|
87
|
+
throw new InvocationError(response.status, refusal.code, refusal.detail);
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(text);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Something answered in the handler's place, like a page the app serves for any path.
|
|
94
|
+
throw new InvocationError(response.status, "not_the_handler", "The app's invoke routes are not reachable.");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function wait(ms, signal) {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
if (signal === null) {
|
|
100
|
+
setTimeout(resolve, ms);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const abort = () => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
reject(signal.reason);
|
|
106
|
+
};
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
signal.removeEventListener("abort", abort);
|
|
109
|
+
resolve();
|
|
110
|
+
}, ms);
|
|
111
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// This browser's note of a remembered run. Storage can be blocked or full;
|
|
115
|
+
// then the run is simply not picked up again. Whose run it is the platform decides:
|
|
116
|
+
// another person's id reads as not found, and the note is let go of.
|
|
117
|
+
const remembered = z.object({ id: z.string(), startedAt: z.number() });
|
|
118
|
+
function recall(name) {
|
|
119
|
+
let text;
|
|
120
|
+
try {
|
|
121
|
+
text = globalThis.localStorage.getItem(name);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if (text === null)
|
|
127
|
+
return null;
|
|
128
|
+
const note = readNote(text);
|
|
129
|
+
if (note === null || Date.now() - note.startedAt > INVOCATION_REMEMBER_MS) {
|
|
130
|
+
keep(name, null);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return note;
|
|
134
|
+
}
|
|
135
|
+
/** A note this code wrote, or null for anything else. */
|
|
136
|
+
function readNote(text) {
|
|
137
|
+
let note;
|
|
138
|
+
try {
|
|
139
|
+
note = JSON.parse(text);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const read = remembered.safeParse(note);
|
|
145
|
+
return read.success ? read.data : null;
|
|
146
|
+
}
|
|
147
|
+
function keep(name, remembered) {
|
|
148
|
+
try {
|
|
149
|
+
if (remembered === null)
|
|
150
|
+
globalThis.localStorage.removeItem(name);
|
|
151
|
+
else
|
|
152
|
+
globalThis.localStorage.setItem(name, JSON.stringify(remembered));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const IDLE = { state: "idle" };
|
|
159
|
+
/**
|
|
160
|
+
* One button's invocations, outside React so it can be tested: at most one is
|
|
161
|
+
* live, and one that was replaced, reset or disposed of reports nothing more.
|
|
162
|
+
* With `remember`, `restore()` picks up the run this browser last started with it.
|
|
163
|
+
*/
|
|
164
|
+
export function createInvocationController(key, onStatus, { remember = null } = {}) {
|
|
165
|
+
const name = remember === null ? null : `${REMEMBERED_PREFIX}${key}:${remember}`;
|
|
166
|
+
// The run this button follows. `note` goes false once it is replaced or
|
|
167
|
+
// reset, so a start still in flight is never noted; an unmount leaves it.
|
|
168
|
+
let current = null;
|
|
169
|
+
const stop = () => {
|
|
170
|
+
if (current !== null)
|
|
171
|
+
current.abort.abort();
|
|
172
|
+
current = null;
|
|
173
|
+
};
|
|
174
|
+
const replace = () => {
|
|
175
|
+
if (current !== null)
|
|
176
|
+
current.note = false;
|
|
177
|
+
stop();
|
|
178
|
+
};
|
|
179
|
+
const forget = () => {
|
|
180
|
+
replace();
|
|
181
|
+
if (name !== null)
|
|
182
|
+
keep(name, null);
|
|
183
|
+
};
|
|
184
|
+
const reset = () => {
|
|
185
|
+
forget();
|
|
186
|
+
onStatus(IDLE);
|
|
187
|
+
};
|
|
188
|
+
/** Follow a run from `begin`, which starts it (no `id` yet) or reads the run `id` once, to its end. */
|
|
189
|
+
const follow = (run) => {
|
|
190
|
+
const mine = { abort: new AbortController(), note: true };
|
|
191
|
+
current = mine;
|
|
192
|
+
const { startedAt } = run;
|
|
193
|
+
let id = run.id;
|
|
194
|
+
let input = null;
|
|
195
|
+
let pollError = null;
|
|
196
|
+
const report = (status) => {
|
|
197
|
+
if (current === mine)
|
|
198
|
+
onStatus(status);
|
|
199
|
+
};
|
|
200
|
+
const pending = () => report({ state: "pending", id, input, elapsedMs: Date.now() - startedAt, pollError });
|
|
201
|
+
const onPoll = (read, error) => {
|
|
202
|
+
if (read !== null)
|
|
203
|
+
input = read.input;
|
|
204
|
+
pollError = error;
|
|
205
|
+
pending();
|
|
206
|
+
};
|
|
207
|
+
pending();
|
|
208
|
+
const ticker = setInterval(pending, ELAPSED_TICK_MS);
|
|
209
|
+
run
|
|
210
|
+
.begin(mine.abort.signal, onPoll)
|
|
211
|
+
.then(async (started) => {
|
|
212
|
+
if (!mine.note)
|
|
213
|
+
throw mine.abort.signal.reason;
|
|
214
|
+
id = started.id;
|
|
215
|
+
input = started.input;
|
|
216
|
+
if (name !== null)
|
|
217
|
+
keep(name, { id: started.id, startedAt });
|
|
218
|
+
pending();
|
|
219
|
+
return awaitInvocation(started, mine.abort.signal, onPoll);
|
|
220
|
+
})
|
|
221
|
+
.then((done) => report({ state: "succeeded", id: done.id, input: done.input, result: done.result, elapsedMs: Date.now() - startedAt }), (error) => {
|
|
222
|
+
// A remembered run the platform doesn't have for this person (someone else's, in a shared browser) is let go of.
|
|
223
|
+
if (name !== null && current === mine && error instanceof InvocationError && error.code === "not_found") {
|
|
224
|
+
reset();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
report({
|
|
228
|
+
state: "failed",
|
|
229
|
+
id,
|
|
230
|
+
input,
|
|
231
|
+
error: error instanceof InvocationError ? error : new InvocationError(0, "unexpected", String(error)),
|
|
232
|
+
elapsedMs: Date.now() - startedAt
|
|
233
|
+
});
|
|
234
|
+
})
|
|
235
|
+
.finally(() => clearInterval(ticker));
|
|
236
|
+
};
|
|
237
|
+
// One unheld read first, so a picked-up run shows its input at once. The note
|
|
238
|
+
// is written before that read, so a reload as soon as the id shows finds it.
|
|
239
|
+
const pickUp = (id, startedAt) => {
|
|
240
|
+
replace();
|
|
241
|
+
if (name !== null)
|
|
242
|
+
keep(name, { id, startedAt });
|
|
243
|
+
follow({ begin: (signal, onPoll) => readAnswered(id, 0, signal, onPoll), id, startedAt });
|
|
244
|
+
};
|
|
245
|
+
return {
|
|
246
|
+
run: (input) => {
|
|
247
|
+
forget();
|
|
248
|
+
follow({ begin: () => startInvocation(key, input), id: null, startedAt: Date.now() });
|
|
249
|
+
},
|
|
250
|
+
// `elapsedMs` counts from here, not from when the run started.
|
|
251
|
+
resume: (id) => pickUp(id, Date.now()),
|
|
252
|
+
/** With `remember`: follow the run this browser noted. */
|
|
253
|
+
restore: () => {
|
|
254
|
+
if (name === null)
|
|
255
|
+
return;
|
|
256
|
+
const noted = recall(name);
|
|
257
|
+
if (noted !== null)
|
|
258
|
+
pickUp(noted.id, noted.startedAt);
|
|
259
|
+
},
|
|
260
|
+
reset,
|
|
261
|
+
dispose: stop
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function useInvocationOf(key, remember) {
|
|
265
|
+
const [status, setStatus] = useState(IDLE);
|
|
266
|
+
const controller = useMemo(() => createInvocationController(key, setStatus, { remember }), [key, remember]);
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
// Another key or `remember` is another button: its own run, or none.
|
|
269
|
+
setStatus(IDLE);
|
|
270
|
+
controller.restore();
|
|
271
|
+
return controller.dispose;
|
|
272
|
+
}, [controller]);
|
|
273
|
+
return { status, run: controller.run, resume: controller.resume, reset: controller.reset };
|
|
274
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type ChatAuth } from "../chat/server.js";
|
|
2
|
+
import type { ContractInput, ContractOutput, Contracts } from "./contracts.js";
|
|
3
|
+
import { InvocationError } from "./error.js";
|
|
4
|
+
export { InvocationError };
|
|
5
|
+
export type { Contract, Contracts, Invocation } from "./contracts.js";
|
|
6
|
+
export type MiiInvokeHandlerOptions = {
|
|
7
|
+
/** Where to report failures that are ours. Defaults to console.error. */
|
|
8
|
+
readonly reportError?: (error: unknown, context: Record<string, string>) => void;
|
|
9
|
+
};
|
|
10
|
+
export declare function defineContracts<const C extends Contracts>(contracts: C): C;
|
|
11
|
+
export type StartInvocation<C extends Contracts, K extends keyof C & string> = {
|
|
12
|
+
/** The request the server function is answering (`getRequest()`): its cookie is the person, its origin is checked. */
|
|
13
|
+
readonly request: Request;
|
|
14
|
+
readonly key: K;
|
|
15
|
+
readonly input: ContractInput<C, K>;
|
|
16
|
+
readonly signal?: AbortSignal | null;
|
|
17
|
+
};
|
|
18
|
+
export type MiiInvoke<C extends Contracts> = {
|
|
19
|
+
/** The invoke routes, mounted under INVOKE_BASE_PATH. */
|
|
20
|
+
readonly handler: (request: Request) => Promise<Response>;
|
|
21
|
+
/**
|
|
22
|
+
* Start a contract from a POST server function, as the person whose request
|
|
23
|
+
* it is answering, and return the run's id without waiting for it: for work
|
|
24
|
+
* that must be recorded with its run (a job row that names it). A page
|
|
25
|
+
* follows it with `resume(id)`. Refusals reject with InvocationError, with
|
|
26
|
+
* the codes a button's press gets.
|
|
27
|
+
*/
|
|
28
|
+
start<K extends keyof C & string>(start: StartInvocation<C, K>): Promise<{
|
|
29
|
+
readonly id: string;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* A finished run's result, read from a server function as the person whose
|
|
33
|
+
* request it is answering, and parsed by this app's output schema for `key`.
|
|
34
|
+
* One read, no waiting: a run still going rejects with code `pending`, one
|
|
35
|
+
* the agents failed with `failed`, one of another contract with `not_found`;
|
|
36
|
+
* refusals reject with InvocationError, with the codes a press gets.
|
|
37
|
+
*/
|
|
38
|
+
result<K extends keyof C & string>(key: K, id: string, request: Request): Promise<ContractOutput<C, K>>;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* The app's contracts, served: the invoke routes and the server's own start.
|
|
42
|
+
* Every output is converted to JSON Schema here, once, so one with no JSON
|
|
43
|
+
* Schema form stops the server starting, not someone's press of the button.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createMiiInvoke<C extends Contracts>(auth: ChatAuth, contracts: C, { reportError }?: MiiInvokeHandlerOptions): MiiInvoke<C>;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// The browser never names a contract id or a schema: it sends a local key and
|
|
2
|
+
// the input, and this server attaches the rest from the app's own registry.
|
|
3
|
+
import { z } from "zod/v4";
|
|
4
|
+
import { miiAuthConfig } from "../auth/config.js";
|
|
5
|
+
import { isSameOrigin, readCookie } from "../chat/server.js";
|
|
6
|
+
import { errorBody } from "../request-error.js";
|
|
7
|
+
import { isUuid } from "../uuid.js";
|
|
8
|
+
import { failedRun, InvocationError } from "./error.js";
|
|
9
|
+
import { INVOKE_BASE_PATH } from "./paths.js";
|
|
10
|
+
export { InvocationError };
|
|
11
|
+
function routeOf(request) {
|
|
12
|
+
const { pathname } = new URL(request.url);
|
|
13
|
+
if (!pathname.startsWith(INVOKE_BASE_PATH))
|
|
14
|
+
return null;
|
|
15
|
+
const path = pathname.slice(INVOKE_BASE_PATH.length);
|
|
16
|
+
if (request.method === "POST" && path === "/invocations")
|
|
17
|
+
return { kind: "start" };
|
|
18
|
+
const read = request.method === "GET" ? /^\/invocations\/([^/]+)$/.exec(path) : null;
|
|
19
|
+
if (read === null)
|
|
20
|
+
return null;
|
|
21
|
+
const [, id = ""] = read;
|
|
22
|
+
return { kind: "read", id };
|
|
23
|
+
}
|
|
24
|
+
const invokeBody = z.object({ key: z.string(), input: z.unknown() });
|
|
25
|
+
const plazaInvocation = z.object({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
contractId: z.string(),
|
|
28
|
+
state: z.enum(["pending", "succeeded", "failed"]),
|
|
29
|
+
input: z.record(z.string(), z.unknown()),
|
|
30
|
+
result: z.record(z.string(), z.unknown()).nullable(),
|
|
31
|
+
lastError: z.string().nullable(),
|
|
32
|
+
createdAt: z.string(),
|
|
33
|
+
finishedAt: z.string().nullable()
|
|
34
|
+
});
|
|
35
|
+
// A poll finds its contract by the id the platform pinned, so an id names one key.
|
|
36
|
+
export function defineContracts(contracts) {
|
|
37
|
+
const keys = new Map();
|
|
38
|
+
for (const [key, contract] of Object.entries(contracts)) {
|
|
39
|
+
const other = keys.get(contract.id);
|
|
40
|
+
// eslint-disable-next-line no-restricted-syntax -- an id not seen yet has no key; Map.get says so with undefined
|
|
41
|
+
if (other !== undefined) {
|
|
42
|
+
throw new Error(`Contracts "${other}" and "${key}" share the id ${contract.id}; each key needs its own contract.`);
|
|
43
|
+
}
|
|
44
|
+
keys.set(contract.id, key);
|
|
45
|
+
}
|
|
46
|
+
return contracts;
|
|
47
|
+
}
|
|
48
|
+
/** A contract with its output as the JSON Schema the platform validates an agent's result against. */
|
|
49
|
+
function declare(key, contract) {
|
|
50
|
+
try {
|
|
51
|
+
return { ...contract, outputSchema: z.toJSONSchema(contract.output, { io: "input" }) };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new Error(`Contract "${key}": ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** What the platform is asked to start: the registry's contract id and schema, and input the app's Zod accepts. */
|
|
58
|
+
function startBody(contract, key, rawInput) {
|
|
59
|
+
// eslint-disable-next-line no-restricted-syntax -- a key the app does not declare has no contract; Map.get says so with undefined
|
|
60
|
+
if (contract === undefined) {
|
|
61
|
+
throw new InvocationError(404, "unknown_contract", `This app declares no contract "${key}".`);
|
|
62
|
+
}
|
|
63
|
+
const input = contract.input.safeParse(rawInput);
|
|
64
|
+
if (!input.success) {
|
|
65
|
+
throw new InvocationError(400, "invalid_input", `Invalid input for "${key}":\n${z.prettifyError(input.error)}`);
|
|
66
|
+
}
|
|
67
|
+
return JSON.stringify({ contractId: contract.id, input: input.data, outputSchema: contract.outputSchema });
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The person behind a request to this app: the session cookie, from one of
|
|
71
|
+
* this app's own pages. Every org app shares one site with every other
|
|
72
|
+
* (*.<domain>), so a page on another org's app can send this app's cookie along.
|
|
73
|
+
*/
|
|
74
|
+
async function signedIn(auth, request) {
|
|
75
|
+
if (!isSameOrigin(request, new URL(request.url))) {
|
|
76
|
+
throw new InvocationError(403, "cross_site", "Contracts can only be invoked from this app's own pages.");
|
|
77
|
+
}
|
|
78
|
+
const token = readCookie(request.headers.get("cookie"), auth.SESSION_COOKIE);
|
|
79
|
+
if (token === null)
|
|
80
|
+
throw new InvocationError(401, "not_signed_in", "Sign in to use this.");
|
|
81
|
+
const session = await auth.resolveSessionWithPlaza(token);
|
|
82
|
+
if (session === null)
|
|
83
|
+
throw new InvocationError(401, "sign_in_again", "Sign out and back in to use it here.");
|
|
84
|
+
return { token, plazaToken: session.plazaToken };
|
|
85
|
+
}
|
|
86
|
+
/** One call to the platform's contract API as the person; a refused token ends their session. Rejects only when the platform can't be reached. */
|
|
87
|
+
async function callPlaza(auth, person, call) {
|
|
88
|
+
const answer = await fetch(call.target, {
|
|
89
|
+
method: call.method,
|
|
90
|
+
headers: {
|
|
91
|
+
Authorization: `Bearer ${person.plazaToken}`,
|
|
92
|
+
...(call.body === null ? {} : { "Content-Type": "application/json" })
|
|
93
|
+
},
|
|
94
|
+
body: call.body,
|
|
95
|
+
cache: "no-store",
|
|
96
|
+
redirect: "manual",
|
|
97
|
+
signal: call.signal
|
|
98
|
+
});
|
|
99
|
+
if (answer.status === 401 || answer.status === 403) {
|
|
100
|
+
await auth.revokeSession(person.token, answer.status === 403 ? "not_a_member" : "token_rejected");
|
|
101
|
+
}
|
|
102
|
+
return answer;
|
|
103
|
+
}
|
|
104
|
+
const plazaTarget = (path) => new URL(`${miiAuthConfig().plazaUrl}/api/app-contracts/v1${path}`);
|
|
105
|
+
/** One call to the platform as the person, read as JSON. A refusal, or no answer, rejects with InvocationError. */
|
|
106
|
+
async function askPlaza(auth, person, call) {
|
|
107
|
+
let answer;
|
|
108
|
+
try {
|
|
109
|
+
answer = await callPlaza(auth, person, call);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (call.signal !== null && call.signal.aborted)
|
|
113
|
+
throw error;
|
|
114
|
+
throw new InvocationError(0, "unavailable", `Couldn't reach the platform: ${String(error)}`);
|
|
115
|
+
}
|
|
116
|
+
const text = await answer.text();
|
|
117
|
+
if (!answer.ok) {
|
|
118
|
+
const refusal = errorBody(text, `The platform answered ${answer.status}.`);
|
|
119
|
+
throw new InvocationError(answer.status, refusal.code, refusal.detail);
|
|
120
|
+
}
|
|
121
|
+
return JSON.parse(text);
|
|
122
|
+
}
|
|
123
|
+
// A result the platform accepted can still miss what this app expects: the schema
|
|
124
|
+
// the platform checked is the JSON Schema form of `output`, which cannot carry a
|
|
125
|
+
// refinement or a transform. The app's own Zod has the last word.
|
|
126
|
+
function resultMismatch(id, error, reportError) {
|
|
127
|
+
const problem = `The result of invocation ${id} does not match this app's output schema:\n${z.prettifyError(error)}`;
|
|
128
|
+
reportError(new Error(problem), { where: "mii-invoke", step: "result" });
|
|
129
|
+
return new InvocationError(422, "result_mismatch", problem);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The app's contracts, served: the invoke routes and the server's own start.
|
|
133
|
+
* Every output is converted to JSON Schema here, once, so one with no JSON
|
|
134
|
+
* Schema form stops the server starting, not someone's press of the button.
|
|
135
|
+
*/
|
|
136
|
+
export function createMiiInvoke(auth, contracts, { reportError = (error, context) => console.error("mii-invoke error", context, error) } = {}) {
|
|
137
|
+
const declared = new Map(Object.entries(contracts).map(([key, contract]) => [key, declare(key, contract)]));
|
|
138
|
+
const handler = async (request) => {
|
|
139
|
+
const route = routeOf(request);
|
|
140
|
+
if (route === null) {
|
|
141
|
+
return refuse(404, "not_found", "There's nothing to invoke here.");
|
|
142
|
+
}
|
|
143
|
+
if (route.kind === "read" && !isUuid(route.id)) {
|
|
144
|
+
return refuse(404, "not_found", "Invocation not found.");
|
|
145
|
+
}
|
|
146
|
+
let person;
|
|
147
|
+
let body = null;
|
|
148
|
+
try {
|
|
149
|
+
person = await signedIn(auth, request);
|
|
150
|
+
if (route.kind === "start")
|
|
151
|
+
body = await readInvoke(request, declared);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (!(error instanceof InvocationError))
|
|
155
|
+
throw error;
|
|
156
|
+
return refuse(error.status, error.code, error.message);
|
|
157
|
+
}
|
|
158
|
+
const target = plazaTarget(route.kind === "start" ? "/invocations" : `/invocations/${route.id}`);
|
|
159
|
+
// How long the platform may hold a read of a pending invocation. This app's own
|
|
160
|
+
// function limit has to outlast it (see the README).
|
|
161
|
+
const wait = new URL(request.url).searchParams.get("wait");
|
|
162
|
+
if (route.kind === "read" && wait !== null)
|
|
163
|
+
target.searchParams.set("wait", wait);
|
|
164
|
+
let answer;
|
|
165
|
+
try {
|
|
166
|
+
// The browser giving up ends the platform's hold too.
|
|
167
|
+
answer = await callPlaza(auth, person, { method: request.method, target, body, signal: request.signal });
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (!request.signal.aborted)
|
|
171
|
+
reportError(error, { where: "mii-invoke", step: "forward" });
|
|
172
|
+
return refuse(502, "unavailable", "Couldn't reach the platform. Try again in a moment.");
|
|
173
|
+
}
|
|
174
|
+
if (!answer.ok) {
|
|
175
|
+
if (answer.status >= 500) {
|
|
176
|
+
reportError(new Error(`Platform answered ${answer.status}: ${(await answer.clone().text()).slice(0, 300)}`), {
|
|
177
|
+
where: "mii-invoke",
|
|
178
|
+
step: "plaza"
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// A refusal passes through as chat's does: the platform's body and content type.
|
|
182
|
+
const headers = new Headers({ "Cache-Control": "no-store" });
|
|
183
|
+
const contentType = answer.headers.get("content-type");
|
|
184
|
+
if (contentType !== null)
|
|
185
|
+
headers.set("Content-Type", contentType);
|
|
186
|
+
return new Response(answer.body, { status: answer.status, headers });
|
|
187
|
+
}
|
|
188
|
+
// Unlike chat, an ok body is read rather than streamed through: a succeeded
|
|
189
|
+
// result is parsed by this app's own schema before the browser sees it.
|
|
190
|
+
return respond(answer.status, plazaInvocation.parse(await answer.json()), declared, reportError);
|
|
191
|
+
};
|
|
192
|
+
const start = async ({ request, key, input, signal = null }) => {
|
|
193
|
+
const person = await signedIn(auth, request);
|
|
194
|
+
const body = startBody(declared.get(key), key, input);
|
|
195
|
+
const answer = await askPlaza(auth, person, { method: "POST", target: plazaTarget("/invocations"), body, signal });
|
|
196
|
+
return { id: plazaInvocation.parse(answer).id };
|
|
197
|
+
};
|
|
198
|
+
const result = async (key, id, request) => {
|
|
199
|
+
const person = await signedIn(auth, request);
|
|
200
|
+
const contract = contracts[key];
|
|
201
|
+
if (!isUuid(id))
|
|
202
|
+
throw new InvocationError(404, "not_found", "Invocation not found.");
|
|
203
|
+
const invocation = plazaInvocation.parse(await askPlaza(auth, person, { method: "GET", target: plazaTarget(`/invocations/${id}`), body: null, signal: null }));
|
|
204
|
+
if (invocation.contractId !== contract.id) {
|
|
205
|
+
throw new InvocationError(404, "not_found", `Invocation ${id} is not a run of "${key}".`);
|
|
206
|
+
}
|
|
207
|
+
if (invocation.state === "pending")
|
|
208
|
+
throw new InvocationError(409, "pending", `Invocation ${id} hasn't finished yet.`);
|
|
209
|
+
if (invocation.state === "failed")
|
|
210
|
+
throw failedRun(invocation.lastError);
|
|
211
|
+
const output = contract.output;
|
|
212
|
+
const parsed = output.safeParse(invocation.result);
|
|
213
|
+
if (!parsed.success)
|
|
214
|
+
throw resultMismatch(id, parsed.error, reportError);
|
|
215
|
+
return parsed.data;
|
|
216
|
+
};
|
|
217
|
+
return { handler, start, result };
|
|
218
|
+
}
|
|
219
|
+
async function readInvoke(request, declared) {
|
|
220
|
+
let sent;
|
|
221
|
+
try {
|
|
222
|
+
sent = await request.json();
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
throw new InvocationError(400, "invalid_input", "The request body is not JSON.");
|
|
226
|
+
}
|
|
227
|
+
const parsed = invokeBody.safeParse(sent);
|
|
228
|
+
if (!parsed.success) {
|
|
229
|
+
throw new InvocationError(400, "invalid_input", 'The request body must be {"key": …, "input": …}.');
|
|
230
|
+
}
|
|
231
|
+
return startBody(declared.get(parsed.data.key), parsed.data.key, parsed.data.input);
|
|
232
|
+
}
|
|
233
|
+
function respond(status, invocation, declared, reportError) {
|
|
234
|
+
const { id, input, lastError, createdAt, finishedAt } = invocation;
|
|
235
|
+
const send = (answer) => new Response(JSON.stringify(answer), {
|
|
236
|
+
status,
|
|
237
|
+
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
|
|
238
|
+
});
|
|
239
|
+
if (invocation.state !== "succeeded") {
|
|
240
|
+
return send({ id, state: invocation.state, input, result: null, lastError, createdAt, finishedAt });
|
|
241
|
+
}
|
|
242
|
+
const [contract = null] = [...declared.values()].filter((candidate) => candidate.id === invocation.contractId);
|
|
243
|
+
if (contract === null) {
|
|
244
|
+
return refuse(404, "unknown_contract", `This app declares no contract with the id ${invocation.contractId}.`);
|
|
245
|
+
}
|
|
246
|
+
const parsed = contract.output.safeParse(invocation.result);
|
|
247
|
+
if (!parsed.success) {
|
|
248
|
+
const mismatch = resultMismatch(id, parsed.error, reportError);
|
|
249
|
+
return refuse(mismatch.status, mismatch.code, mismatch.message);
|
|
250
|
+
}
|
|
251
|
+
return send({ id, state: "succeeded", input, result: parsed.data, lastError, createdAt, finishedAt });
|
|
252
|
+
}
|
|
253
|
+
// The platform's error shape: the sentence a person reads, and the reason as a code.
|
|
254
|
+
function refuse(status, code, error) {
|
|
255
|
+
return new Response(JSON.stringify({ error, code }), {
|
|
256
|
+
status,
|
|
257
|
+
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
|
|
258
|
+
});
|
|
259
|
+
}
|