toolroll 0.9.95 → 0.9.97
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/CHANGELOG.md +13 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +32 -32
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/card-render.d.ts +18 -1
- package/dist/card-render.js +40 -3
- package/dist/chat-delivery.d.ts +5 -1
- package/dist/chat-delivery.js +74 -25
- package/dist/chat-follow.d.ts +6 -3
- package/dist/chat-follow.js +23 -7
- package/dist/chat-live-schema.d.ts +17 -0
- package/dist/chat-live-schema.js +32 -0
- package/dist/chat-live.d.ts +161 -0
- package/dist/chat-live.js +491 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-api.d.ts +3 -1
- package/dist/discord-api.js +9 -3
- package/dist/discord-chat.d.ts +13 -3
- package/dist/discord-chat.js +77 -21
- package/dist/discord.js +8 -1
- package/dist/lead-answer-eval.d.ts +86 -0
- package/dist/lead-answer-eval.js +255 -0
- package/dist/lead-cli-live.d.ts +56 -0
- package/dist/lead-cli-live.js +200 -0
- package/dist/lead-cli.d.ts +3 -0
- package/dist/lead-cli.js +34 -4
- package/dist/lead-latency-schema.d.ts +24 -0
- package/dist/lead-latency-schema.js +36 -0
- package/dist/lead-progress.d.ts +10 -1
- package/dist/lead-progress.js +1 -1
- package/dist/lead-queue-runtime.d.ts +1 -1
- package/dist/lead-queue-runtime.js +1 -1
- package/dist/lead-route.d.ts +56 -0
- package/dist/lead-route.js +104 -0
- package/dist/lead.d.ts +4 -2
- package/dist/lead.js +79 -26
- package/dist/live-reply.d.ts +83 -0
- package/dist/live-reply.js +302 -0
- package/dist/operate.js +39 -1
- package/dist/project-knowledge.d.ts +3 -3
- package/dist/reply-shape.d.ts +9 -0
- package/dist/reply-shape.js +13 -0
- package/dist/serve.js +11 -4
- package/dist/server/chat.d.ts +2 -1
- package/dist/server/chat.js +18 -6
- package/dist/server/live.js +18 -21
- package/dist/server/render-chat.d.ts +5 -0
- package/dist/server/render-chat.js +4 -2
- package/dist/server/render-settings.d.ts +8 -0
- package/dist/server/render-settings.js +13 -2
- package/dist/server/route-table.js +2 -0
- package/dist/server/settings.js +14 -1
- package/dist/slack-api.js +11 -0
- package/dist/slack-chat.d.ts +11 -1
- package/dist/slack-chat.js +50 -5
- package/dist/slack-settings.js +8 -1
- package/dist/slack.js +10 -3
- package/dist/store.d.ts +31 -2
- package/dist/store.js +59 -6
- package/dist/subscription-chat.d.ts +2 -0
- package/dist/subscription-chat.js +3 -1
- package/dist/teams-chat.d.ts +9 -2
- package/dist/teams-chat.js +50 -2
- package/dist/teams.js +5 -2
- package/dist/teardown-git.js +33 -3
- package/dist/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +63 -9
- package/dist/telegram-live.d.ts +116 -0
- package/dist/telegram-live.js +224 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +50 -2
- package/dist/toolroll-update.js +4 -1
- package/package.json +1 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live replies in Slack, Discord and Teams (docs/plans/responsive-chat.md §3.2, §3.5, slice 3): the 👀 on a person's
|
|
3
|
+
* message when it arrives, and one "Working on it…" message per turn that shows the lead's status and its reply as it
|
|
4
|
+
* is written, then becomes the answer.
|
|
5
|
+
*
|
|
6
|
+
* - The 👀 is put on beside the serial chat queue (a turn there can take a minute), from the saved event, for the
|
|
7
|
+
* owner's own direct message only, and taken off once the answer or the failure notice is delivered. It carries no
|
|
8
|
+
* project data: the lease and a live binding are its fence.
|
|
9
|
+
* - The live message is its own `chat_live` row (v157), never a `chat_part`: planning the reply still adds its own
|
|
10
|
+
* parts, and the first of them edits this message, so the event stays queued until then. Creation is
|
|
11
|
+
* creating → placed. On Discord a nonce makes a repeated create within its window return the same message, and
|
|
12
|
+
* a lookup that fails decides nothing; on Slack and Teams an answer that never came leaves the row uncertain, as
|
|
13
|
+
* does an older Discord create not found among the recent messages. An uncertain message is never posted again (the
|
|
14
|
+
* reply then posts fresh, and the event's problem says a "Working on it…" may be left above it). That is the
|
|
15
|
+
* promise: no duplicate, except after an uncertain create.
|
|
16
|
+
* - Edits go through one serialized sender per message (live-reply.ts): one request in flight, the newest view
|
|
17
|
+
* waiting, closed (and the request in flight settled) before the reply is planned. Every edit re-proves the chat's
|
|
18
|
+
* standing (`standing`, the same proof final delivery uses), then the lease after it. Once that fails, nothing more is
|
|
19
|
+
* edited, cleanup included: the last text already ends "(still writing — not final)".
|
|
20
|
+
* - Cosmetic sends share the app's cooldown with durable delivery: none goes while it holds, and a "slow down" on one
|
|
21
|
+
* sets it for everything. Final parts come first (they never wait on these), then the 👀, then live edits, which
|
|
22
|
+
* start each app's spacing conservatively and widen it on every "slow down".
|
|
23
|
+
*
|
|
24
|
+
* No live payload carries a button, a card or an ask: those exist only on the planned reply.
|
|
25
|
+
*/
|
|
26
|
+
import { type LiveView } from "./live-reply.js";
|
|
27
|
+
import type { LeadProgress } from "./lead-progress.js";
|
|
28
|
+
import { type ShapeOptions } from "./reply-shape.js";
|
|
29
|
+
import type { ChatContent, ChatEvent, ChatState } from "./chat-delivery-state.js";
|
|
30
|
+
export type LivePlatform = "slack" | "discord" | "teams";
|
|
31
|
+
/** The live message's fixed words. */
|
|
32
|
+
export declare const LIVE_WORDS: {
|
|
33
|
+
readonly working: "Working on it…";
|
|
34
|
+
readonly notFinal: "(still writing — not final)";
|
|
35
|
+
readonly continues: "(continues…)";
|
|
36
|
+
readonly maybeLeft: "A “Working on it…” message may be left above this reply.";
|
|
37
|
+
};
|
|
38
|
+
/** Each app's starting least time between two live edits (§3.5); a "slow down" doubles it, up to the most. */
|
|
39
|
+
export declare const LIVE_SPACING_MS: Readonly<Record<LivePlatform, number>>;
|
|
40
|
+
/** The chat_meta key that says reactions were refused, and when. */
|
|
41
|
+
export declare const ACK_REFUSED_META = "reactions-refused";
|
|
42
|
+
/** What one app does for a live message. Text arrives already rendered in the app's format (`liveText`). */
|
|
43
|
+
export type ChatLiveTransport = {
|
|
44
|
+
platform: LivePlatform;
|
|
45
|
+
/** The longest rendered text one live message carries (the reply's first window plus the marker). */
|
|
46
|
+
limit: number;
|
|
47
|
+
/** Starting spacing; tests pass 0. */
|
|
48
|
+
spacingMs?: number;
|
|
49
|
+
/** Post the live message; resolves its id. Discord sends its nonce, so a repeat returns the same message. */
|
|
50
|
+
create(text: string): Promise<string>;
|
|
51
|
+
/** Discord only: the message an unconfirmed create left, found by its nonce, or null (not among the recent ones).
|
|
52
|
+
* Throws when the lookup itself failed: that is not absence. */
|
|
53
|
+
recover?(): Promise<string | null>;
|
|
54
|
+
/** With `recover`: how long after a create a repeat under the same nonce is known to return the same message. */
|
|
55
|
+
nonceMs?: number;
|
|
56
|
+
/** Replace the live message's whole text (every field, as the final edit does). */
|
|
57
|
+
edit(message: string, text: string): Promise<void>;
|
|
58
|
+
/** The wait an error asks for when it means "slow down", else null. */
|
|
59
|
+
rateLimited(error: unknown): number | null;
|
|
60
|
+
/** True when the app may not have done it (the answer was lost). */
|
|
61
|
+
uncertain(error: unknown): boolean;
|
|
62
|
+
};
|
|
63
|
+
/** A worker's live-reply switch: on, or on with a least spacing between edits (tests pass 0). */
|
|
64
|
+
export type LiveReplies = boolean | {
|
|
65
|
+
spacingMs: number;
|
|
66
|
+
};
|
|
67
|
+
/** The transport's spacing for that switch (undefined: the app's own start). */
|
|
68
|
+
export declare const liveSpacingOf: (on: LiveReplies | undefined) => number | undefined;
|
|
69
|
+
/** Forget every connection's live spacing (tests). */
|
|
70
|
+
export declare function resetLiveGates(): void;
|
|
71
|
+
/**
|
|
72
|
+
* A view as one live message's text, rendered for the app and measured after rendering: the shaped reply's first
|
|
73
|
+
* window that fits (cut where the final split would cut, never inside a link, code or bold), "(continues…)" when
|
|
74
|
+
* more follows, then the status and the "not final" marker. Null: nothing to show (the turn ended).
|
|
75
|
+
*/
|
|
76
|
+
export declare function liveText(view: LiveView, platform: LivePlatform, limit: number, shape?: ShapeOptions): string | null;
|
|
77
|
+
export type LiveRow = {
|
|
78
|
+
state: "creating" | "uncertain" | "placed" | "finalized" | "abandoned";
|
|
79
|
+
message: string | null;
|
|
80
|
+
generation: number;
|
|
81
|
+
problem: string | null;
|
|
82
|
+
created: string;
|
|
83
|
+
};
|
|
84
|
+
export declare function liveRow(state: ChatState, event: string): LiveRow | null;
|
|
85
|
+
/** Is this message id the live message of that event (the one its reply's first part edits)? */
|
|
86
|
+
export declare function isLiveMessage(state: ChatState, event: string, message: string | undefined): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Plan an event's reply (ChatState.plan) onto its live message: when one was placed, the first part edits it, and the
|
|
89
|
+
* row is finalized in the same transaction. An uncertain create (Slack, Teams) leaves the reply to post fresh, and
|
|
90
|
+
* the event's problem says a "Working on it…" may be left above it.
|
|
91
|
+
*/
|
|
92
|
+
export declare function planWithLive(state: ChatState, event: string, parts: ChatContent[], now: Date): void;
|
|
93
|
+
export type ChatLiveTurn = {
|
|
94
|
+
/** Feed one engine event (synchronous, never throws). The first one posts "Working on it…". */
|
|
95
|
+
push(event: LeadProgress): void;
|
|
96
|
+
/** The message waits behind earlier ones: post "Waiting for your earlier messages…" unless a live message is up. */
|
|
97
|
+
queued(): Promise<void>;
|
|
98
|
+
/** Stop editing: drop the waiting view and settle the request in flight. Resolves before the reply may be planned. */
|
|
99
|
+
close(): Promise<void>;
|
|
100
|
+
};
|
|
101
|
+
export declare function openChatLive(input: {
|
|
102
|
+
state: ChatState;
|
|
103
|
+
event: ChatEvent;
|
|
104
|
+
installation: string;
|
|
105
|
+
transport: ChatLiveTransport;
|
|
106
|
+
/** The owner's message and this app's origin, as the final reply is shaped. */
|
|
107
|
+
shape: ShapeOptions;
|
|
108
|
+
/** The lease, the connection and the binding, read locally (no network): the fence for words that carry no data. */
|
|
109
|
+
fenced: () => boolean;
|
|
110
|
+
/** The chat's full standing (channelAccess under the turn's ceiling) before an edit that carries the turn's words.
|
|
111
|
+
* False: refused (nothing more is edited). Throws: no answer now (that view is dropped). */
|
|
112
|
+
standing: () => Promise<boolean>;
|
|
113
|
+
now: () => Date;
|
|
114
|
+
}): ChatLiveTurn;
|
|
115
|
+
/** What one app does for the 👀. Teams gives bots no reactions: its ack is "typing…", with nothing to take off. */
|
|
116
|
+
export type ChatAckHooks = {
|
|
117
|
+
react?: (event: ChatEvent) => Promise<unknown>;
|
|
118
|
+
unreact?: (event: ChatEvent) => Promise<unknown>;
|
|
119
|
+
typing?: (event: ChatEvent) => Promise<unknown>;
|
|
120
|
+
/** This install may not react (a missing permission or scope): stop asking for a while, quietly. */
|
|
121
|
+
refused: (error: unknown) => boolean;
|
|
122
|
+
/** Already so: the reaction is already on (when adding) or already gone, with its message (when taking it off). */
|
|
123
|
+
settled: (error: unknown) => boolean;
|
|
124
|
+
rateLimited: (error: unknown) => number | null;
|
|
125
|
+
};
|
|
126
|
+
/** Did this install refuse reactions within the last minute? Then no 👀 is asked for (no error after error). */
|
|
127
|
+
export declare function reactionsRefused(state: ChatState, installation: string, now: Date): boolean;
|
|
128
|
+
/**
|
|
129
|
+
* One step of the 👀 work for an installation, beside the queue: take one 👀 off a message whose reply (or failure
|
|
130
|
+
* notice) is delivered, else put one on the next owner's message that arrived. True: it did something (call again).
|
|
131
|
+
*/
|
|
132
|
+
export declare function ackPass(input: {
|
|
133
|
+
state: ChatState;
|
|
134
|
+
installation: string;
|
|
135
|
+
owner: string;
|
|
136
|
+
current: () => boolean;
|
|
137
|
+
hooks: ChatAckHooks;
|
|
138
|
+
now: () => Date;
|
|
139
|
+
}): Promise<boolean>;
|
|
140
|
+
/**
|
|
141
|
+
* Before an event's reply is planned: a create that never confirmed (this pass, or before a restart) is found by its
|
|
142
|
+
* nonce on Discord, else marked uncertain, so planning edits the right message or none. False: the lookup failed, so
|
|
143
|
+
* it is still undecided and planning waits (a "slow down" is shared).
|
|
144
|
+
*/
|
|
145
|
+
export declare function settleLive(state: ChatState, event: string, transport: ChatLiveTransport | null, now: () => Date): Promise<boolean>;
|
|
146
|
+
/** A cosmetic send (typing…) under the app's shared cooldown: skipped while it holds, and a "slow down" sets it. */
|
|
147
|
+
export declare function underCooldown(state: ChatState, installation: string, rateLimited: (error: unknown) => number | null, now: () => Date, call: () => Promise<unknown>): () => Promise<unknown>;
|
|
148
|
+
/** Forget who was acknowledged when (tests). */
|
|
149
|
+
export declare function resetAckSpacing(): void;
|
|
150
|
+
/**
|
|
151
|
+
* The 👀 loop a worker runs beside its queue while its connection is the current one: a pass every quarter second,
|
|
152
|
+
* and at once when `wake` is called (a message just arrived). Never throws.
|
|
153
|
+
*/
|
|
154
|
+
export declare function runChatAcks(input: Omit<Parameters<typeof ackPass>[0], "now"> & {
|
|
155
|
+
same: () => boolean;
|
|
156
|
+
signal: AbortSignal;
|
|
157
|
+
now?: () => Date;
|
|
158
|
+
}): {
|
|
159
|
+
wake: () => void;
|
|
160
|
+
done: Promise<void>;
|
|
161
|
+
};
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live replies in Slack, Discord and Teams (docs/plans/responsive-chat.md §3.2, §3.5, slice 3): the 👀 on a person's
|
|
3
|
+
* message when it arrives, and one "Working on it…" message per turn that shows the lead's status and its reply as it
|
|
4
|
+
* is written, then becomes the answer.
|
|
5
|
+
*
|
|
6
|
+
* - The 👀 is put on beside the serial chat queue (a turn there can take a minute), from the saved event, for the
|
|
7
|
+
* owner's own direct message only, and taken off once the answer or the failure notice is delivered. It carries no
|
|
8
|
+
* project data: the lease and a live binding are its fence.
|
|
9
|
+
* - The live message is its own `chat_live` row (v157), never a `chat_part`: planning the reply still adds its own
|
|
10
|
+
* parts, and the first of them edits this message, so the event stays queued until then. Creation is
|
|
11
|
+
* creating → placed. On Discord a nonce makes a repeated create within its window return the same message, and
|
|
12
|
+
* a lookup that fails decides nothing; on Slack and Teams an answer that never came leaves the row uncertain, as
|
|
13
|
+
* does an older Discord create not found among the recent messages. An uncertain message is never posted again (the
|
|
14
|
+
* reply then posts fresh, and the event's problem says a "Working on it…" may be left above it). That is the
|
|
15
|
+
* promise: no duplicate, except after an uncertain create.
|
|
16
|
+
* - Edits go through one serialized sender per message (live-reply.ts): one request in flight, the newest view
|
|
17
|
+
* waiting, closed (and the request in flight settled) before the reply is planned. Every edit re-proves the chat's
|
|
18
|
+
* standing (`standing`, the same proof final delivery uses), then the lease after it. Once that fails, nothing more is
|
|
19
|
+
* edited, cleanup included: the last text already ends "(still writing — not final)".
|
|
20
|
+
* - Cosmetic sends share the app's cooldown with durable delivery: none goes while it holds, and a "slow down" on one
|
|
21
|
+
* sets it for everything. Final parts come first (they never wait on these), then the 👀, then live edits, which
|
|
22
|
+
* start each app's spacing conservatively and widen it on every "slow down".
|
|
23
|
+
*
|
|
24
|
+
* No live payload carries a button, a card or an ask: those exist only on the planned reply.
|
|
25
|
+
*/
|
|
26
|
+
import { createLiveReply, createLiveSender, LIVE_STATUS } from "./live-reply.js";
|
|
27
|
+
import { renderReply, shapeReplyParts } from "./reply-shape.js";
|
|
28
|
+
/** The live message's fixed words. */
|
|
29
|
+
export const LIVE_WORDS = {
|
|
30
|
+
working: "Working on it…",
|
|
31
|
+
notFinal: "(still writing — not final)",
|
|
32
|
+
continues: "(continues…)",
|
|
33
|
+
maybeLeft: "A “Working on it…” message may be left above this reply.",
|
|
34
|
+
};
|
|
35
|
+
/** Each app's starting least time between two live edits (§3.5); a "slow down" doubles it, up to the most. */
|
|
36
|
+
export const LIVE_SPACING_MS = { slack: 1_500, discord: 1_250, teams: 2_000 };
|
|
37
|
+
const MOST_SPACING_MS = 10_000;
|
|
38
|
+
/** A message older than this when the 👀 would go gets none. */
|
|
39
|
+
const ACK_WINDOW_MS = 60_000;
|
|
40
|
+
/** One 👀 per person a second; a burst waits its turn. */
|
|
41
|
+
const ACK_SPACING_MS = 1_000;
|
|
42
|
+
/** After an app refused reactions (a missing permission), ask again at most this often (one quiet try a minute). */
|
|
43
|
+
const ACK_REFUSED_RETRY_MS = 60_000;
|
|
44
|
+
/** The chat_meta key that says reactions were refused, and when. */
|
|
45
|
+
export const ACK_REFUSED_META = "reactions-refused";
|
|
46
|
+
/** The transport's spacing for that switch (undefined: the app's own start). */
|
|
47
|
+
export const liveSpacingOf = (on) => typeof on === "object" ? on.spacingMs : undefined;
|
|
48
|
+
const gates = new Map();
|
|
49
|
+
function gateOf(state, installation, base) {
|
|
50
|
+
const key = `${state.provider}:${installation}`;
|
|
51
|
+
let gate = gates.get(key);
|
|
52
|
+
if (gate === undefined || gate.base !== base)
|
|
53
|
+
gates.set(key, gate = { spacing: base, base, liveNext: 0 });
|
|
54
|
+
return gate;
|
|
55
|
+
}
|
|
56
|
+
/** The app asked everyone to wait (shared with durable delivery). */
|
|
57
|
+
const coolingDown = (state, installation, now) => state.retryAt(installation) > now.toISOString();
|
|
58
|
+
/** A "slow down" on a cosmetic send: the whole connection waits, and live edits space out further. */
|
|
59
|
+
function slowDown(state, installation, gate, retryMs, now) {
|
|
60
|
+
state.deferUntil(installation, new Date(now.getTime() + Math.max(1_000, retryMs)).toISOString());
|
|
61
|
+
if (gate !== null)
|
|
62
|
+
gate.spacing = Math.min(MOST_SPACING_MS, Math.max(gate.base, gate.spacing * 2, 500));
|
|
63
|
+
}
|
|
64
|
+
const easeBack = (gate) => { gate.spacing = Math.max(gate.base, gate.spacing - (gate.spacing - gate.base) / 4); };
|
|
65
|
+
/** Forget every connection's live spacing (tests). */
|
|
66
|
+
export function resetLiveGates() { gates.clear(); }
|
|
67
|
+
// ---- rendering --------------------------------------------------------------------------------------------------
|
|
68
|
+
const italic = (platform, plain) => {
|
|
69
|
+
const words = renderReply(plain, platform);
|
|
70
|
+
return platform === "discord" ? `*${words}*` : `_${words}_`;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* A view as one live message's text, rendered for the app and measured after rendering: the shaped reply's first
|
|
74
|
+
* window that fits (cut where the final split would cut, never inside a link, code or bold), "(continues…)" when
|
|
75
|
+
* more follows, then the status and the "not final" marker. Null: nothing to show (the turn ended).
|
|
76
|
+
*/
|
|
77
|
+
export function liveText(view, platform, limit, shape = {}) {
|
|
78
|
+
switch (view.phase) {
|
|
79
|
+
case "closed": return null;
|
|
80
|
+
case "queued": return italic(platform, LIVE_STATUS.queued);
|
|
81
|
+
case "working": return italic(platform, view.status);
|
|
82
|
+
case "writing": {
|
|
83
|
+
const marker = italic(platform, view.status === LIVE_STATUS.writing ? LIVE_WORDS.notFinal : `${view.status} ${LIVE_WORDS.notFinal}`);
|
|
84
|
+
const room = Math.max(200, limit - marker.length - LIVE_WORDS.continues.length - 8);
|
|
85
|
+
const windows = shapeReplyParts(view.text, room, shape, shaped => renderReply(shaped, platform).length);
|
|
86
|
+
const first = renderReply(windows[0] ?? "", platform).slice(0, room);
|
|
87
|
+
const more = windows.length > 1 ? `\n\n${italic(platform, LIVE_WORDS.continues)}` : "";
|
|
88
|
+
return `${first}${more}\n\n${marker}`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function liveRow(state, event) {
|
|
93
|
+
const row = state.prepare("SELECT state, message, generation, problem, created FROM chat_live WHERE provider = :provider AND event = ?").get(event);
|
|
94
|
+
return row === undefined ? null : { state: String(row["state"]), message: row["message"] == null ? null : String(row["message"]),
|
|
95
|
+
generation: Number(row["generation"]), problem: row["problem"] == null ? null : String(row["problem"]), created: String(row["created"]) };
|
|
96
|
+
}
|
|
97
|
+
/** The message an unconfirmed create left: found, not among the recent messages (absent), or the lookup failed (a
|
|
98
|
+
* "slow down" is shared). */
|
|
99
|
+
async function recoverLive(state, installation, transport, now) {
|
|
100
|
+
try {
|
|
101
|
+
const found = await transport.recover();
|
|
102
|
+
return found === null ? { absent: true } : { found };
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
const wait = transport.rateLimited(error);
|
|
106
|
+
if (wait !== null)
|
|
107
|
+
slowDown(state, installation, null, wait, now());
|
|
108
|
+
return { failed: true };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const setLive = (state, event, from, to, now, extra = {}) => Number(state.prepare(`UPDATE chat_live SET state = ?, message = COALESCE(?, message), problem = COALESCE(?, problem), updated = ?
|
|
112
|
+
WHERE provider = :provider AND event = ? AND state IN (SELECT value FROM json_each(?))`)
|
|
113
|
+
.run(to, extra.message ?? null, extra.problem ?? null, now.toISOString(), event, JSON.stringify(from)).changes) === 1;
|
|
114
|
+
/** Is this message id the live message of that event (the one its reply's first part edits)? */
|
|
115
|
+
export function isLiveMessage(state, event, message) {
|
|
116
|
+
if (message === undefined)
|
|
117
|
+
return false;
|
|
118
|
+
const row = liveRow(state, event);
|
|
119
|
+
return row !== null && row.message === message;
|
|
120
|
+
}
|
|
121
|
+
/** A first reply part that may replace the live message: words only, to the person's own chat. */
|
|
122
|
+
const editable = (part) => part !== undefined && part.edit === undefined && part.channel === undefined && part.proposal === undefined && part.image === undefined &&
|
|
123
|
+
part.rich === undefined && part.ask === undefined && part.decide === undefined && part.flow === undefined && part.choose === undefined &&
|
|
124
|
+
part.note === undefined && part.question === undefined && part.prompt === undefined;
|
|
125
|
+
/**
|
|
126
|
+
* Plan an event's reply (ChatState.plan) onto its live message: when one was placed, the first part edits it, and the
|
|
127
|
+
* row is finalized in the same transaction. An uncertain create (Slack, Teams) leaves the reply to post fresh, and
|
|
128
|
+
* the event's problem says a "Working on it…" may be left above it.
|
|
129
|
+
*/
|
|
130
|
+
export function planWithLive(state, event, parts, now) {
|
|
131
|
+
state.store.transact(() => {
|
|
132
|
+
const row = liveRow(state, event);
|
|
133
|
+
const onto = row !== null && row.message !== null && (row.state === "placed" || row.state === "abandoned") && editable(parts[0]);
|
|
134
|
+
state.plan(event, onto ? [{ ...parts[0], edit: row.message }, ...parts.slice(1)] : parts, now);
|
|
135
|
+
if (row === null)
|
|
136
|
+
return;
|
|
137
|
+
if (onto)
|
|
138
|
+
setLive(state, event, ["placed", "abandoned"], "finalized", now);
|
|
139
|
+
else if (row.state === "creating" || row.state === "uncertain") {
|
|
140
|
+
setLive(state, event, ["creating", "uncertain"], "uncertain", now, { problem: LIVE_WORDS.maybeLeft });
|
|
141
|
+
state.prepare("UPDATE chat_event SET problem = ? WHERE provider = :provider AND id = ?").run(LIVE_WORDS.maybeLeft, event);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
export function openChatLive(input) {
|
|
146
|
+
const { state, event, transport } = input;
|
|
147
|
+
const gate = gateOf(state, input.installation, transport.spacingMs ?? LIVE_SPACING_MS[transport.platform]);
|
|
148
|
+
const live = createLiveReply(input.shape);
|
|
149
|
+
let row = liveRow(state, event.id);
|
|
150
|
+
// Off for good: an uncertain or finished message, a refused create, or lost standing.
|
|
151
|
+
let off = row !== null && row.state !== "placed" && !(row.state === "creating" && transport.recover !== undefined);
|
|
152
|
+
// A create from before a restart on an app that can't tell whether it landed: uncertain, never posted again.
|
|
153
|
+
if (row?.state === "creating" && transport.recover === undefined)
|
|
154
|
+
setLive(state, event.id, ["creating"], "uncertain", input.now(), { problem: LIVE_WORDS.maybeLeft });
|
|
155
|
+
let message = row?.state === "placed" ? row.message : null;
|
|
156
|
+
let placing = null;
|
|
157
|
+
const stopped = new AbortController();
|
|
158
|
+
const abandon = (problem) => {
|
|
159
|
+
off = true;
|
|
160
|
+
setLive(state, event.id, ["creating", "placed"], "abandoned", input.now(), { problem });
|
|
161
|
+
};
|
|
162
|
+
const place = (text) => {
|
|
163
|
+
if (off || message !== null)
|
|
164
|
+
return Promise.resolve();
|
|
165
|
+
placing ??= (async () => {
|
|
166
|
+
try {
|
|
167
|
+
if (!input.fenced() || coolingDown(state, input.installation, input.now()))
|
|
168
|
+
return;
|
|
169
|
+
row = liveRow(state, event.id);
|
|
170
|
+
if (row === null) {
|
|
171
|
+
state.prepare("INSERT OR IGNORE INTO chat_live (provider, event, state, created, updated) VALUES (:provider, ?, 'creating', ?, ?)")
|
|
172
|
+
.run(event.id, input.now().toISOString(), input.now().toISOString());
|
|
173
|
+
}
|
|
174
|
+
else if (row.state === "creating") {
|
|
175
|
+
// A create from before a restart: Discord finds it by its nonce, or repeats it under the same nonce while
|
|
176
|
+
// that is known to return the same message; Slack and Teams can't know, so it is never posted again.
|
|
177
|
+
if (transport.recover === undefined) {
|
|
178
|
+
off = true;
|
|
179
|
+
setLive(state, event.id, ["creating"], "uncertain", input.now(), { problem: LIVE_WORDS.maybeLeft });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const found = await recoverLive(state, input.installation, transport, input.now);
|
|
183
|
+
// The lookup failed: undecided, so nothing is posted; the row stays creating for a later look.
|
|
184
|
+
if ("failed" in found)
|
|
185
|
+
return;
|
|
186
|
+
if ("found" in found) {
|
|
187
|
+
message = found.found;
|
|
188
|
+
setLive(state, event.id, ["creating"], "placed", input.now(), { message: found.found });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const age = input.now().getTime() - Date.parse(row.created);
|
|
192
|
+
if (!(age >= 0 && age < (transport.nonceMs ?? 0))) {
|
|
193
|
+
off = true;
|
|
194
|
+
setLive(state, event.id, ["creating"], "uncertain", input.now(), { problem: LIVE_WORDS.maybeLeft });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else if (row.state === "placed") {
|
|
199
|
+
message = row.message;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
off = true;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (!input.fenced())
|
|
207
|
+
return;
|
|
208
|
+
const id = await transport.create(text);
|
|
209
|
+
message = id;
|
|
210
|
+
setLive(state, event.id, ["creating"], "placed", input.now(), { message: id });
|
|
211
|
+
gate.liveNext = input.now().getTime() + gate.spacing;
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
const wait = transport.rateLimited(error);
|
|
215
|
+
if (wait !== null)
|
|
216
|
+
slowDown(state, input.installation, gate, wait, input.now());
|
|
217
|
+
if (transport.uncertain(error) && transport.recover === undefined) {
|
|
218
|
+
off = true;
|
|
219
|
+
setLive(state, event.id, ["creating"], "uncertain", input.now(), { problem: LIVE_WORDS.maybeLeft });
|
|
220
|
+
}
|
|
221
|
+
else if (transport.uncertain(error)) {
|
|
222
|
+
// Discord: the row stays creating, so the reply's planning looks for it by nonce once more (settleLive).
|
|
223
|
+
const found = await recoverLive(state, input.installation, transport, input.now);
|
|
224
|
+
if ("found" in found) {
|
|
225
|
+
message = found.found;
|
|
226
|
+
setLive(state, event.id, ["creating"], "placed", input.now(), { message: found.found });
|
|
227
|
+
}
|
|
228
|
+
else
|
|
229
|
+
off = true;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
// Refused outright: nothing was posted. The reply goes out as a new message.
|
|
233
|
+
off = true;
|
|
234
|
+
state.prepare("DELETE FROM chat_live WHERE provider = :provider AND event = ? AND state = 'creating'").run(event.id);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
placing = null;
|
|
239
|
+
}
|
|
240
|
+
})();
|
|
241
|
+
return placing;
|
|
242
|
+
};
|
|
243
|
+
const pause = (ms) => new Promise(resolve => {
|
|
244
|
+
if (ms <= 0 || stopped.signal.aborted)
|
|
245
|
+
return resolve();
|
|
246
|
+
const end = () => { clearTimeout(timer); stopped.signal.removeEventListener("abort", end); resolve(); };
|
|
247
|
+
const timer = setTimeout(end, ms);
|
|
248
|
+
timer.unref?.();
|
|
249
|
+
stopped.signal.addEventListener("abort", end, { once: true });
|
|
250
|
+
});
|
|
251
|
+
const sender = createLiveSender({
|
|
252
|
+
send: async (text, generation) => {
|
|
253
|
+
if (placing !== null)
|
|
254
|
+
await placing;
|
|
255
|
+
if (off || message === null || stopped.signal.aborted)
|
|
256
|
+
return;
|
|
257
|
+
// Spacing: the app's current pace, and a 👀 just sent goes first.
|
|
258
|
+
await pause(gate.liveNext - input.now().getTime());
|
|
259
|
+
if (off || stopped.signal.aborted)
|
|
260
|
+
return;
|
|
261
|
+
if (!input.fenced())
|
|
262
|
+
return abandon("Chat access changed");
|
|
263
|
+
let standing;
|
|
264
|
+
try {
|
|
265
|
+
standing = await input.standing();
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
// Not a refusal, only no answer now (a "slow down", a lost answer): this view is dropped and the message stays.
|
|
269
|
+
const wait = transport.rateLimited(error);
|
|
270
|
+
if (wait !== null)
|
|
271
|
+
slowDown(state, input.installation, gate, wait, input.now());
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (!standing)
|
|
275
|
+
return abandon("Chat access changed");
|
|
276
|
+
// Re-read after the awaits: the turn may have ended, or the lease moved, meanwhile.
|
|
277
|
+
if (off || stopped.signal.aborted || !input.fenced())
|
|
278
|
+
return;
|
|
279
|
+
// A cooldown holds: this view is dropped (a later one may go).
|
|
280
|
+
if (coolingDown(state, input.installation, input.now()))
|
|
281
|
+
return;
|
|
282
|
+
gate.liveNext = input.now().getTime() + gate.spacing;
|
|
283
|
+
try {
|
|
284
|
+
await transport.edit(message, text);
|
|
285
|
+
easeBack(gate);
|
|
286
|
+
state.prepare("UPDATE chat_live SET generation = ?, updated = ? WHERE provider = :provider AND event = ? AND state = 'placed'")
|
|
287
|
+
.run(generation, input.now().toISOString(), event.id);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
const wait = transport.rateLimited(error);
|
|
291
|
+
if (wait !== null)
|
|
292
|
+
slowDown(state, input.installation, gate, wait, input.now());
|
|
293
|
+
// Anything else: this view is dropped, previews are best effort.
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
const show = (view) => {
|
|
298
|
+
// A blocked preview clears at once back to the status line; the turn may still answer.
|
|
299
|
+
const shown = view.phase === "closed" && view.why === "blocked" ? { phase: "working", status: live.status() } : view;
|
|
300
|
+
const text = liveText(shown, transport.platform, transport.limit, input.shape);
|
|
301
|
+
if (text === null)
|
|
302
|
+
return;
|
|
303
|
+
void place(italic(transport.platform, LIVE_WORDS.working));
|
|
304
|
+
sender.offer(text);
|
|
305
|
+
};
|
|
306
|
+
return {
|
|
307
|
+
push(progress) {
|
|
308
|
+
try {
|
|
309
|
+
if (off || sender.closed)
|
|
310
|
+
return;
|
|
311
|
+
if (live.push(progress))
|
|
312
|
+
show(live.view());
|
|
313
|
+
else if (message === null && placing === null)
|
|
314
|
+
void place(italic(transport.platform, LIVE_WORDS.working));
|
|
315
|
+
}
|
|
316
|
+
catch { /* a live message never breaks the turn */ }
|
|
317
|
+
},
|
|
318
|
+
async queued() {
|
|
319
|
+
if (off || message !== null)
|
|
320
|
+
return;
|
|
321
|
+
await place(italic(transport.platform, LIVE_STATUS.queued));
|
|
322
|
+
},
|
|
323
|
+
async close() {
|
|
324
|
+
stopped.abort();
|
|
325
|
+
await sender.close();
|
|
326
|
+
if (placing !== null)
|
|
327
|
+
await placing;
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const setAck = (state, event, ack, now) => {
|
|
332
|
+
state.prepare(`INSERT INTO chat_ack (provider, event, state, created, updated) VALUES (:provider, ?, ?, ?, ?)
|
|
333
|
+
ON CONFLICT (provider, event) DO UPDATE SET state = excluded.state, updated = excluded.updated`).run(event, ack, now.toISOString(), now.toISOString());
|
|
334
|
+
};
|
|
335
|
+
/** Did this install refuse reactions within the last minute? Then no 👀 is asked for (no error after error). */
|
|
336
|
+
export function reactionsRefused(state, installation, now) {
|
|
337
|
+
const at = state.meta(installation, ACK_REFUSED_META);
|
|
338
|
+
return at !== null && Date.parse(at) + ACK_REFUSED_RETRY_MS > now.getTime();
|
|
339
|
+
}
|
|
340
|
+
const lastAck = new Map();
|
|
341
|
+
/**
|
|
342
|
+
* One step of the 👀 work for an installation, beside the queue: take one 👀 off a message whose reply (or failure
|
|
343
|
+
* notice) is delivered, else put one on the next owner's message that arrived. True: it did something (call again).
|
|
344
|
+
*/
|
|
345
|
+
export async function ackPass(input) {
|
|
346
|
+
const { state, installation, hooks } = input;
|
|
347
|
+
const fenced = () => input.current() && state.owns(installation, input.owner, input.now());
|
|
348
|
+
if (!fenced() || coolingDown(state, installation, input.now()))
|
|
349
|
+
return false;
|
|
350
|
+
const eventOf = (row) => (row === undefined ? null : state.event(String(row["event"])));
|
|
351
|
+
// Taking one off: its event is handled and nothing of its reply waits to send.
|
|
352
|
+
if (hooks.unreact !== undefined) {
|
|
353
|
+
const done = eventOf(state.prepare(`SELECT a.event FROM chat_ack a JOIN chat_event e ON e.provider = a.provider AND e.id = a.event
|
|
354
|
+
WHERE a.provider = :provider AND a.state = 'sent' AND e.installation = ? AND e.state IN ('done','dropped')
|
|
355
|
+
AND NOT EXISTS (SELECT 1 FROM chat_part p WHERE p.provider = a.provider AND p.event = a.event AND p.state = 'pending')
|
|
356
|
+
ORDER BY a.created LIMIT 1`).get(installation));
|
|
357
|
+
if (done !== null) {
|
|
358
|
+
try {
|
|
359
|
+
await hooks.unreact(done);
|
|
360
|
+
setAck(state, done.id, "done", input.now());
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
const wait = hooks.rateLimited(error);
|
|
364
|
+
if (wait !== null)
|
|
365
|
+
slowDown(state, installation, null, wait, input.now());
|
|
366
|
+
// Gone already, or not ours to take off any more: settled. Anything else is tried again on a later pass.
|
|
367
|
+
else if (hooks.settled(error) || hooks.refused(error))
|
|
368
|
+
setAck(state, done.id, "done", input.now());
|
|
369
|
+
else
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// Putting one on: the owner's own direct message, saved and not yet acknowledged, recent enough to matter.
|
|
376
|
+
const since = new Date(input.now().getTime() - ACK_WINDOW_MS).toISOString();
|
|
377
|
+
const event = eventOf(state.prepare(`SELECT e.id AS event FROM chat_event e JOIN chat_binding b ON b.provider = e.provider AND b.id = e.binding
|
|
378
|
+
WHERE e.provider = :provider AND e.installation = ? AND e.kind = 'message' AND e.state = 'queued' AND e.created >= ?
|
|
379
|
+
AND b.revoked IS NULL AND e.member = b.member AND e.channel = b.channel
|
|
380
|
+
AND NOT EXISTS (SELECT 1 FROM chat_ack a WHERE a.provider = e.provider AND a.event = e.id)
|
|
381
|
+
ORDER BY e.created, e.id LIMIT 1`).get(installation, since));
|
|
382
|
+
if (event === null || event.binding === null)
|
|
383
|
+
return false;
|
|
384
|
+
const binding = state.bindingById(event.binding);
|
|
385
|
+
if (binding === null || !state.live(binding)) {
|
|
386
|
+
setAck(state, event.id, "skipped", input.now());
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
const key = `${state.provider}:${binding.id}`;
|
|
390
|
+
if ((lastAck.get(key) ?? Number.NEGATIVE_INFINITY) + ACK_SPACING_MS > input.now().getTime())
|
|
391
|
+
return false;
|
|
392
|
+
if (hooks.react === undefined && hooks.typing === undefined) {
|
|
393
|
+
setAck(state, event.id, "skipped", input.now());
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
if (hooks.react !== undefined && reactionsRefused(state, installation, input.now())) {
|
|
397
|
+
setAck(state, event.id, "skipped", input.now());
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
if (!fenced())
|
|
401
|
+
return false;
|
|
402
|
+
lastAck.set(key, input.now().getTime());
|
|
403
|
+
// Saved as on before it is asked for: a crash in between can only leave a take-off with nothing to take off.
|
|
404
|
+
setAck(state, event.id, hooks.react !== undefined ? "sent" : "done", input.now());
|
|
405
|
+
// A 👀 goes before the next live edit.
|
|
406
|
+
for (const [name, gate] of gates)
|
|
407
|
+
if (name === `${state.provider}:${installation}`)
|
|
408
|
+
gate.liveNext = Math.max(gate.liveNext, input.now().getTime() + gate.spacing / 2);
|
|
409
|
+
try {
|
|
410
|
+
if (hooks.react !== undefined)
|
|
411
|
+
await hooks.react(event);
|
|
412
|
+
else
|
|
413
|
+
await hooks.typing(event);
|
|
414
|
+
if (hooks.react !== undefined)
|
|
415
|
+
state.deleteMeta(installation, ACK_REFUSED_META);
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
const wait = hooks.rateLimited(error);
|
|
419
|
+
if (wait !== null)
|
|
420
|
+
slowDown(state, installation, null, wait, input.now());
|
|
421
|
+
if (hooks.react !== undefined && hooks.settled(error))
|
|
422
|
+
return true;
|
|
423
|
+
if (hooks.react !== undefined && hooks.refused(error))
|
|
424
|
+
state.setMeta(installation, ACK_REFUSED_META, input.now().toISOString(), input.now());
|
|
425
|
+
setAck(state, event.id, "skipped", input.now());
|
|
426
|
+
}
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Before an event's reply is planned: a create that never confirmed (this pass, or before a restart) is found by its
|
|
431
|
+
* nonce on Discord, else marked uncertain, so planning edits the right message or none. False: the lookup failed, so
|
|
432
|
+
* it is still undecided and planning waits (a "slow down" is shared).
|
|
433
|
+
*/
|
|
434
|
+
export async function settleLive(state, event, transport, now) {
|
|
435
|
+
const row = liveRow(state, event);
|
|
436
|
+
if (row?.state !== "creating")
|
|
437
|
+
return true;
|
|
438
|
+
const found = transport?.recover === undefined ? { absent: true } : await recoverLive(state, state.event(event)?.installation ?? "", transport, now);
|
|
439
|
+
if ("failed" in found)
|
|
440
|
+
return false;
|
|
441
|
+
if ("found" in found)
|
|
442
|
+
setLive(state, event, ["creating"], "placed", now(), { message: found.found });
|
|
443
|
+
else
|
|
444
|
+
setLive(state, event, ["creating"], "uncertain", now(), { problem: LIVE_WORDS.maybeLeft });
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
/** A cosmetic send (typing…) under the app's shared cooldown: skipped while it holds, and a "slow down" sets it. */
|
|
448
|
+
export function underCooldown(state, installation, rateLimited, now, call) {
|
|
449
|
+
return async () => {
|
|
450
|
+
if (coolingDown(state, installation, now()))
|
|
451
|
+
return;
|
|
452
|
+
try {
|
|
453
|
+
return await call();
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
const wait = rateLimited(error);
|
|
457
|
+
if (wait !== null)
|
|
458
|
+
slowDown(state, installation, null, wait, now());
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/** Forget who was acknowledged when (tests). */
|
|
464
|
+
export function resetAckSpacing() { lastAck.clear(); }
|
|
465
|
+
/**
|
|
466
|
+
* The 👀 loop a worker runs beside its queue while its connection is the current one: a pass every quarter second,
|
|
467
|
+
* and at once when `wake` is called (a message just arrived). Never throws.
|
|
468
|
+
*/
|
|
469
|
+
export function runChatAcks(input) {
|
|
470
|
+
let wakeUp = null;
|
|
471
|
+
const wake = () => { wakeUp?.(); };
|
|
472
|
+
input.signal.addEventListener("abort", wake, { once: true });
|
|
473
|
+
const done = (async () => {
|
|
474
|
+
while (input.same() && !input.signal.aborted) {
|
|
475
|
+
try {
|
|
476
|
+
for (let steps = 0; steps < 8 && await ackPass({ ...input, now: input.now ?? (() => new Date()) }); steps++)
|
|
477
|
+
;
|
|
478
|
+
}
|
|
479
|
+
catch { /* quiet: a 👀 is a courtesy */ }
|
|
480
|
+
if (!input.same() || input.signal.aborted)
|
|
481
|
+
break;
|
|
482
|
+
await new Promise(resolve => {
|
|
483
|
+
const timer = setTimeout(() => wakeUp?.(), 250);
|
|
484
|
+
timer.unref?.();
|
|
485
|
+
wakeUp = () => { clearTimeout(timer); wakeUp = null; resolve(); };
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
input.signal.removeEventListener("abort", wake);
|
|
489
|
+
})();
|
|
490
|
+
return { wake, done };
|
|
491
|
+
}
|
|
@@ -168,6 +168,7 @@ export declare const CONSOLE_FORMS: {
|
|
|
168
168
|
readonly leadIdentity: FormContract<"name" | "persona">;
|
|
169
169
|
readonly leadAbout: FormContract<"about">;
|
|
170
170
|
readonly leadPromiseCancel: FormContract<"promise">;
|
|
171
|
+
readonly leadQuick: FormContract<"enabled">;
|
|
171
172
|
readonly leadCheck: FormContract<"return">;
|
|
172
173
|
readonly leadOn: FormContract<"agent" | "return">;
|
|
173
174
|
readonly welcomeSample: FormContract<never>;
|