@k2b/cloud 0.45.0 → 0.47.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 +11 -4
- package/scripts/build.ts +5 -1
- package/scripts/preload.ts +5 -1
- package/src/_internal/app-queries.ts +73 -0
- package/src/_internal/capabilities.ts +137 -9
- package/src/_internal/define-app.ts +40 -0
- package/src/_internal/heartbeat.ts +19 -0
- package/src/_internal/postgres-application-name.ts +11 -0
- package/src/_internal/process-sync.ts +9 -0
- package/src/_internal/readiness.ts +6 -1
- package/src/_internal/registry-validation.ts +17 -4
- package/src/_internal/registry.ts +32 -4
- package/src/_internal/resource-principals.ts +6 -0
- package/src/_internal/workload-credential.ts +153 -0
- package/src/_internal/workload-protocol.ts +36 -0
- package/src/ai/application-tasks.ts +5 -2
- package/src/ai/capabilities.ts +4 -2
- package/src/ai/chart-block.ts +36 -711
- package/src/ai/chat/map-messages.ts +270 -0
- package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
- package/src/ai/chat/map-notebook.tsx +301 -0
- package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
- package/src/ai/chat/map-widget.css +107 -0
- package/src/ai/chat/map-widget.tsx +1279 -0
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -1
- package/src/ai/chat/turn-view.tsx +17 -1
- package/src/ai/default-tool-contracts.ts +21 -259
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/executor.ts +13 -1
- package/src/ai/map-contracts.ts +209 -0
- package/src/ai/map-execution.ts +14 -0
- package/src/ai/map-notebook-save.ts +276 -0
- package/src/ai/map-notebook.ts +32 -0
- package/src/ai/map-opening-hours.ts +40 -0
- package/src/ai/map-routes.ts +84 -0
- package/src/ai/map-service.ts +288 -0
- package/src/ai/map-store.ts +137 -0
- package/src/ai/map-tool.ts +73 -0
- package/src/ai/migrate.ts +3 -0
- package/src/ai/protocol.ts +3 -1
- package/src/ai/routes.ts +2 -0
- package/src/ai/store.ts +19 -6
- package/src/ai/tools.ts +14 -0
- package/src/ai/types.ts +3 -0
- package/src/api/admin-app-keys.ts +66 -0
- package/src/api/admin-core-settings.ts +2 -0
- package/src/api/admin-identity.ts +27 -1
- package/src/api/admin-settings-env.ts +120 -0
- package/src/api/capabilities.ts +99 -15
- package/src/api/index.ts +3 -0
- package/src/api/mcp.ts +4 -2
- package/src/api/settings-ops.ts +35 -0
- package/src/app-key.ts +125 -0
- package/src/browser/CloudResourceSearch.tsx +48 -41
- package/src/browser/notifications.ts +25 -8
- package/src/capabilities/discussion-providers.ts +100 -0
- package/src/capabilities/executions.ts +2 -2
- package/src/capabilities/migrate.ts +10 -1
- package/src/capabilities/resource-readers.ts +69 -0
- package/src/capabilities/response.ts +4 -1
- package/src/capabilities/server-invoke.ts +64 -36
- package/src/capabilities/server.ts +17 -6
- package/src/capabilities/testing.ts +13 -0
- package/src/capabilities/types.ts +1 -1
- package/src/cli/admin/app-credentials.ts +32 -7
- package/src/cli/admin/app-keys.ts +226 -0
- package/src/cli/admin/index.ts +7 -1
- package/src/cli/admin/settings.ts +62 -0
- package/src/config/define-env.ts +63 -8
- package/src/config/env.ts +27 -21
- package/src/config/index.ts +12 -1
- package/src/config/workflow-ai.ts +28 -0
- package/src/contracts/capabilities.ts +137 -4
- package/src/contracts/discussions.ts +91 -0
- package/src/contracts/geo-document.ts +60 -0
- package/src/contracts/geo-snapshot.ts +86 -0
- package/src/contracts/geo.ts +14 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/resource-types.ts +21 -0
- package/src/contracts/settings-env.ts +21 -0
- package/src/contracts/settings-types.ts +6 -0
- package/src/geo/AddressAutocomplete.tsx +213 -0
- package/src/geo/messages.ts +29 -0
- package/src/geo/navigation.ts +25 -0
- package/src/geo/ui.ts +2 -0
- package/src/index.ts +9 -0
- package/src/runtime/clients.ts +72 -0
- package/src/runtime/connections.ts +37 -0
- package/src/runtime/resolve-connections.ts +91 -0
- package/src/server/client-ip.ts +1 -1
- package/src/services/accounts/app.ts +28 -6
- package/src/services/accounts/entities.ts +11 -4
- package/src/services/accounts/groups.ts +10 -4
- package/src/services/accounts/local-groups.ts +87 -38
- package/src/services/accounts/machine-group-management.ts +94 -0
- package/src/services/app-keys.ts +298 -0
- package/src/services/gateway.ts +6 -0
- package/src/services/geo/cache.ts +2 -1
- package/src/services/geo/geoapify.ts +3 -0
- package/src/services/identity/index.ts +3 -0
- package/src/services/identity/invocation-token.ts +110 -0
- package/src/services/identity/resource-readers.ts +80 -0
- package/src/services/identity/runtime-config.ts +14 -6
- package/src/services/identity/workload-runtime.ts +253 -0
- package/src/services/index.ts +4 -1
- package/src/services/outgoing-mail/resolve-host.ts +5 -4
- package/src/services/pdf/geo-snapshot.ts +107 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/services/service-account-credentials.ts +63 -21
- package/src/services/settings/api.ts +3 -3
- package/src/services/settings/binding.ts +45 -0
- package/src/services/settings/core-settings.ts +99 -1
- package/src/services/settings/defaults.ts +16 -2
- package/src/services/settings/env-status.ts +69 -0
- package/src/services/settings/index.ts +69 -26
- package/src/services/settings/store.ts +73 -8
- package/src/services/settings/validation.ts +40 -0
- package/src/shared/index.ts +11 -10
- package/src/shared/markdown/extensions/tables.ts +10 -1
- package/src/ssr/AdminArea.tsx +2 -2
- package/src/ssr/AdminLayout.tsx +1 -1
- package/src/ssr/AdminSidebar.tsx +1 -1
- package/src/start.ts +36 -0
- package/src/styles/global.css +2 -0
- package/src/workflows/ai/index.ts +1 -0
- package/src/workflows/ai/runtime.ts +16 -8
- package/src/workflows/ai/settings.ts +5 -0
- package/src/shared/markdown/formula.ts +0 -1283
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AiMapCommand } from "./map-contracts";
|
|
2
|
+
import { type AiMapContext, applyAiMap } from "./map-service";
|
|
3
|
+
import { type AiMapStored, aiMaps } from "./map-store";
|
|
4
|
+
|
|
5
|
+
/** Lock and check the revision before provider work; a concurrent or stale click cannot spend against this map. */
|
|
6
|
+
export async function executeStoredAiMap(
|
|
7
|
+
conversationId: string,
|
|
8
|
+
ownerUserId: string,
|
|
9
|
+
before: AiMapStored,
|
|
10
|
+
command: AiMapCommand,
|
|
11
|
+
context: AiMapContext,
|
|
12
|
+
) {
|
|
13
|
+
return aiMaps.mutate(conversationId, ownerUserId, before.map.revision, before.map.id, (current) => applyAiMap(current, command, context));
|
|
14
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type CapabilityClientError, type CapabilityHttpOptions, invokeCapabilityWithDataSchema } from "../capabilities/client";
|
|
3
|
+
import { CAPABILITY_MAX_REQUEST_BYTES } from "../contracts/capabilities";
|
|
4
|
+
import { cloudResourceHref } from "../contracts/resource-href";
|
|
5
|
+
|
|
6
|
+
const hashSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
|
|
7
|
+
const shortId = z.string().regex(/^[A-Za-z0-9]{6}$/);
|
|
8
|
+
const offset = z.number().int().nonnegative();
|
|
9
|
+
const pendingSchema = z
|
|
10
|
+
.object({
|
|
11
|
+
kind: z.enum(["create", "append"]),
|
|
12
|
+
key: z.string().min(1).max(200),
|
|
13
|
+
end: offset,
|
|
14
|
+
beforeHash: hashSchema,
|
|
15
|
+
afterHash: hashSchema,
|
|
16
|
+
})
|
|
17
|
+
.strict();
|
|
18
|
+
|
|
19
|
+
/** Persist under the signed-in user and source key; retain saved checkpoints to prevent duplicates. */
|
|
20
|
+
export const MapNotebookSaveCheckpointSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
version: z.literal(1),
|
|
23
|
+
sourceKey: z.string().min(1).max(256),
|
|
24
|
+
notebookId: shortId,
|
|
25
|
+
contentHash: hashSchema,
|
|
26
|
+
total: offset,
|
|
27
|
+
completed: offset,
|
|
28
|
+
attempt: offset,
|
|
29
|
+
noteId: shortId.optional(),
|
|
30
|
+
pending: pendingSchema.optional(),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
export type MapNotebookSaveCheckpoint = z.infer<typeof MapNotebookSaveCheckpointSchema>;
|
|
34
|
+
export type MapNotebookSaveResult = {
|
|
35
|
+
status: "saved" | "cancelled" | "conflict" | "unknown" | "error";
|
|
36
|
+
checkpoint?: MapNotebookSaveCheckpoint;
|
|
37
|
+
notebookHref: string;
|
|
38
|
+
noteId?: string;
|
|
39
|
+
noteHref?: string;
|
|
40
|
+
/** Stable helper code or the original capability error code; caller supplies localized UI text. */
|
|
41
|
+
code?: string;
|
|
42
|
+
message?: string;
|
|
43
|
+
};
|
|
44
|
+
export type MapNotebookSaveOptions = {
|
|
45
|
+
/** Exact server export, with its existing first # title. Refetch the same export on resume. */
|
|
46
|
+
content: string;
|
|
47
|
+
notebookId: string;
|
|
48
|
+
/** Map ID + revision (and conversation if needed), stable across explicit resumes. */
|
|
49
|
+
sourceKey: string;
|
|
50
|
+
checkpoint?: MapNotebookSaveCheckpoint;
|
|
51
|
+
/** Must durably persist before resolving. Called before EVERY mutation and after every receipt. */
|
|
52
|
+
onCheckpoint: (checkpoint: MapNotebookSaveCheckpoint) => void | Promise<void>;
|
|
53
|
+
signal?: AbortSignal;
|
|
54
|
+
/** Normal public capability transport; also supplies a fake HTTP seam for tests. */
|
|
55
|
+
client?: CapabilityHttpOptions;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const encoder = new TextEncoder();
|
|
59
|
+
const hash = async (content: string): Promise<string> => {
|
|
60
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(content));
|
|
61
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
62
|
+
};
|
|
63
|
+
const fits = (input: unknown): boolean => encoder.encode(JSON.stringify({ input })).byteLength <= CAPABILITY_MAX_REQUEST_BYTES;
|
|
64
|
+
// Offsets and public character caps use UTF-16. Keep a supplementary character in one request.
|
|
65
|
+
const boundary = (content: string, end: number): number =>
|
|
66
|
+
end > 0 && end < content.length && /[\uD800-\uDBFF]/.test(content[end - 1]!) && /[\uDC00-\uDFFF]/.test(content[end]!) ? end - 1 : end;
|
|
67
|
+
|
|
68
|
+
const createEnd = (content: string, notebookId: string): number => {
|
|
69
|
+
let low = 0;
|
|
70
|
+
let high = Math.min(content.length, 200_000);
|
|
71
|
+
while (low < high) {
|
|
72
|
+
const mid = Math.ceil((low + high) / 2);
|
|
73
|
+
if (fits({ notebookId, content: content.slice(0, mid) })) low = mid;
|
|
74
|
+
else high = mid - 1;
|
|
75
|
+
}
|
|
76
|
+
return boundary(content, low);
|
|
77
|
+
};
|
|
78
|
+
type Append = { kind: "append"; content: string; separator: "none" };
|
|
79
|
+
const appendBatch = (content: string, start: number, noteId: string, ifContentHash: string) => {
|
|
80
|
+
const operations: Append[] = [];
|
|
81
|
+
let end = start;
|
|
82
|
+
while (end < content.length && operations.length < 20) {
|
|
83
|
+
const next = boundary(content, Math.min(end + 10_000, content.length));
|
|
84
|
+
const operation: Append = { kind: "append", content: content.slice(end, next), separator: "none" };
|
|
85
|
+
if (!fits({ noteId, ifContentHash, blockLimit: 0, operations: [...operations, operation] })) break;
|
|
86
|
+
operations.push(operation);
|
|
87
|
+
end = next;
|
|
88
|
+
}
|
|
89
|
+
return { end, input: { noteId, ifContentHash, blockLimit: 0, operations } };
|
|
90
|
+
};
|
|
91
|
+
// The platform treats a valid owning-app 4xx as no effect. Unknown/claim errors are exceptions.
|
|
92
|
+
const definitelyRejected = (error: CapabilityClientError): boolean =>
|
|
93
|
+
error.status >= 400 &&
|
|
94
|
+
error.status < 500 &&
|
|
95
|
+
![
|
|
96
|
+
"IDEMPOTENCY_IN_PROGRESS",
|
|
97
|
+
"IDEMPOTENCY_UNCERTAIN",
|
|
98
|
+
"IDEMPOTENCY_CONFLICT",
|
|
99
|
+
"ACTION_OUTCOME_UNKNOWN",
|
|
100
|
+
"REQUEST_CANCELLED",
|
|
101
|
+
"DEADLINE_EXCEEDED",
|
|
102
|
+
"INVALID_APP_RESPONSE",
|
|
103
|
+
"RESPONSE_TOO_LARGE",
|
|
104
|
+
].includes(error.code);
|
|
105
|
+
|
|
106
|
+
const noteSchema = z.object({ id: shortId, notebookId: shortId });
|
|
107
|
+
const readSchema = noteSchema.extend({ contentHash: hashSchema });
|
|
108
|
+
const editSchema = z.object({ beforeHash: hashSchema, afterHash: hashSchema });
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One bounded save, with no automatic retries. The app invokes this again for explicit resume.
|
|
112
|
+
* Unknown creates are never repeated, even after the platform's 24-hour claim retention.
|
|
113
|
+
* note.edit forbids idempotency headers; full-content hashes detect changed prefixes. An uncertain append at the old hash stays unknown.
|
|
114
|
+
* This helper uses ordinary capabilities and never approves an Action on the caller's behalf.
|
|
115
|
+
*/
|
|
116
|
+
export async function saveMapNotebook(options: MapNotebookSaveOptions): Promise<MapNotebookSaveResult> {
|
|
117
|
+
const { content, notebookId, sourceKey, signal, client } = options;
|
|
118
|
+
const notebookHref = cloudResourceHref({ type: "notebooks.notebook", id: notebookId });
|
|
119
|
+
let checkpoint: MapNotebookSaveCheckpoint | undefined;
|
|
120
|
+
const result = (status: MapNotebookSaveResult["status"], code?: string, message?: string): MapNotebookSaveResult => ({
|
|
121
|
+
status,
|
|
122
|
+
checkpoint,
|
|
123
|
+
notebookHref,
|
|
124
|
+
code,
|
|
125
|
+
message,
|
|
126
|
+
...(checkpoint?.noteId
|
|
127
|
+
? { noteId: checkpoint.noteId, noteHref: cloudResourceHref({ type: "notebooks.note", id: checkpoint.noteId }) }
|
|
128
|
+
: {}),
|
|
129
|
+
});
|
|
130
|
+
const persist = async (next: MapNotebookSaveCheckpoint) => {
|
|
131
|
+
checkpoint = next;
|
|
132
|
+
// Never hand mutable internal state to the storage/UI callback.
|
|
133
|
+
await options.onCheckpoint(structuredClone(next));
|
|
134
|
+
};
|
|
135
|
+
const read = (id: string) =>
|
|
136
|
+
invokeCapabilityWithDataSchema(
|
|
137
|
+
{
|
|
138
|
+
kind: "query",
|
|
139
|
+
appId: "notebooks",
|
|
140
|
+
capabilityId: "note.read",
|
|
141
|
+
input: { id, contentLimit: 1 },
|
|
142
|
+
signal,
|
|
143
|
+
},
|
|
144
|
+
readSchema,
|
|
145
|
+
client,
|
|
146
|
+
);
|
|
147
|
+
try {
|
|
148
|
+
shortId.parse(notebookId);
|
|
149
|
+
z.string().min(1).max(256).parse(sourceKey);
|
|
150
|
+
// The exporter supplies a title; otherwise note.create may prepend one and change the prefix.
|
|
151
|
+
const titleEnd = content.indexOf("\n");
|
|
152
|
+
if (!/^# [^\r\n\s][^\r\n]*/.test(content)) return result("error", "invalid_title");
|
|
153
|
+
const contentHash = await hash(content);
|
|
154
|
+
if (options.checkpoint) {
|
|
155
|
+
checkpoint = MapNotebookSaveCheckpointSchema.parse(options.checkpoint);
|
|
156
|
+
if (
|
|
157
|
+
checkpoint.sourceKey !== sourceKey ||
|
|
158
|
+
checkpoint.notebookId !== notebookId ||
|
|
159
|
+
checkpoint.contentHash !== contentHash ||
|
|
160
|
+
checkpoint.total !== content.length ||
|
|
161
|
+
checkpoint.completed > content.length ||
|
|
162
|
+
(!checkpoint.noteId && checkpoint.completed !== 0)
|
|
163
|
+
)
|
|
164
|
+
return result("conflict", "source_changed");
|
|
165
|
+
const pending = checkpoint.pending;
|
|
166
|
+
if (
|
|
167
|
+
pending &&
|
|
168
|
+
(pending.end <= checkpoint.completed ||
|
|
169
|
+
pending.end > content.length ||
|
|
170
|
+
pending.beforeHash !== (await hash(content.slice(0, checkpoint.completed))) ||
|
|
171
|
+
pending.afterHash !== (await hash(content.slice(0, pending.end))) ||
|
|
172
|
+
(pending.kind === "create") === Boolean(checkpoint.noteId))
|
|
173
|
+
)
|
|
174
|
+
return result("conflict", "invalid_checkpoint");
|
|
175
|
+
} else {
|
|
176
|
+
checkpoint = { version: 1, sourceKey, notebookId, contentHash, total: content.length, completed: 0, attempt: 0 };
|
|
177
|
+
}
|
|
178
|
+
if (signal?.aborted) return result("cancelled", "cancelled");
|
|
179
|
+
if (checkpoint.pending?.kind === "create") return result("unknown", "create_outcome_unknown");
|
|
180
|
+
if (checkpoint.noteId) {
|
|
181
|
+
const current = await read(checkpoint.noteId);
|
|
182
|
+
if (!current.ok) return result(signal?.aborted ? "cancelled" : "unknown", current.error.code, current.error.message);
|
|
183
|
+
if (current.data.data.id !== checkpoint.noteId || current.data.data.notebookId !== notebookId)
|
|
184
|
+
return result("conflict", "note_moved");
|
|
185
|
+
const currentHash = current.data.data.contentHash;
|
|
186
|
+
if (checkpoint.pending && currentHash === checkpoint.pending.afterHash) {
|
|
187
|
+
await persist({ ...checkpoint, completed: checkpoint.pending.end, pending: undefined });
|
|
188
|
+
} else if (currentHash === (await hash(content.slice(0, checkpoint.completed)))) {
|
|
189
|
+
// The service checks the hash before publication, without compare-and-swap.
|
|
190
|
+
// The original request could still publish later: the old hash is NOT proof of no effect.
|
|
191
|
+
if (checkpoint.pending) return result("unknown", "append_outcome_unknown");
|
|
192
|
+
} else return result("conflict", "content_changed");
|
|
193
|
+
}
|
|
194
|
+
// Every iteration consumes a nonempty prefix: no retries, no work independent of source size.
|
|
195
|
+
while (checkpoint.completed < content.length) {
|
|
196
|
+
if (signal?.aborted) return result("cancelled", "cancelled");
|
|
197
|
+
const beforeHash = await hash(content.slice(0, checkpoint.completed));
|
|
198
|
+
const batch = checkpoint.noteId ? appendBatch(content, checkpoint.completed, checkpoint.noteId, beforeHash) : undefined;
|
|
199
|
+
const end = batch?.end ?? createEnd(content, notebookId);
|
|
200
|
+
if (end <= checkpoint.completed || (!checkpoint.noteId && end < (titleEnd < 0 ? content.length : titleEnd + 1))) {
|
|
201
|
+
return result("error", "request_too_large");
|
|
202
|
+
}
|
|
203
|
+
const afterHash = await hash(content.slice(0, end));
|
|
204
|
+
// Source digest scopes creates across reloads; offset + attempt identifies each batch.
|
|
205
|
+
const key = `map-note:${await hash(`${sourceKey}:${notebookId}:${contentHash}`)}:${checkpoint.completed}:${checkpoint.attempt}`;
|
|
206
|
+
const pending = { kind: checkpoint.noteId ? ("append" as const) : ("create" as const), key, end, beforeHash, afterHash };
|
|
207
|
+
try {
|
|
208
|
+
await persist({ ...checkpoint, pending, attempt: checkpoint.attempt + 1 });
|
|
209
|
+
} catch (cause) {
|
|
210
|
+
// This attempt is known unsent, even if storage wrote and then threw.
|
|
211
|
+
// Keep its incremented identity; a caller can persist this safe checkpoint and resume.
|
|
212
|
+
checkpoint = { ...checkpoint, pending: undefined };
|
|
213
|
+
return result("error", "checkpoint_failed", cause instanceof Error ? cause.message : String(cause));
|
|
214
|
+
}
|
|
215
|
+
if (signal?.aborted) {
|
|
216
|
+
// Nothing was dispatched; the next explicit resume can safely use a new attempt.
|
|
217
|
+
await persist({ ...checkpoint, pending: undefined });
|
|
218
|
+
return result("cancelled", "cancelled");
|
|
219
|
+
}
|
|
220
|
+
if (!batch) {
|
|
221
|
+
const created = await invokeCapabilityWithDataSchema(
|
|
222
|
+
{
|
|
223
|
+
kind: "action",
|
|
224
|
+
appId: "notebooks",
|
|
225
|
+
capabilityId: "note.create",
|
|
226
|
+
input: { notebookId, content: content.slice(0, end) },
|
|
227
|
+
idempotencyKey: key,
|
|
228
|
+
signal,
|
|
229
|
+
},
|
|
230
|
+
noteSchema,
|
|
231
|
+
client,
|
|
232
|
+
);
|
|
233
|
+
if (!created.ok) {
|
|
234
|
+
if (definitelyRejected(created.error)) {
|
|
235
|
+
await persist({ ...checkpoint, pending: undefined });
|
|
236
|
+
return result("error", created.error.code, created.error.message);
|
|
237
|
+
}
|
|
238
|
+
return result(signal?.aborted ? "cancelled" : "unknown", created.error.code, created.error.message);
|
|
239
|
+
}
|
|
240
|
+
// Persist the known ID before querying or performing another mutation.
|
|
241
|
+
await persist({ ...checkpoint, noteId: created.data.data.id, completed: end, pending: undefined });
|
|
242
|
+
if (created.data.data.notebookId !== notebookId) return result("conflict", "note_moved");
|
|
243
|
+
const current = await read(created.data.data.id);
|
|
244
|
+
if (!current.ok) return result(signal?.aborted ? "cancelled" : "unknown", current.error.code, current.error.message);
|
|
245
|
+
if (current.data.data.contentHash !== afterHash || current.data.data.notebookId !== notebookId)
|
|
246
|
+
return result("conflict", "content_changed");
|
|
247
|
+
} else {
|
|
248
|
+
const edited = await invokeCapabilityWithDataSchema(
|
|
249
|
+
{
|
|
250
|
+
kind: "action",
|
|
251
|
+
appId: "notebooks",
|
|
252
|
+
capabilityId: "note.edit",
|
|
253
|
+
input: batch.input,
|
|
254
|
+
signal,
|
|
255
|
+
},
|
|
256
|
+
editSchema,
|
|
257
|
+
client,
|
|
258
|
+
);
|
|
259
|
+
if (!edited.ok) {
|
|
260
|
+
if (definitelyRejected(edited.error)) {
|
|
261
|
+
await persist({ ...checkpoint, pending: undefined });
|
|
262
|
+
return result(edited.error.status === 409 ? "conflict" : "error", edited.error.code, edited.error.message);
|
|
263
|
+
}
|
|
264
|
+
return result(signal?.aborted ? "cancelled" : "unknown", edited.error.code, edited.error.message);
|
|
265
|
+
}
|
|
266
|
+
if (edited.data.data.beforeHash !== beforeHash || edited.data.data.afterHash !== afterHash)
|
|
267
|
+
return result("conflict", "unexpected_receipt");
|
|
268
|
+
await persist({ ...checkpoint, completed: end, pending: undefined });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return result("saved");
|
|
272
|
+
} catch (cause) {
|
|
273
|
+
// Includes persistence failures. Never proceed to a dependent mutation after a failed callback.
|
|
274
|
+
return result(signal?.aborted ? "cancelled" : "error", "save_failed", cause instanceof Error ? cause.message : String(cause));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { serializeGeoDocument } from "../contracts/geo-document";
|
|
2
|
+
import { type AiMap, aiMapWebsite } from "./map-contracts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* No durable saved timestamp exists on AiMap. Omit savedAt: viewedAt refreshes on reads
|
|
6
|
+
* for opening hours, while exact resumable exports must stay stable at one revision.
|
|
7
|
+
* Notebook therefore labels provenance as the calculation date.
|
|
8
|
+
* Each Places result keeps the Notebook contract's 20-pin bound; no pins are dropped from a follow-up map.
|
|
9
|
+
*/
|
|
10
|
+
export const aiMapNotebookContent = (map: AiMap): string =>
|
|
11
|
+
[
|
|
12
|
+
`# ${map.title.replace(/[\r\n]/g, " ")}`,
|
|
13
|
+
...map.groups
|
|
14
|
+
.filter((g) => g.places.length > 0)
|
|
15
|
+
.map((group) =>
|
|
16
|
+
serializeGeoDocument({
|
|
17
|
+
version: 1,
|
|
18
|
+
title: group.title,
|
|
19
|
+
pins: group.places.map((place) => {
|
|
20
|
+
const website = aiMapWebsite(place.website);
|
|
21
|
+
return {
|
|
22
|
+
id: place.id,
|
|
23
|
+
label: place.name,
|
|
24
|
+
point: place.point,
|
|
25
|
+
provenance: place.provenance,
|
|
26
|
+
...(website ? { link: { type: "url" as const, url: website, label: place.name } } : {}),
|
|
27
|
+
};
|
|
28
|
+
}),
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
...(map.route ? [serializeGeoDocument({ version: 1, title: map.title, pins: [], route: map.route })] : []),
|
|
32
|
+
].join("\n\n");
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Format the plain weekly subset only. Holidays, solar times and conditional rules stay verbatim. */
|
|
2
|
+
export function mapOpeningHours(schedule: string | undefined, at: string, timeZone: string | undefined, locale: string): string {
|
|
3
|
+
const de = locale.startsWith("de");
|
|
4
|
+
if (!schedule) return de ? "Öffnungszeiten unbekannt" : "Opening hours unknown";
|
|
5
|
+
if (schedule === "24/7") return de ? "rund um die Uhr" : "open 24 hours";
|
|
6
|
+
if (!timeZone) return schedule;
|
|
7
|
+
const days = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
|
8
|
+
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
9
|
+
let day: number;
|
|
10
|
+
try {
|
|
11
|
+
day = dayNames.indexOf(new Intl.DateTimeFormat("en", { timeZone, weekday: "short" }).format(new Date(at)));
|
|
12
|
+
} catch {
|
|
13
|
+
return schedule;
|
|
14
|
+
}
|
|
15
|
+
const rules = schedule
|
|
16
|
+
.split(";")
|
|
17
|
+
.map((rule) => /^(Mo|Tu|We|Th|Fr|Sa|Su)(?:-(Mo|Tu|We|Th|Fr|Sa|Su))?\s+(off|\d{1,2}:\d{2}-\d{1,2}:\d{2})$/.exec(rule.trim()));
|
|
18
|
+
if (
|
|
19
|
+
rules.some((rule) => {
|
|
20
|
+
if (!rule) return true;
|
|
21
|
+
if (rule[3] === "off") return false;
|
|
22
|
+
return rule[3]!.split("-").some((part, i) => {
|
|
23
|
+
const [hour, minute] = part.split(":").map(Number);
|
|
24
|
+
return hour! > 24 || minute! > 59 || (hour === 24 && (i === 0 || minute !== 0));
|
|
25
|
+
});
|
|
26
|
+
})
|
|
27
|
+
)
|
|
28
|
+
return schedule;
|
|
29
|
+
let hours: string | undefined;
|
|
30
|
+
for (const rule of rules) {
|
|
31
|
+
if (!rule) return schedule;
|
|
32
|
+
const from = days.indexOf(rule[1]!);
|
|
33
|
+
const to = days.indexOf(rule[2] ?? rule[1]!);
|
|
34
|
+
if (from <= to ? day >= from && day <= to : day >= from || day <= to) hours = rule[3];
|
|
35
|
+
}
|
|
36
|
+
if (!hours) return schedule;
|
|
37
|
+
if (hours === "off") return de ? "heute geschlossen" : "closed today";
|
|
38
|
+
const [start, end] = hours.split("-");
|
|
39
|
+
return de ? `heute ${start}–${end} Uhr` : `today ${start}–${end}`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { withCapabilityRequestTimeout } from "../_internal/capability-timeout";
|
|
4
|
+
import type { CapabilityCaller } from "../capabilities/server";
|
|
5
|
+
import type { AuthContext } from "../server";
|
|
6
|
+
import { auth, getLocale, v } from "../server";
|
|
7
|
+
import { noTransform } from "../server/middleware/no-transform";
|
|
8
|
+
import { AiMapActionSchema } from "./map-contracts";
|
|
9
|
+
import { executeStoredAiMap } from "./map-execution";
|
|
10
|
+
import { aiMapNotebookContent } from "./map-notebook";
|
|
11
|
+
import { AiMapConflict, aiMaps } from "./map-store";
|
|
12
|
+
import { mapWebSourceWasRead } from "./map-tool";
|
|
13
|
+
import { resolveAiChatRequestContext } from "./request-context";
|
|
14
|
+
import { aiConversations } from "./store";
|
|
15
|
+
|
|
16
|
+
export const aiMapRoutes = new Hono<AuthContext>()
|
|
17
|
+
.use("*", noTransform())
|
|
18
|
+
.use("*", auth.requireRole("authenticated"))
|
|
19
|
+
.use("*", auth.requireOAuthScope("read", "write", "admin"))
|
|
20
|
+
.onError((error, c) =>
|
|
21
|
+
c.json(
|
|
22
|
+
{ code: error instanceof AiMapConflict ? "MAP_REVISION_CONFLICT" : "MAP_ACTION_FAILED" },
|
|
23
|
+
error instanceof AiMapConflict ? 409 : 400,
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
.get("/:conversationId/maps/:mapId", async (c) => {
|
|
28
|
+
const ctx = await resolveAiChatRequestContext(c);
|
|
29
|
+
if (ctx instanceof Response) return ctx;
|
|
30
|
+
const params = z.object({ conversationId: z.string().min(1), mapId: z.uuid() }).safeParse(c.req.param());
|
|
31
|
+
if (!params.success) return c.notFound();
|
|
32
|
+
const conversation = await aiConversations.getConversationByShortId({
|
|
33
|
+
shortId: params.data.conversationId,
|
|
34
|
+
ownerUserId: ctx.ownerUserId,
|
|
35
|
+
});
|
|
36
|
+
if (!conversation) return c.notFound();
|
|
37
|
+
const value = await aiMaps.read(conversation.id, params.data.mapId);
|
|
38
|
+
c.header("Cache-Control", "private, no-store");
|
|
39
|
+
return value ? c.json({ map: value.map, canAdmin: ctx.actor.kind === "user" && ctx.actor.user.roles.includes("admin") }) : c.notFound();
|
|
40
|
+
})
|
|
41
|
+
.get("/:conversationId/maps/:mapId/export", async (c) => {
|
|
42
|
+
const ctx = await resolveAiChatRequestContext(c);
|
|
43
|
+
if (ctx instanceof Response) return ctx;
|
|
44
|
+
const params = z.object({ conversationId: z.string().min(1), mapId: z.uuid() }).safeParse(c.req.param());
|
|
45
|
+
if (!params.success) return c.notFound();
|
|
46
|
+
const conversation = await aiConversations.getConversationByShortId({
|
|
47
|
+
shortId: params.data.conversationId,
|
|
48
|
+
ownerUserId: ctx.ownerUserId,
|
|
49
|
+
});
|
|
50
|
+
if (!conversation) return c.notFound();
|
|
51
|
+
const value = await aiMaps.read(conversation.id, params.data.mapId);
|
|
52
|
+
if (!value) return c.notFound();
|
|
53
|
+
c.header("Cache-Control", "private, no-store");
|
|
54
|
+
return c.json({ content: aiMapNotebookContent(value.map), revision: value.map.revision, ownerUserId: ctx.ownerUserId });
|
|
55
|
+
})
|
|
56
|
+
.post("/:conversationId/maps/:mapId/action", auth.requireOAuthScope("write", "admin"), v("json", AiMapActionSchema), async (c) => {
|
|
57
|
+
const ctx = await resolveAiChatRequestContext(c);
|
|
58
|
+
if (ctx instanceof Response) return ctx;
|
|
59
|
+
const params = z.object({ conversationId: z.string().min(1), mapId: z.uuid() }).safeParse(c.req.param());
|
|
60
|
+
if (!params.success) return c.notFound();
|
|
61
|
+
const conversation = await aiConversations.getConversationByShortId({
|
|
62
|
+
shortId: params.data.conversationId,
|
|
63
|
+
ownerUserId: ctx.ownerUserId,
|
|
64
|
+
});
|
|
65
|
+
if (!conversation) return c.notFound();
|
|
66
|
+
const before = await aiMaps.read(conversation.id, params.data.mapId);
|
|
67
|
+
const input = c.req.valid("json");
|
|
68
|
+
if (!before || before.map.revision !== input.revision) throw new AiMapConflict();
|
|
69
|
+
const caller: CapabilityCaller = {
|
|
70
|
+
authority: auth.getAuthority(c),
|
|
71
|
+
locale: getLocale(c),
|
|
72
|
+
signal: c.req.raw.signal,
|
|
73
|
+
origin: "assistant",
|
|
74
|
+
};
|
|
75
|
+
const next = await withCapabilityRequestTimeout(c, "query", () =>
|
|
76
|
+
executeStoredAiMap(conversation.id, ctx.ownerUserId, before, input.command, {
|
|
77
|
+
caller,
|
|
78
|
+
allowCostOverrunRetry: ctx.signedInSession,
|
|
79
|
+
verifyWeb: (url) => mapWebSourceWasRead(conversation.id, url),
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
c.header("Cache-Control", "private, no-store");
|
|
83
|
+
return c.json({ map: next.map });
|
|
84
|
+
});
|