@schlessera/brain-ui-server 0.18.0 → 0.20.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.
- package/README.md +5 -1
- package/data/model-prices.json +63 -0
- package/dist/activity/digest.d.ts +29 -0
- package/dist/activity/digest.d.ts.map +1 -0
- package/dist/activity/digest.js +89 -0
- package/dist/activity/digest.js.map +1 -0
- package/dist/activity/notify.d.ts +62 -0
- package/dist/activity/notify.d.ts.map +1 -0
- package/dist/activity/notify.js +193 -0
- package/dist/activity/notify.js.map +1 -0
- package/dist/activity/push-sender.d.ts +63 -0
- package/dist/activity/push-sender.d.ts.map +1 -0
- package/dist/activity/push-sender.js +131 -0
- package/dist/activity/push-sender.js.map +1 -0
- package/dist/activity/query.d.ts +16 -0
- package/dist/activity/query.d.ts.map +1 -0
- package/dist/activity/query.js +155 -0
- package/dist/activity/query.js.map +1 -0
- package/dist/activity/recorder.d.ts +53 -0
- package/dist/activity/recorder.d.ts.map +1 -0
- package/dist/activity/recorder.js +238 -0
- package/dist/activity/recorder.js.map +1 -0
- package/dist/activity/runtime.d.ts +37 -0
- package/dist/activity/runtime.d.ts.map +1 -0
- package/dist/activity/runtime.js +127 -0
- package/dist/activity/runtime.js.map +1 -0
- package/dist/activity/span-sink.d.ts +22 -0
- package/dist/activity/span-sink.d.ts.map +1 -0
- package/dist/activity/span-sink.js +138 -0
- package/dist/activity/span-sink.js.map +1 -0
- package/dist/activity/store.d.ts +256 -0
- package/dist/activity/store.d.ts.map +1 -0
- package/dist/activity/store.js +657 -0
- package/dist/activity/store.js.map +1 -0
- package/dist/activity/stream.d.ts +49 -0
- package/dist/activity/stream.d.ts.map +1 -0
- package/dist/activity/stream.js +353 -0
- package/dist/activity/stream.js.map +1 -0
- package/dist/agent/backend.d.ts +13 -4
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +68 -5
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +33 -2
- package/dist/app.js.map +1 -1
- package/dist/config/env.d.ts +36 -0
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +66 -2
- package/dist/config/env.js.map +1 -1
- package/dist/cron/scheduler.d.ts.map +1 -1
- package/dist/cron/scheduler.js +20 -5
- package/dist/cron/scheduler.js.map +1 -1
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +32 -6
- package/dist/db/client.js.map +1 -1
- package/dist/db/settings.d.ts +15 -0
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +43 -0
- package/dist/db/settings.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/pricing/model-pricing.d.ts +61 -0
- package/dist/pricing/model-pricing.d.ts.map +1 -0
- package/dist/pricing/model-pricing.js +379 -0
- package/dist/pricing/model-pricing.js.map +1 -0
- package/dist/routes/activity.d.ts +20 -0
- package/dist/routes/activity.d.ts.map +1 -0
- package/dist/routes/activity.js +308 -0
- package/dist/routes/activity.js.map +1 -0
- package/dist/routes/models.d.ts +6 -0
- package/dist/routes/models.d.ts.map +1 -1
- package/dist/routes/models.js +38 -3
- package/dist/routes/models.js.map +1 -1
- package/dist/routes/push.d.ts +6 -0
- package/dist/routes/push.d.ts.map +1 -0
- package/dist/routes/push.js +73 -0
- package/dist/routes/push.js.map +1 -0
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +20 -4
- package/dist/routes/sessions.js.map +1 -1
- package/dist/ws/bridge.d.ts +2 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +27 -2
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +9 -1
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +11 -0
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/host.d.ts +16 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +2 -0
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +55 -1
- package/dist/ws/run-session.js.map +1 -1
- package/migrations/007_activity.sql +114 -0
- package/migrations/008_push_subscriptions.sql +24 -0
- package/migrations/009_activity_followups.sql +22 -0
- package/migrations/010_effective_cost.sql +21 -0
- package/package.json +6 -3
- package/src/activity/digest.ts +120 -0
- package/src/activity/notify.ts +307 -0
- package/src/activity/push-sender.ts +215 -0
- package/src/activity/query.ts +187 -0
- package/src/activity/recorder.ts +313 -0
- package/src/activity/runtime.ts +169 -0
- package/src/activity/span-sink.ts +152 -0
- package/src/activity/store.ts +1069 -0
- package/src/activity/stream.ts +396 -0
- package/src/agent/backend.ts +91 -10
- package/src/app.ts +35 -2
- package/src/config/env.ts +90 -2
- package/src/cron/scheduler.ts +28 -8
- package/src/db/client.ts +31 -6
- package/src/db/settings.ts +56 -0
- package/src/index.ts +23 -0
- package/src/pricing/model-pricing.ts +497 -0
- package/src/routes/activity.ts +371 -0
- package/src/routes/models.ts +57 -6
- package/src/routes/push.ts +90 -0
- package/src/routes/sessions.ts +24 -4
- package/src/ws/bridge.ts +29 -2
- package/src/ws/connection.ts +9 -1
- package/src/ws/dispatch.ts +13 -0
- package/src/ws/host.ts +18 -0
- package/src/ws/run-session.ts +71 -2
package/README.md
CHANGED
|
@@ -71,6 +71,7 @@ Every variable this package reads, and what happens when it is unset.
|
|
|
71
71
|
| --- | --- | --- |
|
|
72
72
|
| `AGENT_BACKEND` | Primary agent backend: "claude" (default) or "pi". | claude |
|
|
73
73
|
| `ALLOWED_ORIGINS` | Comma-separated cross-origin allowlist for a split client/API topology; empty means same-origin only. | (empty) |
|
|
74
|
+
| `ANTHROPIC_API_KEY` | Consulted for PRESENCE only, to classify billing: when set it wins over CLAUDE_CODE_OAUTH_TOKEN (mirroring the Agent SDK's credential precedence), so ambient-credential profiles count as api-billed. | — |
|
|
74
75
|
| `AUTH_MODE` | Authentication mode: password \| tailscale \| proxy \| none. Unset auto-detects (password when a hash is set, else tailscale). | (auto-detect) |
|
|
75
76
|
| `BRAIN_PATH` | Path to the brain repo the server operates on. | $HOME/brain |
|
|
76
77
|
| `BRAIN_UI_ALLOW_LOOPBACK_ORIGIN` | Set "1" to accept loopback Origins for WebAuthn regardless of Host (dev-only, for the vite proxy). | 0 |
|
|
@@ -83,9 +84,12 @@ Every variable this package reads, and what happens when it is unset.
|
|
|
83
84
|
| `BRAIN_UI_MODEL_DISCOVERY` | Model discovery against the Anthropic Models API; "0"/"off"/"false" disables. Defaults ON, except under a test runner (NODE_ENV=test) where it defaults OFF. | on (off under NODE_ENV=test) |
|
|
84
85
|
| `BRAIN_UI_MODEL_TTL_HOURS` | How long a model-discovery result stays fresh, in hours. | 24 |
|
|
85
86
|
| `BRAIN_UI_PASSWORD_HASH` | Bun.password argon2id hash of the shared password. | **required** — AUTH_MODE=password |
|
|
87
|
+
| `BRAIN_UI_PRICING_DISCOVERY` | Remote model-pricing refresh (LiteLLM + OpenRouter catalogs); "0"/"off"/"false" disables, and runs then roll up with unknown effective cost. Defaults ON, except under a test runner (NODE_ENV=test) where it defaults OFF. | on (off under NODE_ENV=test) |
|
|
88
|
+
| `BRAIN_UI_PRICING_TTL_HOURS` | How long a fetched model-pricing table stays fresh, in hours. | 24 |
|
|
86
89
|
| `BRAIN_UI_TURN_TIMEOUT_MS` | Hard per-turn timeout in ms; the host aborts a turn that runs past it. Raise for agent-heavy research work (e.g. 1800000 for 30 minutes). | 600000 (10 minutes) |
|
|
87
90
|
| `BRAIN_UI_WS_BURST` | Inbound WebSocket frames absorbable in one burst before the sustained rate applies. Opening the app legitimately fires several at once. | 60 |
|
|
88
91
|
| `BRAIN_UI_WS_RATE` | Sustained inbound WebSocket frames per second per connection. 0 disables metering entirely. | 20 |
|
|
92
|
+
| `CLAUDE_CODE_OAUTH_TOKEN` | Consulted for PRESENCE only, to classify billing: with it set and no ANTHROPIC_API_KEY, ambient-credential Claude profiles (the built-in default and discovered models) count as subscription-billed. The token itself is consumed by the Claude backend / Agent SDK, not this package. | — |
|
|
89
93
|
| `CLAUDE_CODE_PATH` | Path to the Claude Code native binary handed to the Agent SDK. | /usr/local/bin/claude |
|
|
90
94
|
| `COOKIE_SECRET` | Secret signing the session cookie. | **required** — AUTH_MODE=password |
|
|
91
95
|
| `DB_PATH` | SQLite file for the UI's own database (sessions, passkeys, settings). | ./brain-ui.db |
|
|
@@ -93,7 +97,7 @@ Every variable this package reads, and what happens when it is unset.
|
|
|
93
97
|
| `HOME` | Fallback anchor for the BRAIN_PATH default only. | /root |
|
|
94
98
|
| `HOST` | Bind host; consulted by the auth validation to decide whether AUTH_MODE=none is loopback-safe. | (empty) |
|
|
95
99
|
| `MAX_CONCURRENT_SESSIONS` | Cap on concurrently RUNNING agent sessions. | 3 |
|
|
96
|
-
| `NODE_ENV` | Only consulted for test-runner detection: flips the model-discovery
|
|
100
|
+
| `NODE_ENV` | Only consulted for test-runner detection: flips the model-discovery and pricing-discovery defaults to off under bun test. Never gates any security behavior. | (unset) |
|
|
97
101
|
| `PROXY_AUTH_HEADER` | Header a fronting auth proxy sets for AUTH_MODE=proxy. | x-forwarded-user |
|
|
98
102
|
| `SOURCE_COMMIT` | Git SHA reported by /api/status (baked at image build time). | dev |
|
|
99
103
|
| `TRUST_PROXY` | Set "1" to trust x-forwarded-for/x-real-ip and the proxy auth header; only safe behind a trusted reverse proxy. | **required** — AUTH_MODE=proxy |
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"fetchedAt": 0,
|
|
4
|
+
"snapshot": true,
|
|
5
|
+
"litellm": {
|
|
6
|
+
"fetchedAt": null,
|
|
7
|
+
"rates": {
|
|
8
|
+
"claude-opus-4-6": {
|
|
9
|
+
"input": 1.5e-5,
|
|
10
|
+
"output": 7.5e-5,
|
|
11
|
+
"cacheRead": 1.5e-6,
|
|
12
|
+
"cacheWrite": 1.875e-5
|
|
13
|
+
},
|
|
14
|
+
"claude-sonnet-4-5": {
|
|
15
|
+
"input": 3e-6,
|
|
16
|
+
"output": 1.5e-5,
|
|
17
|
+
"cacheRead": 3e-7,
|
|
18
|
+
"cacheWrite": 3.75e-6
|
|
19
|
+
},
|
|
20
|
+
"claude-haiku-4-5": {
|
|
21
|
+
"input": 1e-6,
|
|
22
|
+
"output": 5e-6,
|
|
23
|
+
"cacheRead": 1e-7,
|
|
24
|
+
"cacheWrite": 1.25e-6
|
|
25
|
+
},
|
|
26
|
+
"gemini/gemini-2.5-pro": {
|
|
27
|
+
"input": 1.25e-6,
|
|
28
|
+
"output": 1e-5,
|
|
29
|
+
"cacheRead": 3.125e-7,
|
|
30
|
+
"cacheWrite": null
|
|
31
|
+
},
|
|
32
|
+
"gemini/gemini-2.5-flash": {
|
|
33
|
+
"input": 3e-7,
|
|
34
|
+
"output": 2.5e-6,
|
|
35
|
+
"cacheRead": 7.5e-8,
|
|
36
|
+
"cacheWrite": null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"openrouter": {
|
|
41
|
+
"fetchedAt": null,
|
|
42
|
+
"rates": {
|
|
43
|
+
"z-ai/glm-4.7": {
|
|
44
|
+
"input": 6e-7,
|
|
45
|
+
"output": 2.2e-6,
|
|
46
|
+
"cacheRead": 1.1e-7,
|
|
47
|
+
"cacheWrite": null
|
|
48
|
+
},
|
|
49
|
+
"openai/gpt-oss-120b": {
|
|
50
|
+
"input": 1e-7,
|
|
51
|
+
"output": 5e-7,
|
|
52
|
+
"cacheRead": null,
|
|
53
|
+
"cacheWrite": null
|
|
54
|
+
},
|
|
55
|
+
"google/gemma-4-31b-it": {
|
|
56
|
+
"input": 1e-7,
|
|
57
|
+
"output": 3e-7,
|
|
58
|
+
"cacheRead": null,
|
|
59
|
+
"cacheWrite": null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The while-you-were-away digest: a deterministic summary rendered from
|
|
3
|
+
* per-run rollups, produced by its OWN scheduled job (through the standard
|
|
4
|
+
* cron wrapper, so its runs land in the record like any other) — never a
|
|
5
|
+
* side effect of instrumentation.
|
|
6
|
+
*
|
|
7
|
+
* The digest's covered-until time is the retention floor: full-detail spans
|
|
8
|
+
* older than the last covered window are prunable, newer ones are not (the
|
|
9
|
+
* hard ceiling in the store bounds a broken digest job regardless). Windows
|
|
10
|
+
* are labeled — the card says WHAT period it covers rather than implying
|
|
11
|
+
* "now". The digest job's own runs are recorded but excluded from the
|
|
12
|
+
* notable list, or every digest would feature itself.
|
|
13
|
+
*/
|
|
14
|
+
import type { Database } from "bun:sqlite";
|
|
15
|
+
import { type ActivityDigest } from "@schlessera/brain-ui-sdk/protocol";
|
|
16
|
+
export type { ActivityDigest } from "@schlessera/brain-ui-sdk/protocol";
|
|
17
|
+
export declare const DIGEST_LATEST_KEY = "activity.digest.latest";
|
|
18
|
+
export declare const DIGEST_COVERED_KEY = "activity.digest.coveredUntil";
|
|
19
|
+
export declare const DIGEST_DISMISSED_KEY = "activity.digest.dismissedAt";
|
|
20
|
+
export declare const DIGEST_JOB_NAME = "digest";
|
|
21
|
+
/** Generate and persist the digest for [coveredUntil, now]. */
|
|
22
|
+
export declare function generateActivityDigest(db: Database, now?: number): ActivityDigest;
|
|
23
|
+
/** The latest digest, or null before the first run. */
|
|
24
|
+
export declare function latestActivityDigest(db: Database): ActivityDigest | null;
|
|
25
|
+
/** The retention floor: never prune spans the digest has not covered. */
|
|
26
|
+
export declare function digestRetentionFloor(db: Database): number;
|
|
27
|
+
export declare function dismissActivityDigest(db: Database, at?: number): void;
|
|
28
|
+
export declare function digestDismissedAt(db: Database): number;
|
|
29
|
+
//# sourceMappingURL=digest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"digest.d.ts","sourceRoot":"","sources":["../../src/activity/digest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,EAAoB,KAAK,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAK1F,YAAY,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAExE,eAAO,MAAM,iBAAiB,2BAA2B,CAAC;AAC1D,eAAO,MAAM,kBAAkB,iCAAiC,CAAC;AACjE,eAAO,MAAM,oBAAoB,gCAAgC,CAAC;AAClE,eAAO,MAAM,eAAe,WAAW,CAAC;AAKxC,+DAA+D;AAC/D,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,QAAQ,EAAE,GAAG,SAAa,GAAG,cAAc,CAQrF;AAiED,uDAAuD;AACvD,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,QAAQ,GAAG,cAAc,GAAG,IAAI,CAExE;AAED,yEAAyE;AACzE,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,QAAQ,GAAG,MAAM,CAEzD;AAED,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,QAAQ,EAAE,EAAE,SAAa,GAAG,IAAI,CAEzE;AAED,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,QAAQ,GAAG,MAAM,CAEtD"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { isFailureOutcome } from "@schlessera/brain-ui-sdk/protocol";
|
|
2
|
+
import { getSetting, setSetting } from "../db/settings.js";
|
|
3
|
+
import { rowToRunRollup, sumEffectiveCost } from "./store.js";
|
|
4
|
+
export const DIGEST_LATEST_KEY = "activity.digest.latest";
|
|
5
|
+
export const DIGEST_COVERED_KEY = "activity.digest.coveredUntil";
|
|
6
|
+
export const DIGEST_DISMISSED_KEY = "activity.digest.dismissedAt";
|
|
7
|
+
export const DIGEST_JOB_NAME = "digest";
|
|
8
|
+
/** First run ever: no covered-until marker — cap the window at 24h. */
|
|
9
|
+
const FIRST_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
/** Generate and persist the digest for [coveredUntil, now]. */
|
|
11
|
+
export function generateActivityDigest(db, now = Date.now()) {
|
|
12
|
+
// The read-compute-write of coveredUntil runs as ONE immediate transaction:
|
|
13
|
+
// the cron digest job and a manual generation are separate processes on the
|
|
14
|
+
// same DB, and unserialized they both window from the same coveredUntil —
|
|
15
|
+
// double-counting the overlap and letting the retention floor regress when
|
|
16
|
+
// the earlier `now` lands last. BEGIN IMMEDIATE + busy_timeout serializes
|
|
17
|
+
// them; the loser re-reads the winner's coveredUntil and windows after it.
|
|
18
|
+
return db.transaction(() => generateInTx(db, now)).immediate();
|
|
19
|
+
}
|
|
20
|
+
function generateInTx(db, now) {
|
|
21
|
+
const covered = getSetting(db, DIGEST_COVERED_KEY, null);
|
|
22
|
+
if (covered !== null && now <= covered) {
|
|
23
|
+
// A newer generation already committed — we serialized behind the race's
|
|
24
|
+
// winner, or our clock trails coveredUntil. The window would be inverted
|
|
25
|
+
// and empty; return the winner's digest instead of overwriting it.
|
|
26
|
+
const latest = latestActivityDigest(db);
|
|
27
|
+
if (latest)
|
|
28
|
+
return latest;
|
|
29
|
+
// Covered set but no stored digest (partial state): degrade to an empty
|
|
30
|
+
// window at the floor rather than inverting it.
|
|
31
|
+
now = covered;
|
|
32
|
+
}
|
|
33
|
+
const windowStart = covered ?? now - FIRST_WINDOW_MS;
|
|
34
|
+
// Windowed on ended_at, not started_at: every FINISHED run gets
|
|
35
|
+
// exactly-once coverage — a run still open at generation is picked up by
|
|
36
|
+
// the NEXT digest once it ends, instead of falling between windows.
|
|
37
|
+
const rows = db
|
|
38
|
+
.query(`SELECT * FROM activity_run_rollups
|
|
39
|
+
WHERE ended_at IS NOT NULL AND ended_at >= ? AND ended_at < ?
|
|
40
|
+
ORDER BY started_at DESC`)
|
|
41
|
+
.all(windowStart, now).map(rowToRunRollup);
|
|
42
|
+
const relevant = rows.filter((r) => r.jobName !== DIGEST_JOB_NAME);
|
|
43
|
+
const failures = relevant.filter((r) => isFailureOutcome(r.outcome));
|
|
44
|
+
// Both cost sums are sum-of-knowns; sumEffectiveCost counts the excluded
|
|
45
|
+
// unknown-cost runs so the digest never passes an unknown off as $0 (AE3).
|
|
46
|
+
const effective = sumEffectiveCost(relevant);
|
|
47
|
+
const digest = {
|
|
48
|
+
generatedAt: now,
|
|
49
|
+
windowStart,
|
|
50
|
+
windowEnd: now,
|
|
51
|
+
runs: relevant.length,
|
|
52
|
+
failures: failures.length,
|
|
53
|
+
costUsd: relevant.reduce((a, r) => a + (r.costUsd ?? 0), 0),
|
|
54
|
+
effectiveCostUsd: effective.effectiveCostUsd,
|
|
55
|
+
unpricedRuns: effective.unpricedRuns,
|
|
56
|
+
inputTokens: relevant.reduce((a, r) => a + (r.inputTokens ?? 0), 0),
|
|
57
|
+
outputTokens: relevant.reduce((a, r) => a + (r.outputTokens ?? 0), 0),
|
|
58
|
+
notable: failures.slice(0, 10).map((r) => ({
|
|
59
|
+
runId: r.runId,
|
|
60
|
+
name: r.name,
|
|
61
|
+
jobName: r.jobName,
|
|
62
|
+
sessionId: r.sessionId,
|
|
63
|
+
outcome: r.outcome,
|
|
64
|
+
failureReason: r.failureReason,
|
|
65
|
+
startedAt: r.startedAt,
|
|
66
|
+
})),
|
|
67
|
+
};
|
|
68
|
+
setSetting(db, DIGEST_LATEST_KEY, digest);
|
|
69
|
+
// Monotonic: a generator whose clock trails the previous coveredUntil
|
|
70
|
+
// (skew, or a back-to-back manual run) must not walk the retention floor
|
|
71
|
+
// backwards and re-expose already-covered spans to double coverage.
|
|
72
|
+
setSetting(db, DIGEST_COVERED_KEY, Math.max(now, covered ?? 0));
|
|
73
|
+
return digest;
|
|
74
|
+
}
|
|
75
|
+
/** The latest digest, or null before the first run. */
|
|
76
|
+
export function latestActivityDigest(db) {
|
|
77
|
+
return getSetting(db, DIGEST_LATEST_KEY, null);
|
|
78
|
+
}
|
|
79
|
+
/** The retention floor: never prune spans the digest has not covered. */
|
|
80
|
+
export function digestRetentionFloor(db) {
|
|
81
|
+
return getSetting(db, DIGEST_COVERED_KEY, 0);
|
|
82
|
+
}
|
|
83
|
+
export function dismissActivityDigest(db, at = Date.now()) {
|
|
84
|
+
setSetting(db, DIGEST_DISMISSED_KEY, at);
|
|
85
|
+
}
|
|
86
|
+
export function digestDismissedAt(db) {
|
|
87
|
+
return getSetting(db, DIGEST_DISMISSED_KEY, 0);
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=digest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"digest.js","sourceRoot":"","sources":["../../src/activity/digest.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification intents: the at-least-once layer between the activity record
|
|
3
|
+
* and every delivery channel.
|
|
4
|
+
*
|
|
5
|
+
* An intent is persisted when the triggering condition is DETECTED (a
|
|
6
|
+
* terminal failure span, a stuck run) and carries its own lifecycle:
|
|
7
|
+
* pending -> sent | send_failed | suppressed. The in-app inbox is the
|
|
8
|
+
* guaranteed tier — it lists unacknowledged intents regardless of delivery
|
|
9
|
+
* status — and web push (the accelerator) marks sent/send_failed on top.
|
|
10
|
+
* Undelivered intents survive restarts by construction; the boot/tick sweep
|
|
11
|
+
* simply finds them still pending.
|
|
12
|
+
*
|
|
13
|
+
* Detection runs on the server's always-on tick with its OWN change-log
|
|
14
|
+
* cursor (the live stream's cursor only advances for subscribers), so a
|
|
15
|
+
* foreign writer's failure — the cron wrapper recording an error while
|
|
16
|
+
* nobody watches — is noticed within one tick, not at the next app open.
|
|
17
|
+
*
|
|
18
|
+
* Storm safety: a run-scoped tag deduplicates (one active intent per tag),
|
|
19
|
+
* per-job debounce collapses repeats, and a global creation rate cap stops
|
|
20
|
+
* one bad API key from paging for every job at once.
|
|
21
|
+
*/
|
|
22
|
+
import type { Database } from "bun:sqlite";
|
|
23
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
24
|
+
import type { ActivityStore } from "./store.js";
|
|
25
|
+
export type IntentKind = "failure" | "completion" | "stuck";
|
|
26
|
+
export interface NotificationIntent {
|
|
27
|
+
id: number;
|
|
28
|
+
runId: string;
|
|
29
|
+
spanId: string | null;
|
|
30
|
+
kind: IntentKind;
|
|
31
|
+
tag: string;
|
|
32
|
+
title: string;
|
|
33
|
+
body: string;
|
|
34
|
+
status: "pending" | "sent" | "send_failed" | "suppressed";
|
|
35
|
+
acknowledged: boolean;
|
|
36
|
+
createdAt: number;
|
|
37
|
+
}
|
|
38
|
+
export interface ActivityNotifierDeps {
|
|
39
|
+
db: Database;
|
|
40
|
+
store: ActivityStore;
|
|
41
|
+
/** Was anyone watching this run/session when the event was recorded? */
|
|
42
|
+
isWatched: (scope: {
|
|
43
|
+
runId?: string;
|
|
44
|
+
sessionId?: string;
|
|
45
|
+
}) => boolean;
|
|
46
|
+
log?: Logger;
|
|
47
|
+
}
|
|
48
|
+
export interface ActivityNotifier {
|
|
49
|
+
/** Detect new terminal failures + stuck runs; create intents. */
|
|
50
|
+
tick(now?: number): void;
|
|
51
|
+
/** Unacknowledged intents, newest first (the inbox). */
|
|
52
|
+
inbox(limit?: number): NotificationIntent[];
|
|
53
|
+
/** Intents awaiting delivery (the push sender's queue). */
|
|
54
|
+
pending(limit?: number): NotificationIntent[];
|
|
55
|
+
markDelivered(id: number, status: "sent" | "send_failed"): void;
|
|
56
|
+
acknowledge(id: number): boolean;
|
|
57
|
+
acknowledgeAll(): number;
|
|
58
|
+
/** Retention: delete acknowledged intents untouched for longer than this. */
|
|
59
|
+
pruneAcknowledged(olderThanMs: number): number;
|
|
60
|
+
}
|
|
61
|
+
export declare function createActivityNotifier(deps: ActivityNotifierDeps): ActivityNotifier;
|
|
62
|
+
//# sourceMappingURL=notify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../../src/activity/notify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAItD,OAAO,KAAK,EAAE,aAAa,EAAW,MAAM,YAAY,CAAC;AAEzD,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,YAAY,GAAG,OAAO,CAAC;AAE5D,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,EAAE,UAAU,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,SAAS,GAAG,MAAM,GAAG,aAAa,GAAG,YAAY,CAAC;IAC1D,YAAY,EAAE,OAAO,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,QAAQ,CAAC;IACb,KAAK,EAAE,aAAa,CAAC;IACrB,wEAAwE;IACxE,SAAS,EAAE,CAAC,KAAK,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC;IACtE,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,gBAAgB;IAC/B,iEAAiE;IACjE,IAAI,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,wDAAwD;IACxD,KAAK,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,kBAAkB,EAAE,CAAC;IAC5C,2DAA2D;IAC3D,OAAO,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,kBAAkB,EAAE,CAAC;IAC9C,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAAC;IAChE,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;IACjC,cAAc,IAAI,MAAM,CAAC;IACzB,6EAA6E;IAC7E,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAAC;CAChD;AAaD,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,oBAAoB,GAAG,gBAAgB,CAuNnF"}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { isFailureOutcome } from "@schlessera/brain-ui-sdk/protocol";
|
|
2
|
+
import { getSetting } from "../db/settings.js";
|
|
3
|
+
/** Global cap on intents CREATED per hour — failure-storm circuit. */
|
|
4
|
+
const MAX_INTENTS_PER_HOUR = 20;
|
|
5
|
+
/** A send_failed intent is retried this many times before push is forfeited
|
|
6
|
+
* (the inbox — the guaranteed tier — has it regardless). */
|
|
7
|
+
const MAX_SEND_ATTEMPTS = 3;
|
|
8
|
+
/** Backoff between retry attempts, so a transient push-service outage longer
|
|
9
|
+
* than one tick still gets fresh attempts spread over ~15 minutes. */
|
|
10
|
+
const SEND_RETRY_BACKOFF_MS = 5 * 60 * 1000;
|
|
11
|
+
/** Watchdog default: a live root run older than this is flagged as stuck. */
|
|
12
|
+
const DEFAULT_STUCK_THRESHOLD_MS = 45 * 60 * 1000;
|
|
13
|
+
export function createActivityNotifier(deps) {
|
|
14
|
+
const { db, store, isWatched, log } = deps;
|
|
15
|
+
let cursor = latestCursor(db);
|
|
16
|
+
/** Runs already flagged as stuck this process lifetime (tag also guards). */
|
|
17
|
+
const stuckFlagged = new Set();
|
|
18
|
+
function latestCursor(database) {
|
|
19
|
+
const row = database
|
|
20
|
+
.query("SELECT COALESCE(MAX(change_id), 0) AS hi FROM activity_changes")
|
|
21
|
+
.get();
|
|
22
|
+
return row.hi;
|
|
23
|
+
}
|
|
24
|
+
function createIntent(input) {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
// Tag dedupe: one live (unacknowledged) intent per tag — repeats coalesce.
|
|
27
|
+
const existing = db
|
|
28
|
+
.query("SELECT id FROM notification_intents WHERE tag = ? AND acknowledged = 0 LIMIT 1")
|
|
29
|
+
.get(input.tag);
|
|
30
|
+
if (existing)
|
|
31
|
+
return;
|
|
32
|
+
// Global creation rate cap.
|
|
33
|
+
const recent = db
|
|
34
|
+
.query("SELECT COUNT(*) AS n FROM notification_intents WHERE created_at > ?")
|
|
35
|
+
.get(now - 60 * 60 * 1000);
|
|
36
|
+
const status = input.suppressed || recent.n >= MAX_INTENTS_PER_HOUR ? "suppressed" : "pending";
|
|
37
|
+
db.query(`INSERT INTO notification_intents
|
|
38
|
+
(run_id, span_id, kind, tag, title, body, status, acknowledged, created_at, updated_at)
|
|
39
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`).run(input.runId, input.spanId ?? null, input.kind, input.tag, input.title, input.body, status, now, now);
|
|
40
|
+
log?.emit({
|
|
41
|
+
severityText: "INFO",
|
|
42
|
+
body: "notification intent created",
|
|
43
|
+
attributes: { kind: input.kind, tag: input.tag, status },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function intentForSpan(span) {
|
|
47
|
+
if (span.parentSpanId)
|
|
48
|
+
return;
|
|
49
|
+
if (isFailureOutcome(span.outcome)) {
|
|
50
|
+
const label = span.jobName ?? span.name;
|
|
51
|
+
// "Unwatched" is evaluated when the failure is NOTICED — a live
|
|
52
|
+
// subscription to the run or its session means the user saw it happen,
|
|
53
|
+
// so the intent lands acknowledged-free but suppressed for delivery.
|
|
54
|
+
const watched = isWatched({
|
|
55
|
+
runId: span.runId,
|
|
56
|
+
...(span.sessionId ? { sessionId: span.sessionId } : {}),
|
|
57
|
+
});
|
|
58
|
+
// Payload minimization: name + outcome word only. Lock screens render
|
|
59
|
+
// this outside the app's auth; detail is behind the tap-through.
|
|
60
|
+
createIntent({
|
|
61
|
+
runId: span.runId,
|
|
62
|
+
spanId: span.spanId,
|
|
63
|
+
kind: "failure",
|
|
64
|
+
tag: `failure:${span.jobName ?? span.sessionId ?? span.runId}`,
|
|
65
|
+
title: `${label} ${span.outcome === "interrupted" ? "was interrupted" : "failed"}`,
|
|
66
|
+
body: "Open to see the run.",
|
|
67
|
+
suppressed: watched,
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (span.outcome === "success" && span.jobName) {
|
|
72
|
+
// Completion notifications are opt-in per job.
|
|
73
|
+
const optIn = getSetting(db, "activity.notify.completions", [], log);
|
|
74
|
+
if (optIn.includes(span.jobName)) {
|
|
75
|
+
createIntent({
|
|
76
|
+
runId: span.runId,
|
|
77
|
+
spanId: span.spanId,
|
|
78
|
+
kind: "completion",
|
|
79
|
+
tag: `completion:${span.jobName}:${span.runId}`,
|
|
80
|
+
title: `${span.jobName} completed`,
|
|
81
|
+
body: "Open to see the run.",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
tick(now) {
|
|
88
|
+
try {
|
|
89
|
+
// New committed changes since our own cursor: terminal root spans
|
|
90
|
+
// among them become intents (SQL-side filter + join in the store).
|
|
91
|
+
// The head is read BEFORE the detection query so advancing past
|
|
92
|
+
// non-terminal changes can never skip a terminal write committed
|
|
93
|
+
// in between; a change seen twice is absorbed by the tag dedupe.
|
|
94
|
+
const head = latestCursor(db);
|
|
95
|
+
const rows = store.terminalRootChangesSince(cursor, 500);
|
|
96
|
+
for (const { span } of rows)
|
|
97
|
+
intentForSpan(span);
|
|
98
|
+
const lastSeen = rows.length > 0 ? rows[rows.length - 1].changeId : cursor;
|
|
99
|
+
cursor = rows.length === 500 ? lastSeen : Math.max(head, lastSeen);
|
|
100
|
+
// Watchdog: an over-threshold LIVE root run is stuck — a signal, not
|
|
101
|
+
// an outcome. Threshold from settings, per-job override supported.
|
|
102
|
+
const defaultThreshold = getSetting(db, "activity.watchdog.thresholdMs", DEFAULT_STUCK_THRESHOLD_MS, log);
|
|
103
|
+
const overrides = getSetting(db, "activity.watchdog.perJobMs", {}, log);
|
|
104
|
+
// Scan with the SMALLEST effective threshold: findStuck pre-filters
|
|
105
|
+
// by age, so a per-job override below the default would otherwise
|
|
106
|
+
// never see its candidates. The per-span check below still applies
|
|
107
|
+
// each job's own threshold.
|
|
108
|
+
const overrideValues = Object.values(overrides).filter((v) => typeof v === "number" && v > 0);
|
|
109
|
+
const scanThreshold = Math.min(defaultThreshold, ...overrideValues);
|
|
110
|
+
for (const span of store.findStuck(scanThreshold, now)) {
|
|
111
|
+
if (stuckFlagged.has(span.runId))
|
|
112
|
+
continue;
|
|
113
|
+
const threshold = span.jobName ? (overrides[span.jobName] ?? defaultThreshold) : defaultThreshold;
|
|
114
|
+
if ((now ?? Date.now()) - span.startedAt < threshold)
|
|
115
|
+
continue;
|
|
116
|
+
stuckFlagged.add(span.runId);
|
|
117
|
+
createIntent({
|
|
118
|
+
runId: span.runId,
|
|
119
|
+
spanId: span.spanId,
|
|
120
|
+
kind: "stuck",
|
|
121
|
+
tag: `stuck:${span.runId}`,
|
|
122
|
+
title: `${span.jobName ?? span.name} is taking unusually long`,
|
|
123
|
+
body: "Still running. Open to check on it.",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
log?.emit({
|
|
129
|
+
severityText: "WARN",
|
|
130
|
+
body: "notifier tick failed",
|
|
131
|
+
attributes: { error: err instanceof Error ? err.message : String(err) },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
inbox(limit = 50) {
|
|
136
|
+
return db
|
|
137
|
+
.query("SELECT * FROM notification_intents WHERE acknowledged = 0 ORDER BY created_at DESC LIMIT ?")
|
|
138
|
+
.all(limit).map(rowToIntent);
|
|
139
|
+
},
|
|
140
|
+
pending(limit = 20) {
|
|
141
|
+
// Acknowledged means dismissed in the inbox — never push it later.
|
|
142
|
+
// send_failed is retried with backoff until the attempt budget is
|
|
143
|
+
// spent: one transient push-service failure must not forfeit push.
|
|
144
|
+
return db
|
|
145
|
+
.query(`SELECT * FROM notification_intents
|
|
146
|
+
WHERE acknowledged = 0
|
|
147
|
+
AND (status = 'pending'
|
|
148
|
+
OR (status = 'send_failed' AND send_attempts < ? AND updated_at < ?))
|
|
149
|
+
ORDER BY created_at LIMIT ?`)
|
|
150
|
+
.all(MAX_SEND_ATTEMPTS, Date.now() - SEND_RETRY_BACKOFF_MS, limit).map(rowToIntent);
|
|
151
|
+
},
|
|
152
|
+
markDelivered(id, status) {
|
|
153
|
+
// A failed pass spends one attempt from the retry budget.
|
|
154
|
+
db.query(`UPDATE notification_intents
|
|
155
|
+
SET status = ?, updated_at = ?,
|
|
156
|
+
send_attempts = send_attempts + (CASE WHEN ? = 'send_failed' THEN 1 ELSE 0 END)
|
|
157
|
+
WHERE id = ?`).run(status, Date.now(), status, id);
|
|
158
|
+
},
|
|
159
|
+
acknowledge(id) {
|
|
160
|
+
const res = db
|
|
161
|
+
.query("UPDATE notification_intents SET acknowledged = 1, updated_at = ? WHERE id = ?")
|
|
162
|
+
.run(Date.now(), id);
|
|
163
|
+
return res.changes > 0;
|
|
164
|
+
},
|
|
165
|
+
acknowledgeAll() {
|
|
166
|
+
const res = db
|
|
167
|
+
.query("UPDATE notification_intents SET acknowledged = 1, updated_at = ? WHERE acknowledged = 0")
|
|
168
|
+
.run(Date.now());
|
|
169
|
+
return res.changes;
|
|
170
|
+
},
|
|
171
|
+
pruneAcknowledged(olderThanMs) {
|
|
172
|
+
const res = db
|
|
173
|
+
.query("DELETE FROM notification_intents WHERE acknowledged = 1 AND updated_at < ?")
|
|
174
|
+
.run(Date.now() - olderThanMs);
|
|
175
|
+
return res.changes;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function rowToIntent(r) {
|
|
180
|
+
return {
|
|
181
|
+
id: r.id,
|
|
182
|
+
runId: r.run_id,
|
|
183
|
+
spanId: r.span_id,
|
|
184
|
+
kind: r.kind,
|
|
185
|
+
tag: r.tag,
|
|
186
|
+
title: r.title,
|
|
187
|
+
body: r.body,
|
|
188
|
+
status: r.status,
|
|
189
|
+
acknowledged: r.acknowledged === 1,
|
|
190
|
+
createdAt: r.created_at,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=notify.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notify.js","sourceRoot":"","sources":["../../src/activity/notify.ts"],"names":[],"mappings":"AAuBA,OAAO,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAErE,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAwC/C,sEAAsE;AACtE,MAAM,oBAAoB,GAAG,EAAE,CAAC;AAChC;6DAC6D;AAC7D,MAAM,iBAAiB,GAAG,CAAC,CAAC;AAC5B;uEACuE;AACvE,MAAM,qBAAqB,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC;AAC5C,6EAA6E;AAC7E,MAAM,0BAA0B,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAElD,MAAM,UAAU,sBAAsB,CAAC,IAA0B;IAC/D,MAAM,EAAE,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IAC3C,IAAI,MAAM,GAAG,YAAY,CAAC,EAAE,CAAC,CAAC;IAC9B,6EAA6E;IAC7E,MAAM,YAAY,GAAG,IAAI,GAAG,EAAU,CAAC;IAEvC,SAAS,YAAY,CAAC,QAAkB;QACtC,MAAM,GAAG,GAAG,QAAQ;aACjB,KAAK,CAAC,gEAAgE,CAAC;aACvE,GAAG,EAAoB,CAAC;QAC3B,OAAO,GAAG,CAAC,EAAE,CAAC;IAChB,CAAC;IAED,SAAS,YAAY,CAAC,KAQrB;QACC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,2EAA2E;QAC3E,MAAM,QAAQ,GAAG,EAAE;aAChB,KAAK,CACJ,gFAAgF,CACjF;aACA,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAClB,IAAI,QAAQ;YAAE,OAAO;QACrB,4BAA4B;QAC5B,MAAM,MAAM,GAAG,EAAE;aACd,KAAK,CAAC,qEAAqE,CAAC;aAC5E,GAAG,CAAC,GAAG,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAkB,CAAC;QAC9C,MAAM,MAAM,GACV,KAAK,CAAC,UAAU,IAAI,MAAM,CAAC,CAAC,IAAI,oBAAoB,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,SAAS,CAAC;QAClF,EAAE,CAAC,KAAK,CACN;;6CAEuC,CACxC,CAAC,GAAG,CACH,KAAK,CAAC,KAAK,EACX,KAAK,CAAC,MAAM,IAAI,IAAI,EACpB,KAAK,CAAC,IAAI,EACV,KAAK,CAAC,GAAG,EACT,KAAK,CAAC,KAAK,EACX,KAAK,CAAC,IAAI,EACV,MAAM,EACN,GAAG,EACH,GAAG,CACJ,CAAC;QACF,GAAG,EAAE,IAAI,CAAC;YACR,YAAY,EAAE,MAAM;YACpB,IAAI,EAAE,6BAA6B;YACnC,UAAU,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE;SACzD,CAAC,CAAC;IACL,CAAC;IAED,SAAS,aAAa,CAAC,IAAa;QAClC,IAAI,IAAI,CAAC,YAAY;YAAE,OAAO;QAC9B,IAAI,gBAAgB,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;YACnC,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,IAAI,CAAC;YACxC,gEAAgE;YAChE,uEAAuE;YACvE,qEAAqE;YACrE,MAAM,OAAO,GAAG,SAAS,CAAC;gBACxB,KAAK,EAAE,IAAI,CAAC,KAAK;gBACjB,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aACzD,CAAC,CAAC;YACH,sEAAsE;YACtE,iEAAiE;YACjE,YAAY,CAAC;gBACX,KAAK,EAAE,IAAI,CAAC,KAAK;gBACjB,MAAM,EAAE,IAAI,CAAC,MAAM;gBACnB,IAAI,EAAE,SAAS;gBACf,GAAG,EAAE,WAAW,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,KAAK,EAAE;gBAC9D,KAAK,EAAE,GAAG,KAAK,IAAI,IAAI,CAAC,OAAO,KAAK,aAAa,CAAC,CAAC,CAAC,iBAAiB,CAAC,CAAC,CAAC,QAAQ,EAAE;gBAClF,IAAI,EAAE,sBAAsB;gBAC5B,UAAU,EAAE,OAAO;aACpB,CAAC,CAAC;YACH,OAAO;QACT,CAAC;QACD,IAAI,IAAI,CAAC,OAAO,KAAK,SAAS,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;YAC/C,+CAA+C;YAC/C,MAAM,KAAK,GAAG,UAAU,CAAW,EAAE,EAAE,6BAA6B,EAAE,EAAE,EAAE,GAAG,CAAC,CAAC;YAC/E,IAAI,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;gBACjC,YAAY,CAAC;oBACX,KAAK,EAAE,IAAI,CAAC,KAAK;oBACjB,MAAM,EAAE,IAAI,CAAC,MAAM;oBACnB,IAAI,EAAE,YAAY;oBAClB,GAAG,EAAE,cAAc,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,KAAK,EAAE;oBAC/C,KAAK,EAAE,GAAG,IAAI,CAAC,OAAO,YAAY;oBAClC,IAAI,EAAE,sBAAsB;iBAC7B,CAAC,CAAC;YACL,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO;QACL,IAAI,CAAC,GAAG;YACN,IAAI,CAAC;gBACH,kEAAkE;gBAClE,mEAAmE;gBACnE,gEAAgE;gBAChE,iEAAiE;gBACjE,iEAAiE;gBACjE,MAAM,IAAI,GAAG,YAAY,CAAC,EAAE,CAAC,CAAC;gBAC9B,MAAM,IAAI,GAAG,KAAK,CAAC,wBAAwB,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;gBACzD,KAAK,MAAM,EAAE,IAAI,EAAE,IAAI,IAAI;oBAAE,aAAa,CAAC,IAAI,CAAC,CAAC;gBACjD,MAAM,QAAQ,GAAG,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC;gBAC5E,MAAM,GAAG,IAAI,CAAC,MAAM,KAAK,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;gBAEnE,qEAAqE;gBACrE,mEAAmE;gBACnE,MAAM,gBAAgB,GAAG,UAAU,CACjC,EAAE,EACF,+BAA+B,EAC/B,0BAA0B,EAC1B,GAAG,CACJ,CAAC;gBACF,MAAM,SAAS,GAAG,UAAU,CAC1B,EAAE,EACF,4BAA4B,EAC5B,EAAE,EACF,GAAG,CACJ,CAAC;gBACF,oEAAoE;gBACpE,kEAAkE;gBAClE,mEAAmE;gBACnE,4BAA4B;gBAC5B,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,MAAM,CACpD,CAAC,CAAC,EAAe,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,GAAG,CAAC,CACnD,CAAC;gBACF,MAAM,aAAa,GAAG,IAAI,CAAC,GAAG,CAAC,gBAAgB,EAAE,GAAG,cAAc,CAAC,CAAC;gBACpE,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,SAAS,CAAC,aAAa,EAAE,GAAG,CAAC,EAAE,CAAC;oBACvD,IAAI,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC;wBAAE,SAAS;oBAC3C,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,gBAAgB,CAAC,CAAC,CAAC,CAAC,gBAAgB,CAAC;oBAClG,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI,CAAC,SAAS,GAAG,SAAS;wBAAE,SAAS;oBAC/D,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;oBAC7B,YAAY,CAAC;wBACX,KAAK,EAAE,IAAI,CAAC,KAAK;wBACjB,MAAM,EAAE,IAAI,CAAC,MAAM;wBACnB,IAAI,EAAE,OAAO;wBACb,GAAG,EAAE,SAAS,IAAI,CAAC,KAAK,EAAE;wBAC1B,KAAK,EAAE,GAAG,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,IAAI,2BAA2B;wBAC9D,IAAI,EAAE,qCAAqC;qBAC5C,CAAC,CAAC;gBACL,CAAC;YACH,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,GAAG,EAAE,IAAI,CAAC;oBACR,YAAY,EAAE,MAAM;oBACpB,IAAI,EAAE,sBAAsB;oBAC5B,UAAU,EAAE,EAAE,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE;iBACxE,CAAC,CAAC;YACL,CAAC;QACH,CAAC;QAED,KAAK,CAAC,KAAK,GAAG,EAAE;YACd,OACE,EAAE;iBACC,KAAK,CACJ,4FAA4F,CAC7F;iBACA,GAAG,CAAC,KAAK,CACb,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QACrB,CAAC;QAED,OAAO,CAAC,KAAK,GAAG,EAAE;YAChB,mEAAmE;YACnE,kEAAkE;YAClE,mEAAmE;YACnE,OACE,EAAE;iBACC,KAAK,CACJ;;;;yCAI6B,CAC9B;iBACA,GAAG,CAAC,iBAAiB,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,qBAAqB,EAAE,KAAK,CACpE,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QACrB,CAAC;QAED,aAAa,CAAC,EAAE,EAAE,MAAM;YACtB,0DAA0D;YAC1D,EAAE,CAAC,KAAK,CACN;;;sBAGc,CACf,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC;QACxC,CAAC;QAED,WAAW,CAAC,EAAE;YACZ,MAAM,GAAG,GAAG,EAAE;iBACX,KAAK,CAAC,+EAA+E,CAAC;iBACtF,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,EAAE,CAAC,CAAC;YACvB,OAAO,GAAG,CAAC,OAAO,GAAG,CAAC,CAAC;QACzB,CAAC;QAED,cAAc;YACZ,MAAM,GAAG,GAAG,EAAE;iBACX,KAAK,CAAC,yFAAyF,CAAC;iBAChG,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;YACnB,OAAO,GAAG,CAAC,OAAO,CAAC;QACrB,CAAC;QAED,iBAAiB,CAAC,WAAW;YAC3B,MAAM,GAAG,GAAG,EAAE;iBACX,KAAK,CAAC,4EAA4E,CAAC;iBACnF,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,WAAW,CAAC,CAAC;YACjC,OAAO,GAAG,CAAC,OAAO,CAAC;QACrB,CAAC;KACF,CAAC;AACJ,CAAC;AAED,SAAS,WAAW,CAAC,CAAM;IACzB,OAAO;QACL,EAAE,EAAE,CAAC,CAAC,EAAE;QACR,KAAK,EAAE,CAAC,CAAC,MAAM;QACf,MAAM,EAAE,CAAC,CAAC,OAAO;QACjB,IAAI,EAAE,CAAC,CAAC,IAAI;QACZ,GAAG,EAAE,CAAC,CAAC,GAAG;QACV,KAAK,EAAE,CAAC,CAAC,KAAK;QACd,IAAI,EAAE,CAAC,CAAC,IAAI;QACZ,MAAM,EAAE,CAAC,CAAC,MAAM;QAChB,YAAY,EAAE,CAAC,CAAC,YAAY,KAAK,CAAC;QAClC,SAAS,EAAE,CAAC,CAAC,UAAU;KACxB,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The accelerator tier: web push delivery for pending notification intents.
|
|
3
|
+
*
|
|
4
|
+
* VAPID keys are generated at first boot into their own table (see migration
|
|
5
|
+
* 008 for why not the settings KV) and never leave the process. Payloads are
|
|
6
|
+
* MINIMIZED — title + outcome word + a deep link — because push renders on
|
|
7
|
+
* lock screens outside the app's auth; detail lives behind the authenticated
|
|
8
|
+
* tap-through.
|
|
9
|
+
*
|
|
10
|
+
* Delivery marks the intent sent/send_failed; the inbox (guaranteed tier)
|
|
11
|
+
* is untouched by any of it. A 404/410 response prunes the subscription —
|
|
12
|
+
* the push service says that device is gone. When every send in a pass
|
|
13
|
+
* fails, that is the all-devices signal (VAPID invalidated by a KV
|
|
14
|
+
* wipe/restore, typically) and it is logged loudly; the client surfaces a
|
|
15
|
+
* re-enable prompt when its subscription check disagrees with the server.
|
|
16
|
+
*/
|
|
17
|
+
import type { Database } from "bun:sqlite";
|
|
18
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
19
|
+
import type { ActivityNotifier } from "./notify.js";
|
|
20
|
+
export interface PushSubscriptionRow {
|
|
21
|
+
endpoint: string;
|
|
22
|
+
p256dh: string;
|
|
23
|
+
auth: string;
|
|
24
|
+
label: string | null;
|
|
25
|
+
createdAt: number;
|
|
26
|
+
lastUsedAt: number | null;
|
|
27
|
+
}
|
|
28
|
+
export interface PushSender {
|
|
29
|
+
publicKey(): string;
|
|
30
|
+
subscribe(sub: {
|
|
31
|
+
endpoint: string;
|
|
32
|
+
keys: {
|
|
33
|
+
p256dh: string;
|
|
34
|
+
auth: string;
|
|
35
|
+
};
|
|
36
|
+
}, label?: string): void;
|
|
37
|
+
unsubscribe(endpoint: string): boolean;
|
|
38
|
+
subscriptions(): PushSubscriptionRow[];
|
|
39
|
+
/** Deliver pending intents to every subscription. Returns sends attempted. */
|
|
40
|
+
deliverPending(notifier: ActivityNotifier): Promise<number>;
|
|
41
|
+
}
|
|
42
|
+
export interface CreatePushSenderOptions {
|
|
43
|
+
/** Injection seam for tests — the real webpush.sendNotification otherwise. */
|
|
44
|
+
send?: (subscription: {
|
|
45
|
+
endpoint: string;
|
|
46
|
+
keys: {
|
|
47
|
+
p256dh: string;
|
|
48
|
+
auth: string;
|
|
49
|
+
};
|
|
50
|
+
}, payload: string, options: {
|
|
51
|
+
vapidDetails: {
|
|
52
|
+
subject: string;
|
|
53
|
+
publicKey: string;
|
|
54
|
+
privateKey: string;
|
|
55
|
+
};
|
|
56
|
+
timeout?: number;
|
|
57
|
+
}) => Promise<unknown>;
|
|
58
|
+
/** `mailto:` or https contact required by the VAPID spec. */
|
|
59
|
+
subject?: string;
|
|
60
|
+
log?: Logger;
|
|
61
|
+
}
|
|
62
|
+
export declare function createPushSender(db: Database, options?: CreatePushSenderOptions): PushSender;
|
|
63
|
+
//# sourceMappingURL=push-sender.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"push-sender.d.ts","sourceRoot":"","sources":["../../src/activity/push-sender.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,IAAI,MAAM,CAAC;IACpB,SAAS,CAAC,GAAG,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnG,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;IACvC,aAAa,IAAI,mBAAmB,EAAE,CAAC;IACvC,8EAA8E;IAC9E,cAAc,CAAC,QAAQ,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAC7D;AAED,MAAM,WAAW,uBAAuB;IACtC,8EAA8E;IAC9E,IAAI,CAAC,EAAE,CACL,YAAY,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,EAC1E,OAAO,EAAE,MAAM,EACf,OAAO,EAAE;QACP,YAAY,EAAE;YAAE,OAAO,EAAE,MAAM,CAAC;YAAC,SAAS,EAAE,MAAM,CAAC;YAAC,UAAU,EAAE,MAAM,CAAA;SAAE,CAAC;QACzE,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB,KACE,OAAO,CAAC,OAAO,CAAC,CAAC;IACtB,6DAA6D;IAC7D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAMD,wBAAgB,gBAAgB,CAC9B,EAAE,EAAE,QAAQ,EACZ,OAAO,GAAE,uBAA4B,GACpC,UAAU,CAwJZ"}
|