@hyperdrive.bot/fleet-server 0.3.155 → 0.3.158
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/bootstrap.js +8 -0
- package/dist/server/server/ingestion/apply.js +12 -0
- package/dist/server/server/ingestion/dry-run.d.ts +3 -1
- package/dist/server/server/ingestion/dry-run.js +8 -0
- package/dist/server/server/ingestion/subscriptions/nudge-route.d.ts +42 -0
- package/dist/server/server/ingestion/subscriptions/nudge-route.js +67 -0
- package/dist/server/server/ingestion/subscriptions/poller.d.ts +11 -0
- package/dist/server/server/ingestion/subscriptions/poller.js +23 -3
- package/dist/server/server/session/chat/chat-ingestion-session.js +5 -1
- package/dist/server/utils/git-command-cache.d.ts +67 -0
- package/dist/server/utils/git-command-cache.js +436 -0
- package/dist/server/utils/run-git-command.js +14 -4
- package/dist/server/utils/spawn-broker-child.mjs +156 -0
- package/dist/server/utils/spawn-broker.d.ts +120 -0
- package/dist/server/utils/spawn-broker.js +299 -0
- package/dist/server/utils/spawn.d.ts +21 -0
- package/dist/server/utils/spawn.js +144 -10
- package/dist/server/web-ui/_expo/static/js/web/{index-81ee92214133211579a50ef5c84491cf.js → index-461552a3716a48e99f87634c93c7b220.js} +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-461552a3716a48e99f87634c93c7b220.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-461552a3716a48e99f87634c93c7b220.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-81ee92214133211579a50ef5c84491cf.js.map.br → index-461552a3716a48e99f87634c93c7b220.js.map.br} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-81ee92214133211579a50ef5c84491cf.js.map.gz → index-461552a3716a48e99f87634c93c7b220.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/dist/src/utils/spawn-broker-child.mjs +156 -0
- package/dist/src/utils/spawn-broker.js +299 -0
- package/dist/src/utils/spawn.js +144 -10
- package/package.json +8 -8
- package/dist/server/web-ui/_expo/static/js/web/index-81ee92214133211579a50ef5c84491cf.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-81ee92214133211579a50ef5c84491cf.js.gz +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"]
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-repo cache for read-only git commands the daemon repeats many times a second
|
|
3
|
+
* (config --get, rev-parse, symbolic-ref, show-ref, ...). Every git spawn from the
|
|
4
|
+
* daemon used to cost about 100ms of blocked event loop, and the same questions
|
|
5
|
+
* were asked over and over.
|
|
6
|
+
*
|
|
7
|
+
* Correctness first:
|
|
8
|
+
* - Repo location answers are cached until the `.git` entry changes.
|
|
9
|
+
* - Config and ref answers are keyed by a fingerprint of cheap stat() calls (HEAD,
|
|
10
|
+
* the ref files named in the command, packed-refs, FETCH_HEAD, repo/worktree/
|
|
11
|
+
* global config) AND expire after a short TTL (PASEO_GIT_CACHE_TTL_MS, 5s). The
|
|
12
|
+
* TTL is the backstop for what the fingerprint cannot see: include/includeIf
|
|
13
|
+
* targets, vendor system configs, and updates to nested ref files
|
|
14
|
+
* (refs/heads/feature/x) that do not touch any watched directory.
|
|
15
|
+
* - Working tree commands (status, ls-files --others, diff --shortstat) are never
|
|
16
|
+
* cached: identical concurrent calls share one spawn, nothing more.
|
|
17
|
+
* - Anything with `@{...}` (upstream/push/reflog) is not cached at all.
|
|
18
|
+
* - Reftable repos are not cached.
|
|
19
|
+
* Any git command not on the read-only list invalidates the repo's entries.
|
|
20
|
+
*/
|
|
21
|
+
type CommandClass =
|
|
22
|
+
/** Answer depends only on where the repository is. */
|
|
23
|
+
"location"
|
|
24
|
+
/** Depends on config files. */
|
|
25
|
+
| "config"
|
|
26
|
+
/** Depends on HEAD and refs. */
|
|
27
|
+
| "refs"
|
|
28
|
+
/** Depends on the working tree: single-flight only, never kept after settling. */
|
|
29
|
+
| "worktree";
|
|
30
|
+
export interface GitRepoDirs {
|
|
31
|
+
/** The `.git` entry (dir or gitfile) found walking up from cwd. */
|
|
32
|
+
dotGit: string;
|
|
33
|
+
/** Worktree-specific git dir (holds HEAD and index). */
|
|
34
|
+
gitDir: string;
|
|
35
|
+
/** Shared git dir (holds config, refs, packed-refs). */
|
|
36
|
+
commonDir: string;
|
|
37
|
+
}
|
|
38
|
+
export interface GitCacheStats {
|
|
39
|
+
hits: number;
|
|
40
|
+
misses: number;
|
|
41
|
+
coalesced: number;
|
|
42
|
+
invalidations: number;
|
|
43
|
+
}
|
|
44
|
+
export declare function isGitCacheEnabled(): boolean;
|
|
45
|
+
export declare function isReadOnlyCommand(args: readonly string[]): boolean;
|
|
46
|
+
/** Classify a command, or return null when it must not be cached. */
|
|
47
|
+
export declare function classifyGitCommand(args: readonly string[]): CommandClass | null;
|
|
48
|
+
export declare class GitCommandCache {
|
|
49
|
+
private readonly entries;
|
|
50
|
+
private readonly dirsByCwd;
|
|
51
|
+
private readonly generations;
|
|
52
|
+
readonly stats: GitCacheStats;
|
|
53
|
+
clear(): void;
|
|
54
|
+
/**
|
|
55
|
+
* Walk up from cwd to the `.git` entry and resolve worktree gitfiles and
|
|
56
|
+
* `commondir`. Pure filesystem work, no git spawn; cached per cwd briefly and
|
|
57
|
+
* re-validated by the `.git` entry still existing.
|
|
58
|
+
*/
|
|
59
|
+
resolveRepoDirs(cwd: string): GitRepoDirs | null;
|
|
60
|
+
run<T>(args: readonly string[], cwd: string, env: Record<string, string | undefined> | undefined, extraKey: string, execute: () => Promise<T>): Promise<T>;
|
|
61
|
+
invalidate(commonDir: string): void;
|
|
62
|
+
private evictIfNeeded;
|
|
63
|
+
}
|
|
64
|
+
export declare function findRepoDirs(cwd: string): GitRepoDirs | null;
|
|
65
|
+
export declare function getSharedGitCommandCache(): GitCommandCache;
|
|
66
|
+
export {};
|
|
67
|
+
//# sourceMappingURL=git-command-cache.d.ts.map
|