workerdeck 2.2.1 → 2.4.0
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.
|
@@ -2,12 +2,370 @@ import { createReadStream, existsSync } from "node:fs";
|
|
|
2
2
|
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
3
|
import { createFileSessionStore, createWorkerServer } from "@workerdeck/server";
|
|
4
4
|
import { dashboardDir } from "@workerdeck/web";
|
|
5
|
-
import { createHash, createHmac, createPrivateKey, randomBytes, sign, timingSafeEqual } from "node:crypto";
|
|
6
5
|
import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { createHash, createHmac, createPrivateKey, randomBytes, sign, timingSafeEqual } from "node:crypto";
|
|
7
7
|
import { connect, constants } from "node:http2";
|
|
8
|
+
import { parseUserQuestions } from "@workerdeck/protocol";
|
|
8
9
|
import { homedir } from "node:os";
|
|
9
10
|
import { pathToFileURL } from "node:url";
|
|
10
11
|
import { spawn } from "node:child_process";
|
|
12
|
+
//#region src/lib/http.ts
|
|
13
|
+
function readBody(req, maxBytes) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const chunks = [];
|
|
16
|
+
let size = 0;
|
|
17
|
+
let settled = false;
|
|
18
|
+
const finish = (value) => {
|
|
19
|
+
if (!settled) {
|
|
20
|
+
settled = true;
|
|
21
|
+
resolve(value);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
req.on("data", (chunk) => {
|
|
25
|
+
size += chunk.length;
|
|
26
|
+
if (size > maxBytes) {
|
|
27
|
+
finish(null);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
chunks.push(chunk);
|
|
31
|
+
});
|
|
32
|
+
req.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
|
|
33
|
+
req.on("error", () => finish(null));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function respondJson(res, status, body, headers) {
|
|
37
|
+
res.writeHead(status, {
|
|
38
|
+
"content-type": "application/json",
|
|
39
|
+
"cache-control": "no-store",
|
|
40
|
+
...headers
|
|
41
|
+
}).end(JSON.stringify(body));
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/apns/activities.ts
|
|
45
|
+
const FILENAME$1 = "apns-activities.json";
|
|
46
|
+
const TOKEN_PATTERN$1 = /^[0-9a-fA-F]{32,200}$/;
|
|
47
|
+
const MAX_BODY_BYTES$1 = 4096;
|
|
48
|
+
function isEnvironment$1(value) {
|
|
49
|
+
return value === "development" || value === "production";
|
|
50
|
+
}
|
|
51
|
+
function keyOf(deviceToken, sessionId) {
|
|
52
|
+
return `${deviceToken}:${sessionId}`;
|
|
53
|
+
}
|
|
54
|
+
function persistable(record) {
|
|
55
|
+
const { deviceToken, sessionId, hostId, environment, updateToken, phase, startedAt } = record;
|
|
56
|
+
return {
|
|
57
|
+
deviceToken,
|
|
58
|
+
sessionId,
|
|
59
|
+
hostId,
|
|
60
|
+
environment,
|
|
61
|
+
updateToken,
|
|
62
|
+
phase,
|
|
63
|
+
startedAt
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
async function createActivityRegistry(options) {
|
|
67
|
+
const path = options.dir === null ? null : join(options.dir, FILENAME$1);
|
|
68
|
+
const records = /* @__PURE__ */ new Map();
|
|
69
|
+
if (path !== null) try {
|
|
70
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
71
|
+
for (const record of parsed.activities ?? []) if (typeof record?.deviceToken === "string" && typeof record.sessionId === "string" && isEnvironment$1(record.environment)) records.set(keyOf(record.deviceToken, record.sessionId), record);
|
|
72
|
+
} catch {}
|
|
73
|
+
const persist = async () => {
|
|
74
|
+
if (path === null) return;
|
|
75
|
+
try {
|
|
76
|
+
await mkdir(options.dir, {
|
|
77
|
+
recursive: true,
|
|
78
|
+
mode: 448
|
|
79
|
+
});
|
|
80
|
+
await writeFile(path, `${JSON.stringify({ activities: [...records.values()].map(persistable) }, null, 2)}\n`, { mode: 384 });
|
|
81
|
+
await chmod(path, 384);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
options.onError?.(error, {
|
|
84
|
+
op: "write",
|
|
85
|
+
path
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
list: () => [...records.values()],
|
|
91
|
+
get: (deviceToken, sessionId) => records.get(keyOf(deviceToken, sessionId)),
|
|
92
|
+
forSession: (sessionId) => [...records.values()].filter((record) => record.sessionId === sessionId),
|
|
93
|
+
async start(record) {
|
|
94
|
+
const existing = records.get(keyOf(record.deviceToken, record.sessionId));
|
|
95
|
+
if (existing !== void 0) return existing;
|
|
96
|
+
const created = {
|
|
97
|
+
...record,
|
|
98
|
+
phase: "starting",
|
|
99
|
+
startedAt: record.startedAt ?? Date.now()
|
|
100
|
+
};
|
|
101
|
+
records.set(keyOf(created.deviceToken, created.sessionId), created);
|
|
102
|
+
await persist();
|
|
103
|
+
return created;
|
|
104
|
+
},
|
|
105
|
+
async attach(match, updateToken) {
|
|
106
|
+
const direct = match.deviceToken === void 0 ? void 0 : records.get(keyOf(match.deviceToken, match.sessionId));
|
|
107
|
+
const candidates = direct !== void 0 ? [direct] : [...records.values()].filter((record) => record.sessionId === match.sessionId && record.environment === match.environment && record.updateToken === void 0);
|
|
108
|
+
const target = candidates.length === 1 ? candidates[0] : void 0;
|
|
109
|
+
if (target === void 0 || target.updateToken === updateToken) return target;
|
|
110
|
+
target.updateToken = updateToken;
|
|
111
|
+
target.phase = "live";
|
|
112
|
+
await persist();
|
|
113
|
+
return target;
|
|
114
|
+
},
|
|
115
|
+
async touch(deviceToken, sessionId, changes) {
|
|
116
|
+
const record = records.get(keyOf(deviceToken, sessionId));
|
|
117
|
+
if (record === void 0) return;
|
|
118
|
+
const structural = changes.phase !== void 0 && changes.phase !== record.phase;
|
|
119
|
+
Object.assign(record, changes);
|
|
120
|
+
if (structural) await persist();
|
|
121
|
+
},
|
|
122
|
+
async remove(deviceToken, sessionId) {
|
|
123
|
+
if (!records.delete(keyOf(deviceToken, sessionId))) return;
|
|
124
|
+
await persist();
|
|
125
|
+
},
|
|
126
|
+
async removeByUpdateToken(updateToken) {
|
|
127
|
+
let removed = false;
|
|
128
|
+
for (const [key, record] of records) if (record.updateToken === updateToken) {
|
|
129
|
+
records.delete(key);
|
|
130
|
+
removed = true;
|
|
131
|
+
}
|
|
132
|
+
if (removed) await persist();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function createActivityRoute(registry, authenticate) {
|
|
137
|
+
return async (req, res) => {
|
|
138
|
+
let pathname;
|
|
139
|
+
try {
|
|
140
|
+
pathname = new URL(req.url ?? "/", "http://internal").pathname;
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
if (pathname !== "/apns/activities") return false;
|
|
145
|
+
if (registry === null) {
|
|
146
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
147
|
+
res.end("this gateway runs without push\n");
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
if (req.method !== "POST" && req.method !== "DELETE") {
|
|
151
|
+
res.writeHead(405, { allow: "POST, DELETE" }).end();
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
if (authenticate(req) === null) {
|
|
155
|
+
respondJson(res, 401, { error: "unauthorized" });
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
const raw = await readBody(req, MAX_BODY_BYTES$1);
|
|
159
|
+
if (raw === null) {
|
|
160
|
+
respondJson(res, 413, { error: "body too large" });
|
|
161
|
+
res.once("finish", () => req.destroy());
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
let body;
|
|
165
|
+
try {
|
|
166
|
+
body = JSON.parse(raw);
|
|
167
|
+
} catch {
|
|
168
|
+
respondJson(res, 400, { error: "invalid JSON body" });
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
const token = body.token;
|
|
172
|
+
if (typeof token !== "string" || !TOKEN_PATTERN$1.test(token)) {
|
|
173
|
+
respondJson(res, 400, { error: "token must be a hex Live Activity update token" });
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
if (req.method === "DELETE") {
|
|
177
|
+
await registry.removeByUpdateToken(token);
|
|
178
|
+
res.writeHead(204).end();
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
const sessionId = body.sessionId;
|
|
182
|
+
if (typeof sessionId !== "string" || sessionId === "") {
|
|
183
|
+
respondJson(res, 400, { error: "sessionId is required" });
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (!isEnvironment$1(body.environment)) {
|
|
187
|
+
respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
const deviceToken = typeof body.deviceToken === "string" && TOKEN_PATTERN$1.test(body.deviceToken) ? body.deviceToken : void 0;
|
|
191
|
+
if (await registry.attach({
|
|
192
|
+
sessionId,
|
|
193
|
+
deviceToken,
|
|
194
|
+
environment: body.environment
|
|
195
|
+
}, token) === void 0) {
|
|
196
|
+
respondJson(res, 404, { error: "no activity awaiting a token for this session" });
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
respondJson(res, 200, {
|
|
200
|
+
attached: true,
|
|
201
|
+
sessionId
|
|
202
|
+
});
|
|
203
|
+
return true;
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region src/apns/devices.ts
|
|
208
|
+
const NOTIFY_EVENTS = [
|
|
209
|
+
"permission_requested",
|
|
210
|
+
"turn_completed",
|
|
211
|
+
"session_error",
|
|
212
|
+
"session_closed"
|
|
213
|
+
];
|
|
214
|
+
const DEFAULT_NOTIFY = [
|
|
215
|
+
"permission_requested",
|
|
216
|
+
"turn_completed",
|
|
217
|
+
"session_error"
|
|
218
|
+
];
|
|
219
|
+
const FILENAME = "apns-devices.json";
|
|
220
|
+
const TOKEN_PATTERN = /^[0-9a-fA-F]{32,200}$/;
|
|
221
|
+
const MAX_BODY_BYTES = 4096;
|
|
222
|
+
function isEnvironment(value) {
|
|
223
|
+
return value === "development" || value === "production";
|
|
224
|
+
}
|
|
225
|
+
function isNotifyList(value) {
|
|
226
|
+
return Array.isArray(value) && value.every((entry) => NOTIFY_EVENTS.includes(entry));
|
|
227
|
+
}
|
|
228
|
+
function sameNotify(a, b) {
|
|
229
|
+
if (a === void 0 || b === void 0) return a === b;
|
|
230
|
+
return a.length === b.length && a.every((entry, index) => entry === b[index]);
|
|
231
|
+
}
|
|
232
|
+
function wantsNotification(device, type) {
|
|
233
|
+
return (device.notify ?? DEFAULT_NOTIFY).includes(type);
|
|
234
|
+
}
|
|
235
|
+
async function createDeviceRegistry(options) {
|
|
236
|
+
const path = options.dir === null ? null : join(options.dir, FILENAME);
|
|
237
|
+
const devices = /* @__PURE__ */ new Map();
|
|
238
|
+
if (path !== null) try {
|
|
239
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
240
|
+
for (const record of parsed.devices ?? []) if (typeof record?.token === "string" && isEnvironment(record.environment)) devices.set(record.token, record);
|
|
241
|
+
} catch {}
|
|
242
|
+
const persist = async () => {
|
|
243
|
+
if (path === null) return;
|
|
244
|
+
try {
|
|
245
|
+
await mkdir(options.dir, {
|
|
246
|
+
recursive: true,
|
|
247
|
+
mode: 448
|
|
248
|
+
});
|
|
249
|
+
await writeFile(path, `${JSON.stringify({ devices: [...devices.values()] }, null, 2)}\n`, { mode: 384 });
|
|
250
|
+
await chmod(path, 384);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
options.onError?.(error, {
|
|
253
|
+
op: "write",
|
|
254
|
+
path
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
return {
|
|
259
|
+
list: () => [...devices.values()],
|
|
260
|
+
async register(record) {
|
|
261
|
+
const existing = devices.get(record.token);
|
|
262
|
+
devices.set(record.token, {
|
|
263
|
+
...record,
|
|
264
|
+
updatedAt: Date.now()
|
|
265
|
+
});
|
|
266
|
+
if (existing !== void 0 && existing.environment === record.environment && existing.hostId === record.hostId && existing.liveActivityStartToken === record.liveActivityStartToken && sameNotify(existing.notify, record.notify)) return;
|
|
267
|
+
await persist();
|
|
268
|
+
},
|
|
269
|
+
async clearStartToken(token) {
|
|
270
|
+
const existing = devices.get(token);
|
|
271
|
+
if (existing === void 0 || existing.liveActivityStartToken === void 0) return;
|
|
272
|
+
delete existing.liveActivityStartToken;
|
|
273
|
+
await persist();
|
|
274
|
+
},
|
|
275
|
+
async remove(token) {
|
|
276
|
+
if (!devices.delete(token)) return;
|
|
277
|
+
await persist();
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function createDeviceRoute(registry, authenticate) {
|
|
282
|
+
return async (req, res) => {
|
|
283
|
+
let pathname;
|
|
284
|
+
try {
|
|
285
|
+
pathname = new URL(req.url ?? "/", "http://internal").pathname;
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
if (pathname !== "/apns/devices") return false;
|
|
290
|
+
if (registry === null) {
|
|
291
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
292
|
+
res.end("this gateway runs without push\n");
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
if (req.method !== "POST" && req.method !== "DELETE") {
|
|
296
|
+
res.writeHead(405, { allow: "POST, DELETE" }).end();
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
if (authenticate(req) === null) {
|
|
300
|
+
respondJson(res, 401, { error: "unauthorized" });
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
const raw = await readBody(req, MAX_BODY_BYTES);
|
|
304
|
+
if (raw === null) {
|
|
305
|
+
respondJson(res, 413, { error: "body too large" });
|
|
306
|
+
res.once("finish", () => req.destroy());
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
let body;
|
|
310
|
+
try {
|
|
311
|
+
body = JSON.parse(raw);
|
|
312
|
+
} catch {
|
|
313
|
+
respondJson(res, 400, { error: "invalid JSON body" });
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
const token = body.token;
|
|
317
|
+
if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
|
|
318
|
+
respondJson(res, 400, { error: "token must be a hex APNs device token" });
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
if (req.method === "DELETE") {
|
|
322
|
+
await registry.remove(token);
|
|
323
|
+
res.writeHead(204).end();
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
if (!isEnvironment(body.environment)) {
|
|
327
|
+
respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
const optionalString = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
|
|
331
|
+
const startTokenGiven = Object.hasOwn(body, "liveActivityStartToken");
|
|
332
|
+
const startToken = body.liveActivityStartToken;
|
|
333
|
+
if (startTokenGiven && startToken !== null && (typeof startToken !== "string" || !TOKEN_PATTERN.test(startToken))) {
|
|
334
|
+
respondJson(res, 400, { error: "liveActivityStartToken must be a hex token or null" });
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
const notifyGiven = Object.hasOwn(body, "notify");
|
|
338
|
+
if (notifyGiven && !isNotifyList(body.notify)) {
|
|
339
|
+
respondJson(res, 400, { error: `notify must be an array of ${NOTIFY_EVENTS.join(", ")}` });
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
const previous = registry.list().find((record) => record.token === token);
|
|
343
|
+
const liveActivityStartToken = startTokenGiven ? startToken ?? void 0 : previous?.liveActivityStartToken;
|
|
344
|
+
const notify = notifyGiven ? body.notify : previous?.notify;
|
|
345
|
+
await registry.register({
|
|
346
|
+
token,
|
|
347
|
+
environment: body.environment,
|
|
348
|
+
hostId: optionalString(body.hostId),
|
|
349
|
+
bundleId: optionalString(body.bundleId),
|
|
350
|
+
platform: optionalString(body.platform),
|
|
351
|
+
...liveActivityStartToken === void 0 ? {} : { liveActivityStartToken },
|
|
352
|
+
...notify === void 0 ? {} : { notify }
|
|
353
|
+
});
|
|
354
|
+
respondJson(res, 200, {
|
|
355
|
+
registered: true,
|
|
356
|
+
environment: body.environment
|
|
357
|
+
});
|
|
358
|
+
return true;
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
//#endregion
|
|
362
|
+
//#region src/apns/routes.ts
|
|
363
|
+
function createApnsRoute(registries, authenticate) {
|
|
364
|
+
const device = createDeviceRoute(registries?.devices ?? null, authenticate);
|
|
365
|
+
const activity = createActivityRoute(registries?.activities ?? null, authenticate);
|
|
366
|
+
return async (req, res) => await device(req, res) || activity(req, res);
|
|
367
|
+
}
|
|
368
|
+
//#endregion
|
|
11
369
|
//#region src/apns/client.ts
|
|
12
370
|
const HOSTS = {
|
|
13
371
|
development: "https://api.sandbox.push.apple.com",
|
|
@@ -16,6 +374,9 @@ const HOSTS = {
|
|
|
16
374
|
const TOKEN_TTL_MS = 24e5;
|
|
17
375
|
const REQUEST_TIMEOUT_MS = 1e4;
|
|
18
376
|
const DIAL_ATTEMPT_TIMEOUT_MS = 2e3;
|
|
377
|
+
function topicFor(topic, pushType) {
|
|
378
|
+
return pushType === "liveactivity" ? `${topic}.push-type.liveactivity` : topic;
|
|
379
|
+
}
|
|
19
380
|
function base64url(input) {
|
|
20
381
|
return Buffer.from(input).toString("base64url");
|
|
21
382
|
}
|
|
@@ -119,8 +480,8 @@ function createApnsClient(config, key, options = {}) {
|
|
|
119
480
|
":method": "POST",
|
|
120
481
|
":path": `/3/device/${request.deviceToken}`,
|
|
121
482
|
authorization: `bearer ${jwt}`,
|
|
122
|
-
"apns-topic": config.topic,
|
|
123
|
-
"apns-push-type": "alert",
|
|
483
|
+
"apns-topic": topicFor(config.topic, request.pushType),
|
|
484
|
+
"apns-push-type": request.pushType ?? "alert",
|
|
124
485
|
"apns-priority": String(request.priority ?? 10),
|
|
125
486
|
"apns-expiration": String(request.expiration ?? 0),
|
|
126
487
|
"content-length": String(body.byteLength),
|
|
@@ -237,142 +598,211 @@ function createApnsClient(config, key, options = {}) {
|
|
|
237
598
|
};
|
|
238
599
|
}
|
|
239
600
|
//#endregion
|
|
240
|
-
//#region src/
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
601
|
+
//#region src/apns/live-activity.ts
|
|
602
|
+
const MAX_PAYLOAD_BYTES$1 = 3800;
|
|
603
|
+
const LIMIT = {
|
|
604
|
+
title: 60,
|
|
605
|
+
headline: 120,
|
|
606
|
+
detail: 200,
|
|
607
|
+
choiceLabel: 40,
|
|
608
|
+
choices: 4
|
|
609
|
+
};
|
|
610
|
+
const STALE_MS = 12e5;
|
|
611
|
+
const DISMISSAL_MS = 9e5;
|
|
612
|
+
const EXPIRATION_MS = 6e5;
|
|
613
|
+
const END_EXPIRATION_MS = 144e5;
|
|
614
|
+
const RELEVANCE = {
|
|
615
|
+
approval: 100,
|
|
616
|
+
question: 100,
|
|
617
|
+
plan: 100,
|
|
618
|
+
running: 50,
|
|
619
|
+
done: 10,
|
|
620
|
+
failed: 10,
|
|
621
|
+
parked: 10,
|
|
622
|
+
ended: 10
|
|
623
|
+
};
|
|
624
|
+
const PREVIEW_KEYS = [
|
|
625
|
+
"command",
|
|
626
|
+
"file_path",
|
|
627
|
+
"path",
|
|
628
|
+
"pattern",
|
|
629
|
+
"url",
|
|
630
|
+
"notebook_path",
|
|
631
|
+
"prompt",
|
|
632
|
+
"description"
|
|
633
|
+
];
|
|
634
|
+
function clamp(text, limit) {
|
|
635
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
636
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
|
|
263
637
|
}
|
|
264
|
-
function
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
...headers
|
|
269
|
-
}).end(JSON.stringify(body));
|
|
638
|
+
function sessionLabel(info) {
|
|
639
|
+
const title = info.title?.trim();
|
|
640
|
+
if (title !== void 0 && title !== "") return title;
|
|
641
|
+
return cwdLeaf(info.cwd) || info.id;
|
|
270
642
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const FILENAME = "apns-devices.json";
|
|
274
|
-
const TOKEN_PATTERN = /^[0-9a-fA-F]{32,200}$/;
|
|
275
|
-
const MAX_BODY_BYTES = 4096;
|
|
276
|
-
function isEnvironment(value) {
|
|
277
|
-
return value === "development" || value === "production";
|
|
643
|
+
function cwdLeaf(cwd) {
|
|
644
|
+
return cwd.split("/").filter(Boolean).at(-1) ?? "";
|
|
278
645
|
}
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
646
|
+
function previewOf(input) {
|
|
647
|
+
for (const key of PREVIEW_KEYS) {
|
|
648
|
+
const value = input[key];
|
|
649
|
+
if (typeof value === "string" && value.trim() !== "") return value;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
function requestKind(request) {
|
|
653
|
+
if (request.toolName === "ExitPlanMode") return "plan";
|
|
654
|
+
return request.toolName === "AskUserQuestion" && parseUserQuestions(request.input).length > 0 ? "question" : "permission";
|
|
655
|
+
}
|
|
656
|
+
function choicesFor(request, kind) {
|
|
657
|
+
if (kind !== "question") return [];
|
|
658
|
+
const questions = parseUserQuestions(request.input);
|
|
659
|
+
const [question] = questions;
|
|
660
|
+
if (questions.length !== 1 || question === void 0 || question.multiSelect === true || question.options.length > LIMIT.choices) return [];
|
|
661
|
+
return question.options.map((option, index) => ({
|
|
662
|
+
index,
|
|
663
|
+
label: clamp(option.label, LIMIT.choiceLabel)
|
|
664
|
+
}));
|
|
665
|
+
}
|
|
666
|
+
function phaseFor(info, request) {
|
|
667
|
+
switch (info.status) {
|
|
668
|
+
case "awaiting_approval": {
|
|
669
|
+
if (request === void 0) return "running";
|
|
670
|
+
const kind = requestKind(request);
|
|
671
|
+
return kind === "permission" ? "approval" : kind;
|
|
672
|
+
}
|
|
673
|
+
case "parked": return "parked";
|
|
674
|
+
case "failed": return "failed";
|
|
675
|
+
case "closed": return "ended";
|
|
676
|
+
case "idle": return "done";
|
|
677
|
+
default: return "running";
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
function headlineFor(info, phase, request) {
|
|
681
|
+
if (request !== void 0 && (phase === "approval" || phase === "question" || phase === "plan")) {
|
|
682
|
+
if (phase === "question") {
|
|
683
|
+
const [question] = parseUserQuestions(request.input);
|
|
684
|
+
return clamp(question?.header !== void 0 && question.header !== "" ? question.header : "A question", LIMIT.headline);
|
|
300
685
|
}
|
|
686
|
+
if (phase === "plan") return "Plan ready for review";
|
|
687
|
+
return clamp(request.title ?? request.displayName ?? request.toolName, LIMIT.headline);
|
|
688
|
+
}
|
|
689
|
+
if (phase === "running") {
|
|
690
|
+
const active = info.checklist?.find((item) => item.status === "in_progress");
|
|
691
|
+
return clamp(active?.text ?? "Working…", LIMIT.headline);
|
|
692
|
+
}
|
|
693
|
+
return {
|
|
694
|
+
done: "Turn finished",
|
|
695
|
+
failed: "Session failed",
|
|
696
|
+
parked: "Parked",
|
|
697
|
+
ended: "Session ended"
|
|
698
|
+
}[phase] ?? "";
|
|
699
|
+
}
|
|
700
|
+
function detailFor(phase, request) {
|
|
701
|
+
if (request === void 0) return;
|
|
702
|
+
if (phase === "question") {
|
|
703
|
+
const [question] = parseUserQuestions(request.input);
|
|
704
|
+
return question === void 0 ? void 0 : clamp(question.question, LIMIT.detail);
|
|
705
|
+
}
|
|
706
|
+
if (phase === "approval" || phase === "plan") {
|
|
707
|
+
const preview = previewOf(request.input) ?? request.description;
|
|
708
|
+
return preview === void 0 ? void 0 : clamp(preview, LIMIT.detail);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
function attributesFor(info, hostId) {
|
|
712
|
+
return {
|
|
713
|
+
sessionId: info.id,
|
|
714
|
+
...hostId === void 0 ? {} : { hostId },
|
|
715
|
+
...info.engine === void 0 ? {} : { engine: info.engine },
|
|
716
|
+
cwdLeaf: cwdLeaf(info.cwd)
|
|
301
717
|
};
|
|
718
|
+
}
|
|
719
|
+
function projectContentState(options) {
|
|
720
|
+
const { info, request, startedAtMs } = options;
|
|
721
|
+
const phase = options.finalPhase ?? phaseFor(info, request);
|
|
722
|
+
const kind = request === void 0 ? void 0 : requestKind(request);
|
|
723
|
+
const choices = request === void 0 || kind === void 0 ? [] : choicesFor(request, kind);
|
|
724
|
+
const total = info.checklist?.length ?? 0;
|
|
302
725
|
return {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
726
|
+
phase,
|
|
727
|
+
title: clamp(sessionLabel(info), LIMIT.title),
|
|
728
|
+
headline: options.finalHeadline ?? headlineFor(info, phase, request),
|
|
729
|
+
...detailFor(phase, request) === void 0 ? {} : { detail: detailFor(phase, request) },
|
|
730
|
+
startedAtMs,
|
|
731
|
+
...request?.expiresAt === void 0 ? {} : { expiresAtMs: request.expiresAt },
|
|
732
|
+
pendingCount: info.pendingPermissionCount,
|
|
733
|
+
...total === 0 ? {} : { steps: {
|
|
734
|
+
done: info.checklist.filter((item) => item.status === "completed").length,
|
|
735
|
+
total
|
|
736
|
+
} },
|
|
737
|
+
...request === void 0 || kind === void 0 ? {} : { request: {
|
|
738
|
+
id: request.id,
|
|
739
|
+
kind,
|
|
740
|
+
choices,
|
|
741
|
+
...choices.length === 0 ? {} : { inputJSON: JSON.stringify(request.input) }
|
|
742
|
+
} },
|
|
743
|
+
...info.epoch === void 0 ? {} : { epoch: info.epoch },
|
|
744
|
+
seq: info.lastSeq,
|
|
745
|
+
decision: null
|
|
317
746
|
};
|
|
318
747
|
}
|
|
319
|
-
function
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
return true;
|
|
331
|
-
}
|
|
332
|
-
if (authenticate(req) === null) {
|
|
333
|
-
respondJson(res, 401, { error: "unauthorized" });
|
|
334
|
-
return true;
|
|
335
|
-
}
|
|
336
|
-
const raw = await readBody(req, MAX_BODY_BYTES);
|
|
337
|
-
if (raw === null) {
|
|
338
|
-
respondJson(res, 413, { error: "body too large" });
|
|
339
|
-
res.once("finish", () => req.destroy());
|
|
340
|
-
return true;
|
|
341
|
-
}
|
|
342
|
-
let body;
|
|
343
|
-
try {
|
|
344
|
-
body = JSON.parse(raw);
|
|
345
|
-
} catch {
|
|
346
|
-
respondJson(res, 400, { error: "invalid JSON body" });
|
|
347
|
-
return true;
|
|
348
|
-
}
|
|
349
|
-
const token = body.token;
|
|
350
|
-
if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
|
|
351
|
-
respondJson(res, 400, { error: "token must be a hex APNs device token" });
|
|
352
|
-
return true;
|
|
353
|
-
}
|
|
354
|
-
if (req.method === "DELETE") {
|
|
355
|
-
await registry.remove(token);
|
|
356
|
-
res.writeHead(204).end();
|
|
357
|
-
return true;
|
|
358
|
-
}
|
|
359
|
-
if (!isEnvironment(body.environment)) {
|
|
360
|
-
respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
|
|
361
|
-
return true;
|
|
748
|
+
function contentHash(state) {
|
|
749
|
+
const { seq: _seq, epoch: _epoch, decision: _decision, ...drawn } = state;
|
|
750
|
+
return createHash("sha256").update(JSON.stringify(drawn)).digest("base64url").slice(0, 22);
|
|
751
|
+
}
|
|
752
|
+
function shrink(state) {
|
|
753
|
+
if (state.request?.inputJSON !== void 0) return {
|
|
754
|
+
...state,
|
|
755
|
+
request: {
|
|
756
|
+
...state.request,
|
|
757
|
+
choices: [],
|
|
758
|
+
inputJSON: void 0
|
|
362
759
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
760
|
+
};
|
|
761
|
+
if (state.detail !== void 0 && state.detail.length > 24) return {
|
|
762
|
+
...state,
|
|
763
|
+
detail: clamp(state.detail, Math.floor(state.detail.length / 2))
|
|
764
|
+
};
|
|
765
|
+
if (state.detail !== void 0) return {
|
|
766
|
+
...state,
|
|
767
|
+
detail: void 0
|
|
768
|
+
};
|
|
769
|
+
if (state.headline.length > 24) return {
|
|
770
|
+
...state,
|
|
771
|
+
headline: clamp(state.headline, Math.floor(state.headline.length / 2))
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
function buildLiveActivityPush(kind, options) {
|
|
775
|
+
const now = options.now ?? Date.now();
|
|
776
|
+
const seconds = Math.floor(now / 1e3);
|
|
777
|
+
let state = options.state;
|
|
778
|
+
const build = (content) => ({ aps: {
|
|
779
|
+
timestamp: seconds,
|
|
780
|
+
event: kind,
|
|
781
|
+
...kind === "start" ? {
|
|
782
|
+
"attributes-type": "SessionActivityAttributes",
|
|
783
|
+
attributes: options.attributes,
|
|
784
|
+
alert: {
|
|
785
|
+
title: content.title,
|
|
786
|
+
body: content.phase === "running" ? "working" : "approval needed"
|
|
787
|
+
},
|
|
788
|
+
"input-push-token": 1
|
|
789
|
+
} : {},
|
|
790
|
+
"content-state": content,
|
|
791
|
+
...kind === "end" ? { "dismissal-date": Math.floor((now + DISMISSAL_MS) / 1e3) } : { "stale-date": Math.floor((now + STALE_MS) / 1e3) },
|
|
792
|
+
"relevance-score": RELEVANCE[content.phase]
|
|
793
|
+
} });
|
|
794
|
+
let payload = build(state);
|
|
795
|
+
while (Buffer.byteLength(JSON.stringify(payload)) > MAX_PAYLOAD_BYTES$1) {
|
|
796
|
+
const smaller = shrink(state);
|
|
797
|
+
if (smaller === void 0) break;
|
|
798
|
+
state = smaller;
|
|
799
|
+
payload = build(state);
|
|
800
|
+
}
|
|
801
|
+
return {
|
|
802
|
+
payload,
|
|
803
|
+
pushType: "liveactivity",
|
|
804
|
+
priority: kind === "update" && options.urgent !== true ? 5 : 10,
|
|
805
|
+
expiration: Math.floor((now + (kind === "end" ? END_EXPIRATION_MS : EXPIRATION_MS)) / 1e3)
|
|
376
806
|
};
|
|
377
807
|
}
|
|
378
808
|
//#endregion
|
|
@@ -396,6 +826,12 @@ function oneLine(text, limit = BODY_LIMIT) {
|
|
|
396
826
|
function collapseKey(sessionId) {
|
|
397
827
|
return createHash("sha256").update(sessionId).digest("base64url").slice(0, 32);
|
|
398
828
|
}
|
|
829
|
+
const COLLAPSE_PREFIX = {
|
|
830
|
+
permission_requested: "p",
|
|
831
|
+
turn_completed: "t",
|
|
832
|
+
session_error: "e",
|
|
833
|
+
session_closed: "c"
|
|
834
|
+
};
|
|
399
835
|
function titleFor(notification, name) {
|
|
400
836
|
switch (notification.type) {
|
|
401
837
|
case "permission_requested": return `Approval needed — ${name}`;
|
|
@@ -432,6 +868,7 @@ function buildPush(notification, hostId) {
|
|
|
432
868
|
type: notification.type,
|
|
433
869
|
sessionId: notification.sessionId,
|
|
434
870
|
seq: notification.seq,
|
|
871
|
+
...notification.session.epoch === void 0 ? {} : { epoch: notification.session.epoch },
|
|
435
872
|
...hostId === void 0 ? {} : { hostId },
|
|
436
873
|
...notification.request === void 0 ? {} : { requestId: notification.request.id }
|
|
437
874
|
});
|
|
@@ -444,7 +881,7 @@ function buildPush(notification, hostId) {
|
|
|
444
881
|
payload,
|
|
445
882
|
priority: permission || notification.type === "session_error" ? 10 : 5,
|
|
446
883
|
expiration: permission && notification.request?.expiresAt !== void 0 ? Math.floor(notification.request.expiresAt / 1e3) : Math.floor(Date.now() / 1e3) + 3600,
|
|
447
|
-
|
|
884
|
+
collapseId: `${COLLAPSE_PREFIX[notification.type]}:${collapseKey(notification.sessionId)}`
|
|
448
885
|
};
|
|
449
886
|
}
|
|
450
887
|
async function createApnsForwarder(options) {
|
|
@@ -455,11 +892,18 @@ async function createApnsForwarder(options) {
|
|
|
455
892
|
dir: options.stateDir,
|
|
456
893
|
onError: (error, context) => warn(`device registry ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}`)
|
|
457
894
|
});
|
|
458
|
-
const
|
|
895
|
+
const activities = await createActivityRegistry({
|
|
896
|
+
dir: options.stateDir,
|
|
897
|
+
onError: (error, context) => warn(`activity registry ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}`)
|
|
898
|
+
});
|
|
899
|
+
const handleRequest = createApnsRoute({
|
|
900
|
+
devices: registry,
|
|
901
|
+
activities
|
|
902
|
+
}, options.authenticate);
|
|
459
903
|
const fallbackEnvironment = options.config.production === false ? "development" : "production";
|
|
460
904
|
const chains = /* @__PURE__ */ new Map();
|
|
461
905
|
const deliver = async (notification) => {
|
|
462
|
-
const devices = registry.list();
|
|
906
|
+
const devices = registry.list().filter((device) => wantsNotification(device, notification.type));
|
|
463
907
|
if (devices.length === 0) return;
|
|
464
908
|
await Promise.all(devices.map(async (device) => {
|
|
465
909
|
const push = buildPush(notification, device.hostId);
|
|
@@ -476,7 +920,110 @@ async function createApnsForwarder(options) {
|
|
|
476
920
|
warn(`push to ${device.token.slice(0, 8)}… failed: ${result.reason} (${result.status})`);
|
|
477
921
|
}));
|
|
478
922
|
};
|
|
923
|
+
const sendActivity = async (kind, target, attributes, state, urgent) => {
|
|
924
|
+
const push = buildLiveActivityPush(kind, {
|
|
925
|
+
attributes,
|
|
926
|
+
state,
|
|
927
|
+
urgent
|
|
928
|
+
});
|
|
929
|
+
const result = await client.send({
|
|
930
|
+
...push,
|
|
931
|
+
deviceToken: target.token,
|
|
932
|
+
environment: target.environment
|
|
933
|
+
});
|
|
934
|
+
if (result.ok) {
|
|
935
|
+
await activities.touch(target.deviceToken, target.sessionId, { lastPushAt: Date.now() });
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (result.unregistered) {
|
|
939
|
+
if (kind === "start") await registry.clearStartToken(target.deviceToken);
|
|
940
|
+
await activities.remove(target.deviceToken, target.sessionId);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
warn(`live activity ${kind} for ${target.sessionId} failed: ${result.reason} (${result.status})`);
|
|
944
|
+
};
|
|
945
|
+
const startCards = async (attributes, state) => {
|
|
946
|
+
await Promise.all(registry.list().map(async (device) => {
|
|
947
|
+
if (device.liveActivityStartToken === void 0) return;
|
|
948
|
+
if (activities.get(device.token, attributes.sessionId) !== void 0) return;
|
|
949
|
+
const environment = device.environment ?? fallbackEnvironment;
|
|
950
|
+
await activities.start({
|
|
951
|
+
deviceToken: device.token,
|
|
952
|
+
sessionId: attributes.sessionId,
|
|
953
|
+
hostId: device.hostId,
|
|
954
|
+
environment
|
|
955
|
+
});
|
|
956
|
+
await sendActivity("start", {
|
|
957
|
+
deviceToken: device.token,
|
|
958
|
+
token: device.liveActivityStartToken,
|
|
959
|
+
environment,
|
|
960
|
+
sessionId: attributes.sessionId
|
|
961
|
+
}, {
|
|
962
|
+
...attributes,
|
|
963
|
+
hostId: device.hostId
|
|
964
|
+
}, state, true);
|
|
965
|
+
}));
|
|
966
|
+
};
|
|
967
|
+
const pushCards = async (kind, sessionId, state, urgent) => {
|
|
968
|
+
await Promise.all(activities.forSession(sessionId).map(async (record) => {
|
|
969
|
+
if (record.updateToken === void 0) return;
|
|
970
|
+
await sendActivity(kind, {
|
|
971
|
+
deviceToken: record.deviceToken,
|
|
972
|
+
token: record.updateToken,
|
|
973
|
+
environment: record.environment,
|
|
974
|
+
sessionId
|
|
975
|
+
}, {
|
|
976
|
+
sessionId,
|
|
977
|
+
hostId: record.hostId,
|
|
978
|
+
cwdLeaf: ""
|
|
979
|
+
}, state, urgent);
|
|
980
|
+
if (kind === "end") await activities.remove(record.deviceToken, sessionId);
|
|
981
|
+
}));
|
|
982
|
+
};
|
|
983
|
+
const queueActivity = (sessionId, run) => {
|
|
984
|
+
const next = (chains.get(sessionId) ?? Promise.resolve()).then(() => run().catch((error) => {
|
|
985
|
+
warn(error instanceof Error ? error.message : String(error));
|
|
986
|
+
}));
|
|
987
|
+
chains.set(sessionId, next);
|
|
988
|
+
next.then(() => {
|
|
989
|
+
if (chains.get(sessionId) === next) chains.delete(sessionId);
|
|
990
|
+
});
|
|
991
|
+
};
|
|
479
992
|
return {
|
|
993
|
+
activity: {
|
|
994
|
+
start(attributes, state) {
|
|
995
|
+
queueActivity(attributes.sessionId, () => startCards(attributes, state));
|
|
996
|
+
},
|
|
997
|
+
update(sessionId, state, { urgent }) {
|
|
998
|
+
queueActivity(sessionId, () => pushCards("update", sessionId, state, urgent));
|
|
999
|
+
},
|
|
1000
|
+
end(sessionId, state) {
|
|
1001
|
+
queueActivity(sessionId, () => pushCards("end", sessionId, state, true));
|
|
1002
|
+
},
|
|
1003
|
+
async endAll(headline) {
|
|
1004
|
+
await Promise.all(activities.list().map(async (record) => {
|
|
1005
|
+
if (record.updateToken !== void 0) await sendActivity("end", {
|
|
1006
|
+
deviceToken: record.deviceToken,
|
|
1007
|
+
token: record.updateToken,
|
|
1008
|
+
environment: record.environment,
|
|
1009
|
+
sessionId: record.sessionId
|
|
1010
|
+
}, {
|
|
1011
|
+
sessionId: record.sessionId,
|
|
1012
|
+
hostId: record.hostId,
|
|
1013
|
+
cwdLeaf: ""
|
|
1014
|
+
}, {
|
|
1015
|
+
phase: "ended",
|
|
1016
|
+
title: record.sessionId,
|
|
1017
|
+
headline,
|
|
1018
|
+
startedAtMs: record.startedAt,
|
|
1019
|
+
pendingCount: 0,
|
|
1020
|
+
decision: null
|
|
1021
|
+
}, true);
|
|
1022
|
+
await activities.remove(record.deviceToken, record.sessionId);
|
|
1023
|
+
}));
|
|
1024
|
+
},
|
|
1025
|
+
count: () => activities.list().length
|
|
1026
|
+
},
|
|
480
1027
|
onNotification(notification) {
|
|
481
1028
|
const next = (chains.get(notification.sessionId) ?? Promise.resolve()).then(() => deliver(notification).catch((error) => {
|
|
482
1029
|
warn(error instanceof Error ? error.message : String(error));
|
|
@@ -492,6 +1039,181 @@ async function createApnsForwarder(options) {
|
|
|
492
1039
|
};
|
|
493
1040
|
}
|
|
494
1041
|
//#endregion
|
|
1042
|
+
//#region src/apns/live-activities.ts
|
|
1043
|
+
const START_DEBOUNCE_MS = 2e3;
|
|
1044
|
+
const END_GRACE_MS = 2e4;
|
|
1045
|
+
const FLUSH_DEBOUNCE_MS = 5e3;
|
|
1046
|
+
const MIN_UPDATE_INTERVAL_MS = 3e4;
|
|
1047
|
+
const HEARTBEAT_MS = 6e5;
|
|
1048
|
+
const RENEW_MS = 279e5;
|
|
1049
|
+
const ENGAGED = /* @__PURE__ */ new Set(["running", "awaiting_approval"]);
|
|
1050
|
+
const RELEVANT = /* @__PURE__ */ new Set([
|
|
1051
|
+
"status_changed",
|
|
1052
|
+
"permission_requested",
|
|
1053
|
+
"permission_resolved",
|
|
1054
|
+
"checklist",
|
|
1055
|
+
"turn_result",
|
|
1056
|
+
"session_error",
|
|
1057
|
+
"session_closed"
|
|
1058
|
+
]);
|
|
1059
|
+
function finalPhaseFor(status) {
|
|
1060
|
+
switch (status) {
|
|
1061
|
+
case "parked": return "parked";
|
|
1062
|
+
case "failed": return "failed";
|
|
1063
|
+
case "closed": return "ended";
|
|
1064
|
+
default: return "done";
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
function isWaiting(phase) {
|
|
1068
|
+
return phase === "approval" || phase === "question" || phase === "plan";
|
|
1069
|
+
}
|
|
1070
|
+
function driveLiveActivities(options) {
|
|
1071
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
1072
|
+
const unsubscribes = /* @__PURE__ */ new Set();
|
|
1073
|
+
const clear = (tracked, name) => {
|
|
1074
|
+
const handle = tracked.timers.get(name);
|
|
1075
|
+
if (handle !== void 0) {
|
|
1076
|
+
clearTimeout(handle);
|
|
1077
|
+
tracked.timers.delete(name);
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
const arm = (tracked, name, delay, run) => {
|
|
1081
|
+
clear(tracked, name);
|
|
1082
|
+
tracked.timers.set(name, setTimeout(() => {
|
|
1083
|
+
tracked.timers.delete(name);
|
|
1084
|
+
run();
|
|
1085
|
+
}, delay));
|
|
1086
|
+
};
|
|
1087
|
+
const forget = (sessionId) => {
|
|
1088
|
+
const tracked = sessions.get(sessionId);
|
|
1089
|
+
if (tracked === void 0) return;
|
|
1090
|
+
for (const name of tracked.timers.keys()) clear(tracked, name);
|
|
1091
|
+
sessions.delete(sessionId);
|
|
1092
|
+
};
|
|
1093
|
+
const send = (runner, tracked, state, urgent) => {
|
|
1094
|
+
const hash = contentHash(state);
|
|
1095
|
+
if (hash === tracked.lastHash) return;
|
|
1096
|
+
tracked.lastHash = hash;
|
|
1097
|
+
tracked.lastSentAt = Date.now();
|
|
1098
|
+
tracked.pending = void 0;
|
|
1099
|
+
clear(tracked, "flush");
|
|
1100
|
+
tracked.attributes = attributesFor(runner.info(), options.hostIdOf?.());
|
|
1101
|
+
if (!tracked.started) {
|
|
1102
|
+
tracked.started = true;
|
|
1103
|
+
options.target.start(tracked.attributes, state);
|
|
1104
|
+
} else options.target.update(runner.id, state, { urgent });
|
|
1105
|
+
arm(tracked, "heartbeat", HEARTBEAT_MS, () => {
|
|
1106
|
+
const current = sessions.get(runner.id);
|
|
1107
|
+
if (current !== void 0 && current.started) {
|
|
1108
|
+
options.target.update(runner.id, {
|
|
1109
|
+
...state,
|
|
1110
|
+
seq: runner.info().lastSeq
|
|
1111
|
+
}, { urgent: false });
|
|
1112
|
+
current.lastSentAt = Date.now();
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
};
|
|
1116
|
+
const finish = (runner, tracked, phase) => {
|
|
1117
|
+
if (tracked.started) options.target.end(runner.id, projectContentState({
|
|
1118
|
+
info: runner.info(),
|
|
1119
|
+
startedAtMs: tracked.startedAtMs,
|
|
1120
|
+
finalPhase: phase
|
|
1121
|
+
}));
|
|
1122
|
+
forget(runner.id);
|
|
1123
|
+
};
|
|
1124
|
+
const evaluate = (runner, immediate = false) => {
|
|
1125
|
+
const info = runner.info();
|
|
1126
|
+
const request = runner.pendingApprovals[0];
|
|
1127
|
+
const now = Date.now();
|
|
1128
|
+
if (!ENGAGED.has(info.status)) {
|
|
1129
|
+
const tracked = sessions.get(info.id);
|
|
1130
|
+
if (tracked === void 0) return;
|
|
1131
|
+
clear(tracked, "start");
|
|
1132
|
+
if (info.status === "idle") {
|
|
1133
|
+
arm(tracked, "end", END_GRACE_MS, () => finish(runner, tracked, "done"));
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
finish(runner, tracked, finalPhaseFor(info.status));
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
let tracked = sessions.get(info.id);
|
|
1140
|
+
if (tracked === void 0) {
|
|
1141
|
+
tracked = {
|
|
1142
|
+
attributes: attributesFor(info, options.hostIdOf?.()),
|
|
1143
|
+
startedAtMs: now,
|
|
1144
|
+
started: false,
|
|
1145
|
+
lastSentAt: 0,
|
|
1146
|
+
lastWaiting: false,
|
|
1147
|
+
timers: /* @__PURE__ */ new Map()
|
|
1148
|
+
};
|
|
1149
|
+
sessions.set(info.id, tracked);
|
|
1150
|
+
}
|
|
1151
|
+
const current = tracked;
|
|
1152
|
+
clear(current, "end");
|
|
1153
|
+
const state = projectContentState({
|
|
1154
|
+
info,
|
|
1155
|
+
request,
|
|
1156
|
+
startedAtMs: current.startedAtMs
|
|
1157
|
+
});
|
|
1158
|
+
const waiting = isWaiting(state.phase);
|
|
1159
|
+
const urgent = waiting !== current.lastWaiting || state.request?.id !== current.lastRequestId;
|
|
1160
|
+
current.lastWaiting = waiting;
|
|
1161
|
+
current.lastRequestId = state.request?.id;
|
|
1162
|
+
if (!current.started && current.timers.get("start") === void 0) {
|
|
1163
|
+
current.pending = state;
|
|
1164
|
+
if (waiting || immediate) {
|
|
1165
|
+
send(runner, current, state, true);
|
|
1166
|
+
arm(current, "renew", RENEW_MS, () => renew(runner));
|
|
1167
|
+
} else arm(current, "start", START_DEBOUNCE_MS, () => {
|
|
1168
|
+
const pending = sessions.get(runner.id);
|
|
1169
|
+
if (pending === void 0 || !ENGAGED.has(runner.info().status)) return;
|
|
1170
|
+
send(runner, pending, pending.pending ?? state, true);
|
|
1171
|
+
arm(pending, "renew", RENEW_MS, () => renew(runner));
|
|
1172
|
+
});
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
if (!current.started) {
|
|
1176
|
+
current.pending = state;
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (urgent) {
|
|
1180
|
+
send(runner, current, state, true);
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
current.pending = state;
|
|
1184
|
+
const since = now - current.lastSentAt;
|
|
1185
|
+
const delay = Math.max(FLUSH_DEBOUNCE_MS, MIN_UPDATE_INTERVAL_MS - since);
|
|
1186
|
+
arm(current, "flush", delay, () => {
|
|
1187
|
+
const pending = sessions.get(runner.id);
|
|
1188
|
+
if (pending?.pending !== void 0) send(runner, pending, pending.pending, false);
|
|
1189
|
+
});
|
|
1190
|
+
};
|
|
1191
|
+
const renew = (runner) => {
|
|
1192
|
+
const tracked = sessions.get(runner.id);
|
|
1193
|
+
if (tracked === void 0 || !ENGAGED.has(runner.info().status)) return;
|
|
1194
|
+
finish(runner, tracked, "ended");
|
|
1195
|
+
evaluate(runner, true);
|
|
1196
|
+
};
|
|
1197
|
+
const stop = options.source.observe((runner) => {
|
|
1198
|
+
const unsubscribe = runner.subscribe((event) => {
|
|
1199
|
+
if (!RELEVANT.has(event.type)) return;
|
|
1200
|
+
evaluate(runner);
|
|
1201
|
+
if (event.type === "session_closed") {
|
|
1202
|
+
unsubscribe();
|
|
1203
|
+
unsubscribes.delete(unsubscribe);
|
|
1204
|
+
}
|
|
1205
|
+
}, runner.info().lastSeq);
|
|
1206
|
+
unsubscribes.add(unsubscribe);
|
|
1207
|
+
evaluate(runner);
|
|
1208
|
+
});
|
|
1209
|
+
return () => {
|
|
1210
|
+
stop();
|
|
1211
|
+
for (const unsubscribe of unsubscribes) unsubscribe();
|
|
1212
|
+
unsubscribes.clear();
|
|
1213
|
+
for (const sessionId of sessions.keys()) forget(sessionId);
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
//#endregion
|
|
495
1217
|
//#region src/auth/auth-key.ts
|
|
496
1218
|
function generateKey() {
|
|
497
1219
|
return randomBytes(24).toString("hex");
|
|
@@ -1486,13 +2208,6 @@ function createHostGuard(allowedHosts) {
|
|
|
1486
2208
|
return isLoopbackHostname(hostname) || allowedHosts.has(hostname);
|
|
1487
2209
|
};
|
|
1488
2210
|
}
|
|
1489
|
-
function pathnameOf(req) {
|
|
1490
|
-
try {
|
|
1491
|
-
return new URL(req.url ?? "/", "http://internal").pathname;
|
|
1492
|
-
} catch {
|
|
1493
|
-
return null;
|
|
1494
|
-
}
|
|
1495
|
-
}
|
|
1496
2211
|
function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
|
|
1497
2212
|
return async (req, res) => {
|
|
1498
2213
|
if (!hostAllowed(req)) {
|
|
@@ -1501,12 +2216,7 @@ function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
|
|
|
1501
2216
|
return;
|
|
1502
2217
|
}
|
|
1503
2218
|
if (await auth.handleAuthRequest(req, res)) return;
|
|
1504
|
-
if (
|
|
1505
|
-
if (apnsRoute === void 0 && pathnameOf(req) === "/apns/devices") {
|
|
1506
|
-
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
1507
|
-
res.end("this gateway runs without push\n");
|
|
1508
|
-
return;
|
|
1509
|
-
}
|
|
2219
|
+
if (await apnsRoute(req, res)) return;
|
|
1510
2220
|
if (webRoot === void 0) {
|
|
1511
2221
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
1512
2222
|
res.end("the web dashboard is disabled on this gateway\n");
|
|
@@ -1564,12 +2274,13 @@ async function startInstance(config, options = {}) {
|
|
|
1564
2274
|
if (config.allowedHosts === null && !config.hostAuthenticates && !auth.enabled) throw new Error("refusing to serve: the resolved config expects auth but no shared secret was materialized — this instance would be open while believing itself authenticated");
|
|
1565
2275
|
if (config.corsOrigins.length > 0 && !config.hostAuthenticates && !auth.enabled) throw new Error("refusing to serve: corsOrigins is set but this instance has no auth — a cross-origin page could drive it with no credential. Set --auth-key.");
|
|
1566
2276
|
const hostAllowed = createHostGuard(config.allowedHosts);
|
|
2277
|
+
const authenticateHost = (req) => hostAllowed(req) ? auth.authenticate(req) : null;
|
|
1567
2278
|
const apns = config.apns === void 0 ? void 0 : await createApnsForwarder({
|
|
1568
2279
|
config: config.apns,
|
|
1569
2280
|
stateDir: config.stateDir,
|
|
1570
|
-
authenticate:
|
|
2281
|
+
authenticate: authenticateHost
|
|
1571
2282
|
});
|
|
1572
|
-
const fallback = createFallback(auth, webRoot, hostAllowed, apns?.handleRequest);
|
|
2283
|
+
const fallback = createFallback(auth, webRoot, hostAllowed, apns?.handleRequest ?? createApnsRoute(null, authenticateHost));
|
|
1573
2284
|
const parking = { ...config.options.parking };
|
|
1574
2285
|
if (config.stateDir && !parking.store) parking.store = createFileSessionStore({
|
|
1575
2286
|
dir: join(config.stateDir, "parked"),
|
|
@@ -1601,6 +2312,14 @@ async function startInstance(config, options = {}) {
|
|
|
1601
2312
|
sweep = setInterval(() => wake.set(sessionsNeedTheMachine(server.registry.list())), WAKE_SWEEP_MS);
|
|
1602
2313
|
sweep.unref();
|
|
1603
2314
|
}
|
|
2315
|
+
let stopActivities;
|
|
2316
|
+
if (apns) {
|
|
2317
|
+
await apns.activity.endAll("Gateway restarted");
|
|
2318
|
+
stopActivities = driveLiveActivities({
|
|
2319
|
+
source: server.registry,
|
|
2320
|
+
target: apns.activity
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
1604
2323
|
const { port } = await server.listen(config.port, config.host);
|
|
1605
2324
|
const displayHost = config.host === "0.0.0.0" || config.host === "::" ? "localhost" : config.host;
|
|
1606
2325
|
const url = `http://${displayHost.includes(":") ? `[${displayHost}]` : displayHost}:${port}`;
|
|
@@ -1628,7 +2347,8 @@ async function startInstance(config, options = {}) {
|
|
|
1628
2347
|
line(config.stateDir ? ` parked sessions persist in ${join(config.stateDir, "parked")}` : " parked sessions are in memory only — a restart drops them");
|
|
1629
2348
|
if (apns) {
|
|
1630
2349
|
const count = apns.deviceCount();
|
|
1631
|
-
|
|
2350
|
+
const cards = apns.activity.count();
|
|
2351
|
+
line(` push: APNs forwarder on ${config.apns?.topic} — ${count === 0 ? "no devices registered yet" : `${count} device(s)`}${cards === 0 ? "" : `, ${cards} live card(s)`}`);
|
|
1632
2352
|
}
|
|
1633
2353
|
if (config.configPath) line(` config ${config.configPath}`);
|
|
1634
2354
|
line("");
|
|
@@ -1642,14 +2362,16 @@ async function startInstance(config, options = {}) {
|
|
|
1642
2362
|
close: async () => {
|
|
1643
2363
|
if (sweep) clearInterval(sweep);
|
|
1644
2364
|
wake?.release();
|
|
2365
|
+
stopActivities?.();
|
|
1645
2366
|
await server.close();
|
|
1646
2367
|
await sessions?.flush?.();
|
|
2368
|
+
if (apns) await Promise.race([apns.activity.endAll("Gateway stopped"), new Promise((resolve) => setTimeout(resolve, 3e3))]);
|
|
1647
2369
|
apns?.close();
|
|
1648
2370
|
resolveClosed();
|
|
1649
2371
|
}
|
|
1650
2372
|
};
|
|
1651
2373
|
}
|
|
1652
2374
|
//#endregion
|
|
1653
|
-
export { createApnsForwarder as _, ConfigError as a,
|
|
2375
|
+
export { createActivityRegistry as C, createDeviceRoute as S, createApnsForwarder as _, ConfigError as a, createApnsRoute as b, isLoopback as c, parseArgs as d, resolveInstanceConfig as f, buildPush as g, materializeAuthKey as h, renderLoginPage as i, isLoopbackHostname as l, createAuthSessionStore as m, resolveWebRoot as n, defaultStateDir as o, createCliAuth as p, startInstance as r, hostnameOf as s, createHostGuard as t, loadConfigFile as u, createApnsClient as v, createActivityRoute as w, createDeviceRegistry as x, loadApnsKey as y };
|
|
1654
2376
|
|
|
1655
|
-
//# sourceMappingURL=instance-
|
|
2377
|
+
//# sourceMappingURL=instance-DqbdaKrO.mjs.map
|