@agent-native/core 0.130.0 → 0.130.1
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +6 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/integrations/a2a-continuation-processor.ts +481 -122
- package/corpus/core/src/integrations/a2a-continuations-store.ts +345 -14
- package/corpus/core/src/integrations/adapters/slack.ts +243 -53
- package/corpus/core/src/integrations/integration-campaign-recovery.ts +4 -1
- package/corpus/core/src/integrations/plugin.ts +39 -21
- package/corpus/core/src/integrations/task-queue-stats.ts +146 -0
- package/corpus/core/src/integrations/types.ts +25 -4
- package/corpus/core/src/integrations/webhook-handler.ts +18 -5
- package/corpus/templates/dispatch/changelog/2026-07-26-slack-replies-recover-after-connected-app-updates.md +6 -0
- package/corpus/templates/slides/.agents/skills/design-systems/SKILL.md +15 -0
- package/corpus/templates/slides/actions/delete-design-system.ts +145 -0
- package/corpus/templates/slides/actions/list-design-systems.ts +94 -2
- package/corpus/templates/slides/app/components/design-system/DesignSystemCard.tsx +43 -1
- package/corpus/templates/slides/app/hooks/use-design-systems.ts +2 -0
- package/corpus/templates/slides/app/i18n/en-US.ts +7 -0
- package/corpus/templates/slides/app/pages/DesignSystems.tsx +56 -1
- package/corpus/templates/slides/changelog/2026-07-28-delete-a-design-system-you-own-from-the-design-systems-page-.md +6 -0
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/integrations/a2a-continuation-processor.d.ts +5 -0
- package/dist/integrations/a2a-continuation-processor.d.ts.map +1 -1
- package/dist/integrations/a2a-continuation-processor.js +293 -79
- package/dist/integrations/a2a-continuation-processor.js.map +1 -1
- package/dist/integrations/a2a-continuations-store.d.ts +23 -1
- package/dist/integrations/a2a-continuations-store.d.ts.map +1 -1
- package/dist/integrations/a2a-continuations-store.js +249 -15
- package/dist/integrations/a2a-continuations-store.js.map +1 -1
- package/dist/integrations/adapters/slack.d.ts.map +1 -1
- package/dist/integrations/adapters/slack.js +155 -31
- package/dist/integrations/adapters/slack.js.map +1 -1
- package/dist/integrations/integration-campaign-recovery.d.ts.map +1 -1
- package/dist/integrations/integration-campaign-recovery.js +3 -1
- package/dist/integrations/integration-campaign-recovery.js.map +1 -1
- package/dist/integrations/plugin.d.ts.map +1 -1
- package/dist/integrations/plugin.js +29 -12
- package/dist/integrations/plugin.js.map +1 -1
- package/dist/integrations/task-queue-stats.d.ts +12 -0
- package/dist/integrations/task-queue-stats.d.ts.map +1 -1
- package/dist/integrations/task-queue-stats.js +106 -0
- package/dist/integrations/task-queue-stats.js.map +1 -1
- package/dist/integrations/types.d.ts +24 -6
- package/dist/integrations/types.d.ts.map +1 -1
- package/dist/integrations/types.js.map +1 -1
- package/dist/integrations/webhook-handler.d.ts.map +1 -1
- package/dist/integrations/webhook-handler.js +12 -3
- package/dist/integrations/webhook-handler.js.map +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
- package/src/integrations/a2a-continuation-processor.ts +481 -122
- package/src/integrations/a2a-continuations-store.ts +345 -14
- package/src/integrations/adapters/slack.ts +243 -53
- package/src/integrations/integration-campaign-recovery.ts +4 -1
- package/src/integrations/plugin.ts +39 -21
- package/src/integrations/task-queue-stats.ts +146 -0
- package/src/integrations/types.ts +25 -4
- package/src/integrations/webhook-handler.ts +18 -5
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* columns before the SELECTs execute.
|
|
9
9
|
*/
|
|
10
10
|
import { getDbExec } from "../db/client.js";
|
|
11
|
+
import { ensureA2AContinuationsTable } from "./a2a-continuations-store.js";
|
|
11
12
|
import { ensurePendingTasksTable } from "./pending-tasks-store.js";
|
|
12
13
|
|
|
13
14
|
export interface RecentFailure {
|
|
@@ -23,6 +24,18 @@ export interface TaskQueueStats {
|
|
|
23
24
|
completed_last_hour: number;
|
|
24
25
|
failed_last_hour: number;
|
|
25
26
|
oldest_pending_age_seconds: number;
|
|
27
|
+
waiting_campaigns: number;
|
|
28
|
+
active_a2a_continuations: number;
|
|
29
|
+
oldest_active_a2a_age_seconds: number;
|
|
30
|
+
terminal_a2a_without_delivery: number;
|
|
31
|
+
recent_a2a_orphans: Array<{
|
|
32
|
+
continuation_id: string;
|
|
33
|
+
integration_task_id: string;
|
|
34
|
+
status: string;
|
|
35
|
+
attempts: number;
|
|
36
|
+
age_seconds: number;
|
|
37
|
+
reason_class: string;
|
|
38
|
+
}>;
|
|
26
39
|
recent_failures: RecentFailure[];
|
|
27
40
|
recent_tasks: Array<{
|
|
28
41
|
id: string;
|
|
@@ -46,6 +59,11 @@ const ZERO_STATS: TaskQueueStats = {
|
|
|
46
59
|
completed_last_hour: 0,
|
|
47
60
|
failed_last_hour: 0,
|
|
48
61
|
oldest_pending_age_seconds: 0,
|
|
62
|
+
waiting_campaigns: 0,
|
|
63
|
+
active_a2a_continuations: 0,
|
|
64
|
+
oldest_active_a2a_age_seconds: 0,
|
|
65
|
+
terminal_a2a_without_delivery: 0,
|
|
66
|
+
recent_a2a_orphans: [],
|
|
49
67
|
recent_failures: [],
|
|
50
68
|
recent_tasks: [],
|
|
51
69
|
};
|
|
@@ -66,11 +84,14 @@ export async function getTaskQueueStats(
|
|
|
66
84
|
scope: TaskQueueStatsScope,
|
|
67
85
|
): Promise<TaskQueueStats> {
|
|
68
86
|
await ensurePendingTasksTable();
|
|
87
|
+
await ensureA2AContinuationsTable();
|
|
69
88
|
const client = getDbExec();
|
|
70
89
|
const now = Date.now();
|
|
71
90
|
const oneHourAgo = now - 60 * 60 * 1000;
|
|
72
91
|
const scopeSql = `owner_email = ?
|
|
73
92
|
AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)`;
|
|
93
|
+
const joinedScopeSql = `t.owner_email = ?
|
|
94
|
+
AND ((t.org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR t.org_id = ?)`;
|
|
74
95
|
const scopeArgs = [scope.ownerEmail, scope.orgId, scope.orgId];
|
|
75
96
|
|
|
76
97
|
try {
|
|
@@ -178,6 +199,17 @@ export async function getTaskQueueStats(
|
|
|
178
199
|
),
|
|
179
200
|
}),
|
|
180
201
|
);
|
|
202
|
+
const waitingCampaigns = await readWaitingCampaignCount(
|
|
203
|
+
client,
|
|
204
|
+
joinedScopeSql,
|
|
205
|
+
scopeArgs,
|
|
206
|
+
);
|
|
207
|
+
const a2aStats = await readA2AContinuationStats(
|
|
208
|
+
client,
|
|
209
|
+
joinedScopeSql,
|
|
210
|
+
scopeArgs,
|
|
211
|
+
now,
|
|
212
|
+
);
|
|
181
213
|
|
|
182
214
|
return {
|
|
183
215
|
pending,
|
|
@@ -185,6 +217,8 @@ export async function getTaskQueueStats(
|
|
|
185
217
|
completed_last_hour: completedLastHour,
|
|
186
218
|
failed_last_hour: failedLastHour,
|
|
187
219
|
oldest_pending_age_seconds: oldestPendingAgeSeconds,
|
|
220
|
+
waiting_campaigns: waitingCampaigns,
|
|
221
|
+
...a2aStats,
|
|
188
222
|
recent_failures: recentFailures,
|
|
189
223
|
recent_tasks: recentTasks,
|
|
190
224
|
};
|
|
@@ -195,3 +229,115 @@ export async function getTaskQueueStats(
|
|
|
195
229
|
throw err;
|
|
196
230
|
}
|
|
197
231
|
}
|
|
232
|
+
|
|
233
|
+
async function readWaitingCampaignCount(
|
|
234
|
+
client: ReturnType<typeof getDbExec>,
|
|
235
|
+
scopeSql: string,
|
|
236
|
+
scopeArgs: Array<string | null>,
|
|
237
|
+
): Promise<number> {
|
|
238
|
+
try {
|
|
239
|
+
const result = await client.execute({
|
|
240
|
+
sql: `SELECT COUNT(*) AS c
|
|
241
|
+
FROM integration_campaigns c
|
|
242
|
+
INNER JOIN integration_pending_tasks t
|
|
243
|
+
ON t.id = c.integration_task_id
|
|
244
|
+
WHERE ${scopeSql}
|
|
245
|
+
AND c.status = 'waiting'`,
|
|
246
|
+
args: scopeArgs,
|
|
247
|
+
});
|
|
248
|
+
return Number(result.rows[0]?.c ?? 0);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
if (isMissingTableError(err)) return 0;
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function readA2AContinuationStats(
|
|
256
|
+
client: ReturnType<typeof getDbExec>,
|
|
257
|
+
scopeSql: string,
|
|
258
|
+
scopeArgs: Array<string | null>,
|
|
259
|
+
now: number,
|
|
260
|
+
): Promise<
|
|
261
|
+
Pick<
|
|
262
|
+
TaskQueueStats,
|
|
263
|
+
| "active_a2a_continuations"
|
|
264
|
+
| "oldest_active_a2a_age_seconds"
|
|
265
|
+
| "terminal_a2a_without_delivery"
|
|
266
|
+
| "recent_a2a_orphans"
|
|
267
|
+
>
|
|
268
|
+
> {
|
|
269
|
+
try {
|
|
270
|
+
const live = await client.execute({
|
|
271
|
+
sql: `SELECT
|
|
272
|
+
COUNT(CASE WHEN c.status IN ('pending', 'processing', 'delivering') THEN 1 END) AS active_count,
|
|
273
|
+
MIN(CASE WHEN c.status IN ('pending', 'processing', 'delivering') THEN c.created_at END) AS oldest_created_at,
|
|
274
|
+
COUNT(CASE WHEN c.status IN ('completed', 'failed')
|
|
275
|
+
AND c.terminal_delivery_confirmed_at IS NULL THEN 1 END) AS orphan_count
|
|
276
|
+
FROM integration_a2a_continuations c
|
|
277
|
+
INNER JOIN integration_pending_tasks t
|
|
278
|
+
ON t.id = c.integration_task_id
|
|
279
|
+
WHERE ${scopeSql}`,
|
|
280
|
+
args: scopeArgs,
|
|
281
|
+
});
|
|
282
|
+
const orphaned = await client.execute({
|
|
283
|
+
sql: `SELECT c.id, c.integration_task_id, c.status, c.attempts,
|
|
284
|
+
c.created_at, c.error_message
|
|
285
|
+
FROM integration_a2a_continuations c
|
|
286
|
+
INNER JOIN integration_pending_tasks t
|
|
287
|
+
ON t.id = c.integration_task_id
|
|
288
|
+
WHERE ${scopeSql}
|
|
289
|
+
AND c.status IN ('completed', 'failed')
|
|
290
|
+
AND c.terminal_delivery_confirmed_at IS NULL
|
|
291
|
+
ORDER BY c.updated_at DESC
|
|
292
|
+
LIMIT 5`,
|
|
293
|
+
args: scopeArgs,
|
|
294
|
+
});
|
|
295
|
+
const activeCount = Number(live.rows[0]?.active_count ?? 0);
|
|
296
|
+
const orphanCount = Number(live.rows[0]?.orphan_count ?? 0);
|
|
297
|
+
const oldestCreatedAt = Number(live.rows[0]?.oldest_created_at ?? now);
|
|
298
|
+
const recentOrphans = (orphaned.rows as Array<Record<string, unknown>>).map(
|
|
299
|
+
(row) => ({
|
|
300
|
+
continuation_id: String(row.id ?? ""),
|
|
301
|
+
integration_task_id: String(row.integration_task_id ?? ""),
|
|
302
|
+
status: String(row.status ?? ""),
|
|
303
|
+
attempts: Number(row.attempts ?? 0),
|
|
304
|
+
age_seconds: Math.max(
|
|
305
|
+
0,
|
|
306
|
+
Math.floor((now - Number(row.created_at ?? now)) / 1000),
|
|
307
|
+
),
|
|
308
|
+
reason_class: classifyA2AOrphanReason(row.error_message),
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
return {
|
|
312
|
+
active_a2a_continuations: activeCount,
|
|
313
|
+
oldest_active_a2a_age_seconds:
|
|
314
|
+
activeCount > 0
|
|
315
|
+
? Math.max(0, Math.floor((now - oldestCreatedAt) / 1000))
|
|
316
|
+
: 0,
|
|
317
|
+
terminal_a2a_without_delivery: orphanCount,
|
|
318
|
+
recent_a2a_orphans: recentOrphans,
|
|
319
|
+
};
|
|
320
|
+
} catch (err) {
|
|
321
|
+
if (isMissingTableError(err)) {
|
|
322
|
+
return {
|
|
323
|
+
active_a2a_continuations: 0,
|
|
324
|
+
oldest_active_a2a_age_seconds: 0,
|
|
325
|
+
terminal_a2a_without_delivery: 0,
|
|
326
|
+
recent_a2a_orphans: [],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function classifyA2AOrphanReason(value: unknown): string {
|
|
334
|
+
const message = String(value ?? "");
|
|
335
|
+
if (!message) return "missing_terminal_delivery";
|
|
336
|
+
if (/timeout|timed out|abort/i.test(message)) return "timeout";
|
|
337
|
+
if (/token|secret|auth|credential/i.test(message)) return "authentication";
|
|
338
|
+
if (/database|sql|store|persist/i.test(message)) return "persistence";
|
|
339
|
+
if (/slack|provider|deliver|stream|response/i.test(message)) {
|
|
340
|
+
return "provider_delivery";
|
|
341
|
+
}
|
|
342
|
+
return "processor_failure";
|
|
343
|
+
}
|
|
@@ -134,6 +134,19 @@ export interface PlatformDeliveryReceipt {
|
|
|
134
134
|
messageRefs?: string[];
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
export interface PlatformDeliveryOptions {
|
|
138
|
+
/** Provider-owned message reference that should be updated in place. */
|
|
139
|
+
placeholderRef?: string;
|
|
140
|
+
/** Stable logical delivery key for provider-side retry reconciliation. */
|
|
141
|
+
idempotencyKey?: string;
|
|
142
|
+
/** Earliest provider timestamp (epoch ms) that can contain this delivery. */
|
|
143
|
+
reconcileAfter?: number;
|
|
144
|
+
/** Abort provider I/O before the caller releases its durable delivery claim. */
|
|
145
|
+
signal?: AbortSignal;
|
|
146
|
+
/** Do not fall back to a fresh post if the stable target cannot be updated. */
|
|
147
|
+
strictTargetRef?: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
137
150
|
/**
|
|
138
151
|
* Proactive outbound message target for a platform.
|
|
139
152
|
* Used when the agent needs to send to a saved destination instead of replying
|
|
@@ -201,12 +214,20 @@ export interface PlatformRunProgress {
|
|
|
201
214
|
* credentials, or provider payload.
|
|
202
215
|
*/
|
|
203
216
|
ref?: PlatformRunProgressRef;
|
|
217
|
+
/** Stable provider message target that can receive the terminal answer. */
|
|
218
|
+
responseTargetRef?: string;
|
|
204
219
|
/** Receive normalized agent events. Implementations should throttle writes. */
|
|
205
|
-
onEvent(
|
|
220
|
+
onEvent(
|
|
221
|
+
event: AgentChatEvent,
|
|
222
|
+
opts?: { signal?: AbortSignal },
|
|
223
|
+
): Promise<void> | void;
|
|
206
224
|
/** Finalize the provider-native progress surface with the answer. */
|
|
207
|
-
complete(
|
|
225
|
+
complete(
|
|
226
|
+
message: OutgoingMessage,
|
|
227
|
+
opts?: { signal?: AbortSignal },
|
|
228
|
+
): Promise<void | PlatformDeliveryReceipt>;
|
|
208
229
|
/** Mark the provider-native surface failed and leave a retryable explanation. */
|
|
209
|
-
fail?(message: string): Promise<void>;
|
|
230
|
+
fail?(message: string, opts?: { signal?: AbortSignal }): Promise<void>;
|
|
210
231
|
}
|
|
211
232
|
|
|
212
233
|
/**
|
|
@@ -322,7 +343,7 @@ export interface PlatformAdapter {
|
|
|
322
343
|
sendResponse(
|
|
323
344
|
message: OutgoingMessage,
|
|
324
345
|
context: IncomingMessage,
|
|
325
|
-
opts?:
|
|
346
|
+
opts?: PlatformDeliveryOptions,
|
|
326
347
|
): Promise<void | PlatformDeliveryReceipt>;
|
|
327
348
|
|
|
328
349
|
/**
|
|
@@ -67,7 +67,8 @@ import {
|
|
|
67
67
|
import { runWithRequestContext } from "../server/request-context.js";
|
|
68
68
|
import { normalizeReasoningEffortForRequest } from "../shared/reasoning-effort.js";
|
|
69
69
|
import { A2A_CONTINUATION_QUEUED_MARKER } from "./a2a-continuation-marker.js";
|
|
70
|
-
import {
|
|
70
|
+
import { reconcileTerminalA2AParentIfDisabled } from "./a2a-continuation-processor.js";
|
|
71
|
+
import { getA2AContinuationTaskOutcome } from "./a2a-continuations-store.js";
|
|
71
72
|
import {
|
|
72
73
|
clearIntegrationAwaitingInput,
|
|
73
74
|
setIntegrationAwaitingInput,
|
|
@@ -1086,10 +1087,22 @@ async function processIncomingMessage(
|
|
|
1086
1087
|
JSON.parse(campaign.row.checkpoint).waitingForA2A === true;
|
|
1087
1088
|
} catch {}
|
|
1088
1089
|
if (waitingForA2A) {
|
|
1089
|
-
const
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1090
|
+
const a2aOutcome = await getA2AContinuationTaskOutcome(opts.taskId);
|
|
1091
|
+
if (a2aOutcome !== "terminal-delivered") {
|
|
1092
|
+
if (
|
|
1093
|
+
a2aOutcome === "terminal-without-delivery" &&
|
|
1094
|
+
(await reconcileTerminalA2AParentIfDisabled(opts.taskId))
|
|
1095
|
+
) {
|
|
1096
|
+
await releaseApplicableIntegrationBudgets(
|
|
1097
|
+
budgetReservations.reservations,
|
|
1098
|
+
);
|
|
1099
|
+
return { status: "campaign-failed" };
|
|
1100
|
+
}
|
|
1101
|
+
if (a2aOutcome !== "active") {
|
|
1102
|
+
console.warn(
|
|
1103
|
+
`[integrations] Waiting campaign ${campaign.row.id} has A2A outcome ${a2aOutcome} without terminal delivery proof`,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1093
1106
|
const waiting = await waitForA2AIntegrationCampaign(campaign.row.id, {
|
|
1094
1107
|
runId: campaign.runId,
|
|
1095
1108
|
leaseToken: campaign.leaseToken,
|
|
@@ -40,6 +40,21 @@ Do not call `create-design-system` locally from `.fig` uploads. Do not call
|
|
|
40
40
|
`import-document` for `.fig` files; it only handles metadata and will miss the
|
|
41
41
|
Builder indexing flow.
|
|
42
42
|
|
|
43
|
+
## Deleting a Design System
|
|
44
|
+
|
|
45
|
+
`delete-design-system` requires admin access or higher (owner or admin share
|
|
46
|
+
role) and removes the system, its shares, and the `designSystemId` link on
|
|
47
|
+
every linked deck the caller can edit — decks the caller can't edit keep a
|
|
48
|
+
dangling reference instead of being silently mutated, reported back as
|
|
49
|
+
`decksSkippedForAccess` (clear it later with `patch-deck`'s
|
|
50
|
+
`patch-deck-fields`, `designSystemId: null`). Those decks keep the tokens
|
|
51
|
+
already baked into their slides — deletion never rewrites deck content — so a
|
|
52
|
+
deck can look on-brand while no longer being linked to a system. If the
|
|
53
|
+
deleted system was the caller's default, another of their design systems is
|
|
54
|
+
promoted to default so future deck creation doesn't silently drop to "no
|
|
55
|
+
design system". Deletion does not remove an upstream Builder-indexed design
|
|
56
|
+
system.
|
|
57
|
+
|
|
43
58
|
## Applying to Slides
|
|
44
59
|
|
|
45
60
|
Before creating or extending a system, read the `creative-context` skill and
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { defineAction } from "@agent-native/core";
|
|
2
|
+
import {
|
|
3
|
+
assertAccess,
|
|
4
|
+
resolveAccess,
|
|
5
|
+
type ShareRole,
|
|
6
|
+
} from "@agent-native/core/sharing";
|
|
7
|
+
import { desc, eq } from "drizzle-orm";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
import { getDb, schema } from "../server/db/index.js";
|
|
11
|
+
import { withDeckLock } from "./patch-deck.js";
|
|
12
|
+
|
|
13
|
+
function canEditDeckRole(role: "owner" | ShareRole) {
|
|
14
|
+
return role === "owner" || role === "admin" || role === "editor";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type UnlinkResult =
|
|
18
|
+
| { deckId: string; status: "unlinked" }
|
|
19
|
+
| { deckId: string; status: "skipped-no-access" };
|
|
20
|
+
|
|
21
|
+
// Runs under the same per-deck lock patch-deck/save-deck/update-slide use for
|
|
22
|
+
// all deck writes, re-reading the row inside the lock so a concurrent slide
|
|
23
|
+
// edit can't be clobbered by this read-modify-write. Decks the caller can't
|
|
24
|
+
// edit are left pointing at the deleted id rather than silently mutated —
|
|
25
|
+
// admin access on a shared design system does not imply edit access on every
|
|
26
|
+
// deck that happens to reference it.
|
|
27
|
+
function unlinkDeck(
|
|
28
|
+
deckId: string,
|
|
29
|
+
designSystemId: string,
|
|
30
|
+
): Promise<UnlinkResult> {
|
|
31
|
+
return withDeckLock(deckId, async () => {
|
|
32
|
+
const access = await resolveAccess("deck", deckId);
|
|
33
|
+
if (!access || !canEditDeckRole(access.role)) {
|
|
34
|
+
return { deckId, status: "skipped-no-access" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const db = getDb();
|
|
38
|
+
const [deck] = await db
|
|
39
|
+
.select({
|
|
40
|
+
designSystemId: schema.decks.designSystemId,
|
|
41
|
+
data: schema.decks.data,
|
|
42
|
+
})
|
|
43
|
+
.from(schema.decks)
|
|
44
|
+
.where(eq(schema.decks.id, deckId));
|
|
45
|
+
if (!deck || deck.designSystemId !== designSystemId) {
|
|
46
|
+
return { deckId, status: "unlinked" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const data = JSON.parse(deck.data);
|
|
50
|
+
if ("designSystemId" in data) delete data.designSystemId;
|
|
51
|
+
await db
|
|
52
|
+
.update(schema.decks)
|
|
53
|
+
.set({
|
|
54
|
+
designSystemId: null,
|
|
55
|
+
data: JSON.stringify(data),
|
|
56
|
+
updatedAt: new Date().toISOString(),
|
|
57
|
+
})
|
|
58
|
+
.where(eq(schema.decks.id, deckId));
|
|
59
|
+
return { deckId, status: "unlinked" };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default defineAction({
|
|
64
|
+
description:
|
|
65
|
+
"Delete a design system. Requires admin access or higher. Decks linked to it that " +
|
|
66
|
+
"the caller can edit are unlinked; others keep a dangling reference (clear it with " +
|
|
67
|
+
"patch-deck's patch-deck-fields, designSystemId: null). If the deleted system was the " +
|
|
68
|
+
"caller's default, another of their design systems is promoted to default.",
|
|
69
|
+
schema: z.object({
|
|
70
|
+
id: z.string().min(1).describe("Design system ID to delete"),
|
|
71
|
+
}),
|
|
72
|
+
run: async ({ id }) => {
|
|
73
|
+
const access = await assertAccess("design-system", id, "admin");
|
|
74
|
+
|
|
75
|
+
const db = getDb();
|
|
76
|
+
|
|
77
|
+
const linkedDeckIds = (
|
|
78
|
+
await db
|
|
79
|
+
.select({ id: schema.decks.id })
|
|
80
|
+
.from(schema.decks)
|
|
81
|
+
.where(eq(schema.decks.designSystemId, id))
|
|
82
|
+
).map((row) => row.id);
|
|
83
|
+
|
|
84
|
+
// Delete the design system (and its shares) before touching linked decks.
|
|
85
|
+
// Once the row is gone, apply-design-system/create-deck's
|
|
86
|
+
// assertAccess("design-system", ...) check fails for anyone trying to
|
|
87
|
+
// attach a fresh link, shrinking the window for a deck to end up pointing
|
|
88
|
+
// at a design system we're about to remove. If this was the owner's
|
|
89
|
+
// default, promote another of their design systems in the same
|
|
90
|
+
// transaction so deck creation never silently drops to "no design
|
|
91
|
+
// system" just because the default happened to be deleted.
|
|
92
|
+
await db.transaction(async (tx) => {
|
|
93
|
+
await tx
|
|
94
|
+
.delete(schema.designSystemShares)
|
|
95
|
+
.where(eq(schema.designSystemShares.resourceId, id));
|
|
96
|
+
|
|
97
|
+
await tx
|
|
98
|
+
.delete(schema.designSystems)
|
|
99
|
+
.where(eq(schema.designSystems.id, id));
|
|
100
|
+
|
|
101
|
+
if (access.resource.isDefault) {
|
|
102
|
+
const [next] = await tx
|
|
103
|
+
.select({ id: schema.designSystems.id })
|
|
104
|
+
.from(schema.designSystems)
|
|
105
|
+
.where(
|
|
106
|
+
eq(schema.designSystems.ownerEmail, access.resource.ownerEmail),
|
|
107
|
+
)
|
|
108
|
+
.orderBy(desc(schema.designSystems.updatedAt))
|
|
109
|
+
.limit(1);
|
|
110
|
+
if (next) {
|
|
111
|
+
await tx
|
|
112
|
+
.update(schema.designSystems)
|
|
113
|
+
.set({ isDefault: true, updatedAt: new Date().toISOString() })
|
|
114
|
+
.where(eq(schema.designSystems.id, next.id));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Best-effort cleanup: the design system is already gone, so a deck we
|
|
120
|
+
// can't touch (missing access) or fail to update (write error) is left
|
|
121
|
+
// dangling rather than retried here. Both cases are reported back instead
|
|
122
|
+
// of swallowed, so the caller can decide whether to intervene.
|
|
123
|
+
const settled = await Promise.allSettled(
|
|
124
|
+
linkedDeckIds.map((deckId) => unlinkDeck(deckId, id)),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const decksSkippedForAccess: string[] = [];
|
|
128
|
+
const decksFailedToUnlink: string[] = [];
|
|
129
|
+
settled.forEach((result, index) => {
|
|
130
|
+
const deckId = linkedDeckIds[index];
|
|
131
|
+
if (result.status === "rejected") {
|
|
132
|
+
decksFailedToUnlink.push(deckId);
|
|
133
|
+
} else if (result.value.status === "skipped-no-access") {
|
|
134
|
+
decksSkippedForAccess.push(deckId);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
id,
|
|
140
|
+
deleted: true,
|
|
141
|
+
...(decksSkippedForAccess.length > 0 ? { decksSkippedForAccess } : {}),
|
|
142
|
+
...(decksFailedToUnlink.length > 0 ? { decksFailedToUnlink } : {}),
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
});
|
|
@@ -1,10 +1,38 @@
|
|
|
1
1
|
import { defineAction } from "@agent-native/core";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
getRequestOrgId,
|
|
4
|
+
getRequestUserEmail,
|
|
5
|
+
} from "@agent-native/core/server/request-context";
|
|
6
|
+
import {
|
|
7
|
+
accessFilter,
|
|
8
|
+
ROLE_RANK,
|
|
9
|
+
type ShareRole,
|
|
10
|
+
} from "@agent-native/core/sharing";
|
|
11
|
+
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
|
|
4
12
|
import { z } from "zod";
|
|
5
13
|
|
|
6
14
|
import { getDb, schema } from "../server/db/index.js";
|
|
7
15
|
|
|
16
|
+
type EffectiveRole = "owner" | ShareRole;
|
|
17
|
+
|
|
18
|
+
function canManageRole(role: EffectiveRole) {
|
|
19
|
+
return role === "owner" || role === "admin";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Mirrors the core access model (assertAccess/resolveAccess), which compares
|
|
23
|
+
// emails with `lower(column) = lowercased-input` so a share or ownership
|
|
24
|
+
// grant survives casing differences between the stored principal and the
|
|
25
|
+
// caller's session email.
|
|
26
|
+
function normalizeEmail(email: string | undefined): string | null {
|
|
27
|
+
const normalized = email?.trim().toLowerCase();
|
|
28
|
+
return normalized || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function strongerRole(current: ShareRole | null, next: ShareRole): ShareRole {
|
|
32
|
+
if (!current || ROLE_RANK[next] > ROLE_RANK[current]) return next;
|
|
33
|
+
return current;
|
|
34
|
+
}
|
|
35
|
+
|
|
8
36
|
export default defineAction({
|
|
9
37
|
description:
|
|
10
38
|
"List all design systems accessible to the current user. " +
|
|
@@ -19,6 +47,8 @@ export default defineAction({
|
|
|
19
47
|
http: { method: "GET" },
|
|
20
48
|
run: async (args) => {
|
|
21
49
|
const db = getDb();
|
|
50
|
+
const userEmail = normalizeEmail(getRequestUserEmail());
|
|
51
|
+
const orgId = getRequestOrgId();
|
|
22
52
|
// Project only the columns this list returns. The default path returns
|
|
23
53
|
// `data`, but neither path returns the heavy `assets` blob — a bare
|
|
24
54
|
// `.select()` would load it off every row for nothing.
|
|
@@ -30,6 +60,8 @@ export default defineAction({
|
|
|
30
60
|
data: schema.designSystems.data,
|
|
31
61
|
isDefault: schema.designSystems.isDefault,
|
|
32
62
|
visibility: schema.designSystems.visibility,
|
|
63
|
+
ownerEmail: schema.designSystems.ownerEmail,
|
|
64
|
+
orgId: schema.designSystems.orgId,
|
|
33
65
|
createdAt: schema.designSystems.createdAt,
|
|
34
66
|
updatedAt: schema.designSystems.updatedAt,
|
|
35
67
|
})
|
|
@@ -41,12 +73,70 @@ export default defineAction({
|
|
|
41
73
|
return { count: 0, designSystems: [] };
|
|
42
74
|
}
|
|
43
75
|
|
|
76
|
+
// Resolve every row's role from a single batched shares query instead of
|
|
77
|
+
// calling resolveAccess() per row, which would re-load each resource and
|
|
78
|
+
// its shares (N+1) and fan out an unbounded Promise.all as the list grows.
|
|
79
|
+
const principalClauses: NonNullable<ReturnType<typeof and>>[] = [];
|
|
80
|
+
if (userEmail) {
|
|
81
|
+
principalClauses.push(
|
|
82
|
+
and(
|
|
83
|
+
eq(schema.designSystemShares.principalType, "user"),
|
|
84
|
+
sql`lower(${schema.designSystemShares.principalId}) = ${userEmail}`,
|
|
85
|
+
)!,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (orgId) {
|
|
89
|
+
principalClauses.push(
|
|
90
|
+
and(
|
|
91
|
+
eq(schema.designSystemShares.principalType, "org"),
|
|
92
|
+
eq(schema.designSystemShares.principalId, orgId),
|
|
93
|
+
)!,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const shareRoleById = new Map<string, ShareRole>();
|
|
98
|
+
if (principalClauses.length > 0) {
|
|
99
|
+
const shareRows = await db
|
|
100
|
+
.select({
|
|
101
|
+
resourceId: schema.designSystemShares.resourceId,
|
|
102
|
+
role: schema.designSystemShares.role,
|
|
103
|
+
})
|
|
104
|
+
.from(schema.designSystemShares)
|
|
105
|
+
.where(
|
|
106
|
+
and(
|
|
107
|
+
inArray(
|
|
108
|
+
schema.designSystemShares.resourceId,
|
|
109
|
+
rows.map((row) => row.id),
|
|
110
|
+
),
|
|
111
|
+
or(...principalClauses),
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
for (const share of shareRows) {
|
|
115
|
+
shareRoleById.set(
|
|
116
|
+
share.resourceId,
|
|
117
|
+
strongerRole(shareRoleById.get(share.resourceId) ?? null, share.role),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
44
122
|
const items = rows.map((row) => {
|
|
123
|
+
let role: EffectiveRole = shareRoleById.get(row.id) ?? "viewer";
|
|
124
|
+
if (
|
|
125
|
+
userEmail &&
|
|
126
|
+
normalizeEmail(row.ownerEmail) === userEmail &&
|
|
127
|
+
(!row.orgId || row.orgId === orgId)
|
|
128
|
+
) {
|
|
129
|
+
role = "owner";
|
|
130
|
+
}
|
|
131
|
+
const canManage = canManageRole(role);
|
|
132
|
+
|
|
45
133
|
if (args.compact === "true") {
|
|
46
134
|
return {
|
|
47
135
|
id: row.id,
|
|
48
136
|
title: row.title,
|
|
49
137
|
isDefault: row.isDefault,
|
|
138
|
+
accessRole: role,
|
|
139
|
+
canManage,
|
|
50
140
|
};
|
|
51
141
|
}
|
|
52
142
|
return {
|
|
@@ -56,6 +146,8 @@ export default defineAction({
|
|
|
56
146
|
data: row.data,
|
|
57
147
|
isDefault: row.isDefault,
|
|
58
148
|
visibility: row.visibility,
|
|
149
|
+
accessRole: role,
|
|
150
|
+
canManage,
|
|
59
151
|
createdAt: row.createdAt,
|
|
60
152
|
updatedAt: row.updatedAt,
|
|
61
153
|
};
|
|
@@ -1,7 +1,21 @@
|
|
|
1
|
+
import { useT } from "@agent-native/core/client/i18n";
|
|
1
2
|
import { ShareButton } from "@agent-native/core/client/sharing";
|
|
2
3
|
import { VisibilityBadge } from "@agent-native/toolkit/sharing";
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
IconDots,
|
|
6
|
+
IconPalette,
|
|
7
|
+
IconStar,
|
|
8
|
+
IconStarFilled,
|
|
9
|
+
IconTrash,
|
|
10
|
+
} from "@tabler/icons-react";
|
|
4
11
|
|
|
12
|
+
import { Button } from "@/components/ui/button";
|
|
13
|
+
import {
|
|
14
|
+
DropdownMenu,
|
|
15
|
+
DropdownMenuContent,
|
|
16
|
+
DropdownMenuItem,
|
|
17
|
+
DropdownMenuTrigger,
|
|
18
|
+
} from "@/components/ui/dropdown-menu";
|
|
5
19
|
import {
|
|
6
20
|
Tooltip,
|
|
7
21
|
TooltipContent,
|
|
@@ -16,8 +30,10 @@ interface DesignSystemCardProps {
|
|
|
16
30
|
data: DesignSystemData;
|
|
17
31
|
isDefault: boolean;
|
|
18
32
|
visibility?: "private" | "org" | "public" | null;
|
|
33
|
+
canManage?: boolean;
|
|
19
34
|
onClick: () => void;
|
|
20
35
|
onSetDefault: () => void;
|
|
36
|
+
onDelete: () => void;
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
function firstFontName(stack: string): string {
|
|
@@ -30,9 +46,12 @@ export function DesignSystemCard({
|
|
|
30
46
|
data,
|
|
31
47
|
isDefault,
|
|
32
48
|
visibility,
|
|
49
|
+
canManage,
|
|
33
50
|
onClick,
|
|
34
51
|
onSetDefault,
|
|
52
|
+
onDelete,
|
|
35
53
|
}: DesignSystemCardProps) {
|
|
54
|
+
const t = useT();
|
|
36
55
|
const swatchColors = [
|
|
37
56
|
{ label: "Primary", color: data.colors.primary },
|
|
38
57
|
{ label: "Secondary", color: data.colors.secondary },
|
|
@@ -93,6 +112,29 @@ export function DesignSystemCard({
|
|
|
93
112
|
resourceId={id}
|
|
94
113
|
resourceTitle={title}
|
|
95
114
|
/>
|
|
115
|
+
{canManage && (
|
|
116
|
+
<DropdownMenu modal={false}>
|
|
117
|
+
<DropdownMenuTrigger asChild>
|
|
118
|
+
<Button
|
|
119
|
+
variant="ghost"
|
|
120
|
+
size="icon"
|
|
121
|
+
aria-label={t("designSystems.moreActions")}
|
|
122
|
+
className="h-9 w-9 rounded-md bg-background/80 backdrop-blur-sm border border-border/40 hover:bg-background cursor-pointer"
|
|
123
|
+
>
|
|
124
|
+
<IconDots className="w-4 h-4 text-muted-foreground" />
|
|
125
|
+
</Button>
|
|
126
|
+
</DropdownMenuTrigger>
|
|
127
|
+
<DropdownMenuContent align="end">
|
|
128
|
+
<DropdownMenuItem
|
|
129
|
+
onClick={onDelete}
|
|
130
|
+
className="text-red-400 focus:text-red-400"
|
|
131
|
+
>
|
|
132
|
+
<IconTrash className="w-3.5 h-3.5 me-2" />
|
|
133
|
+
{t("designSystems.delete")}
|
|
134
|
+
</DropdownMenuItem>
|
|
135
|
+
</DropdownMenuContent>
|
|
136
|
+
</DropdownMenu>
|
|
137
|
+
)}
|
|
96
138
|
</div>
|
|
97
139
|
|
|
98
140
|
{/* Typography preview */}
|