@kabmo-labs/kabmo 0.3.1 → 0.4.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/dist/client/index.html +1 -1
- package/dist/client/index.js +412 -383
- package/dist/client/static/js/index-3qamd33d.css +1 -0
- package/dist/client/static/js/{index-28z316qk.js → index-twsk4wfa.js} +791 -791
- package/package.json +1 -1
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
- package/src/client/components/common/session-actions-menu/index.tsx +23 -2
- package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
- package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
- package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
- package/src/client/components/layout/LeftDock.tsx +119 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
- package/src/client/components/layout/desktop-layout/index.tsx +88 -61
- package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
- package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
- package/src/client/components/mobile/RightSidebar.tsx +8 -5
- package/src/client/components/mobile/SessionSidebar.tsx +6 -0
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
- package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
- package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
- package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
- package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
- package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
- package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
- package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
- package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
- package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
- package/src/client/components/workspace/editor/index.tsx +1 -0
- package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
- package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
- package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
- package/src/client/components/workspace/panels/builtin.tsx +8 -0
- package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
- package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
- package/src/client/hooks/chat/composer/compact.ts +119 -0
- package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
- package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
- package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
- package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
- package/src/client/hooks/chat/timeline/actions.ts +34 -35
- package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
- package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
- package/src/client/hooks/chat/timeline/index.ts +15 -19
- package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
- package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
- package/src/client/hooks/chat/waiting/cancel.ts +34 -0
- package/src/client/hooks/chat/waiting/choice.ts +53 -0
- package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
- package/src/client/hooks/chat/waiting/held.ts +42 -0
- package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
- package/src/client/hooks/chat/waiting/hold.ts +156 -0
- package/src/client/hooks/chat/waiting/store.ts +100 -0
- package/src/client/hooks/workspace/left-dock.ts +132 -0
- package/src/client/hooks/workspace/panel-registry.ts +13 -0
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.test.ts +52 -1
- package/src/server/db.server.ts +49 -1
- package/src/server/index.ts +9 -0
- package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
- package/src/server/lib/assets/compressed-cache.server.ts +88 -0
- package/src/server/lib/db/schema.test.ts +1 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/fs/fs-path.test.ts +1 -1
- package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
- package/src/server/lib/omp/session/delete.server.ts +8 -0
- package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
- package/src/server/lib/panels/registry.server.ts +2 -2
- package/src/server/lib/queue/delivery.server.ts +31 -39
- package/src/server/lib/queue/payload.ts +73 -0
- package/src/server/lib/realtime/signals.server.ts +10 -1
- package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
- package/src/server/lib/realtime/topics/global.server.ts +5 -0
- package/src/server/lib/realtime/topics.server.ts +2 -0
- package/src/server/lib/util/with-timeout.ts +29 -0
- package/src/server/lib/waiting/row-clock.server.ts +37 -0
- package/src/server/lib/waiting/runtime.server.test.ts +278 -0
- package/src/server/lib/waiting/runtime.server.ts +313 -0
- package/src/server/lib/waiting/schema.server.ts +46 -0
- package/src/server/lib/waiting/settle.test.ts +84 -0
- package/src/server/lib/waiting/settle.ts +77 -0
- package/src/server/lib/waiting/store.server.test.ts +167 -0
- package/src/server/lib/waiting/store.server.ts +268 -0
- package/src/server/lib/waiting/sweep-trigger.ts +35 -0
- package/src/server/plugins/compress.ts +18 -6
- package/src/server/plugins/ssr.ts +16 -8
- package/src/server/plugins/static.test.ts +59 -0
- package/src/server/plugins/static.ts +56 -14
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/waiting/bindings.ts +14 -0
- package/src/server/routes/waiting/index.ts +123 -0
- package/src/server/routes/waiting/routes.test.ts +121 -0
- package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
- package/src/shared/lib/chat/timeline/gutter.ts +37 -0
- package/src/shared/lib/realtime/protocol.ts +10 -0
- package/src/shared/lib/workspace/left-dock-width.ts +78 -0
- package/src/shared/lib/workspace/left-dock.test.ts +203 -0
- package/src/shared/lib/workspace/left-dock.ts +197 -0
- package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
- package/src/shared/lib/workspace/panel-ids.ts +114 -26
- package/src/shared/lib/workspace/sidebar-width.ts +4 -0
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/waiting.ts +54 -0
- package/src/shared/types/workspace.ts +8 -0
- package/src/test-support/isolated-db.ts +3 -4
- package/dist/client/static/js/index-pdp5dwc8.css +0 -1
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SQLite store for waiting prompts (`waiting_prompts`).
|
|
8
|
+
*
|
|
9
|
+
* Two rules shape this file:
|
|
10
|
+
*
|
|
11
|
+
* - **The claim is a write.** `claimWaitingPrompt` flips `waiting →
|
|
12
|
+
* dispatched` in one statement and reports whether it won, so two sweeps —
|
|
13
|
+
* or two Kabmo instances on the shared database — can never both spawn a
|
|
14
|
+
* session for one prompt.
|
|
15
|
+
* - **A terminal row is kept for a while.** The client learns that a prompt
|
|
16
|
+
* was dispatched (and which session it became) by reading this list, so a
|
|
17
|
+
* row that vanished the instant it was claimed would leave a sidebar row
|
|
18
|
+
* with nothing to adopt. Rows are pruned by age instead.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { getDb } from '@/server/db.server';
|
|
22
|
+
import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
23
|
+
import { normalizeModel } from '@/server/lib/queue/store.server';
|
|
24
|
+
import type { Attachment, QueuedMessageModel } from '@/shared/types/chat';
|
|
25
|
+
import type { WaitingPrompt, WaitingPromptModes, WaitingPromptStatus } from '@/shared/types/waiting';
|
|
26
|
+
|
|
27
|
+
/** How long a finished (dispatched/cancelled/error) row stays readable. */
|
|
28
|
+
export const WAITING_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
interface WaitingRow {
|
|
31
|
+
id: string;
|
|
32
|
+
pending_id: string | null;
|
|
33
|
+
depends_on: string;
|
|
34
|
+
depends_on_title: string | null;
|
|
35
|
+
message: string;
|
|
36
|
+
attachments: string;
|
|
37
|
+
provider: string | null;
|
|
38
|
+
model_id: string | null;
|
|
39
|
+
thinking_level: string | null;
|
|
40
|
+
access_mode: string | null;
|
|
41
|
+
modes: string | null;
|
|
42
|
+
cwd: string;
|
|
43
|
+
folder_id: number | null;
|
|
44
|
+
status: string;
|
|
45
|
+
session_id: string | null;
|
|
46
|
+
error: string | null;
|
|
47
|
+
created_at: number;
|
|
48
|
+
updated_at: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const STATUSES: readonly WaitingPromptStatus[] = ['waiting', 'dispatched', 'cancelled', 'error'];
|
|
52
|
+
|
|
53
|
+
function isStatus(value: unknown): value is WaitingPromptStatus {
|
|
54
|
+
return typeof value === 'string' && (STATUSES as readonly string[]).includes(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A JSON column, or null when the text is absent or unparseable. */
|
|
58
|
+
function parseColumn(value: string | null): unknown {
|
|
59
|
+
if (!value) return null;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(value);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readModel(row: WaitingRow): QueuedMessageModel | null {
|
|
68
|
+
if (!row.provider || !row.model_id) return null;
|
|
69
|
+
return {
|
|
70
|
+
provider: row.provider,
|
|
71
|
+
modelId: row.model_id,
|
|
72
|
+
thinkingLevel: row.thinking_level ?? 'auto',
|
|
73
|
+
accessMode: isApprovalMode(row.access_mode) ? row.access_mode : 'always-ask',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readModes(raw: string | null): WaitingPromptModes | null {
|
|
78
|
+
const parsed = parseColumn(raw);
|
|
79
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
80
|
+
const modes = parsed as Record<string, unknown>;
|
|
81
|
+
return { plan: modes.plan === true, goal: modes.goal === true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rowToWaitingPrompt(row: WaitingRow): WaitingPrompt {
|
|
85
|
+
const attachments = parseColumn(row.attachments);
|
|
86
|
+
return {
|
|
87
|
+
id: row.id,
|
|
88
|
+
pendingId: row.pending_id,
|
|
89
|
+
dependsOn: row.depends_on,
|
|
90
|
+
dependsOnTitle: row.depends_on_title,
|
|
91
|
+
prompt: row.message,
|
|
92
|
+
attachments: Array.isArray(attachments) ? (attachments as Attachment[]) : [],
|
|
93
|
+
model: readModel(row),
|
|
94
|
+
modes: readModes(row.modes),
|
|
95
|
+
cwd: row.cwd,
|
|
96
|
+
folderId: row.folder_id,
|
|
97
|
+
status: isStatus(row.status) ? row.status : 'error',
|
|
98
|
+
sessionId: row.session_id,
|
|
99
|
+
error: row.error,
|
|
100
|
+
createdAt: row.created_at,
|
|
101
|
+
updatedAt: row.updated_at,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface WaitingPromptInput {
|
|
106
|
+
pendingId?: string | null;
|
|
107
|
+
dependsOn: string;
|
|
108
|
+
dependsOnTitle?: string | null;
|
|
109
|
+
prompt: string;
|
|
110
|
+
attachments?: unknown;
|
|
111
|
+
model?: unknown;
|
|
112
|
+
modes?: WaitingPromptModes | null;
|
|
113
|
+
cwd: string;
|
|
114
|
+
folderId?: number | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Every row a client may still need: all `waiting` plus recent terminal ones. */
|
|
118
|
+
export async function listWaitingPrompts(now = Date.now()): Promise<WaitingPrompt[]> {
|
|
119
|
+
const db = await getDb();
|
|
120
|
+
const rows = (await db.all(
|
|
121
|
+
`SELECT * FROM waiting_prompts
|
|
122
|
+
WHERE status = 'waiting' OR updated_at >= ?
|
|
123
|
+
ORDER BY created_at ASC`,
|
|
124
|
+
[now - WAITING_RETENTION_MS],
|
|
125
|
+
)) as WaitingRow[];
|
|
126
|
+
return rows.map(rowToWaitingPrompt);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function getWaitingPrompt(id: string): Promise<WaitingPrompt | null> {
|
|
130
|
+
const db = await getDb();
|
|
131
|
+
const row = (await db.get('SELECT * FROM waiting_prompts WHERE id = ?', [id])) as WaitingRow | undefined;
|
|
132
|
+
return row ? rowToWaitingPrompt(row) : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Rows still waiting on `sessionId` (used when that session is deleted). */
|
|
136
|
+
export async function listWaitingOn(sessionId: string): Promise<WaitingPrompt[]> {
|
|
137
|
+
const db = await getDb();
|
|
138
|
+
const rows = (await db.all(
|
|
139
|
+
"SELECT * FROM waiting_prompts WHERE status = 'waiting' AND depends_on = ? ORDER BY created_at ASC",
|
|
140
|
+
[sessionId],
|
|
141
|
+
)) as WaitingRow[];
|
|
142
|
+
return rows.map(rowToWaitingPrompt);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Insert one prompt. Returns the stored row (id and clock filled in). */
|
|
146
|
+
export async function createWaitingPrompt(input: WaitingPromptInput): Promise<WaitingPrompt> {
|
|
147
|
+
const db = await getDb();
|
|
148
|
+
const id = `wait-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
149
|
+
const model = normalizeModel(input.model);
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
await db.run(
|
|
152
|
+
`INSERT INTO waiting_prompts
|
|
153
|
+
(id, pending_id, depends_on, depends_on_title, message, attachments,
|
|
154
|
+
provider, model_id, thinking_level, access_mode, modes, cwd, folder_id,
|
|
155
|
+
status, session_id, error, created_at, updated_at)
|
|
156
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, ?, ?)`,
|
|
157
|
+
[
|
|
158
|
+
id,
|
|
159
|
+
input.pendingId ?? null,
|
|
160
|
+
input.dependsOn,
|
|
161
|
+
input.dependsOnTitle ?? null,
|
|
162
|
+
input.prompt,
|
|
163
|
+
JSON.stringify(Array.isArray(input.attachments) ? input.attachments : []),
|
|
164
|
+
model?.provider ?? null,
|
|
165
|
+
model?.modelId ?? null,
|
|
166
|
+
model?.thinkingLevel ?? null,
|
|
167
|
+
model?.accessMode ?? null,
|
|
168
|
+
input.modes ? JSON.stringify({ plan: input.modes.plan === true, goal: input.modes.goal === true }) : null,
|
|
169
|
+
input.cwd,
|
|
170
|
+
input.folderId ?? null,
|
|
171
|
+
now,
|
|
172
|
+
now,
|
|
173
|
+
],
|
|
174
|
+
);
|
|
175
|
+
const created = await getWaitingPrompt(id);
|
|
176
|
+
if (!created) throw new Error('Waiting prompt was not stored');
|
|
177
|
+
return created;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Claim one prompt for dispatch. True only for the caller that moved it.
|
|
182
|
+
*
|
|
183
|
+
* This is the whole concurrency story: the sweep may run in several processes,
|
|
184
|
+
* and a prompt must produce exactly one session.
|
|
185
|
+
*/
|
|
186
|
+
export async function claimWaitingPrompt(id: string): Promise<boolean> {
|
|
187
|
+
const db = await getDb();
|
|
188
|
+
const result = await db.run(
|
|
189
|
+
"UPDATE waiting_prompts SET status = 'dispatched', updated_at = ? WHERE id = ? AND status = 'waiting'",
|
|
190
|
+
[Date.now(), id],
|
|
191
|
+
);
|
|
192
|
+
return result.changes > 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Record the session a claimed prompt became. */
|
|
196
|
+
export async function markWaitingDispatched(id: string, sessionId: string): Promise<void> {
|
|
197
|
+
const db = await getDb();
|
|
198
|
+
await db.run(
|
|
199
|
+
"UPDATE waiting_prompts SET session_id = ?, updated_at = ? WHERE id = ? AND status = 'dispatched'",
|
|
200
|
+
[sessionId, Date.now(), id],
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Return a claimed prompt to `waiting` — the dispatch threw before a session
|
|
206
|
+
* existed, so the prompt has not been delivered and is still wanted.
|
|
207
|
+
*/
|
|
208
|
+
export async function requeueWaitingPrompt(id: string, error: string): Promise<void> {
|
|
209
|
+
const db = await getDb();
|
|
210
|
+
await db.run(
|
|
211
|
+
"UPDATE waiting_prompts SET status = 'waiting', error = ?, updated_at = ? WHERE id = ? AND status = 'dispatched'",
|
|
212
|
+
[error, Date.now(), id],
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Terminal failure: the prompt will not be sent, and the client must say why.
|
|
218
|
+
*
|
|
219
|
+
* Covers BOTH a prompt still waiting and one already claimed: a dispatch that
|
|
220
|
+
* spawned a session and then failed to deliver into it has still ended in
|
|
221
|
+
* failure, and leaving the row at `dispatched` would tell the client a prompt
|
|
222
|
+
* arrived when it did not.
|
|
223
|
+
*/
|
|
224
|
+
export async function failWaitingPrompt(id: string, error: string): Promise<void> {
|
|
225
|
+
const db = await getDb();
|
|
226
|
+
await db.run(
|
|
227
|
+
"UPDATE waiting_prompts SET status = 'error', error = ?, updated_at = ? WHERE id = ? AND status IN ('waiting', 'dispatched')",
|
|
228
|
+
[error, Date.now(), id],
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Count one dispatch attempt; returns the new count. */
|
|
233
|
+
export async function countWaitingAttempt(id: string): Promise<number> {
|
|
234
|
+
const db = await getDb();
|
|
235
|
+
await db.run('UPDATE waiting_prompts SET attempts = attempts + 1 WHERE id = ?', [id]);
|
|
236
|
+
const row = (await db.get('SELECT attempts FROM waiting_prompts WHERE id = ?', [id])) as { attempts?: number } | undefined;
|
|
237
|
+
return row?.attempts ?? 1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Fail every prompt waiting on a session that no longer exists. */
|
|
241
|
+
export async function failWaitingOn(sessionId: string, reason: string): Promise<number> {
|
|
242
|
+
const db = await getDb();
|
|
243
|
+
const result = await db.run(
|
|
244
|
+
"UPDATE waiting_prompts SET status = 'error', error = ?, updated_at = ? WHERE status = 'waiting' AND depends_on = ?",
|
|
245
|
+
[reason, Date.now(), sessionId],
|
|
246
|
+
);
|
|
247
|
+
return result.changes;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Cancel one prompt. False when it was already claimed (or is unknown). */
|
|
251
|
+
export async function cancelWaitingPrompt(id: string): Promise<boolean> {
|
|
252
|
+
const db = await getDb();
|
|
253
|
+
const result = await db.run(
|
|
254
|
+
"UPDATE waiting_prompts SET status = 'cancelled', updated_at = ? WHERE id = ? AND status = 'waiting'",
|
|
255
|
+
[Date.now(), id],
|
|
256
|
+
);
|
|
257
|
+
return result.changes > 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Drop rows past retention; returns how many went. */
|
|
261
|
+
export async function pruneWaitingPrompts(now = Date.now()): Promise<number> {
|
|
262
|
+
const db = await getDb();
|
|
263
|
+
const result = await db.run(
|
|
264
|
+
"DELETE FROM waiting_prompts WHERE status <> 'waiting' AND updated_at < ?",
|
|
265
|
+
[now - WAITING_RETENTION_MS],
|
|
266
|
+
);
|
|
267
|
+
return result.changes;
|
|
268
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* "Something that a held prompt may be waiting on just moved."
|
|
8
|
+
*
|
|
9
|
+
* A one-function slot rather than a direct import, because the producer is the
|
|
10
|
+
* follow-up queue's delivery path and the consumer is the waiting-prompt
|
|
11
|
+
* runtime — and the runtime already imports the queue (it has to ask whether a
|
|
12
|
+
* dependency still holds a queued item, and whether a delivery is armed). A
|
|
13
|
+
* direct edge back would close that cycle.
|
|
14
|
+
*
|
|
15
|
+
* Anchored on `globalThis` for the reason the other process-wide slots are: a
|
|
16
|
+
* `bun --hot` reload re-evaluates the runtime module, and a module-level binding
|
|
17
|
+
* would leave the previous generation's callback installed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const SLOT = '__kabmoWaitingSweepRequest';
|
|
21
|
+
|
|
22
|
+
type SweepRequest = (delayMs: number) => void;
|
|
23
|
+
|
|
24
|
+
/** Install (or clear) the runtime's sweep request. Called by the runtime. */
|
|
25
|
+
export function setWaitingSweepHandler(handler: SweepRequest | null): void {
|
|
26
|
+
(globalThis as unknown as Record<string, SweepRequest | null | undefined>)[SLOT] = handler;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Ask the waiting runtime for a sweep. A no-op when no runtime is installed —
|
|
31
|
+
* which only happens before boot finished, when there is nothing to sweep yet.
|
|
32
|
+
*/
|
|
33
|
+
export function requestWaitingSweep(delayMs = 1_600): void {
|
|
34
|
+
(globalThis as unknown as Record<string, SweepRequest | undefined>)[SLOT]?.(delayMs);
|
|
35
|
+
}
|
|
@@ -19,14 +19,25 @@ import { brotliCompressSync, constants } from 'node:zlib';
|
|
|
19
19
|
* 0.007 ms for gzip at 2 KiB). Brotli remains for a client that accepts `br`
|
|
20
20
|
* but not `zstd`, and gzip is the floor every client has.
|
|
21
21
|
*/
|
|
22
|
-
const MIN_BYTES = 1024;
|
|
22
|
+
export const MIN_BYTES = 1024;
|
|
23
23
|
|
|
24
24
|
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|wasm|svg))/;
|
|
25
25
|
|
|
26
26
|
/** Compression encodings this server can produce, best first. */
|
|
27
27
|
const ENCODINGS = ['zstd', 'br', 'gzip'] as const;
|
|
28
28
|
|
|
29
|
-
type Encoding = (typeof ENCODINGS)[number];
|
|
29
|
+
export type Encoding = (typeof ENCODINGS)[number];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether a content type is worth compressing at all.
|
|
33
|
+
*
|
|
34
|
+
* Exported because the static asset route decides the same thing before it
|
|
35
|
+
* reads a file: buffering a PNG or a woff2 to discover it compresses to itself
|
|
36
|
+
* is work that buys nothing.
|
|
37
|
+
*/
|
|
38
|
+
export function isCompressible(contentType: string): boolean {
|
|
39
|
+
return COMPRESSIBLE.test(contentType);
|
|
40
|
+
}
|
|
30
41
|
|
|
31
42
|
/**
|
|
32
43
|
* The best encoding the client accepts, or null.
|
|
@@ -35,14 +46,15 @@ type Encoding = (typeof ENCODINGS)[number];
|
|
|
35
46
|
* browser's `Accept-Encoding` is an explicit set of alternatives, not a
|
|
36
47
|
* preference ordering this server has to weigh.
|
|
37
48
|
*/
|
|
38
|
-
function
|
|
49
|
+
export function pickEncoding(acceptEncoding: string | null): Encoding | null {
|
|
50
|
+
if (!acceptEncoding) return null;
|
|
39
51
|
for (const encoding of ENCODINGS) {
|
|
40
52
|
if (acceptEncoding.includes(encoding)) return encoding;
|
|
41
53
|
}
|
|
42
54
|
return null;
|
|
43
55
|
}
|
|
44
56
|
|
|
45
|
-
function
|
|
57
|
+
export function compressBytes(body: Uint8Array<ArrayBuffer>, encoding: Encoding): Uint8Array {
|
|
46
58
|
if (encoding === 'zstd') return Bun.zstdCompressSync(body, { level: 3 });
|
|
47
59
|
if (encoding === 'br') {
|
|
48
60
|
return brotliCompressSync(body, { params: { [constants.BROTLI_PARAM_QUALITY]: 4 } });
|
|
@@ -57,7 +69,7 @@ export async function maybeCompress(response: Response, acceptEncoding: string |
|
|
|
57
69
|
if (!COMPRESSIBLE.test(contentType)) return response;
|
|
58
70
|
if (response.status === 204 || response.status === 304) return response;
|
|
59
71
|
|
|
60
|
-
const encoding =
|
|
72
|
+
const encoding = pickEncoding(acceptEncoding);
|
|
61
73
|
if (!encoding) return response;
|
|
62
74
|
|
|
63
75
|
const buffer = new Uint8Array(await response.arrayBuffer()) as Uint8Array<ArrayBuffer>;
|
|
@@ -66,7 +78,7 @@ export async function maybeCompress(response: Response, acceptEncoding: string |
|
|
|
66
78
|
return rebuild(response, buffer);
|
|
67
79
|
}
|
|
68
80
|
|
|
69
|
-
const compressed =
|
|
81
|
+
const compressed = compressBytes(buffer, encoding);
|
|
70
82
|
const headers = new Headers(response.headers);
|
|
71
83
|
headers.set('content-encoding', encoding);
|
|
72
84
|
headers.set('content-length', String(compressed.byteLength));
|
|
@@ -2,6 +2,7 @@ import { Elysia } from 'elysia';
|
|
|
2
2
|
import { getDb } from '@/server/db.server';
|
|
3
3
|
import { isAuthEnabled, isAuthenticatedRequest } from '@/server/lib/auth/guard';
|
|
4
4
|
import { tryServeStatic } from '@/server/plugins/static';
|
|
5
|
+
import { maybeCompress } from '@/server/plugins/compress';
|
|
5
6
|
import { renderShell, SHELL_MARKER } from '@/server/plugins/shell.server';
|
|
6
7
|
import {
|
|
7
8
|
DEV_ASSETS_ENABLED,
|
|
@@ -199,13 +200,20 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
199
200
|
// so a `/_bun/` string inside the settings payload is left alone.
|
|
200
201
|
const shellHtml = DEV_ASSETS_ENABLED ? rewriteDevAssetUrls(rendered.html) : rendered.html;
|
|
201
202
|
|
|
202
|
-
return
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
203
|
+
return maybeCompress(
|
|
204
|
+
new Response(
|
|
205
|
+
shellHtml
|
|
206
|
+
.replace('data-theme="paper"', `data-theme="${theme.id}"`)
|
|
207
|
+
.replace('data-theme-variant="light"', `data-theme-variant="${theme.variant}"`)
|
|
208
|
+
.replace('<meta name="theme-color" content="#faf8f3" />', `<meta name="theme-color" content="${chrome}" />`)
|
|
209
|
+
.replace('<!--app-head-->', themeStyle + fontLink)
|
|
210
|
+
.replace(SHELL_MARKER, `<script>window.__OMP_BOOTSTRAP__=${bootstrap}</script>`),
|
|
211
|
+
{ headers: HTML_HEADERS },
|
|
212
|
+
),
|
|
213
|
+
// 24 KB of markup the browser can never store (`no-store`), so every
|
|
214
|
+
// navigation pays for it again — compressed, it is about a fifth of that.
|
|
215
|
+
// `maybeCompress` adds the `Vary` this response needs for the same reason
|
|
216
|
+
// the static route does.
|
|
217
|
+
request.headers.get('accept-encoding'),
|
|
210
218
|
);
|
|
211
219
|
});
|
|
@@ -17,6 +17,11 @@ beforeAll(() => {
|
|
|
17
17
|
writeFileSync(join(PUBLIC_DIR, 'manifest.webmanifest'), '{"name":"Kabmo"}');
|
|
18
18
|
writeFileSync(join(PUBLIC_DIR, 'sw.js'), 'self.addEventListener("fetch", () => {});');
|
|
19
19
|
writeFileSync(join(PUBLIC_DIR, 'index.html'), '<!doctype html><title>shell</title>');
|
|
20
|
+
// Above the compression floor, and compressible: the production bundle is the
|
|
21
|
+
// asset this rule exists for, so a fixture too small to compress would pass
|
|
22
|
+
// while every real client still downloaded 10 MB.
|
|
23
|
+
writeFileSync(join(PUBLIC_DIR, 'bundle.js'), `const x = 1;\n${'// padding\n'.repeat(200)}`);
|
|
24
|
+
writeFileSync(join(PUBLIC_DIR, 'photo.png'), new Uint8Array(2048));
|
|
20
25
|
});
|
|
21
26
|
|
|
22
27
|
afterAll(() => {
|
|
@@ -58,3 +63,57 @@ describe('servePublicAsset', () => {
|
|
|
58
63
|
expect(await servePublicAsset('/nope.svg', ROOTS)).toBeNull();
|
|
59
64
|
});
|
|
60
65
|
});
|
|
66
|
+
|
|
67
|
+
describe('servePublicAsset compression', () => {
|
|
68
|
+
const accept = (encoding: string) =>
|
|
69
|
+
new Request('http://127.0.0.1/bundle.js', { headers: { 'accept-encoding': encoding } });
|
|
70
|
+
|
|
71
|
+
test('sends a compressible asset compressed, and says so', async () => {
|
|
72
|
+
const response = await servePublicAsset('/bundle.js', ROOTS, accept('gzip, zstd'));
|
|
73
|
+
expect(response?.headers.get('content-encoding')).toBe('zstd');
|
|
74
|
+
expect(response?.headers.get('vary')).toBe('accept-encoding');
|
|
75
|
+
// The declared length is the COMPRESSED one, or a client that trusts it
|
|
76
|
+
// would wait for bytes that are never sent.
|
|
77
|
+
const body = new Uint8Array(await response!.arrayBuffer());
|
|
78
|
+
expect(Number(response?.headers.get('content-length'))).toBe(body.byteLength);
|
|
79
|
+
expect(body.byteLength).toBeLessThan(2000);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// The body must decode back to the file's own bytes, not merely be smaller.
|
|
83
|
+
test('the compressed body decompresses to the file', async () => {
|
|
84
|
+
const response = await servePublicAsset('/bundle.js', ROOTS, accept('gzip'));
|
|
85
|
+
const body = new Uint8Array(await response!.arrayBuffer());
|
|
86
|
+
expect(Bun.gunzipSync(body)).toEqual(new Uint8Array(await Bun.file(join(PUBLIC_DIR, 'bundle.js')).arrayBuffer()));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a client that accepts nothing gets the raw bytes, with no Vary claim to honour', async () => {
|
|
90
|
+
const response = await servePublicAsset('/bundle.js', ROOTS, new Request('http://127.0.0.1/bundle.js'));
|
|
91
|
+
expect(response?.headers.get('content-encoding')).toBeNull();
|
|
92
|
+
expect(response?.headers.get('vary')).toBe('accept-encoding');
|
|
93
|
+
expect(Number(response?.headers.get('content-length'))).toBe(
|
|
94
|
+
(await Bun.file(join(PUBLIC_DIR, 'bundle.js')).stat()).size,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// A PNG compresses to itself, so buffering one to find that out is work that
|
|
99
|
+
// buys nothing — and `Vary` would fragment its cache entry for no reason.
|
|
100
|
+
test('leaves an already-compressed type alone', async () => {
|
|
101
|
+
const response = await servePublicAsset('/photo.png', ROOTS, accept('gzip, zstd'));
|
|
102
|
+
expect(response?.headers.get('content-encoding')).toBeNull();
|
|
103
|
+
expect(response?.headers.get('vary')).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// The `304` must carry `Vary` too: a shared cache that stored the identity
|
|
107
|
+
// variant would otherwise hand it to a client that accepts gzip.
|
|
108
|
+
test('the 304 carries the same Vary as the body it stands in for', async () => {
|
|
109
|
+
const first = await servePublicAsset('/bundle.js', ROOTS, accept('gzip'));
|
|
110
|
+
const etag = first!.headers.get('etag')!;
|
|
111
|
+
const second = await servePublicAsset(
|
|
112
|
+
'/bundle.js',
|
|
113
|
+
ROOTS,
|
|
114
|
+
new Request('http://127.0.0.1/bundle.js', { headers: { 'accept-encoding': 'gzip', 'if-none-match': etag } }),
|
|
115
|
+
);
|
|
116
|
+
expect(second?.status).toBe(304);
|
|
117
|
+
expect(second?.headers.get('vary')).toBe('accept-encoding');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
import { extname, join, normalize, resolve, sep } from 'path';
|
|
2
2
|
|
|
3
3
|
import { notModified } from '@/server/lib/assets/conditional.server';
|
|
4
|
+
import { compressedAsset } from '@/server/lib/assets/compressed-cache.server';
|
|
4
5
|
import { FONT_STYLESHEET_ROUTE, serveFontFile, serveFontStylesheet } from '@/server/lib/assets/font-css.server';
|
|
5
6
|
import { FONT_ROUTE_PREFIX } from '@/server/lib/assets/fonts.server';
|
|
6
7
|
import { packageDir } from '@/server/lib/fs/package-root';
|
|
8
|
+
import { compressBytes, isCompressible, maybeCompress, MIN_BYTES, pickEncoding } from '@/server/plugins/compress';
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
|
-
* File serving for `public
|
|
11
|
+
* File serving for `public/`, the font routes and the production build's own
|
|
12
|
+
* assets.
|
|
10
13
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* Which layer serves the client bundle depends on how the server was started,
|
|
15
|
+
* and both shapes are real:
|
|
16
|
+
*
|
|
17
|
+
* - `kabmo serve` (and `bun run start` after `bun run build`) runs the AOT
|
|
18
|
+
* bundle from `dist/client/index.js`, whose assets are `/static/*` FILES — the
|
|
19
|
+
* `/static/` branch below serves them, with the compression and validators
|
|
20
|
+
* this module adds.
|
|
21
|
+
* - Running `src/server/index.ts` with `NODE_ENV=production` makes Bun bundle
|
|
22
|
+
* the HTML route itself and answer `/chunk-*` from its own routing table,
|
|
23
|
+
* before a request reaches Elysia. Those responses cannot be compressed from
|
|
24
|
+
* here: Bun's table wins for the paths it declares, and `Bun.serve` has no
|
|
25
|
+
* compression option (verified — `compression: 'gzip'` left a 650 KB body
|
|
26
|
+
* untouched). This is the source-run shape, not the installed product.
|
|
27
|
+
* - In development the same table serves `/_bun/asset/*` and `/_bun/client/*`,
|
|
28
|
+
* and the shell points at `/_dev-assets/*` so
|
|
29
|
+
* `lib/assets/dev-assets.server.ts` can fetch the bytes back from the
|
|
30
|
+
* listener and add the `Cache-Control` and `ETag`/`304` handling Bun's dev
|
|
31
|
+
* asset routes lack.
|
|
20
32
|
*
|
|
21
33
|
* Hand-rolled rather than `@elysiajs/static`: that plugin registers its own
|
|
22
34
|
* catch-all, which would shadow this app's SSR route. Exposing one lookup
|
|
@@ -80,6 +92,14 @@ function resolveWithin(root: string, pathname: string): string | null {
|
|
|
80
92
|
* Size+mtime is what `plugins/static.ts` can produce from the `stat` it already
|
|
81
93
|
* performs, and a wrong `304` from it needs both a same-size edit and a
|
|
82
94
|
* preserved mtime, which is not something a build or an editor does.
|
|
95
|
+
*
|
|
96
|
+
* A compressible asset is sent compressed, because production served it raw:
|
|
97
|
+
* the client bundle is 10.4 MB and reached every first visit at full size.
|
|
98
|
+
* `lib/assets/compressed-cache.server.ts` keeps the result per ETag, so the
|
|
99
|
+
* ~20 ms zstd pass happens once per build rather than once per client. The
|
|
100
|
+
* `Vary` header is set on EVERY response for such a type, the `304` included —
|
|
101
|
+
* a shared cache that stored the identity variant would otherwise hand it to a
|
|
102
|
+
* client that accepts gzip.
|
|
83
103
|
*/
|
|
84
104
|
async function serveFile(filePath: string, cacheControl: string, request?: Request): Promise<Response | null> {
|
|
85
105
|
let stat;
|
|
@@ -95,20 +115,36 @@ async function serveFile(filePath: string, cacheControl: string, request?: Reque
|
|
|
95
115
|
// Weak on purpose: the validator identifies the file as the same version, and
|
|
96
116
|
// byte equality is not what it promises.
|
|
97
117
|
const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs / 1000)}"`;
|
|
98
|
-
const
|
|
118
|
+
const compressible = isCompressible(type);
|
|
119
|
+
const headers: Record<string, string> = {
|
|
99
120
|
'content-type': type,
|
|
100
121
|
'cache-control': cacheControl,
|
|
101
122
|
'last-modified': lastModified,
|
|
102
123
|
etag,
|
|
103
124
|
};
|
|
125
|
+
if (compressible) headers.vary = 'accept-encoding';
|
|
104
126
|
|
|
105
127
|
if (request && notModified(request, etag, stat.mtimeMs)) {
|
|
106
128
|
// No `content-length`: a 304 describes the validator, not a body.
|
|
107
129
|
return new Response(null, { status: 304, headers });
|
|
108
130
|
}
|
|
109
131
|
|
|
110
|
-
|
|
111
|
-
|
|
132
|
+
const encoding = request && compressible && stat.size >= MIN_BYTES
|
|
133
|
+
? pickEncoding(request.headers.get('accept-encoding'))
|
|
134
|
+
: null;
|
|
135
|
+
if (!encoding) {
|
|
136
|
+
return new Response(Bun.file(filePath).stream(), {
|
|
137
|
+
headers: { ...headers, 'content-length': String(stat.size) },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const bytes = await compressedAsset(`${etag}:${encoding}`, async () =>
|
|
142
|
+
compressBytes(
|
|
143
|
+
new Uint8Array(await Bun.file(filePath).arrayBuffer()) as Uint8Array<ArrayBuffer>,
|
|
144
|
+
encoding,
|
|
145
|
+
));
|
|
146
|
+
return new Response(bytes, {
|
|
147
|
+
headers: { ...headers, 'content-encoding': encoding, 'content-length': String(bytes.byteLength) },
|
|
112
148
|
});
|
|
113
149
|
}
|
|
114
150
|
|
|
@@ -156,7 +192,13 @@ export async function tryServeStatic(pathname: string, request?: Request): Promi
|
|
|
156
192
|
// Neither is given a conditional here: the stylesheet is `no-store` (nothing
|
|
157
193
|
// is ever stored to revalidate) and a font file is `immutable` (the browser
|
|
158
194
|
// does not ask again). A validator on either would be dead code.
|
|
159
|
-
|
|
195
|
+
//
|
|
196
|
+
// The stylesheet IS compressed, though: it is 4 KB of text served on every
|
|
197
|
+
// page load because it cannot be stored, which is exactly the kind of asset
|
|
198
|
+
// a phone pays for repeatedly.
|
|
199
|
+
if (pathname === FONT_STYLESHEET_ROUTE) {
|
|
200
|
+
return maybeCompress(await serveFontStylesheet(), request?.headers.get('accept-encoding') ?? null);
|
|
201
|
+
}
|
|
160
202
|
if (pathname.startsWith(FONT_ROUTE_PREFIX)) {
|
|
161
203
|
return serveFontFile(decodeURIComponent(pathname.slice(FONT_ROUTE_PREFIX.length)));
|
|
162
204
|
}
|
|
@@ -8,6 +8,7 @@ import { chamberBindings } from '@/server/routes/chamber';
|
|
|
8
8
|
import { chatBindings } from '@/server/routes/chat';
|
|
9
9
|
import { sessionsBindings } from '@/server/routes/sessions';
|
|
10
10
|
import { scheduleBindings } from '@/server/routes/schedule/bindings';
|
|
11
|
+
import { waitingBindings } from '@/server/routes/waiting/bindings';
|
|
11
12
|
import { settingsBindings } from '@/server/routes/settings';
|
|
12
13
|
import { fsBindings } from '@/server/routes/fs';
|
|
13
14
|
import { foldersBindings } from '@/server/routes/folders';
|
|
@@ -37,6 +38,7 @@ const allBindings: HandlerBinding[] = [
|
|
|
37
38
|
...chatBindings,
|
|
38
39
|
...sessionsBindings,
|
|
39
40
|
...scheduleBindings,
|
|
41
|
+
...waitingBindings,
|
|
40
42
|
...settingsBindings,
|
|
41
43
|
...fsBindings,
|
|
42
44
|
...foldersBindings,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { actionBindings, type HandlerBinding } from '@/server/lib/route-adapter';
|
|
2
|
+
import * as waiting from '@/server/routes/waiting/index';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `/api/waiting` — the collection (GET/POST) and cancel (DELETE `?id=`).
|
|
6
|
+
*
|
|
7
|
+
* One path, two mutating verbs: cancel names its row in the QUERY, so no `:id`
|
|
8
|
+
* segment shares a position with anything else in the tree.
|
|
9
|
+
*/
|
|
10
|
+
export const waitingBindings: HandlerBinding[] = actionBindings(
|
|
11
|
+
waiting.action,
|
|
12
|
+
'/api/waiting',
|
|
13
|
+
waiting.loader,
|
|
14
|
+
);
|