@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,190 @@
1
+ /**
2
+ * The category registry: one entry per kind of event, holding everything
3
+ * about it (priority, whether it's on by default, the copy, the deep link).
4
+ * Adding an event is one entry here. Pure, so the mobile app can sync it.
5
+ */
6
+
7
+ import {
8
+ PUSH_CATEGORIES,
9
+ PUSH_TITLE,
10
+ type PushCategory,
11
+ type PushEnvelope,
12
+ type PushEnvelopeV1,
13
+ type PushEvent,
14
+ type PushLevel,
15
+ } from "./push-types";
16
+ import { safeId, safeKey } from "./redact";
17
+
18
+ export interface CategoryDef {
19
+ level: PushLevel;
20
+ defaultOn: boolean;
21
+ /** Settings label. */
22
+ label: string;
23
+ /** The one word or phrase shown as the body. Fixed text, no event data. */
24
+ word: string;
25
+ /** App path for an id, or the fallback when the id is missing or unsafe. */
26
+ link: (id: string | null) => string;
27
+ }
28
+
29
+ /** Where a tap goes when nothing more specific is known. */
30
+ export const FALLBACK_LINK = "/needs";
31
+
32
+ export const CATEGORIES: Record<PushCategory, CategoryDef> = {
33
+ asks: {
34
+ level: "time-sensitive",
35
+ defaultOn: true,
36
+ label: "Asks",
37
+ word: "Needs your call",
38
+ link: (id) => (id ? `/orchestrator?workspaceId=${id}` : FALLBACK_LINK),
39
+ },
40
+ "needs-you": {
41
+ level: "active",
42
+ defaultOn: true,
43
+ label: "Waiting for you",
44
+ word: "Needs you",
45
+ link: (id) => (id ? `/session/${id}` : FALLBACK_LINK),
46
+ },
47
+ review: {
48
+ level: "active",
49
+ defaultOn: true,
50
+ label: "Ready for review",
51
+ word: "Ready for review",
52
+ link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
53
+ },
54
+ ci: {
55
+ level: "active",
56
+ defaultOn: true,
57
+ label: "CI and review problems",
58
+ word: "Update",
59
+ link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
60
+ },
61
+ merged: {
62
+ level: "passive",
63
+ defaultOn: false,
64
+ label: "Merged",
65
+ word: "Update",
66
+ link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
67
+ },
68
+ agents: {
69
+ level: "active",
70
+ defaultOn: true,
71
+ label: "Messages from agents",
72
+ word: "Update",
73
+ link: (id) => (id ? `/session/${id}` : FALLBACK_LINK),
74
+ },
75
+ digest: {
76
+ level: "passive",
77
+ defaultOn: true,
78
+ label: "While you're away",
79
+ word: "Updates",
80
+ link: () => FALLBACK_LINK,
81
+ },
82
+ system: {
83
+ level: "active",
84
+ defaultOn: true,
85
+ label: "Machine alerts",
86
+ word: "Update",
87
+ link: () => "/machines",
88
+ },
89
+ };
90
+
91
+ export const isPushCategory = (c: unknown): c is PushCategory =>
92
+ typeof c === "string" && (PUSH_CATEGORIES as readonly string[]).includes(c);
93
+
94
+ export function defaultCategories(): PushCategory[] {
95
+ return PUSH_CATEGORIES.filter((c) => CATEGORIES[c].defaultOn);
96
+ }
97
+
98
+ /**
99
+ * The categories a device receives: its saved list, or the defaults when it
100
+ * has none. Unknown names are dropped so an old row survives a removed
101
+ * category.
102
+ */
103
+ export function enabledCategories(
104
+ saved: readonly unknown[] | null | undefined
105
+ ): PushCategory[] {
106
+ return saved
107
+ ? PUSH_CATEGORIES.filter((c) => saved.includes(c))
108
+ : defaultCategories();
109
+ }
110
+
111
+ /**
112
+ * The v1 payload for an event. Built only from the registry and a vetted id
113
+ * and key, so it carries no event text whatever the caller passes.
114
+ */
115
+ export function buildPushPayload(event: PushEvent): PushEnvelopeV1 {
116
+ const def = CATEGORIES[event.category];
117
+ const id = safeId(event.id);
118
+ return {
119
+ v: 1,
120
+ c: event.category,
121
+ t: PUSH_TITLE,
122
+ b: def.word,
123
+ l: def.link(id),
124
+ k: safeKey(event.key) ?? `${event.category}:${id ?? "-"}`,
125
+ p: def.level,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * The app side: read what arrived. An unknown version or a malformed body
131
+ * gives the generic fallback, so a newer server never breaks an older app.
132
+ */
133
+ export function parsePushEnvelope(raw: unknown): PushEnvelope {
134
+ const o = (raw && typeof raw === "object" ? raw : {}) as Record<
135
+ string,
136
+ unknown
137
+ >;
138
+ const category = isPushCategory(o.c) ? o.c : "system";
139
+ if (o.v === 1 || o.v === 2) {
140
+ const link =
141
+ typeof o.l === "string" && isAppLink(o.l)
142
+ ? o.l
143
+ : CATEGORIES[category].link(null);
144
+ const base = {
145
+ c: category,
146
+ t: PUSH_TITLE,
147
+ b: typeof o.b === "string" ? o.b.slice(0, 80) : CATEGORIES[category].word,
148
+ l: link,
149
+ k: safeKey(o.k) ?? `${category}:-`,
150
+ p: CATEGORIES[category].level,
151
+ } as const;
152
+ // A v2 body is read as its generic v1 alert until this app can decrypt.
153
+ return { ...base, v: 1 };
154
+ }
155
+ return buildPushPayload({ category });
156
+ }
157
+
158
+ const APP_PATHS = [
159
+ /^\/needs$/,
160
+ /^\/machines$/,
161
+ /^\/session\/[A-Za-z0-9_-]{1,64}$/,
162
+ /^\/task\/[A-Za-z0-9_-]{1,64}$/,
163
+ /^\/orchestrator\?workspaceId=[A-Za-z0-9_-]{1,64}$/,
164
+ ];
165
+
166
+ /** True for a link the registry could have produced. */
167
+ export function isAppLink(link: string): boolean {
168
+ return APP_PATHS.some((re) => re.test(link));
169
+ }
170
+
171
+ /**
172
+ * True only for a payload the registry could have built: fixed title, the
173
+ * category's own word, a known link shape, a plain key. A sender checks this
174
+ * before it sends, so nothing else can leave the machine.
175
+ */
176
+ export function isGenericPayload(p: unknown): p is PushEnvelopeV1 {
177
+ if (!p || typeof p !== "object") return false;
178
+ const o = p as Record<string, unknown>;
179
+ if (o.v !== 1 || !isPushCategory(o.c)) return false;
180
+ const def = CATEGORIES[o.c];
181
+ return (
182
+ Object.keys(o).sort().join() === "b,c,k,l,p,t,v" &&
183
+ o.t === PUSH_TITLE &&
184
+ o.b === def.word &&
185
+ o.p === def.level &&
186
+ typeof o.l === "string" &&
187
+ isAppLink(o.l) &&
188
+ safeKey(o.k) === o.k
189
+ );
190
+ }
@@ -9,6 +9,9 @@ import { db, type Session } from "../db";
9
9
  import { Limiter, type Outcome } from "./limiter";
10
10
  import { holdForBatch, msToNextSweep } from "../away/state";
11
11
  import { selectNotifier, type TelegramConfig } from "./notifiers";
12
+ import { pushAvailable, pushEvent } from "./push";
13
+ import { setFallbackSink } from "./push/sender";
14
+ import type { PushCategory } from "./push-types";
12
15
 
13
16
  export type { Outcome } from "./limiter";
14
17
 
@@ -99,6 +102,23 @@ export function resumePhoneOutbox(): void {
99
102
  }
100
103
  }
101
104
 
105
+ // Held text that push couldn't deliver goes to Telegram once.
106
+ setFallbackSink(async (source, text) => {
107
+ const outcome = await limiter().push(source, text);
108
+ if (outcome.state === "failed") throw new Error(outcome.why);
109
+ });
110
+
111
+ // Which phone-app category a message belongs to. A message with one goes to
112
+ // a registered device instead of Telegram, and to Telegram if push is off.
113
+ export interface PushRoute {
114
+ category: PushCategory;
115
+ id?: string | null;
116
+ key?: string | null;
117
+ collapse?: string | null;
118
+ /** The session it is about, so a mirror of another machine's never pushes. */
119
+ sessionId?: string | null;
120
+ }
121
+
102
122
  export class NotConfigured extends Error {
103
123
  constructor() {
104
124
  super(
@@ -111,10 +131,21 @@ export class NotConfigured extends Error {
111
131
  // NotConfigured when nothing would send it.
112
132
  export async function sendPhone(
113
133
  source: string,
114
- text: string
134
+ text: string,
135
+ push?: PushRoute
115
136
  ): Promise<Outcome> {
116
137
  const body = text.trim();
117
138
  if (!body) throw new Error("Nothing to send");
139
+ if (push && pushAvailable()) {
140
+ const { sessionId, ...event } = push;
141
+ const out = await pushEvent(event, {
142
+ sessionId,
143
+ fallback: { source, text: body },
144
+ });
145
+ if (out === "queued") return { state: "pushed" };
146
+ if (out === "folded") return { state: "held", inMs: msToNextSweep() };
147
+ if (out === "dropped" || out === "muted") return { state: "skipped" };
148
+ }
118
149
  if (!selectNotifier(process.env, telegramConfig())) throw new NotConfigured();
119
150
  const outcome = await limiter().push(source, body);
120
151
  if (outcome.state === "failed")
@@ -142,15 +173,23 @@ export async function notifyFrom(
142
173
  holdForBatch(session.workspace_id, session.id, body)
143
174
  )
144
175
  return { state: "held", inMs: msToNextSweep() };
145
- return sendPhone(
146
- session ? `session:${session.id}` : "cli",
147
- session ? `${session.name}: ${body}` : body
148
- );
176
+ const source = session ? `session:${session.id}` : "cli";
177
+ return sendPhone(source, session ? `${session.name}: ${body}` : body, {
178
+ category: "agents",
179
+ id: session?.id,
180
+ key: `agents:${session?.id ?? "cli"}:${Date.now()}`,
181
+ collapse: `agents:${session?.id ?? "cli"}`,
182
+ sessionId: session?.id,
183
+ });
149
184
  }
150
185
 
151
186
  // For the server's own alerts: never throws, never waits on the send.
152
- export function notifyPhone(source: string, text: string): void {
153
- sendPhone(source, text).catch((error) => {
187
+ export function notifyPhone(
188
+ source: string,
189
+ text: string,
190
+ push?: PushRoute
191
+ ): void {
192
+ sendPhone(source, text, push).catch((error) => {
154
193
  if (!(error instanceof NotConfigured))
155
194
  console.error(
156
195
  `[notify] ${source}:`,
@@ -16,7 +16,12 @@ export type Outcome =
16
16
  | { state: "held"; inMs: number }
17
17
  | { state: "duplicate" }
18
18
  | { state: "dropped" }
19
- | { state: "failed"; why: string };
19
+ | { state: "failed"; why: string }
20
+ // Went to the phone app; Telegram only steps in if that fails.
21
+ | { state: "pushed" }
22
+ // Push had nothing to do: the device turned the category off, or Saad is
23
+ // at the Mac.
24
+ | { state: "skipped" };
20
25
 
21
26
  export function collapse(texts: string[]): string {
22
27
  if (texts.length === 1) return texts[0];
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The one place an event becomes the bytes the relay is given. v1 is the
3
+ * generic, content-free envelope. Encrypted rich text (v2) replaces the body
4
+ * of this function and nothing else: callers hand over an event, never a
5
+ * payload.
6
+ */
7
+
8
+ import crypto from "crypto";
9
+ import { buildPushPayload, isGenericPayload } from "../copy";
10
+ import type { PushEnvelope, PushEvent, PushLevel } from "../push-types";
11
+
12
+ export interface EncodedPush {
13
+ payload: PushEnvelope;
14
+ level: PushLevel;
15
+ priority: 10 | 5;
16
+ /** Opaque: a hash, so the relay and Apple learn nothing from it. */
17
+ collapseId: string;
18
+ threadId: string;
19
+ }
20
+
21
+ /** Same event key, same id, on every machine that sends it. */
22
+ export const collapseId = (key: string) =>
23
+ crypto.createHash("sha256").update(key).digest("hex").slice(0, 32);
24
+
25
+ export function encodePush(
26
+ event: PushEvent,
27
+ _device?: { id: string }
28
+ ): EncodedPush {
29
+ const payload = buildPushPayload({ ...event, key: null });
30
+ // Nothing but what the registry could have built may leave the machine.
31
+ if (!isGenericPayload(payload))
32
+ throw new Error(
33
+ "refusing to send a push payload the registry didn't build"
34
+ );
35
+ return {
36
+ payload,
37
+ level: payload.p,
38
+ priority: payload.p === "passive" ? 5 : 10,
39
+ collapseId: collapseId(event.collapse ?? event.key ?? payload.k),
40
+ threadId: event.category,
41
+ };
42
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * One helper per event that can reach the phone. Each is fire-and-forget:
3
+ * a failure here must never touch the code that raised the event.
4
+ */
5
+
6
+ import { db } from "../../db";
7
+ import { pushEvent } from "./index";
8
+ import type { PushEvent } from "../push-types";
9
+
10
+ function fire(event: PushEvent, sessionId?: string | null): void {
11
+ pushEvent(event, { sessionId }).catch((error) =>
12
+ console.error(
13
+ `[push] ${event.category}:`,
14
+ error instanceof Error ? error.message : error
15
+ )
16
+ );
17
+ }
18
+
19
+ export const pushAskRaised = (askId: number, workspaceId: string) =>
20
+ fire({ category: "asks", id: workspaceId, key: `ask:${askId}` });
21
+
22
+ export const pushNeedsYou = (sessionId: string, armedAt: number) =>
23
+ fire(
24
+ {
25
+ category: "needs-you",
26
+ id: sessionId,
27
+ key: `needs-you:${sessionId}:${armedAt}`,
28
+ collapse: `needs-you:${sessionId}`,
29
+ },
30
+ sessionId
31
+ );
32
+
33
+ const forTask = (
34
+ category: "review" | "ci" | "merged",
35
+ prefix: string,
36
+ taskId: string,
37
+ sha?: string | null
38
+ ) =>
39
+ fire(
40
+ {
41
+ category,
42
+ id: taskId,
43
+ key: `${prefix}:${taskId}${sha ? `:${sha}` : ""}`,
44
+ collapse: `task:${taskId}`,
45
+ },
46
+ taskId
47
+ );
48
+
49
+ export const pushReviewReady = (taskId: string, sha: string) =>
50
+ forTask("review", "review", taskId, sha);
51
+ export const pushCiFailed = (taskId: string, sha: string) =>
52
+ forTask("ci", "ci", taskId, sha);
53
+ export const pushReviewBlocked = (taskId: string, sha: string) =>
54
+ forTask("ci", "review-block", taskId, sha);
55
+ export const pushMerged = (taskId: string) =>
56
+ forTask("merged", "merged", taskId);
57
+
58
+ export const pushPlanReady = (sessionId: string, itemId: string) => {
59
+ // Only a task has a screen to open; a plain chat's plan goes to the list.
60
+ const task = db
61
+ .prepare(`SELECT task_status FROM sessions WHERE id = ?`)
62
+ .get(sessionId) as { task_status: string | null } | undefined;
63
+ fire(
64
+ {
65
+ category: "review",
66
+ id: task?.task_status ? sessionId : null,
67
+ key: `plan:${sessionId}:${itemId}`,
68
+ collapse: `plan:${sessionId}`,
69
+ },
70
+ sessionId
71
+ );
72
+ };
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Push for the phone app. `pushEvent` decides whether an event goes out, to
3
+ * whom, and now or in the Away digest; the sender does the delivering.
4
+ * Telegram is the fallback only for what Telegram carried before push
5
+ * (events given a `fallback`); the newer events are push-only.
6
+ */
7
+
8
+ import crypto from "crypto";
9
+ import { db } from "../../db";
10
+ import { activeAway } from "../../away/state";
11
+ import { listPushDevices } from "@/lib/security/devices";
12
+ import type { PushEvent } from "../push-types";
13
+ import { encodePush } from "./encode";
14
+ import * as outbox from "./outbox";
15
+ import { atMac } from "./presence";
16
+ import {
17
+ CAP_PER_HOUR,
18
+ HOUR_MS,
19
+ capExempt,
20
+ foldsWhenAway,
21
+ presenceGated,
22
+ } from "./policy";
23
+ import { linked, NOT_LINKED } from "./relay";
24
+ import { PushSender } from "./sender";
25
+
26
+ const g = globalThis as unknown as { __agentosPush?: PushSender };
27
+
28
+ export function sender(): PushSender {
29
+ return (g.__agentosPush ??= new PushSender());
30
+ }
31
+
32
+ /** Whether a message with a push category can go by push right now. */
33
+ export function pushAvailable(): boolean {
34
+ return linked() && !sender().unavailable() && listPushDevices().length > 0;
35
+ }
36
+
37
+ export function pushStatus(): { linked: boolean; unavailable: string | null } {
38
+ return { linked: linked(), unavailable: sender().unavailable() };
39
+ }
40
+
41
+ export type PushOutcome =
42
+ | "queued"
43
+ | "folded"
44
+ | "dropped"
45
+ | "muted"
46
+ | "unavailable";
47
+
48
+ export interface PushOptions {
49
+ /** Held text that goes by Telegram if no phone is reached. */
50
+ fallback?: { source: string; text: string } | null;
51
+ /** A session the event is about: a mirror of another machine's never pushes. */
52
+ sessionId?: string | null;
53
+ /** Explicit sends (a test): no presence, Away, cap or category rules. */
54
+ bypass?: boolean;
55
+ deviceIds?: string[];
56
+ }
57
+
58
+ function isMirrored(sessionId: string): boolean {
59
+ const s = db
60
+ .prepare(`SELECT host_id, peer_mirror FROM sessions WHERE id = ?`)
61
+ .get(sessionId) as
62
+ | { host_id: string | null; peer_mirror: number | null }
63
+ | undefined;
64
+ return !!s && (!!s.peer_mirror || (!!s.host_id && s.host_id !== "local"));
65
+ }
66
+
67
+ export async function pushEvent(
68
+ event: PushEvent,
69
+ opts: PushOptions = {}
70
+ ): Promise<PushOutcome> {
71
+ if (opts.sessionId && isMirrored(opts.sessionId)) return "dropped";
72
+ const s = sender();
73
+ if (!linked() || s.unavailable()) return "unavailable";
74
+ const devices = listPushDevices().filter(
75
+ (d) => !opts.deviceIds || opts.deviceIds.includes(d.id)
76
+ );
77
+ if (!devices.length) return "unavailable";
78
+ const targets = opts.bypass
79
+ ? devices
80
+ : devices.filter((d) => d.push.categories.includes(event.category));
81
+ if (!targets.length) return "muted";
82
+
83
+ const now = Date.now();
84
+ const key = event.key ?? `${event.category}:${event.id ?? "-"}:${now}`;
85
+ const enc = encodePush({ ...event, key });
86
+ const row = {
87
+ eventKey: key,
88
+ category: event.category,
89
+ level: enc.level,
90
+ payload: enc.payload,
91
+ collapseId: enc.collapseId,
92
+ now,
93
+ };
94
+ if (!opts.bypass) {
95
+ if (activeAway() && foldsWhenAway(event.category)) {
96
+ outbox.enqueue({ ...row, deviceId: "", state: "folded" });
97
+ return "folded";
98
+ }
99
+ if (presenceGated(event.category) && (await atMac())) return "dropped";
100
+ }
101
+ let queued = 0;
102
+ for (const d of targets) {
103
+ const over =
104
+ !opts.bypass &&
105
+ !capExempt(event.category) &&
106
+ enc.level !== "passive" &&
107
+ outbox.alertingSince(d.id, now) >= CAP_PER_HOUR;
108
+ if (over) {
109
+ outbox.enqueue({ ...row, deviceId: d.id, state: "capped" });
110
+ // One summary an hour says there was more.
111
+ const sum = encodePush({
112
+ category: "digest",
113
+ key: `overflow:${d.id}:${Math.floor(now / HOUR_MS)}`,
114
+ });
115
+ outbox.enqueue({
116
+ eventKey: `overflow:${d.id}:${Math.floor(now / HOUR_MS)}`,
117
+ deviceId: d.id,
118
+ category: "digest",
119
+ level: sum.level,
120
+ payload: sum.payload,
121
+ collapseId: sum.collapseId,
122
+ now,
123
+ });
124
+ queued++;
125
+ continue;
126
+ }
127
+ if (outbox.enqueue({ ...row, deviceId: d.id, fallback: opts.fallback }))
128
+ queued++;
129
+ }
130
+ if (queued) void s.kick();
131
+ return queued ? "queued" : "dropped";
132
+ }
133
+
134
+ export interface TestResult {
135
+ deviceId: string;
136
+ status: "sent" | "gone" | "failed";
137
+ error: string | null;
138
+ }
139
+
140
+ /** A real send to the phones now, ignoring every quiet rule, and its answer. */
141
+ export async function sendTestPush(
142
+ deviceId?: string
143
+ ): Promise<{ results: TestResult[]; unavailable: string | null }> {
144
+ const s = sender();
145
+ if (!linked()) return { results: [], unavailable: NOT_LINKED };
146
+ s.clearUnavailable();
147
+ const key = `test:${Date.now()}:${crypto.randomBytes(3).toString("hex")}`;
148
+ const devices = listPushDevices().filter(
149
+ (d) => !deviceId || d.id === deviceId
150
+ );
151
+ const enc = encodePush({ category: "system", key });
152
+ const now = Date.now();
153
+ for (const d of devices)
154
+ outbox.enqueue({
155
+ eventKey: key,
156
+ deviceId: d.id,
157
+ category: "system",
158
+ level: enc.level,
159
+ payload: enc.payload,
160
+ collapseId: enc.collapseId,
161
+ now,
162
+ });
163
+ await s.kick();
164
+ const results = outbox.rowsFor(key).map((r) => ({
165
+ deviceId: r.device_id,
166
+ status: (r.state === "sent"
167
+ ? "sent"
168
+ : r.state === "gone"
169
+ ? "gone"
170
+ : "failed") as TestResult["status"],
171
+ error: r.state === "sent" ? null : (r.error ?? "not sent"),
172
+ }));
173
+ return { results, unavailable: s.unavailable() };
174
+ }
175
+
176
+ export function resumePushOutbox(): void {
177
+ sender().resume();
178
+ }
179
+
180
+ /** How many held events the next Away digest stands for. */
181
+ export function takeFoldedPushes(now = Date.now()): number {
182
+ return outbox.takeFolded(now);
183
+ }