@k2b/cloud 0.38.0 → 0.39.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/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/define-app.ts +28 -2
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +30 -5
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/browser.ts +10 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/turn-view.tsx +35 -3
- package/src/ai/chat/view-block.tsx +7 -2
- package/src/ai/chat-task-contracts.ts +28 -3
- package/src/ai/chat-task-reasons-migrate.ts +68 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +396 -66
- package/src/ai/index.ts +12 -0
- package/src/ai/migrate.ts +28 -2
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +27 -1
- package/src/ai/runtime.ts +12 -27
- package/src/ai/store.ts +5 -3
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-wording.ts +45 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +12 -1
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-outgoing-mail.ts +4 -2
- package/src/api/app-approval.ts +9 -2
- package/src/api/approvals.ts +1 -1
- package/src/api/index.ts +2 -1
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +7 -2
- package/src/browser/composer-catalog.ts +103 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +150 -0
- package/src/browser/panel-messages.ts +24 -0
- package/src/browser/panel-slot.ts +354 -0
- package/src/browser/panel.ts +28 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/contracts/app-approval.ts +49 -9
- package/src/contracts/app.ts +14 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/shared.ts +2 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +158 -38
- package/src/services/approvals/device.ts +2 -0
- package/src/services/index.ts +21 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/redirect.ts +12 -0
- package/src/ssr/CloudShell.island.tsx +86 -9
- package/src/ssr/Layout.tsx +42 -9
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/PanelLayout.tsx +27 -0
- package/src/ssr/PanelRuntime.island.tsx +87 -0
- package/src/ssr/PanelSlot.tsx +108 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +60 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +100 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +15 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +431 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/panel-slot.browser-harness.tsx +110 -0
- package/src/ssr/platform-messages.ts +30 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +4 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +3 -2
- package/src/styles/global.css +269 -55
- package/src/styles/panel.css +373 -0
- package/src/styles/tokens.css +23 -6
- package/src/styles/utilities-navigation.css +5 -21
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mutation } from "@k2b/stdlib/solid";
|
|
2
2
|
import { announce, IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
|
|
3
|
-
import { createMemo, createSignal, For, onCleanup, Show } from "solid-js";
|
|
3
|
+
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
|
|
4
4
|
import { createStore, reconcile } from "solid-js/store";
|
|
5
5
|
import { CloudAvatar } from "../account/Avatar";
|
|
6
6
|
import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
|
|
@@ -27,9 +27,9 @@ export type GrantableLevel = Exclude<PermissionLevel, "none">;
|
|
|
27
27
|
export type AllowedLevel = GrantableLevel | { level: GrantableLevel; scope?: string; label?: string; icon?: string; description?: string };
|
|
28
28
|
|
|
29
29
|
type PermissionEditorProps = {
|
|
30
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
/** The editor mounts from this list and merges later lists by entry id once no change is saving.
|
|
31
|
+
* Rows the caller changed since its last list win; unchanged rows keep the editor's confirmed result.
|
|
32
|
+
* Keep the editor mounted (no keyed Show or unmount during refresh) and pass refetched data. */
|
|
33
33
|
initialEntries: AccessEntry[];
|
|
34
34
|
|
|
35
35
|
/** Whether the current user can edit permissions. When `false`, the
|
|
@@ -179,7 +179,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
179
179
|
const t = () => accessMessages.resolve([locale()]).t;
|
|
180
180
|
// A store keeps each row's identity across level changes, so an expanded member list stays open. It holds
|
|
181
181
|
// copies: store writes go into the objects themselves, and the caller's entries are not the editor's to change.
|
|
182
|
-
const
|
|
182
|
+
const initialEntries = props.initialEntries;
|
|
183
|
+
let appliedCallerEntries = new Map(initialEntries.map((entry) => [entry.id, JSON.stringify(entry)]));
|
|
184
|
+
const [entries, setEntries] = createStore<AccessEntry[]>(initialEntries.map((entry) => ({ ...entry })));
|
|
183
185
|
const [pending, setPending] = createSignal<string | null>(null);
|
|
184
186
|
const grantPending = () => pending() !== null;
|
|
185
187
|
let disposed = false;
|
|
@@ -281,6 +283,29 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
281
283
|
},
|
|
282
284
|
});
|
|
283
285
|
const busy = () => grantPending() || updateMut.loading() || revokeMut.loading();
|
|
286
|
+
createEffect(() => {
|
|
287
|
+
const next = props.initialEntries;
|
|
288
|
+
const nextCallerEntries = new Map(next.map((entry) => [entry.id, JSON.stringify(entry)]));
|
|
289
|
+
// Mutation loading clears before onSuccess; wait for its local writes and onFinally too.
|
|
290
|
+
if (busy() || updating() !== null || revoking() !== null) return;
|
|
291
|
+
if (
|
|
292
|
+
nextCallerEntries.size === appliedCallerEntries.size &&
|
|
293
|
+
next.every((entry) => nextCallerEntries.get(entry.id) === appliedCallerEntries.get(entry.id))
|
|
294
|
+
)
|
|
295
|
+
return;
|
|
296
|
+
// Rows the caller changed since its last list win; unchanged rows keep the editor's confirmed result.
|
|
297
|
+
const localEntries = new Map(entries.map((entry) => [entry.id, entry]));
|
|
298
|
+
const merged: AccessEntry[] = [];
|
|
299
|
+
for (const entry of next) {
|
|
300
|
+
const row = nextCallerEntries.get(entry.id) !== appliedCallerEntries.get(entry.id) ? entry : localEntries.get(entry.id);
|
|
301
|
+
if (row) merged.push({ ...row });
|
|
302
|
+
}
|
|
303
|
+
for (const entry of entries) {
|
|
304
|
+
if (!nextCallerEntries.has(entry.id) && !appliedCallerEntries.has(entry.id)) merged.push({ ...entry });
|
|
305
|
+
}
|
|
306
|
+
setEntries(reconcile(merged, { key: "id" }));
|
|
307
|
+
appliedCallerEntries = nextCallerEntries;
|
|
308
|
+
});
|
|
284
309
|
// The service refuses to remove the last manager; the row says so up front.
|
|
285
310
|
const lastManagerId = createMemo(() => {
|
|
286
311
|
// A grant on part of the resource never manages it.
|
|
@@ -176,7 +176,11 @@ export default function ResourceApiKeys(props: ResourceApiKeysProps) {
|
|
|
176
176
|
const createMutation = mutations.create<{ credential: ResourceApiKey; token: string }, CreateResourceApiKeyInput>({
|
|
177
177
|
mutation: props.createKey,
|
|
178
178
|
onSuccess: async (data) => {
|
|
179
|
-
setKeys(
|
|
179
|
+
setKeys((current) =>
|
|
180
|
+
current.some((key) => key.id === data.credential.id)
|
|
181
|
+
? current.map((key) => (key.id === data.credential.id ? data.credential : key))
|
|
182
|
+
: [data.credential, ...current],
|
|
183
|
+
);
|
|
180
184
|
await prompts.dialog<void>(() => <TokenDialog token={data.token} />, {
|
|
181
185
|
title: t().keyCreated,
|
|
182
186
|
icon: "ti ti-key",
|
|
@@ -56,7 +56,7 @@ type AiApprovalRouteDependencies = {
|
|
|
56
56
|
limit?: MiddlewareHandler<AuthContext>;
|
|
57
57
|
authenticate?: MiddlewareHandler<AuthContext>;
|
|
58
58
|
listPreferences?: (actorUserId: string, options: { conversationId?: string }) => Promise<AiToolApprovalPreference[]>;
|
|
59
|
-
revokePreference?:
|
|
59
|
+
revokePreference?: typeof revokeAiToolApprovalPreference;
|
|
60
60
|
listCapabilities?: () => Promise<CapabilityRegistryEntry[]>;
|
|
61
61
|
/** Resolves a chat the user owns by its ID or short ID. */
|
|
62
62
|
findConversation?: (ownerUserId: string, conversation: string) => Promise<{ id: string } | null>;
|
|
@@ -157,7 +157,10 @@ export const createAiApprovalPreferenceRoutes = (dependencies: AiApprovalRouteDe
|
|
|
157
157
|
v("param", AiApprovalPreferenceParamSchema),
|
|
158
158
|
async (c) => {
|
|
159
159
|
const user = expectUserBackedActor(c);
|
|
160
|
-
const deleted = await revokePreference(user.id, c.req.valid("param").preferenceId
|
|
160
|
+
const deleted = await revokePreference(user.id, c.req.valid("param").preferenceId, {
|
|
161
|
+
actor: c.get("actor"),
|
|
162
|
+
requestId: c.req.header("x-request-id") ?? null,
|
|
163
|
+
});
|
|
161
164
|
return deleted ? c.json({ deleted: true as const }) : c.json({ message: "Approval not found", code: "NOT_FOUND" }, 404);
|
|
162
165
|
},
|
|
163
166
|
);
|
package/src/ai/approvals.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import { sql } from "bun";
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
|
+
import type { AccessSubject } from "../contracts/shared";
|
|
3
|
+
import type { RequestActor } from "../server/middleware/auth";
|
|
4
|
+
import { type AuditActor, type AuditRecordParams, audit } from "../services/audit";
|
|
5
|
+
import { toPgTextArray } from "../services/postgres";
|
|
2
6
|
import type { AiToolApprovalPolicy, AiTurnRunConfig } from "./types";
|
|
3
7
|
|
|
4
8
|
/** Background authority never inherits a user's interactive Always Allow preference. */
|
|
@@ -31,6 +35,16 @@ export type AiToolApprovalContext = {
|
|
|
31
35
|
actorUserId: string;
|
|
32
36
|
};
|
|
33
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Request identity for approval changes; never persist this context in a turn run config.
|
|
40
|
+
* When actor is absent, audit uses { userId: actorUserId } for the approval's owner.
|
|
41
|
+
* The access subject is derived from actor, or falls back to { type: "user", userId: actorUserId }.
|
|
42
|
+
*/
|
|
43
|
+
export type AiToolApprovalChangeContext = AiToolApprovalContext & {
|
|
44
|
+
actor?: RequestActor;
|
|
45
|
+
requestId?: string | null;
|
|
46
|
+
};
|
|
47
|
+
|
|
34
48
|
/** Where a remembered approval applies: in one chat until it is deleted, or always. */
|
|
35
49
|
export type AiToolApprovalRemember = "chat" | "always";
|
|
36
50
|
|
|
@@ -55,6 +69,59 @@ type AiToolApprovalPreferenceRow = {
|
|
|
55
69
|
expires_at: Date | string | null;
|
|
56
70
|
};
|
|
57
71
|
|
|
72
|
+
type ApprovalAuditRow = Pick<AiToolApprovalPreferenceRow, "id" | "tool_name" | "approval_scope" | "conversation_id">;
|
|
73
|
+
type ApprovalAuditIdentity = {
|
|
74
|
+
actor: AuditActor | null;
|
|
75
|
+
accessSubject: AccessSubject | null;
|
|
76
|
+
serviceAccountId?: string;
|
|
77
|
+
requestId?: string | null;
|
|
78
|
+
reason?: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const approvalAuditIdentity = (context: AiToolApprovalChangeContext): ApprovalAuditIdentity => {
|
|
82
|
+
const user = context.actor?.kind === "user" ? context.actor.user : context.actor?.delegatedUser;
|
|
83
|
+
return {
|
|
84
|
+
actor: context.actor
|
|
85
|
+
? user
|
|
86
|
+
? { userId: user.id, uid: user.uid, provider: user.provider, roles: user.roles }
|
|
87
|
+
: null
|
|
88
|
+
: { userId: context.actorUserId },
|
|
89
|
+
// Match aiChatAccessSubject locally: importing assistant-models creates a cycle through firecrawl-tools.
|
|
90
|
+
accessSubject: user
|
|
91
|
+
? {
|
|
92
|
+
type: "user",
|
|
93
|
+
userId: user.id,
|
|
94
|
+
...(context.actor?.kind === "service_account" ? { delegatedByServiceAccountId: context.actor.serviceAccount.id } : {}),
|
|
95
|
+
}
|
|
96
|
+
: context.actor?.kind === "service_account"
|
|
97
|
+
? { type: "service_account", serviceAccountId: context.actor.serviceAccount.id }
|
|
98
|
+
: { type: "user", userId: context.actorUserId },
|
|
99
|
+
...(context.actor?.kind === "service_account" ? { serviceAccountId: context.actor.serviceAccount.id } : {}),
|
|
100
|
+
requestId: context.requestId,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const approvalAuditEvent = (
|
|
105
|
+
action: "ai.approval.remember" | "ai.approval.revoke",
|
|
106
|
+
row: ApprovalAuditRow,
|
|
107
|
+
identity: ApprovalAuditIdentity,
|
|
108
|
+
): AuditRecordParams => ({
|
|
109
|
+
action,
|
|
110
|
+
outcome: "allowed",
|
|
111
|
+
actor: identity.actor,
|
|
112
|
+
target: { type: "ai_approval", id: row.id, label: row.tool_name },
|
|
113
|
+
requestId: identity.requestId,
|
|
114
|
+
reason: identity.reason,
|
|
115
|
+
metadata: {
|
|
116
|
+
toolName: row.tool_name,
|
|
117
|
+
approvalScope: row.approval_scope,
|
|
118
|
+
remember: row.conversation_id !== null ? "chat" : "always",
|
|
119
|
+
...(row.conversation_id !== null ? { conversationId: row.conversation_id } : {}),
|
|
120
|
+
accessSubject: identity.accessSubject,
|
|
121
|
+
...(identity.serviceAccountId ? { serviceAccountId: identity.serviceAccountId } : {}),
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
58
125
|
export const aiToolApprovalScope = (toolName: string, policy: AiToolApprovalPolicy | undefined): string => {
|
|
59
126
|
if (policy && typeof policy === "object") return policy.scope ?? toolName;
|
|
60
127
|
return toolName;
|
|
@@ -100,20 +167,12 @@ export const hasRememberedAiToolApproval = async (
|
|
|
100
167
|
|
|
101
168
|
/** Remembers an approval everywhere, or in one chat when `conversationId` is set; a chat approval ends with its chat. */
|
|
102
169
|
export const rememberAiToolApproval = async (
|
|
103
|
-
context:
|
|
170
|
+
context: AiToolApprovalChangeContext,
|
|
104
171
|
input: { toolName: string; approvalScope: string; conversationId?: string | null; expiresAt?: Date | null },
|
|
105
172
|
): Promise<void> => {
|
|
106
173
|
const conversationId = input.conversationId ?? null;
|
|
107
|
-
await sql.begin(async () => {
|
|
108
|
-
await
|
|
109
|
-
DELETE FROM ai.tool_approval_preferences
|
|
110
|
-
WHERE actor_user_id = ${context.actorUserId}
|
|
111
|
-
AND tool_name = ${input.toolName}
|
|
112
|
-
AND approval_scope = ${input.approvalScope}
|
|
113
|
-
AND conversation_id IS NOT DISTINCT FROM ${conversationId}::uuid
|
|
114
|
-
`;
|
|
115
|
-
|
|
116
|
-
await sql`
|
|
174
|
+
await sql.begin(async (tx) => {
|
|
175
|
+
const [row] = await tx<{ id: string }[]>`
|
|
117
176
|
INSERT INTO ai.tool_approval_preferences (
|
|
118
177
|
actor_user_id,
|
|
119
178
|
tool_name,
|
|
@@ -130,24 +189,46 @@ export const rememberAiToolApproval = async (
|
|
|
130
189
|
now(),
|
|
131
190
|
${input.expiresAt ?? null}
|
|
132
191
|
)
|
|
192
|
+
ON CONFLICT (actor_user_id, tool_name, approval_scope, conversation_id)
|
|
193
|
+
DO UPDATE SET
|
|
194
|
+
created_at = EXCLUDED.created_at,
|
|
195
|
+
last_used_at = EXCLUDED.last_used_at,
|
|
196
|
+
expires_at = EXCLUDED.expires_at
|
|
197
|
+
RETURNING id
|
|
133
198
|
`;
|
|
199
|
+
await audit.record(
|
|
200
|
+
approvalAuditEvent(
|
|
201
|
+
"ai.approval.remember",
|
|
202
|
+
{
|
|
203
|
+
id: row!.id,
|
|
204
|
+
tool_name: input.toolName,
|
|
205
|
+
approval_scope: input.approvalScope,
|
|
206
|
+
conversation_id: conversationId,
|
|
207
|
+
},
|
|
208
|
+
approvalAuditIdentity(context),
|
|
209
|
+
),
|
|
210
|
+
tx,
|
|
211
|
+
);
|
|
134
212
|
});
|
|
135
213
|
};
|
|
136
214
|
|
|
137
215
|
/** Forgets the approval with exactly this reach: everywhere, or in the one chat `conversationId` names. */
|
|
138
216
|
export const forgetAiToolApproval = async (
|
|
139
|
-
context:
|
|
217
|
+
context: AiToolApprovalChangeContext,
|
|
140
218
|
input: { toolName: string; approvalScope: string; conversationId?: string | null },
|
|
141
219
|
): Promise<boolean> => {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
220
|
+
return sql.begin(async (tx) => {
|
|
221
|
+
const rows = await tx<ApprovalAuditRow[]>`
|
|
222
|
+
DELETE FROM ai.tool_approval_preferences
|
|
223
|
+
WHERE actor_user_id = ${context.actorUserId}
|
|
224
|
+
AND tool_name = ${input.toolName}
|
|
225
|
+
AND approval_scope = ${input.approvalScope}
|
|
226
|
+
AND conversation_id IS NOT DISTINCT FROM ${input.conversationId ?? null}::uuid
|
|
227
|
+
RETURNING id, tool_name, approval_scope, conversation_id
|
|
228
|
+
`;
|
|
229
|
+
for (const row of rows) await audit.record(approvalAuditEvent("ai.approval.revoke", row, approvalAuditIdentity(context)), tx);
|
|
230
|
+
return rows.length > 0;
|
|
231
|
+
});
|
|
151
232
|
};
|
|
152
233
|
|
|
153
234
|
const toIsoString = (value: Date | string): string => (value instanceof Date ? value : new Date(value)).toISOString();
|
|
@@ -186,12 +267,42 @@ export const listAiToolApprovalPreferences = async (
|
|
|
186
267
|
return rows.map(approvalPreferenceFromRow);
|
|
187
268
|
};
|
|
188
269
|
|
|
189
|
-
export const revokeAiToolApprovalPreference = async (
|
|
190
|
-
|
|
270
|
+
export const revokeAiToolApprovalPreference = async (
|
|
271
|
+
actorUserId: string,
|
|
272
|
+
preferenceId: string,
|
|
273
|
+
change?: { actor?: RequestActor; requestId?: string | null },
|
|
274
|
+
): Promise<boolean> =>
|
|
275
|
+
sql.begin(async (tx) => {
|
|
276
|
+
const rows = await tx<ApprovalAuditRow[]>`
|
|
277
|
+
DELETE FROM ai.tool_approval_preferences
|
|
278
|
+
WHERE id = ${preferenceId}
|
|
279
|
+
AND actor_user_id = ${actorUserId}
|
|
280
|
+
RETURNING id, tool_name, approval_scope, conversation_id
|
|
281
|
+
`;
|
|
282
|
+
for (const row of rows)
|
|
283
|
+
await audit.record(approvalAuditEvent("ai.approval.revoke", row, approvalAuditIdentity({ ...change, actorUserId })), tx);
|
|
284
|
+
return rows.length > 0;
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/** Core migrations supply their transaction so deletion and system audit events commit together. */
|
|
288
|
+
export const revokeAiToolApprovalPreferencesAsSystem = async (
|
|
289
|
+
db: SQL,
|
|
290
|
+
input: { ids: readonly string[]; reason: string },
|
|
291
|
+
): Promise<number> => {
|
|
292
|
+
if (input.ids.length === 0) return 0;
|
|
293
|
+
const rows = await db<(ApprovalAuditRow & { actor_user_id: string | null })[]>`
|
|
191
294
|
DELETE FROM ai.tool_approval_preferences
|
|
192
|
-
WHERE id = ${
|
|
193
|
-
|
|
194
|
-
RETURNING id
|
|
295
|
+
WHERE id = ANY(${toPgTextArray([...input.ids])}::uuid[])
|
|
296
|
+
RETURNING id, tool_name, approval_scope, conversation_id, actor_user_id
|
|
195
297
|
`;
|
|
196
|
-
|
|
298
|
+
for (const row of rows)
|
|
299
|
+
await audit.record(
|
|
300
|
+
approvalAuditEvent("ai.approval.revoke", row, {
|
|
301
|
+
actor: null,
|
|
302
|
+
accessSubject: row.actor_user_id === null ? null : { type: "user", userId: row.actor_user_id },
|
|
303
|
+
reason: input.reason,
|
|
304
|
+
}),
|
|
305
|
+
db,
|
|
306
|
+
);
|
|
307
|
+
return rows.length;
|
|
197
308
|
};
|
package/src/ai/browser.ts
CHANGED
|
@@ -189,6 +189,16 @@ export const launchAssistant = async (input: LaunchAssistantInput): Promise<Assi
|
|
|
189
189
|
};
|
|
190
190
|
};
|
|
191
191
|
|
|
192
|
+
export {
|
|
193
|
+
CHAT_TASK_REASON_CODES,
|
|
194
|
+
type ChatTaskReason,
|
|
195
|
+
type ChatTaskReasonCode,
|
|
196
|
+
chatTaskLocale,
|
|
197
|
+
chatTaskNeedsNewTime,
|
|
198
|
+
chatTaskReasonSentence,
|
|
199
|
+
chatTaskReasonState,
|
|
200
|
+
chatTaskStopSentence,
|
|
201
|
+
} from "./chat-task-reasons";
|
|
192
202
|
export {
|
|
193
203
|
CLOUD_AI_STATS_MAX_ITEMS,
|
|
194
204
|
CLOUD_AI_TABLE_MAX_COLUMNS,
|
|
@@ -1,15 +1,4 @@
|
|
|
1
1
|
import { createContext, useContext } from "solid-js";
|
|
2
|
-
import {
|
|
3
|
-
type CapabilityActionWording,
|
|
4
|
-
capabilityActionOutcome,
|
|
5
|
-
capabilityActionSubject,
|
|
6
|
-
resolveCapabilityActionSentences,
|
|
7
|
-
} from "../../_internal/capability-sentences";
|
|
8
|
-
import type { AiTurnBlock } from "../protocol";
|
|
9
|
-
import { CLOUD_AI_TOOL_PRESENTATION } from "../tool-sentences";
|
|
10
|
-
import { displayToolName, isRecord } from "./message-utils";
|
|
11
|
-
|
|
12
|
-
type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
|
|
13
2
|
|
|
14
3
|
const ReaderTimeZoneContext = createContext<string>();
|
|
15
4
|
|
|
@@ -20,36 +9,4 @@ export const AiReaderTimeZoneProvider = ReaderTimeZoneContext.Provider;
|
|
|
20
9
|
export const useReaderTimeZone = (): string =>
|
|
21
10
|
useContext(ReaderTimeZoneContext) ?? (typeof window === "undefined" ? "UTC" : Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
22
11
|
|
|
23
|
-
|
|
24
|
-
* How the chat words one call that acts on something: an app Action through its saved presentation, a
|
|
25
|
-
* Cloud tool through Cloud's own catalog of the same shape. `null` for calls that only read.
|
|
26
|
-
*/
|
|
27
|
-
export const toolWording = (block: ToolBlock, locale: string): CapabilityActionWording | null => {
|
|
28
|
-
const presentation = block.presentation;
|
|
29
|
-
if (presentation?.kind === "capability") {
|
|
30
|
-
if (presentation.capabilityKind !== "action") return null;
|
|
31
|
-
return {
|
|
32
|
-
title: presentation.title,
|
|
33
|
-
...(presentation.sentences ? { sentences: presentation.sentences } : {}),
|
|
34
|
-
...(presentation.fields ? { fields: presentation.fields } : {}),
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
const sentences = resolveCapabilityActionSentences(block.name, CLOUD_AI_TOOL_PRESENTATION, locale);
|
|
38
|
-
return sentences ? { title: displayToolName(block.name, locale), sentences } : null;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
/** What the call does or would do, as one sentence; `null` for calls that only read. */
|
|
42
|
-
export const toolSubject = (block: ToolBlock, locale: string, timeZone: string): string | null => {
|
|
43
|
-
const wording = toolWording(block, locale);
|
|
44
|
-
return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone }) : null;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
/** The sentence for one outcome, or `null` when the tool has none that renders for this call. */
|
|
48
|
-
export const toolOutcome = (block: ToolBlock, key: "done" | "rejected" | "notRun", locale: string, timeZone: string): string | null => {
|
|
49
|
-
const wording = toolWording(block, locale);
|
|
50
|
-
if (!wording) return null;
|
|
51
|
-
const result = isRecord(block.result) ? block.result : undefined;
|
|
52
|
-
// App Actions return their data inside the result envelope; Cloud tools return the data itself.
|
|
53
|
-
const data = block.presentation?.kind === "capability" ? result?.data : block.result;
|
|
54
|
-
return capabilityActionOutcome(wording, key, { input: block.args, data }, { locale, timeZone });
|
|
55
|
-
};
|
|
12
|
+
export { toolOutcome, toolSubject, toolWording } from "../tool-wording";
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
untrack,
|
|
17
17
|
} from "solid-js";
|
|
18
18
|
import { markdown } from "../../shared";
|
|
19
|
+
import { type ChatTaskReason, chatTaskReasonSentence, chatTaskReasonState, chatTaskStopSentence } from "../chat-task-reasons";
|
|
19
20
|
import { AI_TURN_LEASE_MS, type AiTurnBlock } from "../protocol";
|
|
20
21
|
import type { AiTurnError } from "../types";
|
|
21
22
|
import { toolOutcome, toolSubject, useReaderTimeZone } from "./action-sentences";
|
|
@@ -65,8 +66,17 @@ export type AiTurnSegment = {
|
|
|
65
66
|
earlier: boolean;
|
|
66
67
|
/** Read reactively, so a ticking clock does not rebuild the segment. */
|
|
67
68
|
duration: () => AiTurnDuration | null;
|
|
68
|
-
/**
|
|
69
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Shown above the work of a delivered scheduled result. `stop` and `reason` add a system line the chat words in
|
|
71
|
+
* the reader's language; with `stop` or `reasonOnly` that line replaces the stored text.
|
|
72
|
+
*/
|
|
73
|
+
scheduledTask?: {
|
|
74
|
+
taskId: string;
|
|
75
|
+
occurrenceId: string;
|
|
76
|
+
reason?: ChatTaskReason;
|
|
77
|
+
reasonOnly?: true;
|
|
78
|
+
stop?: { cause: "paused" | "deleted"; done: string[] };
|
|
79
|
+
} | null;
|
|
70
80
|
/**
|
|
71
81
|
* The live turn cannot reach the model or the server right now: a model call waits for its retry, or the stream
|
|
72
82
|
* reconnects. The working segment's work line says so while its clock stands; a turn without a work line yet says
|
|
@@ -789,6 +799,7 @@ function AiTurnText(props: {
|
|
|
789
799
|
*/
|
|
790
800
|
export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosureState: AiToolDisclosureState }): JSX.Element {
|
|
791
801
|
const locale = useLocale();
|
|
802
|
+
const timeZone = useReaderTimeZone();
|
|
792
803
|
const chatActions = useAiChatActions();
|
|
793
804
|
const layout = () => props.segment().layout;
|
|
794
805
|
const results = createMemo(() => new Map(layout().results.map((result) => [result.id, result])));
|
|
@@ -829,7 +840,17 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
|
|
|
829
840
|
</div>
|
|
830
841
|
</Show>
|
|
831
842
|
<For each={resultIds()}>{(id) => <AiTurnResultView result={() => results().get(id)} receipt={() => actions().has(id)} />}</For>
|
|
832
|
-
<Show when={
|
|
843
|
+
<Show when={props.segment().scheduledTask?.stop}>
|
|
844
|
+
{(stop) => (
|
|
845
|
+
<Chat.Activity
|
|
846
|
+
class="ai-turn__routine-line"
|
|
847
|
+
wrap
|
|
848
|
+
icon="ti ti-player-stop"
|
|
849
|
+
label={chatTaskStopSentence(stop().done, locale())}
|
|
850
|
+
/>
|
|
851
|
+
)}
|
|
852
|
+
</Show>
|
|
853
|
+
<Show when={layout().text.length > 0 && !props.segment().scheduledTask?.stop && !props.segment().scheduledTask?.reasonOnly}>
|
|
833
854
|
<AiTurnText
|
|
834
855
|
blocks={() => layout().text}
|
|
835
856
|
live={live}
|
|
@@ -838,6 +859,17 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
|
|
|
838
859
|
earlier={() => props.segment().earlier}
|
|
839
860
|
/>
|
|
840
861
|
</Show>
|
|
862
|
+
<Show when={props.segment().scheduledTask?.stop ? undefined : props.segment().scheduledTask?.reason}>
|
|
863
|
+
{(reason) => (
|
|
864
|
+
<Chat.Activity
|
|
865
|
+
class="ai-turn__routine-line"
|
|
866
|
+
wrap
|
|
867
|
+
icon={chatTaskReasonState(reason().code) === "paused" ? "ti ti-player-pause" : "ti ti-alert-triangle"}
|
|
868
|
+
tone={chatTaskReasonState(reason().code) === "paused" ? "neutral" : "warning"}
|
|
869
|
+
label={chatTaskReasonSentence(reason(), { locale: locale(), timeZone })}
|
|
870
|
+
/>
|
|
871
|
+
)}
|
|
872
|
+
</Show>
|
|
841
873
|
<For each={actionIds()}>
|
|
842
874
|
{(id) => <AiTurnActionView action={() => actions().get(id)} turnId={props.segment().turnId} phase={props.segment().phase} />}
|
|
843
875
|
</For>
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
useLocale,
|
|
11
11
|
} from "@k2b/ui";
|
|
12
12
|
import { createMemo, createSignal, createUniqueId, Index, type JSX, Match, onCleanup, onMount, Show, Switch } from "solid-js";
|
|
13
|
-
import type
|
|
13
|
+
import { type CloudAiChartRow, cloudAiNumberFormatters } from "../chart-block";
|
|
14
14
|
import {
|
|
15
15
|
CLOUD_AI_DETAILS_VIEWPORT_ITEMS,
|
|
16
16
|
CLOUD_AI_VIEW_MAX_BYTES,
|
|
@@ -244,12 +244,17 @@ function CloudStatsView(props: { summary: StatsSummary; input: CloudAiStatsView
|
|
|
244
244
|
{(value) => (
|
|
245
245
|
<StatGrid columns={columns()}>
|
|
246
246
|
<Index each={value()}>
|
|
247
|
-
{(tile) => (
|
|
247
|
+
{(tile, index) => (
|
|
248
248
|
<StatCell
|
|
249
249
|
label={tile().label}
|
|
250
250
|
value={tile().value}
|
|
251
251
|
title={tile().value}
|
|
252
252
|
trend={tile().sparkline}
|
|
253
|
+
trendTooltip={({ datum }) => {
|
|
254
|
+
const point = tile().sparkline?.[datum.index];
|
|
255
|
+
const format = cloudAiNumberFormatters(props.input?.items[index]?.format, locale()).value;
|
|
256
|
+
return { rows: point === undefined ? [] : [{ label: tile().label, value: format(point) }] };
|
|
257
|
+
}}
|
|
253
258
|
sub={tile().sub}
|
|
254
259
|
accent={deltaAccent(tile().delta)}
|
|
255
260
|
/>
|
|
@@ -6,14 +6,25 @@ import { dates } from "@k2b/stdlib";
|
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { coreSettings } from "../services";
|
|
8
8
|
import { normalizeWorkflowSchedule } from "../workflows/runtime";
|
|
9
|
+
import type { ChatTaskReason } from "./chat-task-reasons";
|
|
9
10
|
import type { AiChatTask, AiChatTaskOccurrence, AiChatTaskOccurrenceState, AiChatTaskSchedule, AiChatTaskState } from "./chat-tasks";
|
|
10
11
|
import { AI_SHORT_ID_PATTERN } from "./short-id";
|
|
11
12
|
|
|
12
|
-
export const ChatTaskIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character
|
|
13
|
-
export const ChatTaskOccurrenceIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character
|
|
13
|
+
export const ChatTaskIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character routine ID.");
|
|
14
|
+
export const ChatTaskOccurrenceIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character routine run ID.");
|
|
14
15
|
export const AiConversationIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character AI conversation ID.");
|
|
15
16
|
|
|
17
|
+
export const chatTaskName = (task: { title?: string | null; chatTitle?: string | null; prompt: string }): string =>
|
|
18
|
+
task.title?.trim() ||
|
|
19
|
+
task.chatTitle?.trim() ||
|
|
20
|
+
Array.from(task.prompt.split(/\n|:\s/u)[0]!.trim())
|
|
21
|
+
.slice(0, 80)
|
|
22
|
+
.join("");
|
|
23
|
+
|
|
16
24
|
export type AiChatTaskView = {
|
|
25
|
+
title: string | null;
|
|
26
|
+
name: string;
|
|
27
|
+
reason: ChatTaskReason | null;
|
|
17
28
|
nextRunAt?: string | null;
|
|
18
29
|
id: string;
|
|
19
30
|
chatId: string;
|
|
@@ -42,6 +53,9 @@ export type AiChatTaskOccurrenceView = {
|
|
|
42
53
|
};
|
|
43
54
|
|
|
44
55
|
export const toAiChatTaskView = (task: AiChatTask): AiChatTaskView => ({
|
|
56
|
+
title: task.title,
|
|
57
|
+
name: task.name,
|
|
58
|
+
reason: task.reasonCode && task.reasonAt ? { code: task.reasonCode, at: task.reasonAt } : null,
|
|
45
59
|
id: task.shortId,
|
|
46
60
|
chatId: task.chatId,
|
|
47
61
|
chatTitle: task.chatTitle,
|
|
@@ -93,12 +107,23 @@ export const getChatTaskTimezone = async (): Promise<string> =>
|
|
|
93
107
|
export const chatTaskCreateFingerprint = (input: {
|
|
94
108
|
chatId: string;
|
|
95
109
|
prompt: string;
|
|
110
|
+
title?: string;
|
|
96
111
|
schedule: z.infer<typeof ChatTaskScheduleInputSchema>;
|
|
97
112
|
timezone?: string;
|
|
98
113
|
grants?: TaskGrant[];
|
|
99
114
|
}): string =>
|
|
100
115
|
new Bun.CryptoHasher("sha256")
|
|
101
|
-
.update(
|
|
116
|
+
.update(
|
|
117
|
+
JSON.stringify([
|
|
118
|
+
input.chatId,
|
|
119
|
+
input.prompt.trim(),
|
|
120
|
+
input.schedule,
|
|
121
|
+
input.timezone ?? null,
|
|
122
|
+
input.grants ?? [],
|
|
123
|
+
// Without a title the hash stays as before titles existed, so earlier idempotency keys still replay.
|
|
124
|
+
...(input.title?.trim() ? [input.title.trim()] : []),
|
|
125
|
+
]),
|
|
126
|
+
)
|
|
102
127
|
.digest("hex");
|
|
103
128
|
|
|
104
129
|
export const normalizeChatTaskSchedule = async (
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { canonicalLocale } from "../shared/locale";
|
|
3
|
+
|
|
4
|
+
/** Called in a transaction; new columns and reason repairs commit together. */
|
|
5
|
+
export const migrateChatTaskReasons = async (sql: SQL): Promise<void> => {
|
|
6
|
+
await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS reason_code TEXT`.simple();
|
|
7
|
+
await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS reason_at TIMESTAMPTZ`.simple();
|
|
8
|
+
await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS title TEXT`.simple();
|
|
9
|
+
const [column] = await sql`SELECT 1 FROM information_schema.columns
|
|
10
|
+
WHERE table_schema = 'ai' AND table_name = 'chat_tasks' AND column_name = 'locale'`;
|
|
11
|
+
if (!column) {
|
|
12
|
+
await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS locale TEXT`.simple();
|
|
13
|
+
// Data-sized work runs only with the new column. A restart never rescans old turns.
|
|
14
|
+
let cursor: string | null = null;
|
|
15
|
+
while (true) {
|
|
16
|
+
const rows: { id: string; locale: string | null }[] = await sql`
|
|
17
|
+
SELECT task.id, COALESCE(
|
|
18
|
+
(SELECT turn.run_config->>'locale' FROM ai.turns turn
|
|
19
|
+
WHERE turn.conversation_id = task.conversation_id AND turn.run_config->>'kind' = 'chat'
|
|
20
|
+
AND NOT (turn.run_config ? 'background') AND turn.created_at <= task.created_at
|
|
21
|
+
ORDER BY turn.created_at DESC, turn.id DESC LIMIT 1),
|
|
22
|
+
(SELECT turn.run_config->>'locale' FROM ai.turns turn
|
|
23
|
+
WHERE turn.conversation_id = task.conversation_id AND turn.run_config->>'kind' = 'chat'
|
|
24
|
+
AND NOT (turn.run_config ? 'background')
|
|
25
|
+
ORDER BY turn.created_at DESC, turn.id DESC LIMIT 1)) AS locale
|
|
26
|
+
FROM ai.chat_tasks task WHERE (${cursor}::uuid IS NULL OR task.id > ${cursor}::uuid)
|
|
27
|
+
ORDER BY task.id LIMIT 500`;
|
|
28
|
+
if (!rows.length) break;
|
|
29
|
+
for (const row of rows) {
|
|
30
|
+
const locale = canonicalLocale(row.locale);
|
|
31
|
+
if (locale) await sql`UPDATE ai.chat_tasks SET locale = ${locale} WHERE id = ${row.id}::uuid`;
|
|
32
|
+
}
|
|
33
|
+
cursor = rows[rows.length - 1]!.id;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
await sql`
|
|
37
|
+
DO $$ BEGIN
|
|
38
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_reason_code_check') THEN
|
|
39
|
+
ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_reason_code_check
|
|
40
|
+
CHECK (reason_code IN ('paused', 'chat_archived', 'owner_inactive', 'failed', 'rights_changed', 'needs_approval'));
|
|
41
|
+
END IF;
|
|
42
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_title_check') THEN
|
|
43
|
+
ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_title_check CHECK (char_length(btrim(title)) BETWEEN 1 AND 80);
|
|
44
|
+
END IF;
|
|
45
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_reason_state_check') THEN
|
|
46
|
+
-- Repair legacy and rollback writes, preserving valid reasons and their timestamps.
|
|
47
|
+
UPDATE ai.chat_tasks SET reason_code = CASE
|
|
48
|
+
WHEN state NOT IN ('paused', 'needs_attention') THEN NULL
|
|
49
|
+
WHEN state = 'paused' THEN 'paused'
|
|
50
|
+
WHEN last_error IN ('Scheduled task mandate revision changed', 'Scheduled task mandate or sponsor is unavailable') THEN 'rights_changed'
|
|
51
|
+
ELSE 'failed' END,
|
|
52
|
+
reason_at = CASE WHEN state IN ('paused', 'needs_attention') THEN updated_at ELSE NULL END
|
|
53
|
+
WHERE NOT (
|
|
54
|
+
(state IN ('paused', 'needs_attention')) = (reason_code IS NOT NULL)
|
|
55
|
+
AND (reason_code IS NULL OR
|
|
56
|
+
(state = 'paused' AND reason_code IN ('paused', 'chat_archived', 'owner_inactive')) OR
|
|
57
|
+
(state = 'needs_attention' AND reason_code IN ('failed', 'rights_changed', 'needs_approval')))
|
|
58
|
+
);
|
|
59
|
+
ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_reason_state_check CHECK (
|
|
60
|
+
(state IN ('paused', 'needs_attention')) = (reason_code IS NOT NULL)
|
|
61
|
+
AND (reason_code IS NULL OR
|
|
62
|
+
(state = 'paused' AND reason_code IN ('paused', 'chat_archived', 'owner_inactive')) OR
|
|
63
|
+
(state = 'needs_attention' AND reason_code IN ('failed', 'rights_changed', 'needs_approval')))
|
|
64
|
+
);
|
|
65
|
+
END IF;
|
|
66
|
+
END $$
|
|
67
|
+
`.simple();
|
|
68
|
+
};
|