@saadnvd1/agent-os 0.3.516 → 0.3.518

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 (77) hide show
  1. package/README.md +47 -3
  2. package/app/api/devices/route.ts +2 -0
  3. package/app/api/push/test/route.test.ts +112 -0
  4. package/app/api/push/test/route.ts +38 -0
  5. package/app/api/tasks/[id]/arm/route.ts +5 -0
  6. package/app/api/tasks/[id]/chat-tail/route.ts +15 -0
  7. package/app/api/tasks/[id]/done/route.ts +15 -0
  8. package/app/api/tasks/[id]/export/route.ts +6 -1
  9. package/app/api/tasks/[id]/merge/route.ts +9 -1
  10. package/app/api/tasks/[id]/message/route.ts +39 -0
  11. package/app/api/tasks/[id]/moved/route.ts +6 -1
  12. package/app/api/tasks/[id]/resume/route.ts +6 -1
  13. package/app/api/tasks/[id]/stop/route.ts +14 -0
  14. package/app/api/tasks/import/route.ts +11 -1
  15. package/components/Devices/DeviceRow.tsx +52 -3
  16. package/components/Devices/DevicesPanel.tsx +13 -0
  17. package/data/devices/queries.ts +30 -2
  18. package/lib/away/run.ts +15 -3
  19. package/lib/bus/index.ts +15 -2
  20. package/lib/chat/registry.ts +2 -0
  21. package/lib/chat/runner.ts +3 -0
  22. package/lib/connect/control-api.ts +38 -0
  23. package/lib/db/migrations.ts +56 -0
  24. package/lib/db/types.ts +7 -0
  25. package/lib/done/bulk.ts +8 -0
  26. package/lib/done/index.ts +20 -1
  27. package/lib/load/alert.ts +5 -1
  28. package/lib/models/plan-step.ts +13 -3
  29. package/lib/notify/index.ts +46 -7
  30. package/lib/notify/limiter.ts +6 -1
  31. package/lib/notify/push/encode.ts +42 -0
  32. package/lib/notify/push/events.ts +72 -0
  33. package/lib/notify/push/index.ts +183 -0
  34. package/lib/notify/push/outbox.ts +238 -0
  35. package/lib/notify/push/policy.ts +33 -0
  36. package/lib/notify/push/presence.ts +45 -0
  37. package/lib/notify/push/relay.ts +50 -0
  38. package/lib/notify/push/sender.ts +271 -0
  39. package/lib/notify/push/testing.ts +30 -0
  40. package/lib/notify/push/waiting.ts +29 -0
  41. package/lib/notify/push-types.ts +2 -0
  42. package/lib/notify/redact.ts +13 -2
  43. package/lib/orchestrator/act.ts +41 -1
  44. package/lib/orchestrator/armed.ts +6 -1
  45. package/lib/orchestrator/asks.ts +5 -1
  46. package/lib/orchestrator/brakes.ts +9 -0
  47. package/lib/orchestrator/brief.ts +2 -1
  48. package/lib/orchestrator/facts.ts +72 -21
  49. package/lib/orchestrator/read.ts +14 -2
  50. package/lib/orchestrator/review.ts +3 -0
  51. package/lib/orchestrator/serve.ts +6 -0
  52. package/lib/orchestrator/shell-guard.ts +15 -0
  53. package/lib/orchestrator/tool-names.ts +1 -0
  54. package/lib/orchestrator/tool-schemas.ts +11 -0
  55. package/lib/orchestrator/tools.ts +1 -0
  56. package/lib/program-status/tap.ts +11 -1
  57. package/lib/schedules/start.ts +11 -2
  58. package/lib/security/devices.ts +57 -4
  59. package/lib/sessions/stop.ts +15 -1
  60. package/lib/tasks/branch-worktree.ts +16 -1
  61. package/lib/tasks/finish.ts +7 -1
  62. package/lib/tasks/held-remote.ts +80 -0
  63. package/lib/tasks/held-routes.ts +33 -0
  64. package/lib/tasks/held.ts +28 -0
  65. package/lib/tasks/import.ts +60 -7
  66. package/lib/tasks/index.ts +11 -1
  67. package/lib/tasks/last-active.ts +25 -0
  68. package/lib/tasks/move-bundle.ts +53 -1
  69. package/lib/tasks/move-carry.ts +102 -0
  70. package/lib/tasks/move-flow.ts +34 -7
  71. package/lib/tasks/move-guard.ts +17 -13
  72. package/lib/tasks/move-recover.ts +2 -0
  73. package/lib/tasks/move.ts +34 -3
  74. package/lib/tasks/remote.ts +3 -0
  75. package/lib/tasks/session.ts +27 -3
  76. package/package.json +1 -1
  77. package/server.ts +2 -0
@@ -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
+ }
@@ -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
+ }