notify: only "the app is gone" deletes a device row
reported from the field on the first real push: it failed with BadDeviceToken and the server deleted the registration. the build was production-signed and the row said sandbox — a configuration mismatch, with the device and the app both perfectly fine. pruning there destroys the evidence. the phone re-registers on next launch, the row comes back looking healthy, and the next send fails identically; you can lose a day to that loop without ever seeing what is actually wrong. so the one set becomes three. Unregistered/UNREGISTERED/NOT_FOUND prune — apple and google are saying the app is uninstalled. BadDeviceToken, DeviceTokenNotForTopic, TopicDisallowed, INVALID_ARGUMENT and SENDER_ID_MISMATCH are OURS: the row is kept and the log says what to check. everything else is transient and still counts toward three strikes. INVALID_ARGUMENT was the sharpest one. it is how fcm reports a malformed MESSAGE — including a non-string in `data`, which is the mistake this codebase is most likely to make and already has a test for. deleting on it meant one bug in the payload builder would wipe every android device from the registry on a single send, presenting as "android push stopped working" with nothing left to explain why. misconfigured is also not counted as a strike. not deleting is not enough on its own — a persistent mismatch would reach three failures and delete the row anyway, which is the same bug with a slower fuse. the apns log now names the mismatch rather than the reason code, since the code alone sends people to look at the device: it prints the row's environment and topic and says to check the SHIPPED artefact rather than the intermediate build, which is exactly where this one hid.
This commit is contained in:
@@ -4,7 +4,16 @@ import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { getUserSettings, getChatSessionModel, getChatSessionModels, listCommandHqSessionIds } from 'officerdb';
|
||||
import {
|
||||
getUserSettings,
|
||||
getChatSessionModel,
|
||||
getChatSessionModels,
|
||||
listCommandHqSessionIds,
|
||||
getChatNotifyPreference,
|
||||
getChatNotifyPreferences,
|
||||
setChatNotifyPreference,
|
||||
} from 'officerdb';
|
||||
import type { NotifyPreference } from 'officerdb/types';
|
||||
import {
|
||||
listClaudePwds,
|
||||
listClaudeSessions,
|
||||
@@ -156,13 +165,24 @@ chatRouter.get('/sessions', async (ctx) => {
|
||||
() => new Map<string, { model: string; planMode: boolean }>(),
|
||||
);
|
||||
|
||||
// Same batched-not-per-row shape as `models` above, for the same reason — the list renders a bell
|
||||
// badge per row, and per-row queries do not scale to a page of a hundred sessions.
|
||||
const notifyPrefs = await getChatNotifyPreferences(sessions.map((s) => s.id)).catch(
|
||||
() => new Map<string, NotifyPreference>(),
|
||||
);
|
||||
|
||||
return ctx.json({
|
||||
sessions: sessions.map((s) => {
|
||||
const stored = models.get(s.id);
|
||||
// Spelled out field by field rather than spread: this row goes onto the wire untyped (`ctx.json`),
|
||||
// so widening the stored shape from a string to a record would have silently shipped an OBJECT as
|
||||
// `model` and broken every consumer that calls `.split('/')` on it — with nothing to catch it.
|
||||
return { ...s, model: stored?.model ?? null, planMode: stored?.planMode ?? false };
|
||||
return {
|
||||
...s,
|
||||
model: stored?.model ?? null,
|
||||
planMode: stored?.planMode ?? false,
|
||||
notifyPreference: notifyPrefs.get(s.id) ?? 'none',
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -217,6 +237,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
*/
|
||||
const stored = await getChatSessionModel(id).catch(() => null);
|
||||
const officerModel = stored?.model ?? officerModelForApiModel(detail.model) ?? null;
|
||||
const notifyPreference = (await getChatNotifyPreference(id).catch(() => null)) ?? 'none';
|
||||
|
||||
return ctx.json({
|
||||
...detail,
|
||||
@@ -226,6 +247,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
// Whether this conversation is planning, so the composer's toggle comes back in the state the user left
|
||||
// it. Only a stored row can answer — plan mode leaves no trace in the transcript at all.
|
||||
planMode: stored?.planMode ?? false,
|
||||
notifyPreference,
|
||||
messages,
|
||||
total,
|
||||
offset: windowed ? start : 0,
|
||||
@@ -335,6 +357,22 @@ chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
const NOTIFY_PREFERENCES: NotifyPreference[] = ['every_turn', 'input_needed', 'none'];
|
||||
|
||||
// PATCH /chat/sessions/:id/notify-pref — set this session's bell preference. No ownership check beyond
|
||||
// `chatIdentity()` above: the row is keyed only on sessionId, same trust boundary the title route uses,
|
||||
// and a preference on a session the caller cannot otherwise reach is inert (nothing will ever fire it).
|
||||
chatRouter.patch('/sessions/:id/notify-pref', async (ctx) => {
|
||||
await chatIdentity(ctx.get('user'));
|
||||
const id = ctx.req.param('id');
|
||||
const { preference } = await ctx.req.json<{ preference?: string }>();
|
||||
if (!preference || !NOTIFY_PREFERENCES.includes(preference as NotifyPreference)) {
|
||||
return ctx.text('preference must be one of: ' + NOTIFY_PREFERENCES.join(', '), 400);
|
||||
}
|
||||
await setChatNotifyPreference(id, preference as NotifyPreference);
|
||||
return ctx.json({ preference });
|
||||
});
|
||||
|
||||
// GET /chat/tasks/:id — what a background task is doing right now: a subagent's own trace, or the tail of
|
||||
// a backgrounded shell's log. Polled by the tray above the chat input while the task is running.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { getChatNotifyPreference, getUserById } from 'officerdb';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { liveSessionTitle } from './claude-sessions';
|
||||
|
||||
// The browser-facing half of the bell feature: a per-user, session-independent socket that the shell
|
||||
// holds open once, and the fan-out that decides whether a sidecar's turn-end/input-needed ping is
|
||||
// actually worth telling anyone about.
|
||||
//
|
||||
// Deliberately separate from `chatWebsocket` (websocket.ts) — that one is bound to exactly one
|
||||
// `sessionId` per connection, and retrofitting a sessionless "tell me about ALL my chats" mode into its
|
||||
// already-dense state machine risked entangling two very different lifecycles. This one carries nothing
|
||||
// but small, best-effort pings; a missed one (a reconnect gap, a restart) is not replayed and does not
|
||||
// need to be — the badge and the stored preference are the durable truth, this socket is just a bell.
|
||||
|
||||
type WSData = { userId: number };
|
||||
|
||||
const socketsByUser = new Map<number, Set<ServerWebSocket<WSData>>>();
|
||||
|
||||
export const chatNotifyWebsocket = {
|
||||
open(ws: ServerWebSocket<WSData>) {
|
||||
const { userId } = ws.data;
|
||||
let set = socketsByUser.get(userId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
socketsByUser.set(userId, set);
|
||||
}
|
||||
set.add(ws);
|
||||
},
|
||||
message() {
|
||||
// No inbound protocol — this socket only ever receives, it never sends a session id or a command.
|
||||
},
|
||||
close(ws: ServerWebSocket<WSData>) {
|
||||
const { userId } = ws.data;
|
||||
const set = socketsByUser.get(userId);
|
||||
if (!set) return;
|
||||
set.delete(ws);
|
||||
if (set.size === 0) socketsByUser.delete(userId);
|
||||
},
|
||||
};
|
||||
|
||||
/** Best-effort — a session with no group/cwd on record (never taken a turn, or the transcript vanished
|
||||
* mid-lookup) still gets a notification, just with a generic label rather than the chat's real name. */
|
||||
async function resolveTitle(userId: number, sessionKey: string): Promise<string> {
|
||||
try {
|
||||
const user = await getUserById(userId);
|
||||
const resolved = await resolveHomeDir(userId);
|
||||
if (!user || !resolved.ok) return 'Chat';
|
||||
const who = { email: user.email, home: resolved.home, osUser: resolved.osUser, isOwner: resolved.isOwner };
|
||||
return liveSessionTitle(who, sessionKey)?.title ?? 'Chat';
|
||||
} catch {
|
||||
return 'Chat';
|
||||
}
|
||||
}
|
||||
|
||||
sidecar.onClaudeNotify(async (sessionKey, userId, kind) => {
|
||||
const sockets = socketsByUser.get(userId);
|
||||
if (!sockets?.size) return; // nobody listening — skip the DB round trip entirely
|
||||
|
||||
const preference = await getChatNotifyPreference(sessionKey).catch(() => null);
|
||||
const matches =
|
||||
(kind === 'turn-ended' && preference === 'every_turn') ||
|
||||
(kind === 'input-needed' && preference === 'input_needed');
|
||||
if (!matches) return;
|
||||
|
||||
const title = await resolveTitle(userId, sessionKey);
|
||||
const payload = JSON.stringify({ type: 'notify', sessionId: sessionKey, kind, title });
|
||||
for (const ws of sockets) ws.send(payload);
|
||||
});
|
||||
Reference in New Issue
Block a user