@hyperdrive.bot/fleet-server 0.3.155 → 0.3.157

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 (19) hide show
  1. package/dist/server/server/bootstrap.js +8 -0
  2. package/dist/server/server/ingestion/apply.js +12 -0
  3. package/dist/server/server/ingestion/dry-run.d.ts +3 -1
  4. package/dist/server/server/ingestion/dry-run.js +8 -0
  5. package/dist/server/server/ingestion/subscriptions/nudge-route.d.ts +42 -0
  6. package/dist/server/server/ingestion/subscriptions/nudge-route.js +67 -0
  7. package/dist/server/server/ingestion/subscriptions/poller.d.ts +11 -0
  8. package/dist/server/server/ingestion/subscriptions/poller.js +23 -3
  9. package/dist/server/server/session/chat/chat-ingestion-session.js +5 -1
  10. package/dist/server/web-ui/_expo/static/js/web/{index-81ee92214133211579a50ef5c84491cf.js → index-7ca8a9a256b79485a77556fab01588ca.js} +6 -6
  11. package/dist/server/web-ui/_expo/static/js/web/index-7ca8a9a256b79485a77556fab01588ca.js.br +0 -0
  12. package/dist/server/web-ui/_expo/static/js/web/{index-81ee92214133211579a50ef5c84491cf.js.gz → index-7ca8a9a256b79485a77556fab01588ca.js.gz} +0 -0
  13. package/dist/server/web-ui/_expo/static/js/web/{index-81ee92214133211579a50ef5c84491cf.js.map.br → index-7ca8a9a256b79485a77556fab01588ca.js.map.br} +0 -0
  14. package/dist/server/web-ui/_expo/static/js/web/{index-81ee92214133211579a50ef5c84491cf.js.map.gz → index-7ca8a9a256b79485a77556fab01588ca.js.map.gz} +0 -0
  15. package/dist/server/web-ui/index.html +1 -1
  16. package/dist/server/web-ui/index.html.br +0 -0
  17. package/dist/server/web-ui/index.html.gz +0 -0
  18. package/package.json +6 -6
  19. package/dist/server/web-ui/_expo/static/js/web/index-81ee92214133211579a50ef5c84491cf.js.br +0 -0
@@ -123,6 +123,7 @@ import { formatSystemNotificationPrompt, sendPromptToAgent } from "./agent/agent
123
123
  import { SubscriptionStore } from "./ingestion/subscriptions/store.js";
124
124
  import { PendingItemStore } from "./ingestion/subscriptions/pending-store.js";
125
125
  import { SubscriptionNotifier } from "./ingestion/subscriptions/notifier.js";
126
+ import { registerSubscriptionNudgeRoute } from "./ingestion/subscriptions/nudge-route.js";
126
127
  import { SubscriptionPoller } from "./ingestion/subscriptions/poller.js";
127
128
  import { createSubscriptionReader } from "./ingestion/subscriptions/reader.js";
128
129
  import { resolveSourceGateway } from "./ingestion/adapters.js";
@@ -1061,6 +1062,13 @@ export async function createPaseoDaemon(config, rootLogger) {
1061
1062
  logger.warn({ err: error }, "Subscription payload sweep failed");
1062
1063
  });
1063
1064
  subscriptionPoller.start();
1065
+ // "Poll now", for the sources that can tell instead of only being asked.
1066
+ //
1067
+ // Registered next to `start()` so the timer and its shortcut are read
1068
+ // together: the interval is still the floor that covers a bridge which
1069
+ // crashed, went quiet or never had push at all, and this only removes the
1070
+ // wait when something already knows.
1071
+ registerSubscriptionNudgeRoute(app, { poller: subscriptionPoller, logger });
1064
1072
  // The read-only ingestion PREVIEW collaborators (Story 5.1).
1065
1073
  //
1066
1074
  // Wired beside IngestionService rather than through it, because that service
@@ -137,6 +137,18 @@ function selectItems(items, itemKeys) {
137
137
  */
138
138
  export async function dispatchGroup(params) {
139
139
  const { filter, group, runId, ledger, schedule, cohorts, cohortId, limiter } = params;
140
+ // A notify-only filter spawns nothing, and this is the funnel where spawning
141
+ // happens. Refused BEFORE `ledger.claim`, so the items stay unclaimed and a
142
+ // subscription can still announce them: claiming first and bailing after
143
+ // would burn the very items the filter exists to deliver.
144
+ if (filter.target.type === "notify-only") {
145
+ await cohorts.markGroup(cohortId, group.key, {
146
+ status: "skipped",
147
+ error: "this filter is notify-only: it feeds subscriptions and starts no agent",
148
+ });
149
+ limiter.release(filter.id, 1);
150
+ return false;
151
+ }
140
152
  try {
141
153
  const claimed = group.items.filter((item) => ledger.claim(filter.id, item.key, { runId, agentId: null }));
142
154
  if (claimed.length === 0) {
@@ -22,7 +22,9 @@ export interface DryRunPlan {
22
22
  */
23
23
  export interface DryRunDispatcher {
24
24
  runUnattendedSession(input: {
25
- targetConfig: StoredFilter["target"]["config"];
25
+ targetConfig: Extract<StoredFilter["target"], {
26
+ type: "new-agent";
27
+ }>["config"];
26
28
  prompt: string;
27
29
  labels: Record<string, string>;
28
30
  mcpServersOverride?: Record<string, unknown>;
@@ -84,6 +84,14 @@ export function planDryRun(params) {
84
84
  */
85
85
  export async function runDryRun(params) {
86
86
  const { filter, groups, limiter, dispatcher } = params;
87
+ // A dry run rehearses the session a match WOULD open. A notify-only filter
88
+ // opens none, so there is nothing to rehearse and the honest answer is to
89
+ // say so rather than to open a session the real path never would - which is
90
+ // the one way a rehearsal can lie about the thing it rehearses. Refused
91
+ // before `admitDryRun`, so a preview does not spend cap either.
92
+ if (filter.target.type === "notify-only") {
93
+ throw new Error("This filter is notify-only: it feeds subscriptions and starts no agent, so there is no session to preview.");
94
+ }
87
95
  // Matches `service.ts`'s run-id minting. A parameter so tests are deterministic;
88
96
  // `planDryRun` itself stays free of `randomUUID` so it can stay pure.
89
97
  const runIdFactory = params.runIdFactory ?? (() => randomUUID());
@@ -0,0 +1,42 @@
1
+ import type { Express } from "express";
2
+ import type { Logger } from "pino";
3
+ import type { SubscriptionPoller } from "./poller.js";
4
+ export declare const SUBSCRIPTION_NUDGE_PATH = "/ingestion/subscriptions/nudge";
5
+ /**
6
+ * The shortest distance between two nudges that both do work.
7
+ *
8
+ * A chatty group can produce dozens of messages a minute, and each webhook
9
+ * would otherwise cost a full source read. Single-flight already collapses
10
+ * CONCURRENT nudges, but sequential ones arriving a second apart would each
11
+ * run a complete scan, so a busy chat would turn a 5-minute poll into a
12
+ * hundred. Five seconds keeps the latency a human would call instant while
13
+ * bounding the cost at twelve reads a minute in the worst case.
14
+ */
15
+ export declare const DEFAULT_NUDGE_MIN_INTERVAL_MS = 5000;
16
+ /**
17
+ * "Poll now", for a source that knows something changed.
18
+ *
19
+ * The poller exists because most sources can only be asked. A few can TELL:
20
+ * `wacli sync --webhook` POSTs every message as it arrives, so waiting up to a
21
+ * full interval to discover it is latency bought for nothing. This route is
22
+ * how a bridge that already has push says so, without either side learning the
23
+ * other's data model - the payload is ignored entirely, and the answer to
24
+ * "what changed?" is still the ordinary poll.
25
+ *
26
+ * Deliberately NOT a source-specific endpoint. It takes no body, names no app
27
+ * and carries no event: a bridge that can prove it should be listened to gets
28
+ * to say "now", and everything else about which items matched stays where it
29
+ * already is. That also means a second bridge needs no new route.
30
+ *
31
+ * Authentication is the daemon's ordinary bearer middleware. This path is not
32
+ * in `BEARER_AUTH_BYPASS_PATHS` and must never be added to it: an unauthorated
33
+ * caller could otherwise drive source reads at will, which is a rate-limit and
34
+ * cost amplifier against the aggregator, not merely a noisy endpoint.
35
+ */
36
+ export declare function registerSubscriptionNudgeRoute(app: Express, deps: {
37
+ poller: Pick<SubscriptionPoller, "tick">;
38
+ logger: Logger;
39
+ now?: () => number;
40
+ minIntervalMs?: number;
41
+ }): void;
42
+ //# sourceMappingURL=nudge-route.d.ts.map
@@ -0,0 +1,67 @@
1
+ export const SUBSCRIPTION_NUDGE_PATH = "/ingestion/subscriptions/nudge";
2
+ /**
3
+ * The shortest distance between two nudges that both do work.
4
+ *
5
+ * A chatty group can produce dozens of messages a minute, and each webhook
6
+ * would otherwise cost a full source read. Single-flight already collapses
7
+ * CONCURRENT nudges, but sequential ones arriving a second apart would each
8
+ * run a complete scan, so a busy chat would turn a 5-minute poll into a
9
+ * hundred. Five seconds keeps the latency a human would call instant while
10
+ * bounding the cost at twelve reads a minute in the worst case.
11
+ */
12
+ export const DEFAULT_NUDGE_MIN_INTERVAL_MS = 5000;
13
+ /**
14
+ * "Poll now", for a source that knows something changed.
15
+ *
16
+ * The poller exists because most sources can only be asked. A few can TELL:
17
+ * `wacli sync --webhook` POSTs every message as it arrives, so waiting up to a
18
+ * full interval to discover it is latency bought for nothing. This route is
19
+ * how a bridge that already has push says so, without either side learning the
20
+ * other's data model - the payload is ignored entirely, and the answer to
21
+ * "what changed?" is still the ordinary poll.
22
+ *
23
+ * Deliberately NOT a source-specific endpoint. It takes no body, names no app
24
+ * and carries no event: a bridge that can prove it should be listened to gets
25
+ * to say "now", and everything else about which items matched stays where it
26
+ * already is. That also means a second bridge needs no new route.
27
+ *
28
+ * Authentication is the daemon's ordinary bearer middleware. This path is not
29
+ * in `BEARER_AUTH_BYPASS_PATHS` and must never be added to it: an unauthorated
30
+ * caller could otherwise drive source reads at will, which is a rate-limit and
31
+ * cost amplifier against the aggregator, not merely a noisy endpoint.
32
+ */
33
+ export function registerSubscriptionNudgeRoute(app, deps) {
34
+ const now = deps.now ?? (() => Date.now());
35
+ const minIntervalMs = deps.minIntervalMs ?? DEFAULT_NUDGE_MIN_INTERVAL_MS;
36
+ let lastRanAt = Number.NEGATIVE_INFINITY;
37
+ app.post(SUBSCRIPTION_NUDGE_PATH, (_req, res) => {
38
+ const at = now();
39
+ if (at - lastRanAt < minIntervalMs) {
40
+ // 202, not 429: the caller did nothing wrong and needs no backoff. The
41
+ // event is covered by the tick that just ran or is about to, so the
42
+ // honest answer is "accepted, and folded into another read".
43
+ res.status(202).json({ ran: false, reason: "coalesced" });
44
+ return;
45
+ }
46
+ lastRanAt = at;
47
+ // Answered BEFORE the tick, on purpose. A webhook sender is not waiting to
48
+ // learn what matched, and `wacli` gives up on a slow endpoint; holding the
49
+ // response for a full source read would turn a slow aggregator into
50
+ // dropped deliveries upstream.
51
+ res.status(202).json({ ran: true });
52
+ void deps.poller
53
+ .tick()
54
+ .then((summary) => {
55
+ deps.logger.info({ ...summary, trigger: "nudge" }, "Subscription poll tick (nudged)");
56
+ return summary;
57
+ })
58
+ .catch((error) => {
59
+ // The response is already sent, so this is the only place the failure
60
+ // can surface. A bare catch here would make a wedged nudge path
61
+ // indistinguishable from a quiet one, which is the exact bug the tick
62
+ // logging in this same release exists to remove.
63
+ deps.logger.warn({ err: error }, "Nudged subscription poll tick failed");
64
+ });
65
+ });
66
+ }
67
+ //# sourceMappingURL=nudge-route.js.map
@@ -32,6 +32,17 @@ export interface PollSummary {
32
32
  sourcesRead: number;
33
33
  /** Filters evaluated against those reads, which costs CPU and not network. */
34
34
  filtersMatched: number;
35
+ /**
36
+ * ITEMS a filter accepted, which `filtersMatched` does not say.
37
+ *
38
+ * The two are read together or not at all. `filtersMatched: 1, itemsMatched: 0`
39
+ * is a filter that ran and rejected everything, which is where a mistyped
40
+ * `field` lands; `filtersMatched: 0` is a filter that never ran at all. Those
41
+ * are different bugs and the old summary could not tell them apart, which is
42
+ * exactly what made a wrong `field` cost an hour to find: a filter rooted at
43
+ * the item instead of the payload matches nothing, silently, forever.
44
+ */
45
+ itemsMatched: number;
35
46
  notified: number;
36
47
  }
37
48
  /**
@@ -60,11 +60,25 @@ export class SubscriptionPoller {
60
60
  */
61
61
  async tick() {
62
62
  if (this.running) {
63
- return { sourcesRead: 0, filtersMatched: 0, notified: 0 };
63
+ // Not silent. Single-flight is correct, but a tick that is ALWAYS skipped
64
+ // means the previous one never returned, and a wedged poller must not
65
+ // read as a quiet one.
66
+ this.options.logger.warn("Subscription poll tick skipped: the previous tick is still running");
67
+ return { sourcesRead: 0, filtersMatched: 0, itemsMatched: 0, notified: 0 };
64
68
  }
65
69
  this.running = true;
70
+ const startedAt = this.now();
66
71
  try {
67
- return await this.runTick();
72
+ const summary = await this.runTick();
73
+ // Every tick, at info, unconditionally.
74
+ //
75
+ // A summary logged only when something happened is the same trap this
76
+ // line exists to remove: "nothing matched" and "no tick ran" would still
77
+ // look identical from the outside. One line per interval is cheap next to
78
+ // the runtime metrics this daemon already emits twice a minute, and it is
79
+ // the only evidence that the loop is alive at all.
80
+ this.options.logger.info({ ...summary, elapsedMs: this.now() - startedAt }, "Subscription poll tick");
81
+ return summary;
68
82
  }
69
83
  finally {
70
84
  this.running = false;
@@ -73,7 +87,12 @@ export class SubscriptionPoller {
73
87
  async runTick() {
74
88
  const { subscriptions, filterResolver, sourceStore, gatewayResolver, logger } = this.options;
75
89
  const armed = (await subscriptions.list()).filter((s) => s.status === "armed");
76
- const summary = { sourcesRead: 0, filtersMatched: 0, notified: 0 };
90
+ const summary = {
91
+ sourcesRead: 0,
92
+ filtersMatched: 0,
93
+ itemsMatched: 0,
94
+ notified: 0,
95
+ };
77
96
  if (armed.length === 0) {
78
97
  return summary;
79
98
  }
@@ -131,6 +150,7 @@ export class SubscriptionPoller {
131
150
  now: () => now,
132
151
  });
133
152
  summary.filtersMatched += 1;
153
+ summary.itemsMatched += matched.length;
134
154
  if (matched.length === 0)
135
155
  continue;
136
156
  const results = await this.options.notifier.notifyForFilter(filter, matched);
@@ -262,7 +262,11 @@ export class ChatIngestionSession {
262
262
  cohortId: result.cohortId,
263
263
  collapse: result.collapse,
264
264
  report: result.report,
265
- targetCwd: filter.target.config.cwd,
265
+ // Empty for a notify-only filter, which has no working directory
266
+ // because it opens no session. The per-group skip reason in `report`
267
+ // is what says why, so this stays a blank rather than a fabricated
268
+ // path the user could mistake for somewhere work happened.
269
+ targetCwd: filter.target.type === "new-agent" ? filter.target.config.cwd : "",
266
270
  error: null,
267
271
  },
268
272
  });