@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.
- package/package.json +6 -3
- package/src/_internal/capability-sentences.ts +2 -1
- package/src/_internal/define-app.ts +13 -3
- package/src/_internal/heartbeat.ts +203 -14
- package/src/_internal/registry.ts +28 -1
- package/src/_internal/setup-lock.ts +103 -0
- package/src/ai/chat/action-card.ts +69 -3
- package/src/ai/chat/action-question.tsx +12 -1
- package/src/ai/chat/action-text.ts +288 -0
- package/src/ai/chat/tool-action-words.ts +31 -0
- package/src/ai/chat/tool-action.tsx +1 -26
- package/src/ai/executor.ts +10 -2
- package/src/ai/firecrawl-tools.ts +15 -2
- package/src/ai/index.ts +1 -0
- package/src/ai/migrate.ts +15 -0
- package/src/ai/open-questions.ts +172 -0
- package/src/ai/question-text.ts +8 -0
- package/src/ai/routes.ts +51 -5
- package/src/ai/store.ts +18 -0
- package/src/ai/testing.ts +36 -0
- package/src/ai/tool-sentences.ts +152 -34
- package/src/ai/tool-wording.ts +12 -6
- package/src/ai/tools.ts +8 -3
- package/src/ai/types.ts +16 -2
- package/src/api/app-approval-actions.ts +31 -7
- package/src/api/auth/schemas.ts +3 -0
- package/src/api/auth.ts +54 -18
- package/src/api/mcp.ts +27 -3
- package/src/api/me.ts +79 -3
- package/src/approvals/Approvals.tsx +25 -63
- package/src/approvals/approval-card.ts +68 -0
- package/src/contracts/app-approval.ts +3 -3
- package/src/contracts/capabilities.ts +1 -1
- package/src/contracts/shared.ts +3 -0
- package/src/server/middleware/auth.ts +4 -2
- package/src/services/accounts/app.ts +23 -3
- package/src/services/accounts/users.ts +12 -1
- package/src/services/approvals/device.ts +49 -33
- package/src/services/approvals/index.ts +1 -0
- package/src/services/approvals/list.ts +1 -1
- package/src/services/approvals/question.ts +68 -0
- package/src/services/audit/index.ts +1 -0
- package/src/services/auth-flows/ipa.ts +39 -2
- package/src/services/auth-flows/password-reset.ts +32 -6
- package/src/services/index.ts +2 -0
- package/src/services/pdf/markdown.ts +160 -2
- package/src/services/session/index.ts +203 -29
- package/src/ssr/HelpPanelArticle.island.tsx +308 -0
- package/src/ssr/HelpPanelBack.island.tsx +32 -0
- package/src/ssr/HelpPanelMenu.island.tsx +133 -0
- package/src/ssr/HelpPanelSearch.island.tsx +123 -0
- package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
- package/src/ssr/HelpPanelView.island.tsx +27 -0
- package/src/ssr/LayoutHelp.tsx +182 -540
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
- package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
- package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
- package/src/ssr/help.ts +4 -3
- package/src/ssr/index.ts +1 -0
- package/src/ssr/islands/index.ts +3 -4
- package/src/ssr/layout-help-panel-controls.tsx +43 -0
- package/src/ssr/layout-help-panel-document.ts +67 -0
- package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
- package/src/ssr/layout-help-panel-menu.tsx +113 -0
- package/src/ssr/layout-help-panel-modules.ts +7 -0
- package/src/ssr/layout-help-panel-reader.tsx +159 -0
- package/src/ssr/layout-help-panel-search.tsx +69 -0
- package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
- package/src/ssr/layout-help-panel-state.ts +141 -0
- package/src/ssr/layout-help-panel-types.ts +19 -0
- package/src/ssr/layout-help-views.tsx +484 -0
- package/src/ssr/panel-idle.ts +9 -0
- package/src/styles/approvals.css +6 -0
- 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.
|
|
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.
|
|
116
|
-
"@k2b/ui": "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")
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
356
|
+
await remove();
|
|
357
|
+
needsMembershipCleanup = false;
|
|
169
358
|
},
|
|
170
359
|
};
|
|
171
360
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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);
|