in2-agent-server 0.20260821.185429
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 +369 -0
- package/agents/plan-requirement.md +32 -0
- package/dist/campfire/configItem.js +201 -0
- package/dist/campfire/configItemDescription.js +379 -0
- package/dist/campfire/deriveFromBundle.js +1013 -0
- package/dist/cli.js +465 -0
- package/dist/core/betaFeedback.js +493 -0
- package/dist/core/chapterInstructions.js +473 -0
- package/dist/core/claudeAssets.js +61 -0
- package/dist/core/gate.js +201 -0
- package/dist/core/ids.js +9 -0
- package/dist/core/install.js +60 -0
- package/dist/core/operatorQa.js +462 -0
- package/dist/core/orgCallMeter.js +178 -0
- package/dist/core/parcels.js +1235 -0
- package/dist/core/paths.js +105 -0
- package/dist/core/phases.js +225 -0
- package/dist/core/planFile.js +251 -0
- package/dist/core/planFragments.js +119 -0
- package/dist/core/readsLog.js +398 -0
- package/dist/core/registration.js +24 -0
- package/dist/core/result.js +38 -0
- package/dist/core/resume.js +233 -0
- package/dist/core/revisionChain.js +351 -0
- package/dist/core/richText.js +61 -0
- package/dist/core/semanticHash.js +89 -0
- package/dist/core/sessionOrg.js +60 -0
- package/dist/core/sfQuery.js +19 -0
- package/dist/core/spawn.js +182 -0
- package/dist/core/supervisorContract.js +230 -0
- package/dist/core/telemetry.js +1297 -0
- package/dist/core/telemetryContext.js +154 -0
- package/dist/core/telemetrySpool.js +244 -0
- package/dist/core/transcriptUpload.js +516 -0
- package/dist/core/update.js +363 -0
- package/dist/core/viewer.js +117 -0
- package/dist/core/workspace.js +92 -0
- package/dist/core/writeGrants.js +93 -0
- package/dist/core/writebackManifest.js +92 -0
- package/dist/direct/applyLoop.js +2516 -0
- package/dist/direct/batchReport.js +510 -0
- package/dist/direct/comparatorNoise.js +75 -0
- package/dist/direct/containers.js +85 -0
- package/dist/direct/contamination.js +221 -0
- package/dist/direct/crud.js +56 -0
- package/dist/direct/destructive.js +168 -0
- package/dist/direct/knownTokens.js +94 -0
- package/dist/direct/lint.js +382 -0
- package/dist/direct/metadataSchema.generated.json +1 -0
- package/dist/direct/metadataSchema.js +179 -0
- package/dist/direct/operatorPage.js +349 -0
- package/dist/direct/orgRepo.js +1531 -0
- package/dist/direct/orgRepoSpawnCount.js +21 -0
- package/dist/direct/plannedScope.js +119 -0
- package/dist/direct/preImageTargets.js +63 -0
- package/dist/direct/refVerify.js +191 -0
- package/dist/direct/registry.js +129 -0
- package/dist/direct/removals.js +262 -0
- package/dist/direct/rollback.js +1513 -0
- package/dist/direct/rollbackRun.js +1360 -0
- package/dist/direct/sessionProjection.js +126 -0
- package/dist/direct/snapshotComparability.js +165 -0
- package/dist/direct/sourceTree.js +113 -0
- package/dist/direct/unstatedChanges.js +106 -0
- package/dist/direct/waveAssembly.js +556 -0
- package/dist/direct/waveCounts.js +19 -0
- package/dist/direct/waveDerivation.js +152 -0
- package/dist/direct/waveGrade.js +185 -0
- package/dist/index.js +90 -0
- package/dist/orgread/customFieldSnapshot.js +48 -0
- package/dist/orgread/fieldMetadata.js +178 -0
- package/dist/orgread/genericSnapshot.js +61 -0
- package/dist/orgread/layoutSnapshot.js +151 -0
- package/dist/orgread/salesforceOrgReader.js +184 -0
- package/dist/orgread/snapshotTypes.js +9 -0
- package/dist/projection/bridge.js +26 -0
- package/dist/projection/diff.js +141 -0
- package/dist/projection/mapmd.js +213 -0
- package/dist/projection/nameAmbiguity.js +106 -0
- package/dist/projection/precedent.js +136 -0
- package/dist/projection/project.js +214 -0
- package/dist/projection/scope.js +110 -0
- package/dist/projection/types.js +5 -0
- package/dist/tools/analyze.js +322 -0
- package/dist/tools/auth.js +915 -0
- package/dist/tools/campfire.js +1742 -0
- package/dist/tools/closeBatch.js +154 -0
- package/dist/tools/closeText.js +35 -0
- package/dist/tools/direct.js +1613 -0
- package/dist/tools/feedback.js +318 -0
- package/dist/tools/operator.js +287 -0
- package/dist/tools/org.js +739 -0
- package/dist/tools/phase.js +249 -0
- package/dist/tools/projection.js +172 -0
- package/dist/tools/session.js +313 -0
- package/dist/tools/update.js +82 -0
- package/dist/tools/waves.js +793 -0
- package/package.json +48 -0
- package/reference/campfire-configuration-items-guide.md +710 -0
- package/reference/deployability-cribs.md +119 -0
- package/reference/operator-voice.md +141 -0
- package/reference/org-read-cribs.md +34 -0
- package/reference/scoped-dml-interim.md +68 -0
- package/reference/supervisor-playbook.md +313 -0
- package/reference/templates.md +233 -0
- package/skills/in2-delivery/SKILL.md +322 -0
- package/skills/in2-delivery/references/campfire-configuration-items-guide.md +710 -0
- package/skills/in2-delivery/references/deployability-cribs.md +119 -0
- package/skills/in2-delivery/references/operator-voice.md +141 -0
- package/skills/in2-delivery/references/org-read-cribs.md +34 -0
- package/skills/in2-delivery/references/scoped-dml-interim.md +68 -0
- package/skills/in2-delivery/references/templates.md +233 -0
- package/skills/plan-requirement/SKILL.md +188 -0
- package/viewer/index.html +899 -0
- package/viewer/server.mjs +433 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { REPO_ROOT, harnessVersion } from "./paths.js";
|
|
4
|
+
import { currentBatchContext } from "./telemetryContext.js";
|
|
5
|
+
import { TelemetryGrantStore, TelemetrySpool } from "./telemetrySpool.js";
|
|
6
|
+
// THE BETA FEEDBACK CHANNEL — what the run was LIKE, from both voices in it.
|
|
7
|
+
//
|
|
8
|
+
// The ledger records what the system DID. It has no channel for where the model got tripped up
|
|
9
|
+
// (a tool failure it worked around, a doc that misled it, two instructions that conflicted) or how
|
|
10
|
+
// the operator experienced the batch. Both currently evaporate at session end, or survive only as
|
|
11
|
+
// prose buried in a transcript blob nobody queries — so the same tripwire gets rediscovered one
|
|
12
|
+
// support conversation at a time. See docs/plans/2026-08-03-005-beta-feedback-channel.md.
|
|
13
|
+
//
|
|
14
|
+
// §9 added two more rows to the same table and the same outbox: the operator's estimate of what the
|
|
15
|
+
// batch would have cost them BY HAND (asked before execution, so it is a counterfactual rather than
|
|
16
|
+
// hindsight) and the sampled relational answer. Both are operator-voiced and both are their own
|
|
17
|
+
// kind — see `FeedbackKind` for why they are not extra columns on the close-time row.
|
|
18
|
+
//
|
|
19
|
+
// WHY IT IS NOT AN EVENT. Feedback is prose. `convex/lib/events.ts` allowlists every payload key and
|
|
20
|
+
// admits none, and widening PAYLOAD_KEYS to carry a friction report would end the shape-only
|
|
21
|
+
// property of the event stream for every consumer of it, permanently, to answer one question.
|
|
22
|
+
// `transcripts` set the precedent, `parcels` extended it, `operatorQa` made it three: content gets
|
|
23
|
+
// its own route, its own table and a stated reason. This is the fourth, and the event stream is
|
|
24
|
+
// untouched.
|
|
25
|
+
//
|
|
26
|
+
// A FEEDBACK ROW IS A SELF-REPORT. A model that misdiagnosed a failure will misdescribe it
|
|
27
|
+
// fluently. Ground truth stays with the transcript and the event stream; batchId is what keeps every
|
|
28
|
+
// row joinable to both, and the trip-up board is meant to be read BESIDE the failure board, never
|
|
29
|
+
// instead of it.
|
|
30
|
+
//
|
|
31
|
+
// FAIL-OPEN, like every other reporting path here: nothing in this module may fail, delay or mask a
|
|
32
|
+
// delivery. Every entry point returns a result instead of throwing.
|
|
33
|
+
/** Bumped when the wire shape changes; the ledger refuses a version it does not know. */
|
|
34
|
+
export const FEEDBACK_WIRE_VERSION = 1;
|
|
35
|
+
export const FEEDBACK_ROUTE = "/api/feedback/v1";
|
|
36
|
+
/**
|
|
37
|
+
* OFF UNLESS THE INSTALL SAYS OTHERWISE — the one place this family of switches is inverted.
|
|
38
|
+
*
|
|
39
|
+
* `CC_MCP_TRANSCRIPT_UPLOAD`, `CC_MCP_PARCEL_UPLOAD` and `CC_MCP_OPERATOR_QA_UPLOAD` all treat an
|
|
40
|
+
* unset variable as ON, because each of them ships something every install should be sending. This
|
|
41
|
+
* one is a BETA capability: it adds two tools and a section to the operating contract, and an
|
|
42
|
+
* install that never opted in must look exactly like one that predates the feature.
|
|
43
|
+
*
|
|
44
|
+
* `in2 setup` writes `CC_MCP_BETA=1` into the `claude mcp add` registration env (src/cli.ts), so
|
|
45
|
+
* the flag travels with the install and survives every npm update — flipping the eventual default
|
|
46
|
+
* is one setup change, not a server release.
|
|
47
|
+
*
|
|
48
|
+
* The DISABLED set is the same generous one transcript upload uses, for the same reason: an operator
|
|
49
|
+
* who reaches for `CC_MCP_BETA=false` to turn this off must actually turn it off.
|
|
50
|
+
*/
|
|
51
|
+
const DISABLED_VALUES = new Set(["0", "false", "off", "no"]);
|
|
52
|
+
export function betaEnabled(env = process.env) {
|
|
53
|
+
const raw = env.CC_MCP_BETA?.trim().toLowerCase();
|
|
54
|
+
if (raw === undefined || raw === "")
|
|
55
|
+
return false;
|
|
56
|
+
return !DISABLED_VALUES.has(raw);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* OFF BY DEFAULT — the test-corpus authoring tools, which no operator delivery ever calls.
|
|
60
|
+
*
|
|
61
|
+
* `create_campfire_requirements` and `mirror_requirements` exist to stand up and clone the CF-series
|
|
62
|
+
* MASTER CORPORA we run the product against: authoring a new requirement set, then mirroring it
|
|
63
|
+
* under a fresh token so two runs differ only by the build under test. Neither has a role in
|
|
64
|
+
* delivering a real client's batch — a consultant session never authors requirements, it reads the
|
|
65
|
+
* ones Campfire sent.
|
|
66
|
+
*
|
|
67
|
+
* Registering them anyway is not free. A tool definition is resident in EVERY context the server is
|
|
68
|
+
* connected to, before turn one, and these two are ~1.5 KB of description plus their schemas — paid
|
|
69
|
+
* by every delivery session and every analyzer subagent fanned out from one, all of which are
|
|
70
|
+
* forbidden to write to the consultant org in the first place.
|
|
71
|
+
*
|
|
72
|
+
* Same shape and same disabled-value set as `betaEnabled` above, and the same reason for the
|
|
73
|
+
* inversion: a capability an install did not ask for must be indistinguishable from one that
|
|
74
|
+
* predates it. Set `CC_MCP_CORPUS_TOOLS=1` on the registration when the install IS a test harness.
|
|
75
|
+
* The write gate is unchanged and still applies on top — this decides only whether the model is
|
|
76
|
+
* told the tools exist.
|
|
77
|
+
*/
|
|
78
|
+
export function corpusToolsEnabled(env = process.env) {
|
|
79
|
+
const raw = env.CC_MCP_CORPUS_TOOLS?.trim().toLowerCase();
|
|
80
|
+
if (raw === undefined || raw === "")
|
|
81
|
+
return false;
|
|
82
|
+
return !DISABLED_VALUES.has(raw);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Whether anything may LEAVE the machine on this channel.
|
|
86
|
+
*
|
|
87
|
+
* `CC_MCP_TELEMETRY=0` takes the feedback channel with it, and that is stated here rather than left
|
|
88
|
+
* to be true by accident. It would in fact be true by accident — with telemetry off there is no
|
|
89
|
+
* batch grant, and the grant is the only thing that can attribute an upload — but "one switch that
|
|
90
|
+
* stops all egress" is the behaviour an operator reaching for that variable expects, and an
|
|
91
|
+
* expectation that holds only as a side effect of the credential plumbing is one refactor away from
|
|
92
|
+
* being false.
|
|
93
|
+
*
|
|
94
|
+
* Note what this does NOT gate: the tools themselves, and the contract section, are gated on
|
|
95
|
+
* `betaEnabled` alone. A telemetry-disabled install in beta can still record feedback; it simply
|
|
96
|
+
* spools it and never ships it, and `flushFeedback` says so.
|
|
97
|
+
*/
|
|
98
|
+
export function feedbackUploadEnabled(env = process.env) {
|
|
99
|
+
if (env.CC_MCP_TELEMETRY === "0")
|
|
100
|
+
return false;
|
|
101
|
+
return betaEnabled(env);
|
|
102
|
+
}
|
|
103
|
+
let announced = false;
|
|
104
|
+
/** Test seam — the once-guard is module state, and a suite asserting the line needs it reset. */
|
|
105
|
+
export function resetBetaAnnouncementForTests() {
|
|
106
|
+
announced = false;
|
|
107
|
+
}
|
|
108
|
+
const defaultLog = (line) => void process.stderr.write(`[cc-mcp] ${line}\n`);
|
|
109
|
+
/**
|
|
110
|
+
* Say it out loud at startup, once, whenever beta mode is on.
|
|
111
|
+
*
|
|
112
|
+
* Modelled on `transcriptUpload.announce`, and for the same reason: a machine in beta mode has two
|
|
113
|
+
* extra tools and an extra section of instructions, and must never be mistakable for one that does
|
|
114
|
+
* not. This is also the only disclosure that the model will be filing reports at all — the operator
|
|
115
|
+
* consented to telemetry and transcripts, not to a channel they cannot see being written to.
|
|
116
|
+
*/
|
|
117
|
+
export function announceBeta(opts = {}) {
|
|
118
|
+
if (announced)
|
|
119
|
+
return;
|
|
120
|
+
const env = opts.env ?? process.env;
|
|
121
|
+
if (!betaEnabled(env))
|
|
122
|
+
return;
|
|
123
|
+
announced = true;
|
|
124
|
+
const write = opts.log ?? defaultLog;
|
|
125
|
+
if (!feedbackUploadEnabled(env)) {
|
|
126
|
+
write("BETA MODE IS ON (CC_MCP_BETA) — the feedback tools are registered, but CC_MCP_TELEMETRY=0 means nothing leaves this machine. " +
|
|
127
|
+
"Anything recorded stays in the local outbox.");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
write("BETA MODE IS ON (CC_MCP_BETA). This session can send IN2 two extra things: the model's own reports on where it got " +
|
|
131
|
+
"tripped up — tool errors, doc gaps, conflicting instructions, never your org's content — and, if you answer them, your " +
|
|
132
|
+
`ratings and your own words about how the batch went. Both go to ${opts.endpoint ?? "the configured endpoint"}. ` +
|
|
133
|
+
"Set CC_MCP_BETA=0 to turn the whole channel off, or CC_MCP_TELEMETRY=0 to stop everything leaving this machine.");
|
|
134
|
+
}
|
|
135
|
+
/** Same host, same grant, different route — derived exactly as the other three endpoints are. */
|
|
136
|
+
export function feedbackEndpointFrom(telemetryEndpoint) {
|
|
137
|
+
try {
|
|
138
|
+
const url = new URL(telemetryEndpoint);
|
|
139
|
+
url.pathname = FEEDBACK_ROUTE;
|
|
140
|
+
return url.toString();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return telemetryEndpoint.replace(/\/api\/telemetry\/v\d+$/, FEEDBACK_ROUTE);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* THE LEDGER'S FIELD CAPS, MIRRORED — same contract as `operatorQa.WIRE_LIMITS`.
|
|
148
|
+
*
|
|
149
|
+
* Every number here is the number in `apps/campfire-launch/convex/feedback.ts`, which is the
|
|
150
|
+
* validator that actually refuses. Mirroring them moves the refusal to where the report is WRITTEN
|
|
151
|
+
* — a zod error naming the field, at the moment the model wrote it — instead of to a 400 during
|
|
152
|
+
* batch close with no attempt left. `betaFeedbackLimits.test.ts` reads both files and fails if they
|
|
153
|
+
* drift apart.
|
|
154
|
+
*/
|
|
155
|
+
export const FEEDBACK_LIMITS = {
|
|
156
|
+
title: 200,
|
|
157
|
+
body: 4_000,
|
|
158
|
+
citation: 2_000,
|
|
159
|
+
issue: 1_000,
|
|
160
|
+
wentWell: 300,
|
|
161
|
+
tool: 80,
|
|
162
|
+
stage: 60,
|
|
163
|
+
role: 60,
|
|
164
|
+
errorCode: 80,
|
|
165
|
+
additionalRemarks: 4_000
|
|
166
|
+
};
|
|
167
|
+
/** Ledger counterparts: MAX_FRICTION_POINTS / MAX_WENT_WELL / MAX_REPORTS in convex/feedback.ts. */
|
|
168
|
+
export const FEEDBACK_COUNTS = {
|
|
169
|
+
frictionPoints: 25,
|
|
170
|
+
wentWell: 25,
|
|
171
|
+
reportsPerRequest: 50
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* THE TWO NUMERIC BOUNDS ON THE §9.1 ESTIMATE ROW — kept out of `FEEDBACK_LIMITS` on purpose.
|
|
175
|
+
*
|
|
176
|
+
* Everything in that block is a STRING-LENGTH cap on prose; these two bound an integer's VALUE. A
|
|
177
|
+
* reader who found `requirementCount: 1_000` sitting beside `title: 200` would reasonably conclude a
|
|
178
|
+
* requirement count is truncated at a thousand characters. Ledger counterparts are
|
|
179
|
+
* MAX_ESTIMATE_MINUTES / MAX_REQUIREMENT_COUNT, pinned by `betaFeedbackLimits.test.ts` like the rest.
|
|
180
|
+
*/
|
|
181
|
+
export const FEEDBACK_BOUNDS = {
|
|
182
|
+
estimateMinutes: 100_000,
|
|
183
|
+
requirementCount: 1_000
|
|
184
|
+
};
|
|
185
|
+
/** Mirrors MAX_BODY_BYTES in convex/feedback.ts, which measures raw request text and answers 413. */
|
|
186
|
+
const MAX_REQUEST_BYTES = 1024 * 1024;
|
|
187
|
+
/**
|
|
188
|
+
* A NARROW MECHANICAL BACKSTOP — not a secret scanner, and not the primary control.
|
|
189
|
+
*
|
|
190
|
+
* The primary control is the instruction: the tool description tells the model to replace
|
|
191
|
+
* secret-shaped values with `[REDACTED]`, stated at the point of writing because that is where it
|
|
192
|
+
* gets read. This exists because that control has a known failure mode with a known cost — quoted
|
|
193
|
+
* error text has carried a live activation code before, which is why the transcript table is marked
|
|
194
|
+
* sensitive — and because one format here is minted by this system and therefore recognisable
|
|
195
|
+
* without guessing: the activation code is bare `crypto.randomBytes(32).toString("base64url")`
|
|
196
|
+
* (campfire-launch/src/crypto.ts), exactly 43 base64url characters; the batch grant credential is
|
|
197
|
+
* the same 43 behind a `tgr_` prefix (campfire-launch/src/db/convexLedger.ts), 47 token characters
|
|
198
|
+
* in a row that the bare rule can never see — which is the Authorization bearer, i.e. exactly what
|
|
199
|
+
* a quoted HTTP error carries.
|
|
200
|
+
*
|
|
201
|
+
* The bounds are deliberately tight — exactly 43, with no adjacent token character — so it cannot
|
|
202
|
+
* eat things a citation needs. A 40-char git sha and a 64-char sha256 both fail the length test, and
|
|
203
|
+
* that is the point: over-redacting a citation would break the one property the report shape exists
|
|
204
|
+
* to guarantee.
|
|
205
|
+
*/
|
|
206
|
+
const MINTED_TOKEN_RE = /(?<![A-Za-z0-9_-])(?:tgr_)?[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g;
|
|
207
|
+
export function redactSecrets(text) {
|
|
208
|
+
return text.replace(MINTED_TOKEN_RE, "[REDACTED]");
|
|
209
|
+
}
|
|
210
|
+
/** Every free-text field on a record, run through the backstop above. */
|
|
211
|
+
export function redactRecord(record) {
|
|
212
|
+
const s = (value) => (value === undefined ? undefined : redactSecrets(value));
|
|
213
|
+
return {
|
|
214
|
+
...record,
|
|
215
|
+
...(record.title !== undefined ? { title: s(record.title) } : {}),
|
|
216
|
+
...(record.body !== undefined ? { body: s(record.body) } : {}),
|
|
217
|
+
...(record.additionalRemarks !== undefined ? { additionalRemarks: s(record.additionalRemarks) } : {}),
|
|
218
|
+
...(record.tool !== undefined ? { tool: s(record.tool) } : {}),
|
|
219
|
+
...(record.errorCode !== undefined ? { errorCode: s(record.errorCode) } : {}),
|
|
220
|
+
...(record.stage !== undefined ? { stage: s(record.stage) } : {}),
|
|
221
|
+
...(record.role !== undefined ? { role: s(record.role) } : {}),
|
|
222
|
+
...(record.frictionPoints
|
|
223
|
+
? {
|
|
224
|
+
frictionPoints: record.frictionPoints.map((point) => ({
|
|
225
|
+
...point,
|
|
226
|
+
citation: redactSecrets(point.citation),
|
|
227
|
+
issue: redactSecrets(point.issue)
|
|
228
|
+
}))
|
|
229
|
+
}
|
|
230
|
+
: {}),
|
|
231
|
+
...(record.wentWell ? { wentWell: record.wentWell.map(redactSecrets) } : {})
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
export function newFeedbackId() {
|
|
235
|
+
return `fbk_${randomBytes(12).toString("hex")}`;
|
|
236
|
+
}
|
|
237
|
+
let sharedSpool;
|
|
238
|
+
/** The process-wide feedback outbox, beside the telemetry and parcel ones. */
|
|
239
|
+
export function defaultFeedbackSpool(runId = "feedback") {
|
|
240
|
+
if (sharedSpool !== undefined)
|
|
241
|
+
return sharedSpool;
|
|
242
|
+
try {
|
|
243
|
+
sharedSpool = new TelemetrySpool({
|
|
244
|
+
dir: process.env.CC_MCP_FEEDBACK_OUTBOX?.trim() || path.join(REPO_ROOT, ".local", "cc-mcp", "feedback-outbox"),
|
|
245
|
+
runId
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
sharedSpool = null;
|
|
250
|
+
}
|
|
251
|
+
return sharedSpool;
|
|
252
|
+
}
|
|
253
|
+
/** Test seam: the outbox is process-wide, and a suite pointing it elsewhere needs it re-resolved. */
|
|
254
|
+
export function resetFeedbackSpoolForTests() {
|
|
255
|
+
sharedSpool = undefined;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Put one record in the outbox. NEVER THROWS — this is called from a tool handler.
|
|
259
|
+
*
|
|
260
|
+
* ATTRIBUTION IS THE WHOLE JOB HERE, and it is the reason a record without a batch is refused
|
|
261
|
+
* rather than held. A feedback row's value is that it joins to the run it describes; a row spooled
|
|
262
|
+
* with no batch would either sit until the caps retired it or — the version to actively avoid —
|
|
263
|
+
* later be attributed to whichever batch happened to be bound next, which is the mis-attribution
|
|
264
|
+
* `parcels.defaultResolveGrant` exists to prevent. Same rule, stated the same way.
|
|
265
|
+
*/
|
|
266
|
+
export function recordFeedback(record, opts = {}) {
|
|
267
|
+
try {
|
|
268
|
+
const batchId = opts.batchId ?? currentBatchContext()?.batchId ?? null;
|
|
269
|
+
if (!batchId) {
|
|
270
|
+
return {
|
|
271
|
+
recorded: false,
|
|
272
|
+
unattributed: true,
|
|
273
|
+
reason: "no batch is bound to this run yet, and a feedback row with no batch cannot be joined to the run it describes — activate a session first"
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const spool = opts.spool === undefined ? defaultFeedbackSpool() : opts.spool;
|
|
277
|
+
if (!spool)
|
|
278
|
+
return { recorded: false, reason: "feedback outbox unavailable on this machine" };
|
|
279
|
+
const stamped = redactRecord({
|
|
280
|
+
...record,
|
|
281
|
+
...(record.skillVersion === undefined && harnessVersion() ? { skillVersion: harnessVersion() } : {})
|
|
282
|
+
});
|
|
283
|
+
const appended = spool.append({ batchId, event: stamped });
|
|
284
|
+
return appended
|
|
285
|
+
? { recorded: true, reportId: stamped.eventId }
|
|
286
|
+
: { recorded: false, reason: "feedback outbox could not be written" };
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
return { recorded: false, reason: error instanceof Error ? error.message : String(error) };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The grant for a batch, from the durable store first — the same two rules as `parcels`.
|
|
294
|
+
*
|
|
295
|
+
* A spooled report outlives the run that made it, so it resolves THAT batch's grant and never
|
|
296
|
+
* whichever batch happens to be bound now. Borrowing the current one would land a report from one
|
|
297
|
+
* engagement in the ledger attributed to another.
|
|
298
|
+
*/
|
|
299
|
+
export function defaultResolveFeedbackGrant(batchId) {
|
|
300
|
+
if (!batchId)
|
|
301
|
+
return null;
|
|
302
|
+
try {
|
|
303
|
+
const stored = new TelemetryGrantStore(path.join(REPO_ROOT, ".local", "cc-mcp", "telemetry-grants")).get(batchId);
|
|
304
|
+
if (stored?.credential && stored.endpoint) {
|
|
305
|
+
return { endpoint: feedbackEndpointFrom(stored.endpoint), credential: stored.credential };
|
|
306
|
+
}
|
|
307
|
+
// The live context covers the window before a grant reaches disk — same batch only.
|
|
308
|
+
const live = currentBatchContext();
|
|
309
|
+
if (live?.credential && live.endpoint && live.batchId === batchId) {
|
|
310
|
+
return { endpoint: feedbackEndpointFrom(live.endpoint), credential: live.credential };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
// A grant store that cannot be read is a reason to retry later, never to fail a caller.
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
function envelope(reports) {
|
|
319
|
+
return JSON.stringify({
|
|
320
|
+
schemaVersion: FEEDBACK_WIRE_VERSION,
|
|
321
|
+
reports: reports.map(({ eventId, ...rest }) => ({ reportId: eventId, ...rest }))
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Chunks bounded by BOTH caps the ledger enforces — 50 reports, and a megabyte of request text.
|
|
326
|
+
*
|
|
327
|
+
* A single record larger than the whole budget still gets its own chunk rather than being dropped
|
|
328
|
+
* here: it will be refused on its own, which costs exactly that record, and being refused with the
|
|
329
|
+
* ledger's own message names the field a client and server disagree about.
|
|
330
|
+
*/
|
|
331
|
+
function planChunks(records) {
|
|
332
|
+
const overhead = Buffer.byteLength(envelope([]), "utf8");
|
|
333
|
+
const chunks = [];
|
|
334
|
+
let current = [];
|
|
335
|
+
let bytes = overhead;
|
|
336
|
+
for (const record of records) {
|
|
337
|
+
const cost = Buffer.byteLength(JSON.stringify(record), "utf8") + 1;
|
|
338
|
+
if (current.length > 0 && (current.length >= FEEDBACK_COUNTS.reportsPerRequest || bytes + cost > MAX_REQUEST_BYTES)) {
|
|
339
|
+
chunks.push(current);
|
|
340
|
+
current = [];
|
|
341
|
+
bytes = overhead;
|
|
342
|
+
}
|
|
343
|
+
current.push(record);
|
|
344
|
+
bytes += cost;
|
|
345
|
+
}
|
|
346
|
+
if (current.length > 0)
|
|
347
|
+
chunks.push(current);
|
|
348
|
+
return chunks;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Ship a batch's records under its grant. Never throws; every failure is a described result.
|
|
352
|
+
*
|
|
353
|
+
* A PAYLOAD REFUSAL RE-SENDS ONE AT A TIME, for the same reason the Q&A uploader does: the ledger
|
|
354
|
+
* validates a request whole and answers 400 for the first field it dislikes, so one malformed record
|
|
355
|
+
* would otherwise discard the 49 good ones travelling with it — silently, at close, with no second
|
|
356
|
+
* attempt. The retry is deliberately NOT made for network errors or 5xx: those are not
|
|
357
|
+
* record-specific, and re-sending 50 records one at a time against a dead endpoint only makes a slow
|
|
358
|
+
* close slower.
|
|
359
|
+
*/
|
|
360
|
+
export async function uploadFeedback(opts) {
|
|
361
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
362
|
+
const result = { uploaded: [], rejected: [], retained: [] };
|
|
363
|
+
const post = async (chunk) => {
|
|
364
|
+
try {
|
|
365
|
+
const res = await fetchFn(opts.endpoint, {
|
|
366
|
+
method: "POST",
|
|
367
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${opts.credential}` },
|
|
368
|
+
body: envelope(chunk),
|
|
369
|
+
signal: AbortSignal.timeout(30_000)
|
|
370
|
+
});
|
|
371
|
+
if (res.ok)
|
|
372
|
+
return { ok: true };
|
|
373
|
+
const detail = res.status === 400 ? `: ${(await res.text()).slice(0, 200)}` : "";
|
|
374
|
+
return { ok: false, status: res.status, reason: `HTTP ${res.status}${detail}` };
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
/** The statuses that mean "this payload", and so the ones worth re-trying one at a time. */
|
|
381
|
+
const payloadRefusal = (status) => status === 400 || status === 413;
|
|
382
|
+
for (const chunk of planChunks(opts.records)) {
|
|
383
|
+
const sent = await post(chunk);
|
|
384
|
+
if (sent.ok) {
|
|
385
|
+
result.uploaded.push(...chunk.map((record) => record.eventId));
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (chunk.length > 1 && payloadRefusal(sent.status)) {
|
|
389
|
+
for (const record of chunk) {
|
|
390
|
+
const retry = await post([record]);
|
|
391
|
+
if (retry.ok)
|
|
392
|
+
result.uploaded.push(record.eventId);
|
|
393
|
+
else if (payloadRefusal(retry.status)) {
|
|
394
|
+
result.rejected.push(record.eventId);
|
|
395
|
+
result.reason ??= `report ${record.eventId}: ${retry.reason ?? "refused"}`;
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
result.retained.push(record.eventId);
|
|
399
|
+
result.reason ??= retry.reason;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
// 401 is RETAINED on purpose, exactly as parcels retains it: the grant may simply not have been
|
|
405
|
+
// redeemed on this host yet, and the outbox's own caps bound how long that can go on.
|
|
406
|
+
if (payloadRefusal(sent.status)) {
|
|
407
|
+
result.rejected.push(...chunk.map((record) => record.eventId));
|
|
408
|
+
result.reason ??= sent.reason;
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
result.retained.push(...chunk.map((record) => record.eventId));
|
|
412
|
+
result.reason ??= sent.reason;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return result;
|
|
416
|
+
}
|
|
417
|
+
const emptyFlush = () => ({ uploaded: 0, retained: 0, rejected: 0, outboxDropped: 0, notes: [] });
|
|
418
|
+
/**
|
|
419
|
+
* Ship everything spooled that has a usable grant — INCLUDING BATCHES THIS RUN NEVER TOUCHED.
|
|
420
|
+
*
|
|
421
|
+
* This is what makes the channel durable rather than merely persistent. A run that records feedback
|
|
422
|
+
* and is then killed — the client SIGTERMs the server 2 s after closing stdin, and no signal handler
|
|
423
|
+
* gets more time than that (see telemetryContext.registerBatchCloser) — leaves its reports on disk.
|
|
424
|
+
* A LATER run, with the same install and the same grant store, flushes them. `pendingBatchIds` is
|
|
425
|
+
* why: the flush iterates every batch in the outbox, not just the one currently bound.
|
|
426
|
+
*/
|
|
427
|
+
export async function flushFeedback(opts = {}) {
|
|
428
|
+
const summary = emptyFlush();
|
|
429
|
+
const env = opts.env ?? process.env;
|
|
430
|
+
if (!betaEnabled(env))
|
|
431
|
+
return summary;
|
|
432
|
+
const spool = opts.spool === undefined ? defaultFeedbackSpool() : opts.spool;
|
|
433
|
+
if (!spool)
|
|
434
|
+
return summary;
|
|
435
|
+
if (!feedbackUploadEnabled(env)) {
|
|
436
|
+
const waiting = spool.stats().pendingEvents;
|
|
437
|
+
if (waiting > 0) {
|
|
438
|
+
summary.retained = waiting;
|
|
439
|
+
summary.notes.push(`${waiting} feedback record(s) stay on this machine — CC_MCP_TELEMETRY=0 stops all egress, and that covers this channel.`);
|
|
440
|
+
}
|
|
441
|
+
return summary;
|
|
442
|
+
}
|
|
443
|
+
const resolve = opts.resolveGrant ?? defaultResolveFeedbackGrant;
|
|
444
|
+
let batchIds;
|
|
445
|
+
try {
|
|
446
|
+
batchIds = spool.pendingBatchIds();
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return summary;
|
|
450
|
+
}
|
|
451
|
+
for (const batchId of batchIds) {
|
|
452
|
+
const records = spool.readBatch(opts.limit ?? 200, batchId).map((entry) => entry.event);
|
|
453
|
+
if (records.length === 0)
|
|
454
|
+
continue;
|
|
455
|
+
let grant = null;
|
|
456
|
+
try {
|
|
457
|
+
grant = resolve(batchId);
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
grant = null;
|
|
461
|
+
}
|
|
462
|
+
if (!grant) {
|
|
463
|
+
summary.retained += records.length;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const result = await uploadFeedback({
|
|
467
|
+
endpoint: grant.endpoint,
|
|
468
|
+
credential: grant.credential,
|
|
469
|
+
records,
|
|
470
|
+
...(opts.fetchFn ? { fetchFn: opts.fetchFn } : {})
|
|
471
|
+
});
|
|
472
|
+
// Retired together, but only the rejected ones count as lost — the same distinction the
|
|
473
|
+
// telemetry outbox draws, so delivery health reports the gap instead of under-counting.
|
|
474
|
+
spool.acknowledge([...result.uploaded, ...result.rejected], result.rejected.length);
|
|
475
|
+
summary.uploaded += result.uploaded.length;
|
|
476
|
+
summary.rejected += result.rejected.length;
|
|
477
|
+
summary.retained += result.retained.length;
|
|
478
|
+
if (result.rejected.length > 0) {
|
|
479
|
+
summary.notes.push(`${result.rejected.length} feedback record(s) were REFUSED by IN2 and dropped from the outbox (${result.reason ?? "no reason given"}).`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (summary.uploaded > 0)
|
|
483
|
+
summary.notes.push(`beta feedback uploaded: ${summary.uploaded} record(s)`);
|
|
484
|
+
if (summary.retained > 0) {
|
|
485
|
+
summary.notes.push(`${summary.retained} feedback record(s) still queued — they stay in the outbox and go out on a later run under the same batch's grant.`);
|
|
486
|
+
}
|
|
487
|
+
const stats = spool.stats();
|
|
488
|
+
summary.outboxDropped = stats.droppedEvents;
|
|
489
|
+
if (stats.droppedEvents > 0) {
|
|
490
|
+
summary.notes.push(`${stats.droppedEvents} feedback record(s) were dropped by the outbox's size/age caps and did NOT reach IN2.`);
|
|
491
|
+
}
|
|
492
|
+
return summary;
|
|
493
|
+
}
|