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.
- package/LICENSE +202 -0
- package/README.md +200 -0
- package/README.zh-CN.md +200 -0
- package/assets/brand/taphound-mark.svg +9 -0
- package/assets/skills/taphound-ai-journey/GUIDE.md +862 -0
- package/assets/skills/taphound-ai-journey/SKILL.md +377 -0
- package/assets/skills/taphound-ai-journey/prompts/analyze-project.md +526 -0
- package/assets/skills/taphound-ai-journey/prompts/check-completion.md +52 -0
- package/assets/skills/taphound-ai-journey/prompts/generate-step.md +109 -0
- package/assets/skills/taphound-ai-journey/schemas/observe-output.json +42 -0
- package/assets/skills/taphound-ai-journey/schemas/project-context.json +81 -0
- package/assets/skills/taphound-ai-journey/schemas/proposed-step-envelope.json +267 -0
- package/assets/skills/taphound-ai-journey/templates/project-context.example.json +49 -0
- package/dist/adapters/adb/adb-adapter.d.ts +23 -0
- package/dist/adapters/adb/adb-adapter.js +218 -0
- package/dist/adapters/android-cli/android-cli-adapter.d.ts +11 -0
- package/dist/adapters/android-cli/android-cli-adapter.js +64 -0
- package/dist/adapters/android-cli/layout-parser.d.ts +3 -0
- package/dist/adapters/android-cli/layout-parser.js +174 -0
- package/dist/adapters/clock/system-clock.d.ts +5 -0
- package/dist/adapters/clock/system-clock.js +13 -0
- package/dist/adapters/filesystem/artifact-store.d.ts +4 -0
- package/dist/adapters/filesystem/artifact-store.js +73 -0
- package/dist/adapters/filesystem/generation-meta-writer.d.ts +16 -0
- package/dist/adapters/filesystem/generation-meta-writer.js +34 -0
- package/dist/adapters/filesystem/generation-session-store.d.ts +68 -0
- package/dist/adapters/filesystem/generation-session-store.js +1603 -0
- package/dist/adapters/filesystem/journey-writer.d.ts +16 -0
- package/dist/adapters/filesystem/journey-writer.js +34 -0
- package/dist/adapters/filesystem/project-bound-file.d.ts +6 -0
- package/dist/adapters/filesystem/project-bound-file.js +240 -0
- package/dist/adapters/filesystem/project-file-inspector.d.ts +8 -0
- package/dist/adapters/filesystem/project-file-inspector.js +198 -0
- package/dist/adapters/filesystem/skill-installer.d.ts +10 -0
- package/dist/adapters/filesystem/skill-installer.js +39 -0
- package/dist/adapters/process/node-process-runner.d.ts +8 -0
- package/dist/adapters/process/node-process-runner.js +155 -0
- package/dist/adapters/prompt/inquirer-generation-prompt.d.ts +55 -0
- package/dist/adapters/prompt/inquirer-generation-prompt.js +213 -0
- package/dist/adapters/prompt/inquirer-init-prompt.d.ts +32 -0
- package/dist/adapters/prompt/inquirer-init-prompt.js +55 -0
- package/dist/adapters/prompt/inquirer-recorder-prompt.d.ts +47 -0
- package/dist/adapters/prompt/inquirer-recorder-prompt.js +157 -0
- package/dist/application/assertion/expectation-evaluator.d.ts +61 -0
- package/dist/application/assertion/expectation-evaluator.js +207 -0
- package/dist/application/collector/logcat-collector.d.ts +39 -0
- package/dist/application/collector/logcat-collector.js +107 -0
- package/dist/application/context/context-validator.d.ts +27 -0
- package/dist/application/context/context-validator.js +120 -0
- package/dist/application/doctor/doctor-service.d.ts +35 -0
- package/dist/application/doctor/doctor-service.js +178 -0
- package/dist/application/generation/focused-input.d.ts +2 -0
- package/dist/application/generation/focused-input.js +9 -0
- package/dist/application/generation/generation-confirmation-service.d.ts +76 -0
- package/dist/application/generation/generation-confirmation-service.js +383 -0
- package/dist/application/generation/generation-finalizer.d.ts +67 -0
- package/dist/application/generation/generation-finalizer.js +646 -0
- package/dist/application/generation/generation-publisher.d.ts +46 -0
- package/dist/application/generation/generation-publisher.js +178 -0
- package/dist/application/generation/generation-starter.d.ts +31 -0
- package/dist/application/generation/generation-starter.js +102 -0
- package/dist/application/generation/generation-step-executor.d.ts +52 -0
- package/dist/application/generation/generation-step-executor.js +668 -0
- package/dist/application/generation/proposed-step-validator.d.ts +11 -0
- package/dist/application/generation/proposed-step-validator.js +88 -0
- package/dist/application/generation/risk-evaluator.d.ts +9 -0
- package/dist/application/generation/risk-evaluator.js +14 -0
- package/dist/application/generation/runtime-observer.d.ts +39 -0
- package/dist/application/generation/runtime-observer.js +157 -0
- package/dist/application/init/init-service.d.ts +17 -0
- package/dist/application/init/init-service.js +44 -0
- package/dist/application/interaction/action-executor.d.ts +25 -0
- package/dist/application/interaction/action-executor.js +103 -0
- package/dist/application/interaction/fallback-resolver.d.ts +24 -0
- package/dist/application/interaction/fallback-resolver.js +48 -0
- package/dist/application/interaction/scroll-to-executor.d.ts +40 -0
- package/dist/application/interaction/scroll-to-executor.js +191 -0
- package/dist/application/locator/locator-resolver.d.ts +20 -0
- package/dist/application/locator/locator-resolver.js +89 -0
- package/dist/application/project/project-describer.d.ts +14 -0
- package/dist/application/project/project-describer.js +8 -0
- package/dist/application/recorder/locator-selector.d.ts +10 -0
- package/dist/application/recorder/locator-selector.js +80 -0
- package/dist/application/recorder/recorder-service.d.ts +41 -0
- package/dist/application/recorder/recorder-service.js +331 -0
- package/dist/application/report/report-writer.d.ts +10 -0
- package/dist/application/report/report-writer.js +39 -0
- package/dist/application/runtime/activity-waiter.d.ts +33 -0
- package/dist/application/runtime/activity-waiter.js +73 -0
- package/dist/application/runtime/launch-failure.d.ts +2 -0
- package/dist/application/runtime/launch-failure.js +20 -0
- package/dist/application/runtime/process-waiter.d.ts +27 -0
- package/dist/application/runtime/process-waiter.js +73 -0
- package/dist/application/runtime/step-runner.d.ts +50 -0
- package/dist/application/runtime/step-runner.js +429 -0
- package/dist/application/runtime/verify-runtime.d.ts +44 -0
- package/dist/application/runtime/verify-runtime.js +350 -0
- package/dist/application/wait/idle-waiter.d.ts +30 -0
- package/dist/application/wait/idle-waiter.js +90 -0
- package/dist/cli/commands/context.d.ts +3 -0
- package/dist/cli/commands/context.js +94 -0
- package/dist/cli/commands/doctor.d.ts +3 -0
- package/dist/cli/commands/doctor.js +51 -0
- package/dist/cli/commands/generation.d.ts +3 -0
- package/dist/cli/commands/generation.js +518 -0
- package/dist/cli/commands/init.d.ts +3 -0
- package/dist/cli/commands/init.js +104 -0
- package/dist/cli/commands/project.d.ts +3 -0
- package/dist/cli/commands/project.js +58 -0
- package/dist/cli/commands/record.d.ts +3 -0
- package/dist/cli/commands/record.js +88 -0
- package/dist/cli/commands/verify.d.ts +3 -0
- package/dist/cli/commands/verify.js +106 -0
- package/dist/cli/dependencies.d.ts +66 -0
- package/dist/cli/dependencies.js +204 -0
- package/dist/cli/main.d.ts +8 -0
- package/dist/cli/main.js +73 -0
- package/dist/cli/output.d.ts +16 -0
- package/dist/cli/output.js +22 -0
- package/dist/cli/program.d.ts +3 -0
- package/dist/cli/program.js +43 -0
- package/dist/domain/activity.d.ts +7 -0
- package/dist/domain/activity.js +65 -0
- package/dist/domain/app-process.d.ts +7 -0
- package/dist/domain/app-process.js +10 -0
- package/dist/domain/config.d.ts +15 -0
- package/dist/domain/config.js +17 -0
- package/dist/domain/failure.d.ts +4 -0
- package/dist/domain/failure.js +43 -0
- package/dist/domain/generation.d.ts +912 -0
- package/dist/domain/generation.js +342 -0
- package/dist/domain/init.d.ts +42 -0
- package/dist/domain/init.js +114 -0
- package/dist/domain/journey.d.ts +673 -0
- package/dist/domain/journey.js +109 -0
- package/dist/domain/layout.d.ts +35 -0
- package/dist/domain/layout.js +33 -0
- package/dist/domain/locator.d.ts +8 -0
- package/dist/domain/locator.js +5 -0
- package/dist/domain/project-context.d.ts +101 -0
- package/dist/domain/project-context.js +64 -0
- package/dist/domain/proposed-step.d.ts +341 -0
- package/dist/domain/proposed-step.js +87 -0
- package/dist/domain/report.d.ts +349 -0
- package/dist/domain/report.js +135 -0
- package/dist/domain/runtime-snapshot.d.ts +16 -0
- package/dist/domain/runtime-snapshot.js +46 -0
- package/dist/ports/adb.d.ts +42 -0
- package/dist/ports/adb.js +1 -0
- package/dist/ports/android-cli.d.ts +21 -0
- package/dist/ports/android-cli.js +1 -0
- package/dist/ports/artifact-store.d.ts +12 -0
- package/dist/ports/artifact-store.js +1 -0
- package/dist/ports/clock.d.ts +4 -0
- package/dist/ports/clock.js +1 -0
- package/dist/ports/generation-meta-writer.d.ts +10 -0
- package/dist/ports/generation-meta-writer.js +1 -0
- package/dist/ports/generation-prompt.d.ts +19 -0
- package/dist/ports/generation-prompt.js +6 -0
- package/dist/ports/generation-session-store.d.ts +32 -0
- package/dist/ports/generation-session-store.js +26 -0
- package/dist/ports/init-prompt.d.ts +8 -0
- package/dist/ports/init-prompt.js +6 -0
- package/dist/ports/journey-writer.d.ts +10 -0
- package/dist/ports/journey-writer.js +1 -0
- package/dist/ports/process-runner.d.ts +32 -0
- package/dist/ports/process-runner.js +1 -0
- package/dist/ports/project-bound-file.d.ts +8 -0
- package/dist/ports/project-bound-file.js +1 -0
- package/dist/ports/project-file-inspector.d.ts +33 -0
- package/dist/ports/project-file-inspector.js +1 -0
- package/dist/ports/recorder-prompt.d.ts +29 -0
- package/dist/ports/recorder-prompt.js +1 -0
- package/dist/ports/skill-installer.d.ts +8 -0
- package/dist/ports/skill-installer.js +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { basename, extname } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { normalizeActivity } from "../../domain/activity.js";
|
|
6
|
+
import { GenerationMetaSchema, GenerationReportSchema, GenerationSessionSchema } from "../../domain/generation.js";
|
|
7
|
+
import { JourneySchema } from "../../domain/journey.js";
|
|
8
|
+
import { ProjectContextSchema, ProjectRelativePathSchema } from "../../domain/project-context.js";
|
|
9
|
+
import { hashJourney, TapHoundReportSchema } from "../../domain/report.js";
|
|
10
|
+
import { TapHoundConfigSchema } from "../../domain/config.js";
|
|
11
|
+
import { GenerationSessionStoreError } from "../../ports/generation-session-store.js";
|
|
12
|
+
import { GENERATION_BUNDLE_PATHS } from "./generation-publisher.js";
|
|
13
|
+
import { hashGenerationBinding } from "./generation-starter.js";
|
|
14
|
+
const Sha256Schema = z.string().regex(/^[a-f\d]{64}$/);
|
|
15
|
+
const ProjectDescriptionSchema = z.strictObject({
|
|
16
|
+
projectRoot: z.string().min(1),
|
|
17
|
+
packageName: z.string().min(1),
|
|
18
|
+
launchActivity: z.string().min(1)
|
|
19
|
+
});
|
|
20
|
+
const VerificationReceiptSchema = z.strictObject({
|
|
21
|
+
version: z.literal(1),
|
|
22
|
+
generationId: z.string().min(1),
|
|
23
|
+
attemptId: z.string().min(1),
|
|
24
|
+
journey: z.strictObject({
|
|
25
|
+
name: z.string().min(1),
|
|
26
|
+
sha256: Sha256Schema
|
|
27
|
+
}),
|
|
28
|
+
project: z.strictObject({
|
|
29
|
+
root: z.string().min(1),
|
|
30
|
+
packageName: z.string().min(1),
|
|
31
|
+
launchActivity: z.string().min(1)
|
|
32
|
+
}),
|
|
33
|
+
deviceSerial: z.string().min(1),
|
|
34
|
+
bindings: z.strictObject({
|
|
35
|
+
projectHash: Sha256Schema,
|
|
36
|
+
configHash: Sha256Schema,
|
|
37
|
+
contextHash: Sha256Schema,
|
|
38
|
+
snapshotHash: Sha256Schema.nullable()
|
|
39
|
+
}),
|
|
40
|
+
tools: z.record(z.string(), z.string()),
|
|
41
|
+
report: z.strictObject({
|
|
42
|
+
path: z.literal(GENERATION_BUNDLE_PATHS.verificationReport),
|
|
43
|
+
sha256: Sha256Schema,
|
|
44
|
+
runId: z.string().min(1)
|
|
45
|
+
})
|
|
46
|
+
});
|
|
47
|
+
export const GenerationOutputPathSchema = ProjectRelativePathSchema.refine((path) => path.split("/").every((segment) => segment.length > 0 && segment !== "."), "Generation output path must be normalized").refine((path) => (path !== ".taphound/generations"
|
|
48
|
+
&& !path.startsWith(".taphound/generations/")), "Generation output cannot overlap the authoritative bundle");
|
|
49
|
+
export class GenerationFinalizationError extends Error {
|
|
50
|
+
code;
|
|
51
|
+
stage;
|
|
52
|
+
recoverable;
|
|
53
|
+
name = "GenerationFinalizationError";
|
|
54
|
+
constructor(code, stage, recoverable, message, options) {
|
|
55
|
+
super(message, options);
|
|
56
|
+
this.code = code;
|
|
57
|
+
this.stage = stage;
|
|
58
|
+
this.recoverable = recoverable;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function serializeJson(value) {
|
|
62
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
63
|
+
}
|
|
64
|
+
function sha256(value) {
|
|
65
|
+
return createHash("sha256").update(value).digest("hex");
|
|
66
|
+
}
|
|
67
|
+
function commandFailed(result) {
|
|
68
|
+
return result.exitCode !== 0
|
|
69
|
+
|| result.timedOut
|
|
70
|
+
|| result.cancelled
|
|
71
|
+
|| result.spawnError !== undefined;
|
|
72
|
+
}
|
|
73
|
+
function derivedJourneyName(outputPath) {
|
|
74
|
+
const file = basename(outputPath);
|
|
75
|
+
const extension = extname(file);
|
|
76
|
+
const stem = extension.length === 0 ? file : file.slice(0, -extension.length);
|
|
77
|
+
return stem.trim() || "Generated Journey";
|
|
78
|
+
}
|
|
79
|
+
function sameSession(left, right) {
|
|
80
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
81
|
+
}
|
|
82
|
+
function canonicalTools(input) {
|
|
83
|
+
return Object.fromEntries(Object.entries(z.record(z.string(), z.string()).parse(input))
|
|
84
|
+
.sort(([left], [right]) => left.localeCompare(right)));
|
|
85
|
+
}
|
|
86
|
+
function sameJson(left, right) {
|
|
87
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
88
|
+
}
|
|
89
|
+
function locatorMatchIsEligible(matchedBy, step) {
|
|
90
|
+
if (step.action !== "click"
|
|
91
|
+
&& step.action !== "longClick"
|
|
92
|
+
&& step.action !== "swipe") {
|
|
93
|
+
return matchedBy === undefined;
|
|
94
|
+
}
|
|
95
|
+
return matchedBy !== undefined && step.locator[matchedBy] !== undefined;
|
|
96
|
+
}
|
|
97
|
+
function failure(code, stage, message, recoverable = false, cause) {
|
|
98
|
+
return new GenerationFinalizationError(code, stage, recoverable, message, cause === undefined ? undefined : { cause });
|
|
99
|
+
}
|
|
100
|
+
export class GenerationFinalizer {
|
|
101
|
+
dependencies;
|
|
102
|
+
constructor(dependencies) {
|
|
103
|
+
this.dependencies = dependencies;
|
|
104
|
+
}
|
|
105
|
+
finalize = async (input) => {
|
|
106
|
+
const config = TapHoundConfigSchema.parse(input.config);
|
|
107
|
+
const context = ProjectContextSchema.parse(input.context);
|
|
108
|
+
const project = ProjectDescriptionSchema.parse(input.project);
|
|
109
|
+
const canonicalProjectRoot = await realpath(input.projectRoot);
|
|
110
|
+
const outputPath = GenerationOutputPathSchema.parse(input.outputPath);
|
|
111
|
+
const name = input.name === undefined
|
|
112
|
+
? derivedJourneyName(outputPath)
|
|
113
|
+
: z.string().trim().min(1).parse(input.name);
|
|
114
|
+
let session = GenerationSessionSchema.parse(await this.dependencies.store.read(input.generationId));
|
|
115
|
+
this.assertBindings(session, input, config, context, project);
|
|
116
|
+
const journey = JourneySchema.parse({
|
|
117
|
+
version: 1,
|
|
118
|
+
name,
|
|
119
|
+
steps: session.candidateSteps
|
|
120
|
+
});
|
|
121
|
+
let replayed = false;
|
|
122
|
+
let verificationReport;
|
|
123
|
+
if (session.verification.status === "notRun") {
|
|
124
|
+
const begun = await this.beginVerification(session);
|
|
125
|
+
session = begun.session;
|
|
126
|
+
if (!begun.owned
|
|
127
|
+
&& session.verification.status === "failed") {
|
|
128
|
+
throw failure("VERIFICATION_FAILED", "verification", session.verification.failure.message);
|
|
129
|
+
}
|
|
130
|
+
const expected = this.expectedVerification(session, journey, config, canonicalProjectRoot, input.toolVersions);
|
|
131
|
+
if (!begun.owned) {
|
|
132
|
+
if (session.verification.status === "passed") {
|
|
133
|
+
verificationReport = await this.readVerifiedReport(session, expected);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
let reconciled;
|
|
137
|
+
try {
|
|
138
|
+
reconciled = session.verification.status === "running"
|
|
139
|
+
? await this.reconcilePassedVerification(session, expected, () => this.revalidate(input, config, context, project, session))
|
|
140
|
+
: undefined;
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
await this.persistFailedVerification(session, error);
|
|
144
|
+
throw failure("VERIFICATION_FAILED", "verification", error instanceof Error
|
|
145
|
+
? error.message
|
|
146
|
+
: "Immutable verification receipt is invalid", false, error);
|
|
147
|
+
}
|
|
148
|
+
if (reconciled === undefined) {
|
|
149
|
+
throw failure("FINALIZATION_IN_PROGRESS", "verification", "Another finalizer owns verification", true);
|
|
150
|
+
}
|
|
151
|
+
session = reconciled.session;
|
|
152
|
+
verificationReport = reconciled.report;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (begun.owned) {
|
|
156
|
+
try {
|
|
157
|
+
await this.revalidate(input, config, context, project, session);
|
|
158
|
+
if (input.signal?.aborted === true) {
|
|
159
|
+
throw failure("VERIFICATION_FAILED", "verification", "Generation finalization was cancelled");
|
|
160
|
+
}
|
|
161
|
+
const stopped = await this.dependencies.adb.forceStop({
|
|
162
|
+
packageName: session.target.packageName,
|
|
163
|
+
deviceSerial: session.target.deviceSerial,
|
|
164
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
165
|
+
timeoutMs: config.idle.timeoutMs
|
|
166
|
+
});
|
|
167
|
+
if (commandFailed(stopped)) {
|
|
168
|
+
throw failure("VERIFICATION_FAILED", "verification", stopped.stderr.trim()
|
|
169
|
+
|| stopped.spawnError
|
|
170
|
+
|| "Unable to reset the generated Journey process");
|
|
171
|
+
}
|
|
172
|
+
const result = await this.dependencies.verifyRuntime.verify({
|
|
173
|
+
config,
|
|
174
|
+
journey,
|
|
175
|
+
projectRoot: canonicalProjectRoot,
|
|
176
|
+
deviceSerial: input.deviceSerial,
|
|
177
|
+
toolVersions: input.toolVersions,
|
|
178
|
+
requireFocusedInput: true,
|
|
179
|
+
generatedReplayPolicy: true,
|
|
180
|
+
...(input.signal === undefined ? {} : { signal: input.signal })
|
|
181
|
+
});
|
|
182
|
+
replayed = true;
|
|
183
|
+
await this.revalidate(input, config, context, project, session);
|
|
184
|
+
this.assertEligible(result, journey, expected);
|
|
185
|
+
verificationReport = TapHoundReportSchema.parse(result.report);
|
|
186
|
+
session = await this.persistPassedVerification(session, verificationReport, expected);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (session.verification.status === "running"
|
|
190
|
+
&& !(error instanceof GenerationFinalizationError
|
|
191
|
+
&& error.code === "FINALIZATION_IN_PROGRESS")) {
|
|
192
|
+
await this.persistFailedVerification(session, error);
|
|
193
|
+
}
|
|
194
|
+
throw error instanceof GenerationFinalizationError
|
|
195
|
+
? error
|
|
196
|
+
: failure("VERIFICATION_FAILED", "verification", error instanceof Error
|
|
197
|
+
? error.message
|
|
198
|
+
: "Generation verification failed", false, error);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else if (session.verification.status === "running") {
|
|
203
|
+
const expected = this.expectedVerification(session, journey, config, canonicalProjectRoot, input.toolVersions);
|
|
204
|
+
let reconciled;
|
|
205
|
+
try {
|
|
206
|
+
reconciled = await this.reconcilePassedVerification(session, expected, () => this.revalidate(input, config, context, project, session));
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
await this.persistFailedVerification(session, error);
|
|
210
|
+
throw failure("VERIFICATION_FAILED", "verification", error instanceof Error
|
|
211
|
+
? error.message
|
|
212
|
+
: "Immutable verification receipt is invalid", false, error);
|
|
213
|
+
}
|
|
214
|
+
if (reconciled === undefined) {
|
|
215
|
+
throw failure("FINALIZATION_IN_PROGRESS", "verification", "Generation verification is already running or cannot be safely reconciled", true);
|
|
216
|
+
}
|
|
217
|
+
session = reconciled.session;
|
|
218
|
+
verificationReport = reconciled.report;
|
|
219
|
+
}
|
|
220
|
+
else if (session.verification.status === "failed") {
|
|
221
|
+
throw failure("VERIFICATION_FAILED", "verification", session.verification.failure.message);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
verificationReport = await this.readVerifiedReport(session, this.expectedVerification(session, journey, config, canonicalProjectRoot, input.toolVersions));
|
|
225
|
+
}
|
|
226
|
+
if (verificationReport === undefined) {
|
|
227
|
+
throw failure("RECOVERY_REQUIRED", "verification", "Verification completed without durable report evidence");
|
|
228
|
+
}
|
|
229
|
+
this.assertReport(verificationReport, journey, this.expectedVerification(session, journey, config, canonicalProjectRoot, input.toolVersions), "RECOVERY_REQUIRED");
|
|
230
|
+
this.assertBindings(session, input, config, context, project);
|
|
231
|
+
const meta = this.buildMeta(session, outputPath, verificationReport);
|
|
232
|
+
const generationReport = GenerationReportSchema.parse({
|
|
233
|
+
version: 1,
|
|
234
|
+
generationId: session.id,
|
|
235
|
+
status: "verified",
|
|
236
|
+
steps: session.candidateSources.map((source, index) => ({
|
|
237
|
+
index,
|
|
238
|
+
source
|
|
239
|
+
}))
|
|
240
|
+
});
|
|
241
|
+
if (session.publication.status === "notRun") {
|
|
242
|
+
try {
|
|
243
|
+
await this.dependencies.publisher.stage({
|
|
244
|
+
generationId: session.id,
|
|
245
|
+
journey,
|
|
246
|
+
report: generationReport,
|
|
247
|
+
verificationReport,
|
|
248
|
+
meta
|
|
249
|
+
});
|
|
250
|
+
const publishable = GenerationSessionSchema.parse({
|
|
251
|
+
...session,
|
|
252
|
+
revision: session.revision + 1,
|
|
253
|
+
publication: {
|
|
254
|
+
status: "published",
|
|
255
|
+
journeyPath: outputPath
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
try {
|
|
259
|
+
await this.dependencies.store.markBundlePublishable(session.id, session.revision, publishable);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
const latest = GenerationSessionSchema.parse(await this.dependencies.store.read(session.id));
|
|
263
|
+
if (!sameSession(latest, publishable)) {
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
session = publishable;
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
throw failure("PUBLICATION_FAILED", "publication", error instanceof Error ? error.message : "Bundle staging failed", true, error);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else if (session.publication.status !== "published"
|
|
274
|
+
|| session.publication.journeyPath !== outputPath) {
|
|
275
|
+
throw failure("PUBLICATION_FAILED", "publication", "Published generation output path does not match the requested path");
|
|
276
|
+
}
|
|
277
|
+
let bundlePath;
|
|
278
|
+
try {
|
|
279
|
+
bundlePath = await this.dependencies.publisher.publish(session.id);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
try {
|
|
283
|
+
bundlePath = await this.dependencies.publisher.publish(session.id);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
throw failure("PUBLICATION_FAILED", "publication", "Authoritative generation bundle publication is ambiguous", true, error);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
const exported = await this.dependencies.publisher.export({
|
|
291
|
+
generationId: session.id,
|
|
292
|
+
projectRoot: input.projectRoot,
|
|
293
|
+
journeyPath: outputPath,
|
|
294
|
+
journey,
|
|
295
|
+
meta
|
|
296
|
+
});
|
|
297
|
+
return {
|
|
298
|
+
status: "verified",
|
|
299
|
+
journey,
|
|
300
|
+
meta,
|
|
301
|
+
bundlePath,
|
|
302
|
+
journeyPath: exported.journeyPath,
|
|
303
|
+
metaPath: exported.metaPath,
|
|
304
|
+
replayed
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
throw failure("EXPORT_FAILED", "export", "Authoritative bundle was published but Journey export failed", true, error);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
assertBindings(session, input, config, context, project) {
|
|
312
|
+
if (input.projectRoot !== project.projectRoot
|
|
313
|
+
|| input.deviceSerial !== session.target.deviceSerial
|
|
314
|
+
|| project.packageName !== session.target.packageName
|
|
315
|
+
|| session.target.packageName !== config.run.packageName
|
|
316
|
+
|| normalizeActivity(project.packageName, project.launchActivity) !== normalizeActivity(config.run.packageName, config.run.activity)
|
|
317
|
+
|| session.inFlight !== null
|
|
318
|
+
|| session.pendingConfirmation !== null
|
|
319
|
+
|| session.state !== "active"
|
|
320
|
+
|| session.bindings.projectHash !== hashGenerationBinding(project)
|
|
321
|
+
|| session.bindings.configHash !== hashGenerationBinding(config)
|
|
322
|
+
|| session.bindings.contextHash !== hashGenerationBinding(context)
|
|
323
|
+
|| JSON.stringify(session.target.interactionPolicy)
|
|
324
|
+
!== JSON.stringify(context.interactionPolicy)) {
|
|
325
|
+
throw failure("CONTEXT_STALE", "precondition", "Generation identity, context, configuration, or process-only binding changed");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async revalidate(input, config, context, project, session) {
|
|
329
|
+
this.assertBindings(session, input, config, context, project);
|
|
330
|
+
const result = await this.dependencies.contextValidator.validate({
|
|
331
|
+
context,
|
|
332
|
+
projectRoot: input.projectRoot,
|
|
333
|
+
config
|
|
334
|
+
});
|
|
335
|
+
if (result.status !== "valid") {
|
|
336
|
+
throw failure(result.status === "stale" ? "CONTEXT_STALE" : "CONTEXT_INVALID", "precondition", result.reason.message);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async beginVerification(session) {
|
|
340
|
+
const attemptId = this.dependencies.generateAttemptId();
|
|
341
|
+
const expected = GenerationSessionSchema.parse({
|
|
342
|
+
...session,
|
|
343
|
+
revision: session.revision + 1,
|
|
344
|
+
verification: { status: "running", attemptId }
|
|
345
|
+
});
|
|
346
|
+
try {
|
|
347
|
+
return {
|
|
348
|
+
session: await this.dependencies.store.beginVerification(session.id, session.revision, attemptId),
|
|
349
|
+
owned: true
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
const latest = GenerationSessionSchema.parse(await this.dependencies.store.read(session.id));
|
|
354
|
+
if (sameSession(latest, expected)) {
|
|
355
|
+
return { session: latest, owned: true };
|
|
356
|
+
}
|
|
357
|
+
if (latest.verification.status !== "notRun") {
|
|
358
|
+
return { session: latest, owned: false };
|
|
359
|
+
}
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
expectedVerification(session, journey, config, canonicalProjectRoot, tools) {
|
|
364
|
+
if (session.verification.status !== "running"
|
|
365
|
+
&& session.verification.status !== "passed") {
|
|
366
|
+
throw failure("RECOVERY_REQUIRED", "verification", "Generation verification attempt identity is unavailable");
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
generationId: session.id,
|
|
370
|
+
attemptId: session.verification.attemptId,
|
|
371
|
+
journey: {
|
|
372
|
+
name: journey.name,
|
|
373
|
+
sha256: hashJourney(journey)
|
|
374
|
+
},
|
|
375
|
+
project: {
|
|
376
|
+
root: canonicalProjectRoot,
|
|
377
|
+
packageName: session.target.packageName,
|
|
378
|
+
launchActivity: normalizeActivity(session.target.packageName, config.run.activity)
|
|
379
|
+
},
|
|
380
|
+
deviceSerial: session.target.deviceSerial,
|
|
381
|
+
bindings: { ...session.bindings },
|
|
382
|
+
tools: canonicalTools(tools)
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
assertEligible(result, journey, expected) {
|
|
386
|
+
if (result.status !== "passed"
|
|
387
|
+
|| result.exitCode !== 0) {
|
|
388
|
+
throw failure("VERIFICATION_FAILED", "verification", "Generated replay did not pass cleanly");
|
|
389
|
+
}
|
|
390
|
+
this.assertReport(result.report, journey, expected, "VERIFICATION_FAILED");
|
|
391
|
+
}
|
|
392
|
+
assertReport(report, journey, expected, code) {
|
|
393
|
+
if (report.status !== "passed"
|
|
394
|
+
|| report.fallbackUsed
|
|
395
|
+
|| report.primaryFailure !== undefined
|
|
396
|
+
|| report.secondaryErrors.length !== 0
|
|
397
|
+
|| Object.values(report.layers).some((status) => status !== "passed")
|
|
398
|
+
|| report.journey.name !== journey.name
|
|
399
|
+
|| report.journey.sha256 !== hashJourney(journey)
|
|
400
|
+
|| report.project.root !== expected.project.root
|
|
401
|
+
|| report.project.packageName !== expected.project.packageName
|
|
402
|
+
|| report.project.launchActivity !== expected.project.launchActivity
|
|
403
|
+
|| report.environment.deviceSerial !== expected.deviceSerial
|
|
404
|
+
|| !sameJson(canonicalTools(report.environment.tools), expected.tools)
|
|
405
|
+
|| report.steps.length !== journey.steps.length) {
|
|
406
|
+
throw failure(code, "verification", "Verification report does not match the exact generated replay");
|
|
407
|
+
}
|
|
408
|
+
for (const [index, candidate] of journey.steps.entries()) {
|
|
409
|
+
const step = report.steps[index];
|
|
410
|
+
const expectationType = candidate.expect?.type;
|
|
411
|
+
const hasLocator = candidate.action === "click"
|
|
412
|
+
|| candidate.action === "longClick"
|
|
413
|
+
|| candidate.action === "swipe";
|
|
414
|
+
const isScrollTo = candidate.action === "scrollTo";
|
|
415
|
+
if (step === undefined
|
|
416
|
+
|| step.index !== index
|
|
417
|
+
|| step.action !== candidate.action
|
|
418
|
+
|| step.status !== "passed"
|
|
419
|
+
|| step.activity === undefined
|
|
420
|
+
|| step.activity.before.status !== "passed"
|
|
421
|
+
|| step.activity.before.expected !== candidate.activity.before
|
|
422
|
+
|| step.activity.before.actual !== candidate.activity.before
|
|
423
|
+
|| step.activity.after.status !== "passed"
|
|
424
|
+
|| step.activity.after.expected !== candidate.activity.after
|
|
425
|
+
|| step.activity.after.actual !== candidate.activity.after
|
|
426
|
+
|| (expectationType === undefined
|
|
427
|
+
? step.expectation !== undefined
|
|
428
|
+
: (step.expectation === undefined
|
|
429
|
+
|| step.expectation.type !== expectationType
|
|
430
|
+
|| step.expectation.status !== "passed"))
|
|
431
|
+
|| (!hasLocator
|
|
432
|
+
? step.locator !== undefined
|
|
433
|
+
: (step.locator === undefined
|
|
434
|
+
|| step.locator.status !== "found"
|
|
435
|
+
|| step.locator.fallbackUsed
|
|
436
|
+
|| !locatorMatchIsEligible(step.locator.matchedBy, candidate)))
|
|
437
|
+
|| step.locator?.fallbackUsed === true
|
|
438
|
+
|| (isScrollTo
|
|
439
|
+
? (step.scroll === undefined
|
|
440
|
+
|| step.scroll.maxSwipes !== candidate.maxSwipes
|
|
441
|
+
|| step.scroll.swipesUsed > candidate.maxSwipes
|
|
442
|
+
|| step.idle !== undefined)
|
|
443
|
+
: (step.scroll !== undefined
|
|
444
|
+
|| step.idle === undefined
|
|
445
|
+
|| step.idle.status !== "stable"))) {
|
|
446
|
+
throw failure(code, "verification", `Verification report step ${String(index)} does not match the candidate`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
receiptMatches(receipt, expected) {
|
|
451
|
+
return receipt.generationId === expected.generationId
|
|
452
|
+
&& receipt.attemptId === expected.attemptId
|
|
453
|
+
&& sameJson(receipt.journey, expected.journey)
|
|
454
|
+
&& sameJson(receipt.project, expected.project)
|
|
455
|
+
&& receipt.deviceSerial === expected.deviceSerial
|
|
456
|
+
&& sameJson(receipt.bindings, expected.bindings)
|
|
457
|
+
&& sameJson(canonicalTools(receipt.tools), expected.tools);
|
|
458
|
+
}
|
|
459
|
+
async persistPassedVerification(running, report, expected) {
|
|
460
|
+
if (running.verification.status !== "running") {
|
|
461
|
+
throw failure("RECOVERY_REQUIRED", "verification", "Verification cannot pass without exact running-attempt ownership");
|
|
462
|
+
}
|
|
463
|
+
const reportBytes = serializeJson(report);
|
|
464
|
+
await this.ensureVerificationEvidence(running.id, GENERATION_BUNDLE_PATHS.verificationReport, reportBytes);
|
|
465
|
+
const receipt = VerificationReceiptSchema.parse({
|
|
466
|
+
version: 1,
|
|
467
|
+
...expected,
|
|
468
|
+
report: {
|
|
469
|
+
path: GENERATION_BUNDLE_PATHS.verificationReport,
|
|
470
|
+
sha256: sha256(reportBytes),
|
|
471
|
+
runId: report.runId
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
await this.ensureVerificationEvidence(running.id, GENERATION_BUNDLE_PATHS.verificationReceipt, serializeJson(receipt));
|
|
475
|
+
const passed = GenerationSessionSchema.parse({
|
|
476
|
+
...running,
|
|
477
|
+
revision: running.revision + 1,
|
|
478
|
+
verification: {
|
|
479
|
+
status: "passed",
|
|
480
|
+
attemptId: running.verification.attemptId,
|
|
481
|
+
reportPath: receipt.report.path,
|
|
482
|
+
reportSha256: receipt.report.sha256,
|
|
483
|
+
runId: receipt.report.runId
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
try {
|
|
487
|
+
await this.dependencies.store.completeVerification(running.id, running.revision, passed);
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
const latest = GenerationSessionSchema.parse(await this.dependencies.store.read(running.id));
|
|
491
|
+
if (!sameSession(latest, passed)) {
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return passed;
|
|
496
|
+
}
|
|
497
|
+
async reconcilePassedVerification(running, expected, revalidateCurrent) {
|
|
498
|
+
if (running.verification.status !== "running") {
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
let receiptBytes;
|
|
502
|
+
try {
|
|
503
|
+
receiptBytes = await this.dependencies.store.readEvidence(running.id, GENERATION_BUNDLE_PATHS.verificationReceipt);
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
if (error instanceof GenerationSessionStoreError
|
|
507
|
+
&& error.code === "EVIDENCE_NOT_FOUND") {
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
throw failure("VERIFICATION_FAILED", "verification", "Immutable verification receipt cannot be safely read", false, error);
|
|
511
|
+
}
|
|
512
|
+
let receipt;
|
|
513
|
+
let reportBytes;
|
|
514
|
+
try {
|
|
515
|
+
receipt = VerificationReceiptSchema.parse(JSON.parse(receiptBytes.toString("utf8")));
|
|
516
|
+
reportBytes = await this.dependencies.store.readEvidence(running.id, receipt.report.path);
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
throw failure("VERIFICATION_FAILED", "verification", "Immutable verification receipt or report is invalid", false, error);
|
|
520
|
+
}
|
|
521
|
+
if (!this.receiptMatches(receipt, expected)
|
|
522
|
+
|| sha256(reportBytes) !== receipt.report.sha256) {
|
|
523
|
+
throw failure("VERIFICATION_FAILED", "verification", "Immutable verification receipt does not match this generation");
|
|
524
|
+
}
|
|
525
|
+
let report;
|
|
526
|
+
try {
|
|
527
|
+
report = TapHoundReportSchema.parse(JSON.parse(reportBytes.toString("utf8")));
|
|
528
|
+
if (report.runId !== receipt.report.runId) {
|
|
529
|
+
throw new Error("Verification Report run ID does not match receipt");
|
|
530
|
+
}
|
|
531
|
+
this.assertReport(report, {
|
|
532
|
+
version: 1,
|
|
533
|
+
name: expected.journey.name,
|
|
534
|
+
steps: running.candidateSteps
|
|
535
|
+
}, expected, "VERIFICATION_FAILED");
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
throw error instanceof GenerationFinalizationError
|
|
539
|
+
? error
|
|
540
|
+
: failure("VERIFICATION_FAILED", "verification", "Immutable verification Report does not match the receipt", false, error);
|
|
541
|
+
}
|
|
542
|
+
await revalidateCurrent();
|
|
543
|
+
return {
|
|
544
|
+
session: await this.persistPassedVerification(running, report, expected),
|
|
545
|
+
report
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
async persistFailedVerification(running, error) {
|
|
549
|
+
const latest = GenerationSessionSchema.parse(await this.dependencies.store.read(running.id));
|
|
550
|
+
if (running.verification.status !== "running"
|
|
551
|
+
|| latest.verification.status !== "running"
|
|
552
|
+
|| latest.revision !== running.revision
|
|
553
|
+
|| latest.verification.attemptId !== running.verification.attemptId) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const failed = GenerationSessionSchema.parse({
|
|
557
|
+
...latest,
|
|
558
|
+
revision: latest.revision + 1,
|
|
559
|
+
verification: {
|
|
560
|
+
status: "failed",
|
|
561
|
+
failure: {
|
|
562
|
+
code: error instanceof GenerationFinalizationError
|
|
563
|
+
? error.code
|
|
564
|
+
: "VERIFICATION_FAILED",
|
|
565
|
+
message: error instanceof Error
|
|
566
|
+
? error.message
|
|
567
|
+
: "Generation verification failed"
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
try {
|
|
572
|
+
await this.dependencies.store.failVerification(latest.id, latest.revision, failed);
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
const reconciled = GenerationSessionSchema.parse(await this.dependencies.store.read(latest.id));
|
|
576
|
+
if (!sameSession(reconciled, failed)) {
|
|
577
|
+
throw failure("RECOVERY_REQUIRED", "verification", "Unable to durably record verification failure");
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async readVerifiedReport(session, expected) {
|
|
582
|
+
if (session.verification.status !== "passed") {
|
|
583
|
+
throw failure("RECOVERY_REQUIRED", "verification", "Passed verification state lacks immutable report evidence");
|
|
584
|
+
}
|
|
585
|
+
let receipt;
|
|
586
|
+
let bytes;
|
|
587
|
+
try {
|
|
588
|
+
receipt = VerificationReceiptSchema.parse(JSON.parse((await this.dependencies.store.readEvidence(session.id, GENERATION_BUNDLE_PATHS.verificationReceipt)).toString("utf8")));
|
|
589
|
+
bytes = await this.dependencies.store.readEvidence(session.id, receipt.report.path);
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
throw failure("RECOVERY_REQUIRED", "verification", "Passed verification state lacks a valid immutable receipt", false, error);
|
|
593
|
+
}
|
|
594
|
+
if (!this.receiptMatches(receipt, expected)
|
|
595
|
+
|| receipt.report.path !== session.verification.reportPath
|
|
596
|
+
|| receipt.report.sha256 !== session.verification.reportSha256
|
|
597
|
+
|| receipt.report.runId !== session.verification.runId
|
|
598
|
+
|| sha256(bytes) !== receipt.report.sha256) {
|
|
599
|
+
throw failure("RECOVERY_REQUIRED", "verification", "Immutable verification receipt does not match durable state");
|
|
600
|
+
}
|
|
601
|
+
const report = TapHoundReportSchema.parse(JSON.parse(bytes.toString("utf8")));
|
|
602
|
+
if (report.runId !== receipt.report.runId) {
|
|
603
|
+
throw failure("RECOVERY_REQUIRED", "verification", "Immutable verification report is not a clean passed replay");
|
|
604
|
+
}
|
|
605
|
+
this.assertReport(report, {
|
|
606
|
+
version: 1,
|
|
607
|
+
name: expected.journey.name,
|
|
608
|
+
steps: session.candidateSteps
|
|
609
|
+
}, expected, "RECOVERY_REQUIRED");
|
|
610
|
+
return report;
|
|
611
|
+
}
|
|
612
|
+
buildMeta(session, outputPath, report) {
|
|
613
|
+
if (session.verification.status !== "passed") {
|
|
614
|
+
throw failure("RECOVERY_REQUIRED", "verification", "Generation verification is not durably passed");
|
|
615
|
+
}
|
|
616
|
+
return GenerationMetaSchema.parse({
|
|
617
|
+
version: 1,
|
|
618
|
+
status: "verified",
|
|
619
|
+
generationId: session.id,
|
|
620
|
+
journeyPath: outputPath,
|
|
621
|
+
bindings: {
|
|
622
|
+
projectHash: session.bindings.projectHash,
|
|
623
|
+
configHash: session.bindings.configHash,
|
|
624
|
+
contextHash: session.bindings.contextHash
|
|
625
|
+
},
|
|
626
|
+
verification: {
|
|
627
|
+
reportPath: GENERATION_BUNDLE_PATHS.verificationReport,
|
|
628
|
+
reportSha256: session.verification.reportSha256,
|
|
629
|
+
runId: report.runId,
|
|
630
|
+
runs: 1
|
|
631
|
+
},
|
|
632
|
+
manualOverrideStepIndexes: session.candidateSources.flatMap((source, index) => source === "manualOverride" ? [index] : [])
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
async ensureVerificationEvidence(generationId, path, expected) {
|
|
636
|
+
try {
|
|
637
|
+
await this.dependencies.store.writeTextEvidence(generationId, path, expected);
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
const existing = await this.dependencies.store.readEvidence(generationId, path);
|
|
641
|
+
if (!existing.equals(Buffer.from(expected))) {
|
|
642
|
+
throw failure("RECOVERY_REQUIRED", "verification", `Immutable verification evidence conflicts at ${path}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|