@saadnvd1/agent-os 0.3.516 → 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.
- package/README.md +27 -0
- package/app/api/devices/route.ts +2 -0
- package/app/api/push/test/route.test.ts +112 -0
- package/app/api/push/test/route.ts +38 -0
- package/components/Devices/DeviceRow.tsx +52 -3
- package/components/Devices/DevicesPanel.tsx +13 -0
- package/data/devices/queries.ts +30 -2
- package/lib/away/run.ts +15 -3
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +3 -0
- package/lib/connect/control-api.ts +38 -0
- package/lib/db/migrations.ts +31 -0
- package/lib/done/index.ts +5 -1
- package/lib/load/alert.ts +5 -1
- package/lib/notify/index.ts +46 -7
- package/lib/notify/limiter.ts +6 -1
- package/lib/notify/push/encode.ts +42 -0
- package/lib/notify/push/events.ts +72 -0
- package/lib/notify/push/index.ts +183 -0
- package/lib/notify/push/outbox.ts +238 -0
- package/lib/notify/push/policy.ts +33 -0
- package/lib/notify/push/presence.ts +45 -0
- package/lib/notify/push/relay.ts +50 -0
- package/lib/notify/push/sender.ts +271 -0
- package/lib/notify/push/testing.ts +30 -0
- package/lib/notify/push/waiting.ts +29 -0
- package/lib/notify/push-types.ts +2 -0
- package/lib/notify/redact.ts +13 -2
- package/lib/orchestrator/asks.ts +5 -1
- package/lib/orchestrator/review.ts +3 -0
- package/lib/program-status/tap.ts +11 -1
- package/lib/schedules/start.ts +11 -2
- package/lib/security/devices.ts +57 -4
- package/lib/tasks/finish.ts +2 -0
- package/lib/tasks/session.ts +26 -2
- package/package.json +1 -1
- package/server.ts +2 -0
|
@@ -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
|
+
}
|
package/lib/notify/push-types.ts
CHANGED
package/lib/notify/redact.ts
CHANGED
|
@@ -16,6 +16,17 @@ const SECRET_PATTERNS: RegExp[] = [
|
|
|
16
16
|
/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g,
|
|
17
17
|
];
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* An id or key made by the app: no known token shape, and no single
|
|
21
|
+
* segment (split on - _ : .) that is a long hex or base64 run. A uuid or a
|
|
22
|
+
* timestamped id passes; a pasted key or a 40-character sha does not.
|
|
23
|
+
*/
|
|
24
|
+
function plainIdent(text: string): boolean {
|
|
25
|
+
if (SECRET_PATTERNS.some((re) => ((re.lastIndex = 0), re.test(text))))
|
|
26
|
+
return false;
|
|
27
|
+
return !text.split(/[-_:.]/).some((seg) => seg.length >= 24);
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
/** True when the string looks like a token, key or JWT. */
|
|
20
31
|
export function looksSecret(text: string): boolean {
|
|
21
32
|
return [...SECRET_PATTERNS, LONG_RUN].some((re) => {
|
|
@@ -42,7 +53,7 @@ export function safeId(id: unknown): string | null {
|
|
|
42
53
|
if (typeof id !== "string" || id.length === 0 || id.length > MAX_ID)
|
|
43
54
|
return null;
|
|
44
55
|
if (!SAFE_ID.test(id)) return null;
|
|
45
|
-
return
|
|
56
|
+
return plainIdent(id) ? id : null;
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
/**
|
|
@@ -53,5 +64,5 @@ export function safeKey(key: unknown): string | null {
|
|
|
53
64
|
if (typeof key !== "string" || key.length === 0 || key.length > 128)
|
|
54
65
|
return null;
|
|
55
66
|
if (!/^[A-Za-z0-9_:.-]+$/.test(key)) return null;
|
|
56
|
-
return
|
|
67
|
+
return plainIdent(key) ? key : null;
|
|
57
68
|
}
|
package/lib/orchestrator/asks.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* gate or brake ask is spent on that one item: never standing permission.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { pushAskRaised } from "../notify/push/events";
|
|
9
10
|
import { db } from "../db";
|
|
10
11
|
import { answerKey, queueEvent } from "./events";
|
|
11
12
|
import { addNote } from "./notes";
|
|
@@ -167,7 +168,7 @@ export function raiseAsk(input: {
|
|
|
167
168
|
const sha = input.sha ?? null;
|
|
168
169
|
const brakeKey = input.brakeKey ?? null;
|
|
169
170
|
const raisedBy = input.raisedBy ?? null;
|
|
170
|
-
|
|
171
|
+
const result = db.transaction(() => {
|
|
171
172
|
const open = openBySubject(input.workspaceId, input.subject);
|
|
172
173
|
if (open) {
|
|
173
174
|
db.prepare(
|
|
@@ -209,6 +210,9 @@ export function raiseAsk(input: {
|
|
|
209
210
|
created: true,
|
|
210
211
|
};
|
|
211
212
|
})();
|
|
213
|
+
// A new ask buzzes the phone; a refresh of an open one doesn't.
|
|
214
|
+
if (result.created) pushAskRaised(result.ask.id, input.workspaceId);
|
|
215
|
+
return result;
|
|
212
216
|
}
|
|
213
217
|
|
|
214
218
|
export function openAsks(workspaceId: string): AskRow[] {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* the orchestrator hears the verdict as an event.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { pushReviewBlocked, pushReviewReady } from "../notify/push/events";
|
|
13
14
|
import { db, type Session } from "../db";
|
|
14
15
|
import { amendmentsOf } from "./amendments";
|
|
15
16
|
import type { TaskPR } from "../tasks/state";
|
|
@@ -275,6 +276,8 @@ export async function reviewAt(
|
|
|
275
276
|
.then((done) => {
|
|
276
277
|
if (done.status === "pass" && target.task)
|
|
277
278
|
noteQuickReview(target.task, sha);
|
|
279
|
+
if (done.status === "pass") pushReviewReady(target.id, sha);
|
|
280
|
+
else if (done.status === "block") pushReviewBlocked(target.id, sha);
|
|
278
281
|
const said =
|
|
279
282
|
done.status === "pass"
|
|
280
283
|
? "passed"
|
|
@@ -24,7 +24,9 @@ import {
|
|
|
24
24
|
applyProgramReport,
|
|
25
25
|
dropProgramTransient,
|
|
26
26
|
programSessions,
|
|
27
|
+
programSummary,
|
|
27
28
|
} from "./store";
|
|
29
|
+
import { noteWaiting } from "../notify/push/waiting";
|
|
28
30
|
|
|
29
31
|
const execFileAsync = promisify(execFile);
|
|
30
32
|
const tmux = (args: string[]) =>
|
|
@@ -85,7 +87,15 @@ async function handle(name: string, event: OscEvent): Promise<void> {
|
|
|
85
87
|
? await foreground(name)
|
|
86
88
|
: undefined
|
|
87
89
|
);
|
|
88
|
-
if (changed)
|
|
90
|
+
if (changed) {
|
|
91
|
+
notifyStatusChanged();
|
|
92
|
+
const id = (
|
|
93
|
+
getDb()
|
|
94
|
+
.prepare(`SELECT id FROM sessions WHERE tmux_name = ?`)
|
|
95
|
+
.get(name) as { id: string } | undefined
|
|
96
|
+
)?.id;
|
|
97
|
+
if (id) noteWaiting(id, programSummary(name)?.state === "blocked");
|
|
98
|
+
}
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
// What one pane has said but isn't applied yet: the latest report per
|
package/lib/schedules/start.ts
CHANGED
|
@@ -237,7 +237,11 @@ function notify(schedule: Schedule, why: string, paused?: boolean): void {
|
|
|
237
237
|
const text = `Schedule "${schedule.name}" is paused: ${why}. Point it at another session or delete it.`;
|
|
238
238
|
console.error(`[schedules] ${text}`);
|
|
239
239
|
addNote(schedule.workspace_id, text, "pause");
|
|
240
|
-
notifyPhone(`schedule:${schedule.id}`, text
|
|
240
|
+
notifyPhone(`schedule:${schedule.id}`, text, {
|
|
241
|
+
category: "system",
|
|
242
|
+
key: `system:schedule:${schedule.id}:${Date.now()}`,
|
|
243
|
+
collapse: `system:schedule:${schedule.id}`,
|
|
244
|
+
});
|
|
241
245
|
return;
|
|
242
246
|
}
|
|
243
247
|
console.error(`[schedules] ${schedule.name} failed: ${why}`);
|
|
@@ -248,7 +252,12 @@ function notify(schedule: Schedule, why: string, paused?: boolean): void {
|
|
|
248
252
|
);
|
|
249
253
|
notifyPhone(
|
|
250
254
|
`schedule:${schedule.id}`,
|
|
251
|
-
`Schedule "${schedule.name}" failed: ${why}
|
|
255
|
+
`Schedule "${schedule.name}" failed: ${why}`,
|
|
256
|
+
{
|
|
257
|
+
category: "system",
|
|
258
|
+
key: `system:schedule:${schedule.id}:${Date.now()}`,
|
|
259
|
+
collapse: `system:schedule:${schedule.id}`,
|
|
260
|
+
}
|
|
252
261
|
);
|
|
253
262
|
}
|
|
254
263
|
|
package/lib/security/devices.ts
CHANGED
|
@@ -86,13 +86,52 @@ export function touchDevice(id: string, address?: string): boolean {
|
|
|
86
86
|
return true;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
/** What the Devices page shows of a device's push: never the token. */
|
|
90
|
+
export interface DevicePushView {
|
|
91
|
+
enabled: boolean;
|
|
92
|
+
categories: PushCategory[];
|
|
93
|
+
lastError: string | null;
|
|
94
|
+
updatedAt: string | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type ListedDevice = Device & { push: DevicePushView };
|
|
98
|
+
|
|
99
|
+
export function listDevices(): ListedDevice[] {
|
|
100
|
+
const rows = getDb()
|
|
91
101
|
.prepare(
|
|
92
|
-
`SELECT id, name, user_agent, created_at, last_seen_at, last_address, revoked_at, can_approve
|
|
102
|
+
`SELECT id, name, user_agent, created_at, last_seen_at, last_address, revoked_at, can_approve,
|
|
103
|
+
${PUSH_COLS}
|
|
93
104
|
FROM devices WHERE revoked_at IS NULL ORDER BY created_at DESC`
|
|
94
105
|
)
|
|
95
|
-
.all() as Device[];
|
|
106
|
+
.all() as (Device & PushRow)[];
|
|
107
|
+
return rows.map((r) => {
|
|
108
|
+
const {
|
|
109
|
+
push_token,
|
|
110
|
+
push_platform,
|
|
111
|
+
push_environment,
|
|
112
|
+
push_categories,
|
|
113
|
+
push_updated_at,
|
|
114
|
+
push_last_error,
|
|
115
|
+
...device
|
|
116
|
+
} = r;
|
|
117
|
+
const push = pushOf({
|
|
118
|
+
push_token,
|
|
119
|
+
push_platform,
|
|
120
|
+
push_environment,
|
|
121
|
+
push_categories,
|
|
122
|
+
push_updated_at,
|
|
123
|
+
push_last_error,
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
...device,
|
|
127
|
+
push: {
|
|
128
|
+
enabled: !!push,
|
|
129
|
+
categories: push?.categories ?? [],
|
|
130
|
+
lastError: push_last_error,
|
|
131
|
+
updatedAt: push?.updatedAt ?? null,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
});
|
|
96
135
|
}
|
|
97
136
|
|
|
98
137
|
export function renameDevice(id: string, name: string): boolean {
|
|
@@ -301,3 +340,17 @@ function dropDeviceSockets(id: string): void {
|
|
|
301
340
|
for (const s of sockets.get(id) ?? []) s.destroy();
|
|
302
341
|
sockets.delete(id);
|
|
303
342
|
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The relay said this token is dead: forget it, keep the chosen categories,
|
|
346
|
+
* say why. The app registers a fresh token on its next launch.
|
|
347
|
+
*/
|
|
348
|
+
export function pruneDevicePush(id: string, reason: string): void {
|
|
349
|
+
getDb()
|
|
350
|
+
.prepare(
|
|
351
|
+
`UPDATE devices SET push_token = NULL, push_platform = NULL,
|
|
352
|
+
push_environment = NULL, push_last_error = ?
|
|
353
|
+
WHERE id = ?`
|
|
354
|
+
)
|
|
355
|
+
.run(reason.slice(0, 200), id);
|
|
356
|
+
}
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { itemName, liveChildren, signOffRefusal } from "../stacks/guard";
|
|
|
12
12
|
import { resolveMergedTaskAsks } from "../orchestrator/ask-settle";
|
|
13
13
|
import { endSessionAgent } from "../sessions/end";
|
|
14
14
|
import { run } from "./gh";
|
|
15
|
+
import { pushMerged } from "../notify/push/events";
|
|
15
16
|
import { gitFetch, gitPush } from "../git-exec";
|
|
16
17
|
import {
|
|
17
18
|
mergePolicy,
|
|
@@ -155,6 +156,7 @@ export async function signOffTask(
|
|
|
155
156
|
signingOff.delete(id);
|
|
156
157
|
}
|
|
157
158
|
syncTaskCardInBackground(session, "merged", pr);
|
|
159
|
+
pushMerged(id);
|
|
158
160
|
// Reads the plan file now: cleanup below removes the worktree.
|
|
159
161
|
void captureMergedTask(id, { head: opts.head ?? pr!.head ?? null });
|
|
160
162
|
const head = opts.head ?? pr!.head;
|
package/lib/tasks/session.ts
CHANGED
|
@@ -16,6 +16,12 @@ import { findPRStrict } from "./gh";
|
|
|
16
16
|
import { currentBranch, git } from "../git-exec";
|
|
17
17
|
import { lookupPR } from "./pr-poll";
|
|
18
18
|
import { captureMergedTask } from "../session-logs";
|
|
19
|
+
import { getOrchestrator } from "../orchestrator/home";
|
|
20
|
+
import {
|
|
21
|
+
pushCiFailed,
|
|
22
|
+
pushMerged,
|
|
23
|
+
pushReviewReady,
|
|
24
|
+
} from "../notify/push/events";
|
|
19
25
|
import { notifyStatusChanged } from "../status/hub";
|
|
20
26
|
import type { TaskPR } from "./state";
|
|
21
27
|
import { markStatusDirty } from "../status/dirty";
|
|
@@ -193,14 +199,19 @@ export async function prFor(
|
|
|
193
199
|
prCache.set(session.id, { at: Date.now(), pr });
|
|
194
200
|
markStatusDirty(session.id);
|
|
195
201
|
// A PR whose stage inputs moved is news for the sidebar's badge.
|
|
196
|
-
if (prSignature(before) !== prSignature(pr))
|
|
202
|
+
if (prSignature(before) !== prSignature(pr)) {
|
|
203
|
+
notifyStatusChanged();
|
|
204
|
+
if (pr) pushPrChange(session, pr);
|
|
205
|
+
}
|
|
197
206
|
if (pr) {
|
|
198
207
|
db.prepare(
|
|
199
208
|
`UPDATE sessions SET pr_url = ?, pr_number = ?, pr_status = ? WHERE id = ?`
|
|
200
209
|
).run(pr.url, pr.number, pr.state.toLowerCase(), session.id);
|
|
201
210
|
// Merged on GitHub rather than through AgentOS: its log is due too.
|
|
202
|
-
if (pr.state === "MERGED")
|
|
211
|
+
if (pr.state === "MERGED") {
|
|
212
|
+
if (session.pr_status !== "merged") pushMerged(session.id);
|
|
203
213
|
void captureMergedTask(session.id, { head: pr.head });
|
|
214
|
+
}
|
|
204
215
|
}
|
|
205
216
|
// Only an answer just read from GitHub: a cached one may predate a
|
|
206
217
|
// retarget AgentOS made itself (a restack).
|
|
@@ -283,3 +294,16 @@ export function forgetPR(sessionId: string): void {
|
|
|
283
294
|
prCache.delete(sessionId);
|
|
284
295
|
markStatusDirty(sessionId);
|
|
285
296
|
}
|
|
297
|
+
|
|
298
|
+
// CI failing is news for the phone in any workspace. Green is "ready for
|
|
299
|
+
// review" only where no orchestrator runs a review of its own (its verdict is
|
|
300
|
+
// the signal there). Each head is pushed once: the event key holds the sha.
|
|
301
|
+
export function pushPrChange(session: Session, pr: TaskPR): void {
|
|
302
|
+
if (pr.state !== "OPEN" || !pr.head) return;
|
|
303
|
+
if (pr.checks === "fail") pushCiFailed(session.id, pr.head);
|
|
304
|
+
else if (
|
|
305
|
+
pr.checks === "pass" &&
|
|
306
|
+
!(session.workspace_id && getOrchestrator(session.workspace_id))
|
|
307
|
+
)
|
|
308
|
+
pushReviewReady(session.id, pr.head);
|
|
309
|
+
}
|
package/package.json
CHANGED
package/server.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { startOrchestratorWatcher } from "./lib/orchestrator/watcher";
|
|
|
15
15
|
import { realDeps, schedulesEnabled, startScheduler } from "./lib/schedules";
|
|
16
16
|
import { startUsageRollup, usageRollupEnabled } from "./lib/usage/nightly";
|
|
17
17
|
import { resumePhoneOutbox } from "./lib/notify";
|
|
18
|
+
import { resumePushOutbox } from "./lib/notify/push";
|
|
18
19
|
import {
|
|
19
20
|
bindAddresses,
|
|
20
21
|
requestAllowed,
|
|
@@ -395,6 +396,7 @@ app.prepare().then(async () => {
|
|
|
395
396
|
// Schedules tick here, once a minute, and nowhere else.
|
|
396
397
|
if (schedulesEnabled(process.env)) startScheduler(realDeps);
|
|
397
398
|
resumePhoneOutbox();
|
|
399
|
+
resumePushOutbox();
|
|
398
400
|
// The usage ledger's daily rollup and its ratchet alerts.
|
|
399
401
|
if (usageRollupEnabled(process.env)) startUsageRollup();
|
|
400
402
|
// Machine load: a gauge, notes on heavy commands, one alert when red.
|