@k2b/cloud 0.40.0 → 0.42.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 (279) hide show
  1. package/package.json +9 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-sentences.ts +2 -1
  6. package/src/_internal/capability-timeout.ts +71 -0
  7. package/src/_internal/define-app.ts +158 -92
  8. package/src/_internal/heartbeat.ts +203 -14
  9. package/src/_internal/http-drain.ts +56 -0
  10. package/src/_internal/registry-validation.ts +3 -0
  11. package/src/_internal/registry.ts +28 -1
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/setup-lock.ts +103 -0
  14. package/src/_internal/status-preserving-ssr.ts +2 -0
  15. package/src/access/PermissionEditor.tsx +16 -17
  16. package/src/access/PrincipalPicker.tsx +45 -10
  17. package/src/access/messages.ts +8 -0
  18. package/src/access/principal-label.ts +18 -0
  19. package/src/access/ui.ts +1 -0
  20. package/src/account/EntitySearch.tsx +5 -4
  21. package/src/ai/action-review.ts +2 -1
  22. package/src/ai/app-skill-store.ts +1 -1
  23. package/src/ai/capabilities.ts +21 -3
  24. package/src/ai/capability-execution.ts +14 -12
  25. package/src/ai/chat/action-card.ts +76 -4
  26. package/src/ai/chat/action-messages.ts +14 -0
  27. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  28. package/src/ai/chat/action-question.tsx +214 -23
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/tool-action-words.ts +31 -0
  31. package/src/ai/chat/tool-action.tsx +1 -26
  32. package/src/ai/chat-tasks.ts +2 -1
  33. package/src/ai/client/controller.ts +150 -32
  34. package/src/ai/client/transport.ts +14 -2
  35. package/src/ai/code-capability-routes.ts +27 -23
  36. package/src/ai/code-runtime-tools.ts +14 -16
  37. package/src/ai/code-source-tools.ts +16 -18
  38. package/src/ai/executor.ts +64 -21
  39. package/src/ai/fetch-file-tool.ts +67 -76
  40. package/src/ai/firecrawl-tools.ts +15 -2
  41. package/src/ai/http.ts +1 -0
  42. package/src/ai/index.ts +1 -0
  43. package/src/ai/migrate.ts +28 -0
  44. package/src/ai/open-questions.ts +168 -0
  45. package/src/ai/projects.ts +1 -2
  46. package/src/ai/protocol.ts +2 -2
  47. package/src/ai/question-text.ts +8 -0
  48. package/src/ai/recent-resources.ts +12 -5
  49. package/src/ai/review-content.ts +77 -0
  50. package/src/ai/routes.ts +119 -35
  51. package/src/ai/runtime.ts +1 -0
  52. package/src/ai/skill-tool.ts +17 -6
  53. package/src/ai/skills-routes.ts +72 -27
  54. package/src/ai/skills.ts +139 -60
  55. package/src/ai/store.ts +184 -31
  56. package/src/ai/testing.ts +36 -0
  57. package/src/ai/tool-sentences.ts +152 -34
  58. package/src/ai/tool-wording.ts +12 -6
  59. package/src/ai/tools.ts +8 -3
  60. package/src/ai/types.ts +25 -4
  61. package/src/ai/usage.ts +2 -2
  62. package/src/api/admin-core-settings.ts +15 -2
  63. package/src/api/admin-geo.ts +54 -0
  64. package/src/api/admin-linux-identities.ts +6 -3
  65. package/src/api/admin-outgoing-mail.ts +29 -15
  66. package/src/api/app-approval-actions.ts +31 -7
  67. package/src/api/approvals-loader.ts +3 -3
  68. package/src/api/approvals.ts +51 -0
  69. package/src/api/apps.ts +4 -2
  70. package/src/api/auth/schemas.ts +3 -0
  71. package/src/api/auth.ts +54 -18
  72. package/src/api/capabilities.ts +258 -181
  73. package/src/api/capability-streams.ts +21 -13
  74. package/src/api/cli-plugins.ts +4 -1
  75. package/src/api/geo-glyphs.ts +11 -0
  76. package/src/api/geo.ts +128 -0
  77. package/src/api/help.ts +12 -4
  78. package/src/api/index.ts +5 -0
  79. package/src/api/mcp.ts +73 -41
  80. package/src/api/me-panel-consent.ts +8 -5
  81. package/src/api/me.ts +79 -3
  82. package/src/api/search.ts +21 -14
  83. package/src/api/sync-ops.ts +19 -13
  84. package/src/api/widgets.ts +42 -18
  85. package/src/approvals/Approvals.tsx +30 -66
  86. package/src/approvals/approval-card.ts +68 -0
  87. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  88. package/src/browser/CloudResourceSearch.tsx +3 -13
  89. package/src/browser/PanelLeaveNotice.tsx +18 -0
  90. package/src/browser/navigation-search.ts +78 -25
  91. package/src/browser/panel-leave.ts +69 -0
  92. package/src/browser/panel-messages.ts +12 -0
  93. package/src/browser/panel-slot.ts +173 -24
  94. package/src/browser/panel.ts +3 -0
  95. package/src/browser/resource-search-messages.ts +2 -0
  96. package/src/capabilities/client.ts +28 -1
  97. package/src/capabilities/executions.ts +22 -3
  98. package/src/capabilities/geo-client.ts +57 -0
  99. package/src/capabilities/response.ts +2 -1
  100. package/src/capabilities/review-storage.ts +24 -0
  101. package/src/capabilities/reviews.ts +17 -3
  102. package/src/capabilities/server.ts +36 -16
  103. package/src/cli/access.ts +15 -23
  104. package/src/cli/admin/linux.ts +13 -2
  105. package/src/cli/admin/outgoing-mail.ts +26 -4
  106. package/src/cli/capabilities.ts +19 -0
  107. package/src/config/env.ts +0 -6
  108. package/src/contracts/app-access.ts +48 -0
  109. package/src/contracts/app-approval.ts +3 -3
  110. package/src/contracts/app.ts +4 -1
  111. package/src/contracts/approvals.ts +2 -2
  112. package/src/contracts/avatar.ts +186 -0
  113. package/src/contracts/capabilities.ts +149 -55
  114. package/src/contracts/geo-admin.ts +127 -0
  115. package/src/contracts/geo-map.ts +19 -0
  116. package/src/contracts/geo-route.ts +168 -0
  117. package/src/contracts/geo.ts +248 -0
  118. package/src/contracts/index.ts +4 -0
  119. package/src/contracts/outgoing-mail.ts +17 -4
  120. package/src/contracts/registry.ts +2 -0
  121. package/src/contracts/shared.ts +3 -0
  122. package/src/events/app-access.ts +68 -0
  123. package/src/events/live-delivery.ts +128 -0
  124. package/src/events/live.ts +14 -130
  125. package/src/geo/CloudMap.tsx +95 -0
  126. package/src/geo/map-messages.ts +17 -0
  127. package/src/geo/ui.ts +1 -0
  128. package/src/index.ts +2 -0
  129. package/src/server/client-ip.ts +36 -0
  130. package/src/server/index.ts +3 -4
  131. package/src/server/middleware/app-access.ts +312 -0
  132. package/src/server/middleware/auth.ts +5 -3
  133. package/src/server/middleware/invocation.ts +11 -0
  134. package/src/server/middleware/rate-limit.ts +4 -4
  135. package/src/server/services/access.ts +13 -9
  136. package/src/server/services/index.ts +0 -2
  137. package/src/server/services/services.ts +0 -2
  138. package/src/services/accounts/app.ts +23 -3
  139. package/src/services/accounts/posix.ts +21 -7
  140. package/src/services/accounts/users.ts +12 -1
  141. package/src/services/allowed-network-fetch.ts +16 -10
  142. package/src/services/app-access-metrics.ts +24 -0
  143. package/src/services/app-access.ts +123 -0
  144. package/src/services/app-discovery.ts +27 -0
  145. package/src/services/approvals/content.ts +40 -0
  146. package/src/services/approvals/device.ts +49 -33
  147. package/src/services/approvals/index.ts +15 -6
  148. package/src/services/approvals/list.ts +6 -11
  149. package/src/services/approvals/question.ts +72 -0
  150. package/src/services/audit/index.ts +1 -0
  151. package/src/services/auth-flows/ipa.ts +39 -2
  152. package/src/services/auth-flows/password-reset.ts +32 -6
  153. package/src/services/downstream-budget.ts +87 -0
  154. package/src/services/geo/admin.ts +262 -0
  155. package/src/services/geo/bucket.ts +67 -0
  156. package/src/services/geo/cache.ts +57 -0
  157. package/src/services/geo/costs.ts +5 -0
  158. package/src/services/geo/errors.ts +41 -0
  159. package/src/services/geo/fixtures/README.md +17 -0
  160. package/src/services/geo/fixtures/along.json +355 -0
  161. package/src/services/geo/fixtures/autocomplete.json +218 -0
  162. package/src/services/geo/fixtures/geocode.json +129 -0
  163. package/src/services/geo/fixtures/places.json +355 -0
  164. package/src/services/geo/fixtures/reverse.json +61 -0
  165. package/src/services/geo/fixtures/routing.json +57 -0
  166. package/src/services/geo/geoapify-route.ts +93 -0
  167. package/src/services/geo/geoapify.ts +281 -0
  168. package/src/services/geo/ledger.ts +122 -0
  169. package/src/services/geo/map.ts +160 -0
  170. package/src/services/geo/migrate.ts +25 -0
  171. package/src/services/geo/polyline.ts +184 -0
  172. package/src/services/geo/route-chain.ts +145 -0
  173. package/src/services/geo/route-costs.ts +9 -0
  174. package/src/services/geo/route-geometry.ts +39 -0
  175. package/src/services/geo/route-plan.ts +172 -0
  176. package/src/services/geo/route-service.ts +433 -0
  177. package/src/services/geo/service.ts +300 -0
  178. package/src/services/geo/settings.ts +49 -0
  179. package/src/services/help/index.ts +16 -7
  180. package/src/services/help/types.ts +6 -1
  181. package/src/services/identity/invocation-actor.ts +20 -2
  182. package/src/services/identity/issue-invocation.ts +67 -0
  183. package/src/services/identity/key-ring.ts +5 -3
  184. package/src/services/identity/runtime-config.ts +23 -0
  185. package/src/services/index.ts +11 -0
  186. package/src/services/mandates/index.ts +40 -3
  187. package/src/services/network-security.ts +6 -3
  188. package/src/services/network-transport.ts +99 -0
  189. package/src/services/notifications/catalog.ts +3 -2
  190. package/src/services/notifications/runtime.ts +1 -0
  191. package/src/services/notifications/web-push-transport.ts +45 -38
  192. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  193. package/src/services/outgoing-mail/index.ts +55 -1
  194. package/src/services/outgoing-mail/messages.ts +82 -3
  195. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  196. package/src/services/outgoing-mail/retention.ts +13 -1
  197. package/src/services/outgoing-mail/store.ts +36 -6
  198. package/src/services/outgoing-mail/test-send.ts +34 -4
  199. package/src/services/outgoing-mail/transport.ts +1 -1
  200. package/src/services/pdf/gotenberg.ts +11 -38
  201. package/src/services/pdf/markdown.ts +160 -2
  202. package/src/services/pdf/template-preview.ts +1 -1
  203. package/src/services/public-http.ts +28 -40
  204. package/src/services/session/index.ts +203 -29
  205. package/src/services/settings/app.ts +4 -1
  206. package/src/services/settings/core-settings.ts +88 -0
  207. package/src/services/settings/defaults.ts +8 -2
  208. package/src/services/settings/errors.ts +11 -0
  209. package/src/services/settings/geo-budget.ts +40 -0
  210. package/src/services/settings/index.ts +4 -3
  211. package/src/services/settings/store.ts +25 -17
  212. package/src/services/weather/geo.ts +29 -111
  213. package/src/services/weather/index.ts +4 -3
  214. package/src/services/weather/locations.ts +8 -5
  215. package/src/services/weather/migrate.ts +1 -0
  216. package/src/services/weather/settings.ts +0 -16
  217. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  218. package/src/shared/avatar-art.ts +400 -0
  219. package/src/shared/index.ts +2 -0
  220. package/src/shared/network-address.ts +22 -3
  221. package/src/shared/template-rendering.ts +36 -4
  222. package/src/shared-text/documents.ts +1 -1
  223. package/src/shared-text/index.ts +2 -0
  224. package/src/shared-text/log.ts +51 -3
  225. package/src/shared-text/snapshots.ts +340 -0
  226. package/src/ssr/AdminLayout.tsx +4 -2
  227. package/src/ssr/CloudShell.island.tsx +6 -0
  228. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  229. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  230. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  231. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  232. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  233. package/src/ssr/HelpPanelView.island.tsx +27 -0
  234. package/src/ssr/Layout.tsx +65 -15
  235. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  236. package/src/ssr/LayoutHelp.tsx +185 -542
  237. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  238. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  239. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  240. package/src/ssr/PanelRuntime.island.tsx +11 -7
  241. package/src/ssr/PwaLayout.tsx +2 -2
  242. package/src/ssr/RailApps.tsx +6 -0
  243. package/src/ssr/ShellRuntime.island.tsx +23 -7
  244. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  245. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  246. package/src/ssr/admin-navigation.ts +1 -0
  247. package/src/ssr/app-badges.ts +6 -1
  248. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  249. package/src/ssr/help.ts +7 -5
  250. package/src/ssr/index.ts +2 -1
  251. package/src/ssr/islands/index.ts +3 -4
  252. package/src/ssr/layout-context.ts +2 -0
  253. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  254. package/src/ssr/layout-help-panel-document.ts +67 -0
  255. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  256. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  257. package/src/ssr/layout-help-panel-modules.ts +7 -0
  258. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  259. package/src/ssr/layout-help-panel-search.tsx +69 -0
  260. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  261. package/src/ssr/layout-help-panel-state.ts +141 -0
  262. package/src/ssr/layout-help-panel-types.ts +19 -0
  263. package/src/ssr/layout-help-views.tsx +484 -0
  264. package/src/ssr/mobile-menu-history.ts +4 -0
  265. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  266. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  267. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  268. package/src/ssr/page-swap.ts +28 -0
  269. package/src/ssr/page-trail-runtime.ts +32 -13
  270. package/src/ssr/panel-consent.ts +2 -2
  271. package/src/ssr/panel-idle.ts +9 -0
  272. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  273. package/src/ssr/platform-messages.ts +8 -0
  274. package/src/ssr/runtime.ts +17 -0
  275. package/src/ssr/shell-data.tsx +10 -2
  276. package/src/styles/approvals.css +6 -0
  277. package/src/styles/effects.css +15 -0
  278. package/src/styles/global.css +70 -6
  279. package/src/server/services/geo.ts +0 -215
@@ -0,0 +1,340 @@
1
+ import { type Job, type JobConfig, RetentionGapError, type Topic, type Worker } from "@k2b/sync";
2
+ import * as Y from "yjs";
3
+ import { logger } from "../services/logging";
4
+ import { compareStreamCursor } from "./cursor";
5
+ import { createDoc } from "./documents";
6
+ import { applySharedTextEvent, MalformedSharedTextEventError, SHARED_TEXT_NAME, sharedTextNodeId } from "./events";
7
+ import type { SharedTextContributor, SharedTextSaveInput, SharedTextStoredState, SharedTextSyncEvent } from "./types";
8
+
9
+ const ACK_WAIT_MS = 120_000;
10
+ const RECONCILE_LIMIT = 5_000;
11
+ // Each lookup is a short-lived tenant replay stopped after its first event; 16 is far below 5,000 latestCursor lookups per scan page.
12
+ // In steady state, only documents edited within the last snapshot delay or stalled documents remain behind.
13
+ const BACKLOG_LOOKUPS = 16;
14
+
15
+ export const sharedTextSnapshotJobConfig = (
16
+ config: Pick<JobConfig, "id" | "deadLetterRetention" | "terminalRetentionMs" | "dedupeWindowMs"> & {
17
+ retention: NonNullable<JobConfig["retention"]>;
18
+ },
19
+ ) =>
20
+ ({
21
+ id: config.id,
22
+ delivery: { ackWaitMs: ACK_WAIT_MS, maxAttempts: 20, backoffMs: [5_000] },
23
+ // Ordered by document across 8 partitions. Each process reconstructs one
24
+ // document at a time, keeping its memory budget fixed while a fleet can
25
+ // save up to 8 documents concurrently. Changing the count is a resource change.
26
+ ordering: { mode: "partitioned" as const, partitions: 8 },
27
+ retention: config.retention,
28
+ terminalRetentionMs: config.terminalRetentionMs ?? 30 * 24 * 60 * 60 * 1000,
29
+ // 256 dead letters at Sync's 128 KiB payload limit plus its 4 KiB of
30
+ // dead-letter headroom, the depth Sync gives a job by default. A snapshot is
31
+ // dead-lettered only for incomplete or malformed history or after 20 failed
32
+ // attempts; each one carries a document ID and a cursor of a few hundred bytes.
33
+ deadLetterRetention: config.deadLetterRetention ?? { maxBytes: 256 * (128 * 1024 + 4096) },
34
+ ...(config.dedupeWindowMs === undefined ? {} : { dedupeWindowMs: config.dedupeWindowMs }),
35
+ }) satisfies JobConfig;
36
+
37
+ export type SharedTextSnapshotRequest = { docId: string; targetCursor: string; reason: string };
38
+ export type SharedTextSnapshotStore = {
39
+ /** Stable keyset order by docId; include only documents that take snapshots. */
40
+ list(options: { limit: number; after?: string; signal?: AbortSignal }): Promise<Array<{ docId: string; cursor: string | null }>>;
41
+ /** Anchor legacy cursors to this log before returning. */
42
+ load(docId: string, options: { signal?: AbortSignal }): Promise<SharedTextStoredState | null>;
43
+ /**
44
+ * Atomically fence restoreRevision and non-newer cursors. Apply projections
45
+ * only when the write was accepted, never when overtaken. Return normally
46
+ * when covered, gone or locked; throw after a restore if not already covered
47
+ * so the job retries from the current snapshot.
48
+ */
49
+ save(docId: string, input: SharedTextSaveInput): Promise<void>;
50
+ /** Preserve every valid retained update, including pending structs/deletes;
51
+ * save at a captured head with history incomplete. Lost bytes remain lost. */
52
+ recover(docId: string, cause: RetentionGapError | MalformedSharedTextEventError, options: { signal?: AbortSignal }): Promise<unknown>;
53
+ /** Move an idle cursor only while from is still stored and no newer event exists. */
54
+ advance(docId: string, input: { from: string; to: string; latest: string | null }): Promise<boolean>;
55
+ };
56
+ export type SharedTextSnapshotBacklog = {
57
+ log: string;
58
+ measuredAt: Date;
59
+ documents: number;
60
+ oldest: null | {
61
+ docId: string;
62
+ sequence: number;
63
+ ageMs: number | null;
64
+ sequenceDistance: number;
65
+ historyGap?: boolean;
66
+ /** The lookup budget left documents that may contain an older update. */
67
+ partial?: true;
68
+ };
69
+ };
70
+ type SnapshotLog = {
71
+ id: string;
72
+ topic(): Pick<Topic<SharedTextSyncEvent>, "head" | "latestCursor" | "cursorSequence" | "cursorAt" | "replay">;
73
+ firstSequence(): Promise<number>;
74
+ };
75
+ type SnapshotOptions = { log: SnapshotLog; store: SharedTextSnapshotStore; logger?: ReturnType<typeof logger> };
76
+ type InputCodec<Input> = { encode(request: SharedTextSnapshotRequest): Input; decode(input: Input): SharedTextSnapshotRequest };
77
+ type SnapshotJob<Input> = () => Pick<Job<Input>, "submit" | "process">;
78
+
79
+ export function snapshots(
80
+ options: SnapshotOptions & { job: SnapshotJob<SharedTextSnapshotRequest>; input?: undefined },
81
+ ): ReturnType<typeof createSnapshots>;
82
+ export function snapshots<Input>(
83
+ options: SnapshotOptions & { job: SnapshotJob<Input>; input: InputCodec<Input> },
84
+ ): ReturnType<typeof createSnapshots>;
85
+ export function snapshots<Input>(
86
+ options: SnapshotOptions &
87
+ ({ job: SnapshotJob<SharedTextSnapshotRequest>; input?: undefined } | { job: SnapshotJob<Input>; input: InputCodec<Input> }),
88
+ ) {
89
+ if (options.input) return createSnapshots(options, options.job, options.input);
90
+ return createSnapshots(options, options.job, { encode: (request) => request, decode: (input) => input });
91
+ }
92
+
93
+ const createSnapshots = <Input>(
94
+ { log, store, logger: suppliedLogger }: SnapshotOptions,
95
+ job: SnapshotJob<Input>,
96
+ input: InputCodec<Input>,
97
+ ) => {
98
+ const output = suppliedLogger ?? logger("shared-text-snapshots");
99
+ let worker: Promise<Worker> | null = null;
100
+ let lastBacklog: SharedTextSnapshotBacklog | null = null;
101
+ const queue = async (request: SharedTextSnapshotRequest): Promise<void> => {
102
+ const sequence = log.topic().cursorSequence(request.targetCursor);
103
+ // A final unload at a newer cursor must not coalesce with an already settling snapshot.
104
+ await job().submit({ key: `${request.docId}:${sequence}`, coalesce: true, orderingKey: request.docId, input: input.encode(request) });
105
+ };
106
+ const start = async (): Promise<void> => {
107
+ if (worker) {
108
+ await worker;
109
+ return;
110
+ }
111
+ worker = job().process(
112
+ {
113
+ concurrency: 1,
114
+ onError: async ({ error, context }) => {
115
+ output.error("Snapshot job failed; missing or malformed history requires attention", {
116
+ docId: input.decode(context.input).docId,
117
+ error: error instanceof Error ? error.message : String(error),
118
+ attempt: context.attempt,
119
+ });
120
+ if (error instanceof RetentionGapError)
121
+ return {
122
+ action: "dead_letter",
123
+ reason: "Document history is incomplete; retained updates and pending dependencies were recovered",
124
+ };
125
+ if (error instanceof MalformedSharedTextEventError)
126
+ return {
127
+ action: "dead_letter",
128
+ reason: `Retained event cannot be applied; valid updates and pending dependencies were recovered: ${error.message}`,
129
+ };
130
+ return { action: "retry" };
131
+ },
132
+ },
133
+ async (context) => {
134
+ const request = input.decode(context.input);
135
+ const { docId } = request;
136
+ let heartbeatError: unknown;
137
+ const timer = setInterval(() => {
138
+ void context.heartbeat().catch((error) => {
139
+ heartbeatError = error;
140
+ });
141
+ }, 15_000);
142
+ try {
143
+ const topic = log.topic();
144
+ const initial = await store.load(docId, { signal: context.signal });
145
+ // Head BEFORE the document's latest event: once it is applied up to
146
+ // that event, the head is a valid (fresher) cursor for the snapshot.
147
+ const head = await topic.head();
148
+ // Cover queued older cursors in one replay; later jobs skip already saved state.
149
+ const target = await topic.latestCursor({ tenantId: docId });
150
+ if (!target) {
151
+ output.warn("Snapshot skipped: no retained events for the note", { docId, requestedCursor: request.targetCursor });
152
+ return;
153
+ }
154
+ const requestedAt = Date.now();
155
+ if (!initial || (initial.cursor && compareStreamCursor(initial.cursor, target) >= 0)) return;
156
+ const doc = createDoc(initial);
157
+ const contributors = new Map<string, SharedTextContributor>();
158
+ try {
159
+ let reachedTarget = false;
160
+ try {
161
+ for await (const event of topic.replay({
162
+ tenantId: docId,
163
+ after: initial.cursor ?? topic.cursorAt(0),
164
+ until: target,
165
+ signal: context.signal,
166
+ })) {
167
+ applySharedTextEvent(doc, event, docId);
168
+ if (event.data.actor)
169
+ contributors.set(`${event.data.actor.kind}:${event.data.actor.id}`, {
170
+ ...event.data.actor,
171
+ lastContributedAt: event.publishedAt,
172
+ });
173
+ reachedTarget = event.cursor === target;
174
+ }
175
+ } catch (error) {
176
+ // Preserve all valid retained updates and unresolved dependencies;
177
+ // record the missing/invalid history once in the DLQ after recovery.
178
+ if (error instanceof RetentionGapError || error instanceof MalformedSharedTextEventError)
179
+ await store.recover(docId, error, { signal: context.signal });
180
+ throw error;
181
+ }
182
+ context.signal.throwIfAborted();
183
+ if (heartbeatError) throw heartbeatError;
184
+ if (!reachedTarget) throw new Error("Snapshot replay did not reach its target");
185
+ await context.heartbeat();
186
+ context.signal.throwIfAborted();
187
+ await store.save(docId, {
188
+ state: Y.encodeStateAsUpdate(doc),
189
+ text: doc.getText(SHARED_TEXT_NAME).toString(),
190
+ cursor: compareStreamCursor(head, target) > 0 ? head : target,
191
+ restoreRevision: initial.restoreRevision,
192
+ requestedAt,
193
+ actor: null,
194
+ contributors: [...contributors.values()],
195
+ });
196
+ } finally {
197
+ doc.destroy();
198
+ }
199
+ } finally {
200
+ clearInterval(timer);
201
+ }
202
+ },
203
+ );
204
+ try {
205
+ await worker;
206
+ output.debug("Snapshot worker started", { nodeId: sharedTextNodeId });
207
+ } catch (error) {
208
+ worker = null;
209
+ throw error;
210
+ }
211
+ };
212
+ const stop = async (): Promise<void> => {
213
+ const active = await worker;
214
+ worker = null;
215
+ active?.stop();
216
+ await active?.drain({ timeoutMs: ACK_WAIT_MS });
217
+ };
218
+ const reconcile = async (config: { signal?: AbortSignal; heartbeat?: () => Promise<void> } = {}) => {
219
+ const topic = log.topic();
220
+ let after: string | undefined;
221
+ let checked = 0;
222
+ let queued = 0;
223
+ let advanced = 0;
224
+ let lastHeadSeq = 0;
225
+ const backlogCandidates: Array<{ docId: string; stored: string; storedSeq: number; latest: string }> = [];
226
+ let unretainedMinSeq: number | null = null;
227
+ while (true) {
228
+ config.signal?.throwIfAborted();
229
+ const candidates = await store.list({ limit: RECONCILE_LIMIT, after, signal: config.signal });
230
+ // Head BEFORE each document's latest event: an idle document is covered up to it.
231
+ const head = await topic.head();
232
+ const headSeq = topic.cursorSequence(head);
233
+ lastHeadSeq = headSeq;
234
+ const firstSeq = await log.firstSequence();
235
+ const refreshBelow = firstSeq + Math.floor((headSeq - firstSeq) / 2);
236
+ for (const candidate of candidates) {
237
+ config.signal?.throwIfAborted();
238
+ if (checked % 200 === 0) await config.heartbeat?.();
239
+ checked++;
240
+ let stored = candidate.cursor;
241
+ let shared = false;
242
+ if (stored) {
243
+ try {
244
+ topic.cursorSequence(stored);
245
+ shared = true;
246
+ } catch {
247
+ /* Legacy cursor: load anchors it. */
248
+ }
249
+ }
250
+ if (!shared) {
251
+ const loaded = await store.load(candidate.docId, { signal: config.signal });
252
+ if (!loaded) continue;
253
+ stored = loaded.cursor;
254
+ }
255
+ const latest = await topic.latestCursor({ tenantId: candidate.docId });
256
+ // A document without a stored cursor replays from the log's start, like the job does.
257
+ if (latest && (!stored || compareStreamCursor(latest, stored) > 0)) {
258
+ await queue({ docId: candidate.docId, targetCursor: latest, reason: "reconcile" });
259
+ queued++;
260
+ const from = stored ?? topic.cursorAt(0);
261
+ const storedSeq = topic.cursorSequence(from);
262
+ const index = backlogCandidates.findIndex((entry) => entry.storedSeq > storedSeq);
263
+ if (backlogCandidates.length === BACKLOG_LOOKUPS && index === -1) {
264
+ unretainedMinSeq = Math.min(unretainedMinSeq ?? storedSeq, storedSeq);
265
+ } else {
266
+ if (backlogCandidates.length === BACKLOG_LOOKUPS) {
267
+ const evicted = backlogCandidates.pop()!;
268
+ unretainedMinSeq = Math.min(unretainedMinSeq ?? evicted.storedSeq, evicted.storedSeq);
269
+ }
270
+ backlogCandidates.splice(index === -1 ? backlogCandidates.length : index, 0, {
271
+ docId: candidate.docId,
272
+ stored: from,
273
+ storedSeq,
274
+ latest,
275
+ });
276
+ }
277
+ continue;
278
+ }
279
+ if (!stored) continue;
280
+ if (topic.cursorSequence(stored) < refreshBelow && (await store.advance(candidate.docId, { from: stored, to: head, latest })))
281
+ advanced++;
282
+ }
283
+ if (candidates.length < RECONCILE_LIMIT) break;
284
+ after = candidates.at(-1)!.docId;
285
+ }
286
+ const report: SharedTextSnapshotBacklog = { log: log.id, measuredAt: new Date(), documents: queued, oldest: null };
287
+ for (const [index, candidate] of backlogCandidates.entries()) {
288
+ if (report.oldest && candidate.storedSeq + 1 >= report.oldest.sequence) break;
289
+ // The first lookup runs in the scan's last heartbeat interval, each later one
290
+ // renews it. A lost lease ends the run; only the lookup itself fails softly.
291
+ if (index > 0) await config.heartbeat?.();
292
+ const lookup = new AbortController();
293
+ const signal = config.signal ? AbortSignal.any([lookup.signal, config.signal]) : lookup.signal;
294
+ try {
295
+ let found = false;
296
+ for await (const event of topic.replay({ tenantId: candidate.docId, after: candidate.stored, until: candidate.latest, signal })) {
297
+ found = true;
298
+ if (!report.oldest || event.sequence < report.oldest.sequence)
299
+ report.oldest = {
300
+ docId: candidate.docId,
301
+ sequence: event.sequence,
302
+ ageMs: report.measuredAt.getTime() - event.publishedAt.getTime(),
303
+ sequenceDistance: lastHeadSeq - event.sequence,
304
+ };
305
+ break;
306
+ }
307
+ if (!found) throw new Error("Snapshot backlog replay returned no retained event");
308
+ } catch (error) {
309
+ config.signal?.throwIfAborted();
310
+ output.warn("Could not read shared text snapshot backlog history", {
311
+ docId: candidate.docId,
312
+ error: error instanceof Error ? error.message : String(error),
313
+ });
314
+ report.oldest = {
315
+ docId: candidate.docId,
316
+ sequence: candidate.storedSeq + 1,
317
+ ageMs: null,
318
+ sequenceDistance: lastHeadSeq - candidate.storedSeq,
319
+ historyGap: error instanceof RetentionGapError,
320
+ };
321
+ break;
322
+ } finally {
323
+ lookup.abort();
324
+ }
325
+ if (
326
+ candidate === backlogCandidates.at(-1) &&
327
+ report.oldest &&
328
+ unretainedMinSeq !== null &&
329
+ unretainedMinSeq + 1 < report.oldest.sequence
330
+ )
331
+ report.oldest.partial = true;
332
+ }
333
+ config.signal?.throwIfAborted();
334
+ output.info("Snapshot reconcile finished", { checked, queued, advanced });
335
+ output.info("Shared text snapshot backlog", report);
336
+ lastBacklog = report;
337
+ return { checked, queued, advanced };
338
+ };
339
+ return { queue, start, stop, reconcile, backlog: () => lastBacklog };
340
+ };
@@ -3,14 +3,16 @@ import type { JSX } from "solid-js/jsx-runtime";
3
3
  import type { PanelConsentSnapshot } from "../contracts/panel-context";
4
4
  import type { RailSnapshot } from "../contracts/rail-preferences";
5
5
  import { getLocale } from "../server/locale";
6
+ import type { AuthContext } from "../server/middleware/auth";
6
7
  import type { LayoutAnnouncementsState } from "../server/middleware/settings";
7
8
  import AdminSidebar from "./AdminSidebar";
8
9
  import Layout from "./Layout";
9
10
  import { platformMessages } from "./platform-messages";
10
- import { getLocalizedRuntimeContext, type RuntimeContext } from "./runtime";
11
+ import { getDisplayRuntimeContext, type RuntimeContext } from "./runtime";
11
12
 
12
13
  type Breadcrumb = { title: string; href?: string };
13
14
  type AdminLayoutContext = {
15
+ get(key: "actor"): AuthContext["Variables"]["actor"] | undefined;
14
16
  get(key: "panelConsent"): PanelConsentSnapshot | undefined;
15
17
  get(key: "railPreferences"): RailSnapshot | undefined;
16
18
  get(key: "user"): any;
@@ -34,7 +36,7 @@ export default function AdminLayout(props: Props) {
34
36
  const t = platformMessages.resolve([locale]).t;
35
37
  const url = new URL(c.req.raw.url);
36
38
  const currentPath = `${url.pathname}${url.search}`;
37
- const runtime = getLocalizedRuntimeContext(c);
39
+ const runtime = getDisplayRuntimeContext(c);
38
40
  // Admin is the area's root, as an app's root is: the rail and the admin sidebar lead there. So it is no level
39
41
  // above an admin page, and every admin page shows its title alone, whichever app owns it.
40
42
  const overview = title === "Overview" || title === t.overview;
@@ -57,6 +57,9 @@ function BarNavigation(props: { currentPath: string }) {
57
57
  syncLocation();
58
58
  window.addEventListener(RAIL_PREFERENCES_EVENT, sync);
59
59
  window.addEventListener("popstate", syncLocation);
60
+ window.addEventListener("ssr:nav-popstate", syncLocation);
61
+ window.addEventListener("ssr:nav-swapped", sync);
62
+ window.addEventListener("ssr:nav-swapped", syncLocation);
60
63
  window.addEventListener("hashchange", syncLocation);
61
64
  // In-page navigation (`@k2b/ssr/nav`) commits with pushState or replaceState, which only the Navigation API
62
65
  // reports; where a browser lacks it, the bar follows at the next page load.
@@ -64,6 +67,9 @@ function BarNavigation(props: { currentPath: string }) {
64
67
  onCleanup(() => {
65
68
  window.removeEventListener(RAIL_PREFERENCES_EVENT, sync);
66
69
  window.removeEventListener("popstate", syncLocation);
70
+ window.removeEventListener("ssr:nav-popstate", syncLocation);
71
+ window.removeEventListener("ssr:nav-swapped", sync);
72
+ window.removeEventListener("ssr:nav-swapped", syncLocation);
67
73
  window.removeEventListener("hashchange", syncLocation);
68
74
  window.navigation?.removeEventListener("currententrychange", syncLocation);
69
75
  });