@yanolja-next/noya-sdk 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 +107 -0
- package/dist/src/adapters.d.ts +25 -0
- package/dist/src/adapters.js +244 -0
- package/dist/src/issueCompiler.d.ts +17 -0
- package/dist/src/issueCompiler.js +81 -0
- package/dist/src/orchestrator.d.ts +59 -0
- package/dist/src/orchestrator.js +594 -0
- package/dist/src/redaction.d.ts +6 -0
- package/dist/src/redaction.js +53 -0
- package/dist/src/runner.d.ts +26 -0
- package/dist/src/runner.js +423 -0
- package/dist/src/sdk.d.ts +64 -0
- package/dist/src/sdk.js +172 -0
- package/dist/src/sentry.d.ts +7 -0
- package/dist/src/sentry.js +125 -0
- package/dist/src/server.d.ts +10 -0
- package/dist/src/server.js +498 -0
- package/dist/src/store.d.ts +18 -0
- package/dist/src/store.js +58 -0
- package/dist/src/types.d.ts +147 -0
- package/dist/src/types.js +1 -0
- package/package.json +46 -0
- package/public/app.js +185 -0
- package/public/index.html +80 -0
- package/public/styles.css +253 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { redactValue } from "./redaction.js";
|
|
6
|
+
export async function runOnce(config) {
|
|
7
|
+
const capabilities = config.capabilities ?? {
|
|
8
|
+
collect: { enabled: true },
|
|
9
|
+
fix: { enabled: true },
|
|
10
|
+
};
|
|
11
|
+
const nextUrl = new URL("/runner/jobs/next", config.server_url);
|
|
12
|
+
nextUrl.searchParams.set("project_key", config.project_key);
|
|
13
|
+
nextUrl.searchParams.set("runner_id", config.runner_id ?? "agency-platform-local-runner");
|
|
14
|
+
nextUrl.searchParams.set("capabilities", JSON.stringify(capabilities));
|
|
15
|
+
const auth = authHeader(config);
|
|
16
|
+
const next = await getJson(nextUrl, auth);
|
|
17
|
+
if (!next.job)
|
|
18
|
+
return { idle: true };
|
|
19
|
+
const job = next.job;
|
|
20
|
+
try {
|
|
21
|
+
const result = job.type === "collect" ? await collect(job, config) : await fix(job, config);
|
|
22
|
+
return await postJson(new URL(`/runner/jobs/${job.id}/complete`, config.server_url), result, auth);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
await postJson(new URL(`/runner/jobs/${job.id}/fail`, config.server_url), {
|
|
26
|
+
message: error instanceof Error ? error.message : String(error),
|
|
27
|
+
}, auth);
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function collect(job, config) {
|
|
32
|
+
const report = job.payload.report;
|
|
33
|
+
const collectorPlan = job.payload.collector_plan;
|
|
34
|
+
const repoPath = config.repo_path ?? collectorPlan?.repo_path;
|
|
35
|
+
const context = {};
|
|
36
|
+
const policy = [];
|
|
37
|
+
for (const source of collectorPlan?.sources ?? []) {
|
|
38
|
+
const decision = evaluateSource(source, report);
|
|
39
|
+
policy.push(decision);
|
|
40
|
+
if (decision.decision !== "allow")
|
|
41
|
+
continue;
|
|
42
|
+
if (source.type === "fixture_logs") {
|
|
43
|
+
context.logs = limitBytes(limitEntries(collectFixtureLogs(report), source.max_entries), source.max_bytes);
|
|
44
|
+
}
|
|
45
|
+
else if (source.type === "fixture_domain") {
|
|
46
|
+
context.domain_snapshot = limitBytes(collectDomainSnapshot(report), source.max_bytes);
|
|
47
|
+
}
|
|
48
|
+
else if (source.type === "repo_snapshot") {
|
|
49
|
+
context.repo_snapshot = limitBytes(snapshotRepo(repoPath), source.max_bytes);
|
|
50
|
+
}
|
|
51
|
+
else if (source.type === "command_ref" && source.command_ref === "workspace_manifest_v1") {
|
|
52
|
+
context.workspace_manifest = limitBytes(snapshotRepo(repoPath), source.max_bytes);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
mode: "collect",
|
|
57
|
+
context: redactValue(context, { mode: "strict" }),
|
|
58
|
+
policy,
|
|
59
|
+
summary: "Runner collected redacted logs, domain hints, and repository metadata. No production writes were performed.",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function limitEntries(items, maxEntries) {
|
|
63
|
+
if (typeof maxEntries !== "number" || maxEntries < 0)
|
|
64
|
+
return items;
|
|
65
|
+
return items.slice(0, maxEntries);
|
|
66
|
+
}
|
|
67
|
+
function limitBytes(value, maxBytes) {
|
|
68
|
+
if (typeof maxBytes !== "number" || maxBytes < 0)
|
|
69
|
+
return value;
|
|
70
|
+
const json = JSON.stringify(value);
|
|
71
|
+
const bytes = Buffer.byteLength(json, "utf8");
|
|
72
|
+
if (bytes <= maxBytes)
|
|
73
|
+
return value;
|
|
74
|
+
return {
|
|
75
|
+
truncated: true,
|
|
76
|
+
original_bytes: bytes,
|
|
77
|
+
max_bytes: maxBytes,
|
|
78
|
+
preview: json.slice(0, maxBytes),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function fix(job, config) {
|
|
82
|
+
const repoPath = path.resolve(config.repo_path ?? "/workspace/agency-platform");
|
|
83
|
+
const packageJson = path.join(repoPath, "package.json");
|
|
84
|
+
const changedFiles = [];
|
|
85
|
+
const tests = [];
|
|
86
|
+
const transcript = [
|
|
87
|
+
"Local fix mode prepared issue context for an approved command ref.",
|
|
88
|
+
"Push, deploy, merge, and production writes were intentionally skipped by Noya.",
|
|
89
|
+
];
|
|
90
|
+
if (fs.existsSync(packageJson)) {
|
|
91
|
+
tests.push("Detected package.json; recommended verification: package-level focused tests before opening a real PR.");
|
|
92
|
+
}
|
|
93
|
+
const draftDir = path.resolve(config.artifact_dir ?? ".noya/artifacts/agency-platform", "agent-work", job.id);
|
|
94
|
+
fs.mkdirSync(draftDir, { recursive: true });
|
|
95
|
+
const contextPath = path.join(draftDir, "context.md");
|
|
96
|
+
const jobPath = path.join(draftDir, "job.json");
|
|
97
|
+
fs.writeFileSync(contextPath, String(job.payload.context_markdown ?? ""));
|
|
98
|
+
fs.writeFileSync(jobPath, `${JSON.stringify(job, null, 2)}\n`);
|
|
99
|
+
changedFiles.push(contextPath);
|
|
100
|
+
const branch = `noya/${job.issue_id}`;
|
|
101
|
+
const workspace = await prepareAgentWorkspace({
|
|
102
|
+
config,
|
|
103
|
+
repoPath,
|
|
104
|
+
draftDir,
|
|
105
|
+
branch,
|
|
106
|
+
});
|
|
107
|
+
tests.push(...workspace.tests);
|
|
108
|
+
transcript.push(...workspace.transcript);
|
|
109
|
+
const agentResult = await runFixAgent(config, {
|
|
110
|
+
cwd: workspace.cwd,
|
|
111
|
+
contextPath,
|
|
112
|
+
jobPath,
|
|
113
|
+
resultPath: path.join(draftDir, "agent-result.json"),
|
|
114
|
+
});
|
|
115
|
+
const checkoutChanges = await collectCheckoutChanges(workspace.checkoutPath, draftDir);
|
|
116
|
+
changedFiles.push(...(agentResult.changed_files ?? []));
|
|
117
|
+
changedFiles.push(...checkoutChanges.changedFiles);
|
|
118
|
+
tests.push(...(agentResult.tests ?? []));
|
|
119
|
+
tests.push(...checkoutChanges.tests);
|
|
120
|
+
if (workspace.checkoutPath)
|
|
121
|
+
changedFiles.push(workspace.checkoutPath);
|
|
122
|
+
return {
|
|
123
|
+
mode: "fix",
|
|
124
|
+
branch,
|
|
125
|
+
changed_files: [...new Set(changedFiles)],
|
|
126
|
+
tests,
|
|
127
|
+
transcript: [...transcript, agentResult.transcript, ...checkoutChanges.transcript]
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.join("\n\n"),
|
|
130
|
+
checkout: workspace.checkoutPath
|
|
131
|
+
? {
|
|
132
|
+
path: workspace.checkoutPath,
|
|
133
|
+
source_repo: repoPath,
|
|
134
|
+
branch,
|
|
135
|
+
status_artifact: checkoutChanges.statusPath,
|
|
136
|
+
patch_artifact: checkoutChanges.patchPath,
|
|
137
|
+
}
|
|
138
|
+
: null,
|
|
139
|
+
agent: agentResult.agent,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
async function collectCheckoutChanges(checkoutPath, draftDir) {
|
|
143
|
+
if (!checkoutPath)
|
|
144
|
+
return { changedFiles: [], tests: [], transcript: [] };
|
|
145
|
+
const status = await spawnCommand("git", ["status", "--short", "--untracked-files=all"], {
|
|
146
|
+
cwd: checkoutPath,
|
|
147
|
+
timeoutMs: 30_000,
|
|
148
|
+
env: minimalEnv(),
|
|
149
|
+
});
|
|
150
|
+
const changedFiles = parseGitStatusFiles(status.stdout);
|
|
151
|
+
const statusPath = path.join(draftDir, "checkout-status.txt");
|
|
152
|
+
fs.writeFileSync(statusPath, status.stdout || "No checkout changes detected.\n");
|
|
153
|
+
let patchPath;
|
|
154
|
+
const diff = await spawnCommand("git", ["diff", "--", "."], {
|
|
155
|
+
cwd: checkoutPath,
|
|
156
|
+
timeoutMs: 30_000,
|
|
157
|
+
env: minimalEnv(),
|
|
158
|
+
});
|
|
159
|
+
if (diff.stdout.trim().length > 0) {
|
|
160
|
+
patchPath = path.join(draftDir, "checkout.diff.patch");
|
|
161
|
+
fs.writeFileSync(patchPath, diff.stdout);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
changedFiles: [statusPath, ...(patchPath ? [patchPath] : []), ...changedFiles],
|
|
165
|
+
tests: [`Detected ${changedFiles.length} checkout file change(s) after agent execution.`],
|
|
166
|
+
transcript: [
|
|
167
|
+
changedFiles.length > 0
|
|
168
|
+
? `Checkout changes detected:\n${status.stdout.trim()}`
|
|
169
|
+
: "No checkout changes detected after agent execution.",
|
|
170
|
+
],
|
|
171
|
+
statusPath,
|
|
172
|
+
patchPath,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function parseGitStatusFiles(status) {
|
|
176
|
+
return status
|
|
177
|
+
.split("\n")
|
|
178
|
+
.map((line) => line.trimEnd())
|
|
179
|
+
.filter(Boolean)
|
|
180
|
+
.map((line) => line.slice(3).trim())
|
|
181
|
+
.filter(Boolean);
|
|
182
|
+
}
|
|
183
|
+
async function prepareAgentWorkspace({ config, repoPath, draftDir, branch, }) {
|
|
184
|
+
const commandRef = config.agent?.fix?.command_ref;
|
|
185
|
+
if (!commandRef)
|
|
186
|
+
return { cwd: draftDir, tests: [], transcript: [] };
|
|
187
|
+
const checkoutPath = path.join(draftDir, "repo");
|
|
188
|
+
const gitDir = path.join(repoPath, ".git");
|
|
189
|
+
if (fs.existsSync(gitDir)) {
|
|
190
|
+
await spawnCommand("git", ["clone", "--local", "--no-hardlinks", "--quiet", repoPath, checkoutPath], {
|
|
191
|
+
cwd: draftDir,
|
|
192
|
+
timeoutMs: 60_000,
|
|
193
|
+
env: minimalEnv(),
|
|
194
|
+
});
|
|
195
|
+
await spawnCommand("git", ["checkout", "-b", branch], {
|
|
196
|
+
cwd: checkoutPath,
|
|
197
|
+
timeoutMs: 30_000,
|
|
198
|
+
env: minimalEnv(),
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
cwd: checkoutPath,
|
|
202
|
+
checkoutPath,
|
|
203
|
+
tests: [`Prepared isolated git checkout at ${checkoutPath}.`],
|
|
204
|
+
transcript: [`Prepared isolated git checkout from ${repoPath} on branch ${branch}.`],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (!fs.existsSync(repoPath)) {
|
|
208
|
+
return {
|
|
209
|
+
cwd: draftDir,
|
|
210
|
+
tests: [`Repo path unavailable; fix agent ran with context only: ${repoPath}`],
|
|
211
|
+
transcript: [`Repo path unavailable; isolated checkout was skipped: ${repoPath}.`],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
fs.cpSync(repoPath, checkoutPath, {
|
|
215
|
+
recursive: true,
|
|
216
|
+
filter: (source) => !ignoredCheckoutPath(source),
|
|
217
|
+
});
|
|
218
|
+
return {
|
|
219
|
+
cwd: checkoutPath,
|
|
220
|
+
checkoutPath,
|
|
221
|
+
tests: [`Prepared isolated file checkout at ${checkoutPath}.`],
|
|
222
|
+
transcript: [`Prepared isolated file checkout from ${repoPath}.`],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function ignoredCheckoutPath(source) {
|
|
226
|
+
const parts = source.split(path.sep);
|
|
227
|
+
return !parts.some((part) => [".git", "node_modules", ".next", "dist", "build"].includes(part));
|
|
228
|
+
}
|
|
229
|
+
async function runFixAgent(config, jobFiles) {
|
|
230
|
+
const commandRef = config.agent?.fix?.command_ref;
|
|
231
|
+
if (!commandRef) {
|
|
232
|
+
return {
|
|
233
|
+
tests: ["No fix command_ref configured; generated local draft PR context only."],
|
|
234
|
+
transcript: "No fix agent command was configured.",
|
|
235
|
+
agent: { command_ref: null, decision: "skipped" },
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const command = config.agent?.fix?.commands?.[commandRef];
|
|
239
|
+
if (!command) {
|
|
240
|
+
throw new Error(`Unregistered fix command_ref: ${commandRef}`);
|
|
241
|
+
}
|
|
242
|
+
if (!command.bin || command.bin.includes(" ")) {
|
|
243
|
+
throw new Error(`Invalid fix command for ${commandRef}: bin must be an executable, not a shell string`);
|
|
244
|
+
}
|
|
245
|
+
const execution = await spawnCommand(command.bin, command.args ?? [], {
|
|
246
|
+
cwd: jobFiles.cwd,
|
|
247
|
+
timeoutMs: command.timeout_ms ?? 30_000,
|
|
248
|
+
env: {
|
|
249
|
+
PATH: process.env.PATH ?? "",
|
|
250
|
+
NODE_PATH: process.env.NODE_PATH ?? "",
|
|
251
|
+
NOYA_CONTEXT_PATH: jobFiles.contextPath,
|
|
252
|
+
NOYA_JOB_PATH: jobFiles.jobPath,
|
|
253
|
+
NOYA_AGENT_RESULT_PATH: jobFiles.resultPath,
|
|
254
|
+
NOYA_REPO_PATH: jobFiles.cwd,
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
const resultPath = jobFiles.resultPath;
|
|
258
|
+
const result = fs.existsSync(resultPath)
|
|
259
|
+
? JSON.parse(fs.readFileSync(resultPath, "utf8"))
|
|
260
|
+
: {};
|
|
261
|
+
return {
|
|
262
|
+
changed_files: result.changed_files ?? [],
|
|
263
|
+
tests: result.tests ?? [],
|
|
264
|
+
transcript: [
|
|
265
|
+
result.transcript,
|
|
266
|
+
execution.stdout ? `stdout:\n${execution.stdout}` : "",
|
|
267
|
+
execution.stderr ? `stderr:\n${execution.stderr}` : "",
|
|
268
|
+
]
|
|
269
|
+
.filter(Boolean)
|
|
270
|
+
.join("\n\n"),
|
|
271
|
+
agent: {
|
|
272
|
+
command_ref: commandRef,
|
|
273
|
+
exit_code: execution.exitCode,
|
|
274
|
+
cwd: jobFiles.cwd,
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function minimalEnv() {
|
|
279
|
+
return {
|
|
280
|
+
PATH: process.env.PATH ?? "",
|
|
281
|
+
NODE_PATH: process.env.NODE_PATH ?? "",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
async function spawnCommand(bin, args, options) {
|
|
285
|
+
return new Promise((resolve, reject) => {
|
|
286
|
+
const child = spawn(bin, args, {
|
|
287
|
+
cwd: options.cwd,
|
|
288
|
+
env: options.env,
|
|
289
|
+
shell: false,
|
|
290
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
291
|
+
});
|
|
292
|
+
let stdout = "";
|
|
293
|
+
let stderr = "";
|
|
294
|
+
const timer = setTimeout(() => {
|
|
295
|
+
child.kill("SIGKILL");
|
|
296
|
+
reject(new Error(`Fix command timed out after ${options.timeoutMs}ms`));
|
|
297
|
+
}, options.timeoutMs);
|
|
298
|
+
child.stdout.on("data", (chunk) => {
|
|
299
|
+
stdout += String(chunk);
|
|
300
|
+
});
|
|
301
|
+
child.stderr.on("data", (chunk) => {
|
|
302
|
+
stderr += String(chunk);
|
|
303
|
+
});
|
|
304
|
+
child.on("error", (error) => {
|
|
305
|
+
clearTimeout(timer);
|
|
306
|
+
reject(error);
|
|
307
|
+
});
|
|
308
|
+
child.on("close", (code) => {
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
if (code !== 0) {
|
|
311
|
+
reject(new Error(`Fix command exited with ${code}: ${stderr || stdout}`));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
resolve({ exitCode: code ?? 0, stdout, stderr });
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
function collectFixtureLogs(report) {
|
|
319
|
+
return [
|
|
320
|
+
{
|
|
321
|
+
severity: "ERROR",
|
|
322
|
+
request_id: report.request_id ?? report.hints?.request_id,
|
|
323
|
+
message: "Reserve validation rejected incomplete booker nationality state.",
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
severity: "WARNING",
|
|
327
|
+
trace_id: report.hints?.trace_id ?? "trace-local-fixture",
|
|
328
|
+
message: "Frontend submitted reservation precheck with stale form completeness.",
|
|
329
|
+
},
|
|
330
|
+
];
|
|
331
|
+
}
|
|
332
|
+
function collectDomainSnapshot(report) {
|
|
333
|
+
return {
|
|
334
|
+
trip_id: report.hints?.trip_id,
|
|
335
|
+
booking_id: report.hints?.booking_id,
|
|
336
|
+
tenant_id: report.hints?.tenant_id,
|
|
337
|
+
reservation_state: "blocked_before_supplier_hold",
|
|
338
|
+
pii_note: "Fixture domain snapshot is redacted before upload.",
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
function snapshotRepo(repoPath) {
|
|
342
|
+
const normalizedPath = typeof repoPath === "string" ? repoPath : undefined;
|
|
343
|
+
if (!normalizedPath || !fs.existsSync(normalizedPath)) {
|
|
344
|
+
return { available: false, reason: "repo path unavailable", repo_path: normalizedPath ?? null };
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
available: true,
|
|
348
|
+
repo_path: normalizedPath,
|
|
349
|
+
package_json: fs.existsSync(path.join(normalizedPath, "package.json")),
|
|
350
|
+
git_dir: fs.existsSync(path.join(normalizedPath, ".git")),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function evaluateSource(source, report) {
|
|
354
|
+
if ("command" in source || "sql" in source) {
|
|
355
|
+
return {
|
|
356
|
+
source: source.name,
|
|
357
|
+
decision: "deny",
|
|
358
|
+
reason: "raw command or raw SQL collectors are not allowed",
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
if (!["fixture_logs", "fixture_domain", "repo_snapshot", "command_ref"].includes(source.type)) {
|
|
362
|
+
return {
|
|
363
|
+
source: source.name,
|
|
364
|
+
decision: "deny",
|
|
365
|
+
reason: `unsupported collector type: ${String(source.type)}`,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
if (source.type === "command_ref" && source.command_ref !== "workspace_manifest_v1") {
|
|
369
|
+
return {
|
|
370
|
+
source: source.name,
|
|
371
|
+
decision: "deny",
|
|
372
|
+
reason: `unregistered command_ref: ${String(source.command_ref)}`,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
const requiredHint = source.when?.has;
|
|
376
|
+
if (requiredHint && !report.hints?.[requiredHint]) {
|
|
377
|
+
return {
|
|
378
|
+
source: source.name,
|
|
379
|
+
decision: "skip",
|
|
380
|
+
reason: `missing hint: ${requiredHint}`,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
source: source.name,
|
|
385
|
+
decision: "allow",
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
async function getJson(url, authorization) {
|
|
389
|
+
const response = await fetch(url, authorization ? { headers: { authorization } } : undefined);
|
|
390
|
+
if (!response.ok)
|
|
391
|
+
throw new Error(`GET ${url} failed: ${response.status}`);
|
|
392
|
+
return response.json();
|
|
393
|
+
}
|
|
394
|
+
async function postJson(url, body, authorization) {
|
|
395
|
+
const response = await fetch(url, {
|
|
396
|
+
method: "POST",
|
|
397
|
+
headers: { "content-type": "application/json", ...(authorization ? { authorization } : {}) },
|
|
398
|
+
body: JSON.stringify(body),
|
|
399
|
+
});
|
|
400
|
+
if (!response.ok)
|
|
401
|
+
throw new Error(`POST ${url} failed: ${response.status}`);
|
|
402
|
+
return response.json();
|
|
403
|
+
}
|
|
404
|
+
function authHeader(config) {
|
|
405
|
+
const token = config.token ?? (config.token_env ? process.env[config.token_env] : undefined);
|
|
406
|
+
return token ? `Bearer ${token}` : undefined;
|
|
407
|
+
}
|
|
408
|
+
function loadConfig(filePath) {
|
|
409
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
410
|
+
}
|
|
411
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
412
|
+
const configPath = process.argv[2] ?? "examples/agency-platform/noya.runner.json";
|
|
413
|
+
const config = loadConfig(configPath);
|
|
414
|
+
const interval = Number(process.env.NOYA_RUNNER_INTERVAL_MS ?? 0);
|
|
415
|
+
if (interval > 0) {
|
|
416
|
+
setInterval(() => {
|
|
417
|
+
runOnce(config).catch((error) => console.error(error));
|
|
418
|
+
}, interval);
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
runOnce(config).then((result) => console.log(JSON.stringify(result, null, 2)));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
type ContextProvider = {
|
|
2
|
+
name: string;
|
|
3
|
+
collect: () => unknown | Promise<unknown>;
|
|
4
|
+
redact?: "strict" | "none";
|
|
5
|
+
};
|
|
6
|
+
type NoyaConfig = {
|
|
7
|
+
projectKey: string;
|
|
8
|
+
endpoint?: string;
|
|
9
|
+
environment?: string;
|
|
10
|
+
release?: string;
|
|
11
|
+
contextProviders?: ContextProvider[];
|
|
12
|
+
autoCaptureNetwork?: boolean;
|
|
13
|
+
recording?: {
|
|
14
|
+
client?: {
|
|
15
|
+
init?: (options: Record<string, unknown>) => void;
|
|
16
|
+
setTag?: (key: string, value: string) => void;
|
|
17
|
+
setContext?: (key: string, value: Record<string, unknown>) => void;
|
|
18
|
+
};
|
|
19
|
+
initOptions?: Record<string, unknown>;
|
|
20
|
+
replayIntegration?: false | unknown | (() => unknown);
|
|
21
|
+
feedbackIntegration?: false | unknown | (() => unknown);
|
|
22
|
+
};
|
|
23
|
+
sentry?: {
|
|
24
|
+
client?: {
|
|
25
|
+
init?: (options: Record<string, unknown>) => void;
|
|
26
|
+
setTag?: (key: string, value: string) => void;
|
|
27
|
+
setContext?: (key: string, value: Record<string, unknown>) => void;
|
|
28
|
+
};
|
|
29
|
+
initOptions?: Record<string, unknown>;
|
|
30
|
+
replayIntegration?: false | unknown | (() => unknown);
|
|
31
|
+
feedbackIntegration?: false | unknown | (() => unknown);
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
type ReportInput = {
|
|
35
|
+
reportId?: string;
|
|
36
|
+
message?: string;
|
|
37
|
+
impact?: string;
|
|
38
|
+
expectedBehavior?: string;
|
|
39
|
+
actualBehavior?: string;
|
|
40
|
+
reproductionSteps?: string[];
|
|
41
|
+
requestId?: string;
|
|
42
|
+
route?: string;
|
|
43
|
+
telemetry?: unknown;
|
|
44
|
+
sentry?: unknown;
|
|
45
|
+
feedback?: unknown;
|
|
46
|
+
screenshotUrl?: string;
|
|
47
|
+
};
|
|
48
|
+
declare global {
|
|
49
|
+
interface Window {
|
|
50
|
+
Noya?: {
|
|
51
|
+
initNoya: typeof initNoya;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export declare function initNoya(config: NoyaConfig): {
|
|
56
|
+
reportIssue: (input: ReportInput) => Promise<unknown>;
|
|
57
|
+
sessionId: string;
|
|
58
|
+
getCorrelation: () => {
|
|
59
|
+
request_id?: string;
|
|
60
|
+
trace_id?: string;
|
|
61
|
+
traceparent?: string;
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
export {};
|
package/dist/src/sdk.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { redactValue } from "./redaction.js";
|
|
2
|
+
export function initNoya(config) {
|
|
3
|
+
if (!config?.projectKey)
|
|
4
|
+
throw new Error("initNoya requires projectKey");
|
|
5
|
+
const endpoint = config.endpoint ?? "/api/reports";
|
|
6
|
+
const contextProviders = config.contextProviders ?? [];
|
|
7
|
+
const sessionId = stableSessionId();
|
|
8
|
+
const correlation = {};
|
|
9
|
+
if (config.autoCaptureNetwork !== false) {
|
|
10
|
+
installFetchInterceptor(correlation);
|
|
11
|
+
installXhrInterceptor(correlation);
|
|
12
|
+
}
|
|
13
|
+
const recording = config.recording ?? config.sentry;
|
|
14
|
+
recording?.client?.init?.(recordingInitOptions(config));
|
|
15
|
+
recording?.client?.setTag?.("noya.project_key", config.projectKey);
|
|
16
|
+
recording?.client?.setTag?.("noya.session_id", sessionId);
|
|
17
|
+
async function collectContext() {
|
|
18
|
+
const values = {};
|
|
19
|
+
for (const provider of contextProviders) {
|
|
20
|
+
try {
|
|
21
|
+
values[provider.name] = redactValue(await provider.collect(), {
|
|
22
|
+
mode: provider.redact ?? "strict",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
values[provider.name] = {
|
|
27
|
+
error: error instanceof Error ? error.message : String(error),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return values;
|
|
32
|
+
}
|
|
33
|
+
async function reportIssue(input) {
|
|
34
|
+
const context = await collectContext();
|
|
35
|
+
recording?.client?.setContext?.("noya_report", {
|
|
36
|
+
project_key: config.projectKey,
|
|
37
|
+
session_id: sessionId,
|
|
38
|
+
route: input.route ?? globalThis.location?.pathname,
|
|
39
|
+
request_id: input.requestId ?? correlation.request_id,
|
|
40
|
+
trace_id: correlation.trace_id,
|
|
41
|
+
});
|
|
42
|
+
const envelope = redactValue({
|
|
43
|
+
report_id: input.reportId ?? crypto.randomUUID(),
|
|
44
|
+
project_key: config.projectKey,
|
|
45
|
+
session_id: sessionId,
|
|
46
|
+
environment: config.environment,
|
|
47
|
+
release: config.release,
|
|
48
|
+
route: input.route ?? globalThis.location?.pathname,
|
|
49
|
+
browser: globalThis.navigator?.userAgent,
|
|
50
|
+
message: input.message,
|
|
51
|
+
impact: input.impact,
|
|
52
|
+
expected_behavior: input.expectedBehavior,
|
|
53
|
+
actual_behavior: input.actualBehavior,
|
|
54
|
+
reproduction_steps: input.reproductionSteps,
|
|
55
|
+
request_id: input.requestId ?? correlation.request_id,
|
|
56
|
+
trace_id: correlation.trace_id,
|
|
57
|
+
traceparent: correlation.traceparent,
|
|
58
|
+
telemetry: input.telemetry ?? input.sentry,
|
|
59
|
+
sentry: input.sentry,
|
|
60
|
+
feedback: input.feedback,
|
|
61
|
+
screenshot_url: input.screenshotUrl,
|
|
62
|
+
context,
|
|
63
|
+
}, { mode: "strict" });
|
|
64
|
+
const response = await fetch(endpoint, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "content-type": "application/json" },
|
|
67
|
+
body: JSON.stringify(envelope),
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok)
|
|
70
|
+
throw new Error(`Noya report failed: ${response.status}`);
|
|
71
|
+
return response.json();
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
reportIssue,
|
|
75
|
+
sessionId,
|
|
76
|
+
getCorrelation: () => ({ ...correlation }),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (typeof window !== "undefined") {
|
|
80
|
+
window.Noya = { initNoya };
|
|
81
|
+
}
|
|
82
|
+
function recordingInitOptions(config) {
|
|
83
|
+
const recording = config.recording ?? config.sentry;
|
|
84
|
+
const initOptions = {
|
|
85
|
+
...(recording?.initOptions ?? {}),
|
|
86
|
+
environment: config.environment,
|
|
87
|
+
release: config.release,
|
|
88
|
+
};
|
|
89
|
+
const additions = [
|
|
90
|
+
resolveIntegration(recording?.replayIntegration),
|
|
91
|
+
resolveIntegration(recording?.feedbackIntegration),
|
|
92
|
+
].filter((value) => value !== undefined);
|
|
93
|
+
if (additions.length === 0)
|
|
94
|
+
return initOptions;
|
|
95
|
+
const existing = initOptions.integrations;
|
|
96
|
+
if (Array.isArray(existing)) {
|
|
97
|
+
return { ...initOptions, integrations: [...existing, ...additions] };
|
|
98
|
+
}
|
|
99
|
+
if (typeof existing === "function") {
|
|
100
|
+
return {
|
|
101
|
+
...initOptions,
|
|
102
|
+
integrations: (...args) => [...arrayFromIntegrationResult(existing(...args)), ...additions],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { ...initOptions, integrations: additions };
|
|
106
|
+
}
|
|
107
|
+
function resolveIntegration(value) {
|
|
108
|
+
if (!value)
|
|
109
|
+
return undefined;
|
|
110
|
+
return typeof value === "function" ? value() : value;
|
|
111
|
+
}
|
|
112
|
+
function arrayFromIntegrationResult(value) {
|
|
113
|
+
return Array.isArray(value) ? value : value ? [value] : [];
|
|
114
|
+
}
|
|
115
|
+
function stableSessionId() {
|
|
116
|
+
const storageKey = "noya.session_id";
|
|
117
|
+
try {
|
|
118
|
+
const existing = globalThis.sessionStorage?.getItem(storageKey);
|
|
119
|
+
if (existing)
|
|
120
|
+
return existing;
|
|
121
|
+
const next = `noya_sess_${randomId()}`;
|
|
122
|
+
globalThis.sessionStorage?.setItem(storageKey, next);
|
|
123
|
+
return next;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return `noya_sess_${randomId()}`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function randomId() {
|
|
130
|
+
return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
|
|
131
|
+
}
|
|
132
|
+
let fetchInstalled = false;
|
|
133
|
+
function installFetchInterceptor(correlation) {
|
|
134
|
+
if (fetchInstalled || typeof globalThis.fetch !== "function")
|
|
135
|
+
return;
|
|
136
|
+
fetchInstalled = true;
|
|
137
|
+
const originalFetch = globalThis.fetch.bind(globalThis);
|
|
138
|
+
globalThis.fetch = async (input, init) => {
|
|
139
|
+
const response = await originalFetch(input, init);
|
|
140
|
+
captureHeaders(correlation, response.headers);
|
|
141
|
+
return response;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
let xhrInstalled = false;
|
|
145
|
+
function installXhrInterceptor(correlation) {
|
|
146
|
+
if (xhrInstalled || typeof globalThis.XMLHttpRequest === "undefined")
|
|
147
|
+
return;
|
|
148
|
+
xhrInstalled = true;
|
|
149
|
+
const proto = globalThis.XMLHttpRequest.prototype;
|
|
150
|
+
const originalOpen = proto.open;
|
|
151
|
+
const originalSend = proto.send;
|
|
152
|
+
proto.open = function open(...args) {
|
|
153
|
+
this.addEventListener("loadend", () => {
|
|
154
|
+
captureHeaderValue(correlation, "request_id", this.getResponseHeader("x-request-id"));
|
|
155
|
+
captureHeaderValue(correlation, "trace_id", this.getResponseHeader("x-trace-id"));
|
|
156
|
+
captureHeaderValue(correlation, "traceparent", this.getResponseHeader("traceparent"));
|
|
157
|
+
});
|
|
158
|
+
return originalOpen.apply(this, args);
|
|
159
|
+
};
|
|
160
|
+
proto.send = function send(...args) {
|
|
161
|
+
return originalSend.apply(this, args);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function captureHeaders(correlation, headers) {
|
|
165
|
+
captureHeaderValue(correlation, "request_id", headers.get("x-request-id"));
|
|
166
|
+
captureHeaderValue(correlation, "trace_id", headers.get("x-trace-id"));
|
|
167
|
+
captureHeaderValue(correlation, "traceparent", headers.get("traceparent"));
|
|
168
|
+
}
|
|
169
|
+
function captureHeaderValue(correlation, key, value) {
|
|
170
|
+
if (value)
|
|
171
|
+
correlation[key] = value;
|
|
172
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Project, Report } from "./types.js";
|
|
2
|
+
export declare function fetchSentryEventContext(project: Project, sentry: Report["sentry"]): Promise<Partial<Report>>;
|
|
3
|
+
export declare function verifySentryWebhookSignature({ project, rawBody, signature, }: {
|
|
4
|
+
project: Project;
|
|
5
|
+
rawBody: string;
|
|
6
|
+
signature: string | undefined;
|
|
7
|
+
}): void;
|