predictable-ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +265 -0
- package/dist/args.js +76 -0
- package/dist/args.js.map +1 -0
- package/dist/bin.js +17 -0
- package/dist/bin.js.map +1 -0
- package/dist/commands/bundle.js +754 -0
- package/dist/commands/bundle.js.map +1 -0
- package/dist/commands/execute.js +266 -0
- package/dist/commands/execute.js.map +1 -0
- package/dist/config.js +93 -0
- package/dist/config.js.map +1 -0
- package/dist/diagnostics.js +99 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/errors.js +30 -0
- package/dist/errors.js.map +1 -0
- package/dist/generated/commands.json +881 -0
- package/dist/generated/csv-safety.js +181 -0
- package/dist/generated/csv-safety.js.map +1 -0
- package/dist/generated/csv-safety.ts +184 -0
- package/dist/help.js +133 -0
- package/dist/help.js.map +1 -0
- package/dist/http.js +146 -0
- package/dist/http.js.map +1 -0
- package/dist/output.js +102 -0
- package/dist/output.js.map +1 -0
- package/dist/parity.js +30 -0
- package/dist/parity.js.map +1 -0
- package/dist/registry.js +359 -0
- package/dist/registry.js.map +1 -0
- package/dist/run.js +102 -0
- package/dist/run.js.map +1 -0
- package/dist/surface.js +24 -0
- package/dist/surface.js.map +1 -0
- package/dist/version.js +91 -0
- package/dist/version.js.map +1 -0
- package/package.json +27 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { CliError, EXIT } from '../errors.js';
|
|
4
|
+
import { serializeCsvRows, isFormulaHazard, toCsvValue } from '../generated/csv-safety.js';
|
|
5
|
+
import { classify, send } from '../http.js';
|
|
6
|
+
/**
|
|
7
|
+
* `export campaign` — the spec §3.4 file bundle (ticket ga-mfacl.9 / T8,
|
|
8
|
+
* ACs 2-5 and 7).
|
|
9
|
+
*
|
|
10
|
+
* WHAT THIS IS. Seven files written to disk from six reads plus a manifest the
|
|
11
|
+
* verb core writes itself. The MCP head (T10) returns the same seven as sized
|
|
12
|
+
* tool results; that is why the assembly lives here as a function over
|
|
13
|
+
* `RequestContext` rather than inside the command dispatcher.
|
|
14
|
+
*
|
|
15
|
+
* ── THE SERIALIZER IS IMPORTED, NEVER RE-IMPLEMENTED (§5.1a) ────────────────
|
|
16
|
+
*
|
|
17
|
+
* `../generated/csv-safety.js` is a GENERATED MIRROR of
|
|
18
|
+
* `dashboard/src/lib/exa-agent/csv-safety.ts`, written by the dashboard's
|
|
19
|
+
* `generate:agent-surface` and covered by CI's generate-twice diff, exactly as
|
|
20
|
+
* `generated/commands.json` is. The CLI is a separate npm package that ships
|
|
21
|
+
* only `dist/`, so it cannot import across the repo — and a hand-written second
|
|
22
|
+
* serializer is precisely what §5.1a exists to prevent. Edit the source, never
|
|
23
|
+
* the mirror.
|
|
24
|
+
*
|
|
25
|
+
* `accounts.csv` is the exception that proves the rule: it is written BYTE FOR
|
|
26
|
+
* BYTE as the server sent it. `GET /api/export/[icpId]` already serializes
|
|
27
|
+
* through the same module (T8's re-cut), so re-serializing here would parse and
|
|
28
|
+
* re-emit CSV that was already correct — two chances to get it wrong instead of
|
|
29
|
+
* none, and it would drop the server's own hazard neutralization on the floor
|
|
30
|
+
* if the parse disagreed.
|
|
31
|
+
*/
|
|
32
|
+
// ── The envelope (spec §3.4, D-f drafting numbers) ──────────────────────────
|
|
33
|
+
/**
|
|
34
|
+
* Caps, overridable from the environment so AC4 can breach them without
|
|
35
|
+
* writing a quarter-million rows.
|
|
36
|
+
*
|
|
37
|
+
* The override is read at CALL time, not at module load: a test that sets the
|
|
38
|
+
* variable after importing this module must still see it, and a module-level
|
|
39
|
+
* constant would have frozen the production number into the test process.
|
|
40
|
+
*/
|
|
41
|
+
export function envelopeCaps(env) {
|
|
42
|
+
const rows = Number(env.PREDICTABLE_BUNDLE_MAX_ROWS);
|
|
43
|
+
const bytes = Number(env.PREDICTABLE_BUNDLE_MAX_BYTES);
|
|
44
|
+
return {
|
|
45
|
+
maxRows: Number.isInteger(rows) && rows > 0 ? rows : 250_000,
|
|
46
|
+
maxBytes: Number.isInteger(bytes) && bytes > 0 ? bytes : 100 * 1024 * 1024,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The seven files, in one place.
|
|
51
|
+
*
|
|
52
|
+
* The list is the spec's table and the manifest's `files` key reads it, so a
|
|
53
|
+
* file added to the bundle cannot be forgotten in the manifest — and AC2's
|
|
54
|
+
* completeness check compares the directory against THIS, not against a second
|
|
55
|
+
* hand-written list of seven names.
|
|
56
|
+
*/
|
|
57
|
+
export const BUNDLE_FILES = [
|
|
58
|
+
'accounts.csv',
|
|
59
|
+
'contacts.csv',
|
|
60
|
+
'sequence.json',
|
|
61
|
+
'events.csv',
|
|
62
|
+
'replies.json',
|
|
63
|
+
'stats.json',
|
|
64
|
+
'manifest.json',
|
|
65
|
+
];
|
|
66
|
+
/** The export purposes `GET /api/export/[icpId]` accepts (§6). */
|
|
67
|
+
export const EXPORT_PURPOSES = ['prospecting_activation', 'reporting_history'];
|
|
68
|
+
/**
|
|
69
|
+
* The `X-Export-*` response headers `accounts.csv` carries, recorded in the
|
|
70
|
+
* manifest (AC2). Read off the RESPONSE, never recomputed here: they are the
|
|
71
|
+
* server's own count of what its suppression filter excluded, and a second
|
|
72
|
+
* derivation on the client would be a different number wearing the same name.
|
|
73
|
+
*/
|
|
74
|
+
export const EXPORT_HEADER_KEYS = [
|
|
75
|
+
'x-export-purpose',
|
|
76
|
+
'x-export-eligible-count',
|
|
77
|
+
'x-export-excluded-person-suppression',
|
|
78
|
+
'x-export-excluded-organization-suppression',
|
|
79
|
+
'x-export-excluded-automatic-safety',
|
|
80
|
+
'x-export-excluded-total',
|
|
81
|
+
// THE PRODUCER'S OWN HAZARD COUNT (r2 finding 6). accounts.csv is held as
|
|
82
|
+
// verbatim server bytes and this bundle never parses it, so the only honest
|
|
83
|
+
// source for "how many cells in accounts.csv were neutralized" is the route
|
|
84
|
+
// that serialized them. It reports the number here.
|
|
85
|
+
'x-export-formula-hazard-count',
|
|
86
|
+
];
|
|
87
|
+
/** contacts.csv's columns — a closed, named set, header row included. */
|
|
88
|
+
export const CONTACT_COLUMNS = [
|
|
89
|
+
'contact_id',
|
|
90
|
+
'name',
|
|
91
|
+
'title',
|
|
92
|
+
'email',
|
|
93
|
+
'zerobounce_status',
|
|
94
|
+
'linkedin_url',
|
|
95
|
+
'phone',
|
|
96
|
+
'company_name',
|
|
97
|
+
'company_domain',
|
|
98
|
+
'tier',
|
|
99
|
+
];
|
|
100
|
+
/** events.csv's columns. `actor` is flattened to its two fields, not joined. */
|
|
101
|
+
export const EVENT_COLUMNS = [
|
|
102
|
+
'contact_id',
|
|
103
|
+
'kind',
|
|
104
|
+
'channel',
|
|
105
|
+
'sequence_step',
|
|
106
|
+
'sent_at',
|
|
107
|
+
'actor_type',
|
|
108
|
+
'actor_id',
|
|
109
|
+
];
|
|
110
|
+
export class EnvelopeExceeded extends CliError {
|
|
111
|
+
file;
|
|
112
|
+
constructor(file, detail) {
|
|
113
|
+
super(EXIT.USAGE, 'bundle_envelope_exceeded', `${file}: ${detail}`);
|
|
114
|
+
this.name = 'EnvelopeExceeded';
|
|
115
|
+
this.file = file;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// ── Reads ───────────────────────────────────────────────────────────────────
|
|
119
|
+
async function getJson(ctx, path, query) {
|
|
120
|
+
const call = { method: 'GET', path, ...(query ? { query } : {}) };
|
|
121
|
+
const response = await send(ctx, call);
|
|
122
|
+
const failure = classify(response, call);
|
|
123
|
+
if (failure)
|
|
124
|
+
throw failure;
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(response.text);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new CliError(EXIT.SERVER, `error: ${path} did not return JSON`, `GET ${path}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function readStatsSource(ctx, path, query) {
|
|
133
|
+
try {
|
|
134
|
+
return { ok: true, data: await getJson(ctx, path, query) };
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
unavailable: err instanceof CliError ? err.message : String(err),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* THE REQUEST SHAPE EVERY §3.4 SOURCE IS ASKED IN, READ OFF THE ROUTE HANDLER
|
|
145
|
+
* (r3 item R2-1).
|
|
146
|
+
*
|
|
147
|
+
* WHAT WENT WRONG. Round 2 called `/api/billing/usage` with no query. That
|
|
148
|
+
* route reads `searchParams.get('icpId')` and answers 404 when it is absent
|
|
149
|
+
* (billing/usage/route.ts:36-37), so EVERY bundle recorded
|
|
150
|
+
* `{ok:false, unavailable}` for a source that is available at `?icpId=`. The
|
|
151
|
+
* round-2 comment said both reads were "honestly available" — true of the
|
|
152
|
+
* allowlist, false of the call. Reachability was checked; the route's own
|
|
153
|
+
* CONTRACT was not.
|
|
154
|
+
*
|
|
155
|
+
* THE INVARIANT. Every specified source is requested in the form the route
|
|
156
|
+
* actually accepts, so an `unavailable` record means the source refused a
|
|
157
|
+
* well-formed request — never that we asked wrong.
|
|
158
|
+
*
|
|
159
|
+
* THE POPULATION IS EVERY READ THIS BUNDLE ISSUES, not only the three stats
|
|
160
|
+
* sources the finding named. The defect class is "the request shape does not
|
|
161
|
+
* satisfy the handler's own parameter requirements", and nothing about it is
|
|
162
|
+
* special to stats.json — so all nine reads are stated here, each with the
|
|
163
|
+
* handler line the requirement was read from. The brief named three; the
|
|
164
|
+
* property ranges over all of them, and a table that covered three would leave
|
|
165
|
+
* the next one unwatched.
|
|
166
|
+
*
|
|
167
|
+
* THE METHOD that proves the population complete lives in the test
|
|
168
|
+
* (cli/test/bundle.test.ts): the stub REFUSES exactly as each handler does, and
|
|
169
|
+
* the run's recorded request set is compared against this table in BOTH
|
|
170
|
+
* directions — a read with no entry here fails, and an entry never exercised
|
|
171
|
+
* fails. The population is derived from execution, not from this list.
|
|
172
|
+
*/
|
|
173
|
+
export const READ_CONTRACTS = [
|
|
174
|
+
{
|
|
175
|
+
route: 'GET /api/export/[icpId]',
|
|
176
|
+
requires: 'path icpId; ?purpose= optional (defaults prospecting_activation)',
|
|
177
|
+
handler: 'export/[icpId]/route.ts:122-125 — params.icpId; purpose defaults when absent',
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
route: 'GET /api/contacts/[icpId]',
|
|
181
|
+
requires: 'path icpId; ?limit= optional',
|
|
182
|
+
handler: 'contacts/[icpId]/route.ts:13-31 — params.icpId, optional limit',
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
route: 'GET /api/contacts/[icpId]/filter-counts',
|
|
186
|
+
requires: 'path icpId only',
|
|
187
|
+
handler: 'contacts/[icpId]/filter-counts/route.ts:10-13',
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
route: 'GET /api/icps/[id]/sequences',
|
|
191
|
+
requires: 'path id MUST be a uuid — 400 invalid_icp_id otherwise',
|
|
192
|
+
handler: 'icps/[id]/sequences/route.ts:154-158 — UUID_RE.test(icpId)',
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
route: 'GET /api/icps/[id]/sequences/[seqId]',
|
|
196
|
+
requires: 'path id AND seqId must BOTH be uuids — 400 invalid_id otherwise',
|
|
197
|
+
handler: 'icps/[id]/sequences/[seqId]/route.ts:89-92 — UUID_RE on both',
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
route: 'GET /api/replies/[icpId]',
|
|
201
|
+
requires: 'path icpId; every filter optional',
|
|
202
|
+
handler: 'replies/[icpId]/route.ts:6-38',
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
route: 'GET /api/billing/usage',
|
|
206
|
+
requires: 'REQUIRED ?icpId= — 404 without it',
|
|
207
|
+
handler: 'billing/usage/route.ts:36-37 — searchParams.get(\'icpId\'), 404 when absent',
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
route: 'GET /api/pipeline/[icpId]',
|
|
211
|
+
requires: 'path icpId only',
|
|
212
|
+
handler: 'pipeline/[icpId]/route.ts:8-19',
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
route: 'GET /api/agent/lists/[id]/events',
|
|
216
|
+
requires: 'path id; ?cursor= and ?sequence_id= optional, both validated',
|
|
217
|
+
handler: 'agent/lists/[id]/events/route.ts:134-144',
|
|
218
|
+
},
|
|
219
|
+
];
|
|
220
|
+
/**
|
|
221
|
+
* Page `GET /api/agent/lists/[id]/events` to exhaustion (AC3).
|
|
222
|
+
*
|
|
223
|
+
* THE LOOP TERMINATES ON THE SERVER'S OWN `next_cursor`, and it refuses to
|
|
224
|
+
* accept the same cursor twice. A server that echoed a cursor unchanged — or a
|
|
225
|
+
* client that forgot to send it — would otherwise re-read page one forever and
|
|
226
|
+
* write a bundle that looks big and is one page repeated. An unpaged or
|
|
227
|
+
* silently truncated read destroying data is the failure class §3.4 names by
|
|
228
|
+
* name, so both directions are refused rather than tolerated.
|
|
229
|
+
*/
|
|
230
|
+
export async function pageEvents(ctx, listId, maxRows, sequenceId) {
|
|
231
|
+
const rows = [];
|
|
232
|
+
const seen = new Set();
|
|
233
|
+
let cursor;
|
|
234
|
+
for (;;) {
|
|
235
|
+
const query = {};
|
|
236
|
+
if (sequenceId !== undefined)
|
|
237
|
+
query.sequence_id = sequenceId;
|
|
238
|
+
if (cursor !== undefined)
|
|
239
|
+
query.cursor = cursor;
|
|
240
|
+
const body = (await getJson(ctx, `/api/agent/lists/${encodeURIComponent(listId)}/events`, Object.keys(query).length > 0 ? query : undefined));
|
|
241
|
+
if (!Array.isArray(body.events)) {
|
|
242
|
+
throw new CliError(EXIT.SERVER, 'error: events read returned no events array', `list ${listId}`);
|
|
243
|
+
}
|
|
244
|
+
rows.push(...body.events);
|
|
245
|
+
if (rows.length > maxRows) {
|
|
246
|
+
throw new EnvelopeExceeded('events.csv', `more than ${maxRows} rows`);
|
|
247
|
+
}
|
|
248
|
+
const next = body.next_cursor;
|
|
249
|
+
if (next === null || next === undefined)
|
|
250
|
+
return rows;
|
|
251
|
+
if (typeof next !== 'string' || next.length === 0 || seen.has(next)) {
|
|
252
|
+
throw new CliError(EXIT.SERVER, 'error: the events pager was handed a cursor it cannot advance on', `list ${listId}`);
|
|
253
|
+
}
|
|
254
|
+
seen.add(next);
|
|
255
|
+
cursor = next;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// ── CSV construction ────────────────────────────────────────────────────────
|
|
259
|
+
function pick(row, keys) {
|
|
260
|
+
return keys.map((key) => row[key] ?? null);
|
|
261
|
+
}
|
|
262
|
+
/** contacts.csv rows, from `GET /api/contacts/[icpId]`'s camelCase shape. */
|
|
263
|
+
export function contactRows(contacts) {
|
|
264
|
+
return contacts.map((c) => [
|
|
265
|
+
c.contactId ?? null,
|
|
266
|
+
c.name ?? null,
|
|
267
|
+
c.title ?? null,
|
|
268
|
+
c.email ?? null,
|
|
269
|
+
c.zerobounceStatus ?? null,
|
|
270
|
+
c.linkedinUrl ?? null,
|
|
271
|
+
c.phone ?? null,
|
|
272
|
+
c.companyName ?? null,
|
|
273
|
+
c.companyDomain ?? null,
|
|
274
|
+
c.tier ?? null,
|
|
275
|
+
]);
|
|
276
|
+
}
|
|
277
|
+
/** events.csv rows, actor flattened into its two fields. */
|
|
278
|
+
export function eventRows(events) {
|
|
279
|
+
return events.map((e) => {
|
|
280
|
+
const actor = (e.actor ?? null);
|
|
281
|
+
return [
|
|
282
|
+
...pick(e, ['contact_id', 'kind', 'channel', 'sequence_step', 'sent_at']),
|
|
283
|
+
actor?.type ?? null,
|
|
284
|
+
actor?.id ?? null,
|
|
285
|
+
];
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* How many cells in these rows the probe calls hazardous (AC7's manifest count).
|
|
290
|
+
*
|
|
291
|
+
* It runs the SAME probe the serializer runs, over the same final values, so
|
|
292
|
+
* the number is "cells this bundle neutralized" and not an estimate of it. It
|
|
293
|
+
* counts CELLS, not rows: one row with two hazardous fields is two.
|
|
294
|
+
*
|
|
295
|
+
* "THE SAME FINAL VALUES" MEANS AFTER `toCsvValue`, and the first version of
|
|
296
|
+
* this skipped non-strings, which broke that claim. `serializeCsvCell` stringifies
|
|
297
|
+
* and THEN probes, so a numeric cell is neutralized on its stringified form: a
|
|
298
|
+
* negative number is written `"'-5"` while a raw-cell counter reported zero. The
|
|
299
|
+
* count and the bytes have to be computed from the same value or the manifest
|
|
300
|
+
* is describing a different document than the one on disk. Same class as the CRM
|
|
301
|
+
* path's `str()` coercion — found by sweeping this diff for it.
|
|
302
|
+
*/
|
|
303
|
+
export function countHazards(rows) {
|
|
304
|
+
let count = 0;
|
|
305
|
+
for (const row of rows) {
|
|
306
|
+
for (const cell of row) {
|
|
307
|
+
if (isFormulaHazard(toCsvValue(cell)))
|
|
308
|
+
count += 1;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return count;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The first sequence id in a `GET /api/icps/[id]/sequences` response, or null.
|
|
315
|
+
*
|
|
316
|
+
* Tolerant of both shapes the route could answer with — a bare array or an
|
|
317
|
+
* object wrapping one — because the bundle should not fail to write six good
|
|
318
|
+
* files over the shape of the seventh. It never GUESSES an id: anything it
|
|
319
|
+
* cannot read an id out of is null, which the manifest then records.
|
|
320
|
+
*/
|
|
321
|
+
export function firstSequenceId(body) {
|
|
322
|
+
const list = Array.isArray(body)
|
|
323
|
+
? body
|
|
324
|
+
: Array.isArray(body?.sequences)
|
|
325
|
+
? (body.sequences)
|
|
326
|
+
: [];
|
|
327
|
+
for (const row of list) {
|
|
328
|
+
const id = row?.id;
|
|
329
|
+
if (typeof id === 'string' && id.length > 0)
|
|
330
|
+
return id;
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
/** The staging directory's name prefix, and the displaced bundle's. */
|
|
335
|
+
const STAGING_PREFIX = '.predictable-bundle-';
|
|
336
|
+
const DISPLACED_INFIX = '.replaced-';
|
|
337
|
+
/**
|
|
338
|
+
* Put back a previous bundle that a KILLED process left displaced (r3 item
|
|
339
|
+
* R2-9, P2).
|
|
340
|
+
*
|
|
341
|
+
* ── WHAT IS AND IS NOT CURED, STATED PLAINLY ───────────────────────────────
|
|
342
|
+
*
|
|
343
|
+
* The install is two renames: the old bundle moves aside, then staging moves
|
|
344
|
+
* into its place. Between them the destination does not exist. Round 2 cured
|
|
345
|
+
* every CATCHABLE failure in that gap — EXDEV is gone because staging is a
|
|
346
|
+
* sibling, and the catch restores the displaced directory — but a SIGKILL runs
|
|
347
|
+
* no catch, and the invariant the finding states is stronger: NO OPERATION
|
|
348
|
+
* AFTER THE DESTINATION DISAPPEARS MAY FAIL.
|
|
349
|
+
*
|
|
350
|
+
* THAT INVARIANT IS NOT REACHABLE WITH NODE'S PORTABLE PRIMITIVES, and saying
|
|
351
|
+
* so is more useful than a cure that looks like one. Replacing a non-empty
|
|
352
|
+
* directory in ONE atomic act needs an exchange primitive Node does not expose:
|
|
353
|
+
* Linux's `renameat2(RENAME_EXCHANGE)` or macOS's deprecated,
|
|
354
|
+
* filesystem-specific `exchangedata`. `renameSync` over an existing non-empty
|
|
355
|
+
* directory fails with ENOTEMPTY, so the aside-then-install order is forced.
|
|
356
|
+
* The alternative — making `--out` a SYMLINK to a versioned directory, which
|
|
357
|
+
* IS atomically replaceable — changes what a customer finds on disk and is a
|
|
358
|
+
* product decision, not a bug fix. It is named in the delivery report.
|
|
359
|
+
*
|
|
360
|
+
* SO THE WINDOW IS CLOSED FOR DATA LOSS RATHER THAN FOR EXISTENCE. A process
|
|
361
|
+
* killed inside it leaves the previous bundle intact under a sibling name, and
|
|
362
|
+
* this puts it back. The user-visible failure becomes "the bundle is one run
|
|
363
|
+
* old" instead of "the bundle is gone and its bytes are under a dotted name
|
|
364
|
+
* nobody will look for".
|
|
365
|
+
*
|
|
366
|
+
* IT PUTS IT BACK ON THE NEXT RUN THAT REACHES THE WRITE, NOT ON THE NEXT RUN.
|
|
367
|
+
* Its only call site is the first act of the write phase, and the write phase
|
|
368
|
+
* begins only after every remote read has succeeded. A following run that
|
|
369
|
+
* fails a read — a 500, an offline host — throws before recovery is reached
|
|
370
|
+
* and leaves the destination absent for another round. Nothing is lost on that
|
|
371
|
+
* path either, but the restore is owed to the next CLEAN run, and prose that
|
|
372
|
+
* says "the next run" claims more than the call site delivers.
|
|
373
|
+
*
|
|
374
|
+
* IT RESTORES ONLY WHEN THERE IS NO AMBIGUITY: the destination must be absent
|
|
375
|
+
* (a present destination means the install completed and the leftover is
|
|
376
|
+
* garbage from a later crash) and there must be EXACTLY ONE leftover. Two
|
|
377
|
+
* leftovers mean two crashes, and choosing between them would be a guess about
|
|
378
|
+
* which bundle a user wants. It never deletes: a leftover it cannot place is
|
|
379
|
+
* left where a human can find it.
|
|
380
|
+
*/
|
|
381
|
+
export function recoverDisplacedBundle(outDir) {
|
|
382
|
+
if (existsSync(outDir))
|
|
383
|
+
return null;
|
|
384
|
+
const parent = join(outDir, '..');
|
|
385
|
+
if (!existsSync(parent))
|
|
386
|
+
return null;
|
|
387
|
+
const siblings = readdirSync(parent);
|
|
388
|
+
// A DEFECT IN THIS RECOVERY, FOUND BY ASKING WHAT ELSE MAKES THE DESTINATION
|
|
389
|
+
// ABSENT. A crash is not the only answer: ANOTHER RUN currently between its
|
|
390
|
+
// two moves has exactly the same signature — destination gone, one leftover
|
|
391
|
+
// beside it — and "recovering" that would hand a second process the first
|
|
392
|
+
// one's bundle and make its install fail ENOTEMPTY.
|
|
393
|
+
//
|
|
394
|
+
// A live run always holds its staging directory from mkdtemp until the
|
|
395
|
+
// install, which spans precisely the window where the destination is absent.
|
|
396
|
+
// So a staging sibling means a run is in flight and recovery stands down.
|
|
397
|
+
// It is not a lock and it is not claimed as one — two runs against the same
|
|
398
|
+
// --out were already undefined. It closes the window this function opened.
|
|
399
|
+
if (siblings.some((entry) => entry.startsWith(STAGING_PREFIX)))
|
|
400
|
+
return null;
|
|
401
|
+
const prefix = `${basename(outDir)}${DISPLACED_INFIX}`;
|
|
402
|
+
const leftovers = siblings.filter((entry) => entry.startsWith(prefix)).sort();
|
|
403
|
+
if (leftovers.length !== 1)
|
|
404
|
+
return null;
|
|
405
|
+
renameSync(join(parent, leftovers[0]), outDir);
|
|
406
|
+
return leftovers[0];
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* THE CAMPAIGN-BUNDLE VERB CORE — every read, every serialization, the whole
|
|
410
|
+
* envelope, and the manifest. Shared by BOTH heads (spec §3.4, ticket T10-AC5).
|
|
411
|
+
*
|
|
412
|
+
* WHY IT IS SEPARATE FROM `writeCampaignBundle`. §3.4 gives the two heads
|
|
413
|
+
* different SINKS and the same bundle: "CLI writes them to disk; the MCP head
|
|
414
|
+
* returns them as sized tool results". Everything above the sink — the six
|
|
415
|
+
* reads, the pagination, the completeness accounting, the header grammar, the
|
|
416
|
+
* row cap, the byte cap and `bundle_envelope_exceeded` — is one body of code
|
|
417
|
+
* called by both, because T10-AC5 requires a shared verb core rather than a
|
|
418
|
+
* second implementation that agrees today and drifts next month.
|
|
419
|
+
*
|
|
420
|
+
* NOTHING TOUCHES THE FILESYSTEM HERE, and that is load-bearing rather than
|
|
421
|
+
* incidental: every byte is computed and every cap checked BEFORE the caller's
|
|
422
|
+
* sink runs, which is exactly what lets `writeCampaignBundle` promise that a
|
|
423
|
+
* breach leaves the destination as it was, and lets the MCP head refuse an
|
|
424
|
+
* over-cap bundle without having produced a partial one.
|
|
425
|
+
*
|
|
426
|
+
* THE TRANSPORT IS THE CALLER'S. `ctx.transport` (see `RequestContext`) is how
|
|
427
|
+
* the hosted head reaches the same six routes in-process while the CLI reaches
|
|
428
|
+
* them over HTTP. The reads, their order, their query shapes and their
|
|
429
|
+
* refusals are identical either way — which is the property AC5 asserts by
|
|
430
|
+
* pointing both heads' tests at the same fixtures.
|
|
431
|
+
*/
|
|
432
|
+
export async function assembleCampaignBundle(input) {
|
|
433
|
+
const { ctx, listId, purpose, env } = input;
|
|
434
|
+
const { maxRows, maxBytes } = envelopeCaps(env);
|
|
435
|
+
const id = encodeURIComponent(listId);
|
|
436
|
+
// ── accounts.csv: verbatim bytes, plus its headers ────────────────────────
|
|
437
|
+
const accountsCall = { method: 'GET', path: `/api/export/${id}`, query: { purpose } };
|
|
438
|
+
const accountsRes = await send(ctx, accountsCall);
|
|
439
|
+
const accountsFailure = classify(accountsRes, accountsCall);
|
|
440
|
+
if (accountsFailure)
|
|
441
|
+
throw accountsFailure;
|
|
442
|
+
const exportHeaders = {};
|
|
443
|
+
for (const key of EXPORT_HEADER_KEYS)
|
|
444
|
+
exportHeaders[key] = accountsRes.headers.get(key);
|
|
445
|
+
// ── the JSON reads ────────────────────────────────────────────────────────
|
|
446
|
+
const contacts = (await getJson(ctx, `/api/contacts/${id}`));
|
|
447
|
+
if (!Array.isArray(contacts)) {
|
|
448
|
+
throw new CliError(EXIT.SERVER, 'error: contacts read did not return an array', `list ${listId}`);
|
|
449
|
+
}
|
|
450
|
+
const filterCounts = (await getJson(ctx, `/api/contacts/${id}/filter-counts`));
|
|
451
|
+
// TWO DIFFERENT READS, and §3.4 names both. `stats.json` is the sequences
|
|
452
|
+
// LIST (the per-sequence stats the list read carries); `sequence.json` is ONE
|
|
453
|
+
// sequence's DETAIL. Pointing both at the list read would write the same
|
|
454
|
+
// bytes twice and quietly drop the detail the bundle promises.
|
|
455
|
+
const sequenceList = await getJson(ctx, `/api/icps/${id}/sequences`);
|
|
456
|
+
const selectedSequenceId = input.sequenceId ?? firstSequenceId(sequenceList);
|
|
457
|
+
// NULL IS AN ANSWER, NOT A FAILURE. A list with no sequences has no detail to
|
|
458
|
+
// write; the file is `null` and the manifest says which sequence it is (or
|
|
459
|
+
// that there is none), so a reader can tell "no sequence" from "the wrong one".
|
|
460
|
+
const sequenceDetail = selectedSequenceId === null
|
|
461
|
+
? null
|
|
462
|
+
: await getJson(ctx, `/api/icps/${id}/sequences/${encodeURIComponent(selectedSequenceId)}`);
|
|
463
|
+
const replies = await getJson(ctx, `/api/replies/${id}`);
|
|
464
|
+
// stats.json IS THREE READS, NOT ONE (§3.4, r2 finding 2). Round 1 wrote the
|
|
465
|
+
// sequences list alone. The spec's source column names three, verbatim:
|
|
466
|
+
// GET /api/icps/[id]/sequences (list-stats) + GET /api/billing/usage
|
|
467
|
+
// + GET /api/pipeline/[icpId]
|
|
468
|
+
// Both of the missing two are live in the generated allowlist (stats.usage,
|
|
469
|
+
// stats.pipeline, pending:false) and T6b — the slice that gated the anchored
|
|
470
|
+
// billing/usage read — is in LANDED_SLICES, so they are honestly available.
|
|
471
|
+
// ANCHORED, BECAUSE THE ROUTE REQUIRES IT (r3 R2-1). `/api/billing/usage`
|
|
472
|
+
// is CLIENT-scoped and resolves its client FROM an ICP: §10i's "anchor both"
|
|
473
|
+
// ruling means `?icpId=` is mandatory on every channel, and the handler
|
|
474
|
+
// returns 404 without it. An unanchored call is not a source that refused —
|
|
475
|
+
// it is a malformed request recording itself as unavailability. See
|
|
476
|
+
// READ_CONTRACTS above for every read's requirement and where it was read.
|
|
477
|
+
const usage = await readStatsSource(ctx, `/api/billing/usage`, { icpId: listId });
|
|
478
|
+
const pipeline = await readStatsSource(ctx, `/api/pipeline/${id}`);
|
|
479
|
+
const events = await pageEvents(ctx, listId, maxRows, input.sequenceId);
|
|
480
|
+
// ── serialize ─────────────────────────────────────────────────────────────
|
|
481
|
+
const contactData = contactRows(contacts);
|
|
482
|
+
const eventData = eventRows(events);
|
|
483
|
+
// ── THE COMPLETENESS ACCOUNTING, ONE ADDRESS (r2 findings 5 and 6) ───────
|
|
484
|
+
//
|
|
485
|
+
// Round 1 applied the row cap to contacts and events, and counted hazards in
|
|
486
|
+
// contacts and events. accounts.csv was in neither, so a bundle of 250,001
|
|
487
|
+
// accounts under 100 MB succeeded, and hazardous company names were correctly
|
|
488
|
+
// neutralized in the file while the manifest reported zero of them.
|
|
489
|
+
//
|
|
490
|
+
// THE FIX IS NOT "ADD accounts.csv TO BOTH LISTS". That is the spelling, and
|
|
491
|
+
// it leaves the next file added to the bundle in the same hole. This table
|
|
492
|
+
// ranges over ALL of BUNDLE_FILES and is the single source for the row cap,
|
|
493
|
+
// the manifest's row map and the manifest's hazard map. A file added to
|
|
494
|
+
// BUNDLE_FILES without an entry here fails to typecheck.
|
|
495
|
+
//
|
|
496
|
+
// AND THE EXCLUSIONS ARE STATED, NOT IMPLIED. A JSON document has no rows and
|
|
497
|
+
// is not a spreadsheet cell, so it is genuinely outside both claims — but a
|
|
498
|
+
// reader of manifest.json must be able to see that it was excluded ON PURPOSE
|
|
499
|
+
// and why, rather than infer it from a number that quietly does not cover it.
|
|
500
|
+
const BYTES_SELF_REFERENTIAL = 'This map lives inside manifest.json, so manifest.json\'s own byte length depends on the digits written here — a self-reference with no fixed point. Its size is reported by the envelope total and by the CLI result, both measured after the file exists.';
|
|
501
|
+
const NO_ROWS = 'JSON document — it has no row set, so no row count exists. Inside the byte envelope, outside the row cap.';
|
|
502
|
+
const NOT_A_SPREADSHEET = 'JSON document — a formula-looking string in JSON is not a spreadsheet cell, so the CSV hazard probe does not range over it.';
|
|
503
|
+
// accounts.csv's numbers come from the SERVER, which is the only party that
|
|
504
|
+
// still had the values: this bundle holds accounts.csv as verbatim bytes and
|
|
505
|
+
// never parses it. Missing or malformed is a REFUSAL, not a skip — the row cap
|
|
506
|
+
// is a safety claim, and a cap that silently stops applying to a file is the
|
|
507
|
+
// same defect as never having covered it.
|
|
508
|
+
//
|
|
509
|
+
// ── THE GRAMMAR, AND WHY `Number()` WAS NOT ONE (r3 item R2-2) ─────────────
|
|
510
|
+
//
|
|
511
|
+
// Round 2 accepted any finite `Number(raw)`. That admits `-1`, `1.5`, `0x10`,
|
|
512
|
+
// `1e6`, ` 7 ` and `Infinity`'s neighbours — and because the client
|
|
513
|
+
// deliberately never parses accounts.csv, nothing downstream contradicts them.
|
|
514
|
+
// `X-Export-Eligible-Count: -1` walks a 250,001-row file straight past
|
|
515
|
+
// `rows > maxRows`; `X-Export-Formula-Hazard-Count: -5` subtracts five from
|
|
516
|
+
// the manifest's total. A cap defeated by a minus sign is not a cap.
|
|
517
|
+
//
|
|
518
|
+
// THE INVARIANT: a number the bundle's safety claims rest on is a NON-NEGATIVE
|
|
519
|
+
// DECIMAL INTEGER, written in the shortest form of itself, or the bundle
|
|
520
|
+
// refuses. `String(n) === raw` is the whole grammar: it rejects signs,
|
|
521
|
+
// fractions, exponents, radix prefixes, padding and surrounding whitespace in
|
|
522
|
+
// one comparison, and it cannot drift from the value actually used because the
|
|
523
|
+
// value is what generates the comparison.
|
|
524
|
+
const HEADER_NUMBER = /^(0|[1-9][0-9]*)$/;
|
|
525
|
+
const accountsCount = (key) => {
|
|
526
|
+
const raw = exportHeaders[key] ?? null;
|
|
527
|
+
if (raw === null || !HEADER_NUMBER.test(raw)) {
|
|
528
|
+
throw new CliError(EXIT.SERVER, 'error: accounts.csv cannot be accounted for', `${key} is ${raw === null ? 'absent' : JSON.stringify(raw)}, not a non-negative decimal integer; ` +
|
|
529
|
+
'the bundle will not claim a row cap or a hazard count it cannot compute. ' +
|
|
530
|
+
'THE LIKELY CAUSE IS A DEPLOY SKEW: this CLI is newer than the dashboard it is ' +
|
|
531
|
+
'talking to, and that dashboard does not send this header yet. THE REMEDY IS TO ' +
|
|
532
|
+
'DEPLOY THE DASHBOARD (or point PREDICTABLE_BASE_URL at one already carrying ' +
|
|
533
|
+
'T8). Refusing is deliberate: an under-reported count would let a truncated or ' +
|
|
534
|
+
'unneutralized accounts.csv ship inside a bundle that claims otherwise.');
|
|
535
|
+
}
|
|
536
|
+
return Number(raw);
|
|
537
|
+
};
|
|
538
|
+
// EVERY HEADER-SOURCED NUMBER, NOT ONLY THE TWO THE FINDING NAMED. The
|
|
539
|
+
// population is `EXPORT_HEADER_KEYS` minus the one non-numeric key, derived
|
|
540
|
+
// from that list rather than re-typed, so a header added to the bundle cannot
|
|
541
|
+
// be added outside the grammar. The four exclusion counts are not inputs to a
|
|
542
|
+
// cap — but they ARE the manifest's claim about what the server filtered out,
|
|
543
|
+
// and a claim of `-3 organizations excluded` is corrupt whether or not
|
|
544
|
+
// anything divides by it.
|
|
545
|
+
//
|
|
546
|
+
// AND THAT IS THE WHOLE POPULATION IN THIS CLI, not just in this file:
|
|
547
|
+
// `headers.get` appears in exactly one other place, `http.ts:146`, where
|
|
548
|
+
// `retry-after` is interpolated into an error MESSAGE as a string. It is
|
|
549
|
+
// never parsed to a number and no claim rests on it, so it is outside this
|
|
550
|
+
// rule — measured, not assumed by locality.
|
|
551
|
+
const NON_NUMERIC_HEADERS = new Set(['x-export-purpose']);
|
|
552
|
+
for (const key of EXPORT_HEADER_KEYS) {
|
|
553
|
+
if (!NON_NUMERIC_HEADERS.has(key))
|
|
554
|
+
accountsCount(key);
|
|
555
|
+
}
|
|
556
|
+
// AND THE HAZARD COUNT IS BOUNDED BY THE CELLS THAT COULD CARRY ONE. A
|
|
557
|
+
// well-formed integer is still a lie if it is larger than the file can hold,
|
|
558
|
+
// and this number is added into the manifest's total, so an inflated header
|
|
559
|
+
// inflates a claim about every other file too.
|
|
560
|
+
//
|
|
561
|
+
// THE BOUND COSTS NO PARSE, which matters because never parsing accounts.csv
|
|
562
|
+
// is a standing invariant here (bundle.ts:26-31). A hazardous cell holds at
|
|
563
|
+
// least one byte, and every cell after the first is preceded by at least one
|
|
564
|
+
// delimiter byte, so a file of B bytes cannot hold more than (B + 1) / 2
|
|
565
|
+
// hazardous cells. It is a ceiling, not an estimate: it is satisfied by any
|
|
566
|
+
// honest count and violated only by one the file could not have produced.
|
|
567
|
+
const accountsBytes = Buffer.byteLength(accountsRes.text, 'utf8');
|
|
568
|
+
const maxAccountsHazards = Math.floor((accountsBytes + 1) / 2);
|
|
569
|
+
const declaredHazards = accountsCount('x-export-formula-hazard-count');
|
|
570
|
+
if (declaredHazards > maxAccountsHazards) {
|
|
571
|
+
throw new CliError(EXIT.SERVER, 'error: accounts.csv cannot be accounted for', `x-export-formula-hazard-count is ${declaredHazards}, but accounts.csv is ${accountsBytes} bytes ` +
|
|
572
|
+
`and cannot hold more than ${maxAccountsHazards} hazardous cells; the bundle will not carry a ` +
|
|
573
|
+
'count the file it describes could not have produced');
|
|
574
|
+
}
|
|
575
|
+
const accounting = {
|
|
576
|
+
'accounts.csv': { rows: accountsCount('x-export-eligible-count'), hazards: accountsCount('x-export-formula-hazard-count') },
|
|
577
|
+
'contacts.csv': { rows: contactData.length, hazards: countHazards(contactData) },
|
|
578
|
+
'events.csv': { rows: eventData.length, hazards: countHazards(eventData) },
|
|
579
|
+
'sequence.json': { rows: null, rowsWhy: NO_ROWS, hazards: null, hazardsWhy: NOT_A_SPREADSHEET },
|
|
580
|
+
'replies.json': { rows: null, rowsWhy: NO_ROWS, hazards: null, hazardsWhy: NOT_A_SPREADSHEET },
|
|
581
|
+
'stats.json': { rows: null, rowsWhy: NO_ROWS, hazards: null, hazardsWhy: NOT_A_SPREADSHEET },
|
|
582
|
+
'manifest.json': { rows: null, rowsWhy: NO_ROWS, hazards: null, hazardsWhy: NOT_A_SPREADSHEET },
|
|
583
|
+
};
|
|
584
|
+
// The cap ranges over every file that HAS a row count — derived from the
|
|
585
|
+
// table above, never from a second hand-written list of file names.
|
|
586
|
+
for (const file of BUNDLE_FILES) {
|
|
587
|
+
const rows = accounting[file].rows;
|
|
588
|
+
if (rows !== null && rows > maxRows) {
|
|
589
|
+
throw new EnvelopeExceeded(file, `${rows} rows exceeds ${maxRows}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const bodies = {
|
|
593
|
+
'accounts.csv': accountsRes.text,
|
|
594
|
+
'contacts.csv': serializeCsvRows([CONTACT_COLUMNS, ...contactData]),
|
|
595
|
+
'events.csv': serializeCsvRows([EVENT_COLUMNS, ...eventData]),
|
|
596
|
+
'sequence.json': `${JSON.stringify(sequenceDetail, null, 2)}\n`,
|
|
597
|
+
'replies.json': `${JSON.stringify(replies, null, 2)}\n`,
|
|
598
|
+
'stats.json': `${JSON.stringify({
|
|
599
|
+
sequences: { ok: true, data: sequenceList },
|
|
600
|
+
usage,
|
|
601
|
+
pipeline,
|
|
602
|
+
}, null, 2)}\n`,
|
|
603
|
+
};
|
|
604
|
+
// CONTACTS EQUALITY (AC2). `filter-counts.all` is the server's own total for
|
|
605
|
+
// this list, computed by a different query than the table read. Asserting
|
|
606
|
+
// they agree is what makes "the bundle is complete" a claim rather than a
|
|
607
|
+
// hope — a truncated contacts read is silent otherwise, and §3.4 names
|
|
608
|
+
// silent truncation as a known failure class.
|
|
609
|
+
//
|
|
610
|
+
// AND IT FAILS CLOSED (r2 finding 9). Round 1 read
|
|
611
|
+
// `typeof expectedContacts === 'number' && expectedContacts !== …`, so an
|
|
612
|
+
// absent `all`, a string "1200", or any other schema drift SKIPPED the check
|
|
613
|
+
// entirely and a truncated 1000-row read was written as a complete bundle.
|
|
614
|
+
// That is a guard whose own enabling condition comes from the data it is
|
|
615
|
+
// guarding: the input that breaks the data also turns the guard off. An
|
|
616
|
+
// expected total that is missing or unusable is a REFUSAL, never a skip.
|
|
617
|
+
const expectedContacts = filterCounts.all;
|
|
618
|
+
if (typeof expectedContacts !== 'number' || !Number.isFinite(expectedContacts)) {
|
|
619
|
+
throw new CliError(EXIT.SERVER, 'error: contacts.csv completeness cannot be proven', `the list's filter-counts total is ${JSON.stringify(expectedContacts) ?? 'undefined'}, not a number; the bundle will not claim a completeness it cannot check`);
|
|
620
|
+
}
|
|
621
|
+
if (expectedContacts !== contactData.length) {
|
|
622
|
+
throw new CliError(EXIT.SERVER, 'error: contacts.csv is incomplete', `contacts.csv has ${contactData.length} rows, the list's filter-counts total is ${expectedContacts}`);
|
|
623
|
+
}
|
|
624
|
+
const manifest = {
|
|
625
|
+
schema: 'predictable.campaign-bundle/1',
|
|
626
|
+
list_id: listId,
|
|
627
|
+
generated_at: input.now,
|
|
628
|
+
// The purpose is recorded because `reporting_history` is a DELIBERATE
|
|
629
|
+
// suppression bypass (AC5, §6): the file has to say so, or a bundle that
|
|
630
|
+
// skipped the suppression filter is indistinguishable from one that did not.
|
|
631
|
+
purpose,
|
|
632
|
+
sequence_id: selectedSequenceId,
|
|
633
|
+
/** Set when --sequence narrowed events.csv; null when it holds every event. */
|
|
634
|
+
events_filtered_by_sequence: input.sequenceId ?? null,
|
|
635
|
+
export_headers: exportHeaders,
|
|
636
|
+
// EVERY EMITTED FILE IS IN THE FILES MAP, and the one that cannot carry a
|
|
637
|
+
// number says so (r3 item R2-3, Class A). Round 2 FILTERED manifest.json
|
|
638
|
+
// out: the bundle emitted seven files and the map listed six, with nothing
|
|
639
|
+
// in the artifact saying which of "excluded on purpose" and "forgotten" a
|
|
640
|
+
// consumer was looking at. Rows and hazards already answered that question
|
|
641
|
+
// through `completeness_scope`; bytes did not, and inventing a second
|
|
642
|
+
// mechanism for the third claim is how the next one goes missing too.
|
|
643
|
+
//
|
|
644
|
+
// WHY THE VALUE IS null RATHER THAN A NUMBER. This map is INSIDE
|
|
645
|
+
// manifest.json, so manifest.json's own byte length depends on the digits
|
|
646
|
+
// written here — a self-reference with no fixed point. `null` plus a stated
|
|
647
|
+
// reason is the honest form; a number would be wrong the moment it was
|
|
648
|
+
// written. The bundle's OTHER byte claims do cover it: the envelope total
|
|
649
|
+
// below sums all seven, and `BundleResult.files` reports all seven measured
|
|
650
|
+
// after the manifest exists.
|
|
651
|
+
files: Object.fromEntries(BUNDLE_FILES.map((f) => [
|
|
652
|
+
f,
|
|
653
|
+
f === 'manifest.json' ? null : Buffer.byteLength(bodies[f] ?? '', 'utf8'),
|
|
654
|
+
])),
|
|
655
|
+
// EVERY BUNDLE FILE APPEARS IN EVERY CLAIM, or the claim says why not.
|
|
656
|
+
// Both maps below range over ALL of BUNDLE_FILES from the accounting table,
|
|
657
|
+
// so no file can be silently outside a number the manifest reports.
|
|
658
|
+
rows: Object.fromEntries(BUNDLE_FILES.map((f) => [f, accounting[f].rows])),
|
|
659
|
+
contacts_filter_counts: filterCounts,
|
|
660
|
+
// The TOTAL stays a number, which is the shape AC7 names — it now includes
|
|
661
|
+
// accounts.csv, whose count comes from the producing route's own header.
|
|
662
|
+
formula_hazard: BUNDLE_FILES.reduce((sum, f) => sum + (accounting[f].hazards ?? 0), 0),
|
|
663
|
+
formula_hazard_by_file: Object.fromEntries(BUNDLE_FILES.map((f) => [f, accounting[f].hazards])),
|
|
664
|
+
// THE SCOPE OF THOSE TWO NUMBERS, IN THE ARTIFACT. A count that quietly
|
|
665
|
+
// does not cover a file is a lie by omission; a count that names what it
|
|
666
|
+
// does not cover, and why, is a measurement.
|
|
667
|
+
completeness_scope: {
|
|
668
|
+
rows_excluded: Object.fromEntries(BUNDLE_FILES.filter((f) => accounting[f].rowsWhy).map((f) => [f, accounting[f].rowsWhy])),
|
|
669
|
+
formula_hazard_excluded: Object.fromEntries(BUNDLE_FILES.filter((f) => accounting[f].hazardsWhy).map((f) => [f, accounting[f].hazardsWhy])),
|
|
670
|
+
bytes_excluded: {
|
|
671
|
+
'manifest.json': BYTES_SELF_REFERENTIAL,
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
// WHICH SOURCES stats.json actually carries (r2 finding 2). A source that
|
|
675
|
+
// was refused is named here as well as in the file, so "the bundle has no
|
|
676
|
+
// usage data" and "usage was unavailable, for this reason" stay distinct.
|
|
677
|
+
stats_sources: {
|
|
678
|
+
sequences: 'ok',
|
|
679
|
+
usage: usage.ok ? 'ok' : `unavailable: ${usage.unavailable}`,
|
|
680
|
+
pipeline: pipeline.ok ? 'ok' : `unavailable: ${pipeline.unavailable}`,
|
|
681
|
+
},
|
|
682
|
+
envelope: { max_rows: maxRows, max_bytes: maxBytes },
|
|
683
|
+
};
|
|
684
|
+
bodies['manifest.json'] = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
685
|
+
const total = BUNDLE_FILES.reduce((sum, f) => sum + Buffer.byteLength(bodies[f] ?? '', 'utf8'), 0);
|
|
686
|
+
if (total > maxBytes) {
|
|
687
|
+
// NAMED BY THE LARGEST FILE, because "the bundle is too big" tells an
|
|
688
|
+
// operator nothing they can act on.
|
|
689
|
+
const largest = [...BUNDLE_FILES].sort((a, b) => Buffer.byteLength(bodies[b] ?? '', 'utf8') - Buffer.byteLength(bodies[a] ?? '', 'utf8'))[0];
|
|
690
|
+
throw new EnvelopeExceeded(largest, `bundle is ${total} bytes, over the ${maxBytes}-byte cap`);
|
|
691
|
+
}
|
|
692
|
+
return {
|
|
693
|
+
bodies,
|
|
694
|
+
manifest,
|
|
695
|
+
files: Object.fromEntries(BUNDLE_FILES.map((f) => [f, Buffer.byteLength(bodies[f] ?? '', 'utf8')])),
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* `export campaign` as the CLI serves it: the shared core above, then the disk
|
|
700
|
+
* install. The core produces the bytes; this function is only the sink.
|
|
701
|
+
*/
|
|
702
|
+
export async function writeCampaignBundle(input) {
|
|
703
|
+
const { outDir } = input;
|
|
704
|
+
const { bodies, manifest, files } = await assembleCampaignBundle(input);
|
|
705
|
+
// ── NO PARTIAL BUNDLE ON DISK (AC4) ──────────────────────────────────────
|
|
706
|
+
//
|
|
707
|
+
// Every byte is computed and every cap checked BEFORE anything is written,
|
|
708
|
+
// and even then the files land in a staging directory that is renamed into
|
|
709
|
+
// place as the last act. A breach therefore leaves the destination exactly as
|
|
710
|
+
// it was — nonexistent, or the previous bundle, untouched. Writing as we go
|
|
711
|
+
// and deleting on failure would leave a half-bundle behind for any failure
|
|
712
|
+
// between the write and the delete.
|
|
713
|
+
// STAGING LIVES BESIDE THE DESTINATION, NOT IN THE OS TEMP DIR (r2 finding
|
|
714
|
+
// 10). It used to stage under tmpdir(), and when /tmp and the output path are
|
|
715
|
+
// on different filesystems renameSync throws EXDEV — AFTER rmSync has already
|
|
716
|
+
// deleted the previous bundle and BEFORE any new one exists. The comment above
|
|
717
|
+
// promises the destination is left exactly as it was, and on that path it was
|
|
718
|
+
// destroyed. A same-filesystem rename cannot fail that way.
|
|
719
|
+
//
|
|
720
|
+
// The old bundle is also moved ASIDE rather than deleted, and removed only
|
|
721
|
+
// once the new one is in place, so the destroying operation is the LAST one
|
|
722
|
+
// and nothing can fail after it.
|
|
723
|
+
const write = input.writeFile ?? ((path, contents) => writeFileSync(path, contents, 'utf8'));
|
|
724
|
+
const parent = join(outDir, '..');
|
|
725
|
+
mkdirSync(parent, { recursive: true });
|
|
726
|
+
recoverDisplacedBundle(outDir);
|
|
727
|
+
const rename = input.rename ?? renameSync;
|
|
728
|
+
const staging = mkdtempSync(join(parent, STAGING_PREFIX));
|
|
729
|
+
const displaced = `${outDir}${DISPLACED_INFIX}${basename(staging).slice(STAGING_PREFIX.length)}`;
|
|
730
|
+
let moved = false;
|
|
731
|
+
try {
|
|
732
|
+
for (const file of BUNDLE_FILES)
|
|
733
|
+
write(join(staging, file), bodies[file] ?? '');
|
|
734
|
+
if (existsSync(outDir)) {
|
|
735
|
+
rename(outDir, displaced);
|
|
736
|
+
moved = true;
|
|
737
|
+
}
|
|
738
|
+
rename(staging, outDir);
|
|
739
|
+
}
|
|
740
|
+
catch (err) {
|
|
741
|
+
rmSync(staging, { recursive: true, force: true });
|
|
742
|
+
// Put the previous bundle back before surfacing the failure: a breach must
|
|
743
|
+
// leave the destination exactly as it was.
|
|
744
|
+
if (moved && !existsSync(outDir))
|
|
745
|
+
renameSync(displaced, outDir);
|
|
746
|
+
else if (moved)
|
|
747
|
+
rmSync(displaced, { recursive: true, force: true });
|
|
748
|
+
throw err;
|
|
749
|
+
}
|
|
750
|
+
if (moved)
|
|
751
|
+
rmSync(displaced, { recursive: true, force: true });
|
|
752
|
+
return { directory: outDir, files, manifest };
|
|
753
|
+
}
|
|
754
|
+
//# sourceMappingURL=bundle.js.map
|