@hyperdrive.bot/fleet-server 0.3.148 → 0.3.150
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/dist/server/server/agent/tools/paseo-tools.d.ts +9 -0
- package/dist/server/server/agent/tools/paseo-tools.js +76 -1
- package/dist/server/server/agent/tools/read-only-surface.d.ts +7 -0
- package/dist/server/server/agent/tools/read-only-surface.js +8 -0
- package/dist/server/server/bootstrap.js +71 -1
- package/dist/server/server/config.js +4 -0
- package/dist/server/server/exports.d.ts +2 -0
- package/dist/server/server/exports.js +6 -0
- package/dist/server/server/ingestion/adapters.js +2 -0
- package/dist/server/server/ingestion/errors.d.ts +5 -1
- package/dist/server/server/ingestion/errors.js +8 -0
- package/dist/server/server/ingestion/health.js +10 -0
- package/dist/server/server/ingestion/self-hosted/gateway.d.ts +55 -0
- package/dist/server/server/ingestion/self-hosted/gateway.js +295 -0
- package/dist/server/server/ingestion/subscriptions/notification-prompt.d.ts +83 -0
- package/dist/server/server/ingestion/subscriptions/notification-prompt.js +96 -0
- package/dist/server/server/ingestion/subscriptions/notifier.d.ts +96 -0
- package/dist/server/server/ingestion/subscriptions/notifier.js +209 -0
- package/dist/server/server/ingestion/subscriptions/pending-store.d.ts +111 -0
- package/dist/server/server/ingestion/subscriptions/pending-store.js +254 -0
- package/dist/server/server/ingestion/subscriptions/poller.d.ts +73 -0
- package/dist/server/server/ingestion/subscriptions/poller.js +168 -0
- package/dist/server/server/ingestion/subscriptions/reader.d.ts +39 -0
- package/dist/server/server/ingestion/subscriptions/reader.js +30 -0
- package/dist/server/server/ingestion/subscriptions/store.d.ts +35 -0
- package/dist/server/server/ingestion/subscriptions/store.js +82 -0
- package/dist/server/server/ingestion/types.d.ts +14 -1
- package/dist/server/server/ingestion/types.js +1 -1
- package/dist/server/web-ui/_expo/static/js/web/{index-88d5f130a09403b86ae8c8c5fd1f97d4.js → index-4695e64c38d7ce76a8375d72b95c2c0c.js} +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-4695e64c38d7ce76a8375d72b95c2c0c.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-4695e64c38d7ce76a8375d72b95c2c0c.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-88d5f130a09403b86ae8c8c5fd1f97d4.js.map.br → index-4695e64c38d7ce76a8375d72b95c2c0c.js.map.br} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-88d5f130a09403b86ae8c8c5fd1f97d4.js.map.gz → index-4695e64c38d7ce76a8375d72b95c2c0c.js.map.gz} +0 -0
- package/dist/server/web-ui/index.html +1 -1
- package/dist/server/web-ui/index.html.br +0 -0
- package/dist/server/web-ui/index.html.gz +0 -0
- package/package.json +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-88d5f130a09403b86ae8c8c5fd1f97d4.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-88d5f130a09403b86ae8c8c5fd1f97d4.js.gz +0 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { buildSubscriptionNotificationPrompt, toNotificationItem } from "./notification-prompt.js";
|
|
2
|
+
/**
|
|
3
|
+
* Turns a filter's matches into notifications for every session subscribed to it.
|
|
4
|
+
*
|
|
5
|
+
* The whole delivery policy lives here, and it exists because neither of the
|
|
6
|
+
* two policies already in the daemon is usable for a notification:
|
|
7
|
+
*
|
|
8
|
+
* - A schedule fire REFUSES when the target is busy (`schedule/service.ts`
|
|
9
|
+
* throws on `hasInFlightRun`), and the refusal is recorded as a failed run
|
|
10
|
+
* whose cadence then advances, so the event is dropped rather than delayed.
|
|
11
|
+
* - `sendPromptToAgent` PREEMPTS, passing `replaceRunning: true`, which cancels
|
|
12
|
+
* the turn in flight. For an unbidden "by the way" message that is the worst
|
|
13
|
+
* of the three: it destroys work to deliver something explicitly optional.
|
|
14
|
+
*
|
|
15
|
+
* So this one waits. Items accumulate in the pending store and go out whole,
|
|
16
|
+
* coalesced, the next time the subscriber is idle and outside its debounce.
|
|
17
|
+
*/
|
|
18
|
+
export class SubscriptionNotifier {
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.subscriptions = options.subscriptions;
|
|
21
|
+
this.pending = options.pending;
|
|
22
|
+
this.agentManager = options.agentManager;
|
|
23
|
+
this.filterResolver = options.filterResolver;
|
|
24
|
+
this.send = options.send;
|
|
25
|
+
this.logger = options.logger;
|
|
26
|
+
this.now = options.now ?? (() => Date.now());
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Record a filter's matches against every armed subscriber, then try to deliver.
|
|
30
|
+
*
|
|
31
|
+
* Recording is unconditional and delivery is not: an item is never lost
|
|
32
|
+
* because the subscriber happened to be mid-turn when it arrived.
|
|
33
|
+
*/
|
|
34
|
+
async notifyForFilter(filter, matched) {
|
|
35
|
+
if (matched.length === 0) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const subscribers = await this.subscriptions.listArmedForFilter(filter.id);
|
|
39
|
+
if (subscribers.length === 0) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const detectedAt = this.now();
|
|
43
|
+
const results = [];
|
|
44
|
+
for (const subscription of subscribers) {
|
|
45
|
+
// Nothing that predates the subscription. Arming must never replay
|
|
46
|
+
// history: the poll reads a window wide enough to survive a sleeping
|
|
47
|
+
// daemon, so without this a brand new subscriber's FIRST notification
|
|
48
|
+
// would be everything already sitting in the aggregator's buffer. The
|
|
49
|
+
// schedule cadence states the same rule at `schedule/types.ts:19`, and
|
|
50
|
+
// backfill is the deliberate way to reach backwards.
|
|
51
|
+
const since = Date.parse(subscription.createdAt);
|
|
52
|
+
if (!Number.isFinite(since)) {
|
|
53
|
+
// Fail CLOSED. A guard whose failure mode is the exact thing it guards
|
|
54
|
+
// against is not a guard: falling back to "announce everything" would
|
|
55
|
+
// replay the whole poll window on one bad timestamp.
|
|
56
|
+
this.logger.warn({ subscriptionId: subscription.id, createdAt: subscription.createdAt }, "Subscription has an unparseable createdAt; announcing nothing until it is fixed");
|
|
57
|
+
results.push({ subscriptionId: subscription.id, outcome: "error", announced: 0 });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const eligible = matched.filter((entry) => entry.item.timestampMs >= since);
|
|
61
|
+
const skipped = matched.length - eligible.length;
|
|
62
|
+
if (skipped > 0) {
|
|
63
|
+
// Said out loud. An adapter that cannot date an item reports 0, which
|
|
64
|
+
// is before every subscription, so silence here would make a source
|
|
65
|
+
// that never delivers look exactly like a source that is simply quiet.
|
|
66
|
+
this.logger.info({ subscriptionId: subscription.id, filterId: filter.id, skipped }, "Items predating the subscription were not announced");
|
|
67
|
+
}
|
|
68
|
+
if (eligible.length === 0) {
|
|
69
|
+
results.push(await this.deliver(subscription, filter));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// Each subscriber gets its OWN pending rows for the same items. That
|
|
73
|
+
// duplication is the point: the ledger claims by (filter, item), so a
|
|
74
|
+
// shared row would let the first subscriber's claim silence the second,
|
|
75
|
+
// which is the opposite of what a subscription is for.
|
|
76
|
+
// A loop rather than `map` with a spread, per `no-map-spread`.
|
|
77
|
+
const rows = [];
|
|
78
|
+
for (const entry of eligible) {
|
|
79
|
+
rows.push(Object.assign(toNotificationItem(entry), {
|
|
80
|
+
detectedAt,
|
|
81
|
+
notifiedAt: null,
|
|
82
|
+
payload: entry.item.payload,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
await this.pending.append(subscription.id, rows);
|
|
87
|
+
results.push(await this.deliver(subscription, filter));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
// One broken subscriber must not stop the others. Swallowing without a
|
|
91
|
+
// reason would make a real bug look like a legitimate quiet tick, so
|
|
92
|
+
// the reason is always logged.
|
|
93
|
+
this.logger.warn({ err: error, subscriptionId: subscription.id, filterId: filter.id }, "Subscription notification failed");
|
|
94
|
+
results.push({ subscriptionId: subscription.id, outcome: "error", announced: 0 });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Try to announce whatever is pending for one subscription.
|
|
101
|
+
*
|
|
102
|
+
* Safe to call at any time, including from a sweep that has no new items:
|
|
103
|
+
* that is how a notice held back by a busy session eventually goes out.
|
|
104
|
+
*/
|
|
105
|
+
async deliverPending(subscriptionId) {
|
|
106
|
+
const subscription = await this.subscriptions.get(subscriptionId);
|
|
107
|
+
if (!subscription) {
|
|
108
|
+
return { subscriptionId, outcome: "nothing-pending", announced: 0 };
|
|
109
|
+
}
|
|
110
|
+
const filter = await this.filterResolver(subscription.filterId);
|
|
111
|
+
if (!filter) {
|
|
112
|
+
return { subscriptionId, outcome: "filter-missing", announced: 0 };
|
|
113
|
+
}
|
|
114
|
+
return this.deliver(subscription, filter);
|
|
115
|
+
}
|
|
116
|
+
async deliver(subscription, filter) {
|
|
117
|
+
const id = subscription.id;
|
|
118
|
+
if (subscription.status !== "armed") {
|
|
119
|
+
return { subscriptionId: id, outcome: "paused", announced: 0 };
|
|
120
|
+
}
|
|
121
|
+
const waiting = await this.pending.listUnnotified(id);
|
|
122
|
+
if (waiting.length === 0) {
|
|
123
|
+
return { subscriptionId: id, outcome: "nothing-pending", announced: 0 };
|
|
124
|
+
}
|
|
125
|
+
// Debounce before the idle check, deliberately. Both hold the items back,
|
|
126
|
+
// but the debounce is a policy the user set and the idle check is a fact
|
|
127
|
+
// about the world, and checking the cheap declared policy first keeps a
|
|
128
|
+
// tight poll from hammering `hasInFlightRun` for every subscriber.
|
|
129
|
+
const now = this.now();
|
|
130
|
+
if (subscription.lastNotifiedAt !== null) {
|
|
131
|
+
const since = now - Date.parse(subscription.lastNotifiedAt);
|
|
132
|
+
if (Number.isFinite(since) && since < subscription.debounceMs) {
|
|
133
|
+
return { subscriptionId: id, outcome: "debounced", announced: 0 };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// The one policy that matters: a busy session is left alone. Nothing is
|
|
137
|
+
// cancelled and nothing is dropped; the items keep waiting.
|
|
138
|
+
if (this.agentManager.hasInFlightRun(subscription.agentId)) {
|
|
139
|
+
return { subscriptionId: id, outcome: "busy", announced: 0 };
|
|
140
|
+
}
|
|
141
|
+
const { body, announcedKeys } = buildSubscriptionNotificationPrompt({
|
|
142
|
+
subscription,
|
|
143
|
+
filter,
|
|
144
|
+
items: waiting,
|
|
145
|
+
});
|
|
146
|
+
const { route } = await this.send({ agentId: subscription.agentId, body });
|
|
147
|
+
if (route === "no-route") {
|
|
148
|
+
// Loaded, detached, and nothing on disk to resume from. The caller was
|
|
149
|
+
// told the truth rather than `accepted: true`, which is what the RPC
|
|
150
|
+
// handler does today, so the subscription can pause itself instead of
|
|
151
|
+
// marking items announced that nobody read.
|
|
152
|
+
this.logger.warn({ subscriptionId: id, agentId: subscription.agentId }, "Subscription target has no route; pausing rather than dropping items");
|
|
153
|
+
await this.subscriptions.put({
|
|
154
|
+
...subscription,
|
|
155
|
+
status: "paused",
|
|
156
|
+
updatedAt: new Date(now).toISOString(),
|
|
157
|
+
});
|
|
158
|
+
return { subscriptionId: id, outcome: "no-route", announced: 0 };
|
|
159
|
+
}
|
|
160
|
+
const notifiedAt = new Date(now).toISOString();
|
|
161
|
+
// Mark AFTER the send returns a live route, never before: marking on
|
|
162
|
+
// dispatch is how a pipeline starts recording intent as fact.
|
|
163
|
+
// ONLY the keys the notice named. A notification caps its list, and an
|
|
164
|
+
// item announced merely as part of a count has no key anywhere the agent
|
|
165
|
+
// can reach: `get_subscription_items` takes keys. Marking it would strand
|
|
166
|
+
// it until the TTL sweep.
|
|
167
|
+
// Rows, not bare keys: the hash is what stops a version appended during
|
|
168
|
+
// the send from being stamped as if it were the one just announced.
|
|
169
|
+
const announcedRows = waiting.filter((item) => announcedKeys.includes(item.itemKey));
|
|
170
|
+
await this.pending.markNotified(id, announcedRows, notifiedAt);
|
|
171
|
+
await this.subscriptions.put({
|
|
172
|
+
...subscription,
|
|
173
|
+
lastNotifiedAt: notifiedAt,
|
|
174
|
+
updatedAt: notifiedAt,
|
|
175
|
+
});
|
|
176
|
+
this.logger.info({
|
|
177
|
+
subscriptionId: id,
|
|
178
|
+
agentId: subscription.agentId,
|
|
179
|
+
announced: announcedKeys.length,
|
|
180
|
+
stillWaiting: waiting.length - announcedKeys.length,
|
|
181
|
+
route,
|
|
182
|
+
}, "Subscription notification delivered");
|
|
183
|
+
return { subscriptionId: id, outcome: "sent", announced: announcedKeys.length };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Try every armed subscription once.
|
|
187
|
+
*
|
|
188
|
+
* The drain. Without it a notice held back by `busy` or `debounced` would
|
|
189
|
+
* wait for the next item to arrive before anyone looked at it again, which
|
|
190
|
+
* on a quiet source can be hours.
|
|
191
|
+
*/
|
|
192
|
+
async deliverAllPending() {
|
|
193
|
+
const all = await this.subscriptions.list();
|
|
194
|
+
const results = [];
|
|
195
|
+
for (const subscription of all) {
|
|
196
|
+
if (subscription.status !== "armed")
|
|
197
|
+
continue;
|
|
198
|
+
try {
|
|
199
|
+
results.push(await this.deliverPending(subscription.id));
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
this.logger.warn({ err: error, subscriptionId: subscription.id }, "Subscription drain failed");
|
|
203
|
+
results.push({ subscriptionId: subscription.id, outcome: "error", announced: 0 });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return results;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
//# sourceMappingURL=notifier.js.map
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* One detected item, held until the subscriber has been told about it.
|
|
4
|
+
*
|
|
5
|
+
* This record does two jobs that look separate and are not:
|
|
6
|
+
*
|
|
7
|
+
* 1. **The coalescing accumulator.** A subscriber that is mid-turn, or inside
|
|
8
|
+
* its debounce window, must not lose the event. The schedule engine's own
|
|
9
|
+
* failure path is the cautionary tale: `finishRun` has no branch reading
|
|
10
|
+
* `params.status`, so a run that failed because the agent was busy still
|
|
11
|
+
* advances `nextRunAt` a whole cadence period, which turns "deliver later"
|
|
12
|
+
* into "never". Holding the item here is the memory that path lacks.
|
|
13
|
+
* 2. **The payload for the fetch.** `ingestion_items` stores no payload and
|
|
14
|
+
* cohort rows store fingerprints, so without this the `get_subscription_items`
|
|
15
|
+
* tool would be a live network read that can come back empty for an item
|
|
16
|
+
* that left the window between the notice and the question.
|
|
17
|
+
*/
|
|
18
|
+
export declare const PendingItemSchema: z.ZodObject<{
|
|
19
|
+
itemKey: z.ZodString;
|
|
20
|
+
contentHash: z.ZodString;
|
|
21
|
+
title: z.ZodString;
|
|
22
|
+
subtitle: z.ZodString;
|
|
23
|
+
timestampMs: z.ZodNumber;
|
|
24
|
+
detectedAt: z.ZodNumber;
|
|
25
|
+
notifiedAt: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
26
|
+
payload: z.ZodUnknown;
|
|
27
|
+
}, z.core.$strict>;
|
|
28
|
+
export type PendingItem = z.infer<typeof PendingItemSchema>;
|
|
29
|
+
/** How long a detected item is kept before the sweep deletes it. 7 days. */
|
|
30
|
+
export declare const DEFAULT_PENDING_TTL_MS: number;
|
|
31
|
+
/**
|
|
32
|
+
* Per-subscription pending items, one JSON file each under `dir`.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately NOT a sqlite table. `ledger-schema.ts` creates its tables with
|
|
35
|
+
* bare `CREATE TABLE IF NOT EXISTS` and carries no migration step, so adding a
|
|
36
|
+
* column there is a change every existing `paseo.sqlite` silently does not get.
|
|
37
|
+
* A JSON file per subscription has the store convention the rest of ingestion
|
|
38
|
+
* already uses, and its failure mode is one unreadable subscription rather than
|
|
39
|
+
* a schema the database disagrees with.
|
|
40
|
+
*/
|
|
41
|
+
export declare class PendingItemStore {
|
|
42
|
+
private readonly dir;
|
|
43
|
+
constructor(dir: string);
|
|
44
|
+
/**
|
|
45
|
+
* One serialization chain per subscription.
|
|
46
|
+
*
|
|
47
|
+
* Every mutation here is read-modify-write over a whole JSON file, so two
|
|
48
|
+
* overlapping calls for the same subscription would each read the pre-state
|
|
49
|
+
* and the second write would erase the first. Today the only caller is the
|
|
50
|
+
* poller, whose tick is single-flight and sequential, so the race cannot
|
|
51
|
+
* happen by accident. That is exactly the kind of safety that stops being
|
|
52
|
+
* true the first time someone calls `deliverPending` from an RPC handler,
|
|
53
|
+
* and the failure it produces is a silently dropped notification rather than
|
|
54
|
+
* an error. Keyed by id, so two different subscriptions still run in
|
|
55
|
+
* parallel.
|
|
56
|
+
*/
|
|
57
|
+
private readonly chains;
|
|
58
|
+
private withLock;
|
|
59
|
+
private filePath;
|
|
60
|
+
private ensureDir;
|
|
61
|
+
read(subscriptionId: string): Promise<PendingItem[]>;
|
|
62
|
+
private write;
|
|
63
|
+
/**
|
|
64
|
+
* Record newly detected items, keyed by `itemKey`.
|
|
65
|
+
*
|
|
66
|
+
* An item whose `contentHash` CHANGED replaces the stored one and is
|
|
67
|
+
* announced again, which mirrors the ledger's own rule (`contentChanged` at
|
|
68
|
+
* `ledger.ts:368-373`): a changed item is a new event, not a duplicate. An
|
|
69
|
+
* item whose hash is identical is left exactly as it is, including its
|
|
70
|
+
* `notifiedAt`, so re-detecting it does not re-announce it.
|
|
71
|
+
*
|
|
72
|
+
* Returns the items that are genuinely new or changed, which is what the
|
|
73
|
+
* caller announces.
|
|
74
|
+
*/
|
|
75
|
+
append(subscriptionId: string, incoming: readonly PendingItem[]): Promise<PendingItem[]>;
|
|
76
|
+
private appendLocked;
|
|
77
|
+
/** Everything detected but not yet announced. The coalescing read. */
|
|
78
|
+
listUnnotified(subscriptionId: string): Promise<PendingItem[]>;
|
|
79
|
+
/** Stamp `notifiedAt` on the keys just announced, so they are not repeated. */
|
|
80
|
+
/**
|
|
81
|
+
* Stamp the rows just announced, matched on `(itemKey, contentHash)`.
|
|
82
|
+
*
|
|
83
|
+
* The hash is load-bearing, not belt and braces. The caller reads the
|
|
84
|
+
* waiting rows, awaits a network send, and only then marks: if an append
|
|
85
|
+
* lands in that gap carrying the SAME key with CHANGED content, the row on
|
|
86
|
+
* disk is a different event from the one the notice described. Matching on
|
|
87
|
+
* the key alone would stamp the new version as announced and it would never
|
|
88
|
+
* go out. This is the same rule the ledger states as "a changed item is a
|
|
89
|
+
* new event".
|
|
90
|
+
*/
|
|
91
|
+
markNotified(subscriptionId: string, announced: readonly {
|
|
92
|
+
itemKey: string;
|
|
93
|
+
contentHash: string;
|
|
94
|
+
}[], notifiedAt: string): Promise<void>;
|
|
95
|
+
private markNotifiedLocked;
|
|
96
|
+
/** Resolve keys to their stored items. Absent keys are simply not returned. */
|
|
97
|
+
resolve(subscriptionId: string, itemKeys: readonly string[]): Promise<PendingItem[]>;
|
|
98
|
+
/**
|
|
99
|
+
* Delete items older than `ttlMs`, and the file itself once it is empty.
|
|
100
|
+
*
|
|
101
|
+
* This is the retention half of the credential-at-rest trade. It runs at
|
|
102
|
+
* boot beside the other sweeps rather than on a timer of its own: a payload
|
|
103
|
+
* that outlives its usefulness is the part of this design that ages badly.
|
|
104
|
+
*/
|
|
105
|
+
sweepExpired(now: number, ttlMs?: number): Promise<number>;
|
|
106
|
+
/** Drop everything for a subscription that no longer exists. */
|
|
107
|
+
drop(subscriptionId: string): Promise<void>;
|
|
108
|
+
/** Whether the backing directory exists yet. Used only by diagnostics. */
|
|
109
|
+
exists(): Promise<boolean>;
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=pending-store.d.ts.map
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { readFile, readdir, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { writeJsonFileAtomic } from "../../atomic-file.js";
|
|
5
|
+
import { ensurePrivateDirectory, ensurePrivateFile } from "../../private-files.js";
|
|
6
|
+
/**
|
|
7
|
+
* One detected item, held until the subscriber has been told about it.
|
|
8
|
+
*
|
|
9
|
+
* This record does two jobs that look separate and are not:
|
|
10
|
+
*
|
|
11
|
+
* 1. **The coalescing accumulator.** A subscriber that is mid-turn, or inside
|
|
12
|
+
* its debounce window, must not lose the event. The schedule engine's own
|
|
13
|
+
* failure path is the cautionary tale: `finishRun` has no branch reading
|
|
14
|
+
* `params.status`, so a run that failed because the agent was busy still
|
|
15
|
+
* advances `nextRunAt` a whole cadence period, which turns "deliver later"
|
|
16
|
+
* into "never". Holding the item here is the memory that path lacks.
|
|
17
|
+
* 2. **The payload for the fetch.** `ingestion_items` stores no payload and
|
|
18
|
+
* cohort rows store fingerprints, so without this the `get_subscription_items`
|
|
19
|
+
* tool would be a live network read that can come back empty for an item
|
|
20
|
+
* that left the window between the notice and the question.
|
|
21
|
+
*/
|
|
22
|
+
export const PendingItemSchema = z.strictObject({
|
|
23
|
+
itemKey: z.string(),
|
|
24
|
+
contentHash: z.string(),
|
|
25
|
+
title: z.string(),
|
|
26
|
+
subtitle: z.string(),
|
|
27
|
+
timestampMs: z.number(),
|
|
28
|
+
/** Epoch ms the daemon detected it. Drives the TTL sweep, never freshness. */
|
|
29
|
+
detectedAt: z.number(),
|
|
30
|
+
/** ISO 8601, or null while the item is still waiting to be announced. */
|
|
31
|
+
notifiedAt: z.string().nullable().default(null),
|
|
32
|
+
/**
|
|
33
|
+
* The raw aggregator event body.
|
|
34
|
+
*
|
|
35
|
+
* Stored because the user chose reliable fetch over a live re-read. The cost
|
|
36
|
+
* is real and is not hidden: an event body can contain a credential, so this
|
|
37
|
+
* file is the one place in ingestion that holds one at rest. Three things
|
|
38
|
+
* contain it, and none of them is optional: `$PASEO_HOME` is 0700, the value
|
|
39
|
+
* leaves the daemon ONLY through a local MCP tool and never through a client
|
|
40
|
+
* RPC, and `sweepExpired` deletes it on a TTL.
|
|
41
|
+
*/
|
|
42
|
+
payload: z.unknown(),
|
|
43
|
+
});
|
|
44
|
+
const PendingFileSchema = z.strictObject({
|
|
45
|
+
subscriptionId: z.string(),
|
|
46
|
+
items: z.array(PendingItemSchema).default([]),
|
|
47
|
+
});
|
|
48
|
+
/** How long a detected item is kept before the sweep deletes it. 7 days. */
|
|
49
|
+
export const DEFAULT_PENDING_TTL_MS = 7 * 86400000;
|
|
50
|
+
/**
|
|
51
|
+
* Per-subscription pending items, one JSON file each under `dir`.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately NOT a sqlite table. `ledger-schema.ts` creates its tables with
|
|
54
|
+
* bare `CREATE TABLE IF NOT EXISTS` and carries no migration step, so adding a
|
|
55
|
+
* column there is a change every existing `paseo.sqlite` silently does not get.
|
|
56
|
+
* A JSON file per subscription has the store convention the rest of ingestion
|
|
57
|
+
* already uses, and its failure mode is one unreadable subscription rather than
|
|
58
|
+
* a schema the database disagrees with.
|
|
59
|
+
*/
|
|
60
|
+
export class PendingItemStore {
|
|
61
|
+
constructor(dir) {
|
|
62
|
+
this.dir = dir;
|
|
63
|
+
/**
|
|
64
|
+
* One serialization chain per subscription.
|
|
65
|
+
*
|
|
66
|
+
* Every mutation here is read-modify-write over a whole JSON file, so two
|
|
67
|
+
* overlapping calls for the same subscription would each read the pre-state
|
|
68
|
+
* and the second write would erase the first. Today the only caller is the
|
|
69
|
+
* poller, whose tick is single-flight and sequential, so the race cannot
|
|
70
|
+
* happen by accident. That is exactly the kind of safety that stops being
|
|
71
|
+
* true the first time someone calls `deliverPending` from an RPC handler,
|
|
72
|
+
* and the failure it produces is a silently dropped notification rather than
|
|
73
|
+
* an error. Keyed by id, so two different subscriptions still run in
|
|
74
|
+
* parallel.
|
|
75
|
+
*/
|
|
76
|
+
this.chains = new Map();
|
|
77
|
+
}
|
|
78
|
+
withLock(subscriptionId, work) {
|
|
79
|
+
const previous = this.chains.get(subscriptionId) ?? Promise.resolve();
|
|
80
|
+
// `catch` before chaining: one failed operation must not poison every
|
|
81
|
+
// later one on the same subscription.
|
|
82
|
+
const next = previous.then(work, work);
|
|
83
|
+
this.chains.set(subscriptionId, next.catch(() => undefined));
|
|
84
|
+
return next;
|
|
85
|
+
}
|
|
86
|
+
filePath(subscriptionId) {
|
|
87
|
+
return join(this.dir, `${subscriptionId}.json`);
|
|
88
|
+
}
|
|
89
|
+
async ensureDir() {
|
|
90
|
+
// 0700, not the default 0755.
|
|
91
|
+
//
|
|
92
|
+
// This is the one directory in ingestion that holds a raw aggregator event
|
|
93
|
+
// body at rest, and such a body can carry an OAuth token. `$PASEO_HOME` is
|
|
94
|
+
// already 0700, so a 0755 child is unreachable to another user by path
|
|
95
|
+
// anyway - which is exactly the argument `SourceStore` makes for using a
|
|
96
|
+
// plain mkdir. That argument is correct and still leaves this directory
|
|
97
|
+
// one misplaced `$PASEO_HOME` away from being world readable, and unlike a
|
|
98
|
+
// source record the contents here are secret. Defence in depth, measured:
|
|
99
|
+
// a bare recursive mkdir under umask 0022 produces 0755.
|
|
100
|
+
ensurePrivateDirectory(this.dir);
|
|
101
|
+
}
|
|
102
|
+
async read(subscriptionId) {
|
|
103
|
+
await this.ensureDir();
|
|
104
|
+
try {
|
|
105
|
+
const content = await readFile(this.filePath(subscriptionId), "utf-8");
|
|
106
|
+
return PendingFileSchema.parse(JSON.parse(content)).items;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error.code === "ENOENT") {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async write(subscriptionId, items) {
|
|
116
|
+
await this.ensureDir();
|
|
117
|
+
const target = this.filePath(subscriptionId);
|
|
118
|
+
await writeJsonFileAtomic(target, { subscriptionId, items });
|
|
119
|
+
// 0600 on the file too, for the same reason and after the write, because
|
|
120
|
+
// the atomic write renames a fresh temp file into place each time.
|
|
121
|
+
ensurePrivateFile(target);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Record newly detected items, keyed by `itemKey`.
|
|
125
|
+
*
|
|
126
|
+
* An item whose `contentHash` CHANGED replaces the stored one and is
|
|
127
|
+
* announced again, which mirrors the ledger's own rule (`contentChanged` at
|
|
128
|
+
* `ledger.ts:368-373`): a changed item is a new event, not a duplicate. An
|
|
129
|
+
* item whose hash is identical is left exactly as it is, including its
|
|
130
|
+
* `notifiedAt`, so re-detecting it does not re-announce it.
|
|
131
|
+
*
|
|
132
|
+
* Returns the items that are genuinely new or changed, which is what the
|
|
133
|
+
* caller announces.
|
|
134
|
+
*/
|
|
135
|
+
async append(subscriptionId, incoming) {
|
|
136
|
+
return this.withLock(subscriptionId, () => this.appendLocked(subscriptionId, incoming));
|
|
137
|
+
}
|
|
138
|
+
async appendLocked(subscriptionId, incoming) {
|
|
139
|
+
const existing = await this.read(subscriptionId);
|
|
140
|
+
const byKey = new Map(existing.map((item) => [item.itemKey, item]));
|
|
141
|
+
const fresh = [];
|
|
142
|
+
for (const item of incoming) {
|
|
143
|
+
const previous = byKey.get(item.itemKey);
|
|
144
|
+
if (previous && previous.contentHash === item.contentHash) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
byKey.set(item.itemKey, item);
|
|
148
|
+
fresh.push(item);
|
|
149
|
+
}
|
|
150
|
+
if (fresh.length > 0) {
|
|
151
|
+
await this.write(subscriptionId, [...byKey.values()]);
|
|
152
|
+
}
|
|
153
|
+
return fresh;
|
|
154
|
+
}
|
|
155
|
+
/** Everything detected but not yet announced. The coalescing read. */
|
|
156
|
+
async listUnnotified(subscriptionId) {
|
|
157
|
+
const items = await this.read(subscriptionId);
|
|
158
|
+
return items.filter((item) => item.notifiedAt === null);
|
|
159
|
+
}
|
|
160
|
+
/** Stamp `notifiedAt` on the keys just announced, so they are not repeated. */
|
|
161
|
+
/**
|
|
162
|
+
* Stamp the rows just announced, matched on `(itemKey, contentHash)`.
|
|
163
|
+
*
|
|
164
|
+
* The hash is load-bearing, not belt and braces. The caller reads the
|
|
165
|
+
* waiting rows, awaits a network send, and only then marks: if an append
|
|
166
|
+
* lands in that gap carrying the SAME key with CHANGED content, the row on
|
|
167
|
+
* disk is a different event from the one the notice described. Matching on
|
|
168
|
+
* the key alone would stamp the new version as announced and it would never
|
|
169
|
+
* go out. This is the same rule the ledger states as "a changed item is a
|
|
170
|
+
* new event".
|
|
171
|
+
*/
|
|
172
|
+
async markNotified(subscriptionId, announced, notifiedAt) {
|
|
173
|
+
return this.withLock(subscriptionId, () => this.markNotifiedLocked(subscriptionId, announced, notifiedAt));
|
|
174
|
+
}
|
|
175
|
+
async markNotifiedLocked(subscriptionId, announced, notifiedAt) {
|
|
176
|
+
const wanted = new Set(announced.map((row) => `${row.itemKey}\u0000${row.contentHash}`));
|
|
177
|
+
const items = await this.read(subscriptionId);
|
|
178
|
+
// A loop rather than `map` with a spread: `no-map-spread` is on, and the
|
|
179
|
+
// rewritten row is built once per match instead of once per element.
|
|
180
|
+
const next = [];
|
|
181
|
+
for (const item of items) {
|
|
182
|
+
if (wanted.has(`${item.itemKey}\u0000${item.contentHash}`) && item.notifiedAt === null) {
|
|
183
|
+
next.push(Object.assign({}, item, { notifiedAt }));
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
next.push(item);
|
|
187
|
+
}
|
|
188
|
+
await this.write(subscriptionId, next);
|
|
189
|
+
}
|
|
190
|
+
/** Resolve keys to their stored items. Absent keys are simply not returned. */
|
|
191
|
+
async resolve(subscriptionId, itemKeys) {
|
|
192
|
+
const wanted = new Set(itemKeys);
|
|
193
|
+
const items = await this.read(subscriptionId);
|
|
194
|
+
return items.filter((item) => wanted.has(item.itemKey));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Delete items older than `ttlMs`, and the file itself once it is empty.
|
|
198
|
+
*
|
|
199
|
+
* This is the retention half of the credential-at-rest trade. It runs at
|
|
200
|
+
* boot beside the other sweeps rather than on a timer of its own: a payload
|
|
201
|
+
* that outlives its usefulness is the part of this design that ages badly.
|
|
202
|
+
*/
|
|
203
|
+
async sweepExpired(now, ttlMs = DEFAULT_PENDING_TTL_MS) {
|
|
204
|
+
await this.ensureDir();
|
|
205
|
+
const entries = await readdir(this.dir, { withFileTypes: true });
|
|
206
|
+
let removed = 0;
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
209
|
+
continue;
|
|
210
|
+
const subscriptionId = entry.name.slice(0, -".json".length);
|
|
211
|
+
const items = await this.read(subscriptionId);
|
|
212
|
+
const kept = items.filter((item) => now - item.detectedAt < ttlMs);
|
|
213
|
+
removed += items.length - kept.length;
|
|
214
|
+
if (kept.length === items.length)
|
|
215
|
+
continue;
|
|
216
|
+
// Inside the lock: this is a read-modify-write like every other mutator,
|
|
217
|
+
// and an append landing between the read above and the write below would
|
|
218
|
+
// be erased with no error and no log. Safe today only because the caller
|
|
219
|
+
// awaits the sweep before starting the timer, which is exactly the kind
|
|
220
|
+
// of caller-sequencing guarantee this class refuses to rely on.
|
|
221
|
+
await this.withLock(subscriptionId, async () => {
|
|
222
|
+
const current = await this.read(subscriptionId);
|
|
223
|
+
const survivors = current.filter((item) => now - item.detectedAt < ttlMs);
|
|
224
|
+
if (survivors.length === 0) {
|
|
225
|
+
await rm(this.filePath(subscriptionId), { force: true });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
await this.write(subscriptionId, survivors);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return removed;
|
|
232
|
+
}
|
|
233
|
+
/** Drop everything for a subscription that no longer exists. */
|
|
234
|
+
async drop(subscriptionId) {
|
|
235
|
+
await this.withLock(subscriptionId, async () => {
|
|
236
|
+
await this.ensureDir();
|
|
237
|
+
await rm(this.filePath(subscriptionId), { force: true });
|
|
238
|
+
// The chain entry itself is dropped, so a deleted subscription does not
|
|
239
|
+
// leave a settled promise in the map for the life of the daemon.
|
|
240
|
+
this.chains.delete(subscriptionId);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
/** Whether the backing directory exists yet. Used only by diagnostics. */
|
|
244
|
+
async exists() {
|
|
245
|
+
try {
|
|
246
|
+
await stat(this.dir);
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
//# sourceMappingURL=pending-store.js.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { StoredFilter } from "@hyperdrive.bot/fleet-protocol/ingestion/filter-types";
|
|
2
|
+
import type { Logger } from "pino";
|
|
3
|
+
import type { SourceStore } from "../sources/store.js";
|
|
4
|
+
import type { SourceGateway, SourceKind } from "../types.js";
|
|
5
|
+
import type { SubscriptionNotifier } from "./notifier.js";
|
|
6
|
+
import type { PendingItemStore } from "./pending-store.js";
|
|
7
|
+
import type { SubscriptionStore } from "./store.js";
|
|
8
|
+
/** How often the poller reads its sources. */
|
|
9
|
+
export declare const DEFAULT_POLL_INTERVAL_MS = 300000;
|
|
10
|
+
/**
|
|
11
|
+
* How far back each poll reads.
|
|
12
|
+
*
|
|
13
|
+
* Wider than the interval on purpose: a daemon that was asleep, or a source
|
|
14
|
+
* whose aggregator buffer filled late, would otherwise leave a hole no later
|
|
15
|
+
* poll ever covers. Re-reading an item is free, because `PendingItemStore.append`
|
|
16
|
+
* ignores one whose `contentHash` is unchanged.
|
|
17
|
+
*/
|
|
18
|
+
export declare const POLL_WINDOW_DAYS = 1;
|
|
19
|
+
export interface SubscriptionPollerOptions {
|
|
20
|
+
subscriptions: SubscriptionStore;
|
|
21
|
+
pending: PendingItemStore;
|
|
22
|
+
notifier: SubscriptionNotifier;
|
|
23
|
+
sourceStore: SourceStore;
|
|
24
|
+
filterResolver: (filterId: string) => Promise<StoredFilter | null>;
|
|
25
|
+
gatewayResolver: (kind: SourceKind) => SourceGateway;
|
|
26
|
+
logger: Logger;
|
|
27
|
+
intervalMs?: number;
|
|
28
|
+
now?: () => number;
|
|
29
|
+
}
|
|
30
|
+
export interface PollSummary {
|
|
31
|
+
/** Sources actually read. The number of NETWORK reads this tick cost. */
|
|
32
|
+
sourcesRead: number;
|
|
33
|
+
/** Filters evaluated against those reads, which costs CPU and not network. */
|
|
34
|
+
filtersMatched: number;
|
|
35
|
+
notified: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Reads each subscribed source ONCE per tick and fans the result out to every
|
|
39
|
+
* filter, and through them to every subscriber.
|
|
40
|
+
*
|
|
41
|
+
* The shape exists because of a measured cost: today two filters armed on one
|
|
42
|
+
* source cause two independent `listItems` walks, with no cache and no shared
|
|
43
|
+
* scan. With N subscribers that is N reads of the same aggregator buffer per
|
|
44
|
+
* window. Matching is pure string comparison over a dotted path, so fanning out
|
|
45
|
+
* in memory costs CPU while reading costs network, rate limit and latency.
|
|
46
|
+
* One read, many matches.
|
|
47
|
+
*
|
|
48
|
+
* It owns its own timer and touches the schedule engine not at all. A
|
|
49
|
+
* subscription is not a schedule: it must survive its target being archived,
|
|
50
|
+
* and `sweepOrphanedSchedules` would complete it terminally at the next boot.
|
|
51
|
+
*/
|
|
52
|
+
export declare class SubscriptionPoller {
|
|
53
|
+
private readonly options;
|
|
54
|
+
private readonly intervalMs;
|
|
55
|
+
private readonly now;
|
|
56
|
+
private timer;
|
|
57
|
+
/** Single-flight. A slow aggregator must not let two ticks overlap. */
|
|
58
|
+
private running;
|
|
59
|
+
constructor(options: SubscriptionPollerOptions);
|
|
60
|
+
start(): void;
|
|
61
|
+
stop(): void;
|
|
62
|
+
/**
|
|
63
|
+
* One pass: read every subscribed source once, match, notify, then drain.
|
|
64
|
+
*
|
|
65
|
+
* The drain at the end is what makes a notice held back by `busy` or
|
|
66
|
+
* `debounced` eventually go out even when the source has gone quiet.
|
|
67
|
+
*/
|
|
68
|
+
tick(): Promise<PollSummary>;
|
|
69
|
+
private runTick;
|
|
70
|
+
/** Delete pending payloads past their TTL. Called at boot, beside the other sweeps. */
|
|
71
|
+
sweep(): Promise<number>;
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=poller.d.ts.map
|