@oxygen-agent/cli 1.1011.1091 → 1.1013.15
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 +1 -1
- package/dist/command-manifest.d.ts +3 -1
- package/dist/command-manifest.js +11 -0
- package/dist/customer-operation-commands.js +7 -3
- package/dist/diagnostic-upload-main.d.ts +1 -0
- package/dist/diagnostic-upload-main.js +2 -0
- package/dist/diagnostic-uploader.d.ts +3 -0
- package/dist/diagnostic-uploader.js +68 -0
- package/dist/diagnostics.d.ts +22 -0
- package/dist/diagnostics.js +219 -0
- package/dist/http-client.d.ts +7 -0
- package/dist/http-client.js +12 -2
- package/dist/index.js +72 -34
- package/dist/transcript.js +1 -1
- package/node_modules/@oxygen/formula/dist/expression.js +16 -0
- package/node_modules/@oxygen/shared/dist/attempt-provenance.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/attempt-provenance.js +15 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.js +2 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +2 -1
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +28 -4
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +3 -3
- package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +3 -3
- package/node_modules/@oxygen/shared/dist/contracts/geo.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/contracts/geo.js +160 -0
- package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +4 -3
- package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.js +10 -4
- package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/cost-estimate.js +23 -8
- package/node_modules/@oxygen/shared/dist/diagnostic-context.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-context.js +17 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-credentials.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-credentials.js +12 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-lock.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-lock.js +60 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-receipt.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-receipt.js +22 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage-lock.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage-lock.js +39 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage.js +233 -0
- package/node_modules/@oxygen/shared/dist/diagnostics.d.ts +49 -0
- package/node_modules/@oxygen/shared/dist/diagnostics.js +253 -0
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +2 -1
- package/node_modules/@oxygen/shared/dist/external-effect-arming.d.ts +54 -0
- package/node_modules/@oxygen/shared/dist/external-effect-arming.js +90 -0
- package/node_modules/@oxygen/shared/dist/geo.d.ts +264 -0
- package/node_modules/@oxygen/shared/dist/geo.js +342 -0
- package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/linkedin-countries.js +1 -1
- package/node_modules/@oxygen/shared/dist/managed-email-branding.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/managed-email-branding.js +81 -0
- package/node_modules/@oxygen/shared/dist/onboarding-first-leads.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/onboarding-first-leads.js +25 -0
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +4 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +20 -24
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +15 -16
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +3 -3
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +3 -3
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +2 -0
- package/node_modules/@oxygen/shared/dist/product-doctrine.js +6 -5
- package/node_modules/@oxygen/shared/dist/recipes.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/recipes.js +1 -0
- package/node_modules/@oxygen/shared/dist/repricing.d.ts +4 -14
- package/node_modules/@oxygen/shared/dist/repricing.js +25 -64
- package/node_modules/@oxygen/shared/dist/research-depth.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/research-depth.js +7 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +2 -1
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +4 -1
- package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.d.ts +46 -0
- package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.js +51 -1
- package/node_modules/@oxygen/shared/package.json +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { chmod } from "node:fs/promises";
|
|
2
|
+
/** Server mutex for shared host volumes; kernel locks span container PID namespaces. */
|
|
3
|
+
export async function acquireDiagnosticStorageLock(path, waitMs = 0) {
|
|
4
|
+
// Ask the supported Node runtime directly: server bundlers rewrite even
|
|
5
|
+
// createRequire calls to an unsupported URL external for node:sqlite.
|
|
6
|
+
// Browser-side Server Action analysis never needs to resolve this built-in.
|
|
7
|
+
const { DatabaseSync } = process.getBuiltinModule("node:sqlite");
|
|
8
|
+
const databasePath = path + ".sqlite";
|
|
9
|
+
const deadline = performance.now() + waitMs;
|
|
10
|
+
let database;
|
|
11
|
+
while (true) {
|
|
12
|
+
try {
|
|
13
|
+
// SQLite owns every descriptor: closing a separate descriptor for this inode
|
|
14
|
+
// would release POSIX locks held by another connection in this process.
|
|
15
|
+
database = new DatabaseSync(databasePath, { timeout: 0 });
|
|
16
|
+
await chmod(databasePath, 0o600);
|
|
17
|
+
database.exec("BEGIN EXCLUSIVE");
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
database?.close();
|
|
22
|
+
database = undefined;
|
|
23
|
+
const busy = error && typeof error === "object" && "errcode" in error && (error.errcode === 5 || error.errcode === 6);
|
|
24
|
+
if (!busy || performance.now() >= deadline)
|
|
25
|
+
return null;
|
|
26
|
+
// A synchronous SQLite busy timeout would block the same-process owner
|
|
27
|
+
// from finishing its asynchronous fsync and releasing this lock.
|
|
28
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(20, Math.max(1, deadline - performance.now()))));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const held = database;
|
|
32
|
+
let closed = false;
|
|
33
|
+
return async () => {
|
|
34
|
+
if (!closed) {
|
|
35
|
+
closed = true;
|
|
36
|
+
held.close();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { S3Client } from "@aws-sdk/client-s3";
|
|
2
|
+
import { type DiagnosticRecord } from "./diagnostics.js";
|
|
3
|
+
export type DiagnosticStoredReceipt = {
|
|
4
|
+
key: string;
|
|
5
|
+
sha256: string;
|
|
6
|
+
bytes: number;
|
|
7
|
+
compressed_bytes: number;
|
|
8
|
+
record: DiagnosticRecord;
|
|
9
|
+
};
|
|
10
|
+
type Store = {
|
|
11
|
+
client: S3Client;
|
|
12
|
+
bucket: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function observeDiagnosticStorage(observer: (receipt: DiagnosticStoredReceipt) => Promise<void>): void;
|
|
15
|
+
export declare function startDiagnosticRetry(): void;
|
|
16
|
+
export declare function diagnosticsConfigured(): boolean;
|
|
17
|
+
export declare function diagnosticStore(): Store;
|
|
18
|
+
export declare function diagnosticObjectKey(record: Pick<DiagnosticRecord, "id" | "organization_id">): string;
|
|
19
|
+
export declare function encodeDiagnostic(record: DiagnosticRecord): {
|
|
20
|
+
bytes: Buffer;
|
|
21
|
+
sha256: string;
|
|
22
|
+
compressed: Buffer;
|
|
23
|
+
};
|
|
24
|
+
export declare function decodeDiagnostic(compressed: Uint8Array, expectedHash?: string): DiagnosticRecord;
|
|
25
|
+
export declare function readDiagnostic(id: string, organizationId: string | null): Promise<DiagnosticStoredReceipt>;
|
|
26
|
+
/** Durable local write precedes upload. Retries replay telemetry bytes only. */
|
|
27
|
+
export declare function storeDiagnostic(record: DiagnosticRecord): Promise<DiagnosticStoredReceipt | null>;
|
|
28
|
+
export declare function flushDiagnosticSpool(): Promise<void>;
|
|
29
|
+
/** A metadata-only fallback for an unsupported value never claims full capture. */
|
|
30
|
+
export declare function partialDiagnostic(record: DiagnosticRecord, reason: string): DiagnosticRecord;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, readdir, rename, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { gzipSync, gunzipSync } from "node:zlib";
|
|
5
|
+
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
6
|
+
import { createDiagnosticRecord, credentialSafeDiagnostic, DIAGNOSTIC_MAX_BYTES, DIAGNOSTIC_RECORD_MAX_BYTES, DIAGNOSTIC_RETENTION_MS, diagnosticId, diagnosticSummary, parseDiagnosticRecord, sanitizeDiagnosticContent } from "./diagnostics.js";
|
|
7
|
+
import { acquireDiagnosticStorageLock } from "./diagnostic-storage-lock.js";
|
|
8
|
+
import { log } from "./log.js";
|
|
9
|
+
import { recordTelemetryCounter } from "./telemetry.js";
|
|
10
|
+
let cached;
|
|
11
|
+
let warned = false;
|
|
12
|
+
let flushing = null;
|
|
13
|
+
let timer;
|
|
14
|
+
let onStored;
|
|
15
|
+
export function observeDiagnosticStorage(observer) {
|
|
16
|
+
onStored = observer;
|
|
17
|
+
startDiagnosticRetry();
|
|
18
|
+
void flushDiagnosticSpool();
|
|
19
|
+
}
|
|
20
|
+
export function startDiagnosticRetry() {
|
|
21
|
+
if (!timer && diagnosticsConfigured()) {
|
|
22
|
+
timer = setInterval(() => { void flushDiagnosticSpool(); }, 30_000);
|
|
23
|
+
timer.unref();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function diagnosticsConfigured() {
|
|
27
|
+
return process.env.OXYGEN_DIAGNOSTICS_ENABLED !== "0" && Boolean(process.env.OXYGEN_DIAGNOSTICS_S3_ENDPOINT
|
|
28
|
+
&& process.env.OXYGEN_DIAGNOSTICS_S3_BUCKET && process.env.OXYGEN_DIAGNOSTICS_S3_ACCESS_KEY_ID
|
|
29
|
+
&& process.env.OXYGEN_DIAGNOSTICS_S3_SECRET_ACCESS_KEY && process.env.OXYGEN_DIAGNOSTICS_SPOOL_DIR);
|
|
30
|
+
}
|
|
31
|
+
export function diagnosticStore() {
|
|
32
|
+
if (!diagnosticsConfigured())
|
|
33
|
+
throw new Error("Diagnostics storage is not configured");
|
|
34
|
+
const endpoint = process.env.OXYGEN_DIAGNOSTICS_S3_ENDPOINT;
|
|
35
|
+
const bucket = process.env.OXYGEN_DIAGNOSTICS_S3_BUCKET;
|
|
36
|
+
const accessKeyId = process.env.OXYGEN_DIAGNOSTICS_S3_ACCESS_KEY_ID;
|
|
37
|
+
const secretAccessKey = process.env.OXYGEN_DIAGNOSTICS_S3_SECRET_ACCESS_KEY;
|
|
38
|
+
const key = createHash("sha256").update([endpoint, bucket, accessKeyId, secretAccessKey].join("\0")).digest("hex");
|
|
39
|
+
if (cached?.key !== key)
|
|
40
|
+
cached = { key, store: { bucket, client: new S3Client({
|
|
41
|
+
endpoint, region: "us-east-1", forcePathStyle: true, maxAttempts: 2,
|
|
42
|
+
credentials: { accessKeyId, secretAccessKey },
|
|
43
|
+
}) } };
|
|
44
|
+
return cached.store;
|
|
45
|
+
}
|
|
46
|
+
export function diagnosticObjectKey(record) {
|
|
47
|
+
if (!diagnosticId(record.id) || (record.organization_id !== null && !diagnosticId(record.organization_id)))
|
|
48
|
+
throw new Error("Invalid diagnostic scope");
|
|
49
|
+
return `records/${record.organization_id ?? "unknown"}/${record.id}.json.gz`;
|
|
50
|
+
}
|
|
51
|
+
export function encodeDiagnostic(record) {
|
|
52
|
+
const bytes = Buffer.from(JSON.stringify(sanitizeDiagnosticContent(credentialSafeDiagnostic(parseDiagnosticRecord(record)))));
|
|
53
|
+
if (bytes.length > DIAGNOSTIC_RECORD_MAX_BYTES)
|
|
54
|
+
throw new Error("Diagnostic record capacity exceeded");
|
|
55
|
+
return { bytes, sha256: createHash("sha256").update(bytes).digest("hex"), compressed: gzipSync(bytes) };
|
|
56
|
+
}
|
|
57
|
+
export function decodeDiagnostic(compressed, expectedHash) {
|
|
58
|
+
const bytes = gunzipSync(compressed, { maxOutputLength: DIAGNOSTIC_RECORD_MAX_BYTES });
|
|
59
|
+
if (expectedHash && createHash("sha256").update(bytes).digest("hex") !== expectedHash)
|
|
60
|
+
throw new Error("Diagnostic integrity mismatch");
|
|
61
|
+
const record = parseDiagnosticRecord(JSON.parse(bytes.toString("utf8")));
|
|
62
|
+
if (Date.now() - Date.parse(record.started_at) >= DIAGNOSTIC_RETENTION_MS)
|
|
63
|
+
throw Object.assign(new Error("Diagnostic content has expired"), { code: "diagnostic_content_expired" });
|
|
64
|
+
return record;
|
|
65
|
+
}
|
|
66
|
+
export async function readDiagnostic(id, organizationId) {
|
|
67
|
+
const { client, bucket } = diagnosticStore();
|
|
68
|
+
const key = diagnosticObjectKey({ id, organization_id: organizationId });
|
|
69
|
+
const result = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }), { abortSignal: AbortSignal.timeout(15_000) });
|
|
70
|
+
if (!result.Body)
|
|
71
|
+
throw new Error("Diagnostic body missing");
|
|
72
|
+
const compressed = await result.Body.transformToByteArray();
|
|
73
|
+
if (!result.Metadata?.sha256 || !/^[0-9a-f]{64}$/.test(result.Metadata.sha256))
|
|
74
|
+
throw new Error("Diagnostic integrity manifest missing");
|
|
75
|
+
const record = decodeDiagnostic(compressed, result.Metadata.sha256);
|
|
76
|
+
const bytes = gunzipSync(compressed, { maxOutputLength: DIAGNOSTIC_RECORD_MAX_BYTES }).length;
|
|
77
|
+
if (String(bytes) !== result.Metadata.bytes)
|
|
78
|
+
throw new Error("Diagnostic byte manifest mismatch");
|
|
79
|
+
if (record.id !== id || record.organization_id !== organizationId)
|
|
80
|
+
throw new Error("Diagnostic scope mismatch");
|
|
81
|
+
return { key, sha256: result.Metadata.sha256, bytes, compressed_bytes: compressed.length, record };
|
|
82
|
+
}
|
|
83
|
+
/** Durable local write precedes upload. Retries replay telemetry bytes only. */
|
|
84
|
+
export async function storeDiagnostic(record) {
|
|
85
|
+
recordTelemetryCounter("oxygen.diagnostics.attempted", 1, { source: record.surface, layer: record.layer });
|
|
86
|
+
if (!diagnosticsConfigured()) {
|
|
87
|
+
if (!warned && process.env.NODE_ENV !== "test") {
|
|
88
|
+
warned = true;
|
|
89
|
+
log("warn", "diagnostics.not_configured", { capture_state: "unavailable" });
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const directory = process.env.OXYGEN_DIAGNOSTICS_SPOOL_DIR;
|
|
94
|
+
let path;
|
|
95
|
+
try {
|
|
96
|
+
let encoded;
|
|
97
|
+
try {
|
|
98
|
+
encoded = encodeDiagnostic(record);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
record = partialDiagnostic(record, "unsupported_or_oversized_content");
|
|
102
|
+
encoded = encodeDiagnostic(record);
|
|
103
|
+
}
|
|
104
|
+
record = parseDiagnosticRecord(JSON.parse(encoded.bytes.toString("utf8")));
|
|
105
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
106
|
+
// Admission and expiry share one lock across web/worker processes on a host.
|
|
107
|
+
const lockPath = join(directory, ".admission.lock");
|
|
108
|
+
const release = await acquireDiagnosticStorageLock(lockPath, 5_000);
|
|
109
|
+
if (!release)
|
|
110
|
+
throw new Error("Diagnostic spool admission busy");
|
|
111
|
+
try {
|
|
112
|
+
const { used } = await inspectDiagnosticSpool(directory);
|
|
113
|
+
if (used + encoded.compressed.length > DIAGNOSTIC_MAX_BYTES)
|
|
114
|
+
throw new Error("Diagnostic spool is full");
|
|
115
|
+
path = join(directory, `${record.id}.json.gz`);
|
|
116
|
+
const temporary = join(directory, `${record.id}.${randomUUID()}.tmp`);
|
|
117
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
118
|
+
try {
|
|
119
|
+
await handle.writeFile(encoded.compressed);
|
|
120
|
+
await handle.sync();
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
await handle.close();
|
|
124
|
+
}
|
|
125
|
+
await rename(temporary, path);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
await release();
|
|
129
|
+
}
|
|
130
|
+
startDiagnosticRetry();
|
|
131
|
+
const receipt = await uploadEncodedDiagnostic(record, encoded);
|
|
132
|
+
await unlink(path).catch(() => undefined);
|
|
133
|
+
return receipt;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Neither storage credentials nor upstream exception text belong in logs.
|
|
137
|
+
log("warn", path ? "diagnostics.capture_deferred" : "diagnostics.capture_lost", { diagnostic_id: record.id, reason: path ? "upload_failed" : "spool_admission_failed" });
|
|
138
|
+
recordTelemetryCounter(path ? "oxygen.diagnostics.deferred" : "oxygen.diagnostics.lost", 1, { source: record.surface });
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function uploadEncodedDiagnostic(record, encoded) {
|
|
143
|
+
const captured = Date.parse(record.started_at);
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
if (now - captured >= 72 * 60 * 60 * 1000)
|
|
146
|
+
throw new Error("Diagnostic upload has expired");
|
|
147
|
+
// S3 day-based expiry rounds to the next UTC midnight. Tagged age bands
|
|
148
|
+
// leave that final day inside the 30-day deadline measured from capture.
|
|
149
|
+
const retentionDays = Math.min(29, 29 - (Math.floor(now / 86_400_000) - Math.floor(captured / 86_400_000)));
|
|
150
|
+
const { client, bucket } = diagnosticStore();
|
|
151
|
+
const key = diagnosticObjectKey(record);
|
|
152
|
+
await client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: encoded.compressed,
|
|
153
|
+
ContentType: "application/gzip",
|
|
154
|
+
Tagging: `diagnostic_retention_days=${retentionDays}`,
|
|
155
|
+
Metadata: { sha256: encoded.sha256, bytes: String(encoded.bytes.length), expires_at: new Date(Date.parse(record.started_at) + DIAGNOSTIC_RETENTION_MS).toISOString() },
|
|
156
|
+
}), { abortSignal: AbortSignal.timeout(15_000) });
|
|
157
|
+
log("info", "diagnostics.capture_stored", { ...diagnosticSummary(record), org_id: record.organization_id, user_id: record.user_id,
|
|
158
|
+
diagnostic_key: key, bytes: encoded.bytes.length, compressed_bytes: encoded.compressed.length, sha256: encoded.sha256 });
|
|
159
|
+
recordTelemetryCounter("oxygen.diagnostics.stored", 1, { source: record.surface, layer: record.layer });
|
|
160
|
+
const receipt = { key, sha256: encoded.sha256, bytes: encoded.bytes.length, compressed_bytes: encoded.compressed.length, record };
|
|
161
|
+
if (onStored)
|
|
162
|
+
await onStored(receipt);
|
|
163
|
+
return receipt;
|
|
164
|
+
}
|
|
165
|
+
export function flushDiagnosticSpool() {
|
|
166
|
+
if (flushing)
|
|
167
|
+
return flushing;
|
|
168
|
+
flushing = (async () => {
|
|
169
|
+
if (!diagnosticsConfigured())
|
|
170
|
+
return;
|
|
171
|
+
const directory = process.env.OXYGEN_DIAGNOSTICS_SPOOL_DIR;
|
|
172
|
+
const release = await acquireDiagnosticStorageLock(join(directory, ".upload.lock"));
|
|
173
|
+
if (!release)
|
|
174
|
+
return;
|
|
175
|
+
try {
|
|
176
|
+
// Idle workers enforce the same expiry as new admissions. The admission
|
|
177
|
+
// lock protects cleanup against a writer replacing an old record path.
|
|
178
|
+
const admission = await acquireDiagnosticStorageLock(join(directory, ".admission.lock"));
|
|
179
|
+
if (!admission)
|
|
180
|
+
return;
|
|
181
|
+
let files;
|
|
182
|
+
try {
|
|
183
|
+
files = (await inspectDiagnosticSpool(directory)).files.slice(0, 20);
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
await admission();
|
|
187
|
+
}
|
|
188
|
+
for (const name of files) {
|
|
189
|
+
const path = join(directory, name);
|
|
190
|
+
try {
|
|
191
|
+
const compressed = await readFile(path);
|
|
192
|
+
const record = decodeDiagnostic(compressed);
|
|
193
|
+
await uploadEncodedDiagnostic(record, encodeDiagnostic(record));
|
|
194
|
+
await unlink(path).catch(() => undefined);
|
|
195
|
+
}
|
|
196
|
+
catch { /* retain failed uploads; admission expires them visibly */ }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
await release();
|
|
201
|
+
}
|
|
202
|
+
})().catch(() => undefined).finally(() => { flushing = null; });
|
|
203
|
+
return flushing;
|
|
204
|
+
}
|
|
205
|
+
/** Caller holds admission lock; count retained bytes and expire actual filenames. */
|
|
206
|
+
async function inspectDiagnosticSpool(directory) {
|
|
207
|
+
let used = 0;
|
|
208
|
+
const files = [];
|
|
209
|
+
for (const name of await readdir(directory)) {
|
|
210
|
+
const record = /^[0-9a-f-]+\.json\.gz$/.test(name);
|
|
211
|
+
if (!record && !/^[0-9a-f-]+(?:\.[0-9a-f-]+)?\.tmp$/.test(name))
|
|
212
|
+
continue;
|
|
213
|
+
const path = join(directory, name);
|
|
214
|
+
const info = await stat(path).catch(() => null);
|
|
215
|
+
if (!info)
|
|
216
|
+
continue;
|
|
217
|
+
if (Date.now() - info.mtimeMs >= 72 * 60 * 60 * 1000) {
|
|
218
|
+
await unlink(path);
|
|
219
|
+
log("warn", "diagnostics.capture_lost", { reason: "spool_expired", bytes: info.size });
|
|
220
|
+
recordTelemetryCounter("oxygen.diagnostics.lost", 1, { reason: "spool_expired" });
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
used += info.size;
|
|
224
|
+
if (record)
|
|
225
|
+
files.push(name);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { used, files };
|
|
229
|
+
}
|
|
230
|
+
/** A metadata-only fallback for an unsupported value never claims full capture. */
|
|
231
|
+
export function partialDiagnostic(record, reason) {
|
|
232
|
+
return createDiagnosticRecord({ ...record, input: null, output: null, capture_state: "partial", metadata: { capture_reason: reason } });
|
|
233
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Internal diagnostic evidence, distinct from customer run state and usage counts. */
|
|
2
|
+
export declare const DIAGNOSTIC_VERSION = 1;
|
|
3
|
+
export declare const DIAGNOSTIC_RETENTION_MS: number;
|
|
4
|
+
export declare const DIAGNOSTIC_INVOCATION_HEADER = "X-Oxygen-Invocation-Id";
|
|
5
|
+
export declare const DIAGNOSTIC_UPLOAD_CHUNK_BYTES: number;
|
|
6
|
+
export declare const DIAGNOSTIC_MAX_BYTES: number;
|
|
7
|
+
export declare const DIAGNOSTIC_RECORD_MAX_BYTES: number;
|
|
8
|
+
export declare const DIAGNOSTIC_UPLOAD_MAX_CHUNKS = 17;
|
|
9
|
+
export type DiagnosticOutcome = "ok" | "invalid_input" | "refused" | "platform_error" | "network_error" | "cancelled" | "pending";
|
|
10
|
+
export type DiagnosticRecord = {
|
|
11
|
+
version: 1;
|
|
12
|
+
id: string;
|
|
13
|
+
invocation_id: string;
|
|
14
|
+
parent_id: string | null;
|
|
15
|
+
layer: "invocation" | "api" | "worker" | "protocol";
|
|
16
|
+
surface: "cli" | "mcp" | "web" | "worker";
|
|
17
|
+
operation: string;
|
|
18
|
+
started_at: string;
|
|
19
|
+
ended_at: string | null;
|
|
20
|
+
outcome: DiagnosticOutcome;
|
|
21
|
+
error_code: string | null;
|
|
22
|
+
status: number | null;
|
|
23
|
+
organization_id: string | null;
|
|
24
|
+
user_id: string | null;
|
|
25
|
+
identity_verified: boolean;
|
|
26
|
+
client_version: string | null;
|
|
27
|
+
trace_id: string | null;
|
|
28
|
+
traceparent: string | null;
|
|
29
|
+
capture_state: "complete" | "partial";
|
|
30
|
+
input: unknown;
|
|
31
|
+
output: unknown;
|
|
32
|
+
metadata: Record<string, unknown>;
|
|
33
|
+
};
|
|
34
|
+
export declare function diagnosticId(value: unknown): string | null;
|
|
35
|
+
export declare function diagnosticOutcome(ok: boolean, status?: number | null, code?: string | null): DiagnosticOutcome;
|
|
36
|
+
export declare function createDiagnosticRecord(input: {
|
|
37
|
+
[K in keyof DiagnosticRecord]?: DiagnosticRecord[K] | undefined;
|
|
38
|
+
} & Pick<DiagnosticRecord, "surface" | "operation">): DiagnosticRecord;
|
|
39
|
+
export declare function sanitizeDiagnosticText(value: string, knownSecrets?: readonly string[]): string;
|
|
40
|
+
/** No business-content, array, string, field-count or depth truncation. */
|
|
41
|
+
export declare function sanitizeDiagnosticContent(value: unknown, knownSecrets?: readonly string[]): unknown;
|
|
42
|
+
export declare function sanitizeDiagnosticArgv(argv: string[]): string[];
|
|
43
|
+
export declare function parseDiagnosticRecord(value: unknown): DiagnosticRecord;
|
|
44
|
+
export declare function diagnosticSummary(record: DiagnosticRecord): Record<string, string | number | boolean | null>;
|
|
45
|
+
export declare function credentialSafeDiagnostic(record: DiagnosticRecord): DiagnosticRecord;
|
|
46
|
+
/** Bound the diagnostic branch of a response tee without delaying its consumer. */
|
|
47
|
+
export declare function readDiagnosticProtocolText(body: ReadableStream<Uint8Array> | null): Promise<string | null>;
|
|
48
|
+
/** Finite POST SSE is decoded before credential/binary filtering, like JSON. */
|
|
49
|
+
export declare function parseDiagnosticProtocolBody(text: string | null, contentType?: string | null): unknown;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { credentialBearingOperation } from "./diagnostic-credentials.js";
|
|
3
|
+
import { redactSecretsInString } from "./redaction.js";
|
|
4
|
+
/** Internal diagnostic evidence, distinct from customer run state and usage counts. */
|
|
5
|
+
export const DIAGNOSTIC_VERSION = 1;
|
|
6
|
+
export const DIAGNOSTIC_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
7
|
+
export const DIAGNOSTIC_INVOCATION_HEADER = "X-Oxygen-Invocation-Id";
|
|
8
|
+
export const DIAGNOSTIC_UPLOAD_CHUNK_BYTES = 1024 * 1024;
|
|
9
|
+
export const DIAGNOSTIC_MAX_BYTES = 512 * 1024 * 1024;
|
|
10
|
+
// Total spool capacity is separate from the bounded JSON materialization of one
|
|
11
|
+
// record. Larger contents explicitly become partial evidence, never a full claim.
|
|
12
|
+
export const DIAGNOSTIC_RECORD_MAX_BYTES = 16 * 1024 * 1024;
|
|
13
|
+
export const DIAGNOSTIC_UPLOAD_MAX_CHUNKS = 17; // Includes gzip/envelope overhead.
|
|
14
|
+
export function diagnosticId(value) {
|
|
15
|
+
return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) ? value.toLowerCase() : null;
|
|
16
|
+
}
|
|
17
|
+
export function diagnosticOutcome(ok, status, code) {
|
|
18
|
+
if (ok)
|
|
19
|
+
return "ok";
|
|
20
|
+
if (code && /^(network_|.*timeout$)/.test(code))
|
|
21
|
+
return "network_error";
|
|
22
|
+
if (code && /^(cancelled|aborted|interrupted)$/.test(code))
|
|
23
|
+
return "cancelled";
|
|
24
|
+
if (status === 401 || status === 403 || status === 402 || status === 409 || status === 429
|
|
25
|
+
|| (code && /approval|budget|credit|quota|rate_limit|permission|forbidden|not_logged_in|unauthorized|update_required|read_only/.test(code)))
|
|
26
|
+
return "refused";
|
|
27
|
+
if ((status && status >= 400 && status < 500) || (code && /invalid|missing|unknown_(command|option|tool)|not_found|commander\./.test(code)))
|
|
28
|
+
return "invalid_input";
|
|
29
|
+
return "platform_error";
|
|
30
|
+
}
|
|
31
|
+
export function createDiagnosticRecord(input) {
|
|
32
|
+
const id = diagnosticId(input.id) ?? randomUUID();
|
|
33
|
+
return {
|
|
34
|
+
version: DIAGNOSTIC_VERSION, id, invocation_id: diagnosticId(input.invocation_id) ?? id,
|
|
35
|
+
parent_id: input.parent_id ?? null, layer: input.layer ?? "invocation", surface: input.surface,
|
|
36
|
+
operation: input.operation, started_at: input.started_at ?? new Date().toISOString(),
|
|
37
|
+
ended_at: input.ended_at ?? null, outcome: input.outcome ?? "pending", error_code: input.error_code ?? null,
|
|
38
|
+
status: input.status ?? null, organization_id: input.organization_id ?? null, user_id: input.user_id ?? null,
|
|
39
|
+
identity_verified: input.identity_verified ?? false, client_version: input.client_version ?? null,
|
|
40
|
+
trace_id: input.trace_id ?? null, traceparent: input.traceparent ?? null,
|
|
41
|
+
capture_state: input.capture_state ?? "complete", input: input.input ?? null, output: input.output ?? null,
|
|
42
|
+
metadata: input.metadata ?? {},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const SECRET_FIELD = /(?:^|_)(?:authorization|cookie|cookies|password|passwd|passphrase|secret|api_key|access_key|private_key|token|otp|security_code|connection_string|ciphertext|dsn|connection_uri|database_url|x_key|signature)(?:$|_)/i;
|
|
46
|
+
function credentialField(key) {
|
|
47
|
+
return SECRET_FIELD.test(key.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[- .]/g, "_"));
|
|
48
|
+
}
|
|
49
|
+
function binaryField(key) {
|
|
50
|
+
return /(?:^|_)(?:base64|b64)(?:$|_)/i.test(key.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[- .]/g, "_"));
|
|
51
|
+
}
|
|
52
|
+
function binaryMetadata(value) {
|
|
53
|
+
return { withheld: "binary", bytes: Buffer.byteLength(value, "base64") };
|
|
54
|
+
}
|
|
55
|
+
export function sanitizeDiagnosticText(value, knownSecrets = []) {
|
|
56
|
+
// CLI stdout and MCP text often contain serialized JSON rather than objects.
|
|
57
|
+
// Filter its credential fields without changing the surrounding business text.
|
|
58
|
+
for (const secret of knownSecrets)
|
|
59
|
+
if (secret)
|
|
60
|
+
value = value.split(secret).join("[credential excluded]");
|
|
61
|
+
// Text blocks may contain serialized tool results, including binary blocks.
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(value);
|
|
64
|
+
if (parsed && typeof parsed === "object") {
|
|
65
|
+
const sanitized = sanitizeDiagnosticContent(parsed, knownSecrets);
|
|
66
|
+
if (JSON.stringify(parsed) !== JSON.stringify(sanitized))
|
|
67
|
+
value = JSON.stringify(sanitized);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch { /* preserve ordinary business text */ }
|
|
71
|
+
value = value.replace(/data:[\w/+.-]+(?:;[\w=.-]+)*;base64,[A-Za-z0-9+/=]+/g, match => JSON.stringify(binaryMetadata(match.slice(match.indexOf(",") + 1))));
|
|
72
|
+
const redacted = redactSecretsInString(value)
|
|
73
|
+
.replace(/([?&#][\w.-]*(?:api[_-]?key|token|secret|password|signature|credential|code|auth)[\w.-]*=)[^&#\s"'<>]+/gi, "$1[credential excluded]")
|
|
74
|
+
.replace(/\b(?:oxy_(?:live|sess|user)_|gh[pousr]_|xox[baprs]-)[A-Za-z0-9_.-]+/g, "[credential excluded]");
|
|
75
|
+
return redacted.replace(/("(?:[^"\\]|\\.)*"\s*:\s*)("(?:[^"\\]|\\.)*")/g, (match, prefix) => {
|
|
76
|
+
try {
|
|
77
|
+
return credentialField(JSON.parse(prefix.slice(0, prefix.lastIndexOf(":"))).trim()) ? `${prefix}"[credential excluded]"` : match;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return match;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** No business-content, array, string, field-count or depth truncation. */
|
|
85
|
+
export function sanitizeDiagnosticContent(value, knownSecrets = []) {
|
|
86
|
+
const ancestors = new Set();
|
|
87
|
+
const walk = (item) => {
|
|
88
|
+
if (typeof item === "string")
|
|
89
|
+
return sanitizeDiagnosticText(item, knownSecrets);
|
|
90
|
+
if (item === null || typeof item === "number" || typeof item === "boolean")
|
|
91
|
+
return item;
|
|
92
|
+
if (item === undefined)
|
|
93
|
+
return null;
|
|
94
|
+
if (typeof item !== "object")
|
|
95
|
+
throw new Error("Non-JSON diagnostic value");
|
|
96
|
+
if (ancestors.has(item))
|
|
97
|
+
throw new Error("Cyclic diagnostic value");
|
|
98
|
+
ancestors.add(item);
|
|
99
|
+
try {
|
|
100
|
+
if (item instanceof Date)
|
|
101
|
+
return item.toISOString();
|
|
102
|
+
if (item instanceof Error)
|
|
103
|
+
return walk({ name: item.name, message: item.message, stack: item.stack });
|
|
104
|
+
if (ArrayBuffer.isView(item) || item instanceof ArrayBuffer)
|
|
105
|
+
return { withheld: "binary", bytes: item.byteLength };
|
|
106
|
+
if (Array.isArray(item))
|
|
107
|
+
return item.map(walk);
|
|
108
|
+
if (("type" in item && (item.type === "image" || item.type === "audio")) && "data" in item && typeof item.data === "string") {
|
|
109
|
+
return { type: item.type, withheld: "binary", bytes: Buffer.byteLength(item.data, "base64"), ...("mimeType" in item ? { mimeType: item.mimeType } : {}) };
|
|
110
|
+
}
|
|
111
|
+
const result = Object.create(null);
|
|
112
|
+
for (const [key, entry] of Object.entries(item)) {
|
|
113
|
+
const encoded = typeof entry === "string" && (binaryField(key)
|
|
114
|
+
|| ((key === "data" || key === "content") && "encoding" in item && item.encoding === "base64")
|
|
115
|
+
|| (key === "content" && "filename" in item && "content_type" in item)
|
|
116
|
+
|| (key === "blob" && "uri" in item && typeof item.uri === "string"));
|
|
117
|
+
result[key] = credentialField(key) ? "[credential excluded]" : encoded ? binaryMetadata(entry) : walk(entry);
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
ancestors.delete(item);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
return walk(value);
|
|
126
|
+
}
|
|
127
|
+
export function sanitizeDiagnosticArgv(argv) {
|
|
128
|
+
let credentialValue = false;
|
|
129
|
+
let binaryValue = false;
|
|
130
|
+
return argv.map((argument) => {
|
|
131
|
+
if (credentialValue) {
|
|
132
|
+
credentialValue = false;
|
|
133
|
+
return "[credential excluded]";
|
|
134
|
+
}
|
|
135
|
+
if (binaryValue) {
|
|
136
|
+
binaryValue = false;
|
|
137
|
+
return JSON.stringify(binaryMetadata(argument));
|
|
138
|
+
}
|
|
139
|
+
if (argument.startsWith("--")) {
|
|
140
|
+
const [flag] = argument.slice(2).split("=");
|
|
141
|
+
if (credentialField(flag)) {
|
|
142
|
+
if (argument.includes("="))
|
|
143
|
+
return argument.slice(0, argument.indexOf("=") + 1) + "[credential excluded]";
|
|
144
|
+
credentialValue = true;
|
|
145
|
+
}
|
|
146
|
+
else if (binaryField(flag)) {
|
|
147
|
+
if (argument.includes("="))
|
|
148
|
+
return argument.slice(0, argument.indexOf("=") + 1) + JSON.stringify(binaryMetadata(argument.slice(argument.indexOf("=") + 1)));
|
|
149
|
+
binaryValue = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// JSON-valued arguments need keyed filtering as well as token scanning.
|
|
153
|
+
if (/^[\[{]/.test(argument)) {
|
|
154
|
+
try {
|
|
155
|
+
return JSON.stringify(sanitizeDiagnosticContent(JSON.parse(argument)));
|
|
156
|
+
}
|
|
157
|
+
catch { /* ordinary text */ }
|
|
158
|
+
}
|
|
159
|
+
return sanitizeDiagnosticText(argument);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
export function parseDiagnosticRecord(value) {
|
|
163
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
164
|
+
throw new Error("Invalid diagnostic record");
|
|
165
|
+
const row = value;
|
|
166
|
+
if (row.version !== 1 || !diagnosticId(row.id) || !diagnosticId(row.invocation_id)
|
|
167
|
+
|| !["cli", "mcp", "web", "worker"].includes(row.surface)
|
|
168
|
+
|| !["invocation", "api", "worker", "protocol"].includes(row.layer)
|
|
169
|
+
|| !["ok", "invalid_input", "refused", "platform_error", "network_error", "cancelled", "pending"].includes(row.outcome)
|
|
170
|
+
|| !["complete", "partial"].includes(row.capture_state)
|
|
171
|
+
|| typeof row.operation !== "string" || row.operation.length > 512
|
|
172
|
+
|| !Number.isFinite(Date.parse(row.started_at))
|
|
173
|
+
|| (row.ended_at !== null && !Number.isFinite(Date.parse(row.ended_at)))
|
|
174
|
+
|| typeof row.identity_verified !== "boolean"
|
|
175
|
+
|| (row.organization_id !== null && !diagnosticId(row.organization_id))
|
|
176
|
+
|| (row.user_id !== null && typeof row.user_id !== "string")
|
|
177
|
+
|| (row.parent_id !== null && !diagnosticId(row.parent_id))
|
|
178
|
+
|| (row.status !== null && (!Number.isInteger(row.status) || row.status < 0 || row.status > 599))
|
|
179
|
+
|| (row.error_code !== null && (typeof row.error_code !== "string" || row.error_code.length > 512))
|
|
180
|
+
|| !row.metadata || typeof row.metadata !== "object" || Array.isArray(row.metadata))
|
|
181
|
+
throw new Error("Invalid diagnostic record");
|
|
182
|
+
return row;
|
|
183
|
+
}
|
|
184
|
+
export function diagnosticSummary(record) {
|
|
185
|
+
return {
|
|
186
|
+
diagnostic_id: record.id, invocation_id: record.invocation_id, parent_id: record.parent_id,
|
|
187
|
+
layer: record.layer, source: record.surface, operation: record.operation, outcome: record.outcome,
|
|
188
|
+
error_code: record.error_code, status: record.status, capture_state: record.capture_state,
|
|
189
|
+
identity_verified: record.identity_verified, client_version: record.client_version, trace_id: record.trace_id,
|
|
190
|
+
duration_ms: record.ended_at ? Math.max(0, Date.parse(record.ended_at) - Date.parse(record.started_at)) : null,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
export function credentialSafeDiagnostic(record) {
|
|
194
|
+
const input = record.input;
|
|
195
|
+
if (Array.isArray(input)) {
|
|
196
|
+
const members = input.map(member => credentialSafeDiagnostic({ ...record, input: member }));
|
|
197
|
+
const withheld = (value) => Boolean(value && typeof value === "object" && "withheld" in value && value.withheld === "credential_bearing_operation");
|
|
198
|
+
return { ...record,
|
|
199
|
+
input: members.some(member => withheld(member.input)) ? { withheld: "credential_bearing_operation" } : input,
|
|
200
|
+
output: members.some(member => withheld(member.output)) ? { withheld: "credential_bearing_operation" } : record.output };
|
|
201
|
+
}
|
|
202
|
+
const params = input && typeof input === "object" && "params" in input ? input.params : null;
|
|
203
|
+
const toolName = params && typeof params.name === "string" ? params.name : record.operation;
|
|
204
|
+
const args = params?.arguments ?? input;
|
|
205
|
+
const selectedName = ["oxygen_capabilities_read", "oxygen_capabilities_call"].includes(toolName) && args && typeof args === "object" && "tool" in args
|
|
206
|
+
? String(args.tool) : toolName;
|
|
207
|
+
return { ...record, input: credentialBearingOperation(selectedName, "input") ? { withheld: "credential_bearing_operation" } : record.input,
|
|
208
|
+
output: credentialBearingOperation(selectedName, "output") ? { withheld: "credential_bearing_operation" } : record.output };
|
|
209
|
+
}
|
|
210
|
+
/** Bound the diagnostic branch of a response tee without delaying its consumer. */
|
|
211
|
+
export async function readDiagnosticProtocolText(body) {
|
|
212
|
+
if (!body)
|
|
213
|
+
return null;
|
|
214
|
+
const reader = body.getReader();
|
|
215
|
+
const chunks = [];
|
|
216
|
+
let bytes = 0;
|
|
217
|
+
try {
|
|
218
|
+
while (true) {
|
|
219
|
+
const part = await reader.read();
|
|
220
|
+
if (part.done)
|
|
221
|
+
return Buffer.concat(chunks, bytes).toString("utf8");
|
|
222
|
+
bytes += part.value.byteLength;
|
|
223
|
+
if (bytes > DIAGNOSTIC_RECORD_MAX_BYTES) {
|
|
224
|
+
void reader.cancel().catch(() => undefined);
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
chunks.push(part.value);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
reader.releaseLock();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** Finite POST SSE is decoded before credential/binary filtering, like JSON. */
|
|
238
|
+
export function parseDiagnosticProtocolBody(text, contentType) {
|
|
239
|
+
if (text === null)
|
|
240
|
+
return null;
|
|
241
|
+
if (contentType?.includes("text/event-stream")) {
|
|
242
|
+
return text.split(/\r?\n\r?\n/).filter(Boolean).map(event => ({
|
|
243
|
+
event: event.split(/\r?\n/).find(line => line.startsWith("event:"))?.slice(6).trim() ?? null,
|
|
244
|
+
data: parseDiagnosticProtocolBody(event.split(/\r?\n/).filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart()).join("\n")),
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
return JSON.parse(text);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return text;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -56,7 +56,8 @@ export const ENRICHMENT_INTENT_TAXONOMY = [
|
|
|
56
56
|
f("job_start_date", "Job start date", null),
|
|
57
57
|
f("person_location", "Location", "person.location"),
|
|
58
58
|
f("person_bio", "Bio", "person.bio"),
|
|
59
|
-
|
|
59
|
+
// Blitz publishes no follower count; only the fallback lane fills it (OXP-1.27.6).
|
|
60
|
+
f("person_linkedin_followers", "LinkedIn followers (only when the fallback lane answers)", "person.linkedin_followers", "numeric"),
|
|
60
61
|
f("education", "Education", null, "jsonb"),
|
|
61
62
|
f("school_name", "School", null),
|
|
62
63
|
],
|