taphound 0.2.0-dev.1

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 (176) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +200 -0
  3. package/README.zh-CN.md +200 -0
  4. package/assets/brand/taphound-mark.svg +9 -0
  5. package/assets/skills/taphound-ai-journey/GUIDE.md +862 -0
  6. package/assets/skills/taphound-ai-journey/SKILL.md +377 -0
  7. package/assets/skills/taphound-ai-journey/prompts/analyze-project.md +526 -0
  8. package/assets/skills/taphound-ai-journey/prompts/check-completion.md +52 -0
  9. package/assets/skills/taphound-ai-journey/prompts/generate-step.md +109 -0
  10. package/assets/skills/taphound-ai-journey/schemas/observe-output.json +42 -0
  11. package/assets/skills/taphound-ai-journey/schemas/project-context.json +81 -0
  12. package/assets/skills/taphound-ai-journey/schemas/proposed-step-envelope.json +267 -0
  13. package/assets/skills/taphound-ai-journey/templates/project-context.example.json +49 -0
  14. package/dist/adapters/adb/adb-adapter.d.ts +23 -0
  15. package/dist/adapters/adb/adb-adapter.js +218 -0
  16. package/dist/adapters/android-cli/android-cli-adapter.d.ts +11 -0
  17. package/dist/adapters/android-cli/android-cli-adapter.js +64 -0
  18. package/dist/adapters/android-cli/layout-parser.d.ts +3 -0
  19. package/dist/adapters/android-cli/layout-parser.js +174 -0
  20. package/dist/adapters/clock/system-clock.d.ts +5 -0
  21. package/dist/adapters/clock/system-clock.js +13 -0
  22. package/dist/adapters/filesystem/artifact-store.d.ts +4 -0
  23. package/dist/adapters/filesystem/artifact-store.js +73 -0
  24. package/dist/adapters/filesystem/generation-meta-writer.d.ts +16 -0
  25. package/dist/adapters/filesystem/generation-meta-writer.js +34 -0
  26. package/dist/adapters/filesystem/generation-session-store.d.ts +68 -0
  27. package/dist/adapters/filesystem/generation-session-store.js +1603 -0
  28. package/dist/adapters/filesystem/journey-writer.d.ts +16 -0
  29. package/dist/adapters/filesystem/journey-writer.js +34 -0
  30. package/dist/adapters/filesystem/project-bound-file.d.ts +6 -0
  31. package/dist/adapters/filesystem/project-bound-file.js +240 -0
  32. package/dist/adapters/filesystem/project-file-inspector.d.ts +8 -0
  33. package/dist/adapters/filesystem/project-file-inspector.js +198 -0
  34. package/dist/adapters/filesystem/skill-installer.d.ts +10 -0
  35. package/dist/adapters/filesystem/skill-installer.js +39 -0
  36. package/dist/adapters/process/node-process-runner.d.ts +8 -0
  37. package/dist/adapters/process/node-process-runner.js +155 -0
  38. package/dist/adapters/prompt/inquirer-generation-prompt.d.ts +55 -0
  39. package/dist/adapters/prompt/inquirer-generation-prompt.js +213 -0
  40. package/dist/adapters/prompt/inquirer-init-prompt.d.ts +32 -0
  41. package/dist/adapters/prompt/inquirer-init-prompt.js +55 -0
  42. package/dist/adapters/prompt/inquirer-recorder-prompt.d.ts +47 -0
  43. package/dist/adapters/prompt/inquirer-recorder-prompt.js +157 -0
  44. package/dist/application/assertion/expectation-evaluator.d.ts +61 -0
  45. package/dist/application/assertion/expectation-evaluator.js +207 -0
  46. package/dist/application/collector/logcat-collector.d.ts +39 -0
  47. package/dist/application/collector/logcat-collector.js +107 -0
  48. package/dist/application/context/context-validator.d.ts +27 -0
  49. package/dist/application/context/context-validator.js +120 -0
  50. package/dist/application/doctor/doctor-service.d.ts +35 -0
  51. package/dist/application/doctor/doctor-service.js +178 -0
  52. package/dist/application/generation/focused-input.d.ts +2 -0
  53. package/dist/application/generation/focused-input.js +9 -0
  54. package/dist/application/generation/generation-confirmation-service.d.ts +76 -0
  55. package/dist/application/generation/generation-confirmation-service.js +383 -0
  56. package/dist/application/generation/generation-finalizer.d.ts +67 -0
  57. package/dist/application/generation/generation-finalizer.js +646 -0
  58. package/dist/application/generation/generation-publisher.d.ts +46 -0
  59. package/dist/application/generation/generation-publisher.js +178 -0
  60. package/dist/application/generation/generation-starter.d.ts +31 -0
  61. package/dist/application/generation/generation-starter.js +102 -0
  62. package/dist/application/generation/generation-step-executor.d.ts +52 -0
  63. package/dist/application/generation/generation-step-executor.js +668 -0
  64. package/dist/application/generation/proposed-step-validator.d.ts +11 -0
  65. package/dist/application/generation/proposed-step-validator.js +88 -0
  66. package/dist/application/generation/risk-evaluator.d.ts +9 -0
  67. package/dist/application/generation/risk-evaluator.js +14 -0
  68. package/dist/application/generation/runtime-observer.d.ts +39 -0
  69. package/dist/application/generation/runtime-observer.js +157 -0
  70. package/dist/application/init/init-service.d.ts +17 -0
  71. package/dist/application/init/init-service.js +44 -0
  72. package/dist/application/interaction/action-executor.d.ts +25 -0
  73. package/dist/application/interaction/action-executor.js +103 -0
  74. package/dist/application/interaction/fallback-resolver.d.ts +24 -0
  75. package/dist/application/interaction/fallback-resolver.js +48 -0
  76. package/dist/application/interaction/scroll-to-executor.d.ts +40 -0
  77. package/dist/application/interaction/scroll-to-executor.js +191 -0
  78. package/dist/application/locator/locator-resolver.d.ts +20 -0
  79. package/dist/application/locator/locator-resolver.js +89 -0
  80. package/dist/application/project/project-describer.d.ts +14 -0
  81. package/dist/application/project/project-describer.js +8 -0
  82. package/dist/application/recorder/locator-selector.d.ts +10 -0
  83. package/dist/application/recorder/locator-selector.js +80 -0
  84. package/dist/application/recorder/recorder-service.d.ts +41 -0
  85. package/dist/application/recorder/recorder-service.js +331 -0
  86. package/dist/application/report/report-writer.d.ts +10 -0
  87. package/dist/application/report/report-writer.js +39 -0
  88. package/dist/application/runtime/activity-waiter.d.ts +33 -0
  89. package/dist/application/runtime/activity-waiter.js +73 -0
  90. package/dist/application/runtime/launch-failure.d.ts +2 -0
  91. package/dist/application/runtime/launch-failure.js +20 -0
  92. package/dist/application/runtime/process-waiter.d.ts +27 -0
  93. package/dist/application/runtime/process-waiter.js +73 -0
  94. package/dist/application/runtime/step-runner.d.ts +50 -0
  95. package/dist/application/runtime/step-runner.js +429 -0
  96. package/dist/application/runtime/verify-runtime.d.ts +44 -0
  97. package/dist/application/runtime/verify-runtime.js +350 -0
  98. package/dist/application/wait/idle-waiter.d.ts +30 -0
  99. package/dist/application/wait/idle-waiter.js +90 -0
  100. package/dist/cli/commands/context.d.ts +3 -0
  101. package/dist/cli/commands/context.js +94 -0
  102. package/dist/cli/commands/doctor.d.ts +3 -0
  103. package/dist/cli/commands/doctor.js +51 -0
  104. package/dist/cli/commands/generation.d.ts +3 -0
  105. package/dist/cli/commands/generation.js +518 -0
  106. package/dist/cli/commands/init.d.ts +3 -0
  107. package/dist/cli/commands/init.js +104 -0
  108. package/dist/cli/commands/project.d.ts +3 -0
  109. package/dist/cli/commands/project.js +58 -0
  110. package/dist/cli/commands/record.d.ts +3 -0
  111. package/dist/cli/commands/record.js +88 -0
  112. package/dist/cli/commands/verify.d.ts +3 -0
  113. package/dist/cli/commands/verify.js +106 -0
  114. package/dist/cli/dependencies.d.ts +66 -0
  115. package/dist/cli/dependencies.js +204 -0
  116. package/dist/cli/main.d.ts +8 -0
  117. package/dist/cli/main.js +73 -0
  118. package/dist/cli/output.d.ts +16 -0
  119. package/dist/cli/output.js +22 -0
  120. package/dist/cli/program.d.ts +3 -0
  121. package/dist/cli/program.js +43 -0
  122. package/dist/domain/activity.d.ts +7 -0
  123. package/dist/domain/activity.js +65 -0
  124. package/dist/domain/app-process.d.ts +7 -0
  125. package/dist/domain/app-process.js +10 -0
  126. package/dist/domain/config.d.ts +15 -0
  127. package/dist/domain/config.js +17 -0
  128. package/dist/domain/failure.d.ts +4 -0
  129. package/dist/domain/failure.js +43 -0
  130. package/dist/domain/generation.d.ts +912 -0
  131. package/dist/domain/generation.js +342 -0
  132. package/dist/domain/init.d.ts +42 -0
  133. package/dist/domain/init.js +114 -0
  134. package/dist/domain/journey.d.ts +673 -0
  135. package/dist/domain/journey.js +109 -0
  136. package/dist/domain/layout.d.ts +35 -0
  137. package/dist/domain/layout.js +33 -0
  138. package/dist/domain/locator.d.ts +8 -0
  139. package/dist/domain/locator.js +5 -0
  140. package/dist/domain/project-context.d.ts +101 -0
  141. package/dist/domain/project-context.js +64 -0
  142. package/dist/domain/proposed-step.d.ts +341 -0
  143. package/dist/domain/proposed-step.js +87 -0
  144. package/dist/domain/report.d.ts +349 -0
  145. package/dist/domain/report.js +135 -0
  146. package/dist/domain/runtime-snapshot.d.ts +16 -0
  147. package/dist/domain/runtime-snapshot.js +46 -0
  148. package/dist/ports/adb.d.ts +42 -0
  149. package/dist/ports/adb.js +1 -0
  150. package/dist/ports/android-cli.d.ts +21 -0
  151. package/dist/ports/android-cli.js +1 -0
  152. package/dist/ports/artifact-store.d.ts +12 -0
  153. package/dist/ports/artifact-store.js +1 -0
  154. package/dist/ports/clock.d.ts +4 -0
  155. package/dist/ports/clock.js +1 -0
  156. package/dist/ports/generation-meta-writer.d.ts +10 -0
  157. package/dist/ports/generation-meta-writer.js +1 -0
  158. package/dist/ports/generation-prompt.d.ts +19 -0
  159. package/dist/ports/generation-prompt.js +6 -0
  160. package/dist/ports/generation-session-store.d.ts +32 -0
  161. package/dist/ports/generation-session-store.js +26 -0
  162. package/dist/ports/init-prompt.d.ts +8 -0
  163. package/dist/ports/init-prompt.js +6 -0
  164. package/dist/ports/journey-writer.d.ts +10 -0
  165. package/dist/ports/journey-writer.js +1 -0
  166. package/dist/ports/process-runner.d.ts +32 -0
  167. package/dist/ports/process-runner.js +1 -0
  168. package/dist/ports/project-bound-file.d.ts +8 -0
  169. package/dist/ports/project-bound-file.js +1 -0
  170. package/dist/ports/project-file-inspector.d.ts +33 -0
  171. package/dist/ports/project-file-inspector.js +1 -0
  172. package/dist/ports/recorder-prompt.d.ts +29 -0
  173. package/dist/ports/recorder-prompt.js +1 -0
  174. package/dist/ports/skill-installer.d.ts +8 -0
  175. package/dist/ports/skill-installer.js +1 -0
  176. package/package.json +64 -0
@@ -0,0 +1,668 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { appProcessPids, primaryAppPid } from "../../domain/app-process.js";
4
+ import { GenerationSessionSchema, GenerationInFlightSchema, expandProposedStepVariables, hashGenerationConfirmationEvidence } from "../../domain/generation.js";
5
+ import { JourneyStepSchema } from "../../domain/journey.js";
6
+ import { ProposedStepSchema, hashProposedStep } from "../../domain/proposed-step.js";
7
+ import { RuntimeSnapshotSchema, hashRuntimeSnapshot } from "../../domain/runtime-snapshot.js";
8
+ import { ExpectationEvaluator } from "../assertion/expectation-evaluator.js";
9
+ import { LogcatCollector } from "../collector/logcat-collector.js";
10
+ import { ActionExecutor } from "../interaction/action-executor.js";
11
+ import { ScrollToExecutor } from "../interaction/scroll-to-executor.js";
12
+ import { resolveLocator } from "../locator/locator-resolver.js";
13
+ import { IdleWaiter } from "../wait/idle-waiter.js";
14
+ import { summarizeProposedStep } from "./generation-confirmation-service.js";
15
+ import { GenerationOperationError } from "./generation-starter.js";
16
+ import { RiskEvaluator } from "./risk-evaluator.js";
17
+ import { hasExactlyOneEnabledFocusedElement } from "./focused-input.js";
18
+ class StepCancelledError extends Error {
19
+ name = "StepCancelledError";
20
+ }
21
+ function cancellation() {
22
+ return {
23
+ status: "cancelled",
24
+ failure: {
25
+ code: "RECOVERY_REQUIRED",
26
+ message: "Step was cancelled"
27
+ }
28
+ };
29
+ }
30
+ function isCancelled(signal) {
31
+ return signal?.aborted === true;
32
+ }
33
+ function throwIfCancelled(signal) {
34
+ if (isCancelled(signal)) {
35
+ throw new StepCancelledError("Step was cancelled");
36
+ }
37
+ }
38
+ function executionPath(inFlight, suffix) {
39
+ return `evidence/steps/${String(inFlight.stepIndex)}-${inFlight.attemptId}/${suffix}`;
40
+ }
41
+ function sha256(value) {
42
+ return createHash("sha256").update(value).digest("hex");
43
+ }
44
+ function canonicalEvidenceBytes(value) {
45
+ const canonicalize = (input) => {
46
+ if (Array.isArray(input))
47
+ return input.map(canonicalize);
48
+ if (input !== null && typeof input === "object") {
49
+ return Object.fromEntries(Object.keys(input)
50
+ .sort((left, right) => left.localeCompare(right))
51
+ .map((key) => [
52
+ key,
53
+ canonicalize(input[key])
54
+ ]));
55
+ }
56
+ return input;
57
+ };
58
+ return Buffer.from(`${JSON.stringify(canonicalize(value), null, 2)}\n`);
59
+ }
60
+ function asFailure(error) {
61
+ if (error !== null
62
+ && typeof error === "object"
63
+ && "code" in error
64
+ && typeof error.code === "string") {
65
+ return {
66
+ code: error.code,
67
+ message: error instanceof Error ? error.message : error.code
68
+ };
69
+ }
70
+ return {
71
+ code: "INTERNAL_ERROR",
72
+ message: error instanceof Error ? error.message : "Unknown step failure"
73
+ };
74
+ }
75
+ function fail(code, message) {
76
+ throw Object.assign(new Error(message), { code });
77
+ }
78
+ function sameSession(left, right) {
79
+ return JSON.stringify(left) === JSON.stringify(right);
80
+ }
81
+ function exactApprovedChallenge(session, proposal, snapshot, source, now) {
82
+ const challenge = session.pendingConfirmation;
83
+ if (challenge === null
84
+ || challenge.status !== "approved"
85
+ || session.revision !== proposal.binding.baseRevision + 2
86
+ || challenge.stepIndex !== session.candidateSteps.length
87
+ || challenge.proposalHash !== hashProposedStep(proposal)
88
+ || challenge.snapshotHash !== hashRuntimeSnapshot(snapshot)
89
+ || challenge.snapshotHash !== proposal.binding.snapshotHash
90
+ || challenge.evidenceHash !== hashGenerationConfirmationEvidence({
91
+ version: 1,
92
+ proposal,
93
+ snapshot,
94
+ source
95
+ })
96
+ || challenge.actionSummary !== summarizeProposedStep(proposal)
97
+ || now.getTime() >= new Date(challenge.expiresAt).getTime()) {
98
+ throw new GenerationOperationError("RISK_CONFIRMATION_REQUIRED", "An exact non-expired approved confirmation is required");
99
+ }
100
+ return challenge;
101
+ }
102
+ function isBoundApprovedChallenge(challenge, session, proposal, snapshot) {
103
+ return challenge !== null
104
+ && challenge.status === "approved"
105
+ && challenge.stepIndex === session.candidateSteps.length
106
+ && challenge.proposalHash === hashProposedStep(proposal)
107
+ && challenge.snapshotHash === hashRuntimeSnapshot(snapshot)
108
+ && challenge.snapshotHash === proposal.binding.snapshotHash
109
+ && challenge.actionSummary === summarizeProposedStep(proposal);
110
+ }
111
+ function assertBaseAuthorization(session, proposal, snapshot) {
112
+ if (session.id !== proposal.binding.generationId
113
+ || session.id !== snapshot.generationId
114
+ || proposal.binding.generationId !== snapshot.generationId
115
+ || proposal.binding.baseRevision !== snapshot.baseRevision
116
+ || proposal.binding.snapshotHash !== hashRuntimeSnapshot(snapshot)
117
+ || proposal.binding.snapshotHash !== session.bindings.snapshotHash
118
+ || snapshot.expectedPackageName !== session.target.packageName
119
+ || snapshot.deviceSerial !== session.target.deviceSerial
120
+ || snapshot.activity !== proposal.activity.before
121
+ || session.state !== "active"
122
+ || session.inFlight !== null
123
+ || session.verification.status !== "notRun"
124
+ || session.publication.status !== "notRun") {
125
+ throw new GenerationOperationError("SNAPSHOT_STALE", "Proposal is not bound to the authoritative generation snapshot");
126
+ }
127
+ }
128
+ function executableStep(proposal, variables, after) {
129
+ const expanded = expandProposedStepVariables(proposal, variables);
130
+ const action = Object.fromEntries(Object.entries(expanded).filter(([key]) => key !== "binding" && key !== "activity"));
131
+ return JourneyStepSchema.parse({
132
+ ...action,
133
+ activity: {
134
+ before: expanded.activity.before,
135
+ after
136
+ }
137
+ });
138
+ }
139
+ function requireTarget(layout, step) {
140
+ if (step.action !== "click"
141
+ && step.action !== "longClick"
142
+ && step.action !== "swipe") {
143
+ return undefined;
144
+ }
145
+ const resolution = resolveLocator(layout, step.locator);
146
+ if (resolution.status !== "found") {
147
+ fail(resolution.code, resolution.message);
148
+ }
149
+ if (step.action === "click" && resolution.element.clickable !== true) {
150
+ fail("ACTION_FAILED", "click target is not clickable");
151
+ }
152
+ if (step.action === "longClick"
153
+ && resolution.element.longClickable !== true) {
154
+ fail("ACTION_FAILED", "longClick target is not longClickable");
155
+ }
156
+ if (step.action === "swipe"
157
+ && (resolution.element.scrollable !== true
158
+ || resolution.element.bounds === undefined)) {
159
+ fail("ACTION_FAILED", "swipe target lacks scrollable bounds");
160
+ }
161
+ return {
162
+ point: resolution.point,
163
+ ...(resolution.element.bounds === undefined
164
+ ? {}
165
+ : { bounds: resolution.element.bounds })
166
+ };
167
+ }
168
+ export class GenerationStepExecutor {
169
+ dependencies;
170
+ riskEvaluator = new RiskEvaluator();
171
+ constructor(dependencies) {
172
+ this.dependencies = dependencies;
173
+ }
174
+ execute = async (input) => {
175
+ const proposal = ProposedStepSchema.parse(input.proposal);
176
+ const snapshot = RuntimeSnapshotSchema.parse(input.snapshot);
177
+ const source = z.enum(["planner", "manualOverride"]).parse(input.source);
178
+ const session = GenerationSessionSchema.parse(await this.dependencies.store.read(input.generationId));
179
+ assertBaseAuthorization(session, proposal, snapshot);
180
+ const risk = this.riskEvaluator.evaluate(proposal.action, session.target.interactionPolicy);
181
+ if (risk.effectiveRisk === "forbidden") {
182
+ throw new GenerationOperationError("ACTION_FORBIDDEN", `${proposal.action} is forbidden by the Core interaction policy`);
183
+ }
184
+ let approved;
185
+ if (risk.effectiveRisk === "confirmationRequired") {
186
+ try {
187
+ approved = exactApprovedChallenge(session, proposal, snapshot, source, this.dependencies.now());
188
+ }
189
+ catch (error) {
190
+ if (isBoundApprovedChallenge(session.pendingConfirmation, session, proposal, snapshot)) {
191
+ await this.dependencies.clearApprovedConfirmation(session.id, session.pendingConfirmation);
192
+ }
193
+ throw error;
194
+ }
195
+ }
196
+ else if (session.revision !== proposal.binding.baseRevision
197
+ || session.pendingConfirmation !== null) {
198
+ throw new GenerationOperationError("SNAPSHOT_STALE", "Safe proposal revision is no longer current");
199
+ }
200
+ const clearApproved = async () => {
201
+ if (approved !== undefined) {
202
+ await this.dependencies.clearApprovedConfirmation(session.id, approved);
203
+ }
204
+ };
205
+ if (isCancelled(input.signal)) {
206
+ await clearApproved();
207
+ return cancellation();
208
+ }
209
+ let fresh;
210
+ try {
211
+ fresh = await this.dependencies.freshnessGuard.assertFresh(proposal.binding, input.signal, approved);
212
+ }
213
+ catch (error) {
214
+ await clearApproved();
215
+ if (isCancelled(input.signal)) {
216
+ return cancellation();
217
+ }
218
+ throw error;
219
+ }
220
+ if (isCancelled(input.signal)) {
221
+ await clearApproved();
222
+ return cancellation();
223
+ }
224
+ if (fresh.foregroundPackageName !== session.target.packageName) {
225
+ await clearApproved();
226
+ throw new GenerationOperationError("PACKAGE_ESCAPE", "Fresh foreground package escaped the generation target");
227
+ }
228
+ if (fresh.pid === null) {
229
+ await clearApproved();
230
+ throw new GenerationOperationError("APP_CRASHED", "Fresh generation process is not running");
231
+ }
232
+ if (fresh.activity !== proposal.activity.before) {
233
+ await clearApproved();
234
+ throw new GenerationOperationError("SNAPSHOT_STALE", "Fresh Activity does not match the accepted proposal");
235
+ }
236
+ const authoritativePid = fresh.pid;
237
+ let inFlight;
238
+ try {
239
+ inFlight = GenerationInFlightSchema.parse({
240
+ stepIndex: session.candidateSteps.length,
241
+ snapshotHash: proposal.binding.snapshotHash,
242
+ proposalHash: hashProposedStep(proposal),
243
+ attemptId: this.dependencies.generateAttemptId()
244
+ });
245
+ }
246
+ catch (error) {
247
+ await clearApproved();
248
+ throw error;
249
+ }
250
+ if (isCancelled(input.signal)) {
251
+ await clearApproved();
252
+ return cancellation();
253
+ }
254
+ let begun;
255
+ try {
256
+ begun = await this.dependencies.store.beginStep(session.id, session.revision, inFlight, approved);
257
+ }
258
+ catch (error) {
259
+ const latest = GenerationSessionSchema.parse(await this.dependencies.store.read(session.id));
260
+ if (JSON.stringify(latest.inFlight) === JSON.stringify(inFlight)) {
261
+ await this.markRecovery(session.id, inFlight);
262
+ }
263
+ else {
264
+ await clearApproved();
265
+ }
266
+ throw error;
267
+ }
268
+ if (isCancelled(input.signal)) {
269
+ await this.markRecovery(session.id, inFlight);
270
+ return cancellation();
271
+ }
272
+ const proposalEvidencePath = executionPath(inFlight, "proposal.json");
273
+ const snapshotEvidencePath = executionPath(inFlight, "snapshot.json");
274
+ let proposalEvidenceSha256;
275
+ let snapshotEvidenceSha256;
276
+ try {
277
+ await this.dependencies.store.writeEvidence(session.id, proposalEvidencePath, proposal);
278
+ proposalEvidenceSha256 = sha256(canonicalEvidenceBytes(proposal));
279
+ await this.dependencies.store.writeEvidence(session.id, snapshotEvidencePath, snapshot);
280
+ snapshotEvidenceSha256 = sha256(canonicalEvidenceBytes(snapshot));
281
+ }
282
+ catch (error) {
283
+ await this.markRecovery(session.id, inFlight);
284
+ throw error;
285
+ }
286
+ if (isCancelled(input.signal)) {
287
+ await this.markRecovery(session.id, inFlight);
288
+ return cancellation();
289
+ }
290
+ const logcat = new LogcatCollector(this.dependencies.adb, this.dependencies.clock);
291
+ let logcatStarted = false;
292
+ let finalStep;
293
+ let outcome;
294
+ const stepStartedAt = this.dependencies.clock.now();
295
+ try {
296
+ await logcat.start({
297
+ deviceSerial: session.target.deviceSerial,
298
+ ...(input.signal === undefined ? {} : { signal: input.signal })
299
+ });
300
+ logcatStarted = true;
301
+ throwIfCancelled(input.signal);
302
+ const preAction = await this.observeLive(session, authoritativePid, proposal.activity.before, input.signal, fresh);
303
+ logcat.scopeToPids(preAction.pids);
304
+ throwIfCancelled(input.signal);
305
+ const provisional = executableStep(proposal, session.variables, proposal.activity.before);
306
+ const actionExecutor = new ActionExecutor(this.dependencies.adb, session.target.deviceSerial);
307
+ const idleWaiter = new IdleWaiter(this.dependencies.androidCli, this.dependencies.clock, session.target.deviceSerial);
308
+ if (provisional.action === "scrollTo") {
309
+ const scroll = await new ScrollToExecutor({
310
+ androidCli: this.dependencies.androidCli,
311
+ actionExecutor,
312
+ idleWaiter,
313
+ deviceSerial: session.target.deviceSerial,
314
+ idle: this.dependencies.idle,
315
+ beforeSwipe: async () => {
316
+ const guarded = await this.observeLive(session, authoritativePid, proposal.activity.before, input.signal);
317
+ return guarded.layout;
318
+ },
319
+ beforeMutation: async () => {
320
+ await this.assertForegroundIdentity(session, authoritativePid, proposal.activity.before, input.signal);
321
+ },
322
+ requireLiveContainerCapability: true
323
+ }).execute(provisional, input.signal, preAction.layout);
324
+ throwIfCancelled(input.signal);
325
+ if (scroll.status === "cancelled") {
326
+ outcome = {
327
+ status: "cancelled",
328
+ failure: { code: "RECOVERY_REQUIRED", message: "Step was cancelled" }
329
+ };
330
+ }
331
+ else if (scroll.status === "failed") {
332
+ fail(scroll.code, scroll.message);
333
+ }
334
+ }
335
+ else {
336
+ const target = requireTarget(preAction.layout, provisional);
337
+ if (provisional.action === "inputText") {
338
+ if (!hasExactlyOneEnabledFocusedElement(preAction.layout)) {
339
+ fail("ACTION_FAILED", "inputText requires exactly one enabled focused Layout element");
340
+ }
341
+ }
342
+ if (outcome === undefined) {
343
+ throwIfCancelled(input.signal);
344
+ await this.assertForegroundIdentity(session, authoritativePid, proposal.activity.before, input.signal);
345
+ throwIfCancelled(input.signal);
346
+ const action = await actionExecutor.execute(provisional, target, input.signal);
347
+ if (isCancelled(input.signal)) {
348
+ outcome = {
349
+ status: "cancelled",
350
+ failure: {
351
+ code: "RECOVERY_REQUIRED",
352
+ message: "Step was cancelled"
353
+ }
354
+ };
355
+ }
356
+ else if (action.status === "failed") {
357
+ fail(action.code, action.message);
358
+ }
359
+ }
360
+ if (outcome === undefined) {
361
+ const idle = await idleWaiter.waitUntilIdle(this.dependencies.idle, input.signal);
362
+ if (idle.status === "cancelled") {
363
+ outcome = {
364
+ status: "cancelled",
365
+ failure: {
366
+ code: "RECOVERY_REQUIRED",
367
+ message: "Step was cancelled"
368
+ }
369
+ };
370
+ }
371
+ else if (idle.status === "timeout") {
372
+ fail(idle.code, "Layout did not become stable before timeout");
373
+ }
374
+ throwIfCancelled(input.signal);
375
+ }
376
+ }
377
+ if (outcome === undefined) {
378
+ throwIfCancelled(input.signal);
379
+ const after = await this.observeLive(session, authoritativePid, undefined, input.signal);
380
+ logcat.scopeToPids(after.pids);
381
+ finalStep = executableStep(proposal, session.variables, after.activity);
382
+ if (finalStep.expect !== undefined) {
383
+ const expectation = await new ExpectationEvaluator(this.dependencies.adb, this.dependencies.androidCli, logcat, this.dependencies.clock).evaluate(finalStep.expect, {
384
+ packageName: session.target.packageName,
385
+ deviceSerial: session.target.deviceSerial,
386
+ stepStartedAt
387
+ }, input.signal, {
388
+ activity: async (observation) => {
389
+ try {
390
+ const activity = await this.assertForegroundIdentity(session, authoritativePid, undefined, observation.signal, observation.timeoutMs);
391
+ return { status: "observed", activity };
392
+ }
393
+ catch (error) {
394
+ if (error instanceof StepCancelledError)
395
+ throw error;
396
+ return {
397
+ status: "failed",
398
+ message: error instanceof Error
399
+ ? error.message
400
+ : "Generated Expect Activity observation failed"
401
+ };
402
+ }
403
+ },
404
+ layout: async (observation) => {
405
+ try {
406
+ const guarded = await this.observeLive(session, authoritativePid, after.activity, observation.signal, undefined, observation.timeoutMs);
407
+ return { status: "observed", layout: guarded.layout };
408
+ }
409
+ catch (error) {
410
+ if (error instanceof StepCancelledError)
411
+ throw error;
412
+ return {
413
+ status: "failed",
414
+ message: error instanceof Error
415
+ ? error.message
416
+ : "Generated Expect Layout observation failed"
417
+ };
418
+ }
419
+ }
420
+ });
421
+ if (expectation.status === "cancelled") {
422
+ outcome = {
423
+ status: "cancelled",
424
+ failure: {
425
+ code: "RECOVERY_REQUIRED",
426
+ message: "Expectation evaluation was cancelled"
427
+ }
428
+ };
429
+ }
430
+ else if (expectation.status === "failed") {
431
+ fail(expectation.code, expectation.message);
432
+ }
433
+ else {
434
+ throwIfCancelled(input.signal);
435
+ await this.observeLive(session, authoritativePid, finalStep.expect.type === "activity"
436
+ ? finalStep.expect.value
437
+ : after.activity, input.signal);
438
+ throwIfCancelled(input.signal);
439
+ }
440
+ }
441
+ }
442
+ }
443
+ catch (error) {
444
+ outcome = error instanceof StepCancelledError
445
+ ? cancellation()
446
+ : { status: "failed", failure: asFailure(error) };
447
+ }
448
+ let stopFailure;
449
+ if (logcatStarted) {
450
+ try {
451
+ const stopped = await logcat.stop();
452
+ if (isCancelled(input.signal)) {
453
+ outcome = cancellation();
454
+ }
455
+ if (stopped.exitCode !== 0
456
+ || stopped.timedOut
457
+ || stopped.cancelled
458
+ || stopped.spawnError !== undefined) {
459
+ stopFailure = {
460
+ code: "COLLECTION_FAILED",
461
+ message: stopped.stderr.trim()
462
+ || stopped.spawnError
463
+ || "Logcat collector failed to stop"
464
+ };
465
+ }
466
+ }
467
+ catch (error) {
468
+ stopFailure = {
469
+ code: "COLLECTION_FAILED",
470
+ message: error instanceof Error
471
+ ? error.message
472
+ : "Logcat collector failed to stop"
473
+ };
474
+ }
475
+ }
476
+ outcome ??= stopFailure === undefined
477
+ ? { status: "succeeded", step: finalStep }
478
+ : { status: "failed", failure: stopFailure };
479
+ const log = logcatStarted
480
+ ? logcat.lines().map((line) => line.raw).join("\n")
481
+ : "";
482
+ try {
483
+ await this.dependencies.store.writeTextEvidence(session.id, executionPath(inFlight, "logcat.txt"), log.length === 0 ? "" : `${log}\n`);
484
+ }
485
+ catch (error) {
486
+ await this.markRecovery(session.id, inFlight);
487
+ throw error;
488
+ }
489
+ if (isCancelled(input.signal)) {
490
+ await this.markRecovery(session.id, inFlight);
491
+ return cancellation();
492
+ }
493
+ try {
494
+ await this.dependencies.store.writeEvidence(session.id, executionPath(inFlight, "result.json"), {
495
+ version: 1,
496
+ stepIndex: inFlight.stepIndex,
497
+ proposalHash: inFlight.proposalHash,
498
+ snapshotHash: inFlight.snapshotHash,
499
+ attemptId: inFlight.attemptId,
500
+ source,
501
+ proposalEvidence: {
502
+ path: proposalEvidencePath,
503
+ sha256: proposalEvidenceSha256
504
+ },
505
+ snapshotEvidence: {
506
+ path: snapshotEvidencePath,
507
+ sha256: snapshotEvidenceSha256
508
+ },
509
+ outcome,
510
+ ...(stopFailure === undefined
511
+ ? {}
512
+ : { logcatStopFailure: stopFailure })
513
+ });
514
+ }
515
+ catch (error) {
516
+ await this.markRecovery(session.id, inFlight);
517
+ throw error;
518
+ }
519
+ if (isCancelled(input.signal)) {
520
+ await this.markRecovery(session.id, inFlight);
521
+ return cancellation();
522
+ }
523
+ if (outcome.status === "succeeded") {
524
+ const next = GenerationSessionSchema.parse({
525
+ ...begun,
526
+ revision: begun.revision + 1,
527
+ inFlight: null,
528
+ candidateSteps: [...begun.candidateSteps, outcome.step],
529
+ candidateSources: [...begun.candidateSources, source]
530
+ });
531
+ if (isCancelled(input.signal)) {
532
+ await this.markRecovery(session.id, inFlight);
533
+ return cancellation();
534
+ }
535
+ try {
536
+ await this.dependencies.store.completeStep(session.id, begun.revision, inFlight, next);
537
+ }
538
+ catch (error) {
539
+ let latest;
540
+ try {
541
+ latest = GenerationSessionSchema.parse(await this.dependencies.store.read(session.id));
542
+ }
543
+ catch {
544
+ throw new GenerationOperationError("RECOVERY_REQUIRED", "Unable to reconcile step completion state");
545
+ }
546
+ if (sameSession(latest, next)) {
547
+ return outcome;
548
+ }
549
+ if (latest.state === "active"
550
+ && JSON.stringify(latest.inFlight) === JSON.stringify(inFlight)) {
551
+ await this.markRecovery(session.id, inFlight);
552
+ throw error;
553
+ }
554
+ throw new GenerationOperationError("RECOVERY_REQUIRED", "Step completion state is ambiguous and requires recovery");
555
+ }
556
+ return outcome;
557
+ }
558
+ await this.markRecovery(session.id, inFlight);
559
+ return outcome;
560
+ };
561
+ async observeLive(session, expectedPid, expectedActivity, signal, authoritativeSnapshot, timeoutMs = this.dependencies.idle.timeoutMs) {
562
+ const deadline = this.dependencies.clock.now() + timeoutMs;
563
+ const identity = () => ({
564
+ packageName: session.target.packageName,
565
+ deviceSerial: session.target.deviceSerial,
566
+ ...(signal === undefined ? {} : { signal }),
567
+ timeoutMs: Math.max(1, deadline - this.dependencies.clock.now())
568
+ });
569
+ const foreground = await this.dependencies.adb.foregroundComponent(identity());
570
+ throwIfCancelled(signal);
571
+ if (foreground.packageName !== session.target.packageName) {
572
+ fail("PACKAGE_ESCAPE", "Foreground package escaped generation target");
573
+ }
574
+ const processes = await this.dependencies.adb.appProcesses(identity());
575
+ const pid = primaryAppPid(processes, session.target.packageName);
576
+ throwIfCancelled(signal);
577
+ if (pid === null || pid !== expectedPid) {
578
+ fail("APP_CRASHED", "Generation process identity changed");
579
+ }
580
+ if (expectedActivity !== undefined
581
+ && foreground.activity !== expectedActivity) {
582
+ fail("SNAPSHOT_STALE", "Generation Activity changed unexpectedly");
583
+ }
584
+ const layout = await this.dependencies.androidCli.layout({
585
+ deviceSerial: session.target.deviceSerial,
586
+ ...(signal === undefined ? {} : { signal }),
587
+ timeoutMs: Math.max(1, deadline - this.dependencies.clock.now())
588
+ });
589
+ throwIfCancelled(signal);
590
+ const confirmedForeground = await this.dependencies.adb
591
+ .foregroundComponent(identity());
592
+ throwIfCancelled(signal);
593
+ if (confirmedForeground.packageName !== session.target.packageName) {
594
+ fail("PACKAGE_ESCAPE", "Foreground package escaped generation target");
595
+ }
596
+ const confirmedPid = primaryAppPid(await this.dependencies.adb.appProcesses(identity()), session.target.packageName);
597
+ throwIfCancelled(signal);
598
+ if (confirmedPid === null || confirmedPid !== expectedPid) {
599
+ fail("APP_CRASHED", "Generation process identity changed");
600
+ }
601
+ if (confirmedForeground.activity
602
+ !== (expectedActivity ?? foreground.activity)) {
603
+ fail("SNAPSHOT_STALE", "Generation Activity changed unexpectedly");
604
+ }
605
+ if (authoritativeSnapshot !== undefined) {
606
+ const liveHash = hashRuntimeSnapshot({
607
+ ...authoritativeSnapshot,
608
+ foregroundPackageName: foreground.packageName,
609
+ activity: foreground.activity,
610
+ pid,
611
+ layout
612
+ });
613
+ const authoritativeHash = hashRuntimeSnapshot(authoritativeSnapshot);
614
+ if (liveHash !== authoritativeHash) {
615
+ fail("SNAPSHOT_STALE", `Generation Layout changed before action (${authoritativeHash} -> ${liveHash})`);
616
+ }
617
+ }
618
+ return {
619
+ foregroundPackageName: foreground.packageName,
620
+ activity: foreground.activity,
621
+ pid,
622
+ pids: appProcessPids(processes),
623
+ layout
624
+ };
625
+ }
626
+ async assertForegroundIdentity(session, expectedPid, expectedActivity, signal, timeoutMs = this.dependencies.idle.timeoutMs) {
627
+ const deadline = this.dependencies.clock.now() + timeoutMs;
628
+ const identity = () => ({
629
+ packageName: session.target.packageName,
630
+ deviceSerial: session.target.deviceSerial,
631
+ ...(signal === undefined ? {} : { signal }),
632
+ timeoutMs: Math.max(1, deadline - this.dependencies.clock.now())
633
+ });
634
+ const foreground = await this.dependencies.adb.foregroundComponent(identity());
635
+ throwIfCancelled(signal);
636
+ if (foreground.packageName !== session.target.packageName) {
637
+ fail("PACKAGE_ESCAPE", "Foreground package escaped generation target");
638
+ }
639
+ if (expectedActivity !== undefined
640
+ && foreground.activity !== expectedActivity) {
641
+ fail("SNAPSHOT_STALE", "Generation Activity changed before mutation");
642
+ }
643
+ const pid = primaryAppPid(await this.dependencies.adb.appProcesses(identity()), session.target.packageName);
644
+ throwIfCancelled(signal);
645
+ if (pid === null || pid !== expectedPid) {
646
+ fail("APP_CRASHED", "Generation process identity changed");
647
+ }
648
+ return foreground.activity;
649
+ }
650
+ async markRecovery(generationId, inFlight) {
651
+ const latest = GenerationSessionSchema.parse(await this.dependencies.store.read(generationId));
652
+ if (latest.state === "recoveryRequired"
653
+ && JSON.stringify(latest.inFlight) === JSON.stringify(inFlight)) {
654
+ return;
655
+ }
656
+ if (latest.state !== "active"
657
+ || JSON.stringify(latest.inFlight) !== JSON.stringify(inFlight)) {
658
+ throw new GenerationOperationError("RECOVERY_REQUIRED", "Step execution state changed before recovery could be persisted");
659
+ }
660
+ const recovery = GenerationSessionSchema.parse({
661
+ ...latest,
662
+ revision: latest.revision + 1,
663
+ state: "recoveryRequired",
664
+ inFlight
665
+ });
666
+ await this.dependencies.store.update(generationId, latest.revision, recovery);
667
+ }
668
+ }
@@ -0,0 +1,11 @@
1
+ import { type GenerationSession } from "../../domain/generation.js";
2
+ import { type ProposedStep } from "../../domain/proposed-step.js";
3
+ import { type RuntimeSnapshot } from "../../domain/runtime-snapshot.js";
4
+ export interface ProposedStepValidationInput {
5
+ session: GenerationSession;
6
+ snapshot: RuntimeSnapshot;
7
+ proposal: ProposedStep;
8
+ }
9
+ export declare class ProposedStepValidator {
10
+ validate(input: ProposedStepValidationInput): ProposedStep;
11
+ }