@k2b/cloud 0.23.0 → 0.25.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.
Files changed (68) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/capabilities.ts +230 -75
  3. package/src/_internal/define-app.ts +12 -3
  4. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  5. package/src/_internal/page-responses.ts +1 -1
  6. package/src/_internal/registry.ts +78 -3
  7. package/src/access/PermissionEditor.tsx +32 -11
  8. package/src/access/managers.ts +32 -0
  9. package/src/access/messages.ts +6 -0
  10. package/src/ai/chat/blocks.tsx +2 -1
  11. package/src/ai/chat/messages.ts +6 -0
  12. package/src/ai/client/controller.ts +65 -20
  13. package/src/ai/client/transport.ts +5 -5
  14. package/src/ai/code-mode-skill.ts +2 -2
  15. package/src/ai/index.ts +0 -7
  16. package/src/ai/live-events.ts +1 -262
  17. package/src/ai/live.ts +31 -2
  18. package/src/ai/memory-learning.ts +7 -12
  19. package/src/ai/memory-workflow-evidence.ts +10 -2
  20. package/src/ai/migrate.ts +80 -18
  21. package/src/ai/model-access.ts +4 -2
  22. package/src/ai/prefs.ts +19 -5
  23. package/src/ai/projects.ts +5 -2
  24. package/src/ai/runtime.ts +1 -6
  25. package/src/ai/skills.ts +5 -2
  26. package/src/ai/solid.ts +0 -5
  27. package/src/ai/store.ts +14 -0
  28. package/src/api/help.ts +2 -2
  29. package/src/api/pwa-phone.ts +10 -3
  30. package/src/api/search/schemas.ts +34 -0
  31. package/src/api/search.ts +118 -50
  32. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  33. package/src/browser/CloudResourceSearch.tsx +208 -80
  34. package/src/browser/FileChooser.tsx +600 -0
  35. package/src/browser/choose-files.browser-harness.tsx +32 -0
  36. package/src/browser/choose-files.tsx +70 -0
  37. package/src/browser/file-chooser-messages.ts +86 -0
  38. package/src/browser/file-providers.ts +217 -0
  39. package/src/browser/files.tsx +23 -0
  40. package/src/browser/resource-search-messages.ts +15 -2
  41. package/src/browser/search-stream.ts +151 -0
  42. package/src/cli/admin/account-administration.ts +2 -2
  43. package/src/contracts/capabilities.ts +89 -24
  44. package/src/contracts/capability-compatibility.ts +28 -14
  45. package/src/contracts/file-provider.ts +158 -0
  46. package/src/contracts/index.ts +1 -0
  47. package/src/events/live-engine.ts +11 -7
  48. package/src/events/live.ts +32 -32
  49. package/src/server/help.ts +3 -3
  50. package/src/server/index.ts +1 -0
  51. package/src/server/services/access.ts +44 -11
  52. package/src/server/services/index.ts +1 -0
  53. package/src/services/index.ts +1 -1
  54. package/src/services/outbox.ts +29 -115
  55. package/src/services/pdf/markdown.ts +22 -2
  56. package/src/services/pwa-devices.ts +74 -8
  57. package/src/shared/markdown/formula.ts +122 -26
  58. package/src/shared/markdown/index.ts +27 -14
  59. package/src/ssr/PwaLayout.tsx +2 -1
  60. package/src/ssr/platform-messages.ts +2 -2
  61. package/src/styles/file-chooser.css +169 -0
  62. package/src/styles/global.css +1 -0
  63. package/src/styles/resource-search.css +19 -0
  64. package/src/ai/client/live-connection.ts +0 -141
  65. package/src/ai/live-messages.ts +0 -45
  66. package/src/ai/live-outbox.ts +0 -102
  67. package/src/ai/live-routes.ts +0 -412
  68. package/src/shared/markdown/extensions/info-blocks.ts +0 -79
@@ -82,6 +82,7 @@ export type AiProjectAccess = {
82
82
  principal: Principal;
83
83
  permission: AiProjectPermission;
84
84
  displayName?: string;
85
+ avatarHash?: string | null;
85
86
  /** Kind of a `service_account` principal; presentation only. */
86
87
  serviceAccountKind?: ServiceAccountKind;
87
88
  createdAt: string;
@@ -157,6 +158,7 @@ type ProjectAccessRow = {
157
158
  permission: AiProjectPermission;
158
159
  created_at: Date | string;
159
160
  display_name: string | null;
161
+ avatar_hash: string | null;
160
162
  service_account_kind: ServiceAccountKind | null;
161
163
  };
162
164
 
@@ -260,6 +262,7 @@ const toProjectAccess = (row: ProjectAccessRow): AiProjectAccess => ({
260
262
  : { type: "public" },
261
263
  permission: row.permission,
262
264
  displayName: row.display_name ?? undefined,
265
+ ...(row.user_id ? { avatarHash: row.avatar_hash } : {}),
263
266
  serviceAccountKind: row.service_account_kind ?? undefined,
264
267
  createdAt: iso(row.created_at),
265
268
  });
@@ -290,9 +293,9 @@ const listProjectAccess = async (projectId: string, db: SQL = sql): Promise<AiPr
290
293
  const rows = await db<ProjectAccessRow[]>`
291
294
  SELECT project_access.short_id, access.user_id, access.group_id, access.service_account_id, access.authenticated_only,
292
295
  access.permission, access.created_at,
293
- COALESCE(users.display_name, groups.name, service_accounts.name,
296
+ COALESCE(NULLIF(users.display_name, ''), users.uid, groups.name, service_accounts.name,
294
297
  CASE WHEN access.authenticated_only THEN 'All authenticated users' ELSE 'Public' END) AS display_name,
295
- service_accounts.kind AS service_account_kind
298
+ users.avatar_hash, service_accounts.kind AS service_account_kind
296
299
  FROM ai.project_access project_access
297
300
  JOIN auth.access access ON access.id = project_access.access_id
298
301
  LEFT JOIN auth.users users ON users.id = access.user_id
package/src/ai/runtime.ts CHANGED
@@ -12,7 +12,6 @@ import { aiChatAccessSubject, selectAssistantAiModelId } from "./assistant-model
12
12
  import { startAiDictationRuntime } from "./dictation-runtime";
13
13
  import { AiTurnExecutor } from "./executor";
14
14
  import { canonicalizeAiConversationAttachments, snapshotAiConversationFiles } from "./file-context";
15
- import { startAiInvalidationRuntime, stopAiInvalidationRuntime } from "./live-outbox";
16
15
  import { drainQueuedMessages } from "./message-queue";
17
16
  import { aiQuotas } from "./quotas";
18
17
  import { parseAiResourceMarker } from "./resource-markers";
@@ -577,7 +576,6 @@ export const startAiRuntime = (
577
576
  },
578
577
  };
579
578
  running = state;
580
- startAiInvalidationRuntime();
581
579
  log.info("AI runtime started", { workerId: AI_WORKER_ID, concurrency });
582
580
  return releaseAiRuntime(listener);
583
581
  };
@@ -591,10 +589,7 @@ const releaseAiRuntime = (listener: ((event: AiTurnFinalizedEvent) => Promise<vo
591
589
  if (!state) return;
592
590
  if (listener) state.listeners.delete(listener);
593
591
  refCount = Math.max(0, refCount - 1);
594
- if (refCount === 0) {
595
- state.stop();
596
- void stopAiInvalidationRuntime();
597
- }
592
+ if (refCount === 0) state.stop();
598
593
  };
599
594
  };
600
595
 
package/src/ai/skills.ts CHANGED
@@ -58,6 +58,7 @@ export type AiSkillAccess = {
58
58
  principal: Principal;
59
59
  permission: AiSkillPermission;
60
60
  displayName?: string;
61
+ avatarHash?: string | null;
61
62
  /** Kind of a `service_account` principal; presentation only. */
62
63
  serviceAccountKind?: ServiceAccountKind;
63
64
  createdAt: string;
@@ -130,6 +131,7 @@ type SkillAccessRow = {
130
131
  permission: AiSkillPermission;
131
132
  created_at: Date | string;
132
133
  display_name: string | null;
134
+ avatar_hash: string | null;
133
135
  service_account_kind: ServiceAccountKind | null;
134
136
  };
135
137
 
@@ -284,9 +286,9 @@ const listSkillAccess = async (skillId: string, db: SQL = sql): Promise<AiSkillA
284
286
  const rows = await db<SkillAccessRow[]>`
285
287
  SELECT skill_access.short_id, access.user_id, access.group_id, access.service_account_id, access.authenticated_only,
286
288
  access.permission, access.created_at,
287
- COALESCE(users.display_name, groups.name, service_accounts.name,
289
+ COALESCE(NULLIF(users.display_name, ''), users.uid, groups.name, service_accounts.name,
288
290
  CASE WHEN access.authenticated_only THEN 'All authenticated users' ELSE 'Public' END) AS display_name,
289
- service_accounts.kind AS service_account_kind
291
+ users.avatar_hash, service_accounts.kind AS service_account_kind
290
292
  FROM ai.skill_access skill_access
291
293
  JOIN auth.access access ON access.id = skill_access.access_id
292
294
  LEFT JOIN auth.users users ON users.id = access.user_id
@@ -309,6 +311,7 @@ const listSkillAccess = async (skillId: string, db: SQL = sql): Promise<AiSkillA
309
311
  : { type: "public" },
310
312
  permission: row.permission,
311
313
  displayName: row.display_name ?? undefined,
314
+ ...(row.user_id ? { avatarHash: row.avatar_hash } : {}),
312
315
  serviceAccountKind: row.service_account_kind ?? undefined,
313
316
  createdAt: iso(row.created_at),
314
317
  }));
package/src/ai/solid.ts CHANGED
@@ -7,11 +7,6 @@ export {
7
7
  createAiChatController,
8
8
  } from "./client/controller";
9
9
  export { conversationFileSource } from "./client/file-source";
10
- export {
11
- type AiLiveConnection,
12
- type CreateAiLiveConnectionOptions,
13
- createAiLiveConnection,
14
- } from "./client/live-connection";
15
10
  export {
16
11
  type AiActiveTurn,
17
12
  type AiChatProjection,
package/src/ai/store.ts CHANGED
@@ -4,6 +4,7 @@ import { type SQL, sql } from "bun";
4
4
  import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
5
5
  import { logger } from "../services/logging";
6
6
  import { toPgTextArray } from "../services/postgres";
7
+ import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
7
8
  import type { AiTurnBlock } from "./protocol";
8
9
  import { withAiShortId, withAiShortIdForDb } from "./short-id";
9
10
  import { parseAiTodoPlan } from "./todo-contracts";
@@ -2801,6 +2802,19 @@ export const aiConversations: AiConversationService = {
2801
2802
  live_blocks = NULL
2802
2803
  WHERE id = ${input.turnId}
2803
2804
  `;
2805
+ if (input.status === "completed") {
2806
+ // Learning considers only turns that finish while it is on for the chat
2807
+ // owner; turning it on later does not reach back to this turn.
2808
+ await tx`
2809
+ UPDATE ai.turns turn
2810
+ SET memory_learned_at = turn.completed_at
2811
+ FROM ai.conversations conversation
2812
+ LEFT JOIN ai.user_prefs prefs ON prefs.user_id = conversation.created_by_user_id
2813
+ WHERE turn.id = ${input.turnId}
2814
+ AND conversation.id = turn.conversation_id
2815
+ AND NOT COALESCE(prefs.memory_learning_enabled, ${AI_MEMORY_LEARNING_DEFAULT_ENABLED})
2816
+ `;
2817
+ }
2804
2818
  await tx`
2805
2819
  UPDATE ai.pending_actions
2806
2820
  SET status = 'aborted', resolved_at = COALESCE(resolved_at, now())
package/src/api/help.ts CHANGED
@@ -8,7 +8,7 @@ import { helpApiMessages } from "./help-messages";
8
8
  export type HelpRouteDependencies = {
9
9
  help?: HelpReaderFactory;
10
10
  authenticate?: MiddlewareHandler<AuthContext>;
11
- renderMarkdown?: (markdown: string) => string;
11
+ renderMarkdown?: (markdown: string, locale: string) => string;
12
12
  };
13
13
 
14
14
  export const createHelpRoutes = (dependencies: HelpRouteDependencies = {}) => {
@@ -34,7 +34,7 @@ export const createHelpRoutes = (dependencies: HelpRouteDependencies = {}) => {
34
34
  id: document.documentId,
35
35
  title: document.title,
36
36
  markdown: document.markdown,
37
- html: (dependencies.renderMarkdown ?? renderHelpMarkdown)(document.markdown),
37
+ html: (dependencies.renderMarkdown ?? renderHelpMarkdown)(document.markdown, document.locale),
38
38
  };
39
39
  return c.json(payload);
40
40
  });
@@ -2,8 +2,8 @@ import { type Context, Hono } from "hono";
2
2
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
3
3
  import { env } from "../config/env";
4
4
  import { PWA_AUTH_PATH, PWA_COOKIES, PWA_LIMITS, PWA_SCOPE, PwaClaimSchema, type PwaLaunchState, PwaRenameSchema } from "../contracts/pwa";
5
- import { type AuthContext, auth, rateLimit, v } from "../server";
6
- import { type PwaCredentials, PwaError, pwaDevices } from "../services/pwa-devices";
5
+ import { type AuthContext, auth, getLocale, rateLimit, v } from "../server";
6
+ import { type PwaCredentials, type PwaDeviceNotifier, PwaError, pwaDevices } from "../services/pwa-devices";
7
7
  import { defaultShellAvailable, handlePwaError, type PwaRouteOptions, pwaErrorResponse, pwaInvalidRequest, pwaTransport } from "./pwa";
8
8
 
9
9
  const PAIRING_COOKIE_PATH = `${PWA_AUTH_PATH}/pairings`;
@@ -58,12 +58,17 @@ export const launchTarget = (to: string | undefined): string => {
58
58
 
59
59
  const stateUrl = (state: PwaLaunchState) => `${PWA_SCOPE}?pwa=${state}`;
60
60
 
61
+ export type PwaPhoneRouteOptions = PwaRouteOptions & {
62
+ /** Sends the "new phone paired" notice; completion awaits it before the phone gets its credentials. */
63
+ notify: PwaDeviceNotifier;
64
+ };
65
+
61
66
  /**
62
67
  * Phone side of the mobile app (preview), mounted by Core at `/pwa/_auth` before its page
63
68
  * catch-all. The device key and the completion secret use cookie paths below `/pwa/_auth`,
64
69
  * which only Core serves; other applications see only the 24-hour app session.
65
70
  */
66
- export const createPwaPhoneRoutes = (options: PwaRouteOptions = {}) => {
71
+ export const createPwaPhoneRoutes = (options: PwaPhoneRouteOptions) => {
67
72
  const service = options.service ?? pwaDevices;
68
73
  const shellAvailable = options.shellAvailable ?? defaultShellAvailable;
69
74
  const limit = rateLimit({ keyBy: "ip" });
@@ -102,6 +107,8 @@ export const createPwaPhoneRoutes = (options: PwaRouteOptions = {}) => {
102
107
  deviceKey: getCookie(c, PWA_COOKIES.device),
103
108
  appSession: await appSession(c),
104
109
  webUserId: web?.user.id ?? null,
110
+ locale: getLocale(c),
111
+ notify: options.notify,
105
112
  });
106
113
  if (result.state === "waiting") return c.json(result, 202);
107
114
  if (result.credentials) setAppCredentials(c, result.credentials);
@@ -85,6 +85,40 @@ export const SearchResponseSchema = z.object({
85
85
  failedApps: z.array(z.string()).optional(),
86
86
  });
87
87
 
88
+ /** Media type a client sends in `Accept` to receive one NDJSON line per app as soon as it finishes. */
89
+ export const SEARCH_STREAM_CONTENT_TYPE = "application/x-ndjson";
90
+
91
+ export const SearchProviderStatusSchema = z.enum(["ok", "empty", "timeout", "error"]);
92
+
93
+ /**
94
+ * One line of a streamed search: `start` names the apps that search, one `provider` line follows per app in the
95
+ * order the apps finish, and `done` closes the search. Apps without a line before `done` do not exist: every app
96
+ * named in `start` gets exactly one line.
97
+ */
98
+ export const SearchStreamLineSchema = z.discriminatedUnion("type", [
99
+ z.object({
100
+ type: z.literal("start"),
101
+ query: z.string(),
102
+ apps: z.array(SearchAppSchema),
103
+ providers: z.array(z.string()),
104
+ unsupportedTags: z.array(z.string()).optional(),
105
+ }),
106
+ z.object({
107
+ type: z.literal("provider"),
108
+ provider: z.string(),
109
+ status: SearchProviderStatusSchema,
110
+ results: z.array(SearchItemSchema),
111
+ ms: z.number().int().nonnegative(),
112
+ }),
113
+ z.object({
114
+ type: z.literal("done"),
115
+ status: z.enum(["complete", "partial"]),
116
+ count: z.number().int().nonnegative(),
117
+ }),
118
+ ]);
119
+
88
120
  export type SearchApp = z.infer<typeof SearchAppSchema>;
89
121
  export type SearchItem = z.infer<typeof SearchItemSchema>;
90
122
  export type SearchResponse = z.infer<typeof SearchResponseSchema>;
123
+ export type SearchProviderStatus = z.infer<typeof SearchProviderStatusSchema>;
124
+ export type SearchStreamLine = z.infer<typeof SearchStreamLineSchema>;
package/src/api/search.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Hono, type MiddlewareHandler } from "hono";
2
- import { describeRoute } from "hono-openapi";
2
+ import { describeRoute, resolver } from "hono-openapi";
3
3
  import { readBoundedJson } from "../_internal/bounded-json";
4
4
  import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
5
5
  import { listCapabilities } from "../_internal/registry";
@@ -18,14 +18,24 @@ import { searchInvocationOperation } from "../services/identity/invocation-opera
18
18
  import { normalizeInvocationRequestId, signInvocationToken } from "../services/identity/invocation-token";
19
19
  import { withActiveIdentitySigner } from "../services/identity/key-ring";
20
20
  import { LOCALE_HEADER } from "../shared/locale";
21
- import { type SearchItem, SearchItemSchema, SearchQuerySchema, SearchResponseSchema } from "./search/schemas";
21
+ import {
22
+ SEARCH_STREAM_CONTENT_TYPE,
23
+ type SearchItem,
24
+ SearchItemSchema,
25
+ type SearchProviderStatus,
26
+ SearchQuerySchema,
27
+ type SearchResponse,
28
+ SearchResponseSchema,
29
+ type SearchStreamLine,
30
+ SearchStreamLineSchema,
31
+ } from "./search/schemas";
22
32
 
23
33
  const log = logger("search");
24
34
  const SearchProviderResultSchema = capabilityResultSchema(UniversalSearchDataSchema);
25
35
 
26
36
  /**
27
- * Maximum items returned to the client after merging across providers.
28
- * The frontend has no further limit — this caps the rendered list.
37
+ * Maximum items of the merged JSON response. A streamed search has no merged
38
+ * list: each app's line carries at most that app's provider limit.
29
39
  */
30
40
  const GLOBAL_RESULT_LIMIT = 30;
31
41
  const PROVIDER_CONCURRENCY = 8;
@@ -122,8 +132,25 @@ const startBounded = <T, R>(
122
132
  return deferred.map((entry) => entry.promise);
123
133
  };
124
134
 
125
- const settleBounded = async <T, R>(items: readonly T[], concurrency: number, run: (item: T, index: number) => Promise<R>) =>
126
- Promise.all(startBounded(items, concurrency, run));
135
+ /** Highest app-provided priority first, then by title. */
136
+ const rankItems = (items: SearchItem[]) => items.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.title.localeCompare(b.title));
137
+
138
+ /** The browser asks for a stream explicitly; every other client keeps the merged JSON response. */
139
+ const acceptsStream = (accept: string | undefined) =>
140
+ accept?.split(",").some((type) => type.split(";")[0]?.trim().toLowerCase() === SEARCH_STREAM_CONTENT_TYPE) ?? false;
141
+
142
+ const streamHeaders = {
143
+ "content-type": `${SEARCH_STREAM_CONTENT_TYPE}; charset=utf-8`,
144
+ // Each line must reach the browser when it is written: no cache, no transformation, no proxy buffering.
145
+ "cache-control": "no-store, no-transform",
146
+ "x-accel-buffering": "no",
147
+ } as const;
148
+
149
+ const encoder = new TextEncoder();
150
+ const lineText = (line: SearchStreamLine) => `${JSON.stringify(line)}\n`;
151
+ const encodeLine = (line: SearchStreamLine) => encoder.encode(lineText(line));
152
+
153
+ type AppOutcome = { appId: string; status: SearchProviderStatus; items: SearchItem[]; ms: number };
127
154
 
128
155
  const waitWithin = <T>(value: Promise<T>, signal: AbortSignal): Promise<T> =>
129
156
  new Promise<T>((resolve, reject) => {
@@ -160,7 +187,14 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
160
187
  description: "Searches across app providers discovered via the service registry with optional tag filters.",
161
188
  ...requiresAuth,
162
189
  responses: {
163
- 200: jsonResponse(SearchResponseSchema, "Merged search results"),
190
+ 200: {
191
+ description:
192
+ "Merged search results. With `Accept: application/x-ndjson`, one JSON line per app in the order the apps finish instead.",
193
+ content: {
194
+ "application/json": { schema: resolver(SearchResponseSchema) },
195
+ [SEARCH_STREAM_CONTENT_TYPE]: { schema: resolver(SearchStreamLineSchema) },
196
+ },
197
+ },
164
198
  400: jsonResponse(ErrorResponseSchema, "Invalid query"),
165
199
  401: jsonResponse(ErrorResponseSchema, "Authentication required"),
166
200
  403: jsonResponse(ErrorResponseSchema, "User-backed actor required"),
@@ -172,6 +206,7 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
172
206
  expectUserBackedActor(c);
173
207
 
174
208
  const query = c.req.valid("query");
209
+ const stream = acceptsStream(c.req.header("accept"));
175
210
  const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
176
211
  const invocationAuthority = invocationAuthorityFromRequest(auth.getAuthority(c));
177
212
  let entries: CapabilityRegistryEntry[];
@@ -231,18 +266,24 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
231
266
  const active =
232
267
  query.tag.length === 0 ? appProviders : appProviders.filter((provider) => provider.tags.some((tag) => query.tag.includes(tag)));
233
268
 
269
+ // Answers without a provider call: the catalog alone, or only unsupported tags.
270
+ const answerWithoutProviders = (body: SearchResponse) =>
271
+ stream
272
+ ? new Response(
273
+ [
274
+ lineText({ type: "start", query: body.query, apps, providers: [], ...(body.unsupportedTags ? { unsupportedTags } : {}) }),
275
+ lineText({ type: "done", status: "complete", count: 0 }),
276
+ ].join(""),
277
+ { headers: streamHeaders },
278
+ )
279
+ : c.json(body);
280
+
234
281
  if (query.q.length === 0 && query.tag.length === 0 && !query.app && !scope) {
235
- return c.json({ query: "", count: 0, items: [], apps });
282
+ return answerWithoutProviders({ query: "", count: 0, items: [], apps });
236
283
  }
237
284
 
238
285
  if (query.tag.length > 0 && active.length === 0) {
239
- return c.json({
240
- query: query.q,
241
- count: 0,
242
- items: [],
243
- apps,
244
- unsupportedTags,
245
- });
286
+ return answerWithoutProviders({ query: query.q, count: 0, items: [], apps, unsupportedTags });
246
287
  }
247
288
 
248
289
  // Single-provider queries get a larger sample for better local
@@ -252,7 +293,9 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
252
293
  const effectiveProviderLimit = active.length === 1 ? Math.min(GLOBAL_RESULT_LIMIT, query.provider_limit * 3) : query.provider_limit;
253
294
  // The same request-wide budget covers signing and provider I/O.
254
295
  const providerDeadline = AbortSignal.timeout(PROVIDER_TIMEOUT_MS);
255
- const fanoutSignal = AbortSignal.any([c.req.raw.signal, providerDeadline]);
296
+ // A client that stops reading a stream, such as the browser on the next keystroke, ends the search too.
297
+ const streamCancelled = new AbortController();
298
+ const fanoutSignal = AbortSignal.any([c.req.raw.signal, providerDeadline, streamCancelled.signal]);
256
299
  // Guard the whole issuance batch once, then reuse the prepared signer for
257
300
  // every target. This is one Postgres check per outer search request, not
258
301
  // one check per provider.
@@ -298,7 +341,8 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
298
341
  // One request-wide budget keeps latency flat as the number of apps grows.
299
342
  // Workers that have not started when the deadline expires fail fast on
300
343
  // the already-aborted signal instead of opening a fresh timeout window.
301
- const settled = await settleBounded(active, PROVIDER_CONCURRENCY, async (provider, index) => {
344
+ const fanoutStarted = performance.now();
345
+ const settled = startBounded(active, PROVIDER_CONCURRENCY, async (provider, index) => {
302
346
  // Scope tags to those this provider declared. Apps no longer need
303
347
  // their own gate — the framework guarantees they only see tags
304
348
  // they understand.
@@ -395,47 +439,71 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
395
439
  return validItems;
396
440
  });
397
441
 
398
- const items = settled.flatMap((result, index) => {
399
- if (result.status === "fulfilled") return result.value;
400
-
401
- log.warn("Search provider failed", {
402
- appId: active[index]?.appId ?? "unknown",
403
- tags: query.tag,
404
- error: result.reason instanceof Error ? result.reason.message : String(result.reason),
405
- });
406
- return [];
442
+ // One outcome per app, when all of its Queries have finished. Multiple
443
+ // focused Queries from one app must not buy that app a larger share.
444
+ const appIds = [...new Set(active.map((provider) => provider.appId))];
445
+ const outcomes = appIds.map(async (appId): Promise<AppOutcome> => {
446
+ const results = await Promise.all(active.flatMap((provider, index) => (provider.appId === appId ? [settled[index]!] : [])));
447
+ const failed = results.filter((result) => result.status === "rejected");
448
+ for (const result of failed) {
449
+ log.warn("Search provider failed", {
450
+ appId,
451
+ tags: query.tag,
452
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason),
453
+ });
454
+ }
455
+ const items = rankItems(results.flatMap((result) => (result.status === "fulfilled" ? result.value : []))).slice(
456
+ 0,
457
+ effectiveProviderLimit,
458
+ );
459
+ const status = failed.length ? (providerDeadline.aborted ? "timeout" : "error") : items.length ? "ok" : "empty";
460
+ return { appId, status, items, ms: Math.round(performance.now() - fanoutStarted) };
407
461
  });
462
+ const failure = (outcome: AppOutcome) => outcome.status === "timeout" || outcome.status === "error";
408
463
 
409
- items.sort((a, b) => {
410
- const priorityDiff = (b.priority ?? 0) - (a.priority ?? 0);
411
- if (priorityDiff !== 0) return priorityDiff;
412
- return a.title.localeCompare(b.title);
413
- });
464
+ if (stream) {
465
+ let cancelled = false;
466
+ const body = new ReadableStream<Uint8Array>({
467
+ start(controller) {
468
+ const write = (line: SearchStreamLine) => {
469
+ if (!cancelled) controller.enqueue(encodeLine(line));
470
+ };
471
+ write({ type: "start", query: query.q, apps, providers: appIds, ...(unsupportedTags.length > 0 ? { unsupportedTags } : {}) });
472
+ let count = 0;
473
+ let partial = false;
474
+ // A slow app never holds back the others: each line is written when its app finishes.
475
+ void Promise.all(
476
+ outcomes.map((pending) =>
477
+ pending.then((outcome) => {
478
+ count += outcome.items.length;
479
+ partial ||= failure(outcome);
480
+ write({ type: "provider", provider: outcome.appId, status: outcome.status, results: outcome.items, ms: outcome.ms });
481
+ }),
482
+ ),
483
+ ).then(() => {
484
+ write({ type: "done", status: partial ? "partial" : "complete", count });
485
+ if (!cancelled) controller.close();
486
+ });
487
+ },
488
+ cancel() {
489
+ cancelled = true;
490
+ streamCancelled.abort();
491
+ },
492
+ });
493
+ return new Response(body, { headers: streamHeaders });
494
+ }
414
495
 
415
- // Multiple focused Queries from one app must not buy that app a larger
416
- // share of the merged result set.
417
- const appCounts = new Map<string, number>();
418
- const appBounded = items.filter((item) => {
419
- const count = appCounts.get(item.appId) ?? 0;
420
- if (count >= effectiveProviderLimit) return false;
421
- appCounts.set(item.appId, count + 1);
422
- return true;
423
- });
424
- const sliced = appBounded.slice(0, GLOBAL_RESULT_LIMIT);
496
+ const finished = await Promise.all(outcomes);
497
+ const items = rankItems(finished.flatMap((outcome) => outcome.items)).slice(0, GLOBAL_RESULT_LIMIT);
498
+ const failedApps = finished.filter(failure).map((outcome) => outcome.appId);
425
499
 
426
500
  return c.json({
427
501
  query: query.q,
428
- count: sliced.length,
429
- items: sliced,
502
+ count: items.length,
503
+ items,
430
504
  apps,
431
505
  ...(unsupportedTags.length > 0 ? { unsupportedTags } : {}),
432
- ...(settled.some((result) => result.status === "rejected")
433
- ? {
434
- failedApps: [
435
- ...new Set(active.filter((_, index) => settled[index]?.status === "rejected").map((provider) => provider.appId)),
436
- ],
437
- }
438
- : {}),
506
+ ...(failedApps.length > 0 ? { failedApps } : {}),
439
507
  });
440
508
  },
441
509
  );
@@ -0,0 +1,11 @@
1
+ import { createGlobalSearchHost } from "../ssr/GlobalSearchDialog";
2
+ import { openCloudResourcePicker } from "./resource-picker";
3
+
4
+ /**
5
+ * Opens the real global search, or with `?picker` the resource picker, for a browser test that answers
6
+ * `/api/search` with invented, line-by-line results. `?app=<id>` narrows either one to one app.
7
+ */
8
+ const params = new URLSearchParams(location.search);
9
+ const app = params.get("app") ?? undefined;
10
+ if (params.has("picker")) void openCloudResourcePicker({ initialAppId: app });
11
+ else createGlobalSearchHost(() => []).open(app ? { scope: { appId: app, label: params.get("label") ?? app } } : {});