taphound 0.2.0-dev.11 → 0.2.0-dev.13
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/README.zh-CN.md +1 -1
- package/assets/skills/taphound-case-suite/SKILL.md +8 -6
- package/assets/skills/taphound-case-suite/scripts/ledger.mjs +84 -19
- package/assets/skills/taphound-journey-brief-author/SKILL.md +8 -3
- package/assets/skills/taphound-journey-brief-author/prompts/brief-author-role.md +3 -1
- package/assets/skills/taphound-journey-brief-author/prompts/brief-author-role.zh-CN.md +1 -1
- package/assets/skills/taphound-journey-generator/SKILL.md +21 -5
- package/assets/skills/taphound-journey-generator/prompts/consume-journey-brief.md +3 -1
- package/assets/skills/taphound-journey-generator/prompts/generate-step.md +9 -0
- package/assets/skills/taphound-journey-generator/scripts/envelope.mjs +80 -10
- package/assets/skills/taphound-verify-change/references/preserve.md +15 -7
- package/assets/skills/taphound-verify-change/scripts/ui-refactor.mjs +1 -1
- package/dist/adapters/appium/appium-ui-snapshot-provider.d.ts +8 -0
- package/dist/adapters/appium/appium-ui-snapshot-provider.js +123 -54
- package/dist/adapters/filesystem/diagnostics-journal.d.ts +17 -0
- package/dist/adapters/filesystem/diagnostics-journal.js +130 -0
- package/dist/application/diagnostics/diagnostics-exporter.d.ts +29 -0
- package/dist/application/diagnostics/diagnostics-exporter.js +283 -0
- package/dist/application/diagnostics/ui-capture-telemetry.d.ts +16 -0
- package/dist/application/diagnostics/ui-capture-telemetry.js +81 -0
- package/dist/application/generation/proposed-step-validator.js +5 -1
- package/dist/application/journey/journey-check-service.d.ts +7 -1
- package/dist/application/journey/journey-check-service.js +16 -1
- package/dist/application/report/report-writer.d.ts +5 -0
- package/dist/application/report/report-writer.js +20 -0
- package/dist/application/runtime/verify-runtime.js +32 -2
- package/dist/application/ui/observed-ui-snapshot-provider.d.ts +15 -0
- package/dist/application/ui/observed-ui-snapshot-provider.js +66 -0
- package/dist/application/ui/ui-stability-probe.d.ts +6 -0
- package/dist/application/ui/ui-stability-probe.js +21 -5
- package/dist/application/wait/idle-waiter.js +29 -6
- package/dist/cli/commands/diagnose.d.ts +3 -0
- package/dist/cli/commands/diagnose.js +84 -0
- package/dist/cli/commands/generation/session-commands.js +4 -1
- package/dist/cli/commands/journey.js +3 -1
- package/dist/cli/commands/verify.js +55 -3
- package/dist/cli/dependencies.d.ts +11 -0
- package/dist/cli/dependencies.js +58 -5
- package/dist/cli/diagnostics-recorder.d.ts +31 -0
- package/dist/cli/diagnostics-recorder.js +82 -0
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +58 -1
- package/dist/cli/program.js +3 -1
- package/dist/domain/contract.d.ts +6 -6
- package/dist/domain/diagnostics.d.ts +1396 -0
- package/dist/domain/diagnostics.js +193 -0
- package/dist/domain/failure.js +1 -1
- package/dist/domain/report.d.ts +11 -3
- package/dist/domain/report.js +1 -0
- package/dist/domain/verify-receipt.d.ts +17 -0
- package/dist/domain/verify-receipt.js +17 -0
- package/dist/domain/workspace.d.ts +11 -0
- package/dist/domain/workspace.js +14 -0
- package/dist/ports/diagnostics.d.ts +15 -0
- package/dist/ports/diagnostics.js +1 -0
- package/package.json +4 -3
- package/scripts/feedback-pack.mjs +399 -0
|
@@ -14,6 +14,14 @@ function loopbackEndpoint(value) {
|
|
|
14
14
|
}
|
|
15
15
|
return endpoint;
|
|
16
16
|
}
|
|
17
|
+
export class AppiumHttpError extends Error {
|
|
18
|
+
status;
|
|
19
|
+
constructor(status) {
|
|
20
|
+
super(`Appium HTTP ${String(status)}`);
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.name = "AppiumHttpError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
17
25
|
export class FetchAppiumHttpClient {
|
|
18
26
|
endpoint;
|
|
19
27
|
constructor(endpoint) {
|
|
@@ -36,7 +44,7 @@ export class FetchAppiumHttpClient {
|
|
|
36
44
|
});
|
|
37
45
|
const payload = await response.json();
|
|
38
46
|
if (!response.ok) {
|
|
39
|
-
throw new
|
|
47
|
+
throw new AppiumHttpError(response.status);
|
|
40
48
|
}
|
|
41
49
|
return { value: payload.value };
|
|
42
50
|
}
|
|
@@ -47,18 +55,33 @@ function objectValue(value) {
|
|
|
47
55
|
}
|
|
48
56
|
return value;
|
|
49
57
|
}
|
|
58
|
+
function errorText(error) {
|
|
59
|
+
return error instanceof Error ? error.message : String(error);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A page source request that timed out or hit a session Appium no longer
|
|
63
|
+
* knows (HTTP 404) points at a degraded UiAutomator2 session, not at the app.
|
|
64
|
+
*/
|
|
65
|
+
function degradedSession(error) {
|
|
66
|
+
return (error instanceof DOMException && error.name === "TimeoutError")
|
|
67
|
+
|| (error instanceof AppiumHttpError && error.status === 404);
|
|
68
|
+
}
|
|
50
69
|
class AppiumUiSnapshotProvider {
|
|
51
70
|
http;
|
|
52
71
|
sessionId;
|
|
72
|
+
createSession;
|
|
53
73
|
environment;
|
|
54
74
|
descriptor;
|
|
75
|
+
onSessionRecovery;
|
|
55
76
|
closePromise;
|
|
56
77
|
lastSourceSignature;
|
|
57
|
-
constructor(http, sessionId, environment, descriptor) {
|
|
78
|
+
constructor(http, sessionId, createSession, environment, descriptor, onSessionRecovery) {
|
|
58
79
|
this.http = http;
|
|
59
80
|
this.sessionId = sessionId;
|
|
81
|
+
this.createSession = createSession;
|
|
60
82
|
this.environment = environment;
|
|
61
83
|
this.descriptor = descriptor;
|
|
84
|
+
this.onSessionRecovery = onSessionRecovery;
|
|
62
85
|
}
|
|
63
86
|
reset() {
|
|
64
87
|
this.lastSourceSignature = undefined;
|
|
@@ -90,15 +113,24 @@ class AppiumUiSnapshotProvider {
|
|
|
90
113
|
const startedAt = performance.now();
|
|
91
114
|
let source;
|
|
92
115
|
try {
|
|
93
|
-
source =
|
|
94
|
-
method: "GET",
|
|
95
|
-
path: `/session/${this.sessionId}/source`,
|
|
96
|
-
timeoutMs: options.timeoutMs,
|
|
97
|
-
...(options.signal === undefined ? {} : { signal: options.signal })
|
|
98
|
-
})).value;
|
|
116
|
+
source = await this.pageSource(options);
|
|
99
117
|
}
|
|
100
118
|
catch (error) {
|
|
101
|
-
|
|
119
|
+
if (options.signal?.aborted === true || !degradedSession(error)) {
|
|
120
|
+
throw this.captureFailure(errorText(error), error);
|
|
121
|
+
}
|
|
122
|
+
// Capture is read-only, so one retry on a fresh session cannot change
|
|
123
|
+
// what Replay observes; it only stops a degraded session from turning
|
|
124
|
+
// into a false verification failure.
|
|
125
|
+
try {
|
|
126
|
+
await this.recreateSession(options.signal);
|
|
127
|
+
source = await this.pageSource(options);
|
|
128
|
+
this.onSessionRecovery?.(true);
|
|
129
|
+
}
|
|
130
|
+
catch (retryError) {
|
|
131
|
+
this.onSessionRecovery?.(false);
|
|
132
|
+
throw this.captureFailure(`${errorText(error)}; retry on a recreated session failed: ${errorText(retryError)}`, retryError);
|
|
133
|
+
}
|
|
102
134
|
}
|
|
103
135
|
if (typeof source !== "string") {
|
|
104
136
|
throw new UiSnapshotError("UI_SNAPSHOT_INVALID", this.descriptor.id, "Appium returned a non-string page source");
|
|
@@ -121,6 +153,34 @@ class AppiumUiSnapshotProvider {
|
|
|
121
153
|
timing: {}
|
|
122
154
|
});
|
|
123
155
|
}
|
|
156
|
+
async pageSource(options) {
|
|
157
|
+
return (await this.http.request({
|
|
158
|
+
method: "GET",
|
|
159
|
+
path: `/session/${this.sessionId}/source`,
|
|
160
|
+
timeoutMs: options.timeoutMs,
|
|
161
|
+
...(options.signal === undefined ? {} : { signal: options.signal })
|
|
162
|
+
})).value;
|
|
163
|
+
}
|
|
164
|
+
captureFailure(detail, cause) {
|
|
165
|
+
return new UiSnapshotError("UI_SNAPSHOT_FAILED", this.descriptor.id, `Appium page source capture failed: ${detail}`, { cause, terminal: true });
|
|
166
|
+
}
|
|
167
|
+
async recreateSession(signal) {
|
|
168
|
+
await this.http.request({
|
|
169
|
+
method: "DELETE",
|
|
170
|
+
path: `/session/${this.sessionId}`,
|
|
171
|
+
timeoutMs: 2000
|
|
172
|
+
}).catch(() => undefined);
|
|
173
|
+
const sessionId = await this.createSession(signal);
|
|
174
|
+
if (this.closePromise !== undefined) {
|
|
175
|
+
await this.http.request({
|
|
176
|
+
method: "DELETE",
|
|
177
|
+
path: `/session/${sessionId}`,
|
|
178
|
+
timeoutMs: 5000
|
|
179
|
+
}).catch(() => undefined);
|
|
180
|
+
throw new Error("Appium UI snapshot provider is closed");
|
|
181
|
+
}
|
|
182
|
+
this.sessionId = sessionId;
|
|
183
|
+
}
|
|
124
184
|
close() {
|
|
125
185
|
this.closePromise ??= this.http.request({
|
|
126
186
|
method: "DELETE",
|
|
@@ -135,6 +195,7 @@ export class AppiumUiSnapshotProviderFactory {
|
|
|
135
195
|
endpoint;
|
|
136
196
|
http;
|
|
137
197
|
settings;
|
|
198
|
+
onSessionRecovery;
|
|
138
199
|
constructor(runner, http, options = {}) {
|
|
139
200
|
this.runner = runner;
|
|
140
201
|
this.endpoint = loopbackEndpoint(options.endpoint ?? "http://127.0.0.1:4723/");
|
|
@@ -142,6 +203,7 @@ export class AppiumUiSnapshotProviderFactory {
|
|
|
142
203
|
this.settings = {
|
|
143
204
|
mapTestTagToResourceId: options.mapTestTagToResourceId ?? false
|
|
144
205
|
};
|
|
206
|
+
this.onSessionRecovery = options.onSessionRecovery;
|
|
145
207
|
}
|
|
146
208
|
async probe(timeoutMs = 2000) {
|
|
147
209
|
try {
|
|
@@ -158,7 +220,7 @@ export class AppiumUiSnapshotProviderFactory {
|
|
|
158
220
|
}
|
|
159
221
|
async open(options) {
|
|
160
222
|
const environment = await readDeviceUiEnvironment(this.runner, "appium-uiautomator2", options);
|
|
161
|
-
let
|
|
223
|
+
let provider;
|
|
162
224
|
try {
|
|
163
225
|
const status = objectValue((await this.http.request({
|
|
164
226
|
method: "GET",
|
|
@@ -170,41 +232,8 @@ export class AppiumUiSnapshotProviderFactory {
|
|
|
170
232
|
const engineVersion = typeof build.version === "string"
|
|
171
233
|
? build.version
|
|
172
234
|
: "unknown";
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
path: "/session",
|
|
176
|
-
timeoutMs: options.timeoutMs,
|
|
177
|
-
body: {
|
|
178
|
-
capabilities: {
|
|
179
|
-
alwaysMatch: {
|
|
180
|
-
platformName: "Android",
|
|
181
|
-
"appium:automationName": "UiAutomator2",
|
|
182
|
-
"appium:udid": options.deviceSerial,
|
|
183
|
-
"appium:noReset": true,
|
|
184
|
-
"appium:autoLaunch": false,
|
|
185
|
-
"appium:autoGrantPermissions": false,
|
|
186
|
-
"appium:fullReset": false,
|
|
187
|
-
"appium:shouldTerminateApp": false
|
|
188
|
-
},
|
|
189
|
-
firstMatch: [{}]
|
|
190
|
-
}
|
|
191
|
-
},
|
|
192
|
-
...(options.signal === undefined ? {} : { signal: options.signal })
|
|
193
|
-
})).value);
|
|
194
|
-
const sessionId = typeof created.sessionId === "string"
|
|
195
|
-
? created.sessionId
|
|
196
|
-
: undefined;
|
|
197
|
-
if (sessionId === undefined) {
|
|
198
|
-
throw new Error("Appium did not return a session id");
|
|
199
|
-
}
|
|
200
|
-
provisionalSessionId = sessionId;
|
|
201
|
-
await this.http.request({
|
|
202
|
-
method: "POST",
|
|
203
|
-
path: `/session/${sessionId}/appium/settings`,
|
|
204
|
-
timeoutMs: options.timeoutMs,
|
|
205
|
-
body: { settings: this.settings },
|
|
206
|
-
...(options.signal === undefined ? {} : { signal: options.signal })
|
|
207
|
-
});
|
|
235
|
+
const createSession = (signal) => this.createSession(options.deviceSerial, options.timeoutMs, signal);
|
|
236
|
+
const sessionId = await createSession(options.signal);
|
|
208
237
|
const descriptor = {
|
|
209
238
|
id: "appium-uiautomator2",
|
|
210
239
|
adapterVersion: "appium-uiautomator2-v1",
|
|
@@ -215,26 +244,66 @@ export class AppiumUiSnapshotProviderFactory {
|
|
|
215
244
|
capabilitiesVersion: 1
|
|
216
245
|
})).digest("hex")
|
|
217
246
|
};
|
|
218
|
-
|
|
247
|
+
provider = new AppiumUiSnapshotProvider(this.http, sessionId, createSession, environment, descriptor, this.onSessionRecovery);
|
|
219
248
|
await provider.capture({
|
|
220
249
|
reason: "evidence",
|
|
221
250
|
timeoutMs: options.timeoutMs,
|
|
222
251
|
...(options.signal === undefined ? {} : { signal: options.signal })
|
|
223
252
|
});
|
|
224
|
-
provisionalSessionId = undefined;
|
|
225
253
|
return provider;
|
|
226
254
|
}
|
|
227
255
|
catch (error) {
|
|
228
|
-
|
|
229
|
-
await this.http.request({
|
|
230
|
-
method: "DELETE",
|
|
231
|
-
path: `/session/${provisionalSessionId}`,
|
|
232
|
-
timeoutMs: 5000
|
|
233
|
-
}).catch(() => undefined);
|
|
234
|
-
}
|
|
256
|
+
await provider?.close().catch(() => undefined);
|
|
235
257
|
if (error instanceof UiSnapshotError)
|
|
236
258
|
throw error;
|
|
237
259
|
throw new UiSnapshotError("UI_BACKEND_UNAVAILABLE", "appium-uiautomator2", "Appium UiAutomator2 session could not be opened", { cause: error });
|
|
238
260
|
}
|
|
239
261
|
}
|
|
262
|
+
async createSession(deviceSerial, timeoutMs, signal) {
|
|
263
|
+
const created = objectValue((await this.http.request({
|
|
264
|
+
method: "POST",
|
|
265
|
+
path: "/session",
|
|
266
|
+
timeoutMs,
|
|
267
|
+
body: {
|
|
268
|
+
capabilities: {
|
|
269
|
+
alwaysMatch: {
|
|
270
|
+
platformName: "Android",
|
|
271
|
+
"appium:automationName": "UiAutomator2",
|
|
272
|
+
"appium:udid": deviceSerial,
|
|
273
|
+
"appium:noReset": true,
|
|
274
|
+
"appium:autoLaunch": false,
|
|
275
|
+
"appium:autoGrantPermissions": false,
|
|
276
|
+
"appium:fullReset": false,
|
|
277
|
+
"appium:shouldTerminateApp": false
|
|
278
|
+
},
|
|
279
|
+
firstMatch: [{}]
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
...(signal === undefined ? {} : { signal })
|
|
283
|
+
})).value);
|
|
284
|
+
const sessionId = typeof created.sessionId === "string"
|
|
285
|
+
? created.sessionId
|
|
286
|
+
: undefined;
|
|
287
|
+
if (sessionId === undefined) {
|
|
288
|
+
throw new Error("Appium did not return a session id");
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
await this.http.request({
|
|
292
|
+
method: "POST",
|
|
293
|
+
path: `/session/${sessionId}/appium/settings`,
|
|
294
|
+
timeoutMs,
|
|
295
|
+
body: { settings: this.settings },
|
|
296
|
+
...(signal === undefined ? {} : { signal })
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
await this.http.request({
|
|
301
|
+
method: "DELETE",
|
|
302
|
+
path: `/session/${sessionId}`,
|
|
303
|
+
timeoutMs: 5000
|
|
304
|
+
}).catch(() => undefined);
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
return sessionId;
|
|
308
|
+
}
|
|
240
309
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type CommandEvent } from "../../domain/diagnostics.js";
|
|
2
|
+
import type { DiagnosticsJournal } from "../../ports/diagnostics.js";
|
|
3
|
+
/**
|
|
4
|
+
* Appends one JSON line per invocation under `.taphound/build/log/`. It only
|
|
5
|
+
* writes where a command already initialized the ignored build layout (the
|
|
6
|
+
* build directory and `.taphound/.gitignore`), so read-only commands never
|
|
7
|
+
* create project files, and keeps the current file under `maxBytes` with one
|
|
8
|
+
* rotated predecessor.
|
|
9
|
+
*/
|
|
10
|
+
export declare class FileSystemDiagnosticsJournal implements DiagnosticsJournal {
|
|
11
|
+
private readonly maxBytes;
|
|
12
|
+
constructor(maxBytes?: number);
|
|
13
|
+
append(projectRoot: string, event: CommandEvent): Promise<void>;
|
|
14
|
+
readLines(projectRoot: string): Promise<readonly string[]>;
|
|
15
|
+
salt(projectRoot: string): Promise<Buffer>;
|
|
16
|
+
private ensureLogDirectory;
|
|
17
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, readFile, rename } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { CommandEventSchema, DIAGNOSTICS_EVENTS_FILE, DIAGNOSTICS_EVENTS_ROTATED_FILE, DIAGNOSTICS_SALT_FILE } from "../../domain/diagnostics.js";
|
|
6
|
+
import { BUILD_DIR, BUILD_IGNORE_FILE, DIAGNOSTICS_LOG_DIR, TAPHOUND_DIR } from "../../domain/workspace.js";
|
|
7
|
+
import { ensureBuildLayout } from "./workspace-layout.js";
|
|
8
|
+
const DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
9
|
+
function isMissing(error) {
|
|
10
|
+
return error?.code === "ENOENT";
|
|
11
|
+
}
|
|
12
|
+
async function isDirectory(path) {
|
|
13
|
+
try {
|
|
14
|
+
const stats = await lstat(path);
|
|
15
|
+
return stats.isDirectory() && !stats.isSymbolicLink();
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (isMissing(error))
|
|
19
|
+
return false;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function sizeOf(path) {
|
|
24
|
+
try {
|
|
25
|
+
return (await lstat(path)).size;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (isMissing(error))
|
|
29
|
+
return 0;
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function readOptional(path) {
|
|
34
|
+
try {
|
|
35
|
+
return await readFile(path, "utf8");
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (isMissing(error))
|
|
39
|
+
return "";
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function isFile(path) {
|
|
44
|
+
try {
|
|
45
|
+
const stats = await lstat(path);
|
|
46
|
+
return stats.isFile();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (isMissing(error))
|
|
50
|
+
return false;
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Appends one JSON line per invocation under `.taphound/build/log/`. It only
|
|
56
|
+
* writes where a command already initialized the ignored build layout (the
|
|
57
|
+
* build directory and `.taphound/.gitignore`), so read-only commands never
|
|
58
|
+
* create project files, and keeps the current file under `maxBytes` with one
|
|
59
|
+
* rotated predecessor.
|
|
60
|
+
*/
|
|
61
|
+
export class FileSystemDiagnosticsJournal {
|
|
62
|
+
maxBytes;
|
|
63
|
+
constructor(maxBytes = DEFAULT_MAX_BYTES) {
|
|
64
|
+
this.maxBytes = maxBytes;
|
|
65
|
+
}
|
|
66
|
+
async append(projectRoot, event) {
|
|
67
|
+
if (!await isDirectory(join(projectRoot, BUILD_DIR))
|
|
68
|
+
|| !await isFile(join(projectRoot, BUILD_IGNORE_FILE))) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const line = `${JSON.stringify(CommandEventSchema.parse(event))}\n`;
|
|
72
|
+
const directory = await this.ensureLogDirectory(projectRoot);
|
|
73
|
+
const current = join(directory, DIAGNOSTICS_EVENTS_FILE);
|
|
74
|
+
if (await sizeOf(current) + Buffer.byteLength(line) > this.maxBytes) {
|
|
75
|
+
await rename(current, join(directory, DIAGNOSTICS_EVENTS_ROTATED_FILE))
|
|
76
|
+
.catch((error) => {
|
|
77
|
+
if (!isMissing(error))
|
|
78
|
+
throw error;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const handle = await open(current, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW, 0o600);
|
|
82
|
+
try {
|
|
83
|
+
await handle.writeFile(line, "utf8");
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await handle.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async readLines(projectRoot) {
|
|
90
|
+
const directory = join(projectRoot, DIAGNOSTICS_LOG_DIR);
|
|
91
|
+
const text = await readOptional(join(directory, DIAGNOSTICS_EVENTS_ROTATED_FILE))
|
|
92
|
+
+ await readOptional(join(directory, DIAGNOSTICS_EVENTS_FILE));
|
|
93
|
+
return text.split("\n").filter((line) => line.trim().length > 0);
|
|
94
|
+
}
|
|
95
|
+
async salt(projectRoot) {
|
|
96
|
+
if (!await isDirectory(join(projectRoot, TAPHOUND_DIR))) {
|
|
97
|
+
throw Object.assign(new Error(`Not a TapHound project (no ${TAPHOUND_DIR}/ directory): ${projectRoot}`), { code: "CONFIG_INVALID" });
|
|
98
|
+
}
|
|
99
|
+
await ensureBuildLayout(projectRoot);
|
|
100
|
+
const path = join(await this.ensureLogDirectory(projectRoot), DIAGNOSTICS_SALT_FILE);
|
|
101
|
+
try {
|
|
102
|
+
const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
103
|
+
const salt = randomBytes(32);
|
|
104
|
+
try {
|
|
105
|
+
await handle.writeFile(salt);
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await handle.close();
|
|
109
|
+
}
|
|
110
|
+
return salt;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error.code !== "EEXIST")
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
const salt = await readFile(path);
|
|
117
|
+
if (salt.length < 16) {
|
|
118
|
+
throw new Error(`Diagnostics salt is unreadable: ${DIAGNOSTICS_LOG_DIR}/${DIAGNOSTICS_SALT_FILE}`);
|
|
119
|
+
}
|
|
120
|
+
return salt;
|
|
121
|
+
}
|
|
122
|
+
async ensureLogDirectory(projectRoot) {
|
|
123
|
+
const directory = join(projectRoot, DIAGNOSTICS_LOG_DIR);
|
|
124
|
+
await mkdir(directory, { recursive: true });
|
|
125
|
+
if (!await isDirectory(directory)) {
|
|
126
|
+
throw new Error(`${DIAGNOSTICS_LOG_DIR} is not a directory`);
|
|
127
|
+
}
|
|
128
|
+
return directory;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type DiagnosticsBundle } from "../../domain/diagnostics.js";
|
|
2
|
+
import type { DiagnosticsJournal } from "../../ports/diagnostics.js";
|
|
3
|
+
export interface DiagnosticsExporterDependencies {
|
|
4
|
+
journal: DiagnosticsJournal;
|
|
5
|
+
readJson: (path: string) => Promise<unknown>;
|
|
6
|
+
now: () => Date;
|
|
7
|
+
taphoundVersion: string;
|
|
8
|
+
host: DiagnosticsBundle["host"];
|
|
9
|
+
}
|
|
10
|
+
export interface DiagnosticsExportInput {
|
|
11
|
+
projectRoot: string;
|
|
12
|
+
/** Most recent journal events to include. */
|
|
13
|
+
eventLimit: number;
|
|
14
|
+
/** Most recent referenced runs to summarize. */
|
|
15
|
+
runLimit: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Builds a feedback bundle from the local journal and the reports it
|
|
19
|
+
* references. Only allowlisted structural facts survive: identifying strings
|
|
20
|
+
* become ordinal aliases or salted digests, and everything else is dropped.
|
|
21
|
+
* The result is validated against the strict bundle schema before return.
|
|
22
|
+
*/
|
|
23
|
+
export declare class DiagnosticsExporter {
|
|
24
|
+
private readonly dependencies;
|
|
25
|
+
constructor(dependencies: DiagnosticsExporterDependencies);
|
|
26
|
+
export(input: DiagnosticsExportInput): Promise<DiagnosticsBundle>;
|
|
27
|
+
private readConfig;
|
|
28
|
+
private readReport;
|
|
29
|
+
}
|