@namzu/sdk 52.2.0 → 52.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/bridge/a2a/mapper.d.ts.map +1 -1
- package/dist/bridge/a2a/mapper.js +3 -0
- package/dist/bridge/a2a/mapper.js.map +1 -1
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +18 -0
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/contracts/session/api.d.ts +1 -1
- package/dist/contracts/session/api.d.ts.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/pals/channels/routing.d.ts +1 -1
- package/dist/pals/channels/schema.d.ts +7 -7
- package/dist/pals/communication/ingress-schema.d.ts +32 -32
- package/dist/public-runtime.d.ts +1 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +1 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-tools.d.ts +2 -1
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +6 -1
- package/dist/public-tools.js.map +1 -1
- package/dist/runtime/jobs/awaited-jobs.d.ts +59 -2
- package/dist/runtime/jobs/awaited-jobs.d.ts.map +1 -1
- package/dist/runtime/jobs/awaited-jobs.js +108 -5
- package/dist/runtime/jobs/awaited-jobs.js.map +1 -1
- package/dist/runtime/jobs/monitor-stream.d.ts +96 -0
- package/dist/runtime/jobs/monitor-stream.d.ts.map +1 -0
- package/dist/runtime/jobs/monitor-stream.js +221 -0
- package/dist/runtime/jobs/monitor-stream.js.map +1 -0
- package/dist/runtime/jobs/registry.d.ts +96 -0
- package/dist/runtime/jobs/registry.d.ts.map +1 -1
- package/dist/runtime/jobs/registry.js +203 -20
- package/dist/runtime/jobs/registry.js.map +1 -1
- package/dist/runtime/query/executor.d.ts +9 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +6 -0
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts +16 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +93 -3
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/outstanding-work.d.ts +14 -0
- package/dist/runtime/query/iteration/outstanding-work.d.ts.map +1 -1
- package/dist/runtime/query/iteration/outstanding-work.js +64 -3
- package/dist/runtime/query/iteration/outstanding-work.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +11 -2
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/release-turn.d.ts.map +1 -1
- package/dist/runtime/query/release-turn.js +35 -0
- package/dist/runtime/query/release-turn.js.map +1 -1
- package/dist/runtime/query/steering.d.ts +35 -0
- package/dist/runtime/query/steering.d.ts.map +1 -1
- package/dist/runtime/query/steering.js +121 -0
- package/dist/runtime/query/steering.js.map +1 -1
- package/dist/runtime/query/tooling.d.ts +4 -0
- package/dist/runtime/query/tooling.d.ts.map +1 -1
- package/dist/runtime/query/tooling.js +4 -0
- package/dist/runtime/query/tooling.js.map +1 -1
- package/dist/runtime/system-events.d.ts +9 -0
- package/dist/runtime/system-events.d.ts.map +1 -1
- package/dist/runtime/system-events.js +12 -0
- package/dist/runtime/system-events.js.map +1 -1
- package/dist/tools/builtins/bash.d.ts +3 -0
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +3 -2
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/index.d.ts +1 -0
- package/dist/tools/builtins/index.d.ts.map +1 -1
- package/dist/tools/builtins/index.js +1 -0
- package/dist/tools/builtins/index.js.map +1 -1
- package/dist/tools/builtins/job.d.ts.map +1 -1
- package/dist/tools/builtins/job.js +6 -2
- package/dist/tools/builtins/job.js.map +1 -1
- package/dist/tools/builtins/monitor.d.ts +24 -0
- package/dist/tools/builtins/monitor.d.ts.map +1 -0
- package/dist/tools/builtins/monitor.js +167 -0
- package/dist/tools/builtins/monitor.js.map +1 -0
- package/dist/tools/builtins/wait-for-job.d.ts +1 -1
- package/dist/turn/reporter.d.ts.map +1 -1
- package/dist/turn/reporter.js +8 -0
- package/dist/turn/reporter.js.map +1 -1
- package/dist/types/session/events.d.ts +30 -4
- package/dist/types/session/events.d.ts.map +1 -1
- package/dist/types/session/events.js +6 -1
- package/dist/types/session/events.js.map +1 -1
- package/dist/types/session/records.d.ts +2 -2
- package/dist/types/session/records.d.ts.map +1 -1
- package/dist/types/session/records.js +3 -2
- package/dist/types/session/records.js.map +1 -1
- package/dist/types/tool/index.d.ts +25 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/package.json +1 -1
- package/src/bridge/a2a/mapper.ts +3 -0
- package/src/bridge/sse/mapper.ts +18 -0
- package/src/contracts/session/api.ts +1 -0
- package/src/index.ts +23 -0
- package/src/public-runtime.ts +3 -0
- package/src/public-tools.ts +11 -0
- package/src/runtime/jobs/awaited-jobs.ts +127 -5
- package/src/runtime/jobs/monitor-stream.ts +269 -0
- package/src/runtime/jobs/registry.ts +286 -21
- package/src/runtime/query/executor.ts +15 -0
- package/src/runtime/query/index.ts +116 -3
- package/src/runtime/query/iteration/outstanding-work.ts +64 -3
- package/src/runtime/query/iteration/phases/context.ts +9 -0
- package/src/runtime/query/iteration/phases/tool-review.ts +21 -9
- package/src/runtime/query/release-turn.ts +33 -0
- package/src/runtime/query/steering.ts +144 -0
- package/src/runtime/query/tooling.ts +8 -0
- package/src/runtime/system-events.ts +17 -0
- package/src/tools/builtins/bash.ts +3 -2
- package/src/tools/builtins/index.ts +6 -0
- package/src/tools/builtins/job.ts +11 -2
- package/src/tools/builtins/monitor.ts +206 -0
- package/src/turn/reporter.ts +8 -0
- package/src/types/session/events.ts +31 -4
- package/src/types/session/records.ts +3 -2
- package/src/types/tool/index.ts +24 -1
|
@@ -7,7 +7,30 @@ import type { BackgroundJob, BackgroundJobRegistry } from './registry.js'
|
|
|
7
7
|
* test that needs two exits and nothing else should not have to spawn
|
|
8
8
|
* processes to produce them.
|
|
9
9
|
*/
|
|
10
|
-
export type AwaitedJobSource = Pick<BackgroundJobRegistry, 'get' | 'onExit'>
|
|
10
|
+
export type AwaitedJobSource = Pick<BackgroundJobRegistry, 'get' | 'onExit'> &
|
|
11
|
+
Partial<Pick<BackgroundJobRegistry, 'onMonitorEvent'>>
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Turn the adapter into the holder of a turn for the monitors the TURN owns.
|
|
15
|
+
*
|
|
16
|
+
* A monitor is the one kind of job whose point is that it keeps going, so
|
|
17
|
+
* "the model said it is waiting" cannot be the signal that holds a turn open
|
|
18
|
+
* for it: the model starts it and goes on. The signal is ownership instead. A
|
|
19
|
+
* turn that owns its jobs itself (a sub-agent, a plain SDK host: nobody
|
|
20
|
+
* outside the turn is listening) has no one else to hear a monitor's events,
|
|
21
|
+
* so ending while one runs would start a watch and discard everything it
|
|
22
|
+
* finds. A SESSION-owned monitor never comes through here: its host hears the
|
|
23
|
+
* events and wakes on them, and holding the turn for it would delay the end of
|
|
24
|
+
* every turn for as long as the watch lives.
|
|
25
|
+
*/
|
|
26
|
+
export interface MonitorHold {
|
|
27
|
+
/**
|
|
28
|
+
* Whether the monitor notices the turn queued are still unread. The same
|
|
29
|
+
* second opinion `unreadNotice` is for exits: it can only narrow what counts
|
|
30
|
+
* as pending, never open a hold.
|
|
31
|
+
*/
|
|
32
|
+
readonly unreadNotice: () => boolean
|
|
33
|
+
}
|
|
11
34
|
|
|
12
35
|
/** Exits and the text that puts them in front of the model. See {@link AwaitedJobs.takeDelivery}. */
|
|
13
36
|
export interface DeliverableJobExits {
|
|
@@ -49,6 +72,10 @@ export class AwaitedJobs {
|
|
|
49
72
|
private exits: BackgroundJob[] = []
|
|
50
73
|
private readonly arrivals = new Set<() => void>()
|
|
51
74
|
private detach?: () => void
|
|
75
|
+
/** Running monitors the turn owns and holds itself open for. */
|
|
76
|
+
private readonly monitors = new Set<string>()
|
|
77
|
+
/** Monitor events and ends since the notices were last delivered. */
|
|
78
|
+
private monitorArrivals = 0
|
|
52
79
|
|
|
53
80
|
constructor(
|
|
54
81
|
private readonly source: AwaitedJobSource,
|
|
@@ -83,6 +110,8 @@ export class AwaitedJobs {
|
|
|
83
110
|
* unread by definition.
|
|
84
111
|
*/
|
|
85
112
|
private readonly unreadNotice?: () => boolean,
|
|
113
|
+
/** Present when the turn owns its monitors; see {@link MonitorHold}. */
|
|
114
|
+
private readonly monitorHold?: MonitorHold,
|
|
86
115
|
) {}
|
|
87
116
|
|
|
88
117
|
/**
|
|
@@ -94,20 +123,109 @@ export class AwaitedJobs {
|
|
|
94
123
|
*/
|
|
95
124
|
attach(): () => void {
|
|
96
125
|
if (this.detach) return this.detach
|
|
97
|
-
|
|
126
|
+
const stopMonitorEvents = this.monitorHold
|
|
127
|
+
? this.source.onMonitorEvent?.((event) => {
|
|
128
|
+
if (event.owner !== this.owner || !this.monitors.has(event.jobId)) return
|
|
129
|
+
this.arrive()
|
|
130
|
+
})
|
|
131
|
+
: undefined
|
|
132
|
+
const stopExits = this.source.onExit((job) => {
|
|
98
133
|
// `onExit` is a broadcast over a registry a host may share between
|
|
99
134
|
// runs, and the filter is the same one the exit-notice channel
|
|
100
135
|
// applies in `runtime/query/index.ts`.
|
|
101
136
|
if (job.owner !== this.owner) return
|
|
137
|
+
// A monitor's end is news of its own kind, delivered with its events
|
|
138
|
+
// rather than paired with a job-exit notice. It also leaves the
|
|
139
|
+
// awaited set if the model happened to `wait_for_job` it, so the one
|
|
140
|
+
// end cannot be waited for twice.
|
|
141
|
+
if (this.monitors.delete(job.id)) {
|
|
142
|
+
this.outstanding.delete(job.id)
|
|
143
|
+
this.arrive()
|
|
144
|
+
return
|
|
145
|
+
}
|
|
102
146
|
// `outstanding` is the gate, so an id nobody awaited is ignored and
|
|
103
147
|
// a second announcement for the same job cannot queue twice.
|
|
104
148
|
if (!this.outstanding.delete(job.id)) return
|
|
105
149
|
this.exits.push(job)
|
|
106
150
|
for (const wake of [...this.arrivals]) wake()
|
|
107
151
|
})
|
|
152
|
+
this.detach = () => {
|
|
153
|
+
stopMonitorEvents?.()
|
|
154
|
+
stopExits()
|
|
155
|
+
}
|
|
108
156
|
return this.detach
|
|
109
157
|
}
|
|
110
158
|
|
|
159
|
+
private arrive(): void {
|
|
160
|
+
this.monitorArrivals++
|
|
161
|
+
for (const wake of [...this.arrivals]) wake()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Say that the turn owns this running monitor and must not end while it
|
|
166
|
+
* runs. Called by the executor when the bound registry starts one, and a
|
|
167
|
+
* no-op on a turn that does not own its monitors.
|
|
168
|
+
*/
|
|
169
|
+
expectMonitor(id: string): void {
|
|
170
|
+
if (!this.monitorHold) return
|
|
171
|
+
let job: BackgroundJob
|
|
172
|
+
try {
|
|
173
|
+
job = this.source.get(id)
|
|
174
|
+
} catch {
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (job.owner !== this.owner || job.status !== 'running') return
|
|
178
|
+
this.monitors.add(id)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Monitor events or ends the model has not been shown yet. */
|
|
182
|
+
private get unreadMonitor(): boolean {
|
|
183
|
+
if (this.monitorArrivals === 0) return false
|
|
184
|
+
return this.monitorHold?.unreadNotice() ?? false
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Milliseconds until the last held monitor expires, or `undefined` when the
|
|
189
|
+
* turn holds none. A monitor's own expiry is the bound on holding for it: a
|
|
190
|
+
* hold that ended earlier would walk away from a watch with time left, and
|
|
191
|
+
* one that ended later would be waiting for a stop that has already come.
|
|
192
|
+
*/
|
|
193
|
+
monitorHoldMs(now: number = Date.now()): number | undefined {
|
|
194
|
+
let until: number | undefined
|
|
195
|
+
for (const id of this.monitors) {
|
|
196
|
+
let expiresAt: number | undefined
|
|
197
|
+
try {
|
|
198
|
+
expiresAt = this.source.get(id).expiresAt
|
|
199
|
+
} catch {
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
if (expiresAt !== undefined) until = Math.max(until ?? 0, expiresAt)
|
|
203
|
+
}
|
|
204
|
+
return until === undefined ? undefined : Math.max(0, until - now)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Say that the queued monitor notices have been put in front of the model.
|
|
209
|
+
* The counterpart of {@link noticesDelivered}, for the tool-result path.
|
|
210
|
+
*/
|
|
211
|
+
monitorNoticesDelivered(): void {
|
|
212
|
+
this.monitorArrivals = 0
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Take the monitor notices together with the record that they were
|
|
217
|
+
* delivered, or neither. The pairing is {@link takeDelivery}'s, for the
|
|
218
|
+
* same reason: marking them delivered when no text was written would
|
|
219
|
+
* strand what arrived.
|
|
220
|
+
*/
|
|
221
|
+
takeMonitorDelivery(notice: () => string | undefined): string | undefined {
|
|
222
|
+
if (this.monitorArrivals === 0) return undefined
|
|
223
|
+
const text = notice()
|
|
224
|
+
if (text === undefined) return undefined
|
|
225
|
+
this.monitorArrivals = 0
|
|
226
|
+
return text
|
|
227
|
+
}
|
|
228
|
+
|
|
111
229
|
/**
|
|
112
230
|
* Say that the model is waiting on this job's exit.
|
|
113
231
|
*
|
|
@@ -150,7 +268,9 @@ export class AwaitedJobs {
|
|
|
150
268
|
|
|
151
269
|
/** Whether an awaited job is still running, or one has exited unread. */
|
|
152
270
|
get hasPendingWork(): boolean {
|
|
153
|
-
return
|
|
271
|
+
return (
|
|
272
|
+
this.unreadExits || this.outstanding.size > 0 || this.monitors.size > 0 || this.unreadMonitor
|
|
273
|
+
)
|
|
154
274
|
}
|
|
155
275
|
|
|
156
276
|
/**
|
|
@@ -163,8 +283,8 @@ export class AwaitedJobs {
|
|
|
163
283
|
*/
|
|
164
284
|
waitForArrival(timeoutMs: number, signal?: AbortSignal): Promise<void> {
|
|
165
285
|
if (signal?.aborted) return Promise.resolve()
|
|
166
|
-
if (this.unreadExits) return Promise.resolve()
|
|
167
|
-
if (this.outstanding.size === 0) return Promise.resolve()
|
|
286
|
+
if (this.unreadExits || this.unreadMonitor) return Promise.resolve()
|
|
287
|
+
if (this.outstanding.size === 0 && this.monitors.size === 0) return Promise.resolve()
|
|
168
288
|
|
|
169
289
|
return new Promise((resolve) => {
|
|
170
290
|
const finish = (): void => {
|
|
@@ -263,6 +383,8 @@ export class AwaitedJobs {
|
|
|
263
383
|
this.detach?.()
|
|
264
384
|
this.detach = undefined
|
|
265
385
|
this.outstanding.clear()
|
|
386
|
+
this.monitors.clear()
|
|
387
|
+
this.monitorArrivals = 0
|
|
266
388
|
this.exits = []
|
|
267
389
|
// Release anyone still waiting. A closed adapter would otherwise hold
|
|
268
390
|
// them to their own deadline for an exit that can no longer reach them.
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a command's stdout into a few model-sized events.
|
|
3
|
+
*
|
|
4
|
+
* A watcher (`tail -f`, a poll loop, `gh run watch`) can print a line every
|
|
5
|
+
* millisecond, and every line the model is told about costs a context resend.
|
|
6
|
+
* So a monitor does not forward lines, it forwards BATCHES: lines are split,
|
|
7
|
+
* trimmed and truncated here, held for a short fixed window, and delivered
|
|
8
|
+
* together, and the batches themselves are rationed by a token bucket. A
|
|
9
|
+
* source that outruns the ration is first told so, then stopped, because a
|
|
10
|
+
* model that is flooded stops reading and one that is silenced does not know
|
|
11
|
+
* to look.
|
|
12
|
+
*
|
|
13
|
+
* Pure: no process, no registry, no model. The clock and the timer are
|
|
14
|
+
* injectable (and default to the globals read at call time, so fake timers
|
|
15
|
+
* work without any injection), which is what lets the tests advance a window
|
|
16
|
+
* by calling a function rather than by waiting for one.
|
|
17
|
+
*
|
|
18
|
+
* The constants and the wording come from the reference implementation of this
|
|
19
|
+
* tool; deviations are named where they are made.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A longer line keeps its first this-many characters. */
|
|
23
|
+
export const MONITOR_LINE_MAX_CHARS = 500
|
|
24
|
+
/** A joined batch longer than this keeps its first this-many characters. */
|
|
25
|
+
export const MONITOR_BATCH_MAX_CHARS = 3000
|
|
26
|
+
/**
|
|
27
|
+
* The first buffered line arms ONE timer for this long; everything that
|
|
28
|
+
* arrives before it fires rides in the same batch. A fixed window, not a
|
|
29
|
+
* debounce: a source that never pauses still gets a batch every window
|
|
30
|
+
* instead of never.
|
|
31
|
+
*/
|
|
32
|
+
export const MONITOR_BATCH_WINDOW_MS = 200
|
|
33
|
+
/** A partial line (no newline yet) keeps its tail past this many bytes. */
|
|
34
|
+
export const MONITOR_PARTIAL_LINE_MAX_BYTES = 1024 * 1024
|
|
35
|
+
/** One token per BATCH, not per line. */
|
|
36
|
+
export const MONITOR_BUCKET_CAPACITY = 10
|
|
37
|
+
export const MONITOR_BUCKET_REFILL_MS = 2000
|
|
38
|
+
/** Suppression older than this, with another batch dropped, stops the monitor. */
|
|
39
|
+
export const MONITOR_SUPPRESSION_LIMIT_MS = 30_000
|
|
40
|
+
/** The effective ceiling on a monitor's life; a host may lower it, never raise it past the schema. */
|
|
41
|
+
export const MONITOR_TIMEOUT_CAP_MS = 1_800_000
|
|
42
|
+
export const MONITOR_TIMEOUT_DEFAULT_MS = 300_000
|
|
43
|
+
export const MONITOR_TIMEOUT_MIN_MS = 1000
|
|
44
|
+
/** The schema ceiling; anything between the cap and this is clamped, not refused. */
|
|
45
|
+
export const MONITOR_TIMEOUT_MAX_MS = 3_600_000
|
|
46
|
+
/** One notification handed to the model is cut to this many characters, in the middle. */
|
|
47
|
+
export const MONITOR_NOTIFICATION_MAX_CHARS = 100_000
|
|
48
|
+
/**
|
|
49
|
+
* One event's content is shrunk to this many characters BEFORE it is framed,
|
|
50
|
+
* so the notification cap never has to cut inside an envelope. A batch is at
|
|
51
|
+
* most 3000, so this only matters to a notice or a caller that outgrew that.
|
|
52
|
+
*/
|
|
53
|
+
export const MONITOR_EVENT_CONTENT_MAX_CHARS = 20_000
|
|
54
|
+
|
|
55
|
+
const LINE_TRUNCATED = '...(truncated)'
|
|
56
|
+
const BATCH_TRUNCATED = '\n...(truncated)'
|
|
57
|
+
|
|
58
|
+
/** `30m`, `5m`, `90s`: whole minutes as minutes, anything else in seconds. */
|
|
59
|
+
export function formatMonitorDuration(ms: number): string {
|
|
60
|
+
const seconds = Math.max(1, Math.round(ms / 1000))
|
|
61
|
+
return seconds % 60 === 0 ? `${seconds / 60}m` : `${seconds}s`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function suppressionNotice(count: number): string {
|
|
65
|
+
return `[${count} events suppressed — output rate too high. Consider stopping this monitor and starting it with a more selective command.]`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function tooMuchOutputNotice(count: number, overMs: number): string {
|
|
69
|
+
return `[Monitor stopped — too much output (${count} events suppressed over ${formatMonitorDuration(overMs)}). Restart it with a more selective command.]`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function expiryNotice(ms: number, delivered: number): string {
|
|
73
|
+
return delivered > 0
|
|
74
|
+
? `[Monitor expired after ${formatMonitorDuration(ms)} with ${delivered} events delivered. Start it again if you still need the watch.]`
|
|
75
|
+
: `[Monitor expired after ${formatMonitorDuration(ms)} with no events delivered. Start it again if you still need the watch — and widen the filter if silence was unexpected.]`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface MonitorStreamStats {
|
|
79
|
+
/** Batches handed to `onBatch`. */
|
|
80
|
+
readonly eventsDelivered: number
|
|
81
|
+
/** Batches the bucket dropped, over the monitor's whole life. */
|
|
82
|
+
readonly suppressed: number
|
|
83
|
+
/** Characters pushed in, before any trimming: zero means the command never wrote to stdout. */
|
|
84
|
+
readonly stdoutChars: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface MonitorStreamOptions {
|
|
88
|
+
/** One batch of lines, joined with `\n`. */
|
|
89
|
+
onBatch(text: string): void
|
|
90
|
+
/** Housekeeping text from the stream itself (the suppression notice). Not rationed. */
|
|
91
|
+
onNotice(text: string): void
|
|
92
|
+
/**
|
|
93
|
+
* The source was suppressed for too long and another batch was dropped. The
|
|
94
|
+
* stream is closed before this is called; stopping the process is the
|
|
95
|
+
* caller's.
|
|
96
|
+
*/
|
|
97
|
+
onOverflow(info: { readonly suppressed: number; readonly overMs: number }): void
|
|
98
|
+
now?: () => number
|
|
99
|
+
setTimer?: (callback: () => void, ms: number) => unknown
|
|
100
|
+
clearTimer?: (handle: unknown) => void
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface MonitorStream {
|
|
104
|
+
/** Feed raw stdout text; chunks may split a line anywhere. */
|
|
105
|
+
push(text: string): void
|
|
106
|
+
/** Emit the pending batch now, without the partial line. For expiry. */
|
|
107
|
+
flush(): void
|
|
108
|
+
/** The end of the stream: the partial line counts as a line, then everything pending is emitted. */
|
|
109
|
+
drain(): void
|
|
110
|
+
/** Stop for good. Whatever is pending is discarded. */
|
|
111
|
+
close(): void
|
|
112
|
+
stats(): MonitorStreamStats
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The tail of `text` that fits `maxBytes`, starting on a whole code point. */
|
|
116
|
+
function keepTail(text: string, maxBytes: number): string {
|
|
117
|
+
const bytes = Buffer.from(text)
|
|
118
|
+
if (bytes.length <= maxBytes) return text
|
|
119
|
+
let start = bytes.length - maxBytes
|
|
120
|
+
while (start < bytes.length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start++
|
|
121
|
+
return bytes.subarray(start).toString('utf8')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createMonitorStream(options: MonitorStreamOptions): MonitorStream {
|
|
125
|
+
const now = options.now ?? (() => Date.now())
|
|
126
|
+
const setTimer =
|
|
127
|
+
options.setTimer ??
|
|
128
|
+
((callback: () => void, ms: number) => {
|
|
129
|
+
const handle = setTimeout(callback, ms)
|
|
130
|
+
// A pending batch must not keep a process alive; the job's own exit
|
|
131
|
+
// drains it.
|
|
132
|
+
handle.unref?.()
|
|
133
|
+
return handle
|
|
134
|
+
})
|
|
135
|
+
const clearTimer =
|
|
136
|
+
options.clearTimer ?? ((handle: unknown) => clearTimeout(handle as NodeJS.Timeout))
|
|
137
|
+
|
|
138
|
+
let closed = false
|
|
139
|
+
let partial = ''
|
|
140
|
+
let lines: string[] = []
|
|
141
|
+
let timer: unknown
|
|
142
|
+
let timerArmed = false
|
|
143
|
+
|
|
144
|
+
let tokens = MONITOR_BUCKET_CAPACITY
|
|
145
|
+
let refilledAt = now()
|
|
146
|
+
|
|
147
|
+
let eventsDelivered = 0
|
|
148
|
+
let suppressedTotal = 0
|
|
149
|
+
let stdoutChars = 0
|
|
150
|
+
|
|
151
|
+
// The suppression tracker. `count` is what the auto-stop reports; `unannounced`
|
|
152
|
+
// is what the next notice reports. They differ after a notice, because the
|
|
153
|
+
// tracker outlives it on purpose: a source that is told and keeps going must
|
|
154
|
+
// still reach the 30 s rule.
|
|
155
|
+
let suppressionStartedAt: number | undefined
|
|
156
|
+
let lastDropAt = 0
|
|
157
|
+
let count = 0
|
|
158
|
+
let unannounced = 0
|
|
159
|
+
let noticeEmitted = false
|
|
160
|
+
|
|
161
|
+
const refill = (at: number): void => {
|
|
162
|
+
const elapsed = at - refilledAt
|
|
163
|
+
if (elapsed <= 0) return
|
|
164
|
+
tokens = Math.min(MONITOR_BUCKET_CAPACITY, tokens + elapsed / MONITOR_BUCKET_REFILL_MS)
|
|
165
|
+
refilledAt = at
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const deliver = (batch: string): void => {
|
|
169
|
+
const at = now()
|
|
170
|
+
// Reset only after a notice went out AND the source has been quiet for
|
|
171
|
+
// three refills. Resetting on quiet alone would let a source that is
|
|
172
|
+
// dropped every other window never reach the stop rule.
|
|
173
|
+
if (noticeEmitted && at - lastDropAt > 3 * MONITOR_BUCKET_REFILL_MS) {
|
|
174
|
+
suppressionStartedAt = undefined
|
|
175
|
+
count = 0
|
|
176
|
+
unannounced = 0
|
|
177
|
+
noticeEmitted = false
|
|
178
|
+
}
|
|
179
|
+
refill(at)
|
|
180
|
+
if (tokens >= 1) {
|
|
181
|
+
tokens -= 1
|
|
182
|
+
if (unannounced > 0) {
|
|
183
|
+
options.onNotice(suppressionNotice(unannounced))
|
|
184
|
+
unannounced = 0
|
|
185
|
+
noticeEmitted = true
|
|
186
|
+
}
|
|
187
|
+
eventsDelivered++
|
|
188
|
+
options.onBatch(batch)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
// Whether the rule fires is decided by when suppression BEGAN, before
|
|
192
|
+
// this drop is counted, and the count reported includes it.
|
|
193
|
+
const began = suppressionStartedAt
|
|
194
|
+
suppressionStartedAt ??= at
|
|
195
|
+
lastDropAt = at
|
|
196
|
+
suppressedTotal++
|
|
197
|
+
count++
|
|
198
|
+
unannounced++
|
|
199
|
+
if (began !== undefined && at - began > MONITOR_SUPPRESSION_LIMIT_MS) {
|
|
200
|
+
stop()
|
|
201
|
+
options.onOverflow({ suppressed: count, overMs: at - began })
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const emitPending = (): void => {
|
|
206
|
+
if (timerArmed) {
|
|
207
|
+
clearTimer(timer)
|
|
208
|
+
timerArmed = false
|
|
209
|
+
}
|
|
210
|
+
if (lines.length === 0) return
|
|
211
|
+
let batch = lines.join('\n')
|
|
212
|
+
lines = []
|
|
213
|
+
if (batch.length > MONITOR_BATCH_MAX_CHARS) {
|
|
214
|
+
batch = batch.slice(0, MONITOR_BATCH_MAX_CHARS) + BATCH_TRUNCATED
|
|
215
|
+
}
|
|
216
|
+
deliver(batch)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const addLine = (raw: string): void => {
|
|
220
|
+
const line = raw.trim()
|
|
221
|
+
if (line.length === 0) return
|
|
222
|
+
lines.push(
|
|
223
|
+
line.length > MONITOR_LINE_MAX_CHARS
|
|
224
|
+
? line.slice(0, MONITOR_LINE_MAX_CHARS) + LINE_TRUNCATED
|
|
225
|
+
: line,
|
|
226
|
+
)
|
|
227
|
+
if (!timerArmed) {
|
|
228
|
+
timerArmed = true
|
|
229
|
+
timer = setTimer(() => {
|
|
230
|
+
timerArmed = false
|
|
231
|
+
if (!closed) emitPending()
|
|
232
|
+
}, MONITOR_BATCH_WINDOW_MS)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const stop = (): void => {
|
|
237
|
+
closed = true
|
|
238
|
+
if (timerArmed) {
|
|
239
|
+
clearTimer(timer)
|
|
240
|
+
timerArmed = false
|
|
241
|
+
}
|
|
242
|
+
lines = []
|
|
243
|
+
partial = ''
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
push(text) {
|
|
248
|
+
if (closed || text.length === 0) return
|
|
249
|
+
stdoutChars += text.length
|
|
250
|
+
const buffered = partial + text
|
|
251
|
+
const parts = buffered.split('\n')
|
|
252
|
+
partial = keepTail(parts.pop() ?? '', MONITOR_PARTIAL_LINE_MAX_BYTES)
|
|
253
|
+
for (const part of parts) addLine(part)
|
|
254
|
+
},
|
|
255
|
+
flush() {
|
|
256
|
+
if (closed) return
|
|
257
|
+
emitPending()
|
|
258
|
+
},
|
|
259
|
+
drain() {
|
|
260
|
+
if (closed) return
|
|
261
|
+
const rest = partial
|
|
262
|
+
partial = ''
|
|
263
|
+
addLine(rest)
|
|
264
|
+
emitPending()
|
|
265
|
+
},
|
|
266
|
+
close: stop,
|
|
267
|
+
stats: () => ({ eventsDelivered, suppressed: suppressedTotal, stdoutChars }),
|
|
268
|
+
}
|
|
269
|
+
}
|