@agent-native/core 0.109.2 → 0.109.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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +8 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/create.ts +18 -12
- package/corpus/core/src/client/require-session.tsx +19 -3
- package/corpus/core/src/client/use-action.ts +145 -2
- package/corpus/core/src/client/use-session.ts +79 -48
- package/corpus/core/src/db/client.ts +13 -7
- package/corpus/core/src/db/create-get-db.ts +20 -13
- package/corpus/core/src/db/request-telemetry.ts +154 -0
- package/corpus/core/src/integrations/index.ts +1 -0
- package/corpus/core/src/integrations/plugin.ts +9 -3
- package/corpus/core/src/integrations/remote-push-delivery-job.ts +48 -0
- package/corpus/core/src/integrations/remote-push-delivery.ts +370 -0
- package/corpus/core/src/integrations/remote-push-store.ts +275 -26
- package/corpus/core/src/server/better-auth-instance.ts +11 -3
- package/corpus/core/src/server/core-routes-plugin.ts +36 -14
- package/corpus/core/src/server/framework-request-handler.ts +11 -1
- package/corpus/core/src/server/http-response-telemetry.ts +233 -31
- package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +19 -0
- package/corpus/templates/clips/actions/finalize-recording.ts +3 -1
- package/corpus/templates/clips/actions/set-mobile-capture-state.ts +4 -1
- package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -6
- package/corpus/templates/clips/changelog/2026-07-18-new-recording-is-now-emphasized-as-the-primary-action-in-the.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-18-recordings-now-remain-safely-recoverable-until-the-stored-me.md +6 -0
- package/corpus/templates/tasks/AGENTS.md +3 -0
- package/corpus/templates/tasks/actions/render-task-list-inline.ts +202 -0
- package/corpus/templates/tasks/changelog/2026-07-18-task-lists-can-now-appear-as-interactive-widgets-inline-in-c.md +6 -0
- package/corpus/templates/tasks/docs/features/f8-chat.mdx +2 -0
- package/corpus/templates/tasks/server/plugins/agent-chat.ts +2 -0
- package/dist/cli/create.d.ts +10 -1
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/create.js +17 -13
- package/dist/cli/create.js.map +1 -1
- package/dist/client/require-session.d.ts.map +1 -1
- package/dist/client/require-session.js +6 -3
- package/dist/client/require-session.js.map +1 -1
- package/dist/client/use-action.d.ts.map +1 -1
- package/dist/client/use-action.js +117 -1
- package/dist/client/use-action.js.map +1 -1
- package/dist/client/use-session.d.ts.map +1 -1
- package/dist/client/use-session.js +72 -50
- package/dist/client/use-session.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +7 -1
- package/dist/db/client.js.map +1 -1
- package/dist/db/create-get-db.d.ts +3 -6
- package/dist/db/create-get-db.d.ts.map +1 -1
- package/dist/db/create-get-db.js +9 -2
- package/dist/db/create-get-db.js.map +1 -1
- package/dist/db/request-telemetry.d.ts +23 -0
- package/dist/db/request-telemetry.d.ts.map +1 -0
- package/dist/db/request-telemetry.js +95 -0
- package/dist/db/request-telemetry.js.map +1 -0
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/integrations/index.d.ts +1 -0
- package/dist/integrations/index.d.ts.map +1 -1
- package/dist/integrations/index.js +1 -0
- package/dist/integrations/index.js.map +1 -1
- package/dist/integrations/plugin.d.ts.map +1 -1
- package/dist/integrations/plugin.js +8 -3
- package/dist/integrations/plugin.js.map +1 -1
- package/dist/integrations/remote-push-delivery-job.d.ts +4 -0
- package/dist/integrations/remote-push-delivery-job.d.ts.map +1 -0
- package/dist/integrations/remote-push-delivery-job.js +44 -0
- package/dist/integrations/remote-push-delivery-job.js.map +1 -0
- package/dist/integrations/remote-push-delivery.d.ts +11 -0
- package/dist/integrations/remote-push-delivery.d.ts.map +1 -0
- package/dist/integrations/remote-push-delivery.js +265 -0
- package/dist/integrations/remote-push-delivery.js.map +1 -0
- package/dist/integrations/remote-push-store.d.ts +33 -0
- package/dist/integrations/remote-push-store.d.ts.map +1 -1
- package/dist/integrations/remote-push-store.js +195 -26
- package/dist/integrations/remote-push-store.js.map +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/better-auth-instance.js +7 -3
- package/dist/server/better-auth-instance.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts +8 -0
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +23 -8
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/framework-request-handler.d.ts.map +1 -1
- package/dist/server/framework-request-handler.js +9 -1
- package/dist/server/framework-request-handler.js.map +1 -1
- package/dist/server/http-response-telemetry.d.ts +3 -2
- package/dist/server/http-response-telemetry.d.ts.map +1 -1
- package/dist/server/http-response-telemetry.js +160 -27
- package/dist/server/http-response-telemetry.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +19 -0
- package/package.json +1 -1
- package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +19 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
export type DatabaseOperationKind = "connect" | "query";
|
|
4
|
+
|
|
5
|
+
export interface DatabaseRequestTelemetry {
|
|
6
|
+
operationCount: number;
|
|
7
|
+
queryCount: number;
|
|
8
|
+
connectCount: number;
|
|
9
|
+
retryCount: number;
|
|
10
|
+
errorCount: number;
|
|
11
|
+
timeoutCount: number;
|
|
12
|
+
operationTotalMs: number;
|
|
13
|
+
operationWallMs: number;
|
|
14
|
+
queryTotalMs: number;
|
|
15
|
+
connectTotalMs: number;
|
|
16
|
+
slowestOperationMs: number;
|
|
17
|
+
activeOperationCount: number;
|
|
18
|
+
activeWindowStartedAt?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STORAGE_KEY = Symbol.for(
|
|
22
|
+
"@agent-native/core/db.request-telemetry-storage",
|
|
23
|
+
);
|
|
24
|
+
const STARTUP_STATE_KEY = Symbol.for(
|
|
25
|
+
"@agent-native/core/db.startup-telemetry-state",
|
|
26
|
+
);
|
|
27
|
+
const STARTUP_CAPTURE_WINDOW_MS = 120_000;
|
|
28
|
+
interface StartupDatabaseTelemetryState {
|
|
29
|
+
captureUntil: number;
|
|
30
|
+
claimed: boolean;
|
|
31
|
+
telemetry: DatabaseRequestTelemetry;
|
|
32
|
+
}
|
|
33
|
+
type GlobalWithDatabaseTelemetry = typeof globalThis & {
|
|
34
|
+
[STORAGE_KEY]?: AsyncLocalStorage<DatabaseRequestTelemetry>;
|
|
35
|
+
[STARTUP_STATE_KEY]?: StartupDatabaseTelemetryState;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const globalRef = globalThis as GlobalWithDatabaseTelemetry;
|
|
39
|
+
const storage =
|
|
40
|
+
globalRef[STORAGE_KEY] ??
|
|
41
|
+
(globalRef[STORAGE_KEY] = new AsyncLocalStorage<DatabaseRequestTelemetry>());
|
|
42
|
+
|
|
43
|
+
export function createDatabaseRequestTelemetry(): DatabaseRequestTelemetry {
|
|
44
|
+
return {
|
|
45
|
+
operationCount: 0,
|
|
46
|
+
queryCount: 0,
|
|
47
|
+
connectCount: 0,
|
|
48
|
+
retryCount: 0,
|
|
49
|
+
errorCount: 0,
|
|
50
|
+
timeoutCount: 0,
|
|
51
|
+
operationTotalMs: 0,
|
|
52
|
+
operationWallMs: 0,
|
|
53
|
+
queryTotalMs: 0,
|
|
54
|
+
connectTotalMs: 0,
|
|
55
|
+
slowestOperationMs: 0,
|
|
56
|
+
activeOperationCount: 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const startupState =
|
|
61
|
+
globalRef[STARTUP_STATE_KEY] ??
|
|
62
|
+
(globalRef[STARTUP_STATE_KEY] = {
|
|
63
|
+
captureUntil: Date.now() + STARTUP_CAPTURE_WINDOW_MS,
|
|
64
|
+
claimed: false,
|
|
65
|
+
telemetry: createDatabaseRequestTelemetry(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function currentDatabaseTelemetry(): DatabaseRequestTelemetry | undefined {
|
|
69
|
+
const requestTelemetry = storage.getStore();
|
|
70
|
+
if (requestTelemetry) return requestTelemetry;
|
|
71
|
+
if (!startupState.claimed && Date.now() <= startupState.captureUntil) {
|
|
72
|
+
return startupState.telemetry;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function claimStartupDatabaseTelemetry():
|
|
78
|
+
| DatabaseRequestTelemetry
|
|
79
|
+
| undefined {
|
|
80
|
+
if (startupState.claimed) return undefined;
|
|
81
|
+
startupState.claimed = true;
|
|
82
|
+
return startupState.telemetry;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function runWithDatabaseRequestTelemetry<T>(
|
|
86
|
+
telemetry: DatabaseRequestTelemetry,
|
|
87
|
+
fn: () => T,
|
|
88
|
+
): T {
|
|
89
|
+
return storage.run(telemetry, fn);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function enterDatabaseRequestTelemetry(
|
|
93
|
+
telemetry: DatabaseRequestTelemetry,
|
|
94
|
+
): void {
|
|
95
|
+
storage.enterWith(telemetry);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function beginDatabaseOperation(
|
|
99
|
+
operation: DatabaseOperationKind,
|
|
100
|
+
): (outcome: "success" | "error" | "timeout") => void {
|
|
101
|
+
const telemetry = currentDatabaseTelemetry();
|
|
102
|
+
if (!telemetry) return () => {};
|
|
103
|
+
|
|
104
|
+
const startedAt = Date.now();
|
|
105
|
+
if (telemetry.activeOperationCount === 0) {
|
|
106
|
+
telemetry.activeWindowStartedAt = startedAt;
|
|
107
|
+
}
|
|
108
|
+
telemetry.activeOperationCount += 1;
|
|
109
|
+
let completed = false;
|
|
110
|
+
|
|
111
|
+
return (outcome) => {
|
|
112
|
+
if (completed) return;
|
|
113
|
+
completed = true;
|
|
114
|
+
const completedAt = Date.now();
|
|
115
|
+
const duration = Math.max(0, completedAt - startedAt);
|
|
116
|
+
telemetry.operationCount += 1;
|
|
117
|
+
telemetry.operationTotalMs += duration;
|
|
118
|
+
telemetry.slowestOperationMs = Math.max(
|
|
119
|
+
telemetry.slowestOperationMs,
|
|
120
|
+
duration,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (operation === "connect") {
|
|
124
|
+
telemetry.connectCount += 1;
|
|
125
|
+
telemetry.connectTotalMs += duration;
|
|
126
|
+
} else {
|
|
127
|
+
telemetry.queryCount += 1;
|
|
128
|
+
telemetry.queryTotalMs += duration;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (outcome !== "success") telemetry.errorCount += 1;
|
|
132
|
+
if (outcome === "timeout") telemetry.timeoutCount += 1;
|
|
133
|
+
|
|
134
|
+
telemetry.activeOperationCount = Math.max(
|
|
135
|
+
0,
|
|
136
|
+
telemetry.activeOperationCount - 1,
|
|
137
|
+
);
|
|
138
|
+
if (
|
|
139
|
+
telemetry.activeOperationCount === 0 &&
|
|
140
|
+
telemetry.activeWindowStartedAt !== undefined
|
|
141
|
+
) {
|
|
142
|
+
telemetry.operationWallMs += Math.max(
|
|
143
|
+
0,
|
|
144
|
+
completedAt - telemetry.activeWindowStartedAt,
|
|
145
|
+
);
|
|
146
|
+
telemetry.activeWindowStartedAt = undefined;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function recordDatabaseRetry(): void {
|
|
152
|
+
const telemetry = currentDatabaseTelemetry();
|
|
153
|
+
if (telemetry) telemetry.retryCount += 1;
|
|
154
|
+
}
|
|
@@ -84,6 +84,7 @@ export {
|
|
|
84
84
|
unregisterRemotePushRegistrationForOwner,
|
|
85
85
|
upsertRemotePushRegistration,
|
|
86
86
|
} from "./remote-push-store.js";
|
|
87
|
+
export { deliverPendingRemotePushNotifications } from "./remote-push-delivery.js";
|
|
87
88
|
export type {
|
|
88
89
|
ComputerApprovalScope,
|
|
89
90
|
ComputerCommandAction,
|
|
@@ -109,6 +109,7 @@ import {
|
|
|
109
109
|
unregisterRemoteDevice,
|
|
110
110
|
updateRemoteDeviceDetails,
|
|
111
111
|
} from "./remote-devices-store.js";
|
|
112
|
+
import { startRemotePushDeliveryJob } from "./remote-push-delivery-job.js";
|
|
112
113
|
import {
|
|
113
114
|
listRemotePushNotificationsForOwner,
|
|
114
115
|
listRemotePushRegistrationsForOwner,
|
|
@@ -579,7 +580,6 @@ function mountedPathParts(event: any, mountSuffix: string): string[] {
|
|
|
579
580
|
function remoteCommandPushPayload(
|
|
580
581
|
command: RemoteCommand,
|
|
581
582
|
): Record<string, unknown> {
|
|
582
|
-
const result = readObject(command.result);
|
|
583
583
|
const status = command.status;
|
|
584
584
|
const title =
|
|
585
585
|
status === "completed"
|
|
@@ -587,14 +587,19 @@ function remoteCommandPushPayload(
|
|
|
587
587
|
: status === "failed"
|
|
588
588
|
? "Remote run failed"
|
|
589
589
|
: "Remote run updated";
|
|
590
|
+
const body =
|
|
591
|
+
status === "completed"
|
|
592
|
+
? "Open Agent Native to review the result."
|
|
593
|
+
: status === "failed"
|
|
594
|
+
? "Open Agent Native to review the failure."
|
|
595
|
+
: "Open Agent Native to review the latest status.";
|
|
590
596
|
return {
|
|
591
597
|
title,
|
|
592
|
-
body
|
|
598
|
+
body,
|
|
593
599
|
commandId: command.id,
|
|
594
600
|
hostId: command.deviceId,
|
|
595
601
|
kind: command.kind,
|
|
596
602
|
status,
|
|
597
|
-
result: command.result,
|
|
598
603
|
updatedAt: command.updatedAt,
|
|
599
604
|
};
|
|
600
605
|
}
|
|
@@ -2897,6 +2902,7 @@ export function createIntegrationsPlugin(
|
|
|
2897
2902
|
});
|
|
2898
2903
|
startA2AContinuationRetryJob(adapterMap);
|
|
2899
2904
|
startRemoteCommandsRetryJob();
|
|
2905
|
+
startRemotePushDeliveryJob();
|
|
2900
2906
|
|
|
2901
2907
|
// ─── Start Google Docs poller/push ────────────────────────────
|
|
2902
2908
|
if (adapterMap.has("google-docs")) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { deliverPendingRemotePushNotifications } from "./remote-push-delivery.js";
|
|
2
|
+
|
|
3
|
+
const DELIVERY_INTERVAL_MS = 60_000;
|
|
4
|
+
|
|
5
|
+
let retryInterval: ReturnType<typeof setInterval> | null = null;
|
|
6
|
+
let initialTimer: ReturnType<typeof setTimeout> | null = null;
|
|
7
|
+
let running = false;
|
|
8
|
+
|
|
9
|
+
export async function runRemotePushDelivery(): Promise<void> {
|
|
10
|
+
if (running) return;
|
|
11
|
+
running = true;
|
|
12
|
+
try {
|
|
13
|
+
await deliverPendingRemotePushNotifications();
|
|
14
|
+
} catch (error) {
|
|
15
|
+
console.error("[integrations] Remote push delivery failed", {
|
|
16
|
+
error: error instanceof Error ? error.name : "UnknownError",
|
|
17
|
+
});
|
|
18
|
+
} finally {
|
|
19
|
+
running = false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function startRemotePushDeliveryJob(): void {
|
|
24
|
+
if (retryInterval) return;
|
|
25
|
+
initialTimer = setTimeout(() => void runRemotePushDelivery(), 10_000);
|
|
26
|
+
unrefTimer(initialTimer);
|
|
27
|
+
retryInterval = setInterval(
|
|
28
|
+
() => void runRemotePushDelivery(),
|
|
29
|
+
DELIVERY_INTERVAL_MS,
|
|
30
|
+
);
|
|
31
|
+
unrefTimer(retryInterval);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function stopRemotePushDeliveryJob(): void {
|
|
35
|
+
if (initialTimer) {
|
|
36
|
+
clearTimeout(initialTimer);
|
|
37
|
+
initialTimer = null;
|
|
38
|
+
}
|
|
39
|
+
if (retryInterval) {
|
|
40
|
+
clearInterval(retryInterval);
|
|
41
|
+
retryInterval = null;
|
|
42
|
+
}
|
|
43
|
+
running = false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function unrefTimer(timer: ReturnType<typeof setInterval>): void {
|
|
47
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
48
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import {
|
|
2
|
+
claimNextRemotePushDelivery,
|
|
3
|
+
deactivateRemotePushRegistration,
|
|
4
|
+
failRemotePushDelivery,
|
|
5
|
+
markRemotePushDelivered,
|
|
6
|
+
markRemotePushTicketAccepted,
|
|
7
|
+
retryRemotePushDelivery,
|
|
8
|
+
type ClaimedRemotePushDelivery,
|
|
9
|
+
} from "./remote-push-store.js";
|
|
10
|
+
|
|
11
|
+
const EXPO_PUSH_SEND_URL = "https://exp.host/--/api/v2/push/send";
|
|
12
|
+
const EXPO_PUSH_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
|
|
13
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
14
|
+
const RECEIPT_CHECK_DELAY_MS = 15 * 60_000;
|
|
15
|
+
const MAX_DELIVERY_ATTEMPTS = 12;
|
|
16
|
+
const DEFAULT_DELIVERY_LIMIT = 100;
|
|
17
|
+
|
|
18
|
+
type ExpoResult = {
|
|
19
|
+
status?: unknown;
|
|
20
|
+
id?: unknown;
|
|
21
|
+
message?: unknown;
|
|
22
|
+
details?: { error?: unknown };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type ExpoPushResponse = {
|
|
26
|
+
data?: ExpoResult | ExpoResult[] | Record<string, ExpoResult>;
|
|
27
|
+
errors?: Array<{ code?: unknown; message?: unknown }>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type DeliveryOutcome =
|
|
31
|
+
| { kind: "ticket"; ticketId: string }
|
|
32
|
+
| { kind: "delivered" }
|
|
33
|
+
| { kind: "retry"; errorCode: string; resend?: boolean }
|
|
34
|
+
| { kind: "failed"; errorCode: string; deactivate?: boolean };
|
|
35
|
+
|
|
36
|
+
export async function deliverPendingRemotePushNotifications(options?: {
|
|
37
|
+
fetchImpl?: typeof fetch;
|
|
38
|
+
now?: () => number;
|
|
39
|
+
limit?: number;
|
|
40
|
+
}): Promise<{
|
|
41
|
+
sent: number;
|
|
42
|
+
delivered: number;
|
|
43
|
+
retried: number;
|
|
44
|
+
failed: number;
|
|
45
|
+
}> {
|
|
46
|
+
const fetchImpl = options?.fetchImpl ?? fetch;
|
|
47
|
+
const now = options?.now ?? Date.now;
|
|
48
|
+
const limit = Math.max(
|
|
49
|
+
1,
|
|
50
|
+
Math.min(options?.limit ?? DEFAULT_DELIVERY_LIMIT, 100),
|
|
51
|
+
);
|
|
52
|
+
const summary = { sent: 0, delivered: 0, retried: 0, failed: 0 };
|
|
53
|
+
const claimedDeliveries: ClaimedRemotePushDelivery[] = [];
|
|
54
|
+
|
|
55
|
+
for (let processed = 0; processed < limit; processed++) {
|
|
56
|
+
const delivery = await claimNextRemotePushDelivery({ now: now() });
|
|
57
|
+
if (!delivery) break;
|
|
58
|
+
|
|
59
|
+
if (delivery.attempts > MAX_DELIVERY_ATTEMPTS) {
|
|
60
|
+
await failRemotePushDelivery({
|
|
61
|
+
id: delivery.id,
|
|
62
|
+
phase: delivery.phase,
|
|
63
|
+
errorCode: "attempts_exhausted",
|
|
64
|
+
});
|
|
65
|
+
summary.failed++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
claimedDeliveries.push(delivery);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sendDeliveries = claimedDeliveries.filter(
|
|
73
|
+
(delivery) => delivery.phase === "send",
|
|
74
|
+
);
|
|
75
|
+
if (sendDeliveries.length > 0) {
|
|
76
|
+
let outcomes: DeliveryOutcome[];
|
|
77
|
+
try {
|
|
78
|
+
outcomes = await sendExpoPushNotifications(sendDeliveries, fetchImpl);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
const errorCode = classifyTransportError(error);
|
|
81
|
+
outcomes = sendDeliveries.map(() => ({ kind: "retry", errorCode }));
|
|
82
|
+
}
|
|
83
|
+
for (let index = 0; index < sendDeliveries.length; index++) {
|
|
84
|
+
await applyDeliveryOutcome(
|
|
85
|
+
sendDeliveries[index]!,
|
|
86
|
+
outcomes[index]!,
|
|
87
|
+
summary,
|
|
88
|
+
now,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const delivery of claimedDeliveries) {
|
|
94
|
+
if (delivery.phase !== "receipt") continue;
|
|
95
|
+
let outcome: DeliveryOutcome;
|
|
96
|
+
try {
|
|
97
|
+
outcome = await readExpoPushReceipt(delivery, fetchImpl);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
outcome = {
|
|
100
|
+
kind: "retry",
|
|
101
|
+
errorCode: classifyTransportError(error),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await applyDeliveryOutcome(delivery, outcome, summary, now);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return summary;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function applyDeliveryOutcome(
|
|
112
|
+
delivery: ClaimedRemotePushDelivery,
|
|
113
|
+
outcome: DeliveryOutcome,
|
|
114
|
+
summary: {
|
|
115
|
+
sent: number;
|
|
116
|
+
delivered: number;
|
|
117
|
+
retried: number;
|
|
118
|
+
failed: number;
|
|
119
|
+
},
|
|
120
|
+
now: () => number,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
if (outcome.kind === "ticket") {
|
|
123
|
+
await markRemotePushTicketAccepted({
|
|
124
|
+
id: delivery.id,
|
|
125
|
+
providerTicketId: outcome.ticketId,
|
|
126
|
+
checkAfter: now() + RECEIPT_CHECK_DELAY_MS,
|
|
127
|
+
});
|
|
128
|
+
summary.sent++;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (outcome.kind === "delivered") {
|
|
132
|
+
await markRemotePushDelivered(delivery.id);
|
|
133
|
+
summary.delivered++;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (outcome.kind === "failed") {
|
|
137
|
+
await failRemotePushDelivery({
|
|
138
|
+
id: delivery.id,
|
|
139
|
+
phase: delivery.phase,
|
|
140
|
+
errorCode: outcome.errorCode,
|
|
141
|
+
});
|
|
142
|
+
if (outcome.deactivate) {
|
|
143
|
+
await deactivateRemotePushRegistration(delivery.registrationId);
|
|
144
|
+
}
|
|
145
|
+
summary.failed++;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await retryRemotePushDelivery({
|
|
150
|
+
id: delivery.id,
|
|
151
|
+
phase: delivery.phase,
|
|
152
|
+
retryAt: now() + retryDelayMs(delivery.attempts),
|
|
153
|
+
errorCode: outcome.errorCode,
|
|
154
|
+
resend: outcome.resend,
|
|
155
|
+
});
|
|
156
|
+
summary.retried++;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function sendExpoPushNotifications(
|
|
160
|
+
deliveries: ClaimedRemotePushDelivery[],
|
|
161
|
+
fetchImpl: typeof fetch,
|
|
162
|
+
): Promise<DeliveryOutcome[]> {
|
|
163
|
+
const outcomes = new Map<string, DeliveryOutcome>();
|
|
164
|
+
const supportedDeliveries = deliveries.filter((delivery) => {
|
|
165
|
+
const supported =
|
|
166
|
+
delivery.provider === "expo" && isExpoPushToken(delivery.token);
|
|
167
|
+
if (!supported) {
|
|
168
|
+
outcomes.set(delivery.id, {
|
|
169
|
+
kind: "failed",
|
|
170
|
+
errorCode: "unsupported_push_registration",
|
|
171
|
+
deactivate: true,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return supported;
|
|
175
|
+
});
|
|
176
|
+
if (supportedDeliveries.length === 0) {
|
|
177
|
+
return deliveries.map((delivery) => outcomes.get(delivery.id)!);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const messages = supportedDeliveries.map(buildExpoMessage);
|
|
181
|
+
const response = await fetchWithTimeout(
|
|
182
|
+
fetchImpl,
|
|
183
|
+
EXPO_PUSH_SEND_URL,
|
|
184
|
+
expoRequestInit(messages.length === 1 ? messages[0] : messages),
|
|
185
|
+
);
|
|
186
|
+
const body = await readExpoResponse(response);
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
const outcome = responseFailure(response.status, body);
|
|
189
|
+
for (const delivery of supportedDeliveries) {
|
|
190
|
+
outcomes.set(delivery.id, outcome);
|
|
191
|
+
}
|
|
192
|
+
return deliveries.map((delivery) => outcomes.get(delivery.id)!);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const tickets = Array.isArray(body.data)
|
|
196
|
+
? body.data
|
|
197
|
+
: body.data && isExpoResult(body.data)
|
|
198
|
+
? [body.data]
|
|
199
|
+
: [];
|
|
200
|
+
if (
|
|
201
|
+
tickets.length !== supportedDeliveries.length ||
|
|
202
|
+
tickets.some((ticket) => !isExpoResult(ticket))
|
|
203
|
+
) {
|
|
204
|
+
for (const delivery of supportedDeliveries) {
|
|
205
|
+
outcomes.set(delivery.id, {
|
|
206
|
+
kind: "retry",
|
|
207
|
+
errorCode: "invalid_push_ticket",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return deliveries.map((delivery) => outcomes.get(delivery.id)!);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (let index = 0; index < supportedDeliveries.length; index++) {
|
|
214
|
+
const delivery = supportedDeliveries[index]!;
|
|
215
|
+
const ticket = tickets[index]!;
|
|
216
|
+
outcomes.set(
|
|
217
|
+
delivery.id,
|
|
218
|
+
ticket.status === "ok" && typeof ticket.id === "string"
|
|
219
|
+
? { kind: "ticket", ticketId: ticket.id }
|
|
220
|
+
: expoResultFailure(ticket, false),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return deliveries.map((delivery) => outcomes.get(delivery.id)!);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function readExpoPushReceipt(
|
|
227
|
+
delivery: ClaimedRemotePushDelivery,
|
|
228
|
+
fetchImpl: typeof fetch,
|
|
229
|
+
): Promise<DeliveryOutcome> {
|
|
230
|
+
if (!delivery.providerTicketId) {
|
|
231
|
+
return { kind: "retry", errorCode: "missing_push_ticket", resend: true };
|
|
232
|
+
}
|
|
233
|
+
const response = await fetchWithTimeout(
|
|
234
|
+
fetchImpl,
|
|
235
|
+
EXPO_PUSH_RECEIPTS_URL,
|
|
236
|
+
expoRequestInit({ ids: [delivery.providerTicketId] }),
|
|
237
|
+
);
|
|
238
|
+
const body = await readExpoResponse(response);
|
|
239
|
+
if (!response.ok) return responseFailure(response.status, body);
|
|
240
|
+
|
|
241
|
+
const receipts = body.data;
|
|
242
|
+
if (!receipts || Array.isArray(receipts) || isExpoResult(receipts)) {
|
|
243
|
+
return { kind: "retry", errorCode: "push_receipt_unavailable" };
|
|
244
|
+
}
|
|
245
|
+
const receipt = receipts[delivery.providerTicketId];
|
|
246
|
+
if (!receipt) {
|
|
247
|
+
return { kind: "retry", errorCode: "push_receipt_unavailable" };
|
|
248
|
+
}
|
|
249
|
+
if (receipt.status === "ok") return { kind: "delivered" };
|
|
250
|
+
return expoResultFailure(receipt, true);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function buildExpoMessage(delivery: ClaimedRemotePushDelivery) {
|
|
254
|
+
const payload = readRecord(delivery.payload);
|
|
255
|
+
const title = boundedString(payload?.title, 120) ?? "Agent Native update";
|
|
256
|
+
const body = boundedString(payload?.body, 300);
|
|
257
|
+
const data = compactData(payload);
|
|
258
|
+
return {
|
|
259
|
+
to: delivery.token,
|
|
260
|
+
title,
|
|
261
|
+
...(body ? { body } : {}),
|
|
262
|
+
sound: "default",
|
|
263
|
+
priority: "high",
|
|
264
|
+
data: {
|
|
265
|
+
url: "agentnative://sessions",
|
|
266
|
+
...data,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function compactData(payload: Record<string, unknown> | null) {
|
|
272
|
+
if (!payload) return {};
|
|
273
|
+
const data: Record<string, string | number> = {};
|
|
274
|
+
for (const key of ["commandId", "hostId", "kind", "status"] as const) {
|
|
275
|
+
const value = boundedString(payload[key], 200);
|
|
276
|
+
if (value) data[key] = value;
|
|
277
|
+
}
|
|
278
|
+
if (
|
|
279
|
+
typeof payload.updatedAt === "number" &&
|
|
280
|
+
Number.isFinite(payload.updatedAt)
|
|
281
|
+
) {
|
|
282
|
+
data.updatedAt = payload.updatedAt;
|
|
283
|
+
}
|
|
284
|
+
return data;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function expoRequestInit(body: unknown): RequestInit {
|
|
288
|
+
const headers: Record<string, string> = {
|
|
289
|
+
Accept: "application/json",
|
|
290
|
+
"Content-Type": "application/json",
|
|
291
|
+
};
|
|
292
|
+
const accessToken = process.env.EXPO_ACCESS_TOKEN?.trim();
|
|
293
|
+
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
|
|
294
|
+
return { method: "POST", headers, body: JSON.stringify(body) };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function fetchWithTimeout(
|
|
298
|
+
fetchImpl: typeof fetch,
|
|
299
|
+
url: string,
|
|
300
|
+
init: RequestInit,
|
|
301
|
+
): Promise<Response> {
|
|
302
|
+
const controller = new AbortController();
|
|
303
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
304
|
+
try {
|
|
305
|
+
return await fetchImpl(url, { ...init, signal: controller.signal });
|
|
306
|
+
} finally {
|
|
307
|
+
clearTimeout(timeout);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function readExpoResponse(response: Response): Promise<ExpoPushResponse> {
|
|
312
|
+
const body = await response.json().catch(() => null);
|
|
313
|
+
return (readRecord(body) ?? {}) as ExpoPushResponse;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function responseFailure(
|
|
317
|
+
status: number,
|
|
318
|
+
body: ExpoPushResponse,
|
|
319
|
+
): DeliveryOutcome {
|
|
320
|
+
const code = boundedString(body.errors?.[0]?.code, 120);
|
|
321
|
+
if (status === 429 || status >= 500) {
|
|
322
|
+
return { kind: "retry", errorCode: code ?? `expo_http_${status}` };
|
|
323
|
+
}
|
|
324
|
+
return { kind: "failed", errorCode: code ?? `expo_http_${status}` };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function expoResultFailure(
|
|
328
|
+
result: ExpoResult,
|
|
329
|
+
fromReceipt: boolean,
|
|
330
|
+
): DeliveryOutcome {
|
|
331
|
+
const code = boundedString(result.details?.error, 120) ?? "expo_push_error";
|
|
332
|
+
if (code === "DeviceNotRegistered") {
|
|
333
|
+
return { kind: "failed", errorCode: code, deactivate: true };
|
|
334
|
+
}
|
|
335
|
+
if (code === "MessageRateExceeded") {
|
|
336
|
+
return { kind: "retry", errorCode: code, resend: fromReceipt };
|
|
337
|
+
}
|
|
338
|
+
return { kind: "failed", errorCode: code };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function retryDelayMs(attempt: number): number {
|
|
342
|
+
return Math.min(60 * 60_000, 5_000 * 2 ** Math.max(0, attempt - 1));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function classifyTransportError(error: unknown): string {
|
|
346
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
347
|
+
return "expo_request_timeout";
|
|
348
|
+
}
|
|
349
|
+
return "expo_transport_error";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function isExpoPushToken(value: string): boolean {
|
|
353
|
+
return /^(Expo(nent)?PushToken)\[[A-Za-z0-9_-]+\]$/.test(value);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function isExpoResult(value: unknown): value is ExpoResult {
|
|
357
|
+
return value !== null && typeof value === "object" && "status" in value;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function readRecord(value: unknown): Record<string, unknown> | null {
|
|
361
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
362
|
+
? (value as Record<string, unknown>)
|
|
363
|
+
: null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function boundedString(value: unknown, maxLength: number): string | null {
|
|
367
|
+
if (typeof value !== "string") return null;
|
|
368
|
+
const trimmed = value.trim();
|
|
369
|
+
return trimmed ? trimmed.slice(0, maxLength) : null;
|
|
370
|
+
}
|