@hyperdrive.bot/fleet-server 0.3.148 → 0.3.149

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.
Files changed (30) hide show
  1. package/dist/server/server/agent/tools/paseo-tools.d.ts +9 -0
  2. package/dist/server/server/agent/tools/paseo-tools.js +76 -1
  3. package/dist/server/server/agent/tools/read-only-surface.d.ts +7 -0
  4. package/dist/server/server/agent/tools/read-only-surface.js +8 -0
  5. package/dist/server/server/bootstrap.js +71 -1
  6. package/dist/server/server/exports.d.ts +2 -0
  7. package/dist/server/server/exports.js +6 -0
  8. package/dist/server/server/ingestion/subscriptions/notification-prompt.d.ts +83 -0
  9. package/dist/server/server/ingestion/subscriptions/notification-prompt.js +96 -0
  10. package/dist/server/server/ingestion/subscriptions/notifier.d.ts +96 -0
  11. package/dist/server/server/ingestion/subscriptions/notifier.js +209 -0
  12. package/dist/server/server/ingestion/subscriptions/pending-store.d.ts +111 -0
  13. package/dist/server/server/ingestion/subscriptions/pending-store.js +254 -0
  14. package/dist/server/server/ingestion/subscriptions/poller.d.ts +73 -0
  15. package/dist/server/server/ingestion/subscriptions/poller.js +168 -0
  16. package/dist/server/server/ingestion/subscriptions/reader.d.ts +39 -0
  17. package/dist/server/server/ingestion/subscriptions/reader.js +30 -0
  18. package/dist/server/server/ingestion/subscriptions/store.d.ts +35 -0
  19. package/dist/server/server/ingestion/subscriptions/store.js +82 -0
  20. package/dist/server/web-ui/_expo/static/js/web/{index-88d5f130a09403b86ae8c8c5fd1f97d4.js → index-8747c529e5cb02149fe51570f7cb697b.js} +4 -4
  21. package/dist/server/web-ui/_expo/static/js/web/index-8747c529e5cb02149fe51570f7cb697b.js.br +0 -0
  22. package/dist/server/web-ui/_expo/static/js/web/index-8747c529e5cb02149fe51570f7cb697b.js.gz +0 -0
  23. package/dist/server/web-ui/_expo/static/js/web/{index-88d5f130a09403b86ae8c8c5fd1f97d4.js.map.br → index-8747c529e5cb02149fe51570f7cb697b.js.map.br} +0 -0
  24. package/dist/server/web-ui/_expo/static/js/web/{index-88d5f130a09403b86ae8c8c5fd1f97d4.js.map.gz → index-8747c529e5cb02149fe51570f7cb697b.js.map.gz} +0 -0
  25. package/dist/server/web-ui/index.html +1 -1
  26. package/dist/server/web-ui/index.html.br +0 -0
  27. package/dist/server/web-ui/index.html.gz +0 -0
  28. package/package.json +6 -6
  29. package/dist/server/web-ui/_expo/static/js/web/index-88d5f130a09403b86ae8c8c5fd1f97d4.js.br +0 -0
  30. 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