@gr8ful/spf 0.19.0 → 0.19.1
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/README.md +8 -0
- package/assets/skill/references/config.md +1 -0
- package/dist/cli/commands/doctor.js +25 -3
- package/dist/cli/commands/estimate.d.ts +22 -6
- package/dist/cli/commands/estimate.js +32 -10
- package/dist/cli/commands/loop.d.ts +20 -0
- package/dist/cli/commands/loop.js +20 -1
- package/dist/cli/commands/ui.js +2 -1
- package/dist/cli/commands/watch.js +1 -1
- package/dist/cli/index.js +2 -2
- package/dist/cli/interview.js +13 -0
- package/dist/cli/ui/run_dashboard.js +13 -7
- package/dist/core/agent_cc.d.ts +19 -3
- package/dist/core/agent_cc.js +38 -18
- package/dist/core/agent_flue.js +51 -14
- package/dist/core/agent_opencode.d.ts +62 -25
- package/dist/core/agent_opencode.js +71 -30
- package/dist/core/agents.d.ts +51 -4
- package/dist/core/agents.js +79 -4
- package/dist/core/console.d.ts +24 -4
- package/dist/core/console.js +20 -7
- package/dist/core/data_types.d.ts +300 -19
- package/dist/core/data_types.js +134 -5
- package/dist/core/issues/jira_provider.d.ts +51 -1
- package/dist/core/issues/jira_provider.js +69 -1
- package/dist/core/issues/provider.d.ts +23 -0
- package/dist/core/loop.d.ts +39 -1
- package/dist/core/loop.js +33 -2
- package/dist/core/ollama_provider.d.ts +96 -13
- package/dist/core/ollama_provider.js +172 -26
- package/dist/core/otel.js +10 -1
- package/dist/core/otel_propagation.d.ts +168 -24
- package/dist/core/otel_propagation.js +219 -43
- package/dist/core/permissions.d.ts +16 -1
- package/dist/core/permissions.js +91 -3
- package/dist/core/providers.js +8 -3
- package/dist/core/refine.js +13 -1
- package/dist/core/runner.d.ts +33 -2
- package/dist/core/runner.js +40 -5
- package/dist/core/tiering.js +7 -3
- package/dist/core/tracer.d.ts +7 -1
- package/dist/core/tracer.js +15 -3
- package/dist/ui/server/db.d.ts +8 -1
- package/dist/ui/server/db.js +21 -4
- package/dist/ui/server/serve.d.ts +7 -0
- package/dist/ui/server/serve.js +10 -7
- package/dist/ui/shared/types.d.ts +16 -0
- package/package.json +1 -1
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
* `-undici` create a real client span (with a real, non-noop SpanContext)
|
|
15
15
|
* around every outbound `http`/`https`/`fetch`(undici) call made from this
|
|
16
16
|
* process, and the OTel API's global propagator is what those
|
|
17
|
-
* instrumentations use to inject `traceparent` (and, here,
|
|
18
|
-
*
|
|
17
|
+
* instrumentations use to inject `traceparent` (and, here, the gateway's own
|
|
18
|
+
* `x-correlation-id`/`x-spf-agent` — see GatewayHeadersPropagator below;
|
|
19
|
+
* NEVER `x-request-id`, which Envoy/Switchyard own end-to-end and which a
|
|
20
|
+
* client-sent value would corrupt) into that call's headers — REGARDLESS of
|
|
21
|
+
* which provider SDK issued it.
|
|
19
22
|
* This reaches every provider whose Node SDK issues requests through
|
|
20
23
|
* Node's own `http`/`https` modules or `undici` (verified: `fetch()`,
|
|
21
24
|
* `https.request()`). It does NOT reach a provider transport that bypasses
|
|
@@ -58,7 +61,64 @@
|
|
|
58
61
|
* agent processing", which is the id SPF mints and hands to
|
|
59
62
|
* `init(SfAgent, { id })`. Extraction goes through the globally
|
|
60
63
|
* registered propagator against ROOT_CONTEXT, so no leaked loop context
|
|
61
|
-
* can stick.
|
|
64
|
+
* can stick.
|
|
65
|
+
*
|
|
66
|
+
* BLOCKER 1 (fixed) — `resolveRootContext`'s return value NEVER becomes the
|
|
67
|
+
* active context. Verified against `@flue/opentelemetry`'s own dist
|
|
68
|
+
* (index.mjs:361 for the `chat <model>` span, :329 for the interceptor that
|
|
69
|
+
* activates it): the resolved `Context` is passed to `tracer.startSpan(...)`
|
|
70
|
+
* ONLY as `parentContext` — it supplies the new span's trace id/parent span
|
|
71
|
+
* id and is then discarded. What actually gets activated around the real
|
|
72
|
+
* dispatch is `context.with(trace.setSpan(context.active(), span), next)` —
|
|
73
|
+
* a context built from `context.active()` (whatever was active before,
|
|
74
|
+
* almost always ROOT) plus the freshly minted `span`, NOT the resolved
|
|
75
|
+
* context itself. A prior version of this module stashed the session's
|
|
76
|
+
* `adw_id`/`agent_name` as a plain context VALUE on the context
|
|
77
|
+
* `resolveFlueRootContext` returned (keyed under a private context key) and
|
|
78
|
+
* had `GatewayHeadersPropagator` read that value back at inject time — which
|
|
79
|
+
* can never work, because that exact context object is never the one made
|
|
80
|
+
* active; only its SpanContext (traceId/spanId/flags) survives, carried by
|
|
81
|
+
* the new span. Confirmed live: `scratchpad/probe_installed_real.mjs`,
|
|
82
|
+
* reproducing flue's exact two-line sequence against a real pi-ai dispatch,
|
|
83
|
+
* printed `x-correlation-id: ABSENT` / `x-spf-agent: ABSENT` before this fix.
|
|
84
|
+
*
|
|
85
|
+
* THE FIX: key the registration by TRACE ID instead of by context identity.
|
|
86
|
+
* `registerFlueSessionTrace` now ALSO records, in `traceIdRegistrations`,
|
|
87
|
+
* the trace id parsed out of the very `traceparent` it's given — the same
|
|
88
|
+
* deterministic trace id `resolveFlueRootContext` extracts and hands to
|
|
89
|
+
* `tracer.startSpan` as that span's parent, so the span it creates carries
|
|
90
|
+
* that exact trace id forward into whatever context DOES get activated.
|
|
91
|
+
* `GatewayHeadersPropagator.inject()` below reads `trace.getSpanContext(ctx)
|
|
92
|
+
* ?.traceId` — the thing that provably survives into the active context at
|
|
93
|
+
* request time — and looks IT UP in `traceIdRegistrations`, rather than
|
|
94
|
+
* trying to read a value off a context object that was never activated.
|
|
95
|
+
* One deterministic trace id per adw_id (`otel.ts:556`'s `traceIdFor(adwId)`,
|
|
96
|
+
* reused for EVERY agent call in that run — `otel.ts:737`'s
|
|
97
|
+
* `agentCallTraceContext` varies only the span id, never the trace id), and
|
|
98
|
+
* `traceIdRegistrations` is keyed on that trace id alone, not on session id
|
|
99
|
+
* or span id. So "latest registration for this trace id wins" is exact
|
|
100
|
+
* within a run when its agents run one at a time — which is how a `fanout`
|
|
101
|
+
* attempt's own agents run: `fanout.ts:448` derives each attempt its OWN
|
|
102
|
+
* adw_id, so distinct fanout attempts get distinct trace ids and cannot
|
|
103
|
+
* collide here, regardless of `fanout`'s concurrency. The one real collision
|
|
104
|
+
* this map can still see is narrower and does not happen in SPF today: TWO
|
|
105
|
+
* AGENTS OVERLAPPING INSIDE ONE adw_id — a single run dispatching a second
|
|
106
|
+
* flue agent call before the first one's `unregisterFlueSessionTrace` has
|
|
107
|
+
* run — since both share that run's one trace id, the second
|
|
108
|
+
* `registerFlueSessionTrace` call overwrites the first agent's entry in
|
|
109
|
+
* `traceIdRegistrations` while its dispatch may still be in flight, and that
|
|
110
|
+
* agent's outbound request would then carry the OTHER agent's
|
|
111
|
+
* `x-correlation-id`/`x-spf-agent` (never a wrong `adw_id`, since both
|
|
112
|
+
* belong to the same run — only the wrong `agentName`). No chain SPF ships
|
|
113
|
+
* dispatches two agents concurrently within one adw_id; this is flagged as
|
|
114
|
+
* the mechanism's honest limit, not a bug being carried forward. The SAME
|
|
115
|
+
* registration carries this session's `adw_id`/agent name (see
|
|
116
|
+
* `FlueSessionRegistration`) so a
|
|
117
|
+
* session's `x-correlation-id`/`x-spf-agent` ride the exact same "resolved
|
|
118
|
+
* once per submission, read per real HTTP call" path as `traceparent` does,
|
|
119
|
+
* via `GatewayHeadersPropagator` below, instead of the static-per-model-id
|
|
120
|
+
* fallback `ollama_provider.ts` needs when this pipeline isn't installed at
|
|
121
|
+
* all (otel unconfigured). Consequences, all intended:
|
|
62
122
|
* - Flue's spans inherit SPF's sha256 trace id, parented under the
|
|
63
123
|
* right agent-call span PER SESSION — correct under multiple agents
|
|
64
124
|
* per process, concurrent agents, and claim-loop restarts alike. Span
|
|
@@ -66,17 +126,42 @@
|
|
|
66
126
|
* - The http/undici client spans' injected `traceparent` carries the
|
|
67
127
|
* deterministic id too, so Switchyard/vLLM hops land as descendants of
|
|
68
128
|
* SPF's trace — parity with `claude_code`'s `ANTHROPIC_CUSTOM_HEADERS`
|
|
69
|
-
* path.
|
|
70
|
-
* correlation
|
|
129
|
+
* path. The same per-request injection point also carries THIS
|
|
130
|
+
* session's `x-correlation-id`/`x-spf-agent`, correctly attributed even
|
|
131
|
+
* when multiple sessions are concurrent in one process, AS LONG AS their
|
|
132
|
+
* deterministic trace ids differ (their `ctx.id`s, and therefore their
|
|
133
|
+
* registrations, are distinct) — see BLOCKER 1 above for the precise
|
|
134
|
+
* lookup key.
|
|
71
135
|
* - Unmapped sessions (never registered, restarted process with a
|
|
72
136
|
* durable backlog, post-`unregister` straggler bookkeeping spans)
|
|
73
137
|
* resolve to an unparented root — flue's spans root a separate SDK
|
|
74
|
-
* trace exactly as v1 did, correlatable by `
|
|
75
|
-
*
|
|
138
|
+
* trace exactly as v1 did, correlatable by `spf.adw_id`/time window.
|
|
139
|
+
* Degraded join, never an error and never MIS-attributed; the gateway
|
|
140
|
+
* headers for exactly this case are simply ABSENT (no fallback identity
|
|
141
|
+
* — see `GatewayHeadersPropagator.inject()` below) rather than guessing
|
|
142
|
+
* at whichever session happened to register most recently.
|
|
76
143
|
* - flue's internal `executionContext.traceCarrier` (typed but not on
|
|
77
144
|
* the public `AgentDispatchRequest` surface) stays unused — noted here
|
|
78
145
|
* as flue's own escape hatch, not something SPF reaches into.
|
|
79
146
|
*
|
|
147
|
+
* STATIC HEADERS STAY SUPPRESSED WHEN INSTALLED (BLOCKER 1's other half,
|
|
148
|
+
* decided AGAINST enabling): `ollama_provider.ts`'s `modelFor()` keeps
|
|
149
|
+
* omitting `Model.headers`' `x-correlation-id`/`x-spf-agent` whenever
|
|
150
|
+
* `isFluePropagationInstalled()` is true — it is NOT also stamped as a
|
|
151
|
+
* baseline alongside this propagator. Measured live
|
|
152
|
+
* (`scratchpad/probe_duplicate_header.mjs`, against the REAL
|
|
153
|
+
* `@opentelemetry/instrumentation-undici` used here): `request.addHeader(k,
|
|
154
|
+
* v)` — what that instrumentation calls for every header
|
|
155
|
+
* `propagation.inject()` returns — APPENDS a second raw header line rather
|
|
156
|
+
* than replacing one already present (undici's own `Request.addHeader` has
|
|
157
|
+
* no dedupe-by-name step); the receiving `http.IncomingMessage.headers`
|
|
158
|
+
* then comma-joins the two into one corrupted value
|
|
159
|
+
* (`"FROM_STATIC, FROM_PROPAGATOR"`), and `req.rawHeaders` shows the literal
|
|
160
|
+
* duplicate line. So when propagation is installed, THIS propagator is the
|
|
161
|
+
* sole source of `x-correlation-id`/`x-spf-agent` — never doubled up with a
|
|
162
|
+
* static value from `Model.headers` — proven on the wire by
|
|
163
|
+
* `src/test/ollama_gateway_e2e.test.ts`'s "no duplicate headers" test.
|
|
164
|
+
*
|
|
80
165
|
* REGISTRATION TIMING. `installFluePropagation()` is called from
|
|
81
166
|
* `agent_flue.ts`'s `run()`, before `ensureRuntime()`/dispatch — i.e. before
|
|
82
167
|
* the actual outbound call, which is the only ordering that matters for
|
|
@@ -108,56 +193,124 @@ import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trac
|
|
|
108
193
|
import { createOpenTelemetryInstrumentation } from "@flue/opentelemetry";
|
|
109
194
|
import { instrument } from "@flue/runtime";
|
|
110
195
|
import { resolveTracesUrl } from "./otel.js";
|
|
111
|
-
/** The one custom propagation field this repo adds beyond the standard W3C `traceparent`: the current span's own id, for a collector/log pipeline that correlates by request rather than by trace. */
|
|
112
|
-
export const X_REQUEST_ID_HEADER = "x-request-id";
|
|
113
196
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
197
|
+
* The Briefs gateway's own correlation headers — see `ollama_provider.ts`'s
|
|
198
|
+
* "Gateway headers" section for the full three-header contract (the third,
|
|
199
|
+
* `traceparent`, is the W3C standard one `W3CTraceContextPropagator` already
|
|
200
|
+
* injects). Defined here (not in `ollama_provider.ts`) because
|
|
201
|
+
* `GatewayHeadersPropagator` below is the thing that actually injects them
|
|
202
|
+
* onto the wire when this pipeline is installed; `ollama_provider.ts`
|
|
203
|
+
* imports these two constants rather than redeclaring them, so there is
|
|
204
|
+
* exactly one spelling of each header name in the codebase.
|
|
205
|
+
*/
|
|
206
|
+
export const X_CORRELATION_ID_HEADER = "x-correlation-id";
|
|
207
|
+
export const X_SPF_AGENT_HEADER = "x-spf-agent";
|
|
208
|
+
/**
|
|
209
|
+
* Instance-id -> registration backing `resolveFlueRootContext` — see the
|
|
210
|
+
* module header for the full mechanism and WHY this is a map consulted per
|
|
211
|
+
* span rather than a dispatch-time context wrap (flue's single claim loop
|
|
212
|
+
* makes the latter mis-attribute every agent after the first). One entry per
|
|
213
|
+
* in-flight `agent_flue.run()` call.
|
|
214
|
+
*/
|
|
215
|
+
const flueSessionTraces = new Map();
|
|
216
|
+
/**
|
|
217
|
+
* Trace id -> gateway identity, keyed on the deterministic trace id parsed
|
|
218
|
+
* out of a registration's OWN `traceparent` (see `traceIdFromTraceparent`) —
|
|
219
|
+
* see the module header's BLOCKER 1 section for why this, and not a context
|
|
220
|
+
* VALUE, is the lookup `GatewayHeadersPropagator` uses. Populated by
|
|
221
|
+
* `registerFlueSessionTrace`, pruned by `unregisterFlueSessionTrace`. A
|
|
222
|
+
* trace id collision here (two DIFFERENT registrations sharing one trace id)
|
|
223
|
+
* is not expected in practice — trace ids are SPF's deterministic per-adw_id
|
|
224
|
+
* ids — but if it ever happened, the later `registerFlueSessionTrace` call
|
|
225
|
+
* would simply win, matching this map's own "current call wins" rule.
|
|
119
226
|
*/
|
|
120
|
-
|
|
227
|
+
const traceIdRegistrations = new Map();
|
|
228
|
+
/**
|
|
229
|
+
* Parses the W3C `traceId` segment out of a `traceparent` string via a real
|
|
230
|
+
* `propagation.extract`/`getSpanContext` round trip — the SAME extraction
|
|
231
|
+
* `resolveFlueRootContext` performs, so "the trace id we index
|
|
232
|
+
* `traceIdRegistrations` under" and "the trace id a span parented via this
|
|
233
|
+
* `traceparent` actually carries" are provably the same value, not two
|
|
234
|
+
* independent parses that could drift. Returns `undefined` for a malformed
|
|
235
|
+
* `traceparent` (mirrors `resolveFlueRootContext`'s own malformed-input
|
|
236
|
+
* handling) rather than throwing.
|
|
237
|
+
*/
|
|
238
|
+
function traceIdFromTraceparent(traceparent) {
|
|
239
|
+
const extracted = propagation.extract(ROOT_CONTEXT, { traceparent }, defaultTextMapGetter);
|
|
240
|
+
const spanContext = traceApi.getSpanContext(extracted);
|
|
241
|
+
return spanContext && isSpanContextValid(spanContext) ? spanContext.traceId : undefined;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Registers `registration` (SPF's deterministic agent-call traceparent, plus
|
|
245
|
+
* this session's adw_id/agent name — see `FlueSessionRegistration`) as the
|
|
246
|
+
* trace root for flue spans belonging to `sessionId` — flue's instance id,
|
|
247
|
+
* minted by SPF and handed to `init(SfAgent, { id })`. Overwrites a prior
|
|
248
|
+
* registration for the same id (a same-phase retry is the same logical call;
|
|
249
|
+
* the current call wins). Also indexes the SAME identity under this
|
|
250
|
+
* registration's parsed trace id (`traceIdRegistrations`) — see the module
|
|
251
|
+
* header's BLOCKER 1 section and that map's own doc for why.
|
|
252
|
+
*/
|
|
253
|
+
export function registerFlueSessionTrace(sessionId, registration) {
|
|
254
|
+
flueSessionTraces.set(sessionId, registration);
|
|
255
|
+
const identity = { adwId: registration.adwId, agentName: registration.agentName };
|
|
256
|
+
const traceId = traceIdFromTraceparent(registration.traceparent);
|
|
257
|
+
if (traceId)
|
|
258
|
+
traceIdRegistrations.set(traceId, identity);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Injects `x-correlation-id`/`x-spf-agent` by looking up the ACTIVE
|
|
262
|
+
* `Context`'s SpanContext trace id (`trace.getSpanContext(ctx)?.traceId`) in
|
|
263
|
+
* `traceIdRegistrations` — the same per-real-HTTP-call injection point
|
|
264
|
+
* `W3CTraceContextPropagator` uses for `traceparent`, and, critically, keyed
|
|
265
|
+
* on the one piece of `resolveFlueRootContext`'s return value that provably
|
|
266
|
+
* survives into that active context (see the module header's BLOCKER 1
|
|
267
|
+
* section for why a context VALUE does not). Injects NOTHING — no fallback,
|
|
268
|
+
* no last-known identity — when this exact trace id is not in the map: an
|
|
269
|
+
* unregistered/unmapped span (flue's own bookkeeping spans, or ANY span
|
|
270
|
+
* after every session has been unregistered) must not stamp a stale or
|
|
271
|
+
* unrelated session's `x-correlation-id`/`x-spf-agent` onto unrelated
|
|
272
|
+
* outbound traffic (a third-party call, a post-session straggler). A true
|
|
273
|
+
* no-op (nothing set) in that case — never throws, never invents a value.
|
|
274
|
+
* Replaces the old `XRequestIdPropagator`:
|
|
275
|
+
* `x-request-id` must NEVER reach the gateway (Envoy/Switchyard own it
|
|
276
|
+
* end-to-end; a client-sent value corrupts their own sampling) and this repo
|
|
277
|
+
* no longer has anything that wants it emitted.
|
|
278
|
+
*/
|
|
279
|
+
export class GatewayHeadersPropagator {
|
|
121
280
|
inject(ctx, carrier, setter) {
|
|
122
281
|
const spanContext = traceApi.getSpanContext(ctx);
|
|
123
|
-
|
|
282
|
+
const registration = spanContext && traceIdRegistrations.get(spanContext.traceId);
|
|
283
|
+
if (!registration)
|
|
124
284
|
return;
|
|
125
|
-
|
|
285
|
+
if (registration.adwId)
|
|
286
|
+
setter.set(carrier, X_CORRELATION_ID_HEADER, registration.adwId);
|
|
287
|
+
if (registration.agentName)
|
|
288
|
+
setter.set(carrier, X_SPF_AGENT_HEADER, registration.agentName);
|
|
126
289
|
}
|
|
127
290
|
extract(ctx) {
|
|
128
291
|
return ctx;
|
|
129
292
|
}
|
|
130
293
|
fields() {
|
|
131
|
-
return [
|
|
294
|
+
return [X_CORRELATION_ID_HEADER, X_SPF_AGENT_HEADER];
|
|
132
295
|
}
|
|
133
296
|
}
|
|
134
|
-
/**
|
|
135
|
-
* Instance-id -> agent-call traceparent registrations backing
|
|
136
|
-
* `resolveFlueRootContext` — see the module header for the full mechanism
|
|
137
|
-
* and WHY this is a map consulted per span rather than a dispatch-time
|
|
138
|
-
* context wrap (flue's single claim loop makes the latter mis-attribute
|
|
139
|
-
* every agent after the first). One entry per in-flight `agent_flue.run()`
|
|
140
|
-
* call.
|
|
141
|
-
*/
|
|
142
|
-
const flueSessionTraces = new Map();
|
|
143
|
-
/**
|
|
144
|
-
* Registers `traceparent` (SPF's deterministic agent-call span, as a W3C
|
|
145
|
-
* carrier string) as the trace root for flue spans belonging to `sessionId`
|
|
146
|
-
* — flue's instance id, minted by SPF and handed to `init(SfAgent, { id })`.
|
|
147
|
-
* Overwrites a prior registration for the same id (a same-phase retry is
|
|
148
|
-
* the same logical call; the current call wins).
|
|
149
|
-
*/
|
|
150
|
-
export function registerFlueSessionTrace(sessionId, traceparent) {
|
|
151
|
-
flueSessionTraces.set(sessionId, traceparent);
|
|
152
|
-
}
|
|
153
297
|
/**
|
|
154
298
|
* Idempotent — called from `run()`'s `finally`. Post-settlement bookkeeping
|
|
155
299
|
* spans flue mints after this point simply resolve to an unparented root
|
|
156
300
|
* (separate trace), which is preferable to leaking a registration whose id
|
|
157
|
-
* a REUSED session id could collide with on a later phase.
|
|
301
|
+
* a REUSED session id could collide with on a later phase. Also prunes this
|
|
302
|
+
* session's entry out of `traceIdRegistrations` (parsed fresh from the
|
|
303
|
+
* departing registration's own `traceparent`, so it removes exactly the
|
|
304
|
+
* entry this session added.
|
|
158
305
|
*/
|
|
159
306
|
export function unregisterFlueSessionTrace(sessionId) {
|
|
307
|
+
const registration = flueSessionTraces.get(sessionId);
|
|
160
308
|
flueSessionTraces.delete(sessionId);
|
|
309
|
+
if (!registration)
|
|
310
|
+
return;
|
|
311
|
+
const traceId = traceIdFromTraceparent(registration.traceparent);
|
|
312
|
+
if (traceId)
|
|
313
|
+
traceIdRegistrations.delete(traceId);
|
|
161
314
|
}
|
|
162
315
|
/**
|
|
163
316
|
* The `resolveRootContext` implementation handed to
|
|
@@ -169,14 +322,23 @@ export function unregisterFlueSessionTrace(sessionId) {
|
|
|
169
322
|
* unparented root span of its own) for an unmapped session id, a malformed
|
|
170
323
|
* traceparent, an absent ctx — and, with no global propagator installed,
|
|
171
324
|
* for everything. Exported for tests.
|
|
325
|
+
*
|
|
326
|
+
* Used by `@flue/opentelemetry` ONLY as `tracer.startSpan`'s parent
|
|
327
|
+
* argument (see the module header's BLOCKER 1 section) — the value handed
|
|
328
|
+
* back here is never itself activated, so it carries no context VALUES for
|
|
329
|
+
* `GatewayHeadersPropagator` to read; `x-correlation-id`/`x-spf-agent` are
|
|
330
|
+
* instead looked up by trace id, via `traceIdRegistrations`, which
|
|
331
|
+
* `registerFlueSessionTrace` populates independently of this function.
|
|
172
332
|
*/
|
|
173
333
|
export function resolveFlueRootContext(_event, ctx) {
|
|
174
|
-
const
|
|
175
|
-
if (!
|
|
334
|
+
const registration = ctx?.id ? flueSessionTraces.get(ctx.id) : undefined;
|
|
335
|
+
if (!registration)
|
|
176
336
|
return undefined;
|
|
177
|
-
const extracted = propagation.extract(ROOT_CONTEXT, { traceparent }, defaultTextMapGetter);
|
|
337
|
+
const extracted = propagation.extract(ROOT_CONTEXT, { traceparent: registration.traceparent }, defaultTextMapGetter);
|
|
178
338
|
const spanContext = traceApi.getSpanContext(extracted);
|
|
179
|
-
|
|
339
|
+
if (!spanContext || !isSpanContextValid(spanContext))
|
|
340
|
+
return undefined;
|
|
341
|
+
return extracted;
|
|
180
342
|
}
|
|
181
343
|
let installed = false;
|
|
182
344
|
let flueInstrumented = false;
|
|
@@ -200,7 +362,7 @@ export function installFluePropagation(cfg, log = (m) => console.error(m)) {
|
|
|
200
362
|
});
|
|
201
363
|
traceApi.setGlobalTracerProvider(provider);
|
|
202
364
|
contextApi.setGlobalContextManager(new AsyncHooksContextManager().enable());
|
|
203
|
-
propagation.setGlobalPropagator(new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new
|
|
365
|
+
propagation.setGlobalPropagator(new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new GatewayHeadersPropagator()] }));
|
|
204
366
|
registerInstrumentations({ instrumentations: [new HttpInstrumentation(), new UndiciInstrumentation()] });
|
|
205
367
|
// Set AFTER the fallible registrations above: on a construction-time
|
|
206
368
|
// throw, the next call must be free to retry — latching `installed`
|
|
@@ -223,3 +385,17 @@ export function resetFluePropagationForTest() {
|
|
|
223
385
|
installed = false;
|
|
224
386
|
flueInstrumented = false;
|
|
225
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* Whether `installFluePropagation` has actually installed the global
|
|
390
|
+
* TracerProvider/propagator/instrumentations in THIS process. Consulted by
|
|
391
|
+
* `ollama_provider.ts` (see its "Gateway headers" section) so `resolve()`/
|
|
392
|
+
* `modelFor()` know whether the instrumentation above already covers
|
|
393
|
+
* `traceparent`/`x-correlation-id`/`x-spf-agent` per real outbound call —
|
|
394
|
+
* supplying any of the three a second way when this is `true` would double
|
|
395
|
+
* it up on the wire (`UndiciInstrumentation` appends via `addHeader`, it
|
|
396
|
+
* does not replace). `false` — the common case, otel unconfigured — means
|
|
397
|
+
* `ollama_provider.ts` must supply all three itself.
|
|
398
|
+
*/
|
|
399
|
+
export function isFluePropagationInstalled() {
|
|
400
|
+
return installed;
|
|
401
|
+
}
|
|
@@ -73,6 +73,21 @@ export declare function permitted(p: string, agent: AgentConfig, cfg: SFConfig):
|
|
|
73
73
|
* Detection alone would leave the repo holding the unauthorized change while
|
|
74
74
|
* reporting a failure, so anything the agent introduced outside its allowlist
|
|
75
75
|
* is rolled back before the phase dies. What it cannot undo, it names.
|
|
76
|
+
*
|
|
77
|
+
* `defaults.read_only_ignore` carves out one exception to "names, fails" —
|
|
78
|
+
* but only where `isSafeToIgnore` says restoring is actually possible (see
|
|
79
|
+
* its own doc comment): a TRUE read-only agent (`writes: []`) that churned
|
|
80
|
+
* a lockfile that was CLEAN before this phase. That one case is STILL
|
|
81
|
+
* rolled back unconditionally like any other breach — an ignored path is
|
|
82
|
+
* never left standing — it just does not, on its own, fail the phase.
|
|
83
|
+
* Everything else `isIgnorableChurn` alone would have matched (a dirty-before
|
|
84
|
+
* path, an agent that reverted uncommitted work, a write-restricted rather
|
|
85
|
+
* than read-only agent) falls through to the real-breach path below instead.
|
|
86
|
+
* `onIgnored`, when given, is called once with every safely-ignored path
|
|
87
|
+
* before returning, so a caller with a logger (`agents.ts`'s `execute()`)
|
|
88
|
+
* can print the one required info line — this module has no logger of its
|
|
89
|
+
* own to call (`RunLike` is only `repo_root`/`cfg`), so the caller does the
|
|
90
|
+
* printing.
|
|
76
91
|
*/
|
|
77
|
-
export declare function enforce(run: RunLike, _phase: unknown, agent: AgentConfig, before: Record<string, string
|
|
92
|
+
export declare function enforce(run: RunLike, _phase: unknown, agent: AgentConfig, before: Record<string, string>, onIgnored?: (paths: string[]) => void): string[];
|
|
78
93
|
export {};
|
package/dist/core/permissions.js
CHANGED
|
@@ -76,6 +76,19 @@ export function changedPaths(before, after) {
|
|
|
76
76
|
function globToRegex(pattern) {
|
|
77
77
|
let out = "";
|
|
78
78
|
let i = 0;
|
|
79
|
+
// A LEADING "**/" is the common "any depth, including the repo root"
|
|
80
|
+
// idiom (gitignore, npm, ...) — "**/package-lock.json" must match both a
|
|
81
|
+
// root-level "package-lock.json" and a nested "a/b/package-lock.json".
|
|
82
|
+
// The plain "**" -> ".*" rule below (still applied to a "**" anywhere else
|
|
83
|
+
// in a pattern) cannot express that alone: ".*" still requires the
|
|
84
|
+
// literal "/" that follows it in the pattern text, so a root-level file
|
|
85
|
+
// with no directory prefix would never match. Only this one leading shape
|
|
86
|
+
// gets the optional-prefix translation; `defaults.read_only_ignore`'s own
|
|
87
|
+
// default patterns are exactly this shape.
|
|
88
|
+
if (pattern.startsWith("**/")) {
|
|
89
|
+
out += "(?:.*/)?";
|
|
90
|
+
i = 3;
|
|
91
|
+
}
|
|
79
92
|
while (i < pattern.length) {
|
|
80
93
|
const char = pattern[i];
|
|
81
94
|
if (pattern.startsWith("**", i)) {
|
|
@@ -160,6 +173,58 @@ function rollBack(run, p, before, after) {
|
|
|
160
173
|
const result = spawnSync("git", ["checkout", "--", p], { cwd: run.repo_root, encoding: "utf-8" });
|
|
161
174
|
return result.status === 0 ? "rolled back" : "could not roll back";
|
|
162
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* True when `p` matches one of `defaults.read_only_ignore`'s patterns —
|
|
178
|
+
* dependency-manager bookkeeping (a lockfile), not the repo's intent. See
|
|
179
|
+
* that field's own doc comment (`data_types.ts`) for why it exists and what
|
|
180
|
+
* rolling one back "silently" means. Necessary but not sufficient — see
|
|
181
|
+
* `isSafeToIgnore`, which is what `enforce()` actually gates on, and which
|
|
182
|
+
* ALSO requires the path not match `defaults.protected_files`:
|
|
183
|
+
* `read_only_ignore` is a narrow "this churn is incidental, not intent"
|
|
184
|
+
* carve-out, and must never be read as a backdoor around a path an operator
|
|
185
|
+
* explicitly locked down. `protected_files` always wins — a path listed
|
|
186
|
+
* there is never ignorable via `read_only_ignore`, regardless of role.
|
|
187
|
+
*/
|
|
188
|
+
function isIgnorableChurn(p, cfg) {
|
|
189
|
+
return (cfg.defaults.read_only_ignore ?? []).some((pattern) => matches(p, pattern));
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Whether an ignorable-churn path is actually safe to ignore for THIS phase.
|
|
193
|
+
* Both conditions are required, and either one failing means: real breach,
|
|
194
|
+
* `rollBack`'s own honest outcome string, no silent "ignored".
|
|
195
|
+
*
|
|
196
|
+
* - The agent is a TRUE read-only role (`writes: []`), not merely
|
|
197
|
+
* write-restricted (`writes: [...]`). A write-restricted agent that
|
|
198
|
+
* changes `package.json` and its lockfile together left an
|
|
199
|
+
* INCONSISTENT tree, not incidental dependency-manager bookkeeping —
|
|
200
|
+
* `read_only_ignore` exists for the read-only case this module's header
|
|
201
|
+
* describes (a read that happens to rewrite a lockfile), never as a
|
|
202
|
+
* blanket exemption for one specific file pattern regardless of role.
|
|
203
|
+
* - The path was CLEAN before this phase started (`!(p in before)`). Only
|
|
204
|
+
* then does `rollBack` below actually take the "not in before" branch
|
|
205
|
+
* and restore it (delete the untracked file, or `git checkout --` the
|
|
206
|
+
* tracked one back to HEAD). A path that was ALREADY dirty when the
|
|
207
|
+
* agent started hits `rollBack`'s OTHER branch instead — "left as-is"
|
|
208
|
+
* or, if the agent discarded that uncommitted work, "REVERTED-BY-AGENT
|
|
209
|
+
* (uncommitted work lost, cannot restore)" — and neither of those is a
|
|
210
|
+
* restore. Calling that "ignored" would report a repair that never
|
|
211
|
+
* happened; it must fail the phase like any other breach instead.
|
|
212
|
+
* - The path does NOT match `defaults.protected_files`. `protected_files`
|
|
213
|
+
* is what made this a breach in the first place (`permitted()` above);
|
|
214
|
+
* `read_only_ignore` matching the SAME path too is not a stronger claim
|
|
215
|
+
* that the write was safe, it just means an operator's lockfile-churn
|
|
216
|
+
* pattern happens to overlap a path they explicitly protected. Without
|
|
217
|
+
* this check a `read_only_ignore` entry could silently exempt a
|
|
218
|
+
* read-only agent from `protected_files` — the very thing `protected_files`
|
|
219
|
+
* exists to prevent regardless of an agent's `writes` role. So a
|
|
220
|
+
* protected path is never ignorable: it always falls through to the
|
|
221
|
+
* real-breach path below, still rolled back, but failing the phase.
|
|
222
|
+
*/
|
|
223
|
+
function isSafeToIgnore(p, agent, cfg, before) {
|
|
224
|
+
const isTrueReadOnlyAgent = Array.isArray(agent.writes) && agent.writes.length === 0;
|
|
225
|
+
const isProtected = cfg.defaults.protected_files.some((pattern) => matches(p, pattern));
|
|
226
|
+
return isTrueReadOnlyAgent && isIgnorableChurn(p, cfg) && !(p in before) && !isProtected;
|
|
227
|
+
}
|
|
163
228
|
/**
|
|
164
229
|
* Compare the tree against `before`; undo and raise if the agent overstepped.
|
|
165
230
|
*
|
|
@@ -169,19 +234,42 @@ function rollBack(run, p, before, after) {
|
|
|
169
234
|
* Detection alone would leave the repo holding the unauthorized change while
|
|
170
235
|
* reporting a failure, so anything the agent introduced outside its allowlist
|
|
171
236
|
* is rolled back before the phase dies. What it cannot undo, it names.
|
|
237
|
+
*
|
|
238
|
+
* `defaults.read_only_ignore` carves out one exception to "names, fails" —
|
|
239
|
+
* but only where `isSafeToIgnore` says restoring is actually possible (see
|
|
240
|
+
* its own doc comment): a TRUE read-only agent (`writes: []`) that churned
|
|
241
|
+
* a lockfile that was CLEAN before this phase. That one case is STILL
|
|
242
|
+
* rolled back unconditionally like any other breach — an ignored path is
|
|
243
|
+
* never left standing — it just does not, on its own, fail the phase.
|
|
244
|
+
* Everything else `isIgnorableChurn` alone would have matched (a dirty-before
|
|
245
|
+
* path, an agent that reverted uncommitted work, a write-restricted rather
|
|
246
|
+
* than read-only agent) falls through to the real-breach path below instead.
|
|
247
|
+
* `onIgnored`, when given, is called once with every safely-ignored path
|
|
248
|
+
* before returning, so a caller with a logger (`agents.ts`'s `execute()`)
|
|
249
|
+
* can print the one required info line — this module has no logger of its
|
|
250
|
+
* own to call (`RunLike` is only `repo_root`/`cfg`), so the caller does the
|
|
251
|
+
* printing.
|
|
172
252
|
*/
|
|
173
|
-
export function enforce(run, _phase, agent, before) {
|
|
253
|
+
export function enforce(run, _phase, agent, before, onIgnored) {
|
|
174
254
|
const after = snapshot(run);
|
|
175
255
|
const touched = changedPaths(before, after);
|
|
176
256
|
const breaches = touched.filter((p) => !permitted(p, agent, run.cfg));
|
|
177
257
|
if (breaches.length === 0)
|
|
178
258
|
return touched;
|
|
259
|
+
const ignored = breaches.filter((p) => isSafeToIgnore(p, agent, run.cfg, before));
|
|
260
|
+
const realBreaches = breaches.filter((p) => !ignored.includes(p));
|
|
261
|
+
// Roll back EVERY breach, ignored ones included — restoring the tree is
|
|
262
|
+
// unconditional; only whether it fails the PHASE differs below.
|
|
179
263
|
const outcomes = new Map(breaches.map((p) => [p, rollBack(run, p, before, after)]));
|
|
264
|
+
if (ignored.length > 0)
|
|
265
|
+
onIgnored?.(ignored);
|
|
266
|
+
if (realBreaches.length === 0)
|
|
267
|
+
return touched; // nothing left but ignored churn — rolled back, phase still passes
|
|
180
268
|
const scope = agent.writes && agent.writes.length === 0
|
|
181
269
|
? "read-only"
|
|
182
270
|
: agent.writes
|
|
183
271
|
? `limited to ${JSON.stringify(agent.writes)}`
|
|
184
272
|
: `barred from ${JSON.stringify(run.cfg.defaults.protected_files)}`;
|
|
185
|
-
const detail =
|
|
186
|
-
throw new PermissionBreach(`${agent.name} is ${scope} but modified ${
|
|
273
|
+
const detail = realBreaches.map((p) => ` - ${p} — ${outcomes.get(p)}`).join("\n");
|
|
274
|
+
throw new PermissionBreach(`${agent.name} is ${scope} but modified ${realBreaches.length} path(s):\n${detail}`);
|
|
187
275
|
}
|
package/dist/core/providers.js
CHANGED
|
@@ -21,9 +21,14 @@ export const PROVIDER_ENV_KEYS = {
|
|
|
21
21
|
deepseek: ["DEEPSEEK_API_KEY"],
|
|
22
22
|
together: ["TOGETHER_API_KEY"],
|
|
23
23
|
cerebras: ["CEREBRAS_API_KEY"],
|
|
24
|
-
// Keyless: a local server, not a hosted API — nothing to
|
|
25
|
-
// prompt for. An empty array here means "known provider,
|
|
26
|
-
// never "unknown provider" (that's a missing table entry,
|
|
24
|
+
// Keyless by default: a local server, not a hosted API — nothing to
|
|
25
|
+
// require or prompt for. An empty array here means "known provider,
|
|
26
|
+
// needs no key", never "unknown provider" (that's a missing table entry,
|
|
27
|
+
// not `[]`). NOT the same as "no key is ever honored": ollama_provider.ts's
|
|
28
|
+
// `ollamaApiKey()` reads `OLLAMA_API_KEY` and sends it as the bearer when
|
|
29
|
+
// set (e.g. required by a gateway in front of Ollama, like Envoy AI
|
|
30
|
+
// Gateway), falling back to its dummy placeholder otherwise — optional,
|
|
31
|
+
// not required, which is why it stays out of this required-keys table.
|
|
27
32
|
ollama: [],
|
|
28
33
|
// Cloudflare Workers AI — a real Bearer token (NOT keyless like ollama;
|
|
29
34
|
// Cloudflare's API 401s on an empty Authorization header). The base URL
|
package/dist/core/refine.js
CHANGED
|
@@ -41,7 +41,7 @@ export function resolveAuthoringProvider(cfg) {
|
|
|
41
41
|
if (!email || !token) {
|
|
42
42
|
throw new Error('JIRA_EMAIL and JIRA_API_TOKEN must both be set — the refine lane needs an Atlassian account email plus an API token (id.atlassian.com -> Security -> API tokens)');
|
|
43
43
|
}
|
|
44
|
-
return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types, cfg.watch.jira.status_map);
|
|
44
|
+
return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types, cfg.watch.jira.status_map, cfg.watch.jira.link_type);
|
|
45
45
|
}
|
|
46
46
|
if (cfg.watch.issue_provider !== "github") {
|
|
47
47
|
throw new Error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} does not support issue authoring — the refine lane needs "github" or "jira"`);
|
|
@@ -232,6 +232,18 @@ export async function publish(tracker, issues, opts) {
|
|
|
232
232
|
if (parent)
|
|
233
233
|
await tracker.linkChild(parent.issue, issue);
|
|
234
234
|
}
|
|
235
|
+
else if (opts.specIssueId) {
|
|
236
|
+
// THE GAP THIS CLOSES: `node.parent` only ever names another node
|
|
237
|
+
// IN THIS TREE — the tree's own root(s) have none, so the branch
|
|
238
|
+
// above never runs for them, and nothing else in this loop connects
|
|
239
|
+
// a root back to the spec it was refined FROM. Before this, the only
|
|
240
|
+
// trace of that relationship was `renderBody`'s "## Parent" TEXT
|
|
241
|
+
// (still rendered, unchanged) — real on GitHub (auto-linked "#N"),
|
|
242
|
+
// invisible on Jira (plain text, no cross-reference). `linkToSpec` is
|
|
243
|
+
// optional and best-effort on purpose — see its own doc comment
|
|
244
|
+
// (`issues/provider.ts`) for why this is never `linkChild`.
|
|
245
|
+
await tracker.linkToSpec?.(opts.specIssueId, issue);
|
|
246
|
+
}
|
|
235
247
|
}
|
|
236
248
|
return created;
|
|
237
249
|
}
|
package/dist/core/runner.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { type GitHandle } from "./git_helper.ts";
|
|
12
12
|
import { Console, type RunObserver } from "./console.ts";
|
|
13
13
|
import { Tracer } from "./tracer.ts";
|
|
14
|
-
import { type AgentCall, type EnvelopeBase, type Phase, type PhaseParams, type SFConfig } from "./data_types.ts";
|
|
14
|
+
import { type AgentCall, type AgentConfig, type EnvelopeBase, type Phase, type PhaseParams, type SFConfig } from "./data_types.ts";
|
|
15
15
|
import type { TierResolution } from "./tiering.ts";
|
|
16
16
|
import type { Notifier } from "./notify/notifier.ts";
|
|
17
17
|
interface AgentMapEntry {
|
|
@@ -60,6 +60,30 @@ export declare class Run {
|
|
|
60
60
|
phases: Phase[];
|
|
61
61
|
tokens: number;
|
|
62
62
|
cost: number;
|
|
63
|
+
/** The BILLABLE half of `tokens` — see `UsageBreakdown.billable_tokens`'s doc comment. What `assertRunBudget` actually checks `defaults.max_run_tokens` against; `tokens` stays the display total. */
|
|
64
|
+
billable_tokens: number;
|
|
65
|
+
/**
|
|
66
|
+
* True once this run has DISPATCHED at least one `claude_code` agent
|
|
67
|
+
* while `ANTHROPIC_BASE_URL` pointed somewhere other than Anthropic's own
|
|
68
|
+
* API — i.e. a gateway/proxy stood in for Anthropic on a call that
|
|
69
|
+
* actually happened. Read by `Console.sessionFinished` to label the run's
|
|
70
|
+
* printed cost as an estimate rather than a fact: `claude`'s own
|
|
71
|
+
* `total_cost_usd` is ANTHROPIC's price table applied to whatever the CLI
|
|
72
|
+
* thinks it called, which is honest only when Anthropic itself served the
|
|
73
|
+
* request.
|
|
74
|
+
*
|
|
75
|
+
* Starts `false` and is flipped by `recordDispatch()` below, called once
|
|
76
|
+
* per agent dispatch (`agents.ts`'s `execute()`, right before the real
|
|
77
|
+
* coding-agent call) — computed from what this run actually DISPATCHED,
|
|
78
|
+
* never from the roster's static shape (see `agents.ts`'s
|
|
79
|
+
* `isGatewayEstimatedDispatch` vs. `isGatewayEstimatedCost` doc comments
|
|
80
|
+
* for why that distinction matters: a chain can configure a `claude_code`
|
|
81
|
+
* agent it never actually calls this run, and labeling a real cost as
|
|
82
|
+
* "estimated" because the roster merely CONTAINS such an agent would be
|
|
83
|
+
* its own kind of dishonesty). Sticky: once a qualifying dispatch has
|
|
84
|
+
* happened, a later non-qualifying one must never flip it back to false.
|
|
85
|
+
*/
|
|
86
|
+
cost_is_estimate: boolean;
|
|
63
87
|
repo_root: string;
|
|
64
88
|
/** Every git operation for this run, bound to repo_root. Never call git_helper directly. */
|
|
65
89
|
git: GitHandle;
|
|
@@ -81,7 +105,14 @@ export declare class Run {
|
|
|
81
105
|
private agentMapPath;
|
|
82
106
|
constructor(init: RunInit);
|
|
83
107
|
saveAgentMap(agent: string, entry: AgentMapEntry): void;
|
|
84
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Called once per agent dispatch, right before the real coding-agent call
|
|
110
|
+
* (`agents.ts`'s `execute()`) — see `cost_is_estimate`'s own doc comment
|
|
111
|
+
* for why this, not the roster, is what decides the label. Sticky: only
|
|
112
|
+
* ever flips `cost_is_estimate` from false to true, never back.
|
|
113
|
+
*/
|
|
114
|
+
recordDispatch(agent: AgentConfig): void;
|
|
115
|
+
addUsage(tokens: number, cost: number, billableTokens: number): Promise<void>;
|
|
85
116
|
phase<T>(params: PhaseParams, fn: (ph: PhaseHandle) => Promise<T>): Promise<T>;
|
|
86
117
|
/**
|
|
87
118
|
* Finalize the run and return its exit code. Call this exactly once.
|