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,462 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { questionsDir } from "./workspace.js";
|
|
5
|
+
import { transcriptUploadEnabled } from "./transcriptUpload.js";
|
|
6
|
+
// OPERATOR Q&A UPLOAD — what each question card asked, and what the operator answered.
|
|
7
|
+
//
|
|
8
|
+
// THE GAP THIS CLOSES. `operator.question_presented` carries `{questionCount}`, which can say a run
|
|
9
|
+
// asked four questions and never which four, nor what came back. The question text lived only in
|
|
10
|
+
// this machine's round files and the answers only in plan.json blockers, so "what did users get
|
|
11
|
+
// asked and what did they say" — across installs, the question a product team actually has — could
|
|
12
|
+
// not be answered from the ledger at all.
|
|
13
|
+
//
|
|
14
|
+
// WHY THIS IS NOT A TELEMETRY EVENT. The event stream is shape-only by contract
|
|
15
|
+
// (`convex/lib/events.ts` allowlists every payload key and admits no prose), and a question card is
|
|
16
|
+
// prose: it is written in the operator's language about their business. Widening that allowlist to
|
|
17
|
+
// carry it would end the shape-only property for every consumer of the stream, permanently. So this
|
|
18
|
+
// takes the route the two existing content channels took — its own endpoint, its own table, its own
|
|
19
|
+
// stated reason — and the shape-only event is left exactly as it was.
|
|
20
|
+
//
|
|
21
|
+
// WHY THE DEFAULT IS HONEST. A card is relayed into the Claude Code chat and answered there, so
|
|
22
|
+
// both halves are ALREADY inside the transcript this install uploads by default, under a consent
|
|
23
|
+
// paragraph that names "the full text of the Claude Code session for that batch, which includes
|
|
24
|
+
// anything either of us pastes into the chat". This route sends no category of content that was not
|
|
25
|
+
// already leaving the machine; it makes an already-uploaded fact queryable rather than buried in a
|
|
26
|
+
// 20 MB blob.
|
|
27
|
+
//
|
|
28
|
+
// That argument is CONDITIONAL, and the condition is enforced below: with transcript upload off,
|
|
29
|
+
// this is off too. An install that asked for "the timings and outcomes without the session text"
|
|
30
|
+
// must not have the session's questions and answers arrive by a different door.
|
|
31
|
+
//
|
|
32
|
+
// FAIL-OPEN, like every other reporting path here: nothing in this module may fail, delay or mask a
|
|
33
|
+
// delivery. Every entry point returns notes instead of throwing.
|
|
34
|
+
/** Bumped when the ROUND FILE layout changes. v1 files are read identically — see readQuestionRounds. */
|
|
35
|
+
export const QUESTIONS_SCHEMA_VERSION = "cc-operator-questions-v2";
|
|
36
|
+
/** Bumped when the WIRE shape changes; the ledger refuses a version it does not know. */
|
|
37
|
+
export const OPERATOR_QA_WIRE_VERSION = 1;
|
|
38
|
+
/**
|
|
39
|
+
* THE LEDGER'S FIELD CAPS, MIRRORED.
|
|
40
|
+
*
|
|
41
|
+
* Every number here is the same number as `LIMITS` in `apps/campfire-launch/convex/operatorQa.ts`,
|
|
42
|
+
* which is the validator that actually refuses an over-long field — and refuses it with a 400 for
|
|
43
|
+
* the whole request, at batch close, when the run is over and there is no later attempt.
|
|
44
|
+
*
|
|
45
|
+
* Mirroring them on this side moves that refusal to where the card is COMPOSED: `ask_operator`
|
|
46
|
+
* rejects a 3,000-character option description with a zod error naming the field, at the moment the
|
|
47
|
+
* model wrote it, when re-composing costs one tool call. Before this the client schemas said
|
|
48
|
+
* `z.string().min(1)` and the tool description advertised the field as uncapped, so the first news
|
|
49
|
+
* of a limit arrived as a lost round.
|
|
50
|
+
*
|
|
51
|
+
* `operatorQaLimits.test.ts` reads both files and fails if these drift apart.
|
|
52
|
+
*/
|
|
53
|
+
export const WIRE_LIMITS = {
|
|
54
|
+
questionId: 64,
|
|
55
|
+
header: 24,
|
|
56
|
+
question: 4_000,
|
|
57
|
+
label: 120,
|
|
58
|
+
description: 2_000,
|
|
59
|
+
freeText: 4_000
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* The CARD's own caps, deliberately stricter than the wire.
|
|
63
|
+
*
|
|
64
|
+
* These are composition rules, not storage limits: a 60-character label is what keeps an option
|
|
65
|
+
* readable as a verdict, and a 12-character header is what fits the chip. The wire would accept
|
|
66
|
+
* twice each. The cross-app test asserts they stay INSIDE `WIRE_LIMITS` — a card that composes must
|
|
67
|
+
* always be a card that uploads.
|
|
68
|
+
*/
|
|
69
|
+
export const CARD_LIMITS = {
|
|
70
|
+
header: 12,
|
|
71
|
+
label: 60
|
|
72
|
+
};
|
|
73
|
+
/** Ledger counterparts: MAX_QUESTIONS / MAX_OPTIONS / MAX_CHOSEN in convex/operatorQa.ts. */
|
|
74
|
+
export const WIRE_COUNTS = {
|
|
75
|
+
questionsPerRound: 8,
|
|
76
|
+
optionsPerQuestion: 8,
|
|
77
|
+
chosenPerAnswer: 8
|
|
78
|
+
};
|
|
79
|
+
/** The ledger accepts 25 rounds per request (MAX_ROUNDS). A session with more still delivers all. */
|
|
80
|
+
const ROUNDS_PER_REQUEST = 25;
|
|
81
|
+
/**
|
|
82
|
+
* Mirrors MAX_BODY_BYTES in convex/operatorQa.ts, which measures the raw request text and answers
|
|
83
|
+
* 413 above it. Counting rounds alone was not enough: 25 rounds of 8 questions at the field caps
|
|
84
|
+
* above is far over a megabyte, so a pathological session could build a chunk the ledger refuses
|
|
85
|
+
* whole for a reason no round of it is individually guilty of.
|
|
86
|
+
*/
|
|
87
|
+
const MAX_REQUEST_BYTES = 1024 * 1024;
|
|
88
|
+
/**
|
|
89
|
+
* The stable address of one question, `r3q2` — round 3, question 2.
|
|
90
|
+
*
|
|
91
|
+
* DERIVED from position rather than minted, which is what makes the file-format bump a non-event: a
|
|
92
|
+
* v1 round file, written before ids existed, yields exactly the ids a v2 file would. Nothing has to
|
|
93
|
+
* migrate and no reader has to branch on version.
|
|
94
|
+
*/
|
|
95
|
+
export function questionIdFor(round, index) {
|
|
96
|
+
return `r${round}q${index + 1}`;
|
|
97
|
+
}
|
|
98
|
+
/** Every question in a round, with its id filled in whether or not the file recorded one. */
|
|
99
|
+
export function withQuestionIds(round) {
|
|
100
|
+
return round.questions.map((question, index) => ({
|
|
101
|
+
...question,
|
|
102
|
+
questionId: question.questionId ?? questionIdFor(round.round, index)
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
/** ANSWERED once every question has one; PARTIALLY_ANSWERED once at least one does. */
|
|
106
|
+
export function statusFor(questions) {
|
|
107
|
+
const answered = questions.filter((question) => question.answer !== undefined).length;
|
|
108
|
+
if (answered === 0)
|
|
109
|
+
return "PENDING";
|
|
110
|
+
return answered === questions.length ? "ANSWERED" : "PARTIALLY_ANSWERED";
|
|
111
|
+
}
|
|
112
|
+
const DISABLED_VALUES = new Set(["0", "false", "off", "no"]);
|
|
113
|
+
/**
|
|
114
|
+
* ON BY DEFAULT, and gated on the superset.
|
|
115
|
+
*
|
|
116
|
+
* Two switches, and the order matters. `CC_MCP_TRANSCRIPT_UPLOAD=0` turns this off because the
|
|
117
|
+
* consent this rides on is the transcript's: the questions and answers are part of the session text
|
|
118
|
+
* that install declined to send, and routing them out separately would make that decline meaningless.
|
|
119
|
+
* `CC_MCP_OPERATOR_QA_UPLOAD=0` is the narrower one, for an install content to ship the raw
|
|
120
|
+
* transcript but not a queryable table of what its operator was asked.
|
|
121
|
+
*
|
|
122
|
+
* `CC_MCP_TELEMETRY=0` covers both without appearing here: with no telemetry there is no batch
|
|
123
|
+
* grant, and the grant is the only way an upload can be attributed to anything.
|
|
124
|
+
*/
|
|
125
|
+
export function operatorQaUploadEnabled(env = process.env) {
|
|
126
|
+
if (!transcriptUploadEnabled(env))
|
|
127
|
+
return false;
|
|
128
|
+
const raw = env.CC_MCP_OPERATOR_QA_UPLOAD?.trim().toLowerCase();
|
|
129
|
+
return raw === undefined || raw === "" ? true : !DISABLED_VALUES.has(raw);
|
|
130
|
+
}
|
|
131
|
+
let announced = false;
|
|
132
|
+
/** Test seam — the once-guard is module state, and a suite asserting the line needs it reset. */
|
|
133
|
+
export function resetOperatorQaAnnouncementForTests() {
|
|
134
|
+
announced = false;
|
|
135
|
+
}
|
|
136
|
+
const defaultLog = (line) => void process.stderr.write(`[cc-mcp] ${line}\n`);
|
|
137
|
+
/**
|
|
138
|
+
* Say it at startup, in the operator's language, whichever way the switches are set.
|
|
139
|
+
*
|
|
140
|
+
* The transcript announcement already says the whole session leaves, so this line is not the first
|
|
141
|
+
* disclosure of the content — it is the disclosure that the content is also stored in a form
|
|
142
|
+
* somebody can query, which is a different thing to know and the reason the narrow opt-out exists.
|
|
143
|
+
*/
|
|
144
|
+
export function announceOperatorQa(opts = {}) {
|
|
145
|
+
if (announced)
|
|
146
|
+
return;
|
|
147
|
+
announced = true;
|
|
148
|
+
const env = opts.env ?? process.env;
|
|
149
|
+
const write = opts.log ?? defaultLog;
|
|
150
|
+
if (!transcriptUploadEnabled(env)) {
|
|
151
|
+
// Deliberately named as a consequence rather than as a second setting to find: an operator who
|
|
152
|
+
// set the transcript variable has already made this decision and should be told it took effect.
|
|
153
|
+
write("operator question cards are NOT being uploaded either — CC_MCP_TRANSCRIPT_UPLOAD=0 covers them, because they are part of the session text.");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!operatorQaUploadEnabled(env)) {
|
|
157
|
+
write("operator question cards are NOT being uploaded (CC_MCP_OPERATOR_QA_UPLOAD=0). They stay on this machine; the session transcript is unaffected.");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
write("OPERATOR QUESTION CARDS ARE UPLOADED (the default). Every question you are asked, its options, and the answer you give " +
|
|
161
|
+
`are sent to ${opts.endpoint ?? "the configured endpoint"} as records IN2 can search — the same words the session transcript already carries. ` +
|
|
162
|
+
"Set CC_MCP_OPERATOR_QA_UPLOAD=0 to send the transcript without them, or CC_MCP_TRANSCRIPT_UPLOAD=0 to send neither.");
|
|
163
|
+
}
|
|
164
|
+
/** Same host, same grant, different route — derived exactly as the transcript endpoint is. */
|
|
165
|
+
export function operatorQaEndpointFrom(telemetryEndpoint) {
|
|
166
|
+
try {
|
|
167
|
+
const url = new URL(telemetryEndpoint);
|
|
168
|
+
url.pathname = "/api/operator-qa/v1";
|
|
169
|
+
return url.toString();
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return telemetryEndpoint.replace(/\/api\/telemetry\/v\d+$/, "/api/operator-qa/v1");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Every round this session recorded, in asked order.
|
|
177
|
+
*
|
|
178
|
+
* Tolerant by construction: an unreadable or malformed round file is skipped rather than failing the
|
|
179
|
+
* sweep, because one bad file must not cost the rounds beside it — this runs at batch close, after
|
|
180
|
+
* the work, and there is no later attempt.
|
|
181
|
+
*/
|
|
182
|
+
export async function readQuestionRounds(sessionId) {
|
|
183
|
+
const dir = questionsDir(sessionId);
|
|
184
|
+
if (!existsSync(dir))
|
|
185
|
+
return [];
|
|
186
|
+
let names;
|
|
187
|
+
try {
|
|
188
|
+
names = await fs.readdir(dir);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
const rounds = [];
|
|
194
|
+
for (const name of names.sort()) {
|
|
195
|
+
if (!name.endsWith(".json"))
|
|
196
|
+
continue;
|
|
197
|
+
try {
|
|
198
|
+
const parsed = JSON.parse(await fs.readFile(path.join(dir, name), "utf8"));
|
|
199
|
+
if (!parsed || typeof parsed !== "object")
|
|
200
|
+
continue;
|
|
201
|
+
if (!Number.isSafeInteger(parsed.round) || parsed.round < 1)
|
|
202
|
+
continue;
|
|
203
|
+
if (!Array.isArray(parsed.questions) || parsed.questions.length === 0)
|
|
204
|
+
continue;
|
|
205
|
+
rounds.push(parsed);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// A half-written or hand-edited round file is skipped, not fatal.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return rounds.sort((a, b) => a.round - b.round);
|
|
212
|
+
}
|
|
213
|
+
/** The wire shape for one round — the local file with its ids materialized and nothing else added. */
|
|
214
|
+
export function wireRound(round) {
|
|
215
|
+
return {
|
|
216
|
+
round: round.round,
|
|
217
|
+
askedAt: round.askedAt,
|
|
218
|
+
...(round.answeredAt ? { answeredAt: round.answeredAt } : {}),
|
|
219
|
+
questions: withQuestionIds(round).map((question) => ({
|
|
220
|
+
questionId: question.questionId,
|
|
221
|
+
...(question.header ? { header: question.header } : {}),
|
|
222
|
+
question: question.question,
|
|
223
|
+
...(question.multiSelect ? { multiSelect: true } : {}),
|
|
224
|
+
options: (question.options ?? []).map((option) => ({
|
|
225
|
+
label: option.label,
|
|
226
|
+
...(option.description ? { description: option.description } : {}),
|
|
227
|
+
...(option.recommended ? { recommended: true } : {})
|
|
228
|
+
})),
|
|
229
|
+
...(question.answer
|
|
230
|
+
? {
|
|
231
|
+
answer: {
|
|
232
|
+
chosenLabels: question.answer.chosenLabels ?? [],
|
|
233
|
+
...(question.answer.freeText ? { freeText: question.answer.freeText } : {}),
|
|
234
|
+
...(question.answer.answeredAt ? { answeredAt: question.answer.answeredAt } : {})
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
: {})
|
|
238
|
+
}))
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* The first way this round would breach the ledger's caps, or null if it would not.
|
|
243
|
+
*
|
|
244
|
+
* Checked HERE so a length can never cost a request. The zod schemas on `ask_operator` already stop
|
|
245
|
+
* an over-long field being written, but a round file can also be hand-edited, restored from an older
|
|
246
|
+
* version, or written by a build whose caps differ from the ledger it happens to be pointed at — and
|
|
247
|
+
* at batch close the only remedy left is to not send the round rather than to lose the ones beside it.
|
|
248
|
+
*/
|
|
249
|
+
export function roundLimitProblem(round) {
|
|
250
|
+
const questions = round.questions ?? [];
|
|
251
|
+
if (questions.length > WIRE_COUNTS.questionsPerRound) {
|
|
252
|
+
return `it has ${questions.length} questions, over the limit of ${WIRE_COUNTS.questionsPerRound}`;
|
|
253
|
+
}
|
|
254
|
+
const tooLong = (value, max, field) => value !== undefined && value.length > max ? `${field} is ${value.length} characters, over the limit of ${max}` : null;
|
|
255
|
+
for (const [index, question] of questions.entries()) {
|
|
256
|
+
const at = `question ${index + 1}`;
|
|
257
|
+
const options = question.options ?? [];
|
|
258
|
+
if (options.length > WIRE_COUNTS.optionsPerQuestion) {
|
|
259
|
+
return `${at} has ${options.length} options, over the limit of ${WIRE_COUNTS.optionsPerQuestion}`;
|
|
260
|
+
}
|
|
261
|
+
const problem = tooLong(question.questionId, WIRE_LIMITS.questionId, `${at} id`) ??
|
|
262
|
+
tooLong(question.header, WIRE_LIMITS.header, `${at} header`) ??
|
|
263
|
+
tooLong(question.question, WIRE_LIMITS.question, `${at} text`);
|
|
264
|
+
if (problem)
|
|
265
|
+
return problem;
|
|
266
|
+
for (const [optionIndex, option] of options.entries()) {
|
|
267
|
+
const optionAt = `${at} option ${optionIndex + 1}`;
|
|
268
|
+
const optionProblem = tooLong(option.label, WIRE_LIMITS.label, `${optionAt} label`) ??
|
|
269
|
+
tooLong(option.description, WIRE_LIMITS.description, `${optionAt} description`);
|
|
270
|
+
if (optionProblem)
|
|
271
|
+
return optionProblem;
|
|
272
|
+
}
|
|
273
|
+
const answer = question.answer;
|
|
274
|
+
if (!answer)
|
|
275
|
+
continue;
|
|
276
|
+
const chosen = answer.chosenLabels ?? [];
|
|
277
|
+
if (chosen.length > WIRE_COUNTS.chosenPerAnswer) {
|
|
278
|
+
return `${at} records ${chosen.length} chosen labels, over the limit of ${WIRE_COUNTS.chosenPerAnswer}`;
|
|
279
|
+
}
|
|
280
|
+
for (const [labelIndex, label] of chosen.entries()) {
|
|
281
|
+
const labelProblem = tooLong(label, WIRE_LIMITS.label, `${at} chosen label ${labelIndex + 1}`);
|
|
282
|
+
if (labelProblem)
|
|
283
|
+
return labelProblem;
|
|
284
|
+
}
|
|
285
|
+
const freeTextProblem = tooLong(answer.freeText, WIRE_LIMITS.freeText, `${at} free text`);
|
|
286
|
+
if (freeTextProblem)
|
|
287
|
+
return freeTextProblem;
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
function envelope(rounds) {
|
|
292
|
+
return JSON.stringify({ schemaVersion: OPERATOR_QA_WIRE_VERSION, rounds });
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Chunks bounded by BOTH caps the ledger enforces — 25 rounds, and a megabyte of request text.
|
|
296
|
+
*
|
|
297
|
+
* A single round larger than the whole budget still gets its own chunk rather than being dropped
|
|
298
|
+
* here: it will be refused on its own, which costs exactly one round, and being refused with the
|
|
299
|
+
* ledger's own message is more use than a guess made locally.
|
|
300
|
+
*/
|
|
301
|
+
function planChunks(rounds) {
|
|
302
|
+
const overhead = Buffer.byteLength(envelope([]), "utf8");
|
|
303
|
+
const chunks = [];
|
|
304
|
+
let current = [];
|
|
305
|
+
let bytes = overhead;
|
|
306
|
+
for (const round of rounds) {
|
|
307
|
+
const cost = round.bytes + 1; // the comma that will separate it from the previous round
|
|
308
|
+
if (current.length > 0 && (current.length >= ROUNDS_PER_REQUEST || bytes + cost > MAX_REQUEST_BYTES)) {
|
|
309
|
+
chunks.push(current);
|
|
310
|
+
current = [];
|
|
311
|
+
bytes = overhead;
|
|
312
|
+
}
|
|
313
|
+
current.push(round);
|
|
314
|
+
bytes += cost;
|
|
315
|
+
}
|
|
316
|
+
if (current.length > 0)
|
|
317
|
+
chunks.push(current);
|
|
318
|
+
return chunks;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Ship the rounds. The grant is the same batch-scoped credential telemetry uses, so a Q&A record can
|
|
322
|
+
* only ever be attributed to the batch whose credential sent it — the ledger derives identity from
|
|
323
|
+
* the grant and ignores anything the client claims.
|
|
324
|
+
*
|
|
325
|
+
* Chunked at the ledger's per-request round cap AND its body-size cap, so a pathological session
|
|
326
|
+
* delivers every round rather than being refused whole. Chunks are independent: rows are keyed on
|
|
327
|
+
* (batch, round), so a chunk that fails costs only its own rounds.
|
|
328
|
+
*
|
|
329
|
+
* ONE BAD ROUND MUST NOT COST ITS CHUNKMATES. The ledger validates a request whole and answers 400
|
|
330
|
+
* for the first field it dislikes, so a single malformed round used to discard the 24 answered ones
|
|
331
|
+
* travelling with it — silently, at batch close, with no second attempt. A payload refusal now
|
|
332
|
+
* re-sends the chunk one round at a time, which turns "25 rounds lost" into "the one round the
|
|
333
|
+
* ledger actually refused". The retry is deliberately NOT made for network errors or 5xx: those are
|
|
334
|
+
* not round-specific, and re-sending 25 rounds one at a time against a dead endpoint would only make
|
|
335
|
+
* a slow close slower.
|
|
336
|
+
*/
|
|
337
|
+
export async function uploadOperatorQa(opts) {
|
|
338
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
339
|
+
let uploaded = 0;
|
|
340
|
+
let failed = 0;
|
|
341
|
+
let reason;
|
|
342
|
+
// Length-refused before anything is sent, so a 400 for a field size cannot happen at all.
|
|
343
|
+
const sendable = [];
|
|
344
|
+
for (const round of opts.rounds) {
|
|
345
|
+
const problem = roundLimitProblem(round);
|
|
346
|
+
if (problem !== null) {
|
|
347
|
+
failed += 1;
|
|
348
|
+
reason ??= `round ${round.round} exceeds the ledger's limits: ${problem}`;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const body = wireRound(round);
|
|
352
|
+
sendable.push({ round: round.round, body, bytes: Buffer.byteLength(JSON.stringify(body), "utf8") });
|
|
353
|
+
}
|
|
354
|
+
const post = async (chunk) => {
|
|
355
|
+
try {
|
|
356
|
+
const res = await fetchFn(opts.endpoint, {
|
|
357
|
+
method: "POST",
|
|
358
|
+
headers: {
|
|
359
|
+
"content-type": "application/json",
|
|
360
|
+
authorization: `Bearer ${opts.credential}`
|
|
361
|
+
},
|
|
362
|
+
body: envelope(chunk.map((round) => round.body)),
|
|
363
|
+
signal: AbortSignal.timeout(30_000)
|
|
364
|
+
});
|
|
365
|
+
if (res.ok)
|
|
366
|
+
return { ok: true };
|
|
367
|
+
// The ledger's own message names the field it refused, which is the only useful thing to
|
|
368
|
+
// print when a client and a server disagree about a shape.
|
|
369
|
+
const detail = res.status === 400 ? `: ${(await res.text()).slice(0, 200)}` : "";
|
|
370
|
+
return { ok: false, status: res.status, reason: `HTTP ${res.status}${detail}` };
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
/** The statuses that mean "this payload", and so the ones worth re-trying a round at a time. */
|
|
377
|
+
const payloadRefusal = (status) => status === 400 || status === 413;
|
|
378
|
+
for (const chunk of planChunks(sendable)) {
|
|
379
|
+
const sent = await post(chunk);
|
|
380
|
+
if (sent.ok) {
|
|
381
|
+
uploaded += chunk.length;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (chunk.length === 1 || !payloadRefusal(sent.status)) {
|
|
385
|
+
failed += chunk.length;
|
|
386
|
+
reason ??= sent.reason;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
// Deliberately no chunk-level reason recorded here: the per-round attempt below produces the
|
|
390
|
+
// message naming the round that is actually at fault, which is the one worth reporting.
|
|
391
|
+
for (const round of chunk) {
|
|
392
|
+
const retry = await post([round]);
|
|
393
|
+
if (retry.ok) {
|
|
394
|
+
uploaded += 1;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
failed += 1;
|
|
398
|
+
reason ??= `round ${round.round}: ${retry.reason ?? "unknown error"}`;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return { uploaded, failed, ...(reason ? { reason } : {}) };
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* THE UNDERCOUNT WARNING — said mechanically, from the round files, not left to a prompt.
|
|
405
|
+
*
|
|
406
|
+
* A question round is closed by `record_operator_answers`. But an operator's answer ALSO reaches the
|
|
407
|
+
* work through `resolve_blocker`, which records it on requirements/<extId>/plan.json with no link
|
|
408
|
+
* back to the round it came from — and through the chat, which records it nowhere at all. So a run
|
|
409
|
+
* whose operator answered every question and delivered successfully can still close with rounds
|
|
410
|
+
* marked PENDING, and it is then indistinguishable from a run that stopped dead at the gate. The
|
|
411
|
+
* ledger reads `answeredCount: 0` as exactly that meaningful "stopped at the gate" state.
|
|
412
|
+
*
|
|
413
|
+
* There is no cheap way to link the two records — one card question can settle three requirements or
|
|
414
|
+
* none — so the honest move is to stop the reading being certain. Naming the open rounds at close
|
|
415
|
+
* costs nothing and puts the doubt where the number is read.
|
|
416
|
+
*/
|
|
417
|
+
export function openRoundCaveat(open) {
|
|
418
|
+
if (open.length === 0)
|
|
419
|
+
return [];
|
|
420
|
+
const numbers = open.map((round) => round.round).join(", ");
|
|
421
|
+
return [
|
|
422
|
+
`${open.length} question round(s) still open at close (${open.length === 1 ? "round" : "rounds"} ${numbers}) — ` +
|
|
423
|
+
"if the operator answered through resolve_blocker or in the chat, this batch's Q&A record undercounts what they were asked and told. " +
|
|
424
|
+
"record_operator_answers is what puts the answer on the round."
|
|
425
|
+
];
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* The batch-close step: read what this session asked, ship it, and say what happened.
|
|
429
|
+
*
|
|
430
|
+
* Returns notes rather than throwing, and reports "nothing to send" as plainly as a failure — a run
|
|
431
|
+
* that asked no questions and a run whose upload silently did nothing must not look the same.
|
|
432
|
+
*/
|
|
433
|
+
export async function shipOperatorQa(opts) {
|
|
434
|
+
if (!operatorQaUploadEnabled(opts.env ?? process.env))
|
|
435
|
+
return [];
|
|
436
|
+
if (!opts.sessionId)
|
|
437
|
+
return ["operator Q&A upload skipped: no session bound to this run"];
|
|
438
|
+
if (!opts.endpoint || !opts.credential) {
|
|
439
|
+
return ["operator Q&A upload skipped: no batch grant on this install yet"];
|
|
440
|
+
}
|
|
441
|
+
const rounds = await readQuestionRounds(opts.sessionId);
|
|
442
|
+
if (rounds.length === 0)
|
|
443
|
+
return ["operator Q&A: no question rounds were recorded for this session"];
|
|
444
|
+
const open = rounds.filter((round) => statusFor(round.questions) === "PENDING");
|
|
445
|
+
const result = await uploadOperatorQa({
|
|
446
|
+
endpoint: opts.endpoint,
|
|
447
|
+
credential: opts.credential,
|
|
448
|
+
rounds,
|
|
449
|
+
...(opts.fetchFn ? { fetchFn: opts.fetchFn } : {})
|
|
450
|
+
});
|
|
451
|
+
const notes = [];
|
|
452
|
+
if (result.uploaded > 0) {
|
|
453
|
+
notes.push(`operator Q&A uploaded: ${result.uploaded} question round(s)` +
|
|
454
|
+
(open.length > 0 ? `, ${open.length} of them with no answer recorded` : ""));
|
|
455
|
+
}
|
|
456
|
+
if (result.failed > 0) {
|
|
457
|
+
notes.push(`operator Q&A upload failed for ${result.failed} round(s): ${result.reason ?? "unknown error"}`);
|
|
458
|
+
}
|
|
459
|
+
// Last, and independent of how the upload went: the subject is the RECORD, not the transfer.
|
|
460
|
+
notes.push(...openRoundCaveat(open));
|
|
461
|
+
return notes;
|
|
462
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// WHERE A WAVE'S ORG TIME ACTUALLY GOES — measured, per phase, in the ledger.
|
|
2
|
+
//
|
|
3
|
+
// THE QUESTION. A Windows run was observed taking up to ~8 minutes per wave against a Mac's ~34s
|
|
4
|
+
// median (.audit/timing-profile.md). Nothing in the ledger could apportion that, so the cause was
|
|
5
|
+
// argued from anecdote.
|
|
6
|
+
//
|
|
7
|
+
// THE PREMISE THAT WAS WRONG, and why this file is not what it first was. The obvious hypothesis is
|
|
8
|
+
// "Windows spawns the `sf` CLI many times and each spawn is expensive". It does not: the whole
|
|
9
|
+
// direct tier spawns `sf` exactly THREE times per wave (retrieve, deploy, destructive deploy — all
|
|
10
|
+
// in applyLoop.ts), `orgRepo.ts` spawns `git` only, and every projection read goes through jsforce
|
|
11
|
+
// via @salesforce/core, already batched at the API cap of 10 with 5 in flight
|
|
12
|
+
// (orgread/salesforceOrgReader.ts). Both "batch the reads" targets were already optimal. A meter
|
|
13
|
+
// that counted only sf spawns would therefore measure the three cheapest events in the wave and
|
|
14
|
+
// miss the ~50 jsforce round-trips a projection actually makes.
|
|
15
|
+
//
|
|
16
|
+
// So this counts BOTH KINDS, tagged by kind, and the remaining hypotheses are the ones the numbers
|
|
17
|
+
// can now separate:
|
|
18
|
+
// - per-call network latency (jsforce round-trips, steady state)
|
|
19
|
+
// - process startup — node boot, CLI load, and on Windows an sf.cmd shim resolved to its JS entry
|
|
20
|
+
// and run under this process's node (see core/spawn.ts)
|
|
21
|
+
// - on-access AV scanning, which taxes process start and file I/O rather than the network
|
|
22
|
+
//
|
|
23
|
+
// FIRST CALL VS STEADY STATE is split for exactly that reason. A cost that lands entirely on the
|
|
24
|
+
// first call is startup — connection setup, module load, an AV scan of a freshly touched binary. A
|
|
25
|
+
// cost spread evenly across every call is latency. One number cannot tell those apart; two can, and
|
|
26
|
+
// it is the difference between "the org is far away" and "this machine is slow to start things",
|
|
27
|
+
// which have opposite fixes.
|
|
28
|
+
//
|
|
29
|
+
// WHY A MODULE-LEVEL METER AND NOT A PARAMETER THREADED THROUGH. The calls happen across five
|
|
30
|
+
// modules, several behind fail-open helpers with no result channel to carry a measurement back on
|
|
31
|
+
// (`retrievePreImageSource` returns ok/error; the snapshot readers return envelopes). Threading a
|
|
32
|
+
// counter through all of them to serve one telemetry field would put an observability concern into
|
|
33
|
+
// every signature on the read and deploy paths. The meter sits where the calls already funnel and
|
|
34
|
+
// the apply loop reads it.
|
|
35
|
+
//
|
|
36
|
+
// SHAPE ONLY, and structurally so: a phase LABEL from this codebase's own closed vocabulary, a
|
|
37
|
+
// kind, counts and durations. No argv, no SOQL, no component names, no org alias — which matters
|
|
38
|
+
// because the wave event is validated against a shape-only allowlist on ingest
|
|
39
|
+
// (campfire-launch/convex/lib/events.ts), and argv and SOQL are where org content lives.
|
|
40
|
+
//
|
|
41
|
+
// PROCESS-WIDE, NOT REQUEST-SCOPED. One MCP server serves one session's waves serially, so a
|
|
42
|
+
// process-global meter reset at the start of each wave measures that wave. Stated rather than
|
|
43
|
+
// assumed: `resetOrgCallMeter` is what makes the reading a wave's and not a run's.
|
|
44
|
+
/**
|
|
45
|
+
* Where a call is filed when no phase is open.
|
|
46
|
+
*
|
|
47
|
+
* Auth checks, the org gate and session binding all reach the org outside any wave phase. They are
|
|
48
|
+
* counted rather than dropped, because a meter whose phases do not sum to its total quietly
|
|
49
|
+
* under-reports the very cost it exists to expose.
|
|
50
|
+
*/
|
|
51
|
+
const UNPHASED = "other";
|
|
52
|
+
// Keyed on a global symbol for the same reason telemetryContext.ts is: this module can legitimately
|
|
53
|
+
// be loaded more than once in a process (a lazy import from the apply loop, a static one from a
|
|
54
|
+
// tool), and two module instances holding two meters would silently halve every count.
|
|
55
|
+
const KEY = Symbol.for("in2.org.call.meter.v1");
|
|
56
|
+
const globals = globalThis;
|
|
57
|
+
const state = (globals[KEY] ??= {
|
|
58
|
+
count: 0,
|
|
59
|
+
totalMs: 0,
|
|
60
|
+
cliCount: 0,
|
|
61
|
+
cliMs: 0,
|
|
62
|
+
apiCount: 0,
|
|
63
|
+
apiMs: 0,
|
|
64
|
+
firstCallMs: null,
|
|
65
|
+
byPhase: new Map(),
|
|
66
|
+
phase: null
|
|
67
|
+
});
|
|
68
|
+
/**
|
|
69
|
+
* Clear the meter. Called at the start of each wave so a reading measures ONE wave.
|
|
70
|
+
*
|
|
71
|
+
* `firstCallMs` is cleared too, deliberately: it means "first call of this WAVE", which is the
|
|
72
|
+
* comparison the ledger can actually make — first against steady state, within one wave, on one
|
|
73
|
+
* machine. A process-lifetime first call would be one number per run with nothing to compare it to.
|
|
74
|
+
*/
|
|
75
|
+
export function resetOrgCallMeter() {
|
|
76
|
+
state.count = 0;
|
|
77
|
+
state.totalMs = 0;
|
|
78
|
+
state.cliCount = 0;
|
|
79
|
+
state.cliMs = 0;
|
|
80
|
+
state.apiCount = 0;
|
|
81
|
+
state.apiMs = 0;
|
|
82
|
+
state.firstCallMs = null;
|
|
83
|
+
state.byPhase.clear();
|
|
84
|
+
state.phase = null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Record one org call.
|
|
88
|
+
*
|
|
89
|
+
* `durationMs` is full wall clock — for a CLI child that is launch plus work, for an API call the
|
|
90
|
+
* round-trip. Splitting launch from work is not available from outside the child, and inventing the
|
|
91
|
+
* split would be a fabricated number rather than a measured one; the first-call/steady-state
|
|
92
|
+
* comparison is what stands in for it.
|
|
93
|
+
*/
|
|
94
|
+
export function recordOrgCall(kind, durationMs) {
|
|
95
|
+
const ms = Number.isFinite(durationMs) && durationMs > 0 ? Math.round(durationMs) : 0;
|
|
96
|
+
state.count += 1;
|
|
97
|
+
state.totalMs += ms;
|
|
98
|
+
if (kind === "cli") {
|
|
99
|
+
state.cliCount += 1;
|
|
100
|
+
state.cliMs += ms;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
state.apiCount += 1;
|
|
104
|
+
state.apiMs += ms;
|
|
105
|
+
}
|
|
106
|
+
if (state.firstCallMs === null)
|
|
107
|
+
state.firstCallMs = ms;
|
|
108
|
+
const key = state.phase ?? UNPHASED;
|
|
109
|
+
const phase = state.byPhase.get(key) ?? { count: 0, totalMs: 0 };
|
|
110
|
+
phase.count += 1;
|
|
111
|
+
phase.totalMs += ms;
|
|
112
|
+
state.byPhase.set(key, phase);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Attribute every org call inside `fn` to `label`.
|
|
116
|
+
*
|
|
117
|
+
* Restores the ENCLOSING phase rather than clearing to null, and in a `finally`, so a nested phase
|
|
118
|
+
* and a throwing one both leave the outer attribution intact. Getting that wrong would file the
|
|
119
|
+
* rest of a wave under `other` after the first inner failure — the failure mode that makes a meter
|
|
120
|
+
* worse than no meter, because it still looks like a measurement.
|
|
121
|
+
*/
|
|
122
|
+
export async function orgCallPhase(label, fn) {
|
|
123
|
+
const previous = state.phase;
|
|
124
|
+
state.phase = label;
|
|
125
|
+
try {
|
|
126
|
+
return await fn();
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
state.phase = previous;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Time `fn` as one API round-trip, whatever it returns or throws. */
|
|
133
|
+
export async function meteredApiCall(fn) {
|
|
134
|
+
const started = Date.now();
|
|
135
|
+
try {
|
|
136
|
+
return await fn();
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
// A failed round-trip still cost the wall clock the operator waited through. Counting only
|
|
140
|
+
// successes would under-report exactly the runs that hurt.
|
|
141
|
+
recordOrgCall("api", Date.now() - started);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** The meter as plain data. */
|
|
145
|
+
export function readOrgCallMeter() {
|
|
146
|
+
return {
|
|
147
|
+
count: state.count,
|
|
148
|
+
totalMs: state.totalMs,
|
|
149
|
+
cliCount: state.cliCount,
|
|
150
|
+
cliMs: state.cliMs,
|
|
151
|
+
apiCount: state.apiCount,
|
|
152
|
+
apiMs: state.apiMs,
|
|
153
|
+
firstCallMs: state.firstCallMs,
|
|
154
|
+
byPhase: Object.fromEntries([...state.byPhase].map(([key, value]) => [key, { ...value }]))
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The meter as the wave event's payload fragment — spread into the emit, absent when there is
|
|
159
|
+
* nothing to say.
|
|
160
|
+
*
|
|
161
|
+
* A wave that never reached the org (LINT_BLOCKED) legitimately made no calls, and an absent key is
|
|
162
|
+
* how the rest of this payload says "not applicable". Zeroes would be equally true and would make a
|
|
163
|
+
* blocked wave indistinguishable from an install running an older build that sends no meter.
|
|
164
|
+
*/
|
|
165
|
+
export function orgCallMeterSnapshot() {
|
|
166
|
+
if (state.count === 0)
|
|
167
|
+
return {};
|
|
168
|
+
return {
|
|
169
|
+
orgCallCount: state.count,
|
|
170
|
+
orgCallMs: state.totalMs,
|
|
171
|
+
orgCliCount: state.cliCount,
|
|
172
|
+
orgCliMs: state.cliMs,
|
|
173
|
+
orgApiCount: state.apiCount,
|
|
174
|
+
orgApiMs: state.apiMs,
|
|
175
|
+
...(state.firstCallMs !== null ? { orgFirstCallMs: state.firstCallMs } : {}),
|
|
176
|
+
orgCallsByPhase: Object.fromEntries([...state.byPhase].map(([key, value]) => [key, { ...value }]))
|
|
177
|
+
};
|
|
178
|
+
}
|