@saadnvd1/agent-os 0.3.514 → 0.3.517

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.
Files changed (46) hide show
  1. package/README.md +40 -1
  2. package/app/api/devices/me/push/route.test.ts +209 -0
  3. package/app/api/devices/me/push/route.ts +115 -0
  4. package/app/api/devices/me/route.ts +12 -0
  5. package/app/api/devices/route.ts +2 -0
  6. package/app/api/push/test/route.test.ts +112 -0
  7. package/app/api/push/test/route.ts +38 -0
  8. package/components/Devices/DeviceRow.tsx +52 -3
  9. package/components/Devices/DevicesPanel.tsx +13 -0
  10. package/data/devices/queries.ts +30 -2
  11. package/lib/away/run.ts +15 -3
  12. package/lib/chat/registry.ts +2 -0
  13. package/lib/chat/runner.ts +3 -0
  14. package/lib/connect/control-api.ts +38 -0
  15. package/lib/connect/serve.ts +107 -31
  16. package/lib/connect/supervise.ts +110 -11
  17. package/lib/db/migrations.ts +59 -0
  18. package/lib/done/index.ts +5 -1
  19. package/lib/load/alert.ts +5 -1
  20. package/lib/notify/copy.ts +190 -0
  21. package/lib/notify/index.ts +46 -7
  22. package/lib/notify/limiter.ts +6 -1
  23. package/lib/notify/push/encode.ts +42 -0
  24. package/lib/notify/push/events.ts +72 -0
  25. package/lib/notify/push/index.ts +183 -0
  26. package/lib/notify/push/outbox.ts +238 -0
  27. package/lib/notify/push/policy.ts +33 -0
  28. package/lib/notify/push/presence.ts +45 -0
  29. package/lib/notify/push/relay.ts +50 -0
  30. package/lib/notify/push/sender.ts +271 -0
  31. package/lib/notify/push/testing.ts +30 -0
  32. package/lib/notify/push/waiting.ts +29 -0
  33. package/lib/notify/push-types.ts +79 -0
  34. package/lib/notify/redact.ts +68 -0
  35. package/lib/orchestrator/asks.ts +5 -1
  36. package/lib/orchestrator/review.ts +3 -0
  37. package/lib/program-status/tap.ts +11 -1
  38. package/lib/schedules/start.ts +11 -2
  39. package/lib/security/demo.ts +1 -0
  40. package/lib/security/device-me.ts +12 -0
  41. package/lib/security/devices.ts +213 -5
  42. package/lib/security/net.ts +47 -11
  43. package/lib/tasks/finish.ts +2 -0
  44. package/lib/tasks/session.ts +26 -2
  45. package/package.json +1 -1
  46. package/server.ts +3 -0
@@ -0,0 +1,238 @@
1
+ /**
2
+ * The push outbox (SQLite, so a restart keeps what's waiting): one row per
3
+ * event and device. A row is marked `sending` before the relay is called; a
4
+ * restart puts it back to `pending`, and the repeat carries the same collapse
5
+ * id, so the phone replaces the alert rather than stacking a second.
6
+ */
7
+
8
+ import { db } from "../../db";
9
+ import type { PushCategory, PushLevel } from "../push-types";
10
+ import { HOUR_MS } from "./policy";
11
+
12
+ export type PushState =
13
+ | "pending"
14
+ | "sending"
15
+ | "sent"
16
+ | "failed"
17
+ | "gone"
18
+ | "capped"
19
+ | "superseded"
20
+ | "folded"
21
+ | "digested";
22
+
23
+ export interface OutboxRow {
24
+ id: number;
25
+ event_key: string;
26
+ device_id: string;
27
+ category: PushCategory;
28
+ level: PushLevel;
29
+ payload: string;
30
+ collapse_id: string | null;
31
+ state: PushState;
32
+ attempts: number;
33
+ next_attempt_at: number;
34
+ created_at: number;
35
+ sent_at: number | null;
36
+ error: string | null;
37
+ fallback_source: string | null;
38
+ fallback_text: string | null;
39
+ }
40
+
41
+ /** An event older than this isn't worth buzzing about any more. */
42
+ export const MAX_AGE_MS = HOUR_MS;
43
+ const KEEP_MS = 7 * 24 * HOUR_MS;
44
+
45
+ export const isTestKey = (eventKey: string) => eventKey.startsWith("test:");
46
+
47
+ export interface NewRow {
48
+ eventKey: string;
49
+ deviceId: string;
50
+ category: PushCategory;
51
+ level: PushLevel;
52
+ payload: unknown;
53
+ collapseId: string | null;
54
+ state?: "pending" | "capped" | "folded";
55
+ now: number;
56
+ fallback?: { source: string; text: string } | null;
57
+ }
58
+
59
+ /** False when this event was already queued for the device. */
60
+ export function enqueue(r: NewRow): boolean {
61
+ const state = r.state ?? "pending";
62
+ return db.transaction(() => {
63
+ const { changes } = db
64
+ .prepare(
65
+ `INSERT OR IGNORE INTO push_outbox
66
+ (event_key, device_id, category, level, payload, collapse_id, state,
67
+ next_attempt_at, created_at, fallback_source, fallback_text)
68
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
69
+ )
70
+ .run(
71
+ r.eventKey,
72
+ r.deviceId,
73
+ r.category,
74
+ r.level,
75
+ JSON.stringify(r.payload),
76
+ r.collapseId,
77
+ state,
78
+ r.now,
79
+ r.now,
80
+ r.fallback?.source ?? null,
81
+ r.fallback?.text ?? null
82
+ );
83
+ if (changes && state === "pending" && r.collapseId)
84
+ db.prepare(
85
+ `UPDATE push_outbox SET state = 'superseded'
86
+ WHERE device_id = ? AND collapse_id = ? AND state = 'pending' AND event_key != ?`
87
+ ).run(r.deviceId, r.collapseId, r.eventKey);
88
+ return changes > 0;
89
+ })();
90
+ }
91
+
92
+ /** Alerting pushes made for a device in the last hour (the cap's count). */
93
+ export function alertingSince(deviceId: string, now: number): number {
94
+ return (
95
+ db
96
+ .prepare(
97
+ `SELECT COUNT(*) AS n FROM push_outbox
98
+ WHERE device_id = ? AND created_at > ? AND level != 'passive'
99
+ AND category != 'asks' AND event_key NOT LIKE 'test:%'
100
+ AND state IN ('pending', 'sending', 'sent')`
101
+ )
102
+ .get(deviceId, now - HOUR_MS) as { n: number }
103
+ ).n;
104
+ }
105
+
106
+ /** Folded into the next Away digest: how many were waiting, now counted. */
107
+ export function takeFolded(now: number): number {
108
+ return db
109
+ .prepare(
110
+ `UPDATE push_outbox SET state = 'digested', sent_at = ? WHERE state = 'folded'`
111
+ )
112
+ .run(now).changes;
113
+ }
114
+
115
+ /** Pending rows past their age limit fail; returns their event keys. */
116
+ export function expire(now: number): string[] {
117
+ const rows = db
118
+ .prepare(
119
+ `SELECT DISTINCT event_key FROM push_outbox WHERE state = 'pending' AND created_at < ?`
120
+ )
121
+ .all(now - MAX_AGE_MS) as { event_key: string }[];
122
+ if (rows.length)
123
+ db.prepare(
124
+ `UPDATE push_outbox SET state = 'failed', error = 'too old to send'
125
+ WHERE state = 'pending' AND created_at < ?`
126
+ ).run(now - MAX_AGE_MS);
127
+ return rows.map((r) => r.event_key);
128
+ }
129
+
130
+ /** Due rows, marked sending with the attempt counted. */
131
+ export function claimDue(now: number, limit = 50): OutboxRow[] {
132
+ return db.transaction(() => {
133
+ const rows = db
134
+ .prepare(
135
+ `SELECT * FROM push_outbox WHERE state = 'pending' AND next_attempt_at <= ?
136
+ ORDER BY id LIMIT ?`
137
+ )
138
+ .all(now, limit) as OutboxRow[];
139
+ const mark = db.prepare(
140
+ `UPDATE push_outbox SET state = 'sending', attempts = attempts + 1 WHERE id = ?`
141
+ );
142
+ for (const r of rows) {
143
+ mark.run(r.id);
144
+ r.attempts += 1;
145
+ r.state = "sending";
146
+ }
147
+ return rows;
148
+ })();
149
+ }
150
+
151
+ export function markSent(id: number, now: number): void {
152
+ db.prepare(
153
+ `UPDATE push_outbox SET state = 'sent', sent_at = ?, error = NULL WHERE id = ?`
154
+ ).run(now, id);
155
+ }
156
+
157
+ export function markFailed(id: number, error: string, gone = false): void {
158
+ db.prepare(`UPDATE push_outbox SET state = ?, error = ? WHERE id = ?`).run(
159
+ gone ? "gone" : "failed",
160
+ error.slice(0, 200),
161
+ id
162
+ );
163
+ }
164
+
165
+ export function markRetry(id: number, at: number, error: string): void {
166
+ db.prepare(
167
+ `UPDATE push_outbox SET state = 'pending', next_attempt_at = ?, error = ? WHERE id = ?`
168
+ ).run(at, error.slice(0, 200), id);
169
+ }
170
+
171
+ /** Everything waiting fails at once, so a dead relay is asked once, not per row. */
172
+ export function failPending(error: string): string[] {
173
+ const keys = db
174
+ .prepare(
175
+ `SELECT DISTINCT event_key FROM push_outbox WHERE state = 'pending'`
176
+ )
177
+ .all() as { event_key: string }[];
178
+ db.prepare(
179
+ `UPDATE push_outbox SET state = 'failed', error = ? WHERE state = 'pending'`
180
+ ).run(error.slice(0, 200));
181
+ return keys.map((k) => k.event_key);
182
+ }
183
+
184
+ /** Every row of the event has ended and none reached a phone. */
185
+ export function claimFallback(
186
+ eventKey: string
187
+ ): { source: string; text: string } | null {
188
+ return db.transaction(() => {
189
+ const rows = db
190
+ .prepare(
191
+ `SELECT state, fallback_source, fallback_text FROM push_outbox WHERE event_key = ?`
192
+ )
193
+ .all(eventKey) as Pick<
194
+ OutboxRow,
195
+ "state" | "fallback_source" | "fallback_text"
196
+ >[];
197
+ if (
198
+ !rows.length ||
199
+ !rows.every((r) => r.state === "failed" || r.state === "gone")
200
+ )
201
+ return null;
202
+ const row = rows.find((r) => r.fallback_text);
203
+ if (!row?.fallback_text) return null;
204
+ db.prepare(
205
+ `UPDATE push_outbox SET fallback_text = NULL WHERE event_key = ?`
206
+ ).run(eventKey);
207
+ return { source: row.fallback_source ?? "push", text: row.fallback_text };
208
+ })();
209
+ }
210
+
211
+ export function nextDueAt(): number | null {
212
+ const r = db
213
+ .prepare(
214
+ `SELECT MIN(next_attempt_at) AS at FROM push_outbox WHERE state = 'pending'`
215
+ )
216
+ .get() as { at: number | null };
217
+ return r.at;
218
+ }
219
+
220
+ export function rowsFor(eventKey: string): OutboxRow[] {
221
+ return db
222
+ .prepare(`SELECT * FROM push_outbox WHERE event_key = ? ORDER BY id`)
223
+ .all(eventKey) as OutboxRow[];
224
+ }
225
+
226
+ /** After a restart: what was mid-send goes again; old history is dropped. */
227
+ export function resume(now: number): void {
228
+ db.prepare(
229
+ `UPDATE push_outbox SET state = 'pending', next_attempt_at = ? WHERE state = 'sending'`
230
+ ).run(now);
231
+ db.prepare(
232
+ `DELETE FROM push_outbox WHERE state NOT IN ('pending', 'sending', 'folded')
233
+ AND created_at < ?`
234
+ ).run(now - KEEP_MS);
235
+ db.prepare(
236
+ `DELETE FROM push_outbox WHERE state = 'folded' AND created_at < ?`
237
+ ).run(now - 24 * HOUR_MS);
238
+ }
@@ -0,0 +1,33 @@
1
+ /** Which events the quiet rules apply to. Pure. */
2
+
3
+ import type { PushCategory } from "../push-types";
4
+
5
+ /** Alerting pushes per device per rolling hour; beyond it, one summary. */
6
+ export const CAP_PER_HOUR = 10;
7
+ export const HOUR_MS = 3_600_000;
8
+ /** The Mac counts as "being used" when the keyboard or mouse moved this recently. */
9
+ export const AT_MAC_MS = 3 * 60_000;
10
+ /** A needs-you push waits this long, and is dropped if the session moves on. */
11
+ export const WAIT_MS = 60_000;
12
+
13
+ /** At the desk he sees these there. Asks are escalations and always push. */
14
+ const PRESENCE_GATED: ReadonlySet<PushCategory> = new Set([
15
+ "needs-you",
16
+ "review",
17
+ "ci",
18
+ "merged",
19
+ ]);
20
+ /** While Away is on these wait for its 10-minute digest. */
21
+ const FOLDS_WHEN_AWAY: ReadonlySet<PushCategory> = new Set([
22
+ "needs-you",
23
+ "review",
24
+ "ci",
25
+ "merged",
26
+ "agents",
27
+ "system",
28
+ ]);
29
+ const CAP_EXEMPT: ReadonlySet<PushCategory> = new Set(["asks"]);
30
+
31
+ export const presenceGated = (c: PushCategory) => PRESENCE_GATED.has(c);
32
+ export const foldsWhenAway = (c: PushCategory) => FOLDS_WHEN_AWAY.has(c);
33
+ export const capExempt = (c: PushCategory) => CAP_EXEMPT.has(c);
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Is Saad at this Mac right now? macOS reports how long the keyboard and
3
+ * mouse have been still. A machine with no display (or any error) is away.
4
+ */
5
+
6
+ import { execFile } from "child_process";
7
+ import { AT_MAC_MS } from "./policy";
8
+
9
+ /** Seconds idle from `ioreg -c IOHIDSystem` output, or null. */
10
+ export function parseHidIdle(text: string): number | null {
11
+ const m = /"HIDIdleTime"\s*=\s*(\d+)/.exec(text);
12
+ return m ? Number(m[1]) / 1e9 : null;
13
+ }
14
+
15
+ type Run = () => Promise<string>;
16
+
17
+ const ioreg: Run = () =>
18
+ new Promise((resolve, reject) =>
19
+ execFile(
20
+ "ioreg",
21
+ ["-c", "IOHIDSystem"],
22
+ { timeout: 2000, maxBuffer: 4 * 1024 * 1024 },
23
+ (err, out) => (err ? reject(err) : resolve(out))
24
+ )
25
+ );
26
+
27
+ const CACHE_MS = 15_000;
28
+ let cached: { at: number; present: boolean } | null = null;
29
+
30
+ export async function atMac(
31
+ opts: { platform?: string; run?: Run; now?: number } = {}
32
+ ): Promise<boolean> {
33
+ if ((opts.platform ?? process.platform) !== "darwin") return false;
34
+ const now = opts.now ?? Date.now();
35
+ if (cached && now - cached.at < CACHE_MS && !opts.run) return cached.present;
36
+ let present = false;
37
+ try {
38
+ const idle = parseHidIdle(await (opts.run ?? ioreg)());
39
+ present = idle !== null && idle * 1000 < AT_MAC_MS;
40
+ } catch {
41
+ present = false;
42
+ }
43
+ if (!opts.run) cached = { at: now, present };
44
+ return present;
45
+ }
@@ -0,0 +1,50 @@
1
+ /** The Connect push relay, answered in terms the sender can act on. */
2
+
3
+ import {
4
+ ApiError,
5
+ clean,
6
+ pushRelay,
7
+ type PushRequestBody,
8
+ type PushResult,
9
+ } from "@/lib/connect/control-api";
10
+ import { loadConnect } from "@/lib/connect/config";
11
+
12
+ export type RelayAnswer =
13
+ | { kind: "results"; results: PushResult[] }
14
+ // Nothing will go until someone fixes it: don't ask again for a while.
15
+ | { kind: "unavailable"; reason: string }
16
+ // The request itself is wrong: retrying can't help.
17
+ | { kind: "invalid"; reason: string }
18
+ | { kind: "retry"; reason: string; afterMs?: number };
19
+
20
+ export const NOT_LINKED = "this machine isn't linked to Connect";
21
+
22
+ /** Whether this machine can reach the relay at all. */
23
+ export const linked = () => loadConnect() !== null;
24
+
25
+ export async function relaySend(body: PushRequestBody): Promise<RelayAnswer> {
26
+ const files = loadConnect();
27
+ if (!files) return { kind: "unavailable", reason: NOT_LINKED };
28
+ try {
29
+ const results = await pushRelay(files.config, files.machineKey, body);
30
+ return { kind: "results", results };
31
+ } catch (err) {
32
+ if (err instanceof ApiError) {
33
+ const why = clean(err.message, 120);
34
+ if (err.status === 503 && err.body.error === "push_not_configured")
35
+ return {
36
+ kind: "unavailable",
37
+ reason: "push isn't set up on the Connect service yet",
38
+ };
39
+ if (err.status === 401 || err.status === 403 || err.status === 503)
40
+ return { kind: "unavailable", reason: why };
41
+ if (err.status === 400 || err.status === 413)
42
+ return { kind: "invalid", reason: why };
43
+ return { kind: "retry", reason: why };
44
+ }
45
+ return {
46
+ kind: "retry",
47
+ reason: err instanceof Error ? clean(err.message, 120) : "network error",
48
+ };
49
+ }
50
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Drains the push outbox into the Connect relay. Never throws at a caller and
3
+ * never blocks one: `kick()` starts a drain and returns. Retries back off;
4
+ * a relay that says push isn't set up is left alone for ten minutes instead
5
+ * of being asked per message, and what was waiting falls back to Telegram.
6
+ */
7
+
8
+ import type { PushRequestBody } from "@/lib/connect/control-api";
9
+ import {
10
+ getDevicePush,
11
+ pruneDevicePush,
12
+ setDevicePushError,
13
+ } from "@/lib/security/devices";
14
+ import * as outbox from "./outbox";
15
+ import { relaySend, type RelayAnswer } from "./relay";
16
+ import { isTestKey, type OutboxRow } from "./outbox";
17
+
18
+ export const MAX_ATTEMPTS = 3;
19
+ const BACKOFF_MS = [30_000, 120_000];
20
+ const MAX_BACKOFF_MS = 600_000;
21
+ export const UNAVAILABLE_MS = 600_000;
22
+ const BATCH = 10;
23
+ /** Dead tokens are hex; anything else would make the relay refuse the batch. */
24
+ const APNS_TOKEN = /^[0-9a-fA-F]{64,200}$/;
25
+
26
+ export interface SenderDeps {
27
+ now: () => number;
28
+ relay: (body: PushRequestBody) => Promise<RelayAnswer>;
29
+ /** Sends a held message by the other channel. */
30
+ fallback: (source: string, text: string) => Promise<void>;
31
+ log: (line: string) => void;
32
+ }
33
+
34
+ type Sink = SenderDeps["fallback"];
35
+ // On globalThis like the sender: the app's bundles each have their own copy
36
+ // of this module, and the one that sets the sink may not be the one that built
37
+ // the sender.
38
+ const sinks = globalThis as unknown as { __agentosPushFallback?: Sink };
39
+
40
+ /** Where held text goes when push can't deliver it (Telegram). */
41
+ export function setFallbackSink(fn: Sink): void {
42
+ sinks.__agentosPushFallback = fn;
43
+ }
44
+
45
+ const defaults = (): SenderDeps => ({
46
+ now: () => Date.now(),
47
+ relay: relaySend,
48
+ fallback: async (source, text) => sinks.__agentosPushFallback?.(source, text),
49
+ log: (line) => console.error(`[push] ${line}`),
50
+ });
51
+
52
+ export class PushSender {
53
+ private deps: SenderDeps;
54
+ private running = false;
55
+ private again = false;
56
+ private current: Promise<void> = Promise.resolve();
57
+ private timer: ReturnType<typeof setTimeout> | null = null;
58
+ private down: { until: number; reason: string } | null = null;
59
+
60
+ constructor(deps: Partial<SenderDeps> = {}) {
61
+ this.deps = { ...defaults(), ...deps };
62
+ }
63
+
64
+ /** Why pushes aren't being tried right now, or null. */
65
+ unavailable(): string | null {
66
+ if (this.down && this.deps.now() >= this.down.until) this.down = null;
67
+ return this.down?.reason ?? null;
68
+ }
69
+
70
+ clearUnavailable(): void {
71
+ this.down = null;
72
+ }
73
+
74
+ /** Stops the retry timer (a test discarding its sender). */
75
+ dispose(): void {
76
+ if (this.timer) clearTimeout(this.timer);
77
+ this.timer = null;
78
+ }
79
+
80
+ /** Starts a drain if none is running; resolves when it has caught up. */
81
+ kick(): Promise<void> {
82
+ if (this.running) {
83
+ this.again = true;
84
+ return this.current;
85
+ }
86
+ this.running = true;
87
+ this.current = this.drain().finally(() => {
88
+ this.running = false;
89
+ this.arm();
90
+ });
91
+ return this.current;
92
+ }
93
+
94
+ /** Re-arms after a restart. */
95
+ resume(): void {
96
+ outbox.resume(this.deps.now());
97
+ void this.kick();
98
+ }
99
+
100
+ private async drain(): Promise<void> {
101
+ try {
102
+ do {
103
+ this.again = false;
104
+ for (const key of outbox.expire(this.deps.now()))
105
+ await this.settle(key);
106
+ for (;;) {
107
+ const rows = outbox.claimDue(this.deps.now());
108
+ if (!rows.length) break;
109
+ const byEvent = new Map<string, OutboxRow[]>();
110
+ for (const r of rows)
111
+ byEvent.set(r.event_key, [...(byEvent.get(r.event_key) ?? []), r]);
112
+ for (const group of byEvent.values())
113
+ for (let i = 0; i < group.length; i += BATCH)
114
+ await this.sendGroup(group.slice(i, i + BATCH));
115
+ }
116
+ } while (this.again);
117
+ } catch (error) {
118
+ this.deps.log(
119
+ `drain failed: ${error instanceof Error ? error.message : error}`
120
+ );
121
+ }
122
+ }
123
+
124
+ private arm(): void {
125
+ if (this.timer) clearTimeout(this.timer);
126
+ this.timer = null;
127
+ const at = outbox.nextDueAt();
128
+ if (at === null) return;
129
+ this.timer = setTimeout(
130
+ () => void this.kick(),
131
+ Math.max(1000, at - this.deps.now())
132
+ );
133
+ this.timer.unref?.();
134
+ }
135
+
136
+ private async sendGroup(rows: OutboxRow[]): Promise<void> {
137
+ // An earlier group in this drain found the relay unusable.
138
+ const down = this.unavailable();
139
+ if (down) {
140
+ for (const r of rows) outbox.markFailed(r.id, down);
141
+ for (const key of new Set(rows.map((r) => r.event_key)))
142
+ await this.settle(key);
143
+ return;
144
+ }
145
+ const sendable: {
146
+ row: OutboxRow;
147
+ token: string;
148
+ env: "production" | "sandbox";
149
+ }[] = [];
150
+ for (const row of rows) {
151
+ const push = getDevicePush(row.device_id);
152
+ if (!push) outbox.markFailed(row.id, "no push token", true);
153
+ else if (!APNS_TOKEN.test(push.token)) {
154
+ outbox.markFailed(row.id, "not an APNs token");
155
+ setDevicePushError(
156
+ row.device_id,
157
+ "the registered token isn't an APNs token"
158
+ );
159
+ } else sendable.push({ row, token: push.token, env: push.environment });
160
+ }
161
+ // The relay folds a repeated token into one result, so send each once.
162
+ const seen = new Set<string>();
163
+ const unique = sendable.filter((s) => {
164
+ const k = `${s.token.toLowerCase()}:${s.env}`;
165
+ if (seen.has(k)) {
166
+ outbox.markFailed(s.row.id, "duplicate token");
167
+ return false;
168
+ }
169
+ seen.add(k);
170
+ return true;
171
+ });
172
+ if (unique.length) {
173
+ const first = unique[0].row;
174
+ const answer = await this.deps.relay({
175
+ v: 1,
176
+ devices: unique.map((s) => ({ token: s.token, env: s.env })),
177
+ push_type: "alert",
178
+ priority: first.level === "passive" ? 5 : 10,
179
+ level: first.level,
180
+ ...(first.collapse_id && { collapse_id: first.collapse_id }),
181
+ thread_id: first.category,
182
+ payload: JSON.parse(first.payload),
183
+ });
184
+ await this.apply(
185
+ unique.map((s) => s.row),
186
+ answer
187
+ );
188
+ }
189
+ for (const key of new Set(rows.map((r) => r.event_key)))
190
+ await this.settle(key);
191
+ }
192
+
193
+ private async apply(rows: OutboxRow[], answer: RelayAnswer): Promise<void> {
194
+ const now = this.deps.now();
195
+ if (answer.kind === "unavailable") {
196
+ this.down = { until: now + UNAVAILABLE_MS, reason: answer.reason };
197
+ this.deps.log(`push unavailable: ${answer.reason}`);
198
+ for (const r of rows) {
199
+ outbox.markFailed(r.id, answer.reason);
200
+ setDevicePushError(r.device_id, answer.reason);
201
+ }
202
+ for (const key of outbox.failPending(answer.reason))
203
+ await this.settle(key);
204
+ return;
205
+ }
206
+ if (answer.kind === "invalid") {
207
+ for (const r of rows) {
208
+ outbox.markFailed(r.id, answer.reason);
209
+ setDevicePushError(r.device_id, answer.reason);
210
+ }
211
+ return;
212
+ }
213
+ rows.forEach((r, i) => {
214
+ if (answer.kind === "retry")
215
+ return this.retry(r, now, answer.reason, answer.afterMs);
216
+ const res = answer.results[i];
217
+ if (!res) return this.retry(r, now, "no answer for this device");
218
+ if (res.status === "sent") {
219
+ outbox.markSent(r.id, now);
220
+ setDevicePushError(r.device_id, null);
221
+ } else if (res.status === "gone" || res.prune) {
222
+ const why = `token no longer valid${res.reason ? ` (${res.reason})` : ""}`;
223
+ pruneDevicePush(r.device_id, why);
224
+ outbox.markFailed(r.id, why, true);
225
+ } else if (res.status === "retry") {
226
+ this.retry(
227
+ r,
228
+ now,
229
+ res.reason ?? "try again",
230
+ res.retry_after ? res.retry_after * 1000 : undefined
231
+ );
232
+ } else {
233
+ const why = res.reason ?? "rejected";
234
+ outbox.markFailed(r.id, why);
235
+ setDevicePushError(r.device_id, why);
236
+ }
237
+ });
238
+ }
239
+
240
+ private retry(
241
+ r: OutboxRow,
242
+ now: number,
243
+ why: string,
244
+ afterMs?: number
245
+ ): void {
246
+ const max = isTestKey(r.event_key) ? 1 : MAX_ATTEMPTS;
247
+ if (r.attempts >= max) {
248
+ outbox.markFailed(r.id, why);
249
+ setDevicePushError(r.device_id, why);
250
+ return;
251
+ }
252
+ const wait = Math.min(
253
+ Math.max(BACKOFF_MS[r.attempts - 1] ?? BACKOFF_MS.at(-1)!, afterMs ?? 0),
254
+ MAX_BACKOFF_MS
255
+ );
256
+ outbox.markRetry(r.id, now + wait, why);
257
+ }
258
+
259
+ /** Once an event has ended with no phone reached, the held text goes by Telegram. */
260
+ private async settle(eventKey: string): Promise<void> {
261
+ const held = outbox.claimFallback(eventKey);
262
+ if (!held) return;
263
+ try {
264
+ await this.deps.fallback(held.source, held.text);
265
+ } catch (error) {
266
+ this.deps.log(
267
+ `fallback for ${held.source} didn't send: ${error instanceof Error ? error.message : error}`
268
+ );
269
+ }
270
+ }
271
+ }
@@ -0,0 +1,30 @@
1
+ // Test fixtures for the push sender: paired phones with push tokens.
2
+
3
+ import crypto from "crypto";
4
+ import { db } from "../../db";
5
+ import { mintDevice, setDevicePush } from "@/lib/security/devices";
6
+ import type { PushCategory } from "../push-types";
7
+
8
+ export function pairPhone(categories?: PushCategory[]): string {
9
+ const { device } = mintDevice("phone");
10
+ setDevicePush(device.id, {
11
+ token: crypto.randomBytes(32).toString("hex"),
12
+ platform: "ios",
13
+ environment: "production",
14
+ categories,
15
+ });
16
+ return device.id;
17
+ }
18
+
19
+ export function resetPush(): void {
20
+ db.prepare(`DELETE FROM push_outbox`).run();
21
+ db.prepare(`DELETE FROM devices`).run();
22
+ const g = globalThis as { __agentosPush?: { dispose(): void } };
23
+ g.__agentosPush?.dispose();
24
+ g.__agentosPush = undefined;
25
+ }
26
+
27
+ export const outboxRows = (where = "1=1") =>
28
+ db
29
+ .prepare(`SELECT * FROM push_outbox WHERE ${where} ORDER BY id`)
30
+ .all() as import("./outbox").OutboxRow[];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * "Needs you" is a session that has waited on Saad for a minute. A timer
3
+ * starts when it begins waiting and is cancelled by any change, so a quick
4
+ * permission prompt that he answers never buzzes.
5
+ */
6
+
7
+ import { pushNeedsYou } from "./events";
8
+ import { WAIT_MS } from "./policy";
9
+
10
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
11
+
12
+ export function noteWaiting(sessionId: string, waiting: boolean): void {
13
+ const armed = timers.get(sessionId);
14
+ if (!waiting) {
15
+ if (armed) {
16
+ clearTimeout(armed);
17
+ timers.delete(sessionId);
18
+ }
19
+ return;
20
+ }
21
+ if (armed) return;
22
+ const armedAt = Date.now();
23
+ const timer = setTimeout(() => {
24
+ timers.delete(sessionId);
25
+ pushNeedsYou(sessionId, armedAt);
26
+ }, WAIT_MS);
27
+ timer.unref?.();
28
+ timers.set(sessionId, timer);
29
+ }