@k2b/cloud 0.40.0 → 0.41.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 (75) hide show
  1. package/package.json +6 -3
  2. package/src/_internal/capability-sentences.ts +2 -1
  3. package/src/_internal/define-app.ts +13 -3
  4. package/src/_internal/heartbeat.ts +203 -14
  5. package/src/_internal/registry.ts +28 -1
  6. package/src/_internal/setup-lock.ts +103 -0
  7. package/src/ai/chat/action-card.ts +69 -3
  8. package/src/ai/chat/action-question.tsx +12 -1
  9. package/src/ai/chat/action-text.ts +288 -0
  10. package/src/ai/chat/tool-action-words.ts +31 -0
  11. package/src/ai/chat/tool-action.tsx +1 -26
  12. package/src/ai/executor.ts +10 -2
  13. package/src/ai/firecrawl-tools.ts +15 -2
  14. package/src/ai/index.ts +1 -0
  15. package/src/ai/migrate.ts +15 -0
  16. package/src/ai/open-questions.ts +172 -0
  17. package/src/ai/question-text.ts +8 -0
  18. package/src/ai/routes.ts +51 -5
  19. package/src/ai/store.ts +18 -0
  20. package/src/ai/testing.ts +36 -0
  21. package/src/ai/tool-sentences.ts +152 -34
  22. package/src/ai/tool-wording.ts +12 -6
  23. package/src/ai/tools.ts +8 -3
  24. package/src/ai/types.ts +16 -2
  25. package/src/api/app-approval-actions.ts +31 -7
  26. package/src/api/auth/schemas.ts +3 -0
  27. package/src/api/auth.ts +54 -18
  28. package/src/api/mcp.ts +27 -3
  29. package/src/api/me.ts +79 -3
  30. package/src/approvals/Approvals.tsx +25 -63
  31. package/src/approvals/approval-card.ts +68 -0
  32. package/src/contracts/app-approval.ts +3 -3
  33. package/src/contracts/capabilities.ts +1 -1
  34. package/src/contracts/shared.ts +3 -0
  35. package/src/server/middleware/auth.ts +4 -2
  36. package/src/services/accounts/app.ts +23 -3
  37. package/src/services/accounts/users.ts +12 -1
  38. package/src/services/approvals/device.ts +49 -33
  39. package/src/services/approvals/index.ts +1 -0
  40. package/src/services/approvals/list.ts +1 -1
  41. package/src/services/approvals/question.ts +68 -0
  42. package/src/services/audit/index.ts +1 -0
  43. package/src/services/auth-flows/ipa.ts +39 -2
  44. package/src/services/auth-flows/password-reset.ts +32 -6
  45. package/src/services/index.ts +2 -0
  46. package/src/services/pdf/markdown.ts +160 -2
  47. package/src/services/session/index.ts +203 -29
  48. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  49. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  50. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  51. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  52. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  53. package/src/ssr/HelpPanelView.island.tsx +27 -0
  54. package/src/ssr/LayoutHelp.tsx +182 -540
  55. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  56. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  57. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  58. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  59. package/src/ssr/help.ts +4 -3
  60. package/src/ssr/index.ts +1 -0
  61. package/src/ssr/islands/index.ts +3 -4
  62. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  63. package/src/ssr/layout-help-panel-document.ts +67 -0
  64. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  65. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  66. package/src/ssr/layout-help-panel-modules.ts +7 -0
  67. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  68. package/src/ssr/layout-help-panel-search.tsx +69 -0
  69. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  70. package/src/ssr/layout-help-panel-state.ts +141 -0
  71. package/src/ssr/layout-help-panel-types.ts +19 -0
  72. package/src/ssr/layout-help-views.tsx +484 -0
  73. package/src/ssr/panel-idle.ts +9 -0
  74. package/src/styles/approvals.css +6 -0
  75. package/src/styles/effects.css +15 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -67,10 +67,12 @@
67
67
  "./ai": "./src/ai/index.ts",
68
68
  "./ai/admin": "./src/ai/admin.ts",
69
69
  "./ai/browser": "./src/ai/browser.ts",
70
+ "./ai/question-text": "./src/ai/question-text.ts",
70
71
  "./ai/live": "./src/ai/live.ts",
71
72
  "./ai/live-events": "./src/ai/live-events.ts",
72
73
  "./ai/runtime": "./src/ai/runtime.ts",
73
74
  "./ai/solid": "./src/ai/solid.ts",
75
+ "./ai/testing": "./src/ai/testing.ts",
74
76
  "./ai/tools": "./src/ai/runtime-tools.ts",
75
77
  "./ai/ui": "./src/ai/ui.tsx",
76
78
  "./account/ui": "./src/account/ui.ts",
@@ -112,8 +114,8 @@
112
114
  "@tabler/icons": "3.48.0",
113
115
  "@tailwindcss/typography": "0.5.20",
114
116
  "@k2b/nessi": "0.18.0",
115
- "@k2b/ssr": "0.18.0",
116
- "@k2b/ui": "0.30.0",
117
+ "@k2b/ssr": "0.19.0",
118
+ "@k2b/ui": "0.31.0",
117
119
  "@k2b/stdlib": "0.30.0",
118
120
  "@k2b/sync": "7.0.0",
119
121
  "@nats-io/jetstream": "3.4.0",
@@ -121,6 +123,7 @@
121
123
  "@modelcontextprotocol/sdk": "1.32.1",
122
124
  "bun-plugin-tailwind": "0.1.2",
123
125
  "hono-openapi": "1.3.3",
126
+ "entities": "8.0.0",
124
127
  "htmlparser2": "12.0.0",
125
128
  "jose": "6.2.12",
126
129
  "katex": "0.18.9",
@@ -465,7 +465,8 @@ const slotText = (slot: CapabilitySlot, context: CapabilitySlotSentenceContext,
465
465
  }
466
466
  case "person": {
467
467
  const total = slot.total ?? slot.people.length;
468
- if (mode === "button") return total === 1 ? (slot.people[0]!.name ?? slot.people[0]!.address ?? "") : t.people({ count: total });
468
+ if (mode === "button")
469
+ return total === 1 ? plain(slot.people[0]!.name ?? slot.people[0]!.address ?? "", 24, "middle") : t.people({ count: total });
469
470
  const shown = mode === "full" ? slot.people : slot.people.slice(0, 3);
470
471
  const list = shown.map((person) =>
471
472
  mode === "chip" ? plain(person.name ?? person.address ?? "", 40, "middle") : capabilityPersonText(person),
@@ -84,15 +84,16 @@ import { appRuntimeMetadata, buildMetadata } from "./build-metadata";
84
84
  import { compileCapabilities, invokeCompiledCapability, reviewCompiledCapability, serializeCapabilityProviderResult } from "./capabilities";
85
85
  import { cliPluginRoutePrefixes, createCliPluginRoutes, validateAppCliModules } from "./cli-plugins";
86
86
  import { FONT_PRELOAD_LINKS } from "./font-preloads";
87
- import { createHeartbeat } from "./heartbeat";
87
+ import { createHeartbeat, createRegistryMembership } from "./heartbeat";
88
88
  import { compileHelp } from "./help";
89
89
  import { createPageResponses } from "./page-responses";
90
90
  import { configurePostgresApplicationName } from "./postgres-application-name";
91
91
  import { getProcessSync, startProcessSync } from "./process-sync";
92
92
  import { APP_READINESS_PATH, appReadinessResponse } from "./readiness";
93
- import { appRegistry, type CapabilityRegistryRecord, capabilityRegistry } from "./registry";
93
+ import { appInstanceRegistry, appMembershipLock, appRegistry, type CapabilityRegistryRecord, capabilityRegistry } from "./registry";
94
94
  import { canonicalRoute, isOwnAppPath } from "./registry-validation";
95
95
  import { ensureRuntimeWatcher, getCurrentRuntime, registeredCoreRelease, stopRuntimeWatcher } from "./runtime-watcher";
96
+ import { runSetupExclusively } from "./setup-lock";
96
97
  import { publicFileVersion, servePublicAsset, sharedStylesheetVersion, versionedPublicHref } from "./static-assets";
97
98
  import { createStatusPreservingSsrHandler } from "./status-preserving-ssr";
98
99
  import { WEB_VITALS_ASSET_HREF } from "./web-vitals-asset";
@@ -686,9 +687,16 @@ export const defineApp = <
686
687
  cliModules: cliModules.length > 0 ? cliModules : undefined,
687
688
  };
688
689
 
690
+ // Both shared entries follow one process membership; creating it touches no Sync resources.
691
+ const membership = createRegistryMembership(meta.id, {
692
+ registry: appInstanceRegistry,
693
+ lock: appMembershipLock,
694
+ startedAt: entry.startedAt,
695
+ });
689
696
  // Heartbeat
690
697
  const heartbeat = createHeartbeat(meta.id, entry, {
691
698
  registry: appRegistry(),
699
+ membership,
692
700
  beforeWrite: async () => {
693
701
  if (compiledHelp) await registerHelp(compiledHelp.corpus);
694
702
  if (appSkills && skillsManifestHash) await registerAppSkills(meta.id, appSkills, skillsManifestHash);
@@ -717,6 +725,7 @@ export const defineApp = <
717
725
  ? createHeartbeat(meta.id, capabilityEntry, {
718
726
  key: `capabilities/${meta.id}`,
719
727
  registry: capabilityRegistry(),
728
+ membership,
720
729
  onError: (error) =>
721
730
  log.error("Capability registry heartbeat failed", {
722
731
  appId: meta.id,
@@ -997,7 +1006,8 @@ export const defineApp = <
997
1006
 
998
1007
  if (!startOpts.skipSetup && startOpts.lifecycle?.setup) {
999
1008
  log.info(`Setup: ${meta.id}`);
1000
- await startOpts.lifecycle.setup(cloudCtx);
1009
+ const setup = startOpts.lifecycle.setup;
1010
+ await runSetupExclusively(meta.id, () => setup(cloudCtx));
1001
1011
  }
1002
1012
  if (meta.id === "core") await startOutbox();
1003
1013
 
@@ -1,6 +1,8 @@
1
1
  /** Keep an app discoverable even when the ephemeral registry is recreated. */
2
2
 
3
- import { APP_REGISTRY_TTL_MS } from "./registry";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { hostname } from "node:os";
5
+ import { APP_REGISTRY_TTL_MS, type AppInstanceRecord, appInstancePrefix } from "./registry";
4
6
 
5
7
  const HEARTBEAT_INTERVAL_MS = 60_000;
6
8
  const HEARTBEAT_WRITE_TIMEOUT_MS = 10_000;
@@ -15,8 +17,171 @@ type HeartbeatRegistry<T> = {
15
17
  delete: (input: { key: string }) => Promise<unknown>;
16
18
  };
17
19
 
20
+ type SnapshotRegistry<T> = {
21
+ snapshot: (input: { prefix: string }) => Promise<{ entries: { key: string; value: T }[] }>;
22
+ };
23
+
24
+ type MembershipRegistry = HeartbeatRegistry<AppInstanceRecord> & SnapshotRegistry<AppInstanceRecord>;
25
+
26
+ type SharedEntry = {
27
+ read: () => Promise<{ value: unknown } | undefined>;
28
+ write: () => Promise<unknown>;
29
+ remove: () => Promise<unknown>;
30
+ prepare: (() => Promise<void>) | undefined;
31
+ };
32
+
33
+ const entryDigest = (value: unknown): string => createHash("sha256").update(JSON.stringify(value)).digest("base64url");
34
+
35
+ type MembershipLock = {
36
+ withLock: <T>(
37
+ input: { resource: string; ttlMs?: number; signal?: AbortSignal },
38
+ fn: (lock: { expiresAt: Date }) => Promise<T>,
39
+ ) => Promise<T | null>;
40
+ };
41
+
42
+ /** One process membership shared by its app and capability heartbeats. Getters stay lazy until use. */
43
+ export const createRegistryMembership = (
44
+ appId: string,
45
+ options: {
46
+ registry: () => MembershipRegistry;
47
+ lock: () => MembershipLock;
48
+ instanceId?: string;
49
+ hostname?: string;
50
+ startedAt?: number;
51
+ writeTimeoutMs?: number;
52
+ },
53
+ ) => {
54
+ const instanceId = options.instanceId ?? randomBytes(12).toString("base64url");
55
+ const prefix = appInstancePrefix(appId);
56
+ const key = `${prefix}${instanceId}`;
57
+ const value: AppInstanceRecord = {
58
+ appId,
59
+ instanceId,
60
+ hostname: options.hostname ?? hostname(),
61
+ startedAt: options.startedAt ?? Date.now(),
62
+ entries: {},
63
+ };
64
+ const writeTimeoutMs = options.writeTimeoutMs ?? HEARTBEAT_WRITE_TIMEOUT_MS;
65
+ if (!Number.isFinite(writeTimeoutMs) || writeTimeoutMs <= 0) throw new RangeError("Membership write timeout must be positive and finite");
66
+ let left = false;
67
+ const sharedEntries = new Map<string, SharedEntry>();
68
+
69
+ // Prepare idempotent dependent data outside the lock to preserve its bounded lease.
70
+ const prepareShared = async (sharedKey: string): Promise<void> => {
71
+ for (const [key, { prepare }] of sharedEntries) {
72
+ if (key !== sharedKey) await prepare?.();
73
+ }
74
+ };
75
+
76
+ const locked = async (write: (lock: { expiresAt: Date }) => Promise<void>): Promise<void> => {
77
+ const result = await options
78
+ .lock()
79
+ .withLock({ resource: appId, ttlMs: writeTimeoutMs, signal: AbortSignal.timeout(writeTimeoutMs) }, async (lock) => {
80
+ await write(lock);
81
+ return true;
82
+ });
83
+ if (result === null) throw new Error(`Registry membership lock unavailable for app ${appId}`);
84
+ };
85
+ const upsert = () => options.registry().upsert({ key, value });
86
+ const publishShared = async (): Promise<void> => {
87
+ if (left) return;
88
+ await upsert();
89
+ // Never mix entries from different processes: the capability catalog rejects mismatched manifests.
90
+ for (const { write } of sharedEntries.values()) {
91
+ if (left) return;
92
+ await write();
93
+ }
94
+ };
95
+
96
+ return {
97
+ join: async (sharedKey: string, sharedValue: unknown, entry: SharedEntry): Promise<void> => {
98
+ left = false;
99
+ await prepareShared(sharedKey);
100
+ await locked(async () => {
101
+ value.entries[sharedKey] = entryDigest(sharedValue);
102
+ sharedEntries.set(sharedKey, entry);
103
+ await publishShared();
104
+ });
105
+ },
106
+ publish: async (sharedKey: string): Promise<void> => {
107
+ if (left) return;
108
+ await prepareShared(sharedKey);
109
+ await locked(async () => {
110
+ if (left) return;
111
+ await publishShared();
112
+ });
113
+ },
114
+ adopt: async (sharedKey: string): Promise<void> => {
115
+ if (left) return;
116
+ // A live process started after this one covers stored state when all read keys it declares match.
117
+ // Ignore keys it never registered, but heal split pairs when both keys are declared.
118
+ // Start order is total (instance id breaks ties), so two live replicas never take turns.
119
+ const startedLater = (other: AppInstanceRecord): boolean =>
120
+ other.startedAt > value.startedAt || (other.startedAt === value.startedAt && other.instanceId > value.instanceId);
121
+ const needsRepublish = async (): Promise<boolean> => {
122
+ const stored = await Promise.all(
123
+ [...sharedEntries].map(async ([key, { read }]) => {
124
+ const entry = await read();
125
+ return { key, digest: entry ? entryDigest(entry.value) : undefined };
126
+ }),
127
+ );
128
+ if (stored.some(({ digest }) => digest === undefined)) return false;
129
+ if (stored.every(({ key, digest }) => digest === value.entries[key])) return false;
130
+ const snapshot = await options.registry().snapshot({ prefix });
131
+ return !snapshot.entries.some(
132
+ ({ value }) =>
133
+ startedLater(value) &&
134
+ stored.some(({ key }) => key in value.entries) &&
135
+ stored.every(({ key, digest }) => !(key in value.entries) || value.entries[key] === digest),
136
+ );
137
+ };
138
+ if (!(await needsRepublish())) return;
139
+ await prepareShared(sharedKey);
140
+ await locked(async (lock) => {
141
+ if (left) return;
142
+ const stillNeedsRepublish = await needsRepublish();
143
+ if (left || !stillNeedsRepublish) return;
144
+ if (Date.now() > lock.expiresAt.getTime() - writeTimeoutMs / 2) {
145
+ throw new Error("membership lease expired during adoption; shared entry left to its TTL");
146
+ }
147
+ await publishShared();
148
+ });
149
+ },
150
+ renew: async (): Promise<void> => {
151
+ if (left) return;
152
+ if (!(await options.registry().touch({ key }))) {
153
+ await locked(async () => {
154
+ if (!left) await upsert();
155
+ });
156
+ }
157
+ },
158
+ leave: async (): Promise<void> => {
159
+ left = true;
160
+ // A failed delete must never lead to shared removal: leave routing to its TTL.
161
+ // Repeat the delete even after leaving, to remove a late registration write.
162
+ await options.registry().delete({ key });
163
+ await locked(async (lock) => {
164
+ const snapshot = await options.registry().snapshot({ prefix });
165
+ // Ephemeral has no conditional delete, so the remaining lease bounds the removal window.
166
+ // A delete that NATS applies only after the lease expired can still remove a newer
167
+ // replica's entry; that replica restores it on its next refresh (at most 60 s).
168
+ if (Date.now() > lock.expiresAt.getTime() - writeTimeoutMs / 2) {
169
+ throw new Error("membership lease expired during stop; shared entry left to its TTL");
170
+ }
171
+ if (!snapshot.entries.some((entry) => entry.key !== key)) {
172
+ for (const { remove } of sharedEntries.values()) {
173
+ if (Date.now() > lock.expiresAt.getTime() - writeTimeoutMs / 2) {
174
+ throw new Error("membership lease expired during stop; shared entry left to its TTL");
175
+ }
176
+ await remove();
177
+ }
178
+ }
179
+ });
180
+ },
181
+ };
182
+ };
183
+
18
184
  type HeartbeatOptions<T> = {
19
- registry: HeartbeatRegistry<T>;
20
185
  key?: string;
21
186
  /** Publish dependent data before advertising or renewing the app. */
22
187
  beforeWrite?: () => Promise<void>;
@@ -26,7 +191,10 @@ type HeartbeatOptions<T> = {
26
191
  writeTimeoutMs?: number;
27
192
  onError?: (error: unknown) => void;
28
193
  onStale?: (error: unknown) => void;
29
- };
194
+ } & (
195
+ | { registry: HeartbeatRegistry<T>; membership?: undefined }
196
+ | { registry: HeartbeatRegistry<T> & SnapshotRegistry<T>; membership: ReturnType<typeof createRegistryMembership> }
197
+ );
30
198
 
31
199
  export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOptions<T>) => {
32
200
  const intervalMs = options.intervalMs ?? HEARTBEAT_INTERVAL_MS;
@@ -44,6 +212,7 @@ export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOp
44
212
  let timer: Timer | null = null;
45
213
  let inFlight: Promise<void> | null = null;
46
214
  let running = false;
215
+ let needsMembershipCleanup = false;
47
216
  let lastSuccessAt = 0;
48
217
  let staleReported = false;
49
218
  const key = options.key ?? `apps/${appId}`;
@@ -54,31 +223,49 @@ export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOp
54
223
  staleReported = false;
55
224
  };
56
225
 
226
+ const remove = () => (options.membership ? options.membership.leave() : registry.delete({ key }));
227
+ const upsert = () => registry.upsert({ key, value: entry });
228
+
57
229
  const register = async (): Promise<void> => {
58
230
  await options.beforeWrite?.();
59
- await registry.upsert({ key, value: entry });
231
+ if (options.membership) {
232
+ const registry = options.registry;
233
+ await options.membership.join(key, entry, {
234
+ read: async () => (await registry.snapshot({ prefix: key })).entries.find((candidate) => candidate.key === key),
235
+ write: upsert,
236
+ remove: () => registry.delete({ key }),
237
+ prepare: options.beforeWrite,
238
+ });
239
+ } else await upsert();
60
240
  noteSuccess();
61
241
  };
62
242
 
63
243
  const refresh = async (): Promise<void> => {
64
244
  // Touch renews the existing entry (and emits a registry invalidation).
65
245
  // Avoid rebuilding the manifest here. A missing entry is repaired
66
- // immediately, preserving restart-free registry recovery.
246
+ // immediately, preserving restart-free registry recovery. The newest live
247
+ // publisher wins; an entry whose publisher left is replaced by this one.
67
248
  await options.beforeWrite?.();
249
+ await options.membership?.renew();
68
250
  const touched = await registry.touch({ key });
69
- if (!touched) await registry.upsert({ key, value: entry });
251
+ if (!touched) {
252
+ if (options.membership) await options.membership.publish(key);
253
+ else await upsert();
254
+ } else if (options.membership) {
255
+ await options.membership.adopt(key);
256
+ }
70
257
  noteSuccess();
71
258
  };
72
259
 
73
260
  const writeWithTimeout = async (write: Promise<void>): Promise<void> => {
74
261
  let timer: Timer | null = null;
75
262
  let timedOut = false;
76
- void write
77
- .then(async () => {
78
- // A write that completed after its timeout must not resurrect a stopped app.
79
- if (timedOut && !running) await registry.delete({ key }).catch(() => false);
80
- })
81
- .catch(() => undefined);
263
+ // A write that settled after its timeout must not resurrect a stopped app. A failed
264
+ // operation may still have landed earlier steps, such as the liveness key.
265
+ const removeLateWrite = async () => {
266
+ if (timedOut && !running) await remove().catch(() => false);
267
+ };
268
+ void write.then(removeLateWrite, removeLateWrite).catch(() => undefined);
82
269
  try {
83
270
  await Promise.race([
84
271
  write,
@@ -140,6 +327,7 @@ export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOp
140
327
  return;
141
328
  }
142
329
  running = true;
330
+ needsMembershipCleanup = options.membership !== undefined;
143
331
  const operation = writeWithTimeout(register());
144
332
  inFlight = operation;
145
333
  try {
@@ -153,7 +341,7 @@ export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOp
153
341
  schedule(intervalMs);
154
342
  },
155
343
  stop: async () => {
156
- if (!running) return;
344
+ if (!running && !needsMembershipCleanup) return;
157
345
  running = false;
158
346
  if (timer) clearTimeout(timer);
159
347
  timer = null;
@@ -165,7 +353,8 @@ export const createHeartbeat = <T>(appId: string, entry: T, options: HeartbeatOp
165
353
  // still required so a stopped app cannot remain discoverable.
166
354
  }
167
355
  inFlight = null;
168
- await registry.delete({ key });
356
+ await remove();
357
+ needsMembershipCleanup = false;
169
358
  },
170
359
  };
171
360
  };
@@ -1,4 +1,5 @@
1
- import type { EphemeralConfig } from "@k2b/sync";
1
+ import { createHash } from "node:crypto";
2
+ import type { EphemeralConfig, MutexConfig } from "@k2b/sync";
2
3
  import type { AppAppearanceColor } from "../contracts/app";
3
4
  import type { CapabilityManifest, CapabilityPresentationCatalog } from "../contracts/capabilities";
4
5
  import { fileProviderIssues } from "../contracts/file-provider";
@@ -45,6 +46,32 @@ export const CAPABILITY_REGISTRY_CONFIG = {
45
46
  } as const satisfies EphemeralConfig;
46
47
 
47
48
  export const APP_REGISTRY_PREFIX = "apps/";
49
+ export const APP_INSTANCE_PREFIX = "instances/";
50
+
51
+ // Fit Sync's 96-byte name limit for every app id that fits apps/<id>; appId stays in the record value.
52
+ export const appInstancePrefix = (appId: string): string => {
53
+ const segment = createHash("sha256").update(appId).digest("base64url").slice(0, 16);
54
+ return `${APP_INSTANCE_PREFIX}${segment}/`;
55
+ };
56
+
57
+ export type AppInstanceRecord = {
58
+ appId: string;
59
+ instanceId: string;
60
+ hostname: string;
61
+ startedAt: number;
62
+ entries: Record<string, string>;
63
+ };
64
+
65
+ // The id and owner are a cross-version contract: all replicas must use the same lock.
66
+ // TTL and retry are local budgets; Sync's drift identity is [id, owner, replicas].
67
+ export const APP_MEMBERSHIP_LOCK_CONFIG = {
68
+ id: "cloud-app-membership",
69
+ owner: REGISTRY_OWNER,
70
+ retry: { delayMs: 200, maxAttempts: 50 },
71
+ } as const satisfies MutexConfig;
72
+
73
+ export const appInstanceRegistry = lazySync((sync) => sync.ephemeral<AppInstanceRecord>(APP_REGISTRY_CONFIG));
74
+ export const appMembershipLock = lazySync((sync) => sync.mutex(APP_MEMBERSHIP_LOCK_CONFIG));
48
75
 
49
76
  export type CapabilityRegistryRecord = { appId: string; manifest: CapabilityManifest; presentation?: CapabilityPresentationCatalog };
50
77
 
@@ -0,0 +1,103 @@
1
+ import type { Mutex } from "@k2b/sync";
2
+ import { expBackoff } from "@k2b/sync/retry";
3
+ import { logger } from "../services/logging";
4
+ import { lazySync, waitForNats } from "./process-sync";
5
+
6
+ const SETUP_LEASE_MS = 60_000;
7
+ const setupMutex = lazySync((sync) => sync.mutex({ id: "app-setup", owner: "cloud", ttlMs: SETUP_LEASE_MS, retry: { maxAttempts: 1 } }));
8
+
9
+ const scheduleRenewal = (renew: () => Promise<void> | undefined, intervalMs: number): (() => void) => {
10
+ const timer = setInterval(() => void renew(), intervalMs);
11
+ timer.unref();
12
+ return () => clearInterval(timer);
13
+ };
14
+
15
+ /** Internal seam for testing lease timing without NATS or wall-clock waits. */
16
+ export const runExclusive = async ({
17
+ mutex,
18
+ resource,
19
+ run,
20
+ leaseMs = SETUP_LEASE_MS,
21
+ sleep = Bun.sleep,
22
+ now = Date.now,
23
+ schedule = scheduleRenewal,
24
+ log = logger("setup"),
25
+ }: {
26
+ mutex: Pick<Mutex, "ready" | "acquire" | "extend" | "release">;
27
+ resource: string;
28
+ run: () => Promise<void>;
29
+ leaseMs?: number;
30
+ sleep?: (ms: number) => Promise<unknown>;
31
+ now?: () => number;
32
+ schedule?: typeof scheduleRenewal;
33
+ log?: Pick<ReturnType<typeof logger>, "info" | "warn" | "error">;
34
+ }): Promise<void> => {
35
+ const startedAt = now();
36
+ const nats = { application: resource };
37
+ await waitForNats(() => mutex.ready(), nats);
38
+ let lastWaitLog = Number.NEGATIVE_INFINITY;
39
+ let attempt = 1;
40
+ let lock = await waitForNats(() => mutex.acquire({ resource }), nats);
41
+ while (!lock) {
42
+ if (now() - lastWaitLog >= 30_000) {
43
+ lastWaitLog = now();
44
+ log.info("waiting for setup on another replica", { appId: resource, waitedMs: now() - startedAt });
45
+ }
46
+ await sleep(Math.min(2_000, expBackoff(attempt++, { baseMs: 250, maxMs: 2_000, jitter: 0.2 })));
47
+ lock = await waitForNats(() => mutex.acquire({ resource }), nats);
48
+ }
49
+
50
+ const lease = lock;
51
+ let lost = false;
52
+ let running = true;
53
+ let confirmedExpiresAt = lease.expiresAt.getTime();
54
+ let renewing: Promise<void> | undefined;
55
+ const checkExpiry = () => {
56
+ if (running && now() >= confirmedExpiresAt) lost = true;
57
+ };
58
+ const cancelRenewal = schedule(() => {
59
+ // Check even when an earlier extend is still in flight or the event loop was delayed.
60
+ checkExpiry();
61
+ if (renewing || lost) return;
62
+ renewing = (async () => {
63
+ try {
64
+ const extended = await mutex.extend(lease);
65
+ // extend mutates the handle; check the previous confirmed expiry first.
66
+ checkExpiry();
67
+ if (extended) confirmedExpiresAt = lease.expiresAt.getTime();
68
+ else lost = true;
69
+ } catch (error) {
70
+ log.warn("setup lease renewal failed", { appId: resource, error });
71
+ checkExpiry();
72
+ }
73
+ })().finally(() => {
74
+ renewing = undefined;
75
+ });
76
+ return renewing;
77
+ }, leaseMs / 3);
78
+
79
+ try {
80
+ try {
81
+ await run();
82
+ } finally {
83
+ checkExpiry();
84
+ running = false;
85
+ cancelRenewal();
86
+ // Do not race a release against an unfinished renewal.
87
+ await renewing;
88
+ if (lost) {
89
+ log.error("setup lease lost; another replica may have run setup concurrently; restarting so setup reruns", { appId: resource });
90
+ }
91
+ }
92
+ if (lost) throw new Error(`Setup lease lost for application "${resource}"`);
93
+ } finally {
94
+ try {
95
+ if (!(await mutex.release(lease))) log.warn("setup lease release failed; it will expire", { appId: resource });
96
+ } catch (error) {
97
+ log.warn("setup lease release failed; it will expire", { appId: resource, error });
98
+ }
99
+ }
100
+ };
101
+
102
+ export const runSetupExclusively = (appId: string, setup: () => Promise<void>): Promise<void> =>
103
+ runExclusive({ mutex: setupMutex(), resource: appId, run: setup });
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  type CapabilitySentencePart,
3
3
  capabilityAskReasonText,
4
+ capabilityPersonText,
4
5
  capabilityQuestionText,
5
6
  capabilityReceiptParts,
6
7
  capabilityReviewSentence,
7
8
  capabilityReviewSentenceParts,
9
+ capabilitySentenceParts,
8
10
  capabilitySentenceText,
9
11
  } from "../../_internal/capability-sentences";
10
12
  import type {
@@ -55,11 +57,13 @@ export type ActionPreviewModel =
55
57
  locale: string;
56
58
  timeZone: string;
57
59
  preview?: CapabilityPreview;
58
- consequences: { text: ActionText; mark?: "added" | "removed" }[];
60
+ /** Original slots for plain rule sentences, retaining people’s addresses. */
61
+ slots?: CapabilityActionCallReview["slots"];
62
+ consequences: { text: ActionText; plain: string; mark?: "added" | "removed" }[];
59
63
  /** The rule sentence of a `rule` preview, with its chips. */
60
64
  when?: ActionText;
61
65
  /** Each `may` line of a `rule` preview. */
62
- may?: { text: ActionText; mark?: "added" | "removed" }[];
66
+ may?: { text: ActionText; plain: string; mark?: "added" | "removed" }[];
63
67
  }
64
68
  | { kind: "legacy"; rows: AiLegacyReviewRow[]; markdown?: string; input?: unknown };
65
69
 
@@ -96,6 +100,16 @@ export type ActionCard = {
96
100
  hasCheck: boolean;
97
101
  /** The receipt text of an old review's success summary. */
98
102
  summary?: string;
103
+ /**
104
+ * For plain text: every value a new review's slots stand for (each person with address, title, text, time, count),
105
+ * also in consequences and rule lines, and whether the app left people out. Plain text that does not show each value
106
+ * whole is incomplete.
107
+ */
108
+ slotValues?: {
109
+ values: string[];
110
+ times: { text: string; start: string; end?: string; format: "date" | "date-time" }[];
111
+ unlisted: boolean;
112
+ };
99
113
  };
100
114
 
101
115
  const REASON_LOOK: Record<CapabilityAskReason, Omit<ActionReason, "code" | "text">> = {
@@ -174,6 +188,19 @@ export const actionPlainText = (text: ActionText): string =>
174
188
  })
175
189
  .join("");
176
190
 
191
+ /** The short template, else the action sentence, read as the card's chips without person addresses or title quotes. */
192
+ export const reviewShortText = (review: CapabilityActionCallReview, context: ActionCardContext): string =>
193
+ actionPlainText(
194
+ partsText(
195
+ capabilitySentenceParts(review.sentence.short ?? review.sentence.action, review.slots, {
196
+ locale: review.locale,
197
+ timeZone: context.timeZone,
198
+ }),
199
+ review.locale,
200
+ context.app,
201
+ ),
202
+ );
203
+
177
204
  const firstLink = (links: readonly CapabilitySemanticLink[] | undefined, rels: CapabilitySemanticLink["rel"][]) =>
178
205
  rels.map((rel) => links?.find((link) => link.rel === rel)?.href).find(Boolean);
179
206
 
@@ -192,6 +219,34 @@ const firstObjectHref = (review: CapabilityActionCallReview): string | undefined
192
219
  .map((slot) => ((slot.kind === "object" || slot.kind === "place") && slot.ref ? cloudResourceHref(slot.ref) : undefined))
193
220
  .find(Boolean);
194
221
 
222
+ const slotValues = (slots: CapabilitySlot[], context: { locale: string; timeZone: string }) => ({
223
+ values: slots.flatMap((slot) =>
224
+ slot.kind === "person"
225
+ ? slot.people.map(capabilityPersonText)
226
+ : slot.kind === "object" || slot.kind === "place"
227
+ ? [slot.title]
228
+ : slot.kind === "text"
229
+ ? [slot.value]
230
+ : slot.kind === "count"
231
+ ? [capabilitySentenceText("{value}", { value: slot }, context)]
232
+ : [],
233
+ ),
234
+ // A time is shown whole as its words, or as typed rows that hold its start and end with at least its precision.
235
+ times: slots.flatMap((slot) =>
236
+ slot.kind === "time"
237
+ ? [
238
+ {
239
+ text: capabilitySentenceText("{value}", { value: slot }, context),
240
+ start: slot.start,
241
+ ...(slot.end ? { end: slot.end } : {}),
242
+ format: slot.format,
243
+ },
244
+ ]
245
+ : [],
246
+ ),
247
+ unlisted: slots.some((slot) => slot.kind === "person" && (slot.total ?? slot.people.length) > slot.people.length),
248
+ });
249
+
195
250
  export const reviewActionCard = (review: CapabilityActionCallReview, context: ActionCardContext): ActionCard => {
196
251
  const locale = review.locale;
197
252
  const tz = { timeZone: context.timeZone };
@@ -206,6 +261,7 @@ export const reviewActionCard = (review: CapabilityActionCallReview, context: Ac
206
261
  );
207
262
  const consequence = (entry: CapabilityConsequence) => ({
208
263
  text: sentence(entry.text, entry.slots),
264
+ plain: capabilitySentenceText(entry.text, entry.slots ?? review.slots, { locale, timeZone: context.timeZone }),
209
265
  ...(entry.mark ? { mark: entry.mark } : {}),
210
266
  });
211
267
  const code = review.ask ?? "app_asks";
@@ -224,6 +280,7 @@ export const reviewActionCard = (review: CapabilityActionCallReview, context: Ac
224
280
  locale,
225
281
  timeZone: context.timeZone,
226
282
  ...(preview ? { preview } : {}),
283
+ slots: review.slots,
227
284
  consequences: (review.consequences ?? []).map(consequence),
228
285
  ...(preview?.kind === "rule"
229
286
  ? { when: sentence(preview.when.text, preview.when.slots), may: preview.may.map(consequence) }
@@ -255,6 +312,14 @@ export const reviewActionCard = (review: CapabilityActionCallReview, context: Ac
255
312
  : plainText(context.app.name),
256
313
  short: capabilityReviewSentence(review, "short", tz),
257
314
  hasCheck: Boolean(review.check),
315
+ slotValues: slotValues(
316
+ [
317
+ review.slots,
318
+ ...(review.consequences ?? []).map((entry) => entry.slots ?? {}),
319
+ ...(preview?.kind === "rule" ? [preview.when.slots ?? {}, ...preview.may.map((entry) => entry.slots ?? {})] : []),
320
+ ].flatMap((slots) => Object.values(slots)),
321
+ { locale, timeZone: context.timeZone },
322
+ ),
258
323
  };
259
324
  };
260
325
 
@@ -340,7 +405,8 @@ const messageRows = (block: ToolBlock): AiLegacyReviewRow[] => {
340
405
  for (const line of message.split("\n").map((entry) => entry.trim())) {
341
406
  if (!line || /^Review the validated arguments below before running this action\.$/i.test(line)) continue;
342
407
  const heading = /^([^:]{1,40}):$/.exec(line);
343
- const match = /^([^:]{1,40}):\s+(.+)$/.exec(line);
408
+ // The value starts at its first visible character, so the pattern never backtracks over the white space before it.
409
+ const match = /^([^:]{1,40}):\s+(\S.*)$/.exec(line);
344
410
  if (heading) {
345
411
  open = { label: heading[1]!, value: "" };
346
412
  rows.push(open);