taphound 0.2.0-dev.11 → 0.2.0-dev.12

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.
Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/assets/skills/taphound-journey-generator/SKILL.md +7 -0
  4. package/assets/skills/taphound-journey-generator/scripts/envelope.mjs +18 -1
  5. package/assets/skills/taphound-verify-change/references/preserve.md +15 -7
  6. package/assets/skills/taphound-verify-change/scripts/ui-refactor.mjs +1 -1
  7. package/dist/adapters/appium/appium-ui-snapshot-provider.d.ts +8 -0
  8. package/dist/adapters/appium/appium-ui-snapshot-provider.js +123 -54
  9. package/dist/adapters/filesystem/diagnostics-journal.d.ts +17 -0
  10. package/dist/adapters/filesystem/diagnostics-journal.js +130 -0
  11. package/dist/application/diagnostics/diagnostics-exporter.d.ts +29 -0
  12. package/dist/application/diagnostics/diagnostics-exporter.js +283 -0
  13. package/dist/application/diagnostics/ui-capture-telemetry.d.ts +16 -0
  14. package/dist/application/diagnostics/ui-capture-telemetry.js +81 -0
  15. package/dist/application/report/report-writer.d.ts +5 -0
  16. package/dist/application/report/report-writer.js +16 -0
  17. package/dist/application/runtime/verify-runtime.js +23 -1
  18. package/dist/application/ui/observed-ui-snapshot-provider.d.ts +15 -0
  19. package/dist/application/ui/observed-ui-snapshot-provider.js +66 -0
  20. package/dist/application/ui/ui-stability-probe.d.ts +6 -0
  21. package/dist/application/ui/ui-stability-probe.js +21 -5
  22. package/dist/application/wait/idle-waiter.js +19 -4
  23. package/dist/cli/commands/diagnose.d.ts +3 -0
  24. package/dist/cli/commands/diagnose.js +84 -0
  25. package/dist/cli/commands/verify.js +55 -3
  26. package/dist/cli/dependencies.d.ts +11 -0
  27. package/dist/cli/dependencies.js +58 -5
  28. package/dist/cli/diagnostics-recorder.d.ts +31 -0
  29. package/dist/cli/diagnostics-recorder.js +82 -0
  30. package/dist/cli/main.d.ts +1 -1
  31. package/dist/cli/main.js +58 -1
  32. package/dist/cli/program.js +3 -1
  33. package/dist/domain/contract.d.ts +6 -6
  34. package/dist/domain/diagnostics.d.ts +1396 -0
  35. package/dist/domain/diagnostics.js +193 -0
  36. package/dist/domain/failure.js +1 -1
  37. package/dist/domain/report.d.ts +3 -3
  38. package/dist/domain/verify-receipt.d.ts +17 -0
  39. package/dist/domain/verify-receipt.js +17 -0
  40. package/dist/domain/workspace.d.ts +2 -0
  41. package/dist/domain/workspace.js +2 -0
  42. package/dist/ports/diagnostics.d.ts +15 -0
  43. package/dist/ports/diagnostics.js +1 -0
  44. package/package.json +2 -2
package/README.md CHANGED
@@ -90,7 +90,7 @@ taphound init --agent claude,codex,cursor,droid
90
90
  - [Principles](https://github.com/caikaidev/TapHound/blob/main/docs/principles.md) · [Workflow Skills and development scenarios](https://github.com/caikaidev/TapHound/blob/main/docs/workflow-skills.md) · [Agent integration](https://github.com/caikaidev/TapHound/blob/main/docs/agent-integration.md) · [Journey Generator guide](https://github.com/caikaidev/TapHound/blob/main/docs/journey-generator-guide.md)
91
91
  - [Acceptance Contracts](https://github.com/caikaidev/TapHound/blob/main/docs/contract-schema.md) · [Baselines & regression](https://github.com/caikaidev/TapHound/blob/main/docs/checkpoint-regression.md) · [Failure classification](https://github.com/caikaidev/TapHound/blob/main/docs/failure-classification.md)
92
92
  - [Semantic Anchors](https://github.com/caikaidev/TapHound/blob/main/docs/semantic-anchor.md) · [Capability matrix](https://github.com/caikaidev/TapHound/blob/main/docs/capability-matrix.md) · [`observe`](https://github.com/caikaidev/TapHound/blob/main/docs/observe.md)
93
- - [Runtime backends](https://github.com/caikaidev/TapHound/blob/main/docs/architecture/runtime-backend.md) · [Local development & testing](https://github.com/caikaidev/TapHound/blob/main/docs/local-testing.md) · [Releasing](https://github.com/caikaidev/TapHound/blob/main/docs/releasing.md)
93
+ - [Runtime backends](https://github.com/caikaidev/TapHound/blob/main/docs/architecture/runtime-backend.md) · [Local development & testing](https://github.com/caikaidev/TapHound/blob/main/docs/local-testing.md) · [Releasing](https://github.com/caikaidev/TapHound/blob/main/docs/releasing.md) · [Diagnostics and feedback](https://github.com/caikaidev/TapHound/blob/main/docs/diagnostics.md)
94
94
 
95
95
  ## Current Limitations
96
96
 
package/README.zh-CN.md CHANGED
@@ -90,7 +90,7 @@ taphound init --agent claude,codex,cursor,droid
90
90
  - [设计原则](https://github.com/caikaidev/TapHound/blob/main/docs/principles.md) · [Workflow Skill 与开发场景](https://github.com/caikaidev/TapHound/blob/main/docs/workflow-skills.md) · [Agent 集成](https://github.com/caikaidev/TapHound/blob/main/docs/agent-integration.md) · [Journey Generator 指南](https://github.com/caikaidev/TapHound/blob/main/docs/journey-generator-guide.md)
91
91
  - [Acceptance Contract](https://github.com/caikaidev/TapHound/blob/main/docs/contract-schema.md) · [Baseline 与回归对比](https://github.com/caikaidev/TapHound/blob/main/docs/checkpoint-regression.md) · [失败分类](https://github.com/caikaidev/TapHound/blob/main/docs/failure-classification.md)
92
92
  - [Semantic Anchor](https://github.com/caikaidev/TapHound/blob/main/docs/semantic-anchor.md) · [能力矩阵](https://github.com/caikaidev/TapHound/blob/main/docs/capability-matrix.md) · [`observe`](https://github.com/caikaidev/TapHound/blob/main/docs/observe.md)
93
- - [运行时后端](https://github.com/caikaidev/TapHound/blob/main/docs/architecture/runtime-backend.md) · [本地开发与测试](https://github.com/caikaidev/TapHound/blob/main/docs/local-testing.md) · [发布](https://github.com/caikaidev/TapHound/blob/main/docs/releasing.md)
93
+ - [运行时后端](https://github.com/caikaidev/TapHound/blob/main/docs/architecture/runtime-backend.md) · [本地开发与测试](https://github.com/caikaidev/TapHound/blob/main/docs/local-testing.md) · [发布](https://github.com/caikaidev/TapHound/blob/main/docs/releasing.md) · [诊断与反馈](https://github.com/caikaidev/TapHound/blob/main/docs/diagnostics.md)
94
94
 
95
95
  ## 当前限制
96
96
 
@@ -488,6 +488,13 @@ the index with `taphound knowledge rehash --project <project> --json` and
488
488
  session's selected module shards, stop and report a Context coverage gap.
489
489
  Do not add modules after start because `contextSelection` is bound to the
490
490
  authoritative session.
491
+ - When TapHound itself misbehaves (a crash, a result that contradicts the
492
+ device, or unexplained slowness), run
493
+ `taphound diagnose export --project <project>` and give the user the printed
494
+ bundle path to attach to their report. The bundle is redacted; do not add
495
+ paths, screenshots, or log excerpts to the report yourself.
496
+ - `UI_SNAPSHOT_FAILED` exits `3`: it is an environment failure, not
497
+ evidence about the app. Rerun before diagnosing the Journey.
491
498
  - Repeated `UI_SNAPSHOT_FAILED` ("UIAutomator dump failed") on a slow or
492
499
  busy device usually means the dump deadline is too tight, not that the
493
500
  device is broken. Raise `ui.snapshotTimeoutMs` in `.taphound/config.json`
@@ -370,6 +370,14 @@ function validateEnvelope(envelope) {
370
370
 
371
371
  // A bind source is one observe output, one step output, or a raw binding
372
372
  // object. The binding fields are copied verbatim; nothing is invented.
373
+ const BIND_SOURCE_HINT = "bind --from expects the unmodified stdout of "
374
+ + "`taphound generation observe --json` (status \"observed\" with "
375
+ + "generationId, baseRevision, snapshotHash, snapshotRef) or of a succeeded "
376
+ + "`taphound generation step --json` (status \"succeeded\" with nextBinding "
377
+ + "and nextSnapshotRef); save it with `> file` instead of assembling a "
378
+ + "subset. A bare binding {generationId, baseRevision, snapshotHash} is also "
379
+ + "accepted";
380
+
373
381
  function readBindingFromSource(source) {
374
382
  if (!isPlainObject(source)) {
375
383
  fail("ENVELOPE_INVALID", "bind source must be a JSON object");
@@ -460,7 +468,16 @@ async function bind(inputPath, fromPath, outPath) {
460
468
  fail("ENVELOPE_INVALID", "envelope.version must be 1");
461
469
  }
462
470
  const source = await readJsonFile(fromPath, "bind source");
463
- const { binding, snapshotRef } = readBindingFromSource(source);
471
+ let bindingSource;
472
+ try {
473
+ bindingSource = readBindingFromSource(source);
474
+ } catch (error) {
475
+ if (error?.code === "ENVELOPE_INVALID") {
476
+ fail("ENVELOPE_INVALID", `${error.message}. ${BIND_SOURCE_HINT}`);
477
+ }
478
+ throw error;
479
+ }
480
+ const { binding, snapshotRef } = bindingSource;
464
481
  const proposal = isPlainObject(envelope.proposal)
465
482
  ? { ...envelope.proposal, binding }
466
483
  : undefined;
@@ -256,13 +256,16 @@ supported semantic element or structured event. Such a Case stays `PAUSED`,
256
256
  never silently weaker.
257
257
 
258
258
  The helper also requires a machine-generated process receipt because a report
259
- file alone cannot establish an independent CLI invocation:
259
+ file alone cannot establish an independent CLI invocation. `taphound verify
260
+ --journey` writes it as `receipt.json` beside the published `report.json` and
261
+ prints its path as `receiptPath` in the `--json` output:
260
262
 
261
263
  ```json
262
264
  {
263
265
  "version": 1,
264
266
  "argv": [
265
267
  "verify", "--project", "/absolute/project",
268
+ "--config", "/absolute/project/.taphound/config.json",
266
269
  "--journey", "/absolute/project/.taphound/journeys/forward.json",
267
270
  "--device", "emulator-5554", "--policy-from-meta", "--json"
268
271
  ],
@@ -273,9 +276,13 @@ file alone cannot establish an independent CLI invocation:
273
276
  }
274
277
  ```
275
278
 
276
- The workflow runner must capture actual argv, exit status, and resulting
277
- digests. An agent must not write a success receipt merely because a report
278
- exists.
279
+ `argv` is the normalized invocation TapHound ran: absolute project, config,
280
+ and Journey paths plus the selected device serial. Pass the `receiptPath`
281
+ that `verify` printed; never write or edit a receipt by hand. A missing
282
+ `receiptPath` (the receipt could not be written, reported on stderr) means the
283
+ evidence is unavailable, so rerun `verify`. Pass absolute, symlink-free
284
+ `--project` and `--journey` paths so the recorded paths match the helper's
285
+ checks.
279
286
 
280
287
  **A, historical worktree:**
281
288
 
@@ -288,7 +295,8 @@ exists.
288
295
  --device <serial> --policy-from-meta --json
289
296
  ```
290
297
 
291
- 2. Record its process receipt. Prepare with a private input:
298
+ 2. Take `reportPath` and `receiptPath` from that `verify --json` output.
299
+ Prepare with a private input:
292
300
 
293
301
  ```json
294
302
  {
@@ -306,7 +314,7 @@ exists.
306
314
  "journeyPath": "/absolute/old-project/.taphound/journeys/forward.json",
307
315
  "metaPath": "/absolute/old-project/.taphound/journeys/forward.meta.json",
308
316
  "reportPath": "/absolute/old-project/.taphound/build/runs/<run>/report.json",
309
- "receiptPath": "/absolute/old-project/.taphound/build/workflows/<case>/receipt.json"
317
+ "receiptPath": "/absolute/old-project/.taphound/build/runs/<run>/receipt.json"
310
318
  }
311
319
  }
312
320
  ```
@@ -332,7 +340,7 @@ exists.
332
340
  goal/scenario against B's current Project Context and UI, but copy every
333
341
  observable exactly into its deterministic expectations. Finalize it, then
334
342
  launch a separate strict `verify` process on the installed refactored APK
335
- and record the same receipt shape.
343
+ and keep the `reportPath` and `receiptPath` it prints.
336
344
  3. Compare only after that independent Replay:
337
345
 
338
346
  ```
@@ -449,7 +449,7 @@ async function compare(handoff, projectPath, journeyPath, reportPath, receiptPat
449
449
  await checkReceipt(receiptFile, project, journeyFile, reportFile,
450
450
  data.manifest.base.device, {
451
451
  journey: journeyHash(journey), report: await fileHash(reportFile)
452
- }, report.status === "failed" ? 4 : 0);
452
+ }, report.status === "failed" ? 1 : 0);
453
453
  const covered = checkReplay(project, data.manifest.base.device,
454
454
  data.caseData, journey, meta, report, reportFile);
455
455
  if (report.runId === data.manifest.base.runId) {
@@ -15,6 +15,12 @@ export interface AppiumHttpClient {
15
15
  export interface AppiumProviderOptions {
16
16
  endpoint?: string | undefined;
17
17
  mapTestTagToResourceId?: boolean | undefined;
18
+ /** Called after each attempt to recreate a degraded session. */
19
+ onSessionRecovery?: ((succeeded: boolean) => void) | undefined;
20
+ }
21
+ export declare class AppiumHttpError extends Error {
22
+ readonly status: number;
23
+ constructor(status: number);
18
24
  }
19
25
  export declare class FetchAppiumHttpClient implements AppiumHttpClient {
20
26
  private readonly endpoint;
@@ -28,7 +34,9 @@ export declare class AppiumUiSnapshotProviderFactory implements UiSnapshotProvid
28
34
  private readonly endpoint;
29
35
  private readonly http;
30
36
  private readonly settings;
37
+ private readonly onSessionRecovery;
31
38
  constructor(runner: ProcessRunner, http?: AppiumHttpClient, options?: AppiumProviderOptions);
32
39
  probe(timeoutMs?: number): Promise<boolean>;
33
40
  open(options: OpenUiSnapshotProviderOptions): Promise<UiSnapshotProvider>;
41
+ private createSession;
34
42
  }
@@ -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 Error(`Appium HTTP ${String(response.status)}`);
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 = (await this.http.request({
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
- throw new UiSnapshotError("UI_SNAPSHOT_FAILED", this.descriptor.id, `Appium page source capture failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error, terminal: true });
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 provisionalSessionId;
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 created = objectValue((await this.http.request({
174
- method: "POST",
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
- const provider = new AppiumUiSnapshotProvider(this.http, sessionId, environment, descriptor);
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
- if (provisionalSessionId !== undefined) {
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
+ }