@agent-native/core 0.122.1 → 0.122.3
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/README.md +1 -13
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/production-agent.ts +65 -1
- package/corpus/core/src/agent/run-loop-with-resume.ts +12 -0
- package/corpus/core/src/agent/run-store.ts +61 -1
- package/corpus/core/src/integrations/a2a-continuation-processor.ts +182 -0
- package/corpus/core/src/integrations/a2a-continuations-store.ts +122 -12
- package/corpus/core/src/integrations/integration-campaign-recovery.ts +127 -0
- package/corpus/core/src/integrations/integration-campaigns-store.ts +1039 -0
- package/corpus/core/src/integrations/integration-durable-dispatch.ts +28 -3
- package/corpus/core/src/integrations/pending-tasks-retry-job.ts +8 -0
- package/corpus/core/src/integrations/plugin.ts +453 -35
- package/corpus/core/src/integrations/webhook-handler.ts +469 -11
- package/corpus/core/src/server/builder-design-systems.ts +1 -1
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +15 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/report-panel-window.ts +3 -3
- package/corpus/templates/analytics/changelog/2026-07-25-analytics-dashboards-and-scheduled-reports-are-faster-and-mo.md +6 -0
- package/corpus/templates/analytics/server/db/schema.ts +12 -0
- package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +8 -4
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +136 -5
- package/corpus/templates/analytics/server/lib/dashboard-time-scope.ts +250 -1
- package/corpus/templates/analytics/server/lib/first-party-analytics-cache.ts +167 -0
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +34 -9
- package/corpus/templates/analytics/server/lib/first-party-dashboard-repair.ts +105 -23
- package/corpus/templates/analytics/server/lib/first-party-unbounded-panel-repair.ts +169 -0
- package/corpus/templates/analytics/server/plugins/db.ts +41 -1
- package/corpus/templates/analytics/shared/dashboard-report-timeouts.ts +6 -3
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +7 -3
- package/corpus/templates/content/app/components/editor/body-hydration.ts +10 -0
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +1 -0
- package/corpus/templates/content/app/root.tsx +12 -1
- package/corpus/templates/content/changelog/2026-07-23-new-pages-open-immediately-while-slow-network-work-continues.md +6 -0
- package/corpus/templates/content/changelog/2026-07-25-database-preview-actions-open-reliably.md +6 -0
- package/corpus/templates/content/e2e/playwright.config.ts +1 -1
- package/corpus/templates/design/actions/edit-design.ts +66 -27
- package/corpus/templates/design/changelog/2026-07-25-design-edits-retry-concurrent-changes.md +6 -0
- package/corpus/templates/design/server/source-workspace.ts +5 -1
- package/corpus/templates/forms/.agents/skills/form-responses/SKILL.md +6 -4
- package/corpus/templates/forms/actions/create-form.ts +5 -1
- package/corpus/templates/forms/actions/export-responses.ts +22 -11
- package/corpus/templates/forms/actions/update-form.ts +5 -1
- package/corpus/templates/forms/changelog/2026-07-25-fields-can-be-created-without-ids.md +6 -0
- package/corpus/templates/forms/changelog/2026-07-25-response-exports-use-file-storage.md +6 -0
- package/corpus/templates/forms/server/lib/validate-fields.ts +43 -0
- package/corpus/templates/slides/actions/extract-pdf.ts +4 -4
- package/corpus/templates/slides/actions/import-file.ts +3 -4
- package/corpus/templates/slides/changelog/2026-07-25-gemini-image-models-updated.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-25-pdf-imports-tolerate-missing-canvas.md +6 -0
- package/corpus/templates/slides/server/handlers/image-providers/gemini.ts +5 -2
- package/corpus/templates/slides/server/lib/pdf-parse-setup.ts +150 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +63 -1
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-loop-with-resume.d.ts +7 -1
- package/dist/agent/run-loop-with-resume.d.ts.map +1 -1
- package/dist/agent/run-loop-with-resume.js +7 -0
- package/dist/agent/run-loop-with-resume.js.map +1 -1
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +49 -0
- package/dist/agent/run-store.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/integrations/a2a-continuation-processor.d.ts +14 -0
- package/dist/integrations/a2a-continuation-processor.d.ts.map +1 -1
- package/dist/integrations/a2a-continuation-processor.js +134 -1
- package/dist/integrations/a2a-continuation-processor.js.map +1 -1
- package/dist/integrations/a2a-continuations-store.d.ts +23 -0
- package/dist/integrations/a2a-continuations-store.d.ts.map +1 -1
- package/dist/integrations/a2a-continuations-store.js +99 -13
- package/dist/integrations/a2a-continuations-store.js.map +1 -1
- package/dist/integrations/integration-campaign-recovery.d.ts +16 -0
- package/dist/integrations/integration-campaign-recovery.d.ts.map +1 -0
- package/dist/integrations/integration-campaign-recovery.js +94 -0
- package/dist/integrations/integration-campaign-recovery.js.map +1 -0
- package/dist/integrations/integration-campaigns-store.d.ts +121 -0
- package/dist/integrations/integration-campaigns-store.d.ts.map +1 -0
- package/dist/integrations/integration-campaigns-store.js +784 -0
- package/dist/integrations/integration-campaigns-store.js.map +1 -0
- package/dist/integrations/integration-durable-dispatch.d.ts +3 -0
- package/dist/integrations/integration-durable-dispatch.d.ts.map +1 -1
- package/dist/integrations/integration-durable-dispatch.js +22 -3
- package/dist/integrations/integration-durable-dispatch.js.map +1 -1
- package/dist/integrations/pending-tasks-retry-job.d.ts.map +1 -1
- package/dist/integrations/pending-tasks-retry-job.js +6 -0
- package/dist/integrations/pending-tasks-retry-job.js.map +1 -1
- package/dist/integrations/plugin.d.ts.map +1 -1
- package/dist/integrations/plugin.js +325 -18
- package/dist/integrations/plugin.js.map +1 -1
- package/dist/integrations/webhook-handler.d.ts +16 -1
- package/dist/integrations/webhook-handler.d.ts.map +1 -1
- package/dist/integrations/webhook-handler.js +368 -13
- package/dist/integrations/webhook-handler.js.map +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +14 -14
- package/dist/provider-api/actions/provider-api.d.ts +8 -8
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/builder-design-systems.js +1 -1
- package/dist/server/builder-design-systems.js.map +1 -1
- package/package.json +3 -3
- package/src/agent/production-agent.ts +65 -1
- package/src/agent/run-loop-with-resume.ts +12 -0
- package/src/agent/run-store.ts +61 -1
- package/src/integrations/a2a-continuation-processor.ts +182 -0
- package/src/integrations/a2a-continuations-store.ts +122 -12
- package/src/integrations/integration-campaign-recovery.ts +127 -0
- package/src/integrations/integration-campaigns-store.ts +1039 -0
- package/src/integrations/integration-durable-dispatch.ts +28 -3
- package/src/integrations/pending-tasks-retry-job.ts +8 -0
- package/src/integrations/plugin.ts +453 -35
- package/src/integrations/webhook-handler.ts +469 -11
- package/src/server/builder-design-systems.ts +1 -1
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
engineToProvider,
|
|
32
32
|
type ActionEntry,
|
|
33
33
|
} from "../agent/production-agent.js";
|
|
34
|
+
import { appendDurableContinuationContext } from "../agent/run-loop-with-resume.js";
|
|
34
35
|
import { startRun, type ActiveRun } from "../agent/run-manager.js";
|
|
35
36
|
import {
|
|
36
37
|
buildCurrentTimeUserContext,
|
|
@@ -42,6 +43,7 @@ import {
|
|
|
42
43
|
threadDataToEngineMessages,
|
|
43
44
|
} from "../agent/thread-data-builder.js";
|
|
44
45
|
import { attachToolSearch } from "../agent/tool-search.js";
|
|
46
|
+
import type { ContinuationReason } from "../agent/types.js";
|
|
45
47
|
import {
|
|
46
48
|
createThread,
|
|
47
49
|
getThread,
|
|
@@ -58,10 +60,22 @@ import {
|
|
|
58
60
|
import { runWithRequestContext } from "../server/request-context.js";
|
|
59
61
|
import { normalizeReasoningEffortForRequest } from "../shared/reasoning-effort.js";
|
|
60
62
|
import { A2A_CONTINUATION_QUEUED_MARKER } from "./a2a-continuation-marker.js";
|
|
63
|
+
import { hasActiveA2AContinuationsForIntegrationTask } from "./a2a-continuations-store.js";
|
|
61
64
|
import {
|
|
62
65
|
clearIntegrationAwaitingInput,
|
|
63
66
|
setIntegrationAwaitingInput,
|
|
64
67
|
} from "./awaiting-input-store.js";
|
|
68
|
+
import {
|
|
69
|
+
claimIntegrationCampaignDeliveryForTask,
|
|
70
|
+
claimIntegrationCampaign,
|
|
71
|
+
completeIntegrationCampaignTask,
|
|
72
|
+
createIntegrationCampaign,
|
|
73
|
+
failIntegrationCampaign,
|
|
74
|
+
heartbeatIntegrationCampaign,
|
|
75
|
+
scheduleNextIntegrationCampaign,
|
|
76
|
+
waitForA2AIntegrationCampaign,
|
|
77
|
+
type IntegrationCampaign,
|
|
78
|
+
} from "./integration-campaigns-store.js";
|
|
65
79
|
import {
|
|
66
80
|
dispatchPendingIntegrationTask,
|
|
67
81
|
integrationDispatchScopeValue,
|
|
@@ -80,6 +94,7 @@ import type {
|
|
|
80
94
|
IncomingMessage,
|
|
81
95
|
OutgoingMessage,
|
|
82
96
|
PlatformDeliveryReceipt,
|
|
97
|
+
PlatformRunProgressRef,
|
|
83
98
|
} from "./types.js";
|
|
84
99
|
import {
|
|
85
100
|
listIntegrationUsageBudgets,
|
|
@@ -96,6 +111,12 @@ const EMPTY_INTEGRATION_RESPONSE_MESSAGE =
|
|
|
96
111
|
const CUTOFF_INTEGRATION_RESPONSE_MESSAGE =
|
|
97
112
|
"I ran out of time on this one before I could write up an answer — it needed more research than a single run allows. " +
|
|
98
113
|
"Open the thread in Dispatch to see what I gathered, or ask me again in smaller pieces (one source at a time works best).";
|
|
114
|
+
const INTEGRATION_CAMPAIGN_LEASE_MS = 16 * 60_000;
|
|
115
|
+
const INTEGRATION_CAMPAIGN_MAX_CHUNKS = 4;
|
|
116
|
+
const INTEGRATION_CAMPAIGN_A2A_CHECK_MS = 30_000;
|
|
117
|
+
// Keep a lost handoff plus the one-minute sweep inside the two-minute messaging
|
|
118
|
+
// target without shortening general background-run budgets.
|
|
119
|
+
const INTEGRATION_CAMPAIGN_NO_PROGRESS_TIMEOUT_MS = 45_000;
|
|
99
120
|
|
|
100
121
|
/**
|
|
101
122
|
* True when the run stopped at a continuation boundary rather than finishing:
|
|
@@ -108,6 +129,50 @@ function endedAtContinuationBoundary(run: ActiveRun): boolean {
|
|
|
108
129
|
return run.events.some((runEvent) => runEvent.event.type === "auto_continue");
|
|
109
130
|
}
|
|
110
131
|
|
|
132
|
+
function continuationBoundaryReason(run: ActiveRun): ContinuationReason {
|
|
133
|
+
for (let index = run.events.length - 1; index >= 0; index -= 1) {
|
|
134
|
+
const runEvent = run.events[index];
|
|
135
|
+
if (!runEvent) continue;
|
|
136
|
+
if (runEvent.event.type === "auto_continue") return runEvent.event.reason;
|
|
137
|
+
}
|
|
138
|
+
return "run_timeout";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function checkpointContinuationReason(
|
|
142
|
+
checkpoint: string | null | undefined,
|
|
143
|
+
): ContinuationReason {
|
|
144
|
+
if (!checkpoint) return "run_timeout";
|
|
145
|
+
try {
|
|
146
|
+
const reason = JSON.parse(checkpoint).reason;
|
|
147
|
+
if (
|
|
148
|
+
reason === "run_timeout" ||
|
|
149
|
+
reason === "loop_limit" ||
|
|
150
|
+
reason === "max_tokens" ||
|
|
151
|
+
reason === "no_progress" ||
|
|
152
|
+
reason === "stream_ended" ||
|
|
153
|
+
reason === "gateway_timeout" ||
|
|
154
|
+
reason === "network_interrupted"
|
|
155
|
+
) {
|
|
156
|
+
return reason;
|
|
157
|
+
}
|
|
158
|
+
} catch {}
|
|
159
|
+
return "run_timeout";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseIntegrationProgressRef(
|
|
163
|
+
value: string,
|
|
164
|
+
): PlatformRunProgressRef | null {
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(value) as Partial<PlatformRunProgressRef>;
|
|
167
|
+
return typeof parsed.kind === "string" &&
|
|
168
|
+
typeof parsed.streamTs === "string"
|
|
169
|
+
? { kind: parsed.kind, streamTs: parsed.streamTs }
|
|
170
|
+
: null;
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
111
176
|
type ToolDoneEvent = {
|
|
112
177
|
type: "tool_done";
|
|
113
178
|
tool: string;
|
|
@@ -127,14 +192,24 @@ export type IntegrationResponseDeliveryTaskPayload = {
|
|
|
127
192
|
deliveryReceipt?: PlatformDeliveryReceipt;
|
|
128
193
|
deliveredAt?: string;
|
|
129
194
|
artifacts?: A2AArtifactIdentity[];
|
|
195
|
+
campaignTerminalStatus?: "completed" | "failed";
|
|
196
|
+
awaitingA2ACompletion?: true;
|
|
130
197
|
};
|
|
131
198
|
|
|
132
199
|
export type ProcessIntegrationTaskResult =
|
|
133
200
|
| { status: "completed" }
|
|
201
|
+
| { status: "campaign-pending" | "campaign-active" }
|
|
202
|
+
| { status: "campaign-failed" }
|
|
134
203
|
| {
|
|
135
204
|
status: "delivery-pending";
|
|
136
205
|
payload: IntegrationResponseDeliveryTaskPayload;
|
|
137
206
|
errorMessage: string;
|
|
207
|
+
campaignLease?: {
|
|
208
|
+
campaignId: string;
|
|
209
|
+
runId: string;
|
|
210
|
+
leaseToken: string;
|
|
211
|
+
campaignStatus: "completed" | "failed" | "waiting-a2a";
|
|
212
|
+
};
|
|
138
213
|
};
|
|
139
214
|
|
|
140
215
|
/**
|
|
@@ -575,6 +650,10 @@ export function resolveBaseUrl(event: H3Event): string {
|
|
|
575
650
|
export async function processIntegrationTask(
|
|
576
651
|
task: PendingTask,
|
|
577
652
|
options: WebhookHandlerOptions,
|
|
653
|
+
campaignOptions?: {
|
|
654
|
+
enabled?: boolean;
|
|
655
|
+
continuationInvocation?: boolean;
|
|
656
|
+
},
|
|
578
657
|
): Promise<ProcessIntegrationTaskResult> {
|
|
579
658
|
const parsed = JSON.parse(task.payload) as {
|
|
580
659
|
incoming: IncomingMessage;
|
|
@@ -582,7 +661,9 @@ export async function processIntegrationTask(
|
|
|
582
661
|
principalType?: "user" | "service";
|
|
583
662
|
};
|
|
584
663
|
|
|
585
|
-
|
|
664
|
+
if (!campaignOptions?.continuationInvocation) {
|
|
665
|
+
await recordInboundIntegrationAudit(task, parsed.incoming);
|
|
666
|
+
}
|
|
586
667
|
|
|
587
668
|
return processIncomingMessage(parsed.incoming, options, {
|
|
588
669
|
taskId: task.id,
|
|
@@ -590,6 +671,7 @@ export async function processIntegrationTask(
|
|
|
590
671
|
placeholderRef: parsed.placeholderRef,
|
|
591
672
|
orgId: task.orgId ?? undefined,
|
|
592
673
|
principalType: parsed.principalType ?? options.principalType ?? "user",
|
|
674
|
+
durableCampaign: campaignOptions?.enabled === true,
|
|
593
675
|
});
|
|
594
676
|
}
|
|
595
677
|
|
|
@@ -643,6 +725,7 @@ async function processIncomingMessage(
|
|
|
643
725
|
placeholderRef?: string;
|
|
644
726
|
orgId?: string;
|
|
645
727
|
principalType?: "user" | "service";
|
|
728
|
+
durableCampaign?: boolean;
|
|
646
729
|
} = {},
|
|
647
730
|
): Promise<ProcessIntegrationTaskResult> {
|
|
648
731
|
const {
|
|
@@ -831,6 +914,195 @@ async function processIncomingMessage(
|
|
|
831
914
|
);
|
|
832
915
|
});
|
|
833
916
|
}
|
|
917
|
+
|
|
918
|
+
let campaign:
|
|
919
|
+
| {
|
|
920
|
+
row: IntegrationCampaign;
|
|
921
|
+
runId: string;
|
|
922
|
+
leaseToken: string;
|
|
923
|
+
}
|
|
924
|
+
| undefined;
|
|
925
|
+
if (opts.durableCampaign && opts.taskId) {
|
|
926
|
+
const created = await createIntegrationCampaign({
|
|
927
|
+
integrationTaskId: opts.taskId,
|
|
928
|
+
threadId,
|
|
929
|
+
turnId: `integration-turn-${opts.taskId}`,
|
|
930
|
+
});
|
|
931
|
+
if (created.status === "completed") {
|
|
932
|
+
await releaseApplicableIntegrationBudgets(
|
|
933
|
+
budgetReservations.reservations,
|
|
934
|
+
);
|
|
935
|
+
return { status: "completed" };
|
|
936
|
+
}
|
|
937
|
+
if (created.status === "failed") {
|
|
938
|
+
await releaseApplicableIntegrationBudgets(
|
|
939
|
+
budgetReservations.reservations,
|
|
940
|
+
);
|
|
941
|
+
return { status: "campaign-failed" };
|
|
942
|
+
}
|
|
943
|
+
const runId = `integration-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
944
|
+
const leaseToken = crypto.randomUUID();
|
|
945
|
+
const claimed = await claimIntegrationCampaign(created.id, {
|
|
946
|
+
runId,
|
|
947
|
+
leaseToken,
|
|
948
|
+
leaseDurationMs: INTEGRATION_CAMPAIGN_LEASE_MS,
|
|
949
|
+
maxChunks: INTEGRATION_CAMPAIGN_MAX_CHUNKS,
|
|
950
|
+
});
|
|
951
|
+
if (claimed.kind === "chunk-limit") {
|
|
952
|
+
const deliveryRunId = `integration-delivery-${crypto.randomUUID()}`;
|
|
953
|
+
const deliveryLeaseToken = crypto.randomUUID();
|
|
954
|
+
const deliveryCampaign = await claimIntegrationCampaignDeliveryForTask(
|
|
955
|
+
opts.taskId,
|
|
956
|
+
{
|
|
957
|
+
runId: deliveryRunId,
|
|
958
|
+
leaseToken: deliveryLeaseToken,
|
|
959
|
+
leaseDurationMs: INTEGRATION_CAMPAIGN_LEASE_MS,
|
|
960
|
+
},
|
|
961
|
+
);
|
|
962
|
+
if (!deliveryCampaign) {
|
|
963
|
+
await releaseApplicableIntegrationBudgets(
|
|
964
|
+
budgetReservations.reservations,
|
|
965
|
+
);
|
|
966
|
+
return { status: "campaign-active" };
|
|
967
|
+
}
|
|
968
|
+
const exhaustedProgressRef = deliveryCampaign.progressRef
|
|
969
|
+
? parseIntegrationProgressRef(deliveryCampaign.progressRef)
|
|
970
|
+
: null;
|
|
971
|
+
const exhaustedProgress = exhaustedProgressRef
|
|
972
|
+
? await adapter
|
|
973
|
+
.resumeRunProgress?.(incoming, exhaustedProgressRef)
|
|
974
|
+
.catch(() => null)
|
|
975
|
+
: null;
|
|
976
|
+
const exhaustedMessage = adapter.formatAgentResponse(
|
|
977
|
+
"I couldn't safely finish this request after several continuation attempts. No completed write will be replayed.",
|
|
978
|
+
);
|
|
979
|
+
let deliveryPayload: IntegrationResponseDeliveryTaskPayload = {
|
|
980
|
+
kind: "response-delivery",
|
|
981
|
+
incoming,
|
|
982
|
+
message: exhaustedMessage,
|
|
983
|
+
...(opts.placeholderRef ? { placeholderRef: opts.placeholderRef } : {}),
|
|
984
|
+
internalThreadId: threadId,
|
|
985
|
+
campaignTerminalStatus: "failed",
|
|
986
|
+
...buildDeliveryHistoryMessageIds(incoming),
|
|
987
|
+
};
|
|
988
|
+
try {
|
|
989
|
+
await stageTaskDeliveryPayload(
|
|
990
|
+
opts.taskId,
|
|
991
|
+
JSON.stringify(deliveryPayload),
|
|
992
|
+
);
|
|
993
|
+
let receipt: void | PlatformDeliveryReceipt;
|
|
994
|
+
if (exhaustedProgress) {
|
|
995
|
+
try {
|
|
996
|
+
receipt = await exhaustedProgress.complete(exhaustedMessage);
|
|
997
|
+
} catch {
|
|
998
|
+
receipt = await adapter.sendResponse(exhaustedMessage, incoming, {
|
|
999
|
+
placeholderRef: opts.placeholderRef,
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
} else {
|
|
1003
|
+
receipt = await adapter.sendResponse(exhaustedMessage, incoming, {
|
|
1004
|
+
placeholderRef: opts.placeholderRef,
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
if (receipt?.status !== "delivered") {
|
|
1008
|
+
throw new Error(
|
|
1009
|
+
`${incoming.platform} exhaustion response completed without delivery proof`,
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
deliveryPayload = {
|
|
1013
|
+
...deliveryPayload,
|
|
1014
|
+
deliveryReceipt: receipt,
|
|
1015
|
+
deliveredAt: new Date().toISOString(),
|
|
1016
|
+
};
|
|
1017
|
+
await stageTaskDeliveryPayload(
|
|
1018
|
+
opts.taskId,
|
|
1019
|
+
JSON.stringify(deliveryPayload),
|
|
1020
|
+
);
|
|
1021
|
+
await recordIntegrationResponseDelivery(deliveryPayload, receipt);
|
|
1022
|
+
const terminalized = await failIntegrationCampaign(
|
|
1023
|
+
deliveryCampaign.id,
|
|
1024
|
+
{
|
|
1025
|
+
runId: deliveryRunId,
|
|
1026
|
+
leaseToken: deliveryLeaseToken,
|
|
1027
|
+
errorMessage: "Integration campaign exhausted its chunk limit",
|
|
1028
|
+
},
|
|
1029
|
+
);
|
|
1030
|
+
if (!terminalized) {
|
|
1031
|
+
await releaseApplicableIntegrationBudgets(
|
|
1032
|
+
budgetReservations.reservations,
|
|
1033
|
+
);
|
|
1034
|
+
return { status: "campaign-active" };
|
|
1035
|
+
}
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
await releaseApplicableIntegrationBudgets(
|
|
1038
|
+
budgetReservations.reservations,
|
|
1039
|
+
);
|
|
1040
|
+
return {
|
|
1041
|
+
status: "delivery-pending",
|
|
1042
|
+
payload: deliveryPayload,
|
|
1043
|
+
campaignLease: {
|
|
1044
|
+
campaignId: deliveryCampaign.id,
|
|
1045
|
+
runId: deliveryRunId,
|
|
1046
|
+
leaseToken: deliveryLeaseToken,
|
|
1047
|
+
campaignStatus: "failed",
|
|
1048
|
+
},
|
|
1049
|
+
errorMessage:
|
|
1050
|
+
error instanceof Error
|
|
1051
|
+
? error.message.slice(0, 1000)
|
|
1052
|
+
: "Integration exhaustion response delivery failed",
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
await releaseApplicableIntegrationBudgets(
|
|
1056
|
+
budgetReservations.reservations,
|
|
1057
|
+
);
|
|
1058
|
+
return { status: "campaign-failed" };
|
|
1059
|
+
}
|
|
1060
|
+
if (claimed.kind !== "claimed") {
|
|
1061
|
+
await releaseApplicableIntegrationBudgets(
|
|
1062
|
+
budgetReservations.reservations,
|
|
1063
|
+
);
|
|
1064
|
+
return { status: "campaign-active" };
|
|
1065
|
+
}
|
|
1066
|
+
campaign = { row: claimed.campaign, runId, leaseToken };
|
|
1067
|
+
if (campaign.row.checkpoint) {
|
|
1068
|
+
let waitingForA2A = false;
|
|
1069
|
+
try {
|
|
1070
|
+
waitingForA2A =
|
|
1071
|
+
JSON.parse(campaign.row.checkpoint).waitingForA2A === true;
|
|
1072
|
+
} catch {}
|
|
1073
|
+
if (waitingForA2A) {
|
|
1074
|
+
const activeA2A = await hasActiveA2AContinuationsForIntegrationTask(
|
|
1075
|
+
opts.taskId,
|
|
1076
|
+
);
|
|
1077
|
+
if (activeA2A) {
|
|
1078
|
+
const waiting = await waitForA2AIntegrationCampaign(campaign.row.id, {
|
|
1079
|
+
runId: campaign.runId,
|
|
1080
|
+
leaseToken: campaign.leaseToken,
|
|
1081
|
+
nextRunAt: Date.now() + INTEGRATION_CAMPAIGN_A2A_CHECK_MS,
|
|
1082
|
+
progressRef: campaign.row.progressRef,
|
|
1083
|
+
});
|
|
1084
|
+
await releaseApplicableIntegrationBudgets(
|
|
1085
|
+
budgetReservations.reservations,
|
|
1086
|
+
);
|
|
1087
|
+
return {
|
|
1088
|
+
status: waiting ? "campaign-pending" : "campaign-active",
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
const completed = await completeIntegrationCampaignTask(
|
|
1092
|
+
campaign.row.id,
|
|
1093
|
+
{
|
|
1094
|
+
integrationTaskId: opts.taskId!,
|
|
1095
|
+
runId: campaign.runId,
|
|
1096
|
+
leaseToken: campaign.leaseToken,
|
|
1097
|
+
},
|
|
1098
|
+
);
|
|
1099
|
+
await releaseApplicableIntegrationBudgets(
|
|
1100
|
+
budgetReservations.reservations,
|
|
1101
|
+
);
|
|
1102
|
+
return { status: completed ? "completed" : "campaign-active" };
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
834
1106
|
const existingMessages: EngineMessage[] = [];
|
|
835
1107
|
if (thread?.threadData) {
|
|
836
1108
|
existingMessages.push(...threadDataToEngineMessages(thread.threadData));
|
|
@@ -861,15 +1133,21 @@ async function processIncomingMessage(
|
|
|
861
1133
|
// Precise current time rides the engine-facing user message (not the cached
|
|
862
1134
|
// system-prompt prefix, and not the persisted thread text) — the runtime
|
|
863
1135
|
// context appended to the system prompt is day-granular only.
|
|
864
|
-
const messages: EngineMessage[] = [
|
|
865
|
-
|
|
866
|
-
|
|
1136
|
+
const messages: EngineMessage[] = [...existingMessages];
|
|
1137
|
+
if (campaign && campaign.row.chunkCount > 1) {
|
|
1138
|
+
await appendDurableContinuationContext(
|
|
1139
|
+
messages,
|
|
1140
|
+
checkpointContinuationReason(campaign.row.checkpoint),
|
|
1141
|
+
threadId,
|
|
1142
|
+
);
|
|
1143
|
+
} else {
|
|
1144
|
+
messages.push({
|
|
867
1145
|
role: "user",
|
|
868
1146
|
content: [
|
|
869
1147
|
{ type: "text", text: userText + buildCurrentTimeUserContext() },
|
|
870
1148
|
],
|
|
871
|
-
}
|
|
872
|
-
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
873
1151
|
|
|
874
1152
|
// Run agent loop via startRun, wrapped in a request context so that
|
|
875
1153
|
// tools (especially call-agent) can resolve the caller's org for org-scoped
|
|
@@ -897,8 +1175,28 @@ async function processIncomingMessage(
|
|
|
897
1175
|
throw error;
|
|
898
1176
|
}
|
|
899
1177
|
|
|
900
|
-
const runId =
|
|
901
|
-
|
|
1178
|
+
const runId =
|
|
1179
|
+
campaign?.runId ??
|
|
1180
|
+
`integration-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1181
|
+
const storedProgressRef = campaign?.row.progressRef
|
|
1182
|
+
? parseIntegrationProgressRef(campaign.row.progressRef)
|
|
1183
|
+
: null;
|
|
1184
|
+
const progress = storedProgressRef
|
|
1185
|
+
? await adapter
|
|
1186
|
+
.resumeRunProgress?.(incoming, storedProgressRef)
|
|
1187
|
+
.catch(() => null)
|
|
1188
|
+
: await adapter.startRunProgress?.(incoming).catch(() => null);
|
|
1189
|
+
if (campaign && campaign.row.chunkCount > 1 && progress) {
|
|
1190
|
+
await Promise.resolve(
|
|
1191
|
+
progress.onEvent({
|
|
1192
|
+
type: "agent_call_progress",
|
|
1193
|
+
agent: "Agent Native",
|
|
1194
|
+
state: "working",
|
|
1195
|
+
elapsedSeconds: 0,
|
|
1196
|
+
detail: "Continuing in the background",
|
|
1197
|
+
}),
|
|
1198
|
+
).catch(() => {});
|
|
1199
|
+
}
|
|
902
1200
|
let usage: Awaited<ReturnType<typeof runAgentLoop>> | null = null;
|
|
903
1201
|
let budgetsSettled = false;
|
|
904
1202
|
|
|
@@ -1023,7 +1321,28 @@ async function processIncomingMessage(
|
|
|
1023
1321
|
| undefined;
|
|
1024
1322
|
let outcome: ProcessIntegrationTaskResult = { status: "completed" };
|
|
1025
1323
|
try {
|
|
1324
|
+
if (campaign) {
|
|
1325
|
+
const stillOwned = await heartbeatIntegrationCampaign(
|
|
1326
|
+
campaign.row.id,
|
|
1327
|
+
{
|
|
1328
|
+
runId: campaign.runId,
|
|
1329
|
+
leaseToken: campaign.leaseToken,
|
|
1330
|
+
leaseDurationMs: INTEGRATION_CAMPAIGN_LEASE_MS,
|
|
1331
|
+
},
|
|
1332
|
+
);
|
|
1333
|
+
if (!stillOwned) {
|
|
1334
|
+
outcome = { status: "campaign-active" };
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1026
1338
|
queuedA2AContinuation = hasQueuedA2AContinuation(completedRun);
|
|
1339
|
+
const durableCampaignContinuation = Boolean(
|
|
1340
|
+
campaign &&
|
|
1341
|
+
campaign.row.chunkCount < INTEGRATION_CAMPAIGN_MAX_CHUNKS &&
|
|
1342
|
+
completedRun.status === "completed" &&
|
|
1343
|
+
endedAtContinuationBoundary(completedRun) &&
|
|
1344
|
+
!queuedA2AContinuation,
|
|
1345
|
+
);
|
|
1027
1346
|
const slackInputRequest =
|
|
1028
1347
|
incoming.platform === "slack"
|
|
1029
1348
|
? extractSlackInputRequest(completedRun)
|
|
@@ -1049,6 +1368,7 @@ async function processIncomingMessage(
|
|
|
1049
1368
|
let suppressPlatformReply =
|
|
1050
1369
|
queuedA2AContinuation &&
|
|
1051
1370
|
isQueuedA2AContinuationDeferral(responseText);
|
|
1371
|
+
suppressPlatformReply ||= durableCampaignContinuation;
|
|
1052
1372
|
|
|
1053
1373
|
// Compute trusted tool receipts before choosing the empty-answer
|
|
1054
1374
|
// fallback. A completed write must not be reported as though nothing
|
|
@@ -1156,6 +1476,14 @@ async function processIncomingMessage(
|
|
|
1156
1476
|
artifacts: extractA2AArtifactIdentities(toolResults, {
|
|
1157
1477
|
persistedArtifactSecrets: artifactSecrets,
|
|
1158
1478
|
}),
|
|
1479
|
+
...(campaign && queuedA2AContinuation
|
|
1480
|
+
? { awaitingA2ACompletion: true as const }
|
|
1481
|
+
: {}),
|
|
1482
|
+
...(campaign &&
|
|
1483
|
+
!queuedA2AContinuation &&
|
|
1484
|
+
!durableCampaignContinuation
|
|
1485
|
+
? { campaignTerminalStatus: "completed" as const }
|
|
1486
|
+
: {}),
|
|
1159
1487
|
};
|
|
1160
1488
|
if (opts.taskId) {
|
|
1161
1489
|
await stageTaskDeliveryPayload(
|
|
@@ -1256,6 +1584,9 @@ async function processIncomingMessage(
|
|
|
1256
1584
|
}
|
|
1257
1585
|
|
|
1258
1586
|
// Persist thread data
|
|
1587
|
+
const historyMessageIds =
|
|
1588
|
+
stagedDeliveryPayload ??
|
|
1589
|
+
(campaign ? buildDeliveryHistoryMessageIds(incoming) : undefined);
|
|
1259
1590
|
threadCheckpoint = await persistThreadData(
|
|
1260
1591
|
threadId,
|
|
1261
1592
|
incoming.text,
|
|
@@ -1263,8 +1594,10 @@ async function processIncomingMessage(
|
|
|
1263
1594
|
thread,
|
|
1264
1595
|
deliveredResponse,
|
|
1265
1596
|
toolResults,
|
|
1266
|
-
|
|
1597
|
+
historyMessageIds,
|
|
1267
1598
|
artifactSecrets,
|
|
1599
|
+
Boolean(campaign),
|
|
1600
|
+
!durableCampaignContinuation,
|
|
1268
1601
|
);
|
|
1269
1602
|
if (outgoingForDelivery && stagedDeliveryPayload) {
|
|
1270
1603
|
if (!threadCheckpoint) {
|
|
@@ -1299,6 +1632,69 @@ async function processIncomingMessage(
|
|
|
1299
1632
|
usage,
|
|
1300
1633
|
);
|
|
1301
1634
|
budgetsSettled = true;
|
|
1635
|
+
if (campaign && queuedA2AContinuation) {
|
|
1636
|
+
const waiting = await waitForA2AIntegrationCampaign(
|
|
1637
|
+
campaign.row.id,
|
|
1638
|
+
{
|
|
1639
|
+
runId: campaign.runId,
|
|
1640
|
+
leaseToken: campaign.leaseToken,
|
|
1641
|
+
nextRunAt: Date.now() + INTEGRATION_CAMPAIGN_A2A_CHECK_MS,
|
|
1642
|
+
progressRef: progress?.ref
|
|
1643
|
+
? JSON.stringify(progress.ref)
|
|
1644
|
+
: undefined,
|
|
1645
|
+
},
|
|
1646
|
+
);
|
|
1647
|
+
if (!waiting) {
|
|
1648
|
+
throw new Error("Integration campaign lease was superseded");
|
|
1649
|
+
}
|
|
1650
|
+
outcome = { status: "campaign-pending" };
|
|
1651
|
+
} else if (campaign && durableCampaignContinuation) {
|
|
1652
|
+
const scheduled = await scheduleNextIntegrationCampaign(
|
|
1653
|
+
campaign.row.id,
|
|
1654
|
+
{
|
|
1655
|
+
runId: campaign.runId,
|
|
1656
|
+
leaseToken: campaign.leaseToken,
|
|
1657
|
+
nextRunAt: Date.now(),
|
|
1658
|
+
progressRef: progress?.ref
|
|
1659
|
+
? JSON.stringify(progress.ref)
|
|
1660
|
+
: undefined,
|
|
1661
|
+
checkpoint: JSON.stringify({
|
|
1662
|
+
reason: continuationBoundaryReason(completedRun),
|
|
1663
|
+
threadId,
|
|
1664
|
+
...(threadCheckpoint?.assistantMessageId
|
|
1665
|
+
? {
|
|
1666
|
+
assistantMessageId: threadCheckpoint.assistantMessageId,
|
|
1667
|
+
}
|
|
1668
|
+
: {}),
|
|
1669
|
+
}),
|
|
1670
|
+
},
|
|
1671
|
+
);
|
|
1672
|
+
if (!scheduled) {
|
|
1673
|
+
throw new Error("Integration campaign lease was superseded");
|
|
1674
|
+
}
|
|
1675
|
+
await dispatchPendingIntegrationTask({
|
|
1676
|
+
taskId: opts.taskId!,
|
|
1677
|
+
task: {
|
|
1678
|
+
platform: incoming.platform,
|
|
1679
|
+
externalThreadId: incoming.externalThreadId,
|
|
1680
|
+
platformContext: incoming.platformContext,
|
|
1681
|
+
},
|
|
1682
|
+
campaignContinuation: true,
|
|
1683
|
+
});
|
|
1684
|
+
outcome = { status: "campaign-pending" };
|
|
1685
|
+
} else if (campaign) {
|
|
1686
|
+
const completed = await completeIntegrationCampaignTask(
|
|
1687
|
+
campaign.row.id,
|
|
1688
|
+
{
|
|
1689
|
+
integrationTaskId: opts.taskId!,
|
|
1690
|
+
runId: campaign.runId,
|
|
1691
|
+
leaseToken: campaign.leaseToken,
|
|
1692
|
+
},
|
|
1693
|
+
);
|
|
1694
|
+
if (!completed) {
|
|
1695
|
+
throw new Error("Integration campaign lease was superseded");
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1302
1698
|
} catch (err) {
|
|
1303
1699
|
console.error(
|
|
1304
1700
|
`[integrations] Error sending response to ${incoming.platform}:`,
|
|
@@ -1317,8 +1713,12 @@ async function processIncomingMessage(
|
|
|
1317
1713
|
thread,
|
|
1318
1714
|
undefined,
|
|
1319
1715
|
collectToolResultSummaries(completedRun),
|
|
1320
|
-
stagedDeliveryPayload
|
|
1716
|
+
stagedDeliveryPayload ??
|
|
1717
|
+
(campaign
|
|
1718
|
+
? buildDeliveryHistoryMessageIds(incoming)
|
|
1719
|
+
: undefined),
|
|
1321
1720
|
artifactSecrets,
|
|
1721
|
+
Boolean(campaign),
|
|
1322
1722
|
);
|
|
1323
1723
|
}
|
|
1324
1724
|
if (usage) {
|
|
@@ -1356,10 +1756,31 @@ async function processIncomingMessage(
|
|
|
1356
1756
|
? { assistantMessageId: threadCheckpoint.assistantMessageId }
|
|
1357
1757
|
: {}),
|
|
1358
1758
|
},
|
|
1759
|
+
...(campaign &&
|
|
1760
|
+
(stagedDeliveryPayload?.campaignTerminalStatus ||
|
|
1761
|
+
stagedDeliveryPayload?.awaitingA2ACompletion)
|
|
1762
|
+
? {
|
|
1763
|
+
campaignLease: {
|
|
1764
|
+
campaignId: campaign.row.id,
|
|
1765
|
+
runId: campaign.runId,
|
|
1766
|
+
leaseToken: campaign.leaseToken,
|
|
1767
|
+
campaignStatus:
|
|
1768
|
+
stagedDeliveryPayload.campaignTerminalStatus ??
|
|
1769
|
+
("waiting-a2a" as const),
|
|
1770
|
+
},
|
|
1771
|
+
}
|
|
1772
|
+
: {}),
|
|
1359
1773
|
errorMessage,
|
|
1360
1774
|
};
|
|
1361
1775
|
return;
|
|
1362
1776
|
}
|
|
1777
|
+
if (campaign) {
|
|
1778
|
+
// A campaign-owned, no-delivery path must never fall through to
|
|
1779
|
+
// the default completed outcome. Its lease/checkpoint recovery
|
|
1780
|
+
// remains the sole owner of this logical turn.
|
|
1781
|
+
outcome = { status: "campaign-active" };
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1363
1784
|
// A queued continuation owns the final platform response. Later
|
|
1364
1785
|
// bookkeeping failures must not close its stream with a false failure.
|
|
1365
1786
|
if (queuedA2AContinuation) return;
|
|
@@ -1406,6 +1827,12 @@ async function processIncomingMessage(
|
|
|
1406
1827
|
{
|
|
1407
1828
|
useHostedSoftTimeoutDefault: true,
|
|
1408
1829
|
backgroundFunction: isInBackgroundFunctionRuntime(),
|
|
1830
|
+
...(campaign
|
|
1831
|
+
? {
|
|
1832
|
+
turnId: campaign.row.turnId,
|
|
1833
|
+
noProgressTimeoutMs: INTEGRATION_CAMPAIGN_NO_PROGRESS_TIMEOUT_MS,
|
|
1834
|
+
}
|
|
1835
|
+
: {}),
|
|
1409
1836
|
},
|
|
1410
1837
|
);
|
|
1411
1838
|
});
|
|
@@ -1815,6 +2242,8 @@ async function persistThreadData(
|
|
|
1815
2242
|
"userMessageId" | "assistantMessageId"
|
|
1816
2243
|
>,
|
|
1817
2244
|
artifactSecrets: readonly string[] = [],
|
|
2245
|
+
mergeRunContent = false,
|
|
2246
|
+
deliveryAttempted = true,
|
|
1818
2247
|
): Promise<{ userMessageId: string; assistantMessageId?: string } | undefined> {
|
|
1819
2248
|
try {
|
|
1820
2249
|
let repo: any;
|
|
@@ -1846,9 +2275,38 @@ async function persistThreadData(
|
|
|
1846
2275
|
(message: any) => message?.id === builtAssistantMsg.id,
|
|
1847
2276
|
)
|
|
1848
2277
|
: undefined;
|
|
2278
|
+
if (builtAssistantMsg && mergeRunContent) {
|
|
2279
|
+
builtAssistantMsg.metadata.integrationRunIds = [completedRun.runId];
|
|
2280
|
+
}
|
|
2281
|
+
if (mergeRunContent && existingAssistantMsg && builtAssistantMsg) {
|
|
2282
|
+
const previousRunIds = Array.isArray(
|
|
2283
|
+
existingAssistantMsg.metadata?.integrationRunIds,
|
|
2284
|
+
)
|
|
2285
|
+
? existingAssistantMsg.metadata.integrationRunIds.filter(
|
|
2286
|
+
(value: unknown): value is string => typeof value === "string",
|
|
2287
|
+
)
|
|
2288
|
+
: [];
|
|
2289
|
+
if (!previousRunIds.includes(completedRun.runId)) {
|
|
2290
|
+
existingAssistantMsg.content = [
|
|
2291
|
+
...(Array.isArray(existingAssistantMsg.content)
|
|
2292
|
+
? existingAssistantMsg.content
|
|
2293
|
+
: []),
|
|
2294
|
+
...(Array.isArray(builtAssistantMsg.content)
|
|
2295
|
+
? builtAssistantMsg.content
|
|
2296
|
+
: []),
|
|
2297
|
+
];
|
|
2298
|
+
existingAssistantMsg.metadata = {
|
|
2299
|
+
...existingAssistantMsg.metadata,
|
|
2300
|
+
...builtAssistantMsg.metadata,
|
|
2301
|
+
integrationRunIds: [...previousRunIds, completedRun.runId],
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
1849
2305
|
const assistantMsg = existingAssistantMsg ?? builtAssistantMsg;
|
|
1850
|
-
if (assistantMsg) {
|
|
2306
|
+
if (assistantMsg && deliveryAttempted) {
|
|
1851
2307
|
assistantMsg.metadata.integrationDeliveryAttempted = true;
|
|
2308
|
+
}
|
|
2309
|
+
if (assistantMsg) {
|
|
1852
2310
|
const artifactIdentities = extractA2AArtifactIdentities(toolResults, {
|
|
1853
2311
|
persistedArtifactSecrets: artifactSecrets,
|
|
1854
2312
|
});
|
|
@@ -140,7 +140,7 @@ function trimTrailingSlash(value: string): string {
|
|
|
140
140
|
export function getBuilderDesignSystemsBaseUrl(): string {
|
|
141
141
|
return (
|
|
142
142
|
process.env.BUILDER_DESIGN_SYSTEMS_BASE_URL ||
|
|
143
|
-
`${trimTrailingSlash(getBuilderProxyOrigin())}/design-systems/v1`
|
|
143
|
+
`${trimTrailingSlash(getBuilderProxyOrigin())}/agent-native/design-systems/v1`
|
|
144
144
|
);
|
|
145
145
|
}
|
|
146
146
|
|
|
@@ -84,6 +84,21 @@ Do not rely on undeclared time variables. Server validation rejects unbound
|
|
|
84
84
|
first-party SQL, so declare the filter or choose an explicit non-dashboard
|
|
85
85
|
scope before saving.
|
|
86
86
|
|
|
87
|
+
**A bound anywhere in the SQL is not the same as every CTE having its own
|
|
88
|
+
bound.** If a panel has multiple top-level CTEs (`WITH a AS (...), b AS
|
|
89
|
+
(...)`) and more than one of them reads `analytics_events`, EVERY one of
|
|
90
|
+
those CTEs needs its own `{{timeRange}}`/`{{<id>Start}}`/`{{<id>End}}`
|
|
91
|
+
reference or literal date bound — not just the final `SELECT` or one sibling
|
|
92
|
+
CTE. A CTE that computes something like "this user's first-ever active day"
|
|
93
|
+
by scanning `analytics_events` with no bound at all will full-table-scan on
|
|
94
|
+
every render even though the panel *looks* time-bound overall (root cause of
|
|
95
|
+
a 2026-07-25 production incident: several dashboards had exactly this shape).
|
|
96
|
+
Server validation checks each top-level CTE independently now, so this fails
|
|
97
|
+
at save time — but write it right the first time: bound every CTE, or use
|
|
98
|
+
`config.timeScope: "cohort-history"` only for a CTE that is genuinely
|
|
99
|
+
defining a cohort (e.g. a first-seen date), never as a way to skip bounding
|
|
100
|
+
an ordinary activity scan.
|
|
101
|
+
|
|
87
102
|
## Creating A Dashboard
|
|
88
103
|
|
|
89
104
|
When the user asks for a dashboard:
|